Compare commits

...
6 Commits
Author SHA1 Message Date
Menghuan1918 bf54a60e37 chore: v0.6.0 — README 更新双面板说明并升版本 2026-08-12 16:54:36 +08:00
Menghuan1918 c7af7dccdb feat: 侧边栏双面板 — 右上角按钮簇、底部面板(首展自动终端/关闭按钮)、跨面板拖 Tab、拖动性能优化
- 右上角持久按钮簇:横向排列、独立 glyph(底栏/侧拉)、始终钉在视口右上角(侧边栏展开时位于其标签条右端,真实挤占 Tab 空间)
- 底部面板:独立工作台,只挤占中间 Agent 输出区(不覆盖左右侧边栏),右缘分割线、关闭 x、首展自动开终端(二级设置开关,默认开)
- 两面板拐角双向拖动调节尺寸;拖动改为 rAF 节流直写 DOM,消除卡顿;面板尺寸互钳制防丢失
- 跨面板拖 Tab(合并/拆分落点),moveTab/moveTabToEdge 支持跨树移动
- 欢迎卡片改响应式网格;布局/几何全部按会话持久化,旧状态平滑迁移
2026-08-12 16:43:24 +08:00
Menghuan1918 7eccc49f6e revert: 移除构建期 IME 守卫补丁 — 补丁打在产物上不具持久性
taekchef 的 apply-ime-guard.mjs 在每次 build/prepare/bundle 后把守卫
字符串注入 lib/client.js,但补丁对象是构建产物而非源码:
- 裸 tsdown 重建 / 其他构建路径产出无守卫的 bundle(#562 复发根因)
- 守卫逻辑游离于源码之外,难以测试与维护

运行时方案(src/client/ime-guard.ts + tests/ime-guard.spec.ts)已在
源码内实现同等守卫,经 document capture 覆盖全部劫持路径,任何构建
方式产物都自带守卫。合并远端提交后撤掉构建期补丁,保留运行时方案。
2026-08-12 14:41:36 +08:00
Menghuan1918 d1c393a048 Merge branch 'main' of https://github.com/dsh-external/DSH-better-sidebar 2026-08-12 14:41:26 +08:00
taekchef a465bdee38 fix(ime): 持久化 IME 守卫脚本并挂入 build/prepare/bundle 构建路径
- scripts/apply-ime-guard.mjs 纳入版本库(此前 untracked,重 clone 即丢)
- Univer InputNumber 在 document capture 拦截全页 ↑/↓ 且无 isComposing
  守卫,会摧毁中文输入法组合;prepare/bundle 裸 tsdown 重建会产出无守卫
  的 lib/client.js,2026-08-11 因此复发(dsh-external/issues#562)
- 脚本幂等:已注入则跳过;目标模式匹配不到则 exit 1 显式失败

Fixes #562
2026-08-12 03:13:31 +08:00
Menghuan1918 31b78f7941 fix: IME 组合期按键守卫 — document capture 拦截,防内联第三方组件劫持方向键(#562)
Univer InputNumber 的 keydown 对 ArrowUp/Down 直接 preventDefault 且无
isComposing 守卫,中文输入法组合期候选词方向键被吞(#562 回归,同类 #535)。
新增 src/client/ime-guard.ts:document capture 阶段对组合期按键(isComposing
或 keyCode 229)stopPropagation,运行于 React 委托与任何原生监听之前,覆盖
内联依赖的全部劫持路径;apply 时经 ctx.effect 注册(HMR-safe)。

- tests/ime-guard.spec.ts:12 个用例,覆盖纯决策、原生 document/input 监听、
  React 合成事件路径与 disposer 恢复(新增 jsdom devDependency)
- 全量 280 测试通过,typecheck 干净
2026-08-12 01:16:20 +08:00
25 changed files with 1618 additions and 177 deletions
+5 -4
View File
@@ -1,6 +1,6 @@
# dsh-better-sidebar
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
> DeepSeek Harness Web GUI 的 **VSCode 风格侧边栏**插件:文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
@@ -12,9 +12,10 @@
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏、分隔线调比例、宽度拖拽、折叠 + 全屏
- **🔁 会话隔离**:布局/分栏/Tab 按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
## ⌨️ 快捷键
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.5.0",
"version": "0.6.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.5.0",
"version": "0.6.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"private": true,
"type": "module",
@@ -134,6 +134,7 @@
"@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
+324 -2
View File
@@ -168,6 +168,9 @@ importers:
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5)
jsdom:
specifier: ^29.1.1
version: 29.1.1
lightningcss:
specifier: ^1.32.0
version: 1.33.0
@@ -185,7 +188,7 @@ importers:
version: 5.6.2
vitest:
specifier: ^4.1.8
version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3))
version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.1(@types/node@24.13.3))
packages:
@@ -197,10 +200,29 @@ packages:
pdfjs-dist:
optional: true
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@7.1.1':
resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/generational-cache@1.0.1':
resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
@@ -276,6 +298,42 @@ packages:
node-addon-require-builtin:
optional: true
'@csstools/color-helpers@6.1.0':
resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.3.0':
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.1.10':
resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.7':
resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@deepseek-ai/cordis-plugin-include@1.0.5-rc.1':
resolution: {integrity: sha512-fwDm2DvCUawaP7o2xx9kPQ46FU8krYkB51iQxOIXwZFNRCamfGkdYwIEe4ycEnXoEpjm4P/gA9pZovbJJNqnAw==}
peerDependencies:
@@ -779,6 +837,15 @@ packages:
'@deepseek-ai/schemastery@3.18.1-rc.1':
resolution: {integrity: sha512-gilia4mY6S/j6oFYsn4x2Ve9K6QfvzTgqJntkty0wFsKjTYHQ6e2WDvXLMw48t75WRBhpLt+ZpFm3GHmTWkNMw==}
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@flatten-js/interval-tree@1.1.3':
resolution: {integrity: sha512-xhFWUBoHJFF77cJO1D6REjdgJEMRf2Y2Z+eKEPav8evGKcLSnj1ud5pLXQSbGuxF3VSvT1rWhMfVpXEKJLTL+A==}
@@ -2152,6 +2219,9 @@ packages:
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
cac@7.0.0:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
@@ -2241,9 +2311,17 @@ packages:
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2301,6 +2379,10 @@ packages:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-module-lexer@2.3.1:
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
@@ -2367,6 +2449,10 @@ packages:
hookable@6.1.1:
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -2387,6 +2473,9 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -2397,6 +2486,15 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@29.1.1:
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
@@ -2506,6 +2604,10 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2551,6 +2653,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -2691,6 +2796,9 @@ packages:
pako@1.0.2:
resolution: {integrity: sha512-qCaDHl8pk5JSbz5k1Bi5zTZf430DUuV13pmcFEHHan4fW++WIwm2HXUirpzfciBRyFUN+eeZW11bfIM/Y4Zeog==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -2718,6 +2826,10 @@ packages:
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
engines: {node: '>=12.0.0'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
@@ -2795,6 +2907,10 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -2828,6 +2944,10 @@ packages:
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
@@ -2884,6 +3004,9 @@ packages:
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -2902,6 +3025,21 @@ packages:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
tldts-core@7.4.10:
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
tldts@7.4.10:
resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
hasBin: true
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -2963,6 +3101,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unicode-regex@4.2.0:
resolution: {integrity: sha512-fEYz7CCnvHDAdrb8OYAP7qlQCWzXBO5cHXQ3XI+HoZaBpiAwyC6b2nixMGl91yrDYEIRm7NDskgTvnLZ7mqrKQ==}
engines: {node: '>=16'}
@@ -3117,6 +3259,22 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -3151,6 +3309,13 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
@@ -3207,8 +3372,32 @@ snapshots:
echarts: 6.1.0
jszip: 3.10.1
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@asamuzakjp/dom-selector@7.1.1':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
'@asamuzakjp/generational-cache@1.0.1': {}
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/runtime@7.29.7': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
@@ -3376,6 +3565,30 @@ snapshots:
cordis: 4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5)
cosmokit: 1.8.1
'@csstools/color-helpers@6.1.0': {}
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.1.0
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@deepseek-ai/cordis-plugin-include@1.0.5-rc.1(@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1)(@deepseek-ai/cordis@4.0.1-rc.1)':
dependencies:
'@deepseek-ai/cordis': 4.0.1-rc.1(@deepseek-ai/cordis-plugin-include@1.0.5-rc.1)(@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1)
@@ -3961,6 +4174,8 @@ snapshots:
'@deepseek-ai/cosmokit': 1.8.2-rc.1
'@standard-schema/spec': 1.1.0
'@exodus/bytes@1.15.1': {}
'@flatten-js/interval-tree@1.1.3': {}
'@floating-ui/core@1.8.0':
@@ -6121,6 +6336,10 @@ snapshots:
async-lock@1.4.1: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
cac@7.0.0: {}
ccount@2.0.1: {}
@@ -6186,8 +6405,20 @@ snapshots:
crelt@1.0.7: {}
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
csstype@3.2.3: {}
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -6230,6 +6461,8 @@ snapshots:
empathic@2.0.1: {}
entities@8.0.0: {}
es-module-lexer@2.3.1: {}
escalade@3.2.0: {}
@@ -6287,6 +6520,12 @@ snapshots:
hookable@6.1.1: {}
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
transitivePeerDependencies:
- '@noble/hashes'
html-void-elements@3.0.0: {}
immediate@3.0.6: {}
@@ -6299,6 +6538,8 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
is-potential-custom-element-name@1.0.1: {}
isarray@1.0.0: {}
js-tokens@3.0.0: {}
@@ -6307,6 +6548,32 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@29.1.1:
dependencies:
'@asamuzakjp/css-color': 5.1.11
'@asamuzakjp/dom-selector': 7.1.1
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.2
undici: 7.29.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
jszip@3.10.1:
dependencies:
lie: 3.3.0
@@ -6393,6 +6660,8 @@ snapshots:
dependencies:
js-tokens: 3.0.0
lru-cache@11.5.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -6525,6 +6794,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.27.1: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
@@ -6766,6 +7037,10 @@ snapshots:
pako@1.0.2: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -6802,6 +7077,8 @@ snapshots:
'@types/node': 24.13.3
long: 5.3.2
punycode@2.3.1: {}
quansync@1.0.0: {}
quickselect@3.0.0: {}
@@ -6882,6 +7159,8 @@ snapshots:
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
rolldown-plugin-dts@0.27.14(rolldown@1.2.3)(typescript@5.6.2):
@@ -6924,6 +7203,10 @@ snapshots:
safe-buffer@5.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.23.0:
dependencies:
loose-envify: 1.4.0
@@ -6986,6 +7269,8 @@ snapshots:
style-mod@4.1.3: {}
symbol-tree@3.2.4: {}
tailwind-merge@2.6.0: {}
tinybench@2.9.0: {}
@@ -6999,6 +7284,20 @@ snapshots:
tinyrainbow@3.1.1: {}
tldts-core@7.4.10: {}
tldts@7.4.10:
dependencies:
tldts-core: 7.4.10
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.10
tr46@6.0.0:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
trigram-utils@2.0.1:
@@ -7046,6 +7345,8 @@ snapshots:
undici-types@7.18.2: {}
undici@7.29.0: {}
unicode-regex@4.2.0:
dependencies:
regexp-util: 2.0.3
@@ -7126,7 +7427,7 @@ snapshots:
'@types/node': 24.13.3
fsevents: 2.3.3
vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)):
vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.1(@types/node@24.13.3)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3))
@@ -7150,11 +7451,28 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
jsdom: 29.1.1
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -7182,6 +7500,10 @@ snapshots:
wmf: 1.0.2
word: 0.3.0
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xterm@5.3.0: {}
y18n@5.0.8: {}
+327 -74
View File
@@ -1,25 +1,34 @@
/**
* The sidebar shell: a fixed-position right panel portalled onto
* document.body (the core AppFrame owns the left sidebar / center / details
* columns and has no right-side hole for plugins), plus the edge toggle
* button. The panel width drags from its left edge; the whole layout lives
* in the per-session store, so switching conversations swaps the sidebar.
* The sidebar shell: fixed-position panels portalled onto document.body
* (the core AppFrame owns the left sidebar / center / details columns and
* has no right-side hole for plugins). The right panel hosts the original
* workbench; the bottom panel hosts a second, independent workbench. The
* bottom panel squeezes ONLY the center column (the agent output area): it
* spans from the app shell's own left sidebar to the right panel's left
* edge, so neither sidebar gives up any position (the right panel keeps its
* full height). A persistent two-button cluster at the top-right corner
* toggles each panel; the right panel's width drags from its left edge, the
* bottom panel's height from its top edge, and the shared corner drags both
* at once. The whole layout lives in the per-session store, so switching
* conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the four views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer). Tabs live in one tree only —
* they never cross panels; only the panel sizes drag against each other.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import {
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
@@ -194,6 +203,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
@@ -214,33 +227,151 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (pending === undefined || sessionId !== pending) return
subagentJumpRef.current = undefined
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
// Panel width drag (left edge strip).
// The app shell's own columns: the bottom panel spans ONLY the center
// column ("squeezes the agent output area") — it starts at the app
// sidebar's right edge and ends at the details column's left edge (the
// details column sits between the center and the right panel). Measured
// from the AppFrame's DOM (the plugin already couples to #root in
// layout.css); a missing frame degrades to 0 (full width).
const [appSidebarWidth, setAppSidebarWidth] = useState(0)
const [detailsWidth, setDetailsWidth] = useState(0)
useEffect(() => {
const frame = document.querySelector('#root > div')
const sidebarCol = frame?.children[0] as HTMLElement | undefined
const detailsCol = frame?.children[2] as HTMLElement | undefined
if (sidebarCol === undefined || detailsCol === undefined) return
const measure = (): void => {
setAppSidebarWidth(sidebarCol.getBoundingClientRect().width)
setDetailsWidth(detailsCol.getBoundingClientRect().width)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(sidebarCol)
observer.observe(detailsCol)
return () => { observer.disconnect() }
}, [])
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
* there. "Try" is literal — the terminal's own quota and enable switch
* gate the attempt (a full quota or a disabled terminal type makes it a
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
* nested settings toggle, default on). Only a false→true TRANSITION fires
* (a panel persisted open never counts as an expansion), and the session's
* bottomOpenedOnce flag is set atomically with the first fire so later
* expansions never repeat it.
*/
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => {
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
if (state.bottomOpenedOnce) return
if (store.getPrefs().bottomPanelAutoTerminal === false) return
if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return
// Land the tab in the bottom panel's first pane; the once-flag is set
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.betterSidebar?.openTab({ type: 'terminal' })
}, [state, store, ctx])
// Panel drags: the right panel's width (left edge strip), the bottom
// panel's height (top edge strip), and the shared corner (both at once).
// Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
// variables) instead of round-tripping the store on every pointer move —
// a store reduce re-renders both workbenches (terminals, editors…) per
// move, which is the visible drag lag. The store is committed once on
// pointer up (clamping + persistence).
const panelRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const cornerRef = useRef<HTMLDivElement | null>(null)
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
const [draggingBottom, setDraggingBottom] = useState(false)
const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
const [draggingCorner, setDraggingCorner] = useState(false)
const anyDragging = draggingWidth || draggingBottom || draggingCorner
// Layout push: the app shell gives up the panel's width while it is open
// (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. The margin is capped at the viewport so a stale
// persisted width (e.g. fullscreen on a bigger window) can never crush
// the app shell to zero. Dragging disables the layout transition.
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
// re-clamps on commit; these keep the panels from overshooting mid-drag).
const clampWidth = (width: number): number =>
Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag. */
const applyDrag = (width: number, height: number): void => {
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
bottomRef.current?.style.setProperty('right', `${width + detailsWidth}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
if (cornerRef.current !== null) {
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
}
}
// Drags write at most once per frame: pointer events fire several times
// faster than the display refresh, and each write reflows the app shell
// (the layout push) plus the panels — batching to one write per frame is
// what keeps the drag smooth. The store is still committed once on release.
const dragFrame = useRef<number | null>(null)
const pendingDrag = useRef<{ width: number; height: number } | null>(null)
const scheduleDrag = (width: number, height: number): void => {
pendingDrag.current = { width, height }
if (dragFrame.current !== null) return
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null
const pending = pendingDrag.current
if (pending !== null) {
pendingDrag.current = null
applyDrag(pending.width, pending.height)
}
})
}
/** Flush any pending drag write and stop scheduling (the store commit on
* pointer up applies the final clamped values). */
const stopDragScheduling = (): void => {
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
pendingDrag.current = null
}
// Layout push: the app shell gives up the panel's width/height while the
// panels are open (0 while collapsed), so the conversation and input bar
// are squeezed instead of covered. The margins are capped at the viewport
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
// crush the app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const width = snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
const height = snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width])
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
useEffect(() => {
if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '')
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [draggingWidth])
}, [anyDragging])
// Whether the panel currently fills the viewport (fullscreen expansion).
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
@@ -287,7 +418,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
})
},
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
}), [store, sessionId, cwd])
@@ -318,10 +449,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleRail}>
<div className={css.toggleCluster}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelLeftOutline16 size={16} />
<IconPanelBottomOutline16 />
</button>
</Tooltip>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
@@ -349,7 +485,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => (
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
<TabContent
tab={tab}
sessionId={sessionId}
@@ -359,7 +495,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.panelOpen && active}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
@@ -367,28 +503,47 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return (
<>
{!state.panelOpen && (
<div className={css.toggleRail}>
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)}
{/*
The panel stays mounted while collapsed (hidden off-screen) so the
slide in/out can animate; visibility hides it after the slide settles.
The persistent toggle cluster at the top-right corner: the bottom
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
Always pinned to the viewport corner — inside the right panel's
top-right while it is open, sitting flush in the tab strip whose
right end it really squeezes (the strip reserves its width via CSS),
so the tabs genuinely yield space to it.
*/}
<div className={css.toggleCluster}>
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.panelOpen ? t('collapse') : t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
{/*
The right panel stays mounted while collapsed (hidden off-screen) so
the slide in/out can animate; visibility hides it after the slide
settles. Its bottom edge follows the bottom panel's height (0 while
the bottom panel is closed) — the VSCode-style "sidebar above panel".
*/}
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
data-dragging={anyDragging || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
@@ -401,43 +556,19 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
setDraggingWidth(false)
}}
/>
<div className={css.panelHeader}>
<button
type="button"
className={css.iconButton}
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
store.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
}}
>
<IconFullscreenOutline16 />
</button>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.panelBody}>
<Workbench
state={state}
@@ -453,6 +584,128 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
/>
</div>
</div>
{/*
The bottom panel: a second, independent workbench. It squeezes ONLY
the center column (the agent output area): it starts at the app
shell's own left sidebar and ends at the right panel's left edge —
neither sidebar gives up any position (the right panel keeps its
full height). Its resize strip is the top edge; hidden by sliding
down like the right panel.
*/}
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: appSidebarWidth,
// Ends at the details column's left edge (the details column sits
// between the center and the right panel) plus the right panel's
// own width — the bottom panel squeezes ONLY the center column.
right: detailsWidth + (state.panelOpen ? Math.min(state.width, window.innerWidth) : 0),
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
>
<div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
setDraggingBottom(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startY, startHeight } = bottomDrag.current
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(Math.min(state.width, window.innerWidth), height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
setDraggingBottom(false)
}}
/>
{/*
The bottom panel's own close control at its tab strip's right end
(the strip reserves the width via CSS so the + menu never hides
under it): one tap collapses the panel.
*/}
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.bottomClose}
aria-label={t('collapseBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconCloseFill14 />
</button>
</Tooltip>
<div className={css.panelBody}>
<Workbench
state={state}
tree={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
/>
</div>
</div>
{/*
The shared corner (only while BOTH panels are open): the intersection
of the right panel's left edge and the bottom panel's top edge.
Horizontal drags resize the right panel's width, vertical drags the
bottom panel's height — the two panels drag against each other.
*/}
{state.panelOpen && state.bottomOpen && (
<div
ref={cornerRef}
className={css.cornerHandle}
style={{
left: window.innerWidth - state.width - 6,
top: window.innerHeight - state.bottomHeight - 6,
}}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
setDraggingCorner(false)
}}
/>
)}
</>
)
}
+8 -3
View File
@@ -106,14 +106,19 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch renders
// under this row in the Side card settings page (the Terminal page's
// own related setting; the host gates the toolset on it independently).
// Declarative settings: the model-facing terminal tools switch and the
// bottom-panel first-expansion auto-terminal switch render under this
// row in the Side card settings page (the Terminal page's own related
// settings; the host gates the toolset on the tools one independently).
settings: {
toggles: [{
key: 'agentTerminalTools',
title: () => t('settingsToolsTitle'),
desc: () => t('settingsToolsDesc'),
}, {
key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'),
}],
},
createTab: (state) => {
+27 -3
View File
@@ -1,11 +1,35 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none) and a diff glyph. Per-tab icons live on the tab
* descriptors (`descriptor.icon`), not in a type-keyed switch — the icon
* mapping was registry-ized with the tab types.
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
* the top-right cluster. Per-tab icons live on the tab descriptors
* (`descriptor.icon`), not in a type-keyed switch — the icon mapping was
* registry-ized with the tab types.
*/
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
* along its RIGHT edge, in the app's outline style (1.5px stroke,
* currentColor).
*/
export const IconPanelRightOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
* along its BOTTOM edge, in the app's outline style.
*/
export const IconPanelBottomOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="3.25" y="10" width="9.5" height="2.75" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
* a rounded frame with a prompt chevron and underscore cursor.
+47
View File
@@ -0,0 +1,47 @@
/**
* IME-composition key guard.
*
* While a Chinese/Japanese/Korean input method is composing (the user is
* picking a candidate from the IME window), every pressed key BELONGS to the
* input method: arrows move the candidate highlight, Enter/Space confirm the
* composition, Escape cancels it. Page code must not process those keys —
* a component that does (a number stepper calling preventDefault() on
* ArrowUp/ArrowDown, a submit handler reacting to Enter, ...) silently
* breaks the IME: candidates stop responding, the composition gets torn
* apart, and only bare letters come out.
*
* This guard enforces that rule at the document boundary: a capture-phase
* keydown/keyup listener that stops the event from propagating further
* whenever a composition is in progress. Because it runs in the capture
* phase on `document` — the outermost node — it fires BEFORE React's
* delegated handlers (attached at the root container) and before any native
* target/bubble listener, so an inlined third-party component (e.g. the
* Univer office UI bundled into this plugin) can never intercept
* composition keys. The browser's native IME processing is untouched:
* stopPropagation only silences page JS, not the default action.
*
* The composition signal follows the DSH core convention (InputBar's IME
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as the
* legacy signal engines emit without isComposing.
*/
/** The pure decision: is this keyboard event part of an IME composition? */
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
return event.isComposing || event.keyCode === 229
}
/**
* Register the document-level capture guard. Returns the disposer
* (HMR-safe; call through `ctx.effect`).
*/
export function registerImeGuard(): () => void {
const onKey = (event: KeyboardEvent): void => {
if (isImeComposition(event)) event.stopPropagation()
}
document.addEventListener('keydown', onKey, true)
document.addEventListener('keyup', onKey, true)
return () => {
document.removeEventListener('keydown', onKey, true)
document.removeEventListener('keyup', onKey, true)
}
}
+20
View File
@@ -17,6 +17,7 @@ import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
@@ -183,6 +184,25 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: link interception',
)
// The IME guard: composition keys (candidate arrows, confirm, cancel)
// belong to the input method, never to page JS. Inlined third-party UI
// (Univer's office controls) has shipped unguarded keydown handlers that
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
// document-capture guard neutralizes the whole class before React or any
// native listener sees the event. Registered as early as possible so no
// other capture-phase listener can win the ordering race.
ctx.effect(
() => {
try {
return registerImeGuard()
} catch (error) {
fail('ime guard', error)
return undefined
}
},
'dsh-better-sidebar: IME composition guard',
)
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
+27 -11
View File
@@ -1,15 +1,20 @@
/**
* Layout push: when the sidebar is open it OCCUPIES the layout instead of
* Layout push: when a panel is open it OCCUPIES the layout instead of
* floating over it — the app shell (#root, the AppFrame three-column grid)
* gives up the panel's width on the right. Only the center column is
* flexible (1fr), so the squeeze lands exactly on the conversation output
* and the input bar, like a VSCode sidebar. The fixed panel covers the
* vacated strip, which looks seamless because #root's background is the
* theme base.
* gives up space. Only the center column is flexible (1fr), so the right
* panel's width squeeze (margin-right on #root) lands exactly on the
* conversation output and the input bar, like a VSCode sidebar.
*
* The width rides a CSS variable updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margin and the panel slide
* on the same theme duration. Width drags disable the transition so the
* The bottom panel squeezes ONLY the center column — it must not cover the
* app's own left sidebar or the right panel. The AppFrame's grid items are
* (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so
* the vertical push lands on the center column alone. The fixed panels
* cover the vacated strips, which looks seamless because #root's background
* is the theme base.
*
* The sizes ride CSS variables updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margins and the panel
* slides on the same theme duration. Drags disable the transition so the
* layout tracks the pointer.
*/
#root {
@@ -17,12 +22,23 @@
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
body[data-dsh-sidebar-dragging] #root {
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
1-3 of #root > div) — nth-child(2) is the center column. A stretched grid
item shrinks by its margins, so the conversation content (output + input
bar) lifts without touching the sidebars. */
#root > div > div:nth-child(2) {
margin-bottom: var(--dsh-sidebar-height, 0px);
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
#root {
#root,
#root > div > div:nth-child(2) {
transition: none;
}
}
+8 -4
View File
@@ -18,8 +18,8 @@ const zh = {
close: '关闭',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
collapseBottomPanel: '折叠底部面板',
expandBottomPanel: '展开底部面板',
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
@@ -123,6 +123,8 @@ const zh = {
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
@@ -193,8 +195,8 @@ const en: Record<keyof typeof zh, string> = {
close: 'Close',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
collapseBottomPanel: 'Collapse bottom panel',
expandBottomPanel: 'Expand bottom panel',
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
@@ -298,6 +300,8 @@ const en: Record<keyof typeof zh, string> = {
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General',
+3
View File
@@ -43,6 +43,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
? record.bottomPanelAutoTerminal
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
+5 -3
View File
@@ -379,7 +379,9 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
for (const leaf of allLeaves(state.splits)) {
// The scan covers BOTH trees: opening a single-instance tab from the
// bottom panel focuses an existing instance wherever it lives.
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
@@ -387,9 +389,9 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
return openTabInActivePane(state, tab)
}
/** Find which pane hosts a tab id ('' if none). */
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
function findPaneIdOf(state: SidebarState, tabId: string): string {
for (const leaf of allLeaves(state.splits)) {
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
}
return state.activePane ?? ''
+150 -38
View File
@@ -10,27 +10,39 @@
/* ── Shell ─────────────────────────────────────────────────────────────── */
.toggleRail {
/* The persistent expand/collapse cluster at the top-right corner: two rail
controls side by side (bottom panel's glyph LEFT of the right panel's).
Always pinned to the viewport corner — inside the right panel's top-right
while it is open, sitting flush in the 34px tab strip (28px buttons at
top: 3) whose right end it really squeezes. */
.toggleCluster {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
top: 3px;
right: 10px;
z-index: 55;
display: flex;
align-items: center;
/* Native rail inset: the 36px control sits 10px off the column edge. */
padding: 0 10px;
flex-direction: row;
gap: 4px;
}
/* The closed-state affordance is the native rail icon control: a plain 36px
circle, transparent fill, secondary ink; hover raises the fill. No border,
no shadow — the icon alone is the affordance (SidebarRoot .iconButton). */
/* The tab strip of the OPEN right panel reserves the cluster's width at its
right end, so the tabs and the + menu genuinely yield space to the cluster
(never hiding under it). */
.panel:not(.panelHidden) .tabBar {
padding-right: 72px;
}
/* The closed-state affordance is the native rail icon control: a plain
28px circle (the app's icon-button size — the cluster must stay inside
the 34px tab-strip band so it never covers the pane headers below),
transparent fill, secondary ink; hover raises the fill. No border, no
shadow — the icon alone is the affordance. */
.toggleButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
@@ -55,6 +67,8 @@
position: fixed;
top: 0;
right: 0;
/* Full height: the bottom panel squeezes only the center column, so the
right panel never gives up any vertical position. */
bottom: 0;
z-index: 50;
display: flex;
@@ -100,25 +114,6 @@
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
flex: none;
}
.panelTitle {
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panelBody {
flex: 1;
min-height: 0;
@@ -126,6 +121,112 @@
display: flex;
}
/* ── Bottom panel ──────────────────────────────────────────────────────── */
/* The bottom panel: an independent workbench at the bottom of the CENTER
column only (the Sidebar shell writes left/right inline: from the app's
own left sidebar to the right panel's left edge) — the sidebars never
give up any position. Same surface as the right panel, with a top
hairline instead of the left one. */
.bottomPanel {
position: fixed;
bottom: 0;
z-index: 50;
display: flex;
flex-direction: column;
background: var(--dsw-specific-sidebar-fill);
border-top: 1px solid var(--dsw-alias-border-l2);
/* Expand/collapse slides the panel; height drags animate the height. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
.bottomPanelHidden {
transform: translateY(102%);
pointer-events: none;
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
/* Height drags write at pointer cadence; easing would detach the edge. */
.bottomPanel[data-dragging] {
transition: none;
}
/* The bottom panel's height drag: the top edge strip (mirror of the right
panel's width strip). */
.bottomResize {
position: absolute;
left: 0;
right: 0;
top: -4px;
height: 8px;
cursor: row-resize;
z-index: 2;
touch-action: none;
}
.bottomResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* The bottom panel's close control: the app's icon-button pattern (28px
circle), pinned to the tab strip's right end — one tap collapses the
panel. The tab strips below reserve its width so the + menu never hides
under it. */
.bottomClose {
position: absolute;
top: 3px;
right: 6px;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.bottomClose:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The bottom panel's tab strips reserve the close control's width at their
right end, so the + menu never hides under it. */
.bottomPanel .tabBar {
padding-right: 40px;
}
/* The shared corner (both panels open): the intersection of the right
panel's left edge and the bottom panel's top edge. Horizontal drags
resize the width, vertical drags the height — the two panels drag
against each other. */
.cornerHandle {
position: fixed;
width: 12px;
height: 12px;
z-index: 52;
cursor: nwse-resize;
touch-action: none;
}
.cornerHandle:hover,
.cornerHandle[data-dragging] {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px
circle, secondary label, hover fill; no border so the icon alone is the
affordance. */
@@ -317,28 +418,35 @@
display: none;
}
/* An empty pane's welcome cards: a responsive grid of compact cards — as
many per row as the pane width allows; rows that cannot fit the pane
height are clipped (never scrolled or squeezed smaller). */
.paneEmptyCards {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
align-content: start;
gap: 8px;
padding: 16px;
padding: 12px;
overflow: hidden;
}
.paneCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 12px;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 12px 8px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
text-align: left;
text-align: center;
}
.paneCard:hover:not(:disabled) {
@@ -1713,6 +1821,7 @@
/* Keyboard focus stays visible on every interactive control (the styling
policy requires focus visibility; the accent token doubles as the ring). */
.toggleButton:focus-visible,
.bottomClose:focus-visible,
.iconButton:focus-visible,
.tab:focus-visible,
.tabClose:focus-visible,
@@ -1739,6 +1848,9 @@
@media (prefers-reduced-motion: reduce) {
.panel,
.panelHidden,
.bottomPanel,
.bottomPanelHidden,
.toggleCluster,
.toggleButton,
.tab,
.tabBarPlus,
+7 -3
View File
@@ -262,20 +262,24 @@ function NodeView(props: {
)
}
/** The workbench: the split tree filling the sidebar body. */
/** The workbench: the split tree filling the sidebar body. `tree` selects
* which tree renders (the right panel's by default; the bottom panel passes
* `state.bottomSplits` — the actions route by pane id, so one action set
* serves both). */
export function Workbench(props: {
state: SidebarState
tree?: SplitNode
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
return (
<div className={css.workbench}>
<NodeView
node={state.splits}
node={tree ?? state.splits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
+189 -22
View File
@@ -57,7 +57,9 @@ export type SplitNode = SidebarLeaf | SidebarSplit
export interface SidebarState {
panelOpen: boolean
width: number
/** The pane receiving newly opened tabs (last pane the user touched). */
/** The pane receiving newly opened tabs (last pane the user touched).
* Pane ids are globally unique across BOTH trees (shared uid counter), so
* one field resolves into either tree — see {@link treeOf}. */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
@@ -65,13 +67,31 @@ export interface SidebarState {
nextBrowser: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
/** The right sidebar's split tree (the original workbench). */
splits: SplitNode
/** Whether the bottom panel (a second, independent workbench) is open. */
bottomOpen: boolean
/** The bottom panel's height (clamped to the contract range). */
bottomHeight: number
/**
* Whether the bottom panel has been expanded at least once in this
* session — the FIRST expansion tries to auto-open a terminal tab (gated
* on the bottomPanelAutoTerminal pref); later expansions never do.
*/
bottomOpenedOnce: boolean
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
* tabs never cross panels — the two panels only share panel-size drags). */
bottomSplits: SplitNode
}
export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
/** Bottom panel geometry contract (mirrors the width contract; the upper
* bound is the viewport, enforced by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -110,6 +130,7 @@ function maxCounterId(parsed: unknown): number {
}
}
walk((parsed as Record<string, unknown> | null)?.splits)
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
return max
}
@@ -126,6 +147,9 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
}
// The bottom panel starts closed with an empty pane (its welcome cards
// offer the openable types on first use).
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return {
panelOpen,
width,
@@ -134,9 +158,29 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx
nextBrowser: 1,
expanded: [],
splits: leaf,
bottomOpen: false,
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
}
}
/** Whether a tree node (or any descendant) carries the given pane/split id. */
function treeHasId(node: SplitNode, id: string): boolean {
if (node.id === id) return true
if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
return false
}
/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the
* bottom panel's tree, else 'splits' (the right panel's tree). Ids are
* globally unique (the shared uid counter), so an id in neither tree falls
* back to the right tree, where tree operations no-op on a missing node —
* the pre-bottom-panel behavior. */
export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' {
return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits'
}
/** Walk the tree and apply `visit` to the leaf with the given id. */
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
if (node.kind === 'leaf') {
@@ -179,9 +223,10 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state tree (any pane). */
/** Whether a tab exists anywhere in a state (either tree, any pane). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
@@ -237,6 +282,9 @@ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
* The VSCode drag gesture: move a tab out of its pane and either merge it
* into the target pane (center) or split the target pane with the tab in a
* fresh leaf (edge). The source pane collapses when it empties.
*
* The panes may live in DIFFERENT trees (dragging a tab between the two
* panels): the tab then leaves its own tree and lands in the other one.
*/
export function moveTabToEdge(
state: SidebarState,
@@ -249,11 +297,43 @@ export function moveTabToEdge(
// Dropped back onto its own pane's center: reorder to the end.
return moveTab(state, fromPane, tabId, toPane, -1)
}
const source = leafWithTab(state.splits, tabId)
const key = treeOf(state, fromPane)
const toKey = treeOf(state, toPane)
if (key !== toKey) {
// Cross-panel drop: remove the tab from its own tree, then merge (center)
// or split (edge) a pane of the OTHER tree with the tab.
const source = leafWithTab(state[key], tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let sourceNode = mapLeaf(state[key], source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (emptied) sourceNode = removeLeafAt(sourceNode, source.id)
let targetNode = state[toKey]
let activePane: string
if (zone === 'center') {
targetNode = mapLeaf(targetNode, toPane, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
activePane = toPane
} else {
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up')
targetNode = result.node
activePane = result.leafId
}
return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane }
}
const node = state[key]
const source = leafWithTab(node, tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let splits = mapLeaf(state.splits, source.id, (leaf) => {
let splits = mapLeaf(node, source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
@@ -264,11 +344,11 @@ export function moveTabToEdge(
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
return { ...state, splits, activePane: toPane }
return { ...state, [key]: splits, activePane: toPane }
}
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
return { ...state, splits: result.node, activePane: result.leafId }
return { ...state, [key]: result.node, activePane: result.leafId }
}
/**
@@ -291,21 +371,23 @@ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
const key = treeOf(state, paneId)
let emptied = false
const splits = mapLeaf(state.splits, paneId, (leaf) => {
const splits = mapLeaf(state[key], paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits }
}
/** Activate a tab in its pane. */
/** Activate a tab in its pane (the pane's own tree). */
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
const key = treeOf(state, paneId)
return {
...state,
activePane: paneId,
splits: mapLeaf(state.splits, paneId, (leaf) => {
[key]: mapLeaf(state[key], paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}
@@ -314,7 +396,7 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
/** Update the display fields of one open tab (title / path) without
* re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. */
* missing tab id is a no-op. The tab may live in either tree. */
export function patchTab(
state: SidebarState,
tabId: string,
@@ -338,7 +420,8 @@ export function patchTab(
return children === node.children ? node : { ...node, children }
}
const splits = walk(state.splits)
return changed ? { ...state, splits } : state
const bottomSplits = walk(state.bottomSplits)
return changed ? { ...state, splits, bottomSplits } : state
}
/**
@@ -349,34 +432,68 @@ export function patchTab(
* openDiffTab already check existence before calling) and the landing
* itself — the service's dedupe path delegates here after its dedupeKey
* check misses.
*
* The active pane may live in EITHER tree (pane ids are globally unique):
* a stale id that survives in neither tree falls back to the right tree's
* first pane instead of swallowing the open.
*/
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
let targetId = state.activePane ?? firstLeaf(state.splits).id
// A stale activePane (its pane was closed since) must not swallow the
// open: fall back to the first pane instead of dropping the tab.
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
// open: fall back to the first pane of the right tree instead of dropping
// the tab.
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
// Id-based safety net: if a tab with the same id exists, focus it.
for (const leaf of allLeaves(state.splits)) {
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
return {
...state,
activePane: targetId,
splits: mapLeaf(state.splits, targetId, (leaf) => {
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
/** Move a tab from one pane to another (insert at index; -1 appends). */
/** Move a tab from one pane to another (insert at index; -1 appends).
* The panes may live in DIFFERENT trees — dragging a tab between the two
* panels removes it from its own tree and lands it in the other one. */
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
const fromKey = treeOf(state, fromPane)
const toKey = treeOf(state, toPane)
if (fromKey !== toKey) {
let moved: SidebarTab | undefined
let emptied = false
const source = mapLeaf(state[fromKey], fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (moved === undefined) return state
const target = mapLeaf(state[toKey], toPane, (leaf) => {
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return {
...state,
[fromKey]: emptied ? removeLeafAt(source, fromPane) : source,
[toKey]: target,
activePane: toPane,
}
}
let moved: SidebarTab | undefined
let emptied = false
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
let splits = mapLeaf(state[fromKey], fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
@@ -391,13 +508,14 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return { ...state, splits, activePane: toPane }
return { ...state, [fromKey]: splits, activePane: toPane }
}
/** Split the active pane (or the pane containing the active tab). */
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
const paneId = state.activePane ?? firstLeaf(state.splits).id
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
const key = treeOf(state, paneId)
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
}
/**
@@ -443,6 +561,11 @@ export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Toggle the bottom panel open/closed (independent of the right panel). */
export function toggleBottomPanel(state: SidebarState): SidebarState {
return { ...state, bottomOpen: !state.bottomOpen }
}
/** Set the panel width (clamped to the contract range; the upper bound is
* the viewport so the fullscreen expansion can fill the window). */
export function setWidth(state: SidebarState, width: number): SidebarState {
@@ -450,6 +573,16 @@ export function setWidth(state: SidebarState, width: number): SidebarState {
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
}
/** Set the bottom panel height (clamped to the contract range). The upper
* bound leaves the center column (the agent output area) at least PANEL_MIN
* tall — without the cap the bottom panel could swallow the whole viewport
* and squeeze the conversation to zero height. */
export function setBottomHeight(state: SidebarState, height: number): SidebarState {
const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity
const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN)
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
}
/** Toggle a directory in the explorer expansion set. */
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
const expanded = state.expanded.includes(path)
@@ -476,6 +609,13 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
}
}
/** State-level {@link resizeSplit} route: the divider may live in either
* tree (split ids are globally unique). */
export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
const key = treeOf(state, splitId)
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
@@ -510,7 +650,7 @@ export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
@@ -613,9 +753,29 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
: 1
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
// The seen/reid maps are SHARED across both trees: pane/split ids must be
// globally unique (the runtime uid counter is shared too), so a duplicate
// seen first in the right tree gets a fresh id when it reappears in the
// bottom tree.
const seen = new Set<string>()
const reid = new Map<string, string>()
const splits = sanitizeNode(record.splits, new Set(), reid)
const splits = sanitizeNode(record.splits, seen, reid)
if (splits === undefined) return undefined
// Bottom-panel fields arrived in a later build: a missing or malformed
// value on an OLDER persisted state defaults (closed / default height /
// empty pane) so existing layouts keep loading, like nextBrowser.
const bottomOpen = record.bottomOpen === true
// Cap the persisted height so the center column (the agent output area)
// keeps at least PANEL_MIN tall (a stale full-height bottom panel from an
// older build must never squeeze the conversation to zero).
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight)
? record.bottomHeight
: BOTTOM_DEFAULT
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
@@ -627,6 +787,13 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
nextBrowser,
expanded: record.expanded as string[],
splits,
bottomOpen,
bottomHeight,
// An older persisted state never expanded the bottom panel (the field
// arrived later): defaulting to false gives it the first-expansion
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
}
}
+1
View File
@@ -80,6 +80,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
interceptOpenPath: z.boolean().default(true),
htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false),
+8
View File
@@ -27,6 +27,13 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Whether expanding the bottom panel for the FIRST time in a session tries
* to open a fresh terminal tab there (the terminal quota/type still gates
* the attempt). On by default; the switch lives under the terminal tab's
* row in the Side card settings.
*/
bottomPanelAutoTerminal: boolean
/**
* Whether chat-side file opens (tool-row path links, the produced-files
* row, prose file mentions — every path that funnels through the client
@@ -97,6 +104,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
+1
View File
@@ -48,6 +48,7 @@ if (g.window === undefined) {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
innerHeight: 768,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ describe('built-in tab registrations', () => {
it('the terminal tab declares the model terminal-tools related setting', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal'])
})
it('the browser tab declares its sandbox and link-takeover related settings', () => {
+187
View File
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
/**
* IME-composition guard tests.
*
* The guard registers document CAPTURE-phase keydown/keyup listeners that
* stopPropagation() while an input method is composing (isComposing or the
* legacy keyCode 229). These tests pin:
*
* 1. the pure decision (`isImeComposition`);
* 2. the native-listener path — a bubble listener on `document` and a
* target-phase listener on the input must NOT see composition keys, but
* must see every other key;
* 3. the React-synthetic path — a React `onKeyDown` handler (delegated at
* the root container) must not fire for composition keys, proving the
* document-capture listener wins the ordering race against React's
* delegation (the mechanism that inlined third-party UI, e.g. Univer's
* InputNumber, uses to hijack ArrowUp/ArrowDown);
* 4. the disposer restores normal flow (HMR-safe).
*
* Events are dispatched on a deep element (an `<input>` in `document.body`)
* exactly like the browser does, so capture-phase blocking behaves like in
* production — dispatching on `document` itself would run all listeners in
* the target phase where stopPropagation does not stop same-node listeners.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { isImeComposition, registerImeGuard } from '../src/client/ime-guard.ts'
/** Build a KeyboardEvent the way jsdom allows: keyCode/isComposing via defineProperty. */
function keyEvent(
type: 'keydown' | 'keyup',
init: { key: string; isComposing?: boolean; keyCode?: number; bubbles?: boolean },
): KeyboardEvent {
const event = new KeyboardEvent(type, { key: init.key, bubbles: init.bubbles ?? true, cancelable: true })
if (init.isComposing !== undefined) {
Object.defineProperty(event, 'isComposing', { value: init.isComposing })
}
if (init.keyCode !== undefined) {
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
}
return event
}
describe('isImeComposition', () => {
it('treats isComposing as composition', () => {
expect(isImeComposition({ isComposing: true, keyCode: 0 })).toBe(true)
})
it('treats keyCode 229 as composition (legacy engines without isComposing)', () => {
expect(isImeComposition({ isComposing: false, keyCode: 229 })).toBe(true)
})
it('treats both signals together as composition', () => {
expect(isImeComposition({ isComposing: true, keyCode: 229 })).toBe(true)
})
it('lets ordinary keys through', () => {
expect(isImeComposition({ isComposing: false, keyCode: 40 })).toBe(false)
expect(isImeComposition({ isComposing: false, keyCode: 0 })).toBe(false)
})
})
describe('registerImeGuard — native listeners', () => {
let input: HTMLInputElement
let dispose: (() => void) | undefined
const seen: string[] = []
const onDocumentBubble = (event: Event): void => {
seen.push(`document:${event.type}`)
}
const onInputTarget = (event: Event): void => {
seen.push(`input:${event.type}`)
}
beforeEach(() => {
input = document.createElement('input')
document.body.appendChild(input)
seen.length = 0
document.addEventListener('keydown', onDocumentBubble)
document.addEventListener('keyup', onDocumentBubble)
input.addEventListener('keydown', onInputTarget)
input.addEventListener('keyup', onInputTarget)
})
afterEach(() => {
dispose?.()
dispose = undefined
document.removeEventListener('keydown', onDocumentBubble)
document.removeEventListener('keyup', onDocumentBubble)
input.removeEventListener('keydown', onInputTarget)
input.removeEventListener('keyup', onInputTarget)
input.remove()
})
it('lets ordinary keys reach document bubble and input target listeners', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown' }))
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown' }))
expect(seen).toEqual(['input:keydown', 'document:keydown', 'input:keyup', 'document:keyup'])
})
it('blocks composition keys (isComposing) from every downstream listener', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
})
it('blocks composition keys signalled by keyCode 229 only', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', keyCode: 229 }))
expect(seen).toEqual([])
})
it('still blocks after the composition signal is gone (guard is per-event)', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
expect(seen).toEqual(['input:keydown', 'document:keydown'])
})
it('disposer restores normal flow', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
dispose()
dispose = undefined
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual(['input:keydown', 'document:keydown'])
})
})
describe('registerImeGuard — React synthetic path', () => {
let container: HTMLDivElement
let root: Root
let input: HTMLInputElement
let dispose: (() => void) | undefined
const seen: string[] = []
const onReactKeyDown = (): void => {
seen.push('react:keydown')
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(createElement('input', { 'data-testid': 'ime-target', onKeyDown: onReactKeyDown }))
})
input = container.querySelector('input') as HTMLInputElement
seen.length = 0
})
afterEach(() => {
dispose?.()
dispose = undefined
act(() => { root.unmount() })
container.remove()
})
it('baseline: React onKeyDown receives composition keys when the guard is absent', () => {
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual(['react:keydown'])
})
it('guard blocks React synthetic onKeyDown during composition (capture beats delegation)', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
// Ordinary keys still flow through React after the guard is in place.
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
expect(seen).toEqual(['react:keydown'])
})
it('disposer restores the React synthetic path', () => {
dispose = registerImeGuard()
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual([])
dispose()
dispose = undefined
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
expect(seen).toEqual(['react:keydown'])
})
})
+1 -1
View File
@@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
})
})
+51
View File
@@ -414,3 +414,54 @@ describe('service.openTab dedupe', () => {
expect(tabs).toHaveLength(1)
})
})
describe('service.openTab across the two panels', () => {
it('openTab lands in the bottom tree when the active pane lives there', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'git', title: 'Git', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
service.openTab({ type: 'git', title: 'Git' })
const state = store.getSnapshot().state!
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(true)
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(false)
})
it('dedupeKey focuses an existing instance in the OTHER tree (single-instance across panels)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'singleton',
title: 'Singleton',
single: true,
component: () => null,
})
store.setSession('s1')
// Open in the right tree first.
service.openTab({ type: 'singleton', title: 'Singleton' })
// Switch the active pane to the bottom tree and open again: the dedupe
// scan covers both trees, so the existing instance is focused, not
// duplicated in the bottom panel.
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
service.openTab({ type: 'singleton', title: 'Singleton' })
const state = store.getSnapshot().state!
const total = allLeaves(state.splits).concat(allLeaves(state.bottomSplits))
.flatMap(l => l.tabs).filter(t => t.type === 'singleton')
expect(total).toHaveLength(1)
})
it('closeTab by id closes a tab living in the bottom tree', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'git', title: 'Git', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
service.openTab({ type: 'git', title: 'Git' })
const state = store.getSnapshot().state!
const gitTab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
service.closeTab(gitTab.id)
const after = store.getSnapshot().state!
expect(allLeaves(after.bottomSplits).flatMap(l => l.tabs).some(t => t.id === gitTab.id)).toBe(false)
})
})
+1
View File
@@ -507,6 +507,7 @@ describe('side card settings routes', () => {
defaultWidthPercent: 30,
autoOpenSubagent: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
+217 -6
View File
@@ -3,8 +3,8 @@ import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
@@ -520,6 +520,203 @@ describe('sidebar state', () => {
s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
})
// ── Bottom panel (the second, independent workbench) ───────────────────
it('toggleBottomPanel flips the bottom panel independently of the right panel', () => {
let s = state()
expect(s.bottomOpen).toBe(false)
s = toggleBottomPanel(s)
expect(s.bottomOpen).toBe(true)
// Collapsing the right panel leaves the bottom panel open (independent toggles).
s = togglePanel(s)
expect(s.panelOpen).toBe(false)
expect(s.bottomOpen).toBe(true)
})
it('setBottomHeight clamps to the contract range', () => {
expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN)
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerHeight: 800 }
try {
// The bottom panel must leave the center column at least PANEL_MIN
// tall (800 - 280), regardless of the right panel's open state.
expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280)
expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
const tab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTabInActivePane(s, tab)
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git')
// The right tree is untouched.
expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer'])
expect(s.activePane).toBe(bottomPane)
// The id safety net works across trees: reopening the same id focuses it.
const after = openTabInActivePane(s, tab)
expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git'])
})
it('openTabInActivePane falls back to the right tree when the active pane is stale', () => {
let s = state()
s = toggleBottomPanel(s)
s = { ...s, activePane: 'pane:gone' }
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
})
it('closeTab routes to the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
s = closeTab(s, bottomPane, 'terminal:1')
expect(tabOpenIn(s, 'terminal:1')).toBe(false)
// The right tree is untouched.
expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true)
})
it('moveTabToEdge splits within the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right')
expect(s.bottomSplits.kind).toBe('split')
expect(s.splits.kind).toBe('leaf')
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
// The fresh leaf (the drop's active pane) differs from the source pane.
expect(s.activePane).not.toBe(bottomPane)
})
it('resizeSplitIn routes a divider to its own tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = splitPane({ ...s, activePane: bottomPane }, 'row')
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
s = resizeSplitIn(s, split.id, 0, 0.1)
const next = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
expect(next.sizes[0]).toBeCloseTo(0.6)
expect(s.splits.kind).toBe('leaf')
})
it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the bottom fields: defaults, state kept.
const s = sanitizeState(base)
expect(s?.bottomOpen).toBe(false)
expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT)
expect(s?.bottomSplits.kind).toBe('leaf')
expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
// A malformed bottom tree is replaced with a fresh empty pane.
const broken = sanitizeState({ ...base, bottomSplits: 'junk' })
expect(broken?.splits).toBeDefined()
expect(broken?.bottomSplits.kind).toBe('leaf')
// A valid persisted bottom tree survives.
const withBottom = sanitizeState({
...base,
bottomOpen: true,
bottomHeight: 300,
bottomSplits: {
kind: 'leaf',
id: 'pane:9',
active: 'b1',
tabs: [{ id: 'b1', type: 'terminal', title: 'T' }],
},
})
expect(withBottom?.bottomOpen).toBe(true)
expect(withBottom?.bottomHeight).toBe(300)
expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1'])
// Heights are clamped to the contract range.
expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN)
// A stale full-height bottom panel must not squeeze the center column
// (the agent output area) to zero: the cap leaves it at least PANEL_MIN
// tall, regardless of the right panel's open state.
const g = globalThis as Record<string, unknown>
const previous = g.window
g.window = { innerHeight: 800 }
try {
expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280)
} finally {
if (previous === undefined) delete g.window
else g.window = previous
}
})
it('tabOpenIn and patchTab see tabs in the bottom tree', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane(
{ ...s, activePane: bottomPane },
{ id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' },
)
expect(tabOpenIn(s, 'browser:1')).toBe(true)
s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' })
const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1')
expect(tab?.title).toBe('other.com')
})
it('moves a tab across panels (center merge into the other tree)', () => {
let s = state()
s = toggleBottomPanel(s)
const rightPane = (s.splits as { id: string }).id
const bottomPane = (s.bottomSplits as { id: string }).id
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
// Drag the explorer tab from the right panel into the bottom panel (center).
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center')
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId)
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(s.activePane).toBe(bottomPane)
// And back, inserted at an index.
s = moveTab(s, bottomPane, explorerId, rightPane, 0)
expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId)
expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
})
it('moves a tab across panels by splitting the target pane (edge drop)', () => {
let s = state()
s = toggleBottomPanel(s)
const rightPane = (s.splits as { id: string }).id
const bottomPane = (s.bottomSplits as { id: string }).id
const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right')
// The source tree empties back to a leaf; the target tree splits.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(s.bottomSplits.kind).toBe('split')
expect(tabOpenIn(s, explorerId)).toBe(true)
const split = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
expect(split.children.some(
child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId),
)).toBe(true)
// The fresh leaf (the drop's active pane) differs from the source pane.
expect(s.activePane).not.toBe(rightPane)
})
})
describe('agent terminal reconciliation', () => {
@@ -576,6 +773,17 @@ describe('agent terminal reconciliation', () => {
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
let s = makeDefaultState(400, true)
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
})
})
describe('pty helpers', () => {
@@ -883,6 +1091,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 60,
autoOpenSubagent: false,
agentTerminalTools: true,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -900,6 +1109,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 33,
autoOpenSubagent: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -917,6 +1127,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 40,
autoOpenSubagent: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
@@ -963,9 +1174,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -978,7 +1189,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -986,7 +1197,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])