Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf54a60e37 | ||
|
|
c7af7dccdb | ||
|
|
7eccc49f6e | ||
|
|
d1c393a048 | ||
|
|
a465bdee38 | ||
|
|
31b78f7941 |
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+324
-2
@@ -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
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,6 +48,7 @@ if (g.window === undefined) {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
innerHeight: 768,
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user