|
## 🌐 插件生态
@@ -263,6 +262,24 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
**支持的 DSH 版本**: · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
+### v0.19.0-alpha.0
+
+> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.1+**(peer 下限 `^0.1.5-alpha.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`)。0.1.2-rc.1 稳定线请继续用 **v0.18.1**(npm `latest`)。
+
+**✨ 新功能**
+
+- 🖥️ **接入 DSH 原生右侧栏**(#604):右列改为 DSH 自己的右侧栏——插件的 7 个 tab 类型全部注册成原生 tab 类型 + 原生 tab 体;聊天里的文件打开统一走 `ctx.sidebarRight.openResource(dsh-resource://file/…)`;`editor` 类型以 `extension` 优先级认领文件资源(压过内置文本预览)并接管内置「文件」页 kind(注销即复位);跨会话打开在目标会话未上屏时排队重放。
+- 🧩 **退役插件自绘右侧面板与自由窗口**:右列归 DSH 后,插件只保留底部工作台(单分栏树、随会话持久化),开合按钮注册进 DSH 会话头 utilities 槽;浮窗 API(`floats` / `floatTab` / `dockFloat` / `raiseFloat` / 右键「移动到自由窗口」)、`features` 里的 `'floatWindows'`,以及 `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项一并删除(旧持久化文档里的 `floats` 字段被忽略,不影响加载)。
+- 🔗 **适配 DSH 0.1.5 宿主契约**(#603):`assistant/chunk` 事件删除 → 实时增量改由 `agent/assistant-stream` 帧折叠(侧边对话转录的 `live` 字段);`sessionPersistence.inspect` 删除 → 冷会话读取改走 `open(id,'read')`;会话头 `version` 用 `SESSION_FORMAT_VERSION`。
+
+**🐛 修复**
+
+- 侧边对话转录 / `jobs.output` 回放 / fork 继承等 8 处会话事件读取跟随 0.1.5 契约;自定义种子补齐 fork 标记对,避免继承父会话未领取的 inbox 输入。
+
+**🧰 CI 与内部**
+
+- 真机挂载冒烟门禁钉 0.1.5-alpha.1,e2e 增加「展开原生栏 → 经引导页逐个打开插件 tab 类型」的巡检;typecheck / lint / 单测 / 挂载车道全绿。
+
### v0.18.1
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
@@ -316,9 +333,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
历史版本(v0.12.0 – v0.15.2)
-### v0.19.0-alpha.0
+### v0.19.0-alpha.0(0.1.2-alpha.5 适配,未发布,内容并入 v0.18.0)
-> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
+> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`)。该版本号当时未单独发布,内容已并入 **v0.18.0** 正式版;v0.19.0-alpha.0 这个号后来被 0.1.5 适配线复用(见上)。
- 🔗 **适配 DSH 0.1.2-alpha.5(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.5(真机挂载冒烟 14/14 验证)。`dsh-client-locale` 上游停在 0.1.2-alpha.3 未发新版,其 peer 下限 / devDep 保持并天然兼容 alpha.5 运行时(`pnpm peers check` 零失配,无需新增提升传递 peer)。代码适配了 alpha.4 的兼容性标记改动——`Session.events` 属性移除,迁移到按需读 API `snapshotEvents()`(sidechat 转录 live 读、fork 继承、`jobs.output` 回放、subagent 活跃度共 8 处),新建线程 meta 中宿主已删的 `seedLength` 一并移除;alpha.4 其余变化(双向 `send_message`、自定义模型发现复用 Profile 请求头、`SessionSeq`/`SessionLogOffset` 强类型)与 alpha.5(升级启动修复)经核实不触及本插件其余表面。
@@ -526,7 +543,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
-完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
+完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
### ➕ 添加插件(推荐插件目录)
diff --git a/README_EN.md b/README_EN.md
index f129fe3..c3ec0d5 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -47,8 +47,7 @@
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label. `.html` ops (read / write / edit) gain a **Render** toggle in the preview head — the editor's `/sidebar/html` route iframe, with relative assets resolving inside the route and segmented reads rendering the full document; always sandboxed (opaque origin + CSP header, no escape hatch). `.pdf` ops (read / write / edit) gain the same **Render** toggle — the editor's PDF preview reused verbatim (media-route bytes + explicit Blob, the browser's native viewer inline, with a download fallback)
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
-- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
-- **🪟 Free Windows**: drag any tab onto the main conversation area to turn it into a movable / resizable / raiseable floating window (default 390×780); drag it back onto a pane to dock; persisted per session. `features` includes `'floatWindows'` and plugin tabs are supported identically
+- **🖥️ Native right sidebar + plugin bottom workbench**: the right column belongs to DSH 0.1.5's own sidebar — the plugin registers every tab type as a native tab (file opens go through `dsh-resource://file/**`, and the built-in Files page / file tree is taken over), keeping only its own bottom workbench (split panes / terminals / per-session persistence) whose toggle sits in the session header
- **📌 Pinned Terminals**: right-click a terminal tab to "Pin to Workspace / Pin Globally" — pinned terminals survive session switches and surface inline in the TabBar as virtual tabs (click activates in-place, PTY connects directly to the home session's PTY via WS, no session jump needed); agent terminals exempted from reconcile removal
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
@@ -166,7 +165,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
| **🖥️ CodeMirror editor**
| **🖼️ Inline image preview**
|
| **💻 Real Terminal** xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.
| **🌿 Changes: Git lens + This-Session lens** Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).
|
| **🌐 Embedded Browser** Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).
| **🧩 Tasks: Agent Topology + Background Jobs** Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-activate the Tasks page, expanding the sidebar on wide viewports without forcing narrow full-screen drawers open (configurable).
|
-| **💬 Side Chat (beta)** Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
| **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes** The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer; drag a tab onto the main conversation area to turn it into a **free window** (float / resize / raise, drag back onto a pane to dock).
|
+| **💬 Side Chat (beta)** Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
| **🖥️ DSH's native right sidebar + plugin bottom workbench** The right column is DSH's own sidebar: the plugin registers every tab type as a native tab (including the built-in Files page and file previews), so clicking a file in the chat lands there directly; the plugin's own bottom panel can stay open alongside it — drag a tab to a pane edge to **split**, to the middle to **merge**, drag the top edge to resize; the toggle lives in the session header.
|
| **⚙️ Declarative Settings** The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.
| **📱 Mobile** On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
|
## 🌐 Plugin Ecosystem
@@ -267,6 +266,24 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
**Supported DSH versions**: · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
+### v0.19.0-alpha.0
+
+> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`): supports **DSH 0.1.5-alpha.1+** only (peer floor `^0.1.5-alpha.1`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.1`). The 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
+
+**✨ New**
+
+- 🖥️ **DSH's native right sidebar** (#604): the right column is now DSH's own sidebar — all seven plugin tab types register as native tab types with native tab bodies; every file open from the chat goes through `ctx.sidebarRight.openResource(dsh-resource://file/…)`; the `editor` type claims file resources at `extension` priority (outranking the built-in text preview) and takes over the built-in Files page kind (restored on unregister); cross-session opens queue until the target session is on screen.
+- 🧩 **Own right panel and free windows retired**: with the right column handed back to DSH, the plugin keeps only its bottom workbench (single split tree, per-session persistence) and registers its toggle into DSH's session-header utilities slot; the float API (`floats` / `floatTab` / `dockFloat` / `raiseFloat` / the "Move to Free Window" menu entry), the `'floatWindows'` feature string, and the `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` settings are gone (a persisted `floats` field is ignored, so old documents still load).
+- 🔗 **DSH 0.1.5 host contracts** (#603): the `assistant/chunk` event is gone — live deltas now fold from `agent/assistant-stream` frames (the side chat transcript's `live` field); `sessionPersistence.inspect` is gone — cold reads go through `open(id,'read')`; session headers use `SESSION_FORMAT_VERSION`.
+
+**🐛 Fixes**
+
+- Eight session-event read sites follow the 0.1.5 contracts (side-chat transcript, `jobs.output` replay, fork inheritance); custom seeds now carry the fork marker pair, so they no longer inherit the parent's unclaimed inbox input.
+
+**🧰 CI & internals**
+
+- The real-host mount smoke lane pins 0.1.5-alpha.1 and sweeps the native sidebar's guide page to open every plugin tab type; typecheck / lint / unit tests / mount lane all green.
+
### v0.18.1
> 📌 **Stable release** (npm `latest`): the DSH baseline is unchanged (**0.1.2-rc.1+**, peer floor `^0.1.2-rc.1`) — this is the incremental cut after v0.18.0: richer op previews in the changes panel, a writable file tree, and five fixes.
@@ -323,9 +340,9 @@ All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0
- 10+ new curated plugins: dsh-better-sidebar-icons (#441), dsh-sidenote (#451, formerly dsh-sidechat #470), dsh-github-workbench (#410), dsh-bilingual-reader (#379), dsh-server-deck (#413), dsh-md-export (#405), dsh-code-nav (#404), dsh-suhuang-scroll (#392), dsh-better-overleaf (#370), and more (each with 18+ language i18n coverage)
-### v0.19.0-alpha.0
+### v0.19.0-alpha.0 (0.1.2-alpha.5 adaptation, unpublished, folded into v0.18.0)
-> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
+> 🧪 **Alpha track**: this release targeted **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`). This number was never published separately — its content shipped in the **v0.18.0** stable release, and the same number was later reused for the 0.1.5 adaptation line (see above).
- 🔗 **Adapted to DSH 0.1.2-alpha.5 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.5 (verified by a real-host mount smoke 14/14). `dsh-client-locale` has no alpha.5 upstream (newest is 0.1.2-alpha.3), so its peer floor / devDep pin trail the baseline and already accept the alpha.5 runtime (`pnpm peers check` reports zero mismatches — no new transitive peers to hoist). The code adapts to alpha.4's compatibility-flagged change — the `Session.events` property was removed, migrated to the on-demand `snapshotEvents()` API (8 sites: sidechat transcript live reads, fork inheritance, `jobs.output` replay, subagent activity), and the `seedLength` meta field the host dropped was removed from thread creation; alpha.4's remaining changes (bidirectional `send_message`, custom-model discovery reusing Profile headers, `SessionSeq`/`SessionLogOffset` strong typing) and alpha.5's upgrade-startup fix were verified not to touch any other plugin surface.
@@ -527,7 +544,7 @@ All changes since v0.14.0:
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 8 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
-Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, float windows and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md).
+Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, the native-sidebar surface and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md).
### ➕ Add Plugins (recommended plugin catalog)
diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md
index 0580714..562a2ad 100644
--- a/docs/external-plugin-guide.md
+++ b/docs/external-plugin-guide.md
@@ -2,15 +2,15 @@
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
-> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.0-alpha.0**(alpha 通道,仅支持 DSH **0.1.5-alpha.1+**;0.1.2-rc.1 稳定线请用 v0.18.x)。
+> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.0-alpha.0**(alpha 通道,仅支持 DSH **0.1.5-alpha.1+**;0.1.2-rc.1 稳定线请用 v0.18.x)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
---
-## 0. 承载面:DSH 原生右侧栏(v0.19.0-alpha.0 起)
+## 0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起)
-从 v0.19.0-alpha.0 起,插件**不再自绘右侧面板**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`:
+从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、终端、会话内持久化)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`:
- `registerTab` / `registerFileViewer` 签名不变;
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台);
@@ -26,6 +26,8 @@
| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 |
| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `text` 预览),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 |
| 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 |
+| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 |
+| 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略) |
---
@@ -383,7 +385,7 @@ ctx.effect(() => {
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 |
-| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab——落点由二级设置 `changesDiffFloat` 决定(默认**自由浮窗**居中弹出,可选面板下半 split),设置项见 tab descriptor 的 `settings.toggles` select |
+| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏) |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `sidechat` | 35 | 否(`sidechat:`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) |
| `terminal` | 40 | 否(`terminal:`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned::`),就地按 home scope 连 PTY;global 全会话可见、workspace 仅同 cwd;`tab.pin = { scope, homeCwd? }` 随会话持久化,渲染期解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`) |
@@ -605,10 +607,10 @@ interface BetterSidebarService {
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */
readonly version: string
- /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
- * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
- * 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows'
- * ——用 `features.includes('xxx')` 按能力 gate。 */
+ /** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
+ * 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
+ * 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
+ * 'settingSelect' ——用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
@@ -755,16 +757,13 @@ ctx.effect(() =>
---
-## 11. 自由窗口(v0.16.0,`features` 含 `'floatWindows'`)
+## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**)
-任意 tab(含你注册的)可拖出侧边栏成为悬浮**自由窗口**——对你的组件基本透明,只需知道以下语义:
+自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。
-- **拖出**:tab 拖到主会话区域(conversation 列)松开即浮动(`floatTab`:移入 `SidebarState.floats`,默认 390×780 按视口钳制居中,清空 pane 折叠)。检测在 `Sidebar.tsx`(`body[data-dsh-tab-dragging]` 门控);窄视口禁用。右键「移动到自由窗口」始终可用。
-- **窗口操作**(`src/client/FreeWindow.tsx`,`[data-dsh-panel-host]` 内、z-42):头部拖动移动;拖到 pane(`[data-dsh-pane]`)松开**停靠**(`dockFloat`);右下角缩放(最小 320×200);点击置顶(层叠 = `floats` 数组序);X = `closeTab`(触发 `onClose`、释放终端)。
-- **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:`);`sanitizeState` 宽容校验(非法条目单独丢弃、几何钳入视口、diff/ephemeral 不持久化)。
-- **服务语义**:`openTab` 聚焦命中浮动 tab = 置顶窗口(不重复开);`closeTab`/`activateTab` 关窗/置顶并触发回调;agent 终端 reconcile 移除已消失的浮动窗口。**浮窗内你的组件 `visible` 恒 true**。
-- **稳定寻址面**:`[data-dsh-float-window]` / `[data-dsh-float-id]` / `[data-dsh-pane]` / `[data-dsh-float-dock-over]` / `[class*='floatDropHint']`;全令牌驱动,头部 `-webkit-app-region: no-drag`。
-- **⚠️ portal 事件劫持陷阱**:拖拽表面子树含 portal 覆盖层时(如你的组件在 tab 头部区域渲染弹层),portal 后代合成事件**沿 React 树冒泡**回 `onPointerDown`,会被误判为拖拽开始(吞点击 + 抢 pointer,菜单/X 失灵;jsdom 不走此路径,只有 e2e 能抓)。拖拽起点必须带**同源守卫**:`event.currentTarget.contains(event.target)` 为假或目标在 `button` 内时直接返回(回归:`tests/free-window.spec.tsx` portaled-menu 用例)。
+- 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在);
+- 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败;
+- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
---
@@ -882,8 +881,9 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"),
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts);Office 预览见 plugins-viewers.ts
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
-- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → ``)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)、自由窗口拖拽检测
-- **`src/client/FreeWindow.tsx`**:自由窗口(移动/停靠/缩放/置顶,§11)
+- **`src/client/Sidebar.tsx`**:底部工作台外壳 + `TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → ``)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
+- **`src/client/native/`**:原生右侧栏接入(tab 类型注册、合成 `SidebarTab` 适配、资源地址、跨会话打开排队)
+- **`src/client/sidebar/bottom-toggle.tsx`**:底部工作台开合按钮(注册进 DSH 会话头 utilities 槽)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + 嵌套设置行 + 开关持久化)
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
- **`src/client/plugins-tabs.ts`** / **`plugins-viewers.ts`**:推荐插件目录(「添加插件」弹窗数据源;加一条数据即上架,`tests/plugin-list.spec.ts` 守护)
diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx
index d09b281..e5421d9 100644
--- a/src/client/EditorHost.tsx
+++ b/src/client/EditorHost.tsx
@@ -42,7 +42,7 @@ import { relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
-import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
+import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
@@ -178,8 +178,7 @@ export function EditorHost(props: {
*/
const openFileSide = (absolute: string): void => {
store.reduce((state) => {
- const key = treeOf(state, tab.id)
- const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
+ const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
const fresh: SidebarTab = {
id: mintTabId(),
type: 'editor',
@@ -187,8 +186,8 @@ export function EditorHost(props: {
path: absolute,
meta: { treeOpen: false },
}
- const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
- return { ...state, [key]: node, activePane: leafId }
+ const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false)
+ return { ...state, bottomSplits: node, activePane: leafId }
})
}
diff --git a/src/client/FreeWindow.tsx b/src/client/FreeWindow.tsx
deleted file mode 100644
index 6ded906..0000000
--- a/src/client/FreeWindow.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-/**
- * One free window: a tab dragged out of the workbench floating over the
- * conversation area at viewport coordinates (rendered inside the panel host,
- * so desktop-shell transforms can never hijack its fixed containing block).
- *
- * The header drags the window with the panel-resize pattern — pointer
- * capture + per-frame direct DOM writes + a store commit on release — and
- * doubling as the DOCK-BACK gesture: while the pointer is over a workbench
- * pane ([data-dsh-pane], either panel), that pane highlights live and
- * releasing docks the tab into it (center merge); releasing anywhere else
- * just moves the window. The SE corner resizes, any press raises (the
- * floats array's order is the stacking order), the header right-click menu
- * and the X button dock / close. The tab content reuses the regular tab
- * renderer, so every tab type (terminal, editor, plugin tabs) floats
- * unchanged.
- */
-import { useEffect, useRef, useState, type ReactNode } from 'react'
-import clsx from 'clsx'
-import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { FloatWindow, SidebarTab } from './state.ts'
-import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
-import { t } from './locales.ts'
-import css from './sidebar.module.css'
-
-/** A drag's mutable bookkeeping (start geometry + the last applied values). */
-interface DragState {
- /** Which handle owns the drag (the abort path reads it from this ref —
- * the React `dragging` state lags a render behind pointerdown). */
- mode: 'move' | 'resize'
- pointerX: number
- pointerY: number
- startX: number
- startY: number
- startW: number
- startH: number
- /** Last geometry actually written to the DOM (the cancel-path fallback). */
- applied: { x: number; y: number; w: number; h: number }
- /** Settled after the final pointer event (the up position wins over the
- * rAF-pending value — a fast flick coalesces its tail into the up event). */
- committed: boolean
-}
-
-/** The pane under a viewport point, if any (rect hit-test; the dragged
- * window itself is not a pane, so it cannot shadow the targets). */
-function paneAt(x: number, y: number): HTMLElement | null {
- for (const pane of document.querySelectorAll('[data-dsh-pane]')) {
- const rect = pane.getBoundingClientRect()
- if (rect.width === 0 || rect.height === 0) continue
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
- }
- return null
-}
-
-/** Pointer-capture helpers tolerant of environments without the API (jsdom
- * lacks setPointerCapture — component tests dispatch plain MouseEvents, so
- * the optional calls keep them driving the drag; real browsers always have
- * it and a missing pointerId can never occur there). */
-const capturePointer = (element: HTMLElement, pointerId: number): void => {
- element.setPointerCapture?.(pointerId)
-}
-
-const releasePointer = (element: HTMLElement, pointerId: number): void => {
- element.releasePointerCapture?.(pointerId)
-}
-
-/** Whether the element holds the pointer (assumed true without the API). */
-const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
- return element.hasPointerCapture?.(pointerId) !== false
-}
-
-export function FreeWindow(props: {
- float: FloatWindow
- renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
- getTabIcon?: (tab: SidebarTab) => ReactNode
- onRaise: () => void
- onMove: (x: number, y: number) => void
- onResize: (w: number, h: number) => void
- /** Dock the tab back; the pane id, or null for the active pane. */
- onDock: (paneId: string | null) => void
- onClose: () => void
-}) {
- const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
- const rootRef = useRef(null)
- const dragRef = useRef(null)
- // The pane currently highlighted as the dock target (a direct DOM write:
- // per-frame React state would re-render every mounted tab at drag cadence).
- const dockTargetRef = useRef(null)
- // rAF coalescing: pointermove outruns the display, one write per frame.
- const frameRef = useRef(null)
- const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
- const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
- const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
-
- useEffect(() => () => {
- if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
- dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
- }, [])
-
- /** Apply the pending geometry to the DOM (one rAF per frame at most). */
- const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
- pendingRef.current = geo
- if (frameRef.current !== null) return
- frameRef.current = requestAnimationFrame(() => {
- frameRef.current = null
- const pending = pendingRef.current
- const drag = dragRef.current
- const root = rootRef.current
- if (pending === null || drag === null || root === null) return
- drag.applied = pending
- root.style.left = `${pending.x}px`
- root.style.top = `${pending.y}px`
- root.style.width = `${pending.w}px`
- root.style.height = `${pending.h}px`
- })
- }
-
- /** Flush the pending frame synchronously (the release path's last write). */
- const flushNow = (): void => {
- const pending = pendingRef.current
- const drag = dragRef.current
- const root = rootRef.current
- if (pending === null || drag === null || root === null) return
- if (frameRef.current !== null) {
- cancelAnimationFrame(frameRef.current)
- frameRef.current = null
- }
- pendingRef.current = null
- drag.applied = pending
- root.style.left = `${pending.x}px`
- root.style.top = `${pending.y}px`
- root.style.width = `${pending.w}px`
- root.style.height = `${pending.h}px`
- }
-
- const clearDockHighlight = (): void => {
- dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
- dockTargetRef.current = null
- }
-
- /** Release a drag: `pane` (when set) docks instead of moving. */
- const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
- const drag = dragRef.current
- if (drag === null || drag.committed) return
- drag.committed = true
- dragRef.current = null
- setDragging(null)
- const target = pane ?? dockTargetRef.current
- clearDockHighlight()
- if (mode === 'move' && target !== null) {
- onDock(target.getAttribute('data-dsh-pane'))
- } else if (mode === 'move') {
- onMove(geo.x, geo.y)
- } else {
- onResize(geo.w, geo.h)
- }
- }
-
- /** Cancel-path settle (pointercancel / lostpointercapture): the last
- * APPLIED geometry is the user-visible truth — commit it, never roll back
- * (the panel drags' issue-#247 semantics). The mode comes from the ref —
- * the React `dragging` state can still be null when the cancel lands
- * before the pointerdown re-render commits. */
- const abortDrag = (): void => {
- const drag = dragRef.current
- if (drag === null || drag.committed) return
- flushNow()
- finishDrag(drag.mode, drag.applied, null)
- }
-
- const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
- const vw = window.innerWidth
- const vh = window.innerHeight
- return {
- x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
- y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
- w: float.w,
- h: float.h,
- }
- }
-
- const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
- const vw = window.innerWidth
- const vh = window.innerHeight
- return {
- x: float.x,
- y: float.y,
- w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
- h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
- }
- }
-
- return (
-
{ onRaise() }}
- >
-
{
- if (event.button !== 0) return
- // Portaled overlays (the header Menu) live at document.body in the
- // DOM, yet their events still bubble through the React tree into
- // this handler — without the containment guard a menu-row click
- // would start a drag, preventDefault (killing the row's compat
- // mousedown) and capture the pointer (retargeting the release at
- // the header, so the row's click never fires). Buttons inside the
- // header (the close X) need the same exclusion: a capture here
- // swallows their click.
- if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
- if (event.target instanceof Element && event.target.closest('button') !== null) return
- event.preventDefault()
- capturePointer(event.currentTarget, event.pointerId)
- dragRef.current = {
- mode: 'move',
- pointerX: event.clientX,
- pointerY: event.clientY,
- startX: float.x,
- startY: float.y,
- startW: float.w,
- startH: float.h,
- applied: { x: float.x, y: float.y, w: float.w, h: float.h },
- committed: false,
- }
- setDragging('move')
- }}
- onPointerMove={(event) => {
- const drag = dragRef.current
- if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
- const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
- scheduleApply(geo)
- const target = paneAt(event.clientX, event.clientY)
- if (target !== dockTargetRef.current) {
- clearDockHighlight()
- if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
- dockTargetRef.current = target
- }
- }}
- onPointerUp={(event) => {
- if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
- releasePointer(event.currentTarget, event.pointerId)
- flushNow()
- const geo = clampMove(
- dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
- dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
- )
- finishDrag('move', geo, paneAt(event.clientX, event.clientY))
- }}
- onPointerCancel={() => { abortDrag() }}
- onLostPointerCapture={() => { abortDrag() }}
- onContextMenu={(event) => {
- event.preventDefault()
- setMenu({ x: event.clientX, y: event.clientY })
- }}
- >
- {getTabIcon?.(float.tab) ?? null}
- {float.tab.title}
-
-
- {/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the
- column-flex chain (window → .floatContent → .paneTab) is what
- gives the component a definite width via align-stretch — the exact
- sizing contract a tab gets inside a pane. */}
-
{t('settingsOpenToolsTitle')}
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index fcbc034..5907276 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -1,32 +1,28 @@
/**
- * The sidebar shell: panels mounted inside the unified panel host — a
- * fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
- * to document.body — instead of individual fixed-position elements, so a
- * desktop shell's intermediate wrapper transforms can never hijack the
- * panels' fixed containing block (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 sidebar shell: ONE surface — the bottom workbench docked to the
+ * conversation column.
+ *
+ * DSH 0.1.5 owns the right column: its native Sidebar hosts every tab type
+ * this plugin registers (client/native/), so this shell renders no right
+ * panel of its own. What remains plugin-owned is the bottom workbench: it
+ * spans ONLY the AppFrame's center column (the agent output area), from the
+ * app shell's own left sidebar to the details column's left edge, so neither
+ * sidebar gives up any position. Its height drags from its top edge, and the
+ * expand/collapse control is a header button registered into DSH's
+ * `conversation.session.header.utilities` list slot
+ * (sidebar/bottom-toggle.tsx) — the header's right corner belongs to the
+ * native sidebar's own expand control.
+ *
+ * The panel is mounted inside the unified panel host — a fixed,
+ * viewport-sized containing block ([data-dsh-panel-host]) appended to
+ * document.body — instead of a fixed-position element, so a desktop shell's
+ * intermediate wrapper transforms can never hijack its containing block.
+ * The whole layout lives in the per-session store, so switching
+ * conversations swaps the workbench.
*
* The shell binds the workbench actions to the store and dispatches tab
* 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.
- *
- * Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
- * narrow migrates the bottom panel's tabs INTO the right tree
- * (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
- * strips. The right panel becomes a full-width drawer, the bottom panel
- * and its toggle button disappear, and the layout push is disabled (the
- * drawer floats). Widening does not migrate back: the tabs keep living in
- * the right tree.
+ * terminal; editors open from the explorer).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
@@ -35,17 +31,16 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
import {
- BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab,
- isAgentTabId, leafWithTab, migrateBottomTabs,
- moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
- setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
- type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
+ BOTTOM_MIN, CONVERSATION_MIN, agentUuidOf, firstLeaf, isAgentTabId,
+ leafWithTab, moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
+ setBottomHeight, setTabPin, toggleBottomPanel, toggleExpanded,
+ type DropZone, type SidebarStore, type SidebarTab,
} from './state.ts'
import { getPinnedHomeScope } from './pinned.ts'
-import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
+import { IconPanelBottomOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
-import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
-import { layoutPushSize } from './layout-push.ts'
+import { useViewportSize } from './breakpoints.ts'
+import { bottomPushHeight } from './layout-push.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
@@ -54,7 +49,6 @@ import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
-import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
@@ -64,7 +58,7 @@ import css from './sidebar.module.css'
* OS file drags over the sidebar belong to the sidebar, not to the chat:
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
* answers with a full-screen "drop image here" mask plus image intake on
- * drop. Both panel-host render sites swallow the whole event quartet —
+ * drop. The panel host swallows the whole event quartet —
* enter/over/leave/drop — so the region is a black hole to that document
* listener. All four must be stopped: InputBar keeps an enter/leave depth
* counter, and a leave that escapes without its matching enter unbalances
@@ -101,23 +95,8 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
return tag
}
-export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurface?: boolean }) {
+export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
- // With DSH's native right Sidebar owning the right column, this panel keeps
- // only its bottom workbench: the right panel and its toggle are not offered.
- const nativeSurface = props.nativeSurface === true
-
- // Keep the retired right panel collapsed: nothing routes content into it any
- // more, so an open state (restored from a pre-0.1.5 layout, or written by a
- // stale flow) would reserve width for an empty column.
- useEffect(() => {
- if (!nativeSurface) return
- const enforce = (): void => {
- store.reduce(state => (state.panelOpen ? { ...state, panelOpen: false } : state))
- }
- enforce()
- return store.subscribe(enforce)
- }, [nativeSurface, store])
// Copy freshness: re-render the whole tree when the DSH locale switches.
// The module-level t() reads the active locale at call time, so a root
@@ -174,16 +153,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
return service.subscribe(() => setTabsVersion(version => version + 1))
}, [ctx])
- // Narrow (mobile) viewports collapse the two panels into one: the right
- // panel becomes a full-width drawer holding BOTH workbenches, the bottom
- // panel (and its toggle button) disappears, and the layout push is
- // disabled (the drawer floats over the app shell). Entering narrow
- // MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
- // — the merged display is the right sidebar alone, the bottom tabs thrown
- // into its strips. Widening never rewrites the migrated state: the tabs
- // keep living in the right tree.
const viewport = useViewportSize()
- const narrow = isNarrowWidth(viewport.width)
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
// when the visual viewport shrinks below the layout viewport, bottom-
@@ -191,7 +161,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
// the visual viewport is scrolled/zoomed under browser chrome, so
- // omitting it would over-lift the panels (CR #232 P2). offsetTop changes
+ // omitting it would over-lift the panel (CR #232 P2). offsetTop changes
// through the viewport's scroll event too, so both events are listened.
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
// at 0. rAF-throttled, same pattern as useNarrowViewport.
@@ -217,9 +187,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
- // The bottom panel is offset above the on-screen keyboard. Cap its height
- // against that same visible area, not the taller layout viewport, so the
- // conversation keeps PANEL_MIN even on wide touch devices.
+ // The bottom workbench is offset above the on-screen keyboard. Cap its
+ // height against that same visible area, not the taller layout viewport,
+ // so the conversation keeps CONVERSATION_MIN even on wide touch devices.
const layoutViewportHeight = visualViewportHeight ?? viewport.height
// Current conversation (the sessions list feed).
@@ -239,27 +209,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
const state = snapshot.state
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
- const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({
- narrow,
- panelOpen: false,
- bottomOpen,
- width: 0,
- bottomHeight,
- viewportWidth: viewport.width,
- viewportHeight: layoutViewportHeight,
- }).height
-
- // The collapsed toggle cluster reclaims the top-right corner, so the DSH
- // session header's right-aligned utilities (the "Session log" download
- // capsule) must yield. layout.css keys off this body attribute to push the
- // header's right padding out past the cluster. Only the CLOSED panel needs
- // it — an open panel reserves AppFrame padding, moving the header clear.
- const collapsed = state === undefined || !state.panelOpen
- useEffect(() => {
- if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
- else document.body.removeAttribute('data-dsh-sidebar-collapsed')
- return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
- }, [collapsed])
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
// auto — CONSERVATIVE: only the standard Window Controls Overlay
@@ -317,18 +266,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
return () => { for (const tag of tags) tag.remove() }
}, [presetCss, customCss, preset?.id])
- /**
- * Bottom-panel merge on narrow viewports: whenever a session is current
- * while narrow (mount, session switch, or a desktop→narrow transition),
- * throw the bottom tree's tabs into the right tree. Idempotent — after
- * the first migration the bottom tree is empty and the reducer returns
- * the same reference, so this effect settles immediately.
- */
- useEffect(() => {
- if (!narrow || sessionId === undefined) return
- store.reduce(migrateBottomTabs)
- }, [narrow, sessionId, store])
-
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
@@ -345,8 +282,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
- // The + menu options ride a memo so the two Workbenches share ONE array
- // identity across renders that did not change the store (drag state,
+ // The + menu options ride a memo so the workbench does not rebuild the
+ // array identity across renders that did not change the store (drag state,
// viewport resize): fresh arrays per render re-rendered every LeafView's
// + affordance whether or not anything tab-related moved.
const newTabOptions = useMemo(
@@ -364,19 +301,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
// piece the render side consumes (renderTab's onSubagentJump arms it).
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
- // Center-column tracking (sidebar/use-center-column.ts): the bottom panel
- // spans ONLY the app shell's center column ("squeezes the agent output
- // area"); the hook locates the AppFrame's center column DOM (host anchor
- // observers + slow retry), measures its edges into a ref, and writes them
- // straight to the bottom panel element — per-frame tracking without
- // re-rendering the shell (the comments live with the hook now).
+ // Center-column tracking (sidebar/use-center-column.ts): the bottom
+ // workbench spans ONLY the app shell's center column ("squeezes the agent
+ // output area"); the hook locates the AppFrame's center column DOM (host
+ // anchor observers + slow retry), measures its edges into a ref, and
+ // writes them straight to the bottom panel element — per-frame tracking
+ // without re-rendering the shell (the comments live with the hook now).
const bottomRef = useRef(null)
- const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
-
- // Free-window drag-out gesture (sidebar/free-windows.tsx): watches the
- // document for OUR tab drags hovering the conversation column and floats
- // the tab on release. Returns the drop-zone hint geometry for the layer.
- const { floatHint } = useFloatDragout({ narrow, sessionId, store, centerColRef })
+ const { centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
@@ -391,10 +323,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
*/
const bottomWasOpenRef = useRef(undefined)
useEffect(() => {
- // The bottom panel does not exist on narrow viewports (the two
- // workbenches merge into one panel), so the first-expansion auto
- // terminal is a desktop-only behavior.
- if (narrow) return
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
@@ -406,102 +334,74 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' })
- }, [state, store, ctx, narrow])
+ }, [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(null)
- const widthDrag = useRef({ startX: 0, startWidth: 0 })
- const [draggingWidth, setDraggingWidth] = useState(false)
+ // The bottom panel's height drag (top edge strip). Drags write the size
+ // DIRECTLY to the DOM (panel style + the layout CSS variable) instead of
+ // round-tripping the store on every pointer move — a store reduce
+ // re-renders the workbench (terminals, editors…) per move, which is the
+ // visible drag lag. The store is committed once on pointer up
+ // (clamping + persistence).
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
// Pause center-column measurement while dragging, and re-measure once the
// drag settles at its committed size. The store commit lands on release and
- // the final width equals the last drag width, so no ResizeObserver event
+ // the final height equals the last drag height, so no ResizeObserver event
// fires to refresh centerRect — this explicit re-measure covers that gap.
useEffect(() => {
- draggingRef.current = anyDragging
- if (!anyDragging) measureCenter()
+ draggingRef.current = draggingBottom
+ if (!draggingBottom) measureCenter()
// draggingRef is a stable ref object from useCenterColumn (same
- // provenance note as the float effect above).
- }, [anyDragging, measureCenter, draggingRef])
+ // provenance note as the hook's own comments).
+ }, [draggingBottom, measureCenter, draggingRef])
- // 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))
+ // Clamp mirror of setBottomHeight for mid-drag values (the store re-clamps
+ // on commit; this keeps the panel from overshooting mid-drag).
const clampHeight = (height: number): number =>
- Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
+ Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - CONVERSATION_MIN))
- /** Single writer for the layout-push variables: the app shell gives up
- * the panel's width/height while open (0 while collapsed) through
- * layout.css's margins. Every size change — drag frames and committed
- * state — flows through here so the push never forks between paths. */
- const writeGeometry = (width: number, height: number): void => {
- document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
+ /** Single writer for the layout-push variable: the app shell gives up the
+ * workbench's height while it is open (0 while collapsed) through
+ * layout.css's margin on the center column. Every size change — drag
+ * frames and committed state — flows through here so the push never
+ * forks between paths. The right column is DSH's native Sidebar, so no
+ * width is written. */
+ const writeGeometry = (height: number): void => {
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
- // The corner handle positions itself relative to the panel (CSS
- // `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
- // variables are all it needs — no viewport coordinates written here
- // (issue #106: skins that inset the panels must not fight JS coords).
}
- /** Last size a drag actually applied to the DOM (updated by applyDrag).
+ /** Last height a drag actually applied to the DOM (updated by applyDrag).
* When a pointer stream dies without any position info (issue #247: an
* ultra-fast flick whose release events carried no usable coordinates),
* the abort path adopts this instead of rolling back to the pre-drag
* value — the DOM's current size is the only truthful record left. */
- const lastDragSize = useRef<{ width: number; height: number } | null>(null)
+ const lastDragHeight = useRef(null)
- /** 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.
- * The layout push rides the shared writer (writeGeometry). */
- const applyDrag = (width: number, height: number): void => {
- lastDragSize.current = { width, height }
- panelRef.current?.style.setProperty('width', `${width}px`)
+ /** Apply a drag height to the DOM without touching React state or the
+ * store. The layout push rides the shared writer (writeGeometry). */
+ const applyDrag = (height: number): void => {
+ lastDragHeight.current = height
bottomRef.current?.style.setProperty('height', `${height}px`)
- // centerRect.right is the center column's right edge at the committed
- // width (innerWidth - state.width - detailsWidth), so this equals
- // `width + detailsWidth` — derived from the measured column, keeping the
- // drag write-only (no React re-render mid-drag).
- bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
- const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
- // The pushed width must ride the same gate as the committed push effect
- // (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is
- // the only drag reachable with the panel closed — writing the panel's
- // persisted width preference here squeezed #root mid-drag, dropped the
- // host viewport across its 1024px auto-collapse breakpoint, and the
- // native left sidebar snapped to its 56px rail (and back on release).
- const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0
- writeGeometry(pushWidth, bottomPush)
+ const push = state?.bottomOpen === true ? height + keyboardInset : 0
+ writeGeometry(push)
}
// 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
+ // (the layout push) plus the panel — batching to one write per frame is
// what keeps the drag smooth. The store is still committed once on release.
const dragFrame = useRef(null)
- const pendingDrag = useRef<{ width: number; height: number } | null>(null)
- const scheduleDrag = (width: number, height: number): void => {
- pendingDrag.current = { width, height }
+ const pendingDrag = useRef(null)
+ const scheduleDrag = (height: number): void => {
+ pendingDrag.current = 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)
+ applyDrag(pending)
}
})
}
@@ -517,27 +417,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
}
/**
- * Finalize a drag on pointer up: flush the LAST drag frame to the DOM
- * synchronously, then commit the SAME clamped values to the store. A fast
+ * Finalize the drag on pointer up: flush the LAST drag frame to the DOM
+ * synchronously, then commit the SAME clamped value to the store. A fast
* release cancels the rAF before it ran — without the flush the DOM would
* sit at the pre-drag size until React re-renders with the committed
* value, and a value that never made it into a move handler would never
* be applied at all. The measurement pause ends here too: the center
* column is re-measured BEFORE the committed re-render lands, so the
- * bottom panel's React-rendered right edge already reflects the new
- * width (otherwise the re-render would re-apply the stale rect — the
- * bottom panel visibly jumps for one frame).
+ * panel's React-rendered edges already reflect the new height.
*/
- const commitDrag = (
- width: number,
- height: number,
- reduce: (state: SidebarState) => SidebarState,
- ): void => {
+ const commitDrag = (height: number): void => {
stopDragScheduling()
- applyDrag(width, height)
+ applyDrag(height)
draggingRef.current = false
measureCenter()
- store.reduce(reduce)
+ store.reduce(s => setBottomHeight(s, height))
}
/** Set once a drag's pointerup handler commits — premature capture loss
@@ -548,8 +442,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
* Abort a drag whose pointer stream was interrupted (pointercancel, or
* capture lost before pointerup): no pointerup will arrive, so without
* this the dragging state would stick true and center-column measurement
- * would stay paused forever — the bottom panel freezes at stale edges and
- * stops tracking sidebar/app-rail layout changes.
+ * would stay paused forever — the panel freezes at stale edges and stops
+ * tracking layout changes.
*
* A FAST release is the common trigger: browsers merge pointermove bursts,
* and an ultra-fast flick can cancel the stream before ANY move lands.
@@ -558,121 +452,78 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
* position (only pointercancel is trusted to carry coordinates —
* lostpointercapture's coordinates are not guaranteed, so the handlers
* pass the event only from pointercancel), and finally the size the drag
- * last APPLIED to the DOM (lastDragSize). A drag that produced none of
- * those (pure down+up at the same spot) commits the store's own sizes —
- * a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
- * an interrupted fast flick; the abort path added in the unified-host
- * refactor did, and that regression is what this ordering removes).
+ * last APPLIED to the DOM (lastDragHeight). A drag that produced none of
+ * those (pure down+up at the same spot) commits the store's own height —
+ * a no-op, never an explicit rollback (issue #247).
*
* Every commit path marks the drag committed, so the interrupt
* double-fire (pointercancel → lostpointercapture) cannot commit once
* and then roll the same drag back.
*/
- const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
+ const abortDrag = (reset: () => void, event?: { clientY: number }): void => {
if (dragCommitted.current) return
const pending = pendingDrag.current
- let width: number | undefined
let height: number | undefined
if (pending !== null) {
- width = pending.width
- height = pending.height
+ height = pending
} else if (event !== undefined) {
// No move ever landed: the cancel position is all we have — commit it
// (clamped) instead of rolling back the flick.
- if (draggingWidth) {
- width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
- height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0)
- } else if (draggingBottom) {
- width = Math.min(state?.width ?? 0, window.innerWidth)
- height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)))
- } else if (draggingCorner) {
- width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
- height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)))
- }
+ height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
}
- if (width !== undefined && height !== undefined) {
- dragCommitted.current = true
- pendingDrag.current = null
- if (dragFrame.current !== null) {
- cancelAnimationFrame(dragFrame.current)
- dragFrame.current = null
- }
- applyDrag(width, height)
- draggingRef.current = false
- measureCenter()
- store.reduce(s => setBottomHeight(setWidth(s, width), height))
- } else {
- // No pending write and no usable event coordinates: keep the size the
- // drag last applied instead of rolling back to the pre-drag value
- // (the flick's moves may have been consumed by the rAF just before
- // the stream died — the DOM already shows the dragged size). Clamp to
- // the current geometry like the layout-push effect (closed/narrow
- // panels are written 0 by the push).
+ if (height !== undefined) {
dragCommitted.current = true
stopDragScheduling()
- const last = lastDragSize.current
- const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({
- narrow,
- panelOpen: state?.panelOpen === true,
- bottomOpen: state?.bottomOpen === true,
- width: last?.width ?? state?.width ?? 0,
- bottomHeight: last?.height ?? state?.bottomHeight ?? 0,
- viewportWidth: viewport.width,
- viewportHeight: layoutViewportHeight,
- })
- applyDrag(adoptedWidth, adoptedHeight)
+ applyDrag(height)
draggingRef.current = false
measureCenter()
- store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
+ store.reduce(s => setBottomHeight(s, height))
+ } else {
+ // No pending write and no usable event coordinates: keep the height the
+ // drag last applied instead of rolling back to the pre-drag value (the
+ // flick's moves may have been consumed by the rAF just before the
+ // stream died — the DOM already shows the dragged size).
+ dragCommitted.current = true
+ stopDragScheduling()
+ const adopted = clampHeight(lastDragHeight.current ?? state?.bottomHeight ?? BOTTOM_MIN)
+ applyDrag(adopted)
+ draggingRef.current = false
+ measureCenter()
+ store.reduce(s => setBottomHeight(s, adopted))
}
reset()
}
- // 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.
- // On NARROW viewports the drawer FLOATS over the app shell — no push, the
- // conversation keeps the full width behind the drawer.
+ // Layout push: the app shell gives up the workbench's height 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 height (e.g. fullscreen on a bigger window) can never crush the
+ // app shell to zero. Dragging disables the layout transition.
useEffect(() => {
- const { width, height } = layoutPushSize({
- narrow,
- panelOpen: snapshot.state?.panelOpen === true,
- bottomOpen: snapshot.state?.bottomOpen === true,
- width: snapshot.state?.width ?? 0,
- bottomHeight: snapshot.state?.bottomHeight ?? 0,
- viewportWidth: viewport.width,
+ const height = bottomPushHeight({
+ open: snapshot.state?.bottomOpen === true,
+ height: snapshot.state?.bottomHeight ?? 0,
viewportHeight: layoutViewportHeight,
})
- const bottomPush = !narrow && snapshot.state?.bottomOpen === true
- ? height + keyboardInset
- : 0
- writeGeometry(width, bottomPush)
- }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset])
+ writeGeometry(snapshot.state?.bottomOpen === true ? height + keyboardInset : 0)
+ }, [snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, layoutViewportHeight, keyboardInset])
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
- // HMR), the CSS variables would otherwise stay on and layout.css
+ // HMR), the CSS variable would otherwise stay on and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
// in the push effect's cleanup: React can yield between a passive
- // effect's cleanup and setup phases, and removing the variables on a
- // dependency change used to paint the push-less layout for a frame (the
- // center column went full width) while the re-add restarted the margin
- // transition — the bottom panel flashed full width after every width
- // drag (issue #258). Keeping the variables continuously valid while
- // mounted makes the push invisible to mid-flush style recals.
+ // effect's cleanup and setup phases, and removing the variable on a
+ // dependency change used to paint the push-less layout for a frame.
useEffect(() => {
return () => {
- document.documentElement.style.removeProperty('--dsh-sidebar-width')
document.documentElement.style.removeProperty('--dsh-sidebar-height')
}
}, [])
useEffect(() => {
- if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
+ if (draggingBottom) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
- }, [anyDragging])
-
+ }, [draggingBottom])
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
@@ -684,12 +535,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
- // Terminal tabs may live in EITHER tree (the bottom panel hosts them
- // too) — the pty-release lookup covers both, or the HTTP fallback is
- // skipped for a bottom-panel terminal whose WS frame never arrived.
- const leaf = current === undefined
- ? undefined
- : leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId)
+ const leaf = current === undefined ? undefined : leafWithTab(current.bottomSplits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
// Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose); the session
@@ -705,9 +551,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
}
},
activateTab: (paneId, tabId) => {
- // Route through the service: same reducer (finds the pane in EITHER
- // tree, sets the active pane) and fires descriptor.onActivate; the
- // session scope (with its cwd) rides to the callback.
+ // Route through the service: same reducer (finds the pane, sets the
+ // active pane) and fires descriptor.onActivate; the session scope
+ // (with its cwd) rides to the callback.
ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
@@ -717,7 +563,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
store.reduce((s) => {
let index = -1
- const source = leafWithTab(s.splits, beforeTabId)
+ const source = leafWithTab(s.bottomSplits, beforeTabId)
if (source !== undefined && source.id === toPane) {
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
}
@@ -727,17 +573,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
- // The tab context menu's "move to free window": no drop point exists, so
- // the window is born over the conversation column's center (the user's
- // focus area; clamped into the viewport by the reducer) — the same
- // landing the drag-out gesture produces.
- floatTab: (tabId) => {
- const col = centerColRef.current
- const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
- const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
- const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
- store.reduce(s => floatTab(s, tabId, x, y))
- },
// Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
// pin time so a workspace-scoped pin only resurfaces in sessions whose
// cwd matches. Unpin passes null — the tab stays open in its home
@@ -745,10 +580,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
pinTab: (tabId, scope) => {
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
},
- }), [store, sessionId, cwd, ctx, centerColRef])
+ }), [store, sessionId, cwd, ctx])
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
- // tabs inject into the right panel's first leaf, and the actions are
+ // tabs inject into the bottom workbench's first leaf, and the actions are
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
// revision bump).
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
@@ -770,59 +605,30 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
- // Keep the unavailable controls focusable: touch users have no hover, so
- // focus is the only way the existing Tooltip can explain what is missing.
- return (
-
-
- {!narrow && (
-
-
-
- )}
-
-
-
-
-
- )
+ // No conversation yet: the host stays mounted (the drag shield keeps
+ // covering the region) but nothing is rendered — the toggle button lives
+ // in DSH's session header, which does not exist without a session.
+ return
}
- const bottomPanelHeight = layoutPushSize({
- narrow,
- panelOpen: state.panelOpen,
- // Keep the hidden panel's geometry ready for its slide-in transition;
- // only the layout push itself becomes zero while it is closed.
- bottomOpen: true,
- width: state.width,
- bottomHeight: state.bottomHeight,
- viewportWidth: viewport.width,
+ const bottomPanelHeight = bottomPushHeight({
+ open: true,
+ height: state.bottomHeight,
viewportHeight: layoutViewportHeight,
- }).height
+ })
- const onNewTab = (optionId: string, target: 'right' | 'bottom' = 'right'): void => {
+ const onNewTab = (optionId: string): void => {
const service = ctx.get('betterSidebar')
const descriptor = service?.getTab(optionId)
if (service === undefined || descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
- // The session scope rides along: lifecycle callbacks receive it (and
- // the open stays in the current session, as before). The bottom panel's
- // own + menu passes 'bottom' so the tab lands in THIS workbench, not in
- // the native right Sidebar.
- service.openTab({ type: optionId, title, target }, { sessionId, cwd })
+ // The session scope rides along: lifecycle callbacks receive it. The
+ // open is bottom-targeted so the tab lands in THIS workbench, not in the
+ // native right Sidebar.
+ service.openTab({ type: optionId, title, target: 'bottom' }, { sessionId, cwd })
}
- /**
- * The explorer's @-reference button: append `@` to the
- * session's composer draft (space-separated). Resolves the session-scope
- * ctx and the conversation input service at click time; a missing service
- * or scope degrades to a logged no-op, never a crash.
- */
- /** The tab icon from the tab-type registry (shared by every workbench). */
+ /** The tab icon from the tab-type registry. */
const tabIconOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) return null
@@ -856,13 +662,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
- // `placement` decides the visibility contract handed to the tab component:
- // pane tabs are visible while their panel is open and they are active, but
- // a free window is its own surface — its tab stays visible no matter what
- // the panels do (the AGENTS §7.5 contract; plugin components honor
- // `visible` to pause work, so tying floats to panelOpen would blank them
- // the moment the sidebar collapses).
- const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => {
+ const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => {
// Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
// TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
// the descriptor component receives the ORIGINAL tab id (the virtual id
@@ -882,13 +682,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
- visible={
- placement === 'float'
- ? true
- : placement === 'bottom'
- ? state.bottomOpen && active
- : state.panelOpen && active
- }
+ visible={state.bottomOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
@@ -900,184 +694,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
return (
{/*
- 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.
+ The bottom 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 details column's left edge — neither sidebar gives up any
+ position. Its resize strip is the top edge; hidden by sliding down.
+ It only becomes VISIBLE once the center column is measured: before
+ that, `centerRect` is the {0,0} fallback and `right` computes to the
+ full viewport width — the panel (and its overflow content) would
+ flash full-width for a frame until the first measurement lands.
+ Rendering stays unconditional so the mount/render chain (auto-terminal
+ etc.) is never gated on geometry.
*/}
-
- {/*
- Narrow viewports merge the two workbenches into the one drawer —
- there is no bottom panel, so its toggle button is not offered.
- */}
- {!narrow && (
-
-
-
- )}
- {!nativeSurface && (
-
-
-
- )}
-
- {/*
- 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".
- On NARROW viewports it is a full-width drawer holding both
- workbenches (see MobileWorkbench); the width drag strip is not
- offered there — a full-screen sheet has nothing to drag.
- */}
-
{
- event.preventDefault()
- event.currentTarget.setPointerCapture(event.pointerId)
- dragCommitted.current = false
- widthDrag.current = { startX: event.clientX, startWidth: state.width }
- setDraggingWidth(true)
- }}
- onPointerMove={(event) => {
- if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
- const { startX, startWidth } = widthDrag.current
- const width = clampWidth(startWidth + (startX - event.clientX))
- const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
- scheduleDrag(width, height)
- }}
- onPointerUp={(event) => {
- if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
- if (dragCommitted.current) return
- dragCommitted.current = true
- event.currentTarget.releasePointerCapture(event.pointerId)
- const { startX, startWidth } = widthDrag.current
- // The up position is the pointer's FINAL position — a fast
- // flick's tail is coalesced into the up event, so the last
- // pointermove (the rAF pending value) can be stale. Commit
- // from the up position (v0.13.1 semantics; issue #247).
- const width = clampWidth(startWidth + (startX - event.clientX))
- const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
- commitDrag(width, height, s => setWidth(s, width))
- setDraggingWidth(false)
- }}
- onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
- onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
- />
- )}
-
-
-
- {/*
- 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. Rendered INSIDE the right panel and positioned by CSS
- relative to it (left edge + the bottom panel's height via the
- --dsh-sidebar-height layout variable) — no JS-written viewport
- coordinates to keep in sync. (Never on narrow viewports: the
- bottom panel does not exist there.)
- */}
- {!narrow && state.panelOpen && state.bottomOpen && (
-
- {/*
- 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. On NARROW viewports it does not exist —
- the bottom workbench lives inside the drawer (MobileWorkbench).
- */}
- {/* The bottom panel only becomes VISIBLE once the center column is
- measured: before that, `centerRect` is the {0,0} fallback and
- `right` computes to the full viewport width — the panel (and its
- overflow content) would flash full-width for a frame until the
- first measurement lands. Rendering stays unconditional so the
- mount/render chain (auto-terminal etc.) is never gated on
- geometry. */}
- {!narrow && (
0 ? `${keyboardInset}px` : undefined,
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
- // details column's left edge (the details column sits between the
- // center and the right panel, and the right panel's reserved frame
- // padding is already baked into centerRect.right).
+ // details column's left edge.
right: window.innerWidth - centerRectRef.current.right,
- // 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,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerMeasured ? undefined : 'hidden',
}}
-
- data-dragging={(draggingBottom || draggingCorner) || undefined}
+ data-dragging={draggingBottom || undefined}
>
{
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
@@ -1119,8 +738,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startY, startHeight } = bottomDrag.current
- const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
- scheduleDrag(Math.min(state.width, window.innerWidth), height)
+ scheduleDrag(clampHeight(startHeight + (startY - event.clientY)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
@@ -1128,10 +746,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
- // Up position wins over the rAF pending value (see the width
- // strip handler — issue #247).
- const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
- commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
+ // Up position wins over the rAF pending value (see the abortDrag
+ // comment — issue #247).
+ commitDrag(clampHeight(startHeight + (startY - event.clientY)))
setDraggingBottom(false)
}}
onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
@@ -1155,33 +772,43 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurfac
- )}
- {/*
- Free windows + the drag-out hint: the layer renders the floats (the
- array's order is the stacking order) reusing the regular tab
- renderer, followed by the dashed drop-zone overlay (sidebar/
- free-windows.tsx renders both as one fragment — DOM unchanged).
- */}
-
)
}
+
+/** The header control that expands/collapses the bottom workbench (see
+ * sidebar/bottom-toggle.tsx — registered into DSH's session header). */
+export function BottomDockToggle(props: { store: SidebarStore }) {
+ const { store } = props
+ const snapshot = useSyncExternalStore(
+ useCallback((callback: () => void) => store.subscribe(callback), [store]),
+ useCallback(() => store.getSnapshot(), [store]),
+ )
+ const open = snapshot.state?.bottomOpen === true
+ const label = open ? t('collapseBottomPanel') : t('expandBottomPanel')
+ return (
+
+
+
+ )
+}
diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx
index bbffc13..7d5ce44 100644
--- a/src/client/TabBar.tsx
+++ b/src/client/TabBar.tsx
@@ -4,9 +4,8 @@
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane. Right-clicking a
- * tab opens the tab context menu (float as a free window / close / close
- * others / close to the left / close to the right, the close ones scoped to
- * this pane).
+ * tab opens the tab context menu (close / close others / close to the left /
+ * close to the right, the close ones scoped to this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -68,9 +67,6 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
- /** Float a tab out as a free window (the tab context menu's entry; the
- * drag-to-conversation gesture is handled at the Sidebar shell level). */
- onFloatTab: (tabId: string) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or
* `'global'` to pin (the shell snapshots the home cwd), or `null` to
@@ -85,7 +81,7 @@ export function TabBar(props: {
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
- paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, onPinTab, getTabIcon, getTabBadge,
+ paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onPinTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
@@ -298,9 +294,8 @@ export function TabBar(props: {
// The target tab drives the pin entry's shape: terminal tabs
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
// row (pinned). Non-terminal tabs and missing onPinTab get no
- // pin entry at all — the menu stays exactly the legacy 5-item
- // shape. Pinned VIRTUAL tabs (injected from other sessions)
- // get a stripped menu: only Unpin + Close (no float, no
+ // pin entry at all. Pinned VIRTUAL tabs (injected from other
+ // sessions) get a stripped menu: only Unpin + Close (no
// close-others/left/right — those are pane-scoped operations
// that don't apply to cross-session virtual tabs).
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
@@ -325,7 +320,6 @@ export function TabBar(props: {
]
}
return [
- { id: 'float', label: t('moveToFreeWindow') },
...pinEntries,
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
@@ -339,9 +333,7 @@ export function TabBar(props: {
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
- if (id === 'float') {
- onFloatTab(target.tabId)
- } else if (id === 'pinWorkspace') {
+ if (id === 'pinWorkspace') {
onPinTab?.(target.tabId, 'workspace')
} else if (id === 'pinGlobal') {
onPinTab?.(target.tabId, 'global')
diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx
index 1a73d66..2bc5fc5 100644
--- a/src/client/builtins/tabs.tsx
+++ b/src/client/builtins/tabs.tsx
@@ -22,7 +22,7 @@ import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
-import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx'
+import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -72,7 +72,7 @@ function terminalUuid(): string {
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
- return allLeaves(state.splits)
+ return allLeaves(state.bottomSplits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
@@ -155,31 +155,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
const count = opCountOf(scope.sessionId)
return count === undefined || count === 0 ? null : count
},
- // Declarative settings: the diff-open picker (free window vs docked
- // pane) renders as an iconed select row under the changes card's gear
- // in the Side card settings page.
- settings: {
- toggles: [{
- key: 'changesDiffFloat',
- type: 'select',
- title: () => t('changesDiffOpenTitle'),
- desc: () => t('changesDiffOpenDesc'),
- options: [
- {
- value: true,
- icon: (size: number) => ,
- title: () => t('changesDiffOpenFloat'),
- desc: () => t('changesDiffOpenFloatDesc'),
- },
- {
- value: false,
- icon: (size: number) => ,
- title: () => t('changesDiffOpenPane'),
- desc: () => t('changesDiffOpenPaneDesc'),
- },
- ],
- }],
- },
component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => (
{
if (preview?.kind !== 'git') return
- const diffTab = diffTabOf(preview.ref)
- onOpenDiff?.(diffTab)
- if (store.getPrefs().changesDiffFloat !== false) {
- const x = Math.round(window.innerWidth / 2)
- const y = Math.round(window.innerHeight / 2)
- store.reduce(state => floatTab(state, diffTab.id, x, y))
- }
+ onOpenDiff?.(diffTabOf(preview.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
diff --git a/src/client/icons.tsx b/src/client/icons.tsx
index bd728f6..3ea06b2 100644
--- a/src/client/icons.tsx
+++ b/src/client/icons.tsx
@@ -1,24 +1,12 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
- * 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
+ * icon library has none), a diff glyph, and the bottom-workbench toggle.
+ * 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) => (
-
-)
-
/**
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
* along its BOTTOM edge, in the app's outline style.
@@ -161,15 +149,3 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
)
-
-/**
- * Free-window glyph in the app's outline style (1.5px stroke, currentColor):
- * a background frame with a detached rounded mini-window floating over its
- * top-right — the changes tab's "diff opens as a free window" setting.
- */
-export const IconFloatWindowOutline16 = ({ size = 16, className }: IconProps) => (
-
-)
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 48e8ae5..d5c82a3 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -20,6 +20,7 @@ import { RenderBoundary } from './RenderBoundary.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { createNativeTabRecords } from './native/tab-adapter.tsx'
import { registerNativeSurface } from './native/index.ts'
+import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
import { createNativeSurface } from './native/surface.ts'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
@@ -151,6 +152,12 @@ export function apply(ctx: Context): void {
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
'dsh-better-sidebar: native right-Sidebar registrations',
)
+ // The bottom workbench's expand/collapse button in DSH's session header
+ // (the header's corner seat belongs to the native sidebar's own control).
+ ctx.effect(
+ () => registerBottomToggle(ctx, sidebarStore),
+ 'dsh-better-sidebar: bottom-workbench toggle',
+ )
ctx.effect(
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
'dsh-better-sidebar: native right-Sidebar surface',
@@ -166,8 +173,7 @@ export function apply(ctx: Context): void {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
- const tabs = allLeaves(snapshot.state.splits)
- .concat(allLeaves(snapshot.state.bottomSplits))
+ const tabs = allLeaves(snapshot.state.bottomSplits)
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
@@ -302,7 +308,7 @@ export function apply(ctx: Context): void {
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
- root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore, nativeSurface: true })))
+ root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
mounted = true
guardAnchor()
scheduleHostCheck()
diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx
index 7f04b00..b4f130b 100644
--- a/src/client/intercept.tsx
+++ b/src/client/intercept.tsx
@@ -8,7 +8,7 @@
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
-import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
+import { revealPaths, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
@@ -26,9 +26,8 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
/**
* Reveal the produced files in the sidebar explorer: expand their parent
- * directories, highlight the rows, and focus the explorer tab (expanding the
- * hosting panel when it is collapsed). Unknown files fall back to revealing
- * the workspace root itself.
+ * directories, highlight the rows, and focus the explorer tab. Unknown
+ * files fall back to revealing the workspace root itself.
*/
export function revealInExplorer(
ctx: Context,
@@ -45,15 +44,6 @@ export function revealInExplorer(
? files.map(path => resolveSidebarPath(cwd, path))
: cwd === undefined ? [] : [cwd]
store.reduce(state => revealPaths(state, cwd, targets))
- // A type-only open never auto-expands the panel (only content opens do,
- // see service.openTab) — so a reveal opens the panel itself when it is
- // collapsed, exactly like the subagent auto-open flows, or the highlight
- // would be set on an invisible panel.
- store.reduce(s => (s.panelOpen ? s : togglePanel(s)))
- // Pin the landing to the right panel: the files window 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 }))
// Focus the single-instance editor home tab (the files window) where the
// reveal highlight renders. Read via ctx.get like every other internal
// consumer (#357): the provider is not on this fiber chain, so a direct
diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts
index 8b53cb2..b6b88b9 100644
--- a/src/client/layout-push.ts
+++ b/src/client/layout-push.ts
@@ -1,37 +1,28 @@
/**
- * Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`.
- * The conversation column (output + composer) must keep at least
- * {@link PANEL_MIN} of the viewport after the bottom panel claims height.
+ * Size written to `--dsh-sidebar-height`. The conversation column (output +
+ * composer) must keep at least {@link CONVERSATION_MIN} of the viewport after
+ * the bottom workbench claims height; a closed workbench pushes 0. The right
+ * column belongs to DSH's native Sidebar, so this shell pushes no width.
*/
-import { PANEL_MIN } from './state.ts'
+import { CONVERSATION_MIN } from './state.ts'
-export interface LayoutPushInput {
- narrow: boolean
- panelOpen: boolean
- bottomOpen: boolean
- width: number
- bottomHeight: number
- viewportWidth: number
- viewportHeight: number
-}
-
-export interface LayoutPushSize {
- width: number
+export interface BottomPushInput {
+ /** Whether the bottom workbench is expanded. */
+ open: boolean
+ /** Committed (or mid-drag) bottom height in px. */
height: number
+ /** Viewport height the cap is measured against (visual viewport when known). */
+ viewportHeight: number
}
function finiteNonNegative(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0
}
-/** Compute the live layout-push size. Narrow drawers float and push 0. */
-export function layoutPushSize(input: LayoutPushInput): LayoutPushSize {
- if (input.narrow) return { width: 0, height: 0 }
- const viewportWidth = finiteNonNegative(input.viewportWidth)
+/** Compute the live layout-push height for the bottom workbench. */
+export function bottomPushHeight(input: BottomPushInput): number {
+ if (!input.open) return 0
const viewportHeight = finiteNonNegative(input.viewportHeight)
- const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight))
- return {
- width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0,
- height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0,
- }
+ const maxHeight = Math.max(0, viewportHeight - Math.min(CONVERSATION_MIN, viewportHeight))
+ return Math.min(finiteNonNegative(input.height), maxHeight)
}
diff --git a/src/client/layout.css b/src/client/layout.css
index f497840..4cfd00f 100644
--- a/src/client/layout.css
+++ b/src/client/layout.css
@@ -1,44 +1,19 @@
/**
- * Layout push: an open panel occupies layout instead of floating over it.
- * The right panel reserves padding inside the AppFrame three-column grid,
- * so the app sidebar, conversation, and details column all remain to its
- * left while the frame's border-box stays viewport-wide. Harness measures
- * that border-box to choose desktop versus narrow presentation; preserving
- * it prevents a desktop window from entering narrow mode merely because the
- * plugin panel opened.
+ * Layout push: the bottom workbench occupies layout instead of floating over
+ * the conversation. The right column belongs to DSH's native Sidebar, so this
+ * stylesheet pushes no width — only the workbench's height.
*
- * AppFrame positions its details drag handle from the measured border-box.
- * Move that handle left by the same reserved width so it stays on the details
- * column edge. The ordinary sidebar handle remains at its native coordinate.
- *
- * The bottom panel squeezes only the conversation column. The anchor is the
+ * The workbench squeezes only the conversation column. The anchor is the
* [data-dsh-center-col] tag the Sidebar shell's locator writes on the
* frame's measured center grid item (Sidebar.tsx locate — not a positional
* path: the frame also contains an overlay layer and drag handles). A
* stretched grid item shrinks by its bottom margin, so the conversation
* output and composer lift without touching either sidebar.
*
- * Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed).
- * Expand/collapse uses the host theme duration; drags disable transitions so
- * every reserved edge tracks the pointer.
+ * The size rides a CSS variable updated by the Sidebar shell (0 while
+ * collapsed). Expand/collapse uses the host theme duration; drags disable
+ * transitions so the reserved edge tracks the pointer.
*/
-/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same
- AppFrame only as the root slot's direct child. */
-#root [data-dsh-frame],
-#root > [data-slot="root"] > div {
- box-sizing: border-box;
- padding-right: var(--dsh-sidebar-width, 0px);
- transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
-}
-
-#root [data-dsh-frame] > [data-side="details"],
-#root > [data-slot="root"] > div > [data-side="details"] {
- transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px)));
- transition:
- left var(--ds-transition-duration-slow) var(--ds-ease-in-out),
- transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
-}
-
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
@@ -73,13 +48,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
padding-right: 78px;
}
-body[data-dsh-sidebar-dragging] #root [data-dsh-frame],
-body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div,
-body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"],
-body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"],
-body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
-#root [data-dsh-frame][data-dragging] > [data-side="details"],
-#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] {
+body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
transition: none;
}
@@ -103,10 +72,6 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
}
@media (prefers-reduced-motion: reduce) {
- #root [data-dsh-frame],
- #root > [data-slot="root"] > div,
- #root [data-dsh-frame] > [data-side="details"],
- #root > [data-slot="root"] > div > [data-side="details"],
#root [data-dsh-center-col] {
transition: none;
}
@@ -117,10 +82,10 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
* The ui-primitives Menu clamps its main portal list into the viewport but
* positions nested submenus with static CSS only — anchored at the parent
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
- * Docked on the window's right edge, both defaults are the worst case for
- * this panel: a tall "open with" submenu escapes past the viewport top when
- * the row menu opens near the top, and past the right edge on narrow panels
- * (PANEL_MIN 280 < card 218 + gap + submenu 165).
+ * Both defaults are the worst case for our menus: a tall "open with" submenu
+ * escapes past the viewport top when the row menu opens near the top, and
+ * past the right edge in a narrow workbench pane (a pane can be far narrower
+ * than the card 218 + gap + submenu 165 a submenu needs).
*
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on — with the
* words "down" and/or "left" — for exactly the lifetime of one of OUR
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index e8f1c6c..e9ae36c 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -83,7 +83,6 @@ export const ar: Record = {
closeOtherTabs: 'إغلاق التبويبات الأخرى',
closeLeftTabs: 'إغلاق التبويبات على اليسار',
closeRightTabs: 'إغلاق التبويبات على اليمين',
- moveToFreeWindow: 'النقل إلى نافذة حرة',
floatDropHint: 'حرّك للفتح في نافذة حرة',
dockToSidebar: 'العودة إلى الشريط الجانبي',
pinTerminal: 'تثبيت الطرفية',
@@ -98,8 +97,6 @@ export const ar: Record = {
pinnedTerminalScopeGlobal: 'مثبتة عام',
pinnedRailLabel: 'الطرفيات المثبتة',
closePinnedTerminal: 'إغلاق الطرفية',
- collapse: 'طي الشريط الجانبي',
- expand: 'توسيع الشريط الجانبي',
collapseBottomPanel: 'طي اللوحة السفلية',
expandBottomPanel: 'توسيع اللوحة السفلية',
terminalError: 'فشل اتصال الطرفية',
@@ -155,12 +152,6 @@ export const ar: Record = {
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
changesClosePreview: 'إغلاق المعاينة',
changesResizePreview: 'تغيير حجم المعاينة',
- changesDiffOpenTitle: 'يفتح الفرق كـ',
- changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
- changesDiffOpenFloat: 'نافذة عائمة',
- changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
- changesDiffOpenPane: 'لوحة',
- changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
staged: 'مُدرج',
unstaged: 'غير مُدرج',
@@ -234,11 +225,6 @@ export const ar: Record = {
settingsIntro: 'إدارة ما تعرضه البطاقة الجانبية وكيف تتصرف',
settingsPopupDesc: 'تكوين خيارات ذات صلة بـ {feature}',
settingsDone: 'تم',
- settingsOpenTitle: 'فتح افتراضياً للمحادثات الجديدة',
- settingsOpenDesc: 'توسيع البطاقة الجانبية تلقائياً للمحادثات الجديدة تماماً؛ المحادثات الموجودة تحتفظ بتخطيطاتها',
- settingsWidthTitle: 'الحصة الافتراضية للعرض',
- settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
settingsTitleBarTitle: 'وضع توافق الموضع',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index 881dcb0..d901a5b 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -68,7 +68,6 @@ export const de: Record = {
closeOtherTabs: 'Andere Tabs schließen',
closeLeftTabs: 'Tabs links schließen',
closeRightTabs: 'Tabs rechts schließen',
- moveToFreeWindow: 'In freies Fenster verschieben',
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
dockToSidebar: 'Zurück zur Seitenleiste',
pinTerminal: 'Terminal anheften',
@@ -83,8 +82,6 @@ export const de: Record = {
pinnedTerminalScopeGlobal: 'Global angeheftet',
pinnedRailLabel: 'Angeheftete Terminals',
closePinnedTerminal: 'Terminal schließen',
- collapse: 'Seitenleiste einklappen',
- expand: 'Seitenleiste ausklappen',
collapseBottomPanel: 'Unteres Panel einklappen',
expandBottomPanel: 'Unteres Panel ausklappen',
terminalError: 'Terminalverbindung fehlgeschlagen',
@@ -140,12 +137,6 @@ export const de: Record = {
changesOpenDiffTab: 'In eigenem Tab öffnen',
changesClosePreview: 'Vorschau schließen',
changesResizePreview: 'Vorschauhöhe anpassen',
- changesDiffOpenTitle: 'Diff öffnet als',
- changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert',
- changesDiffOpenFloat: 'Freies Fenster',
- changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten',
- changesDiffOpenPane: 'Bereich',
- changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)',
changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar',
staged: 'Gestaged',
unstaged: 'Nicht gestaged',
@@ -219,11 +210,6 @@ export const de: Record = {
settingsIntro: 'Verwalten Sie, was die Seitenkarte anzeigt und wie sie sich verhält',
settingsPopupDesc: 'Optionen für „{feature}“ konfigurieren',
settingsDone: 'Fertig',
- settingsOpenTitle: 'Für neue Unterhaltungen standardmäßig geöffnet',
- settingsOpenDesc: 'Die Seitenkarte wird für brandneue Unterhaltungen automatisch ausgeklappt; bestehende Unterhaltungen behalten ihr eigenes Layout',
- settingsWidthTitle: 'Standardbreite',
- settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index 1904063..a7a0a8f 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -75,7 +75,6 @@ export const fr: Record = {
closeOtherTabs: 'Fermer les autres onglets',
closeLeftTabs: 'Fermer les onglets à gauche',
closeRightTabs: 'Fermer les onglets à droite',
- moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
dockToSidebar: 'Revenir à la barre latérale',
pinTerminal: 'Épingler le terminal',
@@ -90,8 +89,6 @@ export const fr: Record = {
pinnedTerminalScopeGlobal: 'Épinglé globalement',
pinnedRailLabel: 'Terminaux épinglés',
closePinnedTerminal: 'Fermer le terminal',
- collapse: 'Réduire la barre latérale',
- expand: 'Déployer la barre latérale',
collapseBottomPanel: 'Réduire le panneau inférieur',
expandBottomPanel: 'Déployer le panneau inférieur',
terminalError: 'Échec de la connexion au terminal',
@@ -147,12 +144,6 @@ export const fr: Record = {
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
changesClosePreview: 'Fermer l\'aperçu',
changesResizePreview: 'Redimensionner l\'aperçu',
- changesDiffOpenTitle: 'Le diff s\'ouvre en',
- changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff',
- changesDiffOpenFloat: 'Fenêtre flottante',
- changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan',
- changesDiffOpenPane: 'Panneau',
- changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)',
changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles',
staged: 'En scène',
unstaged: 'Hors scène',
@@ -226,11 +217,6 @@ export const fr: Record = {
settingsIntro: 'Gérer le contenu affiché par la carte latérale et son comportement par défaut',
settingsPopupDesc: 'Configurer les options liées à « {feature} »',
settingsDone: 'Terminé',
- settingsOpenTitle: 'Ouvert par défaut pour les nouvelles sessions',
- settingsOpenDesc: 'Déployer automatiquement la carte latérale lors de la création d’une session ; les sessions existantes conservent chacune leur disposition',
- settingsWidthTitle: 'Part de largeur par défaut',
- settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
settingsTitleBarTitle: 'Mode de compatibilité de position',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index bee6300..7703a95 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -82,7 +82,6 @@ export const hi: Record = {
closeOtherTabs: 'अन्य टैब बंद करें',
closeLeftTabs: 'बाएँ टैब बंद करें',
closeRightTabs: 'दाएँ टैब बंद करें',
- moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
dockToSidebar: 'साइडबार पर वापस जाएँ',
pinTerminal: 'टर्मिनल पिन करें',
@@ -97,8 +96,6 @@ export const hi: Record = {
pinnedTerminalScopeGlobal: 'वैश्विक रूप से पिन किया गया',
pinnedRailLabel: 'पिन किए गए टर्मिनल',
closePinnedTerminal: 'टर्मिनल बंद करें',
- collapse: 'साइडबार संक्षिप्त करें',
- expand: 'साइडबार विस्तृत करें',
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
expandBottomPanel: 'निचला पैनल विस्तृत करें',
terminalError: 'टर्मिनल कनेक्शन विफल',
@@ -154,12 +151,6 @@ export const hi: Record = {
changesOpenDiffTab: 'अलग diff टैब में खोलें',
changesClosePreview: 'पूर्वावलोकन बंद करें',
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
- changesDiffOpenTitle: 'diff खुलता है',
- changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
- changesDiffOpenFloat: 'मुक्त विंडो',
- changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
- changesDiffOpenPane: 'पैनल',
- changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
staged: 'स्टेज किया',
unstaged: 'अनस्टेज',
@@ -233,11 +224,6 @@ export const hi: Record = {
settingsIntro: 'साइड कार्ड क्या दिखाता है और कैसे व्यवहार करता है प्रबंधित करें',
settingsPopupDesc: '{feature} के लिए संबंधित विकल्प कॉन्फ़िगर करें',
settingsDone: 'पूर्ण',
- settingsOpenTitle: 'नए वार्तालाप के लिए डिफ़ॉल्ट रूप से खोलें',
- settingsOpenDesc: 'बिल्कुल नए वार्तालाप के लिए साइड कार्ड स्वतः विस्तृत करें; मौजूदा वार्तालाप अपना लेआउट रखते हैं',
- settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
- settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsTitleBarTitle: 'स्थिति संगतता मोड',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 2a4389e..cefa087 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -80,7 +80,6 @@ export const id: Record = {
closeOtherTabs: 'Tutup Tab Lain',
closeLeftTabs: 'Tutup Tab di Kiri',
closeRightTabs: 'Tutup Tab di Kanan',
- moveToFreeWindow: 'Pindahkan ke jendela bebas',
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
dockToSidebar: 'Kembali ke bilah samping',
pinTerminal: 'Sematkan Terminal',
@@ -95,8 +94,6 @@ export const id: Record = {
pinnedTerminalScopeGlobal: 'Disematkan global',
pinnedRailLabel: 'Terminal Tersemat',
closePinnedTerminal: 'Tutup Terminal',
- collapse: 'Lipat sidebar',
- expand: 'Bentangkan sidebar',
collapseBottomPanel: 'Lipat panel bawah',
expandBottomPanel: 'Bentangkan panel bawah',
terminalError: 'Koneksi terminal gagal',
@@ -152,12 +149,6 @@ export const id: Record = {
changesOpenDiffTab: 'Buka di tab diff terpisah',
changesClosePreview: 'Tutup pratinjau',
changesResizePreview: 'Ubah ukuran pratinjau',
- changesDiffOpenTitle: 'Diff terbuka sebagai',
- changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff',
- changesDiffOpenFloat: 'Jendela bebas',
- changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas',
- changesDiffOpenPane: 'Panel',
- changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)',
changesLoadError: 'Catatan berkas sesi sedang tidak tersedia',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -231,11 +222,6 @@ export const id: Record = {
settingsIntro: 'Kelola apa yang ditampilkan kartu samping dan perilakunya',
settingsPopupDesc: 'Konfigurasikan opsi terkait untuk {feature}',
settingsDone: 'Selesai',
- settingsOpenTitle: 'Buka secara default untuk obrolan baru',
- settingsOpenDesc: 'Bentangkan kartu samping secara otomatis untuk obrolan baru; obrolan yang sudah ada mempertahankan tata letaknya',
- settingsWidthTitle: 'Porsi lebar default',
- settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index fb10153..ae54f55 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -73,7 +73,6 @@ export const it: Record = {
closeOtherTabs: 'Chiudi le altre schede',
closeLeftTabs: 'Chiudi le schede a sinistra',
closeRightTabs: 'Chiudi le schede a destra',
- moveToFreeWindow: 'Sposta in finestra libera',
floatDropHint: 'Rilascia per aprire in una finestra libera',
dockToSidebar: 'Torna alla barra laterale',
pinTerminal: 'Blocca Terminale',
@@ -88,8 +87,6 @@ export const it: Record = {
pinnedTerminalScopeGlobal: 'Bloccato globalmente',
pinnedRailLabel: 'Terminali Bloccati',
closePinnedTerminal: 'Chiudi Terminale',
- collapse: 'Comprimi barra laterale',
- expand: 'Espandi barra laterale',
collapseBottomPanel: 'Comprimi pannello inferiore',
expandBottomPanel: 'Espandi pannello inferiore',
terminalError: 'Connessione del terminale non riuscita',
@@ -145,12 +142,6 @@ export const it: Record = {
changesOpenDiffTab: 'Apri in una scheda diff',
changesClosePreview: 'Ridimensiona l\'anteprima',
changesResizePreview: 'Ridimensiona anteprima',
- changesDiffOpenTitle: 'Il diff si apre come',
- changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff',
- changesDiffOpenFloat: 'Finestra libera',
- changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano',
- changesDiffOpenPane: 'Pannello',
- changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)',
changesLoadError: 'I record dei file della sessione non sono disponibili',
staged: 'In stage',
unstaged: 'Non in stage',
@@ -224,11 +215,6 @@ export const it: Record = {
settingsIntro: 'Gestisca cosa mostra la scheda laterale e come si comporta',
settingsPopupDesc: 'Configuri le opzioni relative a {feature}',
settingsDone: 'Fatto',
- settingsOpenTitle: 'Apri per impostazione predefinita per le nuove conversazioni',
- settingsOpenDesc: 'Espande automaticamente la scheda laterale per le conversazioni nuove; le conversazioni esistenti mantengono il proprio layout',
- settingsWidthTitle: 'Quota di larghezza predefinita',
- settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index 57dcf52..b0d412d 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -82,7 +82,6 @@ export const ja: Record = {
closeOtherTabs: '他のタブを閉じる',
closeLeftTabs: '左のタブを閉じる',
closeRightTabs: '右のタブを閉じる',
- moveToFreeWindow: 'フリーウィンドウへ移動',
floatDropHint: '離すとフリーウィンドウで開きます',
dockToSidebar: 'サイドバーへ戻す',
pinTerminal: 'ターミナルを固定',
@@ -97,8 +96,6 @@ export const ja: Record = {
pinnedTerminalScopeGlobal: 'グローバルに固定',
pinnedRailLabel: '固定されたターミナル',
closePinnedTerminal: 'ターミナルを閉じる',
- collapse: 'サイドバーを折りたたむ',
- expand: 'サイドバーを展開',
collapseBottomPanel: '下パネルを折りたたむ',
expandBottomPanel: '下パネルを展開',
terminalError: 'ターミナル接続に失敗',
@@ -154,12 +151,6 @@ export const ja: Record = {
changesOpenDiffTab: '独立タブで開く',
changesClosePreview: 'プレビューを閉じる',
changesResizePreview: 'プレビューの高さを変更',
- changesDiffOpenTitle: 'diff の展開先',
- changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
- changesDiffOpenFloat: 'フリーウィンドウ',
- changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
- changesDiffOpenPane: 'ペイン',
- changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
changesLoadError: 'セッションのファイル記録は現在利用できません',
staged: 'ステージ済み',
unstaged: '未ステージ',
@@ -233,11 +224,6 @@ export const ja: Record = {
settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理',
settingsPopupDesc: '「{feature}」の関連オプションを設定',
settingsDone: '完了',
- settingsOpenTitle: '新規会話でデフォルト展開',
- settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持',
- settingsWidthTitle: 'デフォルト幅の比率',
- settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
settingsTitleBarTitle: '位置互換モード',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index 5b26f05..3654dd8 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -74,7 +74,6 @@ export const ko: Record = {
closeOtherTabs: '다른 탭 닫기',
closeLeftTabs: '왼쪽 탭 닫기',
closeRightTabs: '오른쪽 탭 닫기',
- moveToFreeWindow: '자유 창으로 이동',
floatDropHint: '놓으면 자유 창에서 열립니다',
dockToSidebar: '사이드바로 돌아가기',
pinTerminal: '터미널 고정',
@@ -89,8 +88,6 @@ export const ko: Record = {
pinnedTerminalScopeGlobal: '전역에 고정됨',
pinnedRailLabel: '고정된 터미널',
closePinnedTerminal: '터미널 닫기',
- collapse: '사이드바 접기',
- expand: '사이드바 펼치기',
collapseBottomPanel: '하단 패널 접기',
expandBottomPanel: '하단 패널 펼치기',
terminalError: '터미널 연결 실패',
@@ -146,12 +143,6 @@ export const ko: Record = {
changesOpenDiffTab: '별도 탭에서 열기',
changesClosePreview: '미리보기 닫기',
changesResizePreview: '미리보기 높이 조정',
- changesDiffOpenTitle: 'diff 열기 위치',
- changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
- changesDiffOpenFloat: '자유 창',
- changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
- changesDiffOpenPane: '패널',
- changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
staged: '스테이징됨',
unstaged: '스테이징 안 됨',
@@ -225,11 +216,6 @@ export const ko: Record = {
settingsIntro: '사이드 카드의 표시 내용과 기본 동작을 관리합니다',
settingsPopupDesc: '"{feature}" 관련 옵션을 구성합니다',
settingsDone: '완료',
- settingsOpenTitle: '새 대화 기본 열림',
- settingsOpenDesc: '새 대화를 만들 때 사이드 카드를 자동으로 펼칩니다. 기존 대화는 각자의 레이아웃을 유지합니다',
- settingsWidthTitle: '기본 너비 비율',
- settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
settingsTitleBarTitle: '위치 호환 모드',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 89242b2..d9183e5 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -80,7 +80,6 @@ export const nl: Record = {
closeOtherTabs: 'Andere tabbladen sluiten',
closeLeftTabs: 'Tabbladen links sluiten',
closeRightTabs: 'Tabbladen rechts sluiten',
- moveToFreeWindow: 'Naar vrij venster verplaatsen',
floatDropHint: 'Loslaten om in een vrij venster te openen',
dockToSidebar: 'Terug naar de zijbalk',
pinTerminal: 'Terminal vastmaken',
@@ -95,8 +94,6 @@ export const nl: Record = {
pinnedTerminalScopeGlobal: 'Wereldwijd vastgemaakt',
pinnedRailLabel: 'Vastgemaakte terminals',
closePinnedTerminal: 'Terminal sluiten',
- collapse: 'Zijbalk inklappen',
- expand: 'Zijbalk uitklappen',
collapseBottomPanel: 'Onderpaneel inklappen',
expandBottomPanel: 'Onderpaneel uitklappen',
terminalError: 'Terminalverbinding mislukt',
@@ -152,12 +149,6 @@ export const nl: Record = {
changesOpenDiffTab: 'In eigen diff-tabblad openen',
changesClosePreview: 'Voorbeeld sluiten',
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
- changesDiffOpenTitle: 'Diff opent als',
- changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst',
- changesDiffOpenFloat: 'Vrij venster',
- changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden',
- changesDiffOpenPane: 'Paneel',
- changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)',
changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar',
staged: 'Gestaged',
unstaged: 'Niet gestaged',
@@ -231,11 +222,6 @@ export const nl: Record = {
settingsIntro: 'Beheer wat de zijkaart toont en hoe deze zich gedraagt',
settingsPopupDesc: 'Gerelateerde opties voor {feature} configureren',
settingsDone: 'Klaar',
- settingsOpenTitle: 'Standaard openen voor nieuwe conversaties',
- settingsOpenDesc: 'De zijkaart automatisch uitklappen voor gloednieuwe conversaties; bestaande conversaties behouden hun eigen layout',
- settingsWidthTitle: 'Standaard breedte-aandeel',
- settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 4e60ed2..b9dbc76 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -84,7 +84,6 @@ export const pl: Record = {
closeOtherTabs: 'Zamknij inne karty',
closeLeftTabs: 'Zamknij karty po lewej',
closeRightTabs: 'Zamknij karty po prawej',
- moveToFreeWindow: 'Przenieś do wolnego okna',
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
dockToSidebar: 'Wróć do panelu bocznego',
pinTerminal: 'Przypnij Terminal',
@@ -99,8 +98,6 @@ export const pl: Record = {
pinnedTerminalScopeGlobal: 'Przypięty globalnie',
pinnedRailLabel: 'Przypięte terminale',
closePinnedTerminal: 'Zamknij terminal',
- collapse: 'Zwiń panel boczny',
- expand: 'Rozwiń panel boczny',
collapseBottomPanel: 'Zwiń panel dolny',
expandBottomPanel: 'Rozwiń panel dolny',
terminalError: 'Połączenie z terminalem nie powiodło się',
@@ -156,12 +153,6 @@ export const pl: Record = {
changesOpenDiffTab: 'Otwórz w osobnej karcie',
changesClosePreview: 'Zamknij podgląd',
changesResizePreview: 'Zmień wysokość podglądu',
- changesDiffOpenTitle: 'Diff otwiera się jako',
- changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff',
- changesDiffOpenFloat: 'Odrębne okno',
- changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu',
- changesDiffOpenPane: 'Panel',
- changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)',
changesLoadError: 'Zapisy plików sesji są teraz niedostępne',
staged: 'W indeksie',
unstaged: 'Poza indeksem',
@@ -235,11 +226,6 @@ export const pl: Record = {
settingsIntro: 'Zarządzaj tym, co pokazuje karta boczna i jak się zachowuje',
settingsPopupDesc: 'Skonfiguruj opcje powiązane z: {feature}',
settingsDone: 'Gotowe',
- settingsOpenTitle: 'Otwórz domyślnie dla nowych rozmów',
- settingsOpenDesc: 'Rozwiń kartę boczną automatycznie dla zupełnie nowych rozmów; istniejące rozmowy zachowują własne układy',
- settingsWidthTitle: 'Domyślny udział szerokości',
- settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
settingsTitleBarTitle: 'Tryb zgodności pozycji',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 9bfc9fa..4b6a71e 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -65,7 +65,6 @@ export const pt: Record = {
closeOtherTabs: 'Fechar outras abas',
closeLeftTabs: 'Fechar abas à esquerda',
closeRightTabs: 'Fechar abas à direita',
- moveToFreeWindow: 'Mover para janela flutuante',
floatDropHint: 'Solte para abrir em uma janela flutuante',
dockToSidebar: 'Voltar para a barra lateral',
pinTerminal: 'Fixar Terminal',
@@ -80,8 +79,6 @@ export const pt: Record = {
pinnedTerminalScopeGlobal: 'Fixado globalmente',
pinnedRailLabel: 'Terminais Fixados',
closePinnedTerminal: 'Fechar Terminal',
- collapse: 'Recolher barra lateral',
- expand: 'Expandir barra lateral',
collapseBottomPanel: 'Recolher painel inferior',
expandBottomPanel: 'Expandir painel inferior',
terminalError: 'Falha na conexão do terminal',
@@ -137,12 +134,6 @@ export const pt: Record = {
changesOpenDiffTab: 'Abrir em uma aba de diff',
changesClosePreview: 'Fechar prévia',
changesResizePreview: 'Ajustar altura da prévia',
- changesDiffOpenTitle: 'O diff abre como',
- changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff',
- changesDiffOpenFloat: 'Janela livre',
- changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo',
- changesDiffOpenPane: 'Painel',
- changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)',
changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento',
staged: 'Preparadas',
unstaged: 'Não preparadas',
@@ -216,11 +207,6 @@ export const pt: Record = {
settingsIntro: 'Gerencie o que o cartão lateral mostra e como ele se comporta',
settingsPopupDesc: 'Configurar opções relacionadas para {feature}',
settingsDone: 'Concluído',
- settingsOpenTitle: 'Abrir por padrão em novas conversas',
- settingsOpenDesc: 'Expandir o cartão lateral automaticamente em conversas totalmente novas; conversas existentes mantêm seus próprios layouts',
- settingsWidthTitle: 'Participação padrão na largura',
- settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index b8c51bf..722dacb 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -78,7 +78,6 @@ export const ru: Record = {
closeOtherTabs: 'Закрыть остальные вкладки',
closeLeftTabs: 'Закрыть вкладки слева',
closeRightTabs: 'Закрыть вкладки справа',
- moveToFreeWindow: 'Переместить в свободное окно',
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
dockToSidebar: 'Вернуть на боковую панель',
pinTerminal: 'Закрепить терминал',
@@ -93,8 +92,6 @@ export const ru: Record = {
pinnedTerminalScopeGlobal: 'Закреплён глобально',
pinnedRailLabel: 'Закреплённые терминалы',
closePinnedTerminal: 'Закрыть терминал',
- collapse: 'Свернуть боковую панель',
- expand: 'Развернуть боковую панель',
collapseBottomPanel: 'Свернуть нижнюю панель',
expandBottomPanel: 'Развернуть нижнюю панель',
terminalError: 'Сбой подключения терминала',
@@ -150,12 +147,6 @@ export const ru: Record = {
changesOpenDiffTab: 'Открыть в отдельной вкладке',
changesClosePreview: 'Закрыть просмотр',
changesResizePreview: 'Изменить высоту просмотра',
- changesDiffOpenTitle: 'Diff открывается как',
- changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
- changesDiffOpenFloat: 'Свободное окно',
- changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
- changesDiffOpenPane: 'Панель',
- changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
changesLoadError: 'Записи файлов сессии сейчас недоступны',
staged: 'В индексе',
unstaged: 'Не в индексе',
@@ -230,11 +221,6 @@ export const ru: Record = {
settingsIntro: 'Управление содержимым и поведением боковой карточки по умолчанию',
settingsPopupDesc: 'Настройка параметров «{feature}»',
settingsDone: 'Готово',
- settingsOpenTitle: 'Открывать по умолчанию для новых сессий',
- settingsOpenDesc: 'Автоматически разворачивать боковую карточку для новых сессий; у существующих сессий сохраняется их собственная раскладка',
- settingsWidthTitle: 'Доля ширины по умолчанию',
- settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
settingsTitleBarTitle: 'Режим совместимости заголовка',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 5cb9780..f70ab43 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -65,7 +65,6 @@ export const sv: Record = {
closeOtherTabs: 'Stäng andra flikar',
closeLeftTabs: 'Stäng flikar till vänster',
closeRightTabs: 'Stäng flikar till höger',
- moveToFreeWindow: 'Flytta till fritt fönster',
floatDropHint: 'Släpp för att öppna i ett fritt fönster',
dockToSidebar: 'Tillbaka till sidopanelen',
pinTerminal: 'Fäst Terminal',
@@ -80,8 +79,6 @@ export const sv: Record = {
pinnedTerminalScopeGlobal: 'Fäst globalt',
pinnedRailLabel: 'Fästa terminaler',
closePinnedTerminal: 'Stäng terminal',
- collapse: 'Komprimera sidopanel',
- expand: 'Expandera sidopanel',
collapseBottomPanel: 'Komprimera bottenpanel',
expandBottomPanel: 'Expandera bottenpanel',
terminalError: 'Terminalanslutning misslyckades',
@@ -137,12 +134,6 @@ export const sv: Record = {
changesOpenDiffTab: 'Öppna i egen diff-flik',
changesClosePreview: 'Stäng förhandsvisning',
changesResizePreview: 'Ändra storlek på förhandsvisningen',
- changesDiffOpenTitle: 'Diff öppnas som',
- changesDiffOpenDesc: 'Var åtgärden "öppna i egen diff-flik" placerar diffen',
- changesDiffOpenFloat: 'Fritt fönster',
- changesDiffOpenFloatDesc: 'Ett flytande fönster centrerat i vyn — dra, ändra storlek, håll överst',
- changesDiffOpenPane: 'Panel',
- changesDiffOpenPaneDesc: 'Dockad under källpanelen (VSCode-liknande diff-delning)',
changesLoadError: 'Sessionens filposter är inte tillgängliga just nu',
staged: 'Köade',
unstaged: 'Avköade',
@@ -216,11 +207,6 @@ export const sv: Record = {
settingsIntro: 'Hantera vad sidokortet visar och hur det beter sig',
settingsPopupDesc: 'Konfigurera relaterade alternativ för {feature}',
settingsDone: 'Klar',
- settingsOpenTitle: 'Öppna som standard för nya konversationer',
- settingsOpenDesc: 'Expandera sidokortet automatiskt för helt nya konversationer; befintliga konversationer behåller sina egna layouter',
- settingsWidthTitle: 'Standardbreddandel',
- settingsWidthDesc: 'Sidokortets standardandel av fönsterbredden för nya konversationer (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen',
settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)',
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 24f7a77..d360d16 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -82,7 +82,6 @@ export const th: Record = {
closeOtherTabs: 'ปิด Tab อื่น',
closeLeftTabs: 'ปิด Tab ด้านซ้าย',
closeRightTabs: 'ปิด Tab ด้านขวา',
- moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ',
floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ',
dockToSidebar: 'กลับไปที่แถบข้าง',
pinTerminal: 'ปักหมุดเทอร์มินัล',
@@ -97,8 +96,6 @@ export const th: Record = {
pinnedTerminalScopeGlobal: 'ปักหมุดแบบ Global',
pinnedRailLabel: 'เทอร์มินัลที่ปักหมุด',
closePinnedTerminal: 'ปิดเทอร์มินัล',
- collapse: 'ย่อแถบด้านข้าง',
- expand: 'ขยายแถบด้านข้าง',
collapseBottomPanel: 'ย่อพาเนลด้านล่าง',
expandBottomPanel: 'ขยายพาเนลด้านล่าง',
terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว',
@@ -154,12 +151,6 @@ export const th: Record = {
changesOpenDiffTab: 'เปิดในแท็บ diff แยก',
changesClosePreview: 'ปิดตัวอย่าง',
changesResizePreview: 'ปรับขนาดตัวอย่าง',
- changesDiffOpenTitle: 'diff เปิดเป็น',
- changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด',
- changesDiffOpenFloat: 'หน้าต่างลอย',
- changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด',
- changesDiffOpenPane: 'แพเนล',
- changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)',
changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -233,11 +224,6 @@ export const th: Record = {
settingsIntro: 'จัดการสิ่งที่การ์ดด้านข้างแสดงและพฤติกรรมของมัน',
settingsPopupDesc: 'กำหนดค่าตัวเลือกที่เกี่ยวข้องสำหรับ {feature}',
settingsDone: 'เสร็จสิ้น',
- settingsOpenTitle: 'เปิดโดยค่าเริ่มต้นสำหรับแชทใหม่',
- settingsOpenDesc: 'ขยายการ์ดด้านข้างโดยอัตโนมัติสำหรับแชทใหม่; แชทที่มีอยู่แล้วยังคงเค้าโครงของตัวเอง',
- settingsWidthTitle: 'สัดส่วนความกว้างเริ่มต้น',
- settingsWidthDesc: 'สัดส่วนความกว้างเริ่มต้นของการ์ดด้านข้างจากความกว้างหน้าต่างสำหรับแชทใหม่ (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index ad1baa3..50da4a8 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -82,7 +82,6 @@ export const tr: Record = {
closeOtherTabs: 'Diğer sekmeleri kapat',
closeLeftTabs: 'Soldaki sekmeleri kapat',
closeRightTabs: 'Sağdaki sekmeleri kapat',
- moveToFreeWindow: 'Serbest pencereye taşı',
floatDropHint: 'Serbest pencerede açmak için bırakın',
dockToSidebar: 'Kenar çubuğuna geri dön',
pinTerminal: 'Terminali Sabitle',
@@ -97,8 +96,6 @@ export const tr: Record = {
pinnedTerminalScopeGlobal: 'Genel olarak sabitlendi',
pinnedRailLabel: 'Sabitlenmiş Terminaller',
closePinnedTerminal: 'Terminali Kapat',
- collapse: 'Kenar çubuğunu daralt',
- expand: 'Kenar çubuğunu genişlet',
collapseBottomPanel: 'Alt paneli daralt',
expandBottomPanel: 'Alt paneli genişlet',
terminalError: 'Terminal bağlantısı başarısız',
@@ -154,12 +151,6 @@ export const tr: Record = {
changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç',
changesClosePreview: 'Önizlemeyi kapat',
changesResizePreview: 'Önizlemeyi boyutlandır',
- changesDiffOpenTitle: 'diff açılır',
- changesDiffOpenDesc: '"Ayrı bir diff sekmesinde aç" eylemi diff\'i nereye koyar',
- changesDiffOpenFloat: 'Serbest pencere',
- changesDiffOpenFloatDesc: 'Görünümün ortasında yüzen pencere — sürükle, boyutlandır, üstte tut',
- changesDiffOpenPane: 'Panel',
- changesDiffOpenPaneDesc: 'Kaynak panelin altına sabitlenmiş (VSCode tarzı diff bölmesi)',
changesLoadError: 'Oturum dosya kayıtları şu anda kullanılamıyor',
staged: 'Sahnelendi',
unstaged: 'Sahnelenmedi',
@@ -233,11 +224,6 @@ export const tr: Record = {
settingsIntro: 'Yan kartın ne gösterdiğini ve nasıl davrandığını yönetin',
settingsPopupDesc: '«{feature}» için ilgili seçenekleri yapılandırın',
settingsDone: 'Bitti',
- settingsOpenTitle: 'Yeni sohbetlerde varsayılan olarak aç',
- settingsOpenDesc: 'Yepyeni sohbetler için yan kartı otomatik genişlet; var olan sohbetler kendi düzenlerini korur',
- settingsWidthTitle: 'Varsayılan genişlik payı',
- settingsWidthDesc: 'Yeni sohbetler için yan kartın pencere genişliğindeki varsayılan payı (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et',
settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)',
settingsTitleBarTitle: 'Konum uyumluluk modu',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index f59f075..3b3fc28 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -82,7 +82,6 @@ export const vi: Record = {
closeOtherTabs: 'Đóng các tab khác',
closeLeftTabs: 'Đóng tab bên trái',
closeRightTabs: 'Đóng tab bên phải',
- moveToFreeWindow: 'Chuyển sang cửa sổ tự do',
floatDropHint: 'Thả để mở trong cửa sổ tự do',
dockToSidebar: 'Quay lại thanh bên',
pinTerminal: 'Ghim Terminal',
@@ -97,8 +96,6 @@ export const vi: Record = {
pinnedTerminalScopeGlobal: 'Đã ghim toàn cục',
pinnedRailLabel: 'Terminal đã ghim',
closePinnedTerminal: 'Đóng Terminal',
- collapse: 'Thu gọn thanh bên',
- expand: 'Mở rộng thanh bên',
collapseBottomPanel: 'Thu gọn panel dưới',
expandBottomPanel: 'Mở rộng panel dưới',
terminalError: 'Kết nối terminal thất bại',
@@ -154,12 +151,6 @@ export const vi: Record = {
changesOpenDiffTab: 'Mở trong tab diff riêng',
changesClosePreview: 'Đóng xem trước',
changesResizePreview: 'Điều chỉnh kích thước xem trước',
- changesDiffOpenTitle: 'diff mở thành',
- changesDiffOpenDesc: 'Hành động «mở trong tab diff riêng» đặt diff ở đâu',
- changesDiffOpenFloat: 'Cửa sổ tự do',
- changesDiffOpenFloatDesc: 'Cửa sổ nổi giữa khung nhìn — kéo, đổi kích thước, ghim trên cùng',
- changesDiffOpenPane: 'Bảng',
- changesDiffOpenPaneDesc: 'Gắn dưới bảng nguồn (chia diff kiểu VSCode)',
changesLoadError: 'Bản ghi tệp phiên hiện không khả dụng',
staged: 'Đã stage',
unstaged: 'Chưa stage',
@@ -233,11 +224,6 @@ export const vi: Record = {
settingsIntro: 'Quản lý nội dung hiển thị và hành vi mặc định của thẻ bên',
settingsPopupDesc: 'Cấu hình tùy chọn cho «{feature}»',
settingsDone: 'Xong',
- settingsOpenTitle: 'Mở mặc định cho phiên mới',
- settingsOpenDesc: 'Tự động mở rộng thẻ bên cho phiên mới; phiên đã có giữ nguyên bố cục',
- settingsWidthTitle: 'Tỷ lệ chiều rộng mặc định',
- settingsWidthDesc: 'Tỷ lệ phần trăm chiều rộng cửa sổ mà thẻ bên chiếm cho phiên mới (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình',
settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)',
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index e99f0d5..dcd9e19 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -97,7 +97,6 @@ export const zhHK: Record = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
- moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
pinTerminal: '固定終端',
@@ -112,8 +111,6 @@ export const zhHK: Record = {
pinnedTerminalScopeGlobal: '固定到全域',
pinnedRailLabel: '固定終端',
closePinnedTerminal: '關閉終端',
- collapse: '收起側邊欄',
- expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
expandBottomPanel: '展開底部面板',
terminalError: '終端連線失敗',
@@ -169,12 +166,6 @@ export const zhHK: Record = {
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
- changesDiffOpenTitle: '差異展開方式',
- changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
- changesDiffOpenFloat: '浮動視窗',
- changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
- changesDiffOpenPane: '面板',
- changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
@@ -248,11 +239,6 @@ export const zhHK: Record = {
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
settingsPopupDesc: '為「{feature}」設定相關選項',
settingsDone: '完成',
- settingsOpenTitle: '新工作階段預設開啟',
- settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
- settingsWidthTitle: '預設寬度佔比',
- settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index ac5b2a0..5b60c3f 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -97,7 +97,6 @@ export const zhMO: Record = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
- moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
pinTerminal: '固定終端',
@@ -112,8 +111,6 @@ export const zhMO: Record = {
pinnedTerminalScopeGlobal: '固定到全域',
pinnedRailLabel: '固定終端',
closePinnedTerminal: '關閉終端',
- collapse: '收起側邊欄',
- expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
expandBottomPanel: '展開底部面板',
terminalError: '終端連線失敗',
@@ -169,12 +166,6 @@ export const zhMO: Record = {
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
- changesDiffOpenTitle: '差異展開方式',
- changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
- changesDiffOpenFloat: '浮動視窗',
- changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
- changesDiffOpenPane: '面板',
- changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
@@ -248,11 +239,6 @@ export const zhMO: Record = {
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
settingsPopupDesc: '為「{feature}」設定相關選項',
settingsDone: '完成',
- settingsOpenTitle: '新工作階段預設開啟',
- settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
- settingsWidthTitle: '預設寬度佔比',
- settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index a80b965..4dbf024 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -97,7 +97,6 @@ export const zhTW: Record = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
- moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
pinTerminal: '固定終端',
@@ -112,8 +111,6 @@ export const zhTW: Record = {
pinnedTerminalScopeGlobal: '固定到全域',
pinnedRailLabel: '固定終端',
closePinnedTerminal: '關閉終端',
- collapse: '收起側邊欄',
- expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
expandBottomPanel: '展開底部面板',
terminalError: '終端連線失敗',
@@ -169,12 +166,6 @@ export const zhTW: Record = {
changesOpenDiffTab: '在獨立頁籤中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
- changesDiffOpenTitle: '差異展開方式',
- changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡',
- changesDiffOpenFloat: '浮動視窗',
- changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
- changesDiffOpenPane: '面板',
- changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
@@ -248,11 +239,6 @@ export const zhTW: Record = {
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
settingsPopupDesc: '為「{feature}」設定相關選項',
settingsDone: '完成',
- settingsOpenTitle: '新工作階段預設開啟',
- settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
- settingsWidthTitle: '預設寬度佔比',
- settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 2e31c77..7fab631 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -94,7 +94,6 @@ export const zh = {
closeOtherTabs: '关闭其他页签',
closeLeftTabs: '关闭左侧页签',
closeRightTabs: '关闭右侧页签',
- moveToFreeWindow: '移动到自由窗口',
floatDropHint: '松开以在自由窗口中打开',
dockToSidebar: '回到侧边栏',
pinTerminal: '固定终端',
@@ -109,8 +108,6 @@ export const zh = {
pinnedTerminalScopeGlobal: '固定到全局',
pinnedRailLabel: '固定终端',
closePinnedTerminal: '关闭终端',
- collapse: '折叠侧边栏',
- expand: '展开侧边栏',
collapseBottomPanel: '折叠底部面板',
expandBottomPanel: '展开底部面板',
terminalError: '终端连接失败',
@@ -165,12 +162,6 @@ export const zh = {
changesOpenDiffTab: '在独立页签中打开',
changesClosePreview: '关闭预览',
changesResizePreview: '调整预览高度',
- changesDiffOpenTitle: '差异展开方式',
- changesDiffOpenDesc: '「展开为独立页签」把 diff 放到哪里',
- changesDiffOpenFloat: '浮窗',
- changesDiffOpenFloatDesc: '作为自由窗口居中打开,可拖拽、缩放、置顶',
- changesDiffOpenPane: '面板',
- changesDiffOpenPaneDesc: '停靠在源面板下方(VSCode 式 diff 分栏)',
changesLoadError: '会话文件记录暂不可用',
staged: '已暂存',
unstaged: '未暂存',
@@ -245,11 +236,6 @@ export const zh = {
settingsIntro: '管理侧边卡片的显示内容与默认行为',
settingsPopupDesc: '为「{feature}」配置相关选项',
settingsDone: '完成',
- settingsOpenTitle: '新会话默认打开',
- settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
- settingsWidthTitle: '默认宽度占比',
- settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
settingsTitleBarTitle: '位置兼容模式',
@@ -543,7 +529,6 @@ export const en: Record = {
closeOtherTabs: 'Close Other Tabs',
closeLeftTabs: 'Close Tabs to the Left',
closeRightTabs: 'Close Tabs to the Right',
- moveToFreeWindow: 'Move to Free Window',
floatDropHint: 'Release to open in a free window',
dockToSidebar: 'Dock Back to Sidebar',
pinTerminal: 'Pin Terminal',
@@ -558,8 +543,6 @@ export const en: Record = {
pinnedTerminalScopeGlobal: 'Pinned globally',
pinnedRailLabel: 'Pinned Terminals',
closePinnedTerminal: 'Close Terminal',
- collapse: 'Collapse sidebar',
- expand: 'Expand sidebar',
collapseBottomPanel: 'Collapse bottom panel',
expandBottomPanel: 'Expand bottom panel',
terminalError: 'Terminal connection failed',
@@ -614,12 +597,6 @@ export const en: Record = {
changesOpenDiffTab: 'Open in a diff tab',
changesClosePreview: 'Close preview',
changesResizePreview: 'Resize preview',
- changesDiffOpenTitle: 'Diff opens as',
- changesDiffOpenDesc: 'Where the "expand to a diff tab" action lands the diff',
- changesDiffOpenFloat: 'Free window',
- changesDiffOpenFloatDesc: 'A floating window centered on the viewport — drag, resize, keep on top',
- changesDiffOpenPane: 'Pane',
- changesDiffOpenPaneDesc: 'Docked below the source panel (VSCode-style diff split)',
changesLoadError: 'Session file records are unavailable right now',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -694,11 +671,6 @@ export const en: Record = {
settingsIntro: 'Manage what the side card shows and how it behaves',
settingsPopupDesc: 'Configure related options for {feature}',
settingsDone: 'Done',
- settingsOpenTitle: 'Open by default for new conversations',
- settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
- settingsWidthTitle: 'Default width share',
- settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
- settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model',
settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)',
settingsTitleBarTitle: 'Position compatibility mode',
diff --git a/src/client/pinned.ts b/src/client/pinned.ts
index 6ebdcb6..cc01b57 100644
--- a/src/client/pinned.ts
+++ b/src/client/pinned.ts
@@ -143,9 +143,8 @@ export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean
* Collect every pinned terminal visible to the viewer across ALL cached
* session states. Excludes the viewer's own session (those tabs are on its
* own strip). Order is stable: sessions in the cache's insertion order,
- * tabs in tree order (splits → bottomSplits → floats) within each session
- * — the order tabs were opened/pinned, so the rail never reorders between
- * renders.
+ * tabs in tree order within each session — the order tabs were
+ * opened/pinned, so the rail never reorders between renders.
*/
export function collectPinnedTabs(
bySession: ReadonlyMap,
@@ -154,20 +153,14 @@ export function collectPinnedTabs(
const entries: PinnedTabEntry[] = []
for (const [homeSessionId, state] of bySession) {
if (homeSessionId === viewer.sessionId) continue
- collectFromTree(state.splits, homeSessionId, viewer, entries)
collectFromTree(state.bottomSplits, homeSessionId, viewer, entries)
- for (const float of state.floats) {
- if (float.tab.type === 'terminal' && pinnedVisibleTo(float.tab, viewer)) {
- entries.push({ tab: float.tab, homeSessionId })
- }
- }
}
return entries
}
/** Walk one split tree depth-first, collecting visible pinned terminals. */
function collectFromTree(
- node: SidebarState['splits'],
+ node: SplitNode,
homeSessionId: string,
viewer: PinnedViewer,
out: PinnedTabEntry[],
diff --git a/src/client/prefs.ts b/src/client/prefs.ts
index 5eae009..4e6416f 100644
--- a/src/client/prefs.ts
+++ b/src/client/prefs.ts
@@ -12,7 +12,6 @@ import type { api } from './api.ts'
import {
clampTerminalFontSize,
clampTitleBarStrip,
- clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
@@ -26,7 +25,6 @@ export {
TITLE_BAR_STRIP_DEFAULT,
clampTerminalFontSize,
clampTitleBarStrip,
- clampWidthPercent,
}
export type { SidebarPrefs, TitleBarScheme }
@@ -43,12 +41,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
const record = value as Record
return {
- openByDefault: typeof record.openByDefault === 'boolean'
- ? record.openByDefault
- : SIDEBAR_PREFS_DEFAULTS.openByDefault,
- defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
- ? clampWidthPercent(record.defaultWidthPercent)
- : SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
@@ -79,9 +71,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
- changesDiffFloat: typeof record.changesDiffFloat === 'boolean'
- ? record.changesDiffFloat
- : SIDEBAR_PREFS_DEFAULTS.changesDiffFloat,
workspaceFence: typeof record.workspaceFence === 'boolean'
? record.workspaceFence
: SIDEBAR_PREFS_DEFAULTS.workspaceFence,
diff --git a/src/client/service.ts b/src/client/service.ts
index d92b381..f948402 100644
--- a/src/client/service.ts
+++ b/src/client/service.ts
@@ -22,11 +22,10 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
- activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
- leafWithTab, openTabInActivePane, openTabInBottomPane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
+ activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer,
+ leafWithTab, openTabInBottomPane, patchTab, tabOpenIn,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType,
} from './state.ts'
-import { isNarrowWidth } from './breakpoints.ts'
import { extOf } from './paths.ts'
import type { SessionScope } from './api.ts'
import type { SidebarPrefs } from '../prefs-shared.ts'
@@ -430,12 +429,10 @@ export interface BetterSidebarService {
* without switching the UI's active session; when absent the open lands
* in the currently active session (the pre-0.12 behavior).
*
- * A CONTENT open (a `path` or `url` seed) must land in sight: when the
- * panel hosting the landing pane is collapsed, it is expanded
- * automatically (the right panel by default, the bottom panel when the
- * active pane lives there; on narrow viewports the merged drawer opens).
- * Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
- * the panel behavior is their caller's business.
+ * Every open lands in the bottom workbench and expands it (the workbench
+ * is the plugin's only own surface; the right column is DSH's native
+ * Sidebar). An open carrying a `path` or `url` goes through the native
+ * surface instead, which never touches this state.
*
* Note: `available` gates the + menu's disabled state only — it does NOT
* refuse `openTab` (only the settings disable switch does).
@@ -536,9 +533,10 @@ export const SIDEBAR_SERVICE_VERSION = '0.19.0-alpha.0'
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
- * - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
- * id focus targets RAISE the floating window (never duplicate the tab or
- * expand panels), closeTab on a floating tab closes it with its window.
+ *
+ * v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5
+ * owns the right column, so the plugin keeps only its bottom workbench).
+ * Consumers must not gate on it any more.
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -551,7 +549,6 @@ export const SIDEBAR_FEATURES = [
'pluginSettings',
'urlTarget',
'settingSelect',
- 'floatWindows',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
@@ -733,7 +730,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// A bottom-targeted open lands in the bottom workbench's own pane; the
// right tree it would otherwise follow is no longer rendered (DSH's
// native sidebar owns the right column).
- const land = seed.target === 'bottom' ? openTabInBottomPane : openTabInActivePane
+ const land = openTabInBottomPane
const reducer = (state: SidebarState): SidebarState => {
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
let tab: SidebarTab
@@ -766,8 +763,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// tab that never closes.
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
- const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
- .concat(state.floats.map(f => f.tab))
+ const inputTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
const existedByKey = key !== undefined
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
const existedById = tabOpenIn(state, tab.id)
@@ -787,57 +783,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
if (isCreation) {
// Resolve the ACTUAL landed tab — the url patch mints a new object,
// so the callback must see the tab that was really inserted.
- const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
+ const landedTabs = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs)
created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
} else {
// A focus happened: resolve the tab that is actually active now and
// report THAT to onActivate (never the caller's un-inserted seed).
- // The pool covers free windows too — a floating instance focuses by
- // raising, and the callback must see the real (patched) tab.
- const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
- .concat(landed.floats.map(f => f.tab))
+ const candidates = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs)
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
- // A CONTENT open that focuses an existing FLOATING tab is already in
- // sight (free windows render regardless of panel state): expanding a
- // panel for it would point the user at a pane the content is not in.
- if (
- !isCreation
- && floatWithTab(landed, activated?.id ?? tab.id) !== undefined
- ) {
- return landed
- }
- // A CONTENT open (file / browser) must land in sight: when the panel
- // hosting the landing pane is collapsed, expand it. On narrow
- // viewports the two workbenches merge into one drawer, so the drawer
- // (panelOpen) is the only lever; on wide viewports the landing pane's
- // own panel opens — the bottom panel when the active pane lives in the
- // bottom tree, else the right panel. Type-only opens (+ menu,
- // agent-terminal auto-tabs) never expand (the panel behavior is their
- // caller's business). The check runs on the post-dedupe state, so a
- // content open that merely FOCUSES an existing tab expands the panel
- // too — the open must never land out of sight. Opens targeted at an
- // INACTIVE session never expand (nothing is in sight for the user).
- if (
- !targetsInactiveSession
- && typeof window !== 'undefined'
- && (seed.path !== undefined || seed.url !== undefined)
- ) {
- if (isNarrowWidth(window.innerWidth)) {
- if (!landed.panelOpen) return togglePanel(landed)
- } else {
- const hostKey = treeOf(landed, landed.activePane ?? '')
- if (hostKey === 'bottomSplits') {
- if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
- } else if (!landed.panelOpen) {
- return togglePanel(landed)
- }
- }
- }
- return landed
+ // Every open that lands in the workbench is visible: a creation
+ // expands it (openTabInBottomPane) and so does a FOCUS (the dedupe/id
+ // reducers only activate the tab). An open targeted at an INACTIVE
+ // session expands THAT session's workbench — it is waiting for the
+ // user when they switch to it.
+ return landed.bottomOpen ? landed : { ...landed, bottomOpen: true }
}
// A scope targeting ANOTHER session lands the open there without
// switching the UI; a scope naming the active session (or no scope)
@@ -871,14 +833,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// Unknown tab ids are a strict no-op: no state churn, no notify, no
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
if (!tabOpenIn(state, tabId)) return state
- // A floating tab closes WITH its window — the float is the tab's pane.
- const float = floatWithTab(state, tabId)
- if (float !== undefined) {
- closed = float.tab
- return closeFloatByTab(state, tabId)
- }
const paneId = findPaneIdOf(state, tabId)
- const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
+ const leaf = leafWithTab(state.bottomSplits, tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
return closeTabReducer(state, paneId, tabId)
})
@@ -915,14 +871,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
store.reduce((state) => {
// Unknown tab ids are a strict no-op (no state churn / notify).
if (!tabOpenIn(state, tabId)) return state
- // A floating tab "activates" by raising its window — no pane switch.
- const float = floatWithTab(state, tabId)
- if (float !== undefined) {
- activated = float.tab
- return raiseFloat(state, float.id)
- }
const paneId = findPaneIdOf(state, tabId)
- const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
+ const leaf = leafWithTab(state.bottomSplits, tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
return activateTabReducer(state, paneId, tabId)
})
@@ -968,37 +918,30 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
/**
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
- * same type exists, focus it; otherwise land the tab through
- * `openTabInActivePane` (the id safety net + active-pane landing are that
- * reducer's job — not re-implemented here).
+ * same type exists, focus it; otherwise land the tab through `land` (the id
+ * safety net + landing are that reducer's job — not re-implemented here).
* `single: true` resolves to the id-key sugar when no explicit key is given.
*/
function applyDedupe(
state: SidebarState,
tab: SidebarTab,
descriptor: TabDescriptor,
- land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInActivePane,
+ land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInBottomPane,
): SidebarState {
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
- // 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))) {
+ for (const leaf of allLeaves(state.bottomSplits)) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
- // A floating instance focuses by raising its window (no duplicate tab,
- // no panel expansion — the window is the tab's pane).
- const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
- if (floated !== undefined) return raiseFloat(state, floated.id)
}
return land(state, tab)
}
-/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
+/** Find which pane hosts a tab id ('' if none). */
function findPaneIdOf(state: SidebarState, tabId: string): string {
- for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
+ for (const leaf of allLeaves(state.bottomSplits)) {
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
}
return state.activePane ?? ''
diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css
index 3694a6b..6bd4146 100644
--- a/src/client/sidebar.module.css
+++ b/src/client/sidebar.module.css
@@ -69,41 +69,6 @@
left: 0;
}
-/* 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. Host-internal z-index 45
- (above the panels' 40); the host's global layer is z-index 25, so the
- whole cluster stays below DSH's ui-cordis dynamic-plugin panel (30) and
- the app's overlay stack (menus/tooltips/modals sit at 100+), so a cordis
- approval popup is never hidden behind the workbench (issue #52). Absolute
- within the panel host (the host's inset: 0 makes viewport coordinates
- identical). */
-.toggleCluster {
- position: absolute;
- /* env() resolves to 0 on devices without a top inset (desktops). */
- top: calc(3px + env(safe-area-inset-top));
- right: 10px;
- z-index: 45;
- display: flex;
- flex-direction: row;
- gap: 4px;
- pointer-events: auto;
- /* The cluster recenters between the panel's tab strip (top: 3) and DSH's
- session header row (top: 14, see the collapsed rule below) on every
- expand/collapse — ride the panel slide's own duration/easing so the two
- moves stay in lockstep instead of the cluster snapping. */
- transition: top var(--ds-transition-duration-slow) var(--ds-ease-in-out);
-}
-
-/* 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),
@@ -136,75 +101,6 @@
cursor: default;
}
-.panel {
- box-sizing: border-box;
- position: absolute;
- 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;
- /* Host-internal z-index 40 (sibling of the bottom panel's 40; the two
- never overlap — the bottom panel's right edge is this panel's left
- edge — and both sit below the toggle cluster's 45). The absolute
- global layer comes from the host ([data-dsh-panel-host], 25) — the
- whole stack stays above the app's in-flow content and below DSH's
- ui-cordis dynamic-plugin panel (30) and the app's overlay stack (100+),
- so menus/tooltips/modals always win over the panels (issue #52). The
- corner handle is a child and stacks within the panel. */
- z-index: 40;
- display: flex;
- flex-direction: column;
- /* Re-enable interaction inside the click-transparent panel host. */
- pointer-events: auto;
- /* The generic card surface (see the root contract above). Never
- `--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
- app's own left nav and a panel consuming it loses its fill or clashes
- with labels (#60/#105/#106). */
- background: var(--dsw-alias-bg-layer-1);
- border-left: 1px solid var(--dsw-alias-border-l2);
- /* Bottom safe-area (notched phones): the drawer's bottom content clears
- the home indicator; 0 on desktops. */
- padding-bottom: env(safe-area-inset-bottom);
- /* Expand/collapse slides the panel; fullscreen animates the width. */
- transition:
- transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
- width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
-}
-
-/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
-.panelHidden {
- transform: translateX(102%);
- pointer-events: none;
- visibility: hidden;
- transition:
- transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
- width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
- visibility 0s linear var(--ds-transition-duration-slow);
-}
-
-/* Width drags write at pointer cadence; easing would detach the edge. */
-.panel[data-dragging] {
- transition: none;
-}
-
-.panelResize {
- position: absolute;
- /* Centered on the panel edge (an 8px hit strip straddling the border, the
- same pattern as the host's own frame handles). */
- left: -4px;
- top: 0;
- bottom: 0;
- width: 8px;
- cursor: col-resize;
- z-index: 2;
- touch-action: none;
-}
-
-.panelResizeActive {
- background: var(--dsw-alias-interactive-bg-hover-accent);
-}
-
.panelBody {
flex: 1;
min-height: 0;
@@ -263,7 +159,6 @@
will-change: transform is only live while a drag is (the body attribute
toggles with the drag state), promoting the panels to their own layer
without paying the compositing cost at rest. */
-.panel,
.bottomPanel {
contain: layout style;
}
@@ -324,148 +219,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding-right: 40px;
}
-/* ── Free windows ──────────────────────────────────────────────────────── */
-
-/* One floating tab: the same card surface as the panels, at viewport
- coordinates inside the panel host (immune to shell transforms). z-index
- 42: above the panels (40) so a window drags across them, below the
- toggle cluster (45) and the app's overlay stack (100+, issue #52).
- Stacking among windows is the floats array order (siblings share the
- z-index; DOM order wins). Deliberately NO box transition — the header/
- resize drags write left/top/width/height per frame. Elevated surface,
- so unlike the flat panels it earns a shadow token. */
-.floatWindow {
- position: absolute;
- z-index: 42;
- display: flex;
- flex-direction: column;
- /* Re-enable interaction inside the click-transparent panel host. */
- pointer-events: auto;
- background: var(--dsw-alias-bg-layer-1);
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 8px;
- box-shadow: var(--dsw-shadow-lv3);
- overflow: hidden;
- contain: layout style;
-}
-
-.floatWindowDragging {
- will-change: left, top, width, height;
-}
-
-/* The header: the move/dock-back drag handle, mirroring the tab strip's
- chrome (34px, hairline bottom, secondary label). */
-.floatHeader {
- flex: none;
- display: flex;
- align-items: center;
- gap: 4px;
- height: 34px;
- padding: 0 4px 0 10px;
- font: var(--dsw-font-xxs-12);
- color: var(--dsw-alias-label-secondary);
- background: var(--dsw-alias-bg-layer-1);
- border-bottom: 1px solid var(--dsw-alias-border-l1);
- cursor: grab;
- user-select: none;
-}
-
-.floatWindowDragging .floatHeader {
- cursor: grabbing;
-}
-
-.floatTitle {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Mirrors .tabClose. */
-.floatClose {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- height: 18px;
- padding: 0;
- border: none;
- border-radius: 4px;
- background: transparent;
- color: var(--dsw-alias-label-tertiary);
- cursor: pointer;
- flex: none;
-}
-
-.floatClose:hover {
- background: var(--dsw-alias-interactive-bg-hover);
- color: var(--dsw-alias-label-primary);
-}
-
-/* Mirrors .paneContent's column-flex contract: the tab cell must hand its
- child a definite WIDTH via align-stretch. A row flex (the default) sizes
- the main axis (width) from content — intrinsic-zero hosts (e.g. a plugin
- tab UI) collapse to a 0px-wide sliver and paint nothing (the blank
- whale-report window). */
-.floatContent {
- flex: 1;
- min-height: 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-/* The SE resize corner (mirror of .cornerHandle). */
-.floatResize {
- position: absolute;
- right: 0;
- bottom: 0;
- width: 14px;
- height: 14px;
- z-index: 2;
- cursor: nwse-resize;
- touch-action: none;
-}
-
-.floatResize:hover {
- background: var(--dsw-alias-interactive-bg-hover-accent);
-}
-
-/* A pane highlighted as the dock target while a float header drag hovers it
- (the attribute is written directly by the drag at pointer cadence;
- skin-addressable via [data-dsh-pane][data-dsh-float-dock-over]). */
-.pane[data-dsh-float-dock-over] {
- outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
- outline-offset: -2px;
-}
-
-/* The drag-out hint over the conversation column: a dashed frame marking
- the drop zone, pointer-transparent so it never disturbs the drag it
- describes. z-index 46: above the windows (42) for unambiguous feedback,
- still below the app's overlay stack. */
-.floatDropHint {
- position: absolute;
- z-index: 46;
- pointer-events: none;
- border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 12%, transparent);
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.floatDropHintLabel {
- padding: 4px 12px;
- border-radius: 999px;
- font: var(--dsw-font-xxs-12);
- color: var(--dsw-alias-label-primary);
- background: var(--dsw-alias-bg-layer-1);
- border: 1px solid var(--dsw-alias-border-l2);
-}
-
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
/* Frameless desktop shells make the top strip of the web content draggable
@@ -475,10 +228,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
wrappers) have no blanket button rule. The property is a no-op in plain
browsers and in shells without drag regions — safe everywhere. */
-.toggleCluster,
.toggleButton,
-.tabBar,
-.floatHeader {
+.tabBar {
-webkit-app-region: no-drag;
}
@@ -487,67 +238,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* Windows frameless / hidden-title-bar windows draw the native caption
strip (minimize / maximize / close) at the window's top-right corner,
OVER the web content. When the user enables the "position compatibility"
- pref, the sidebar yields the strip: the toggle cluster drops below it and
- the right panel's content starts below it — the panel's surface still
- spans the full height, so the caption buttons float over empty sidebar
- fill (the VSCode hidden-title-bar look). The strip height is user-tunable
- (`--dsh-title-bar-strip`, written by the Sidebar shell from
- `titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
- sits at the window's bottom, never under the top strip. */
-:global(body[data-dsh-title-bar-compat]) .toggleCluster {
- top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
-}
-
-:global(body[data-dsh-title-bar-compat]) .panel {
- /* The caption strip is drawn over the panel's own top edge: keep the
- surface full-height and push the content (the tab strip) below the
- strip instead. .panelResize is absolutely positioned and unaffected. */
- padding-top: var(--dsh-title-bar-strip, 40px);
-}
-
-/* ── Collapsed cluster alignment with the DSH web session header ───────── */
-
-/* While the right panel is open, the cluster rides the panel's 34px tab
- strip (28px buttons at top: 3). Collapsed, it floats over DSH's own
- session header instead — whose controls (title, "Session log" capsule)
- are vertically centered at 28px on both desktop and narrow viewports.
- top: 3 then strands the cluster ~11px above that row; re-center the
- 28px buttons at 14px. The attribute disappears while the panel is open,
- so the tab-strip alignment above is untouched. With position
- compatibility active the DSH header sits below the caption strip, hence
- strip + 14. */
-:global(body[data-dsh-sidebar-collapsed]) .toggleCluster {
- top: calc(14px + env(safe-area-inset-top));
-}
-
-:global(body[data-dsh-sidebar-collapsed][data-dsh-title-bar-compat]) .toggleCluster {
- top: calc(var(--dsh-title-bar-strip, 40px) + 14px);
-}
-
-/* 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. Rendered INSIDE the right panel and positioned
- relative to it (left edge + the bottom panel's height from the layout
- variable), so no JS-written viewport coordinates are involved. The
- z-index is panel-local (above the panel's content; the panel itself sits
- at 40, below the app's overlay stack). */
-.cornerHandle {
- position: absolute;
- left: -6px;
- bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
- width: 12px;
- height: 12px;
- z-index: 2;
- cursor: nwse-resize;
- touch-action: none;
-}
-
-.cornerHandle:hover,
-.cornerHandle[data-dragging] {
- background: var(--dsw-alias-interactive-bg-hover-accent);
-}
-
+ pref, the Sidebar shell publishes the strip height as
+ `--dsh-title-bar-strip` plus `body[data-dsh-title-bar-compat]` for the
+ user's own CSS; the plugin's remaining chrome (the bottom workbench) sits
+ at the window's bottom and is never under the top strip. */
/* 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. */
@@ -2444,11 +2138,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* Reduced motion: no slides, fades or color transitions. */
@media (prefers-reduced-motion: reduce) {
- .panel,
- .panelHidden,
.bottomPanel,
.bottomPanelHidden,
- .toggleCluster,
.toggleButton,
.tab,
.tabBarPlus,
@@ -2465,20 +2156,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
- strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
- mobile layout — one full-screen drawer (the bottom panel's tabs were
- migrated into the right tree), one toggle button. The structural
- switches (which panels render, the migration) are JS-driven via
- useNarrowViewport; this block only adapts the pure style details. */
+ strictly below 768px (i.e. max-width: 767px) get the phone-width tab
+ rhythm; the bottom workbench keeps its own geometry (it spans the center
+ column at every width). */
@media (max-width: 767px) {
- /* The toggle cluster holds a single button on narrow viewports (the
- bottom panel button is not rendered), so the open panel's tab strip
- reserves 40px instead of the desktop 72px — the tabs and + menu still
- yield space to the one remaining control. */
- .panel:not(.panelHidden) .tabBar {
- padding-right: 40px;
- }
-
/* Narrower tabs: a phone-width drawer fits more open tabs before the
strip scrolls. */
.tab {
diff --git a/src/client/sidebar/bottom-toggle.tsx b/src/client/sidebar/bottom-toggle.tsx
new file mode 100644
index 0000000..8af9a0e
--- /dev/null
+++ b/src/client/sidebar/bottom-toggle.tsx
@@ -0,0 +1,29 @@
+/**
+ * The bottom workbench's expand/collapse control, registered into DSH's
+ * session header utilities list (`conversation.session.header.utilities`).
+ *
+ * DSH 0.1.5 draws its own right-Sidebar expand button in the header's corner
+ * slot (`conversation.session.header.corner`, a SINGLE slot owned by the
+ * native sidebar package), so the plugin cannot take that seat. The utilities
+ * list sits immediately left of it — the same row as the host's "Session
+ * log" capsule — which is where a dock toggle belongs: in flow, so it never
+ * overlaps host chrome and needs no absolute positioning or measurement.
+ *
+ * Registration goes through `slots.inject` because the declaration may not be
+ * on the ledger yet at plugin activation (the ui-conversation parent entry
+ * declares it); the callback re-runs if the declaration collapses and is
+ * re-declared. `order: 10` places the button after the host's own utilities.
+ */
+import type { Context } from '../../context-types.ts'
+import type { SidebarStore } from '../state.ts'
+import { BottomDockToggle } from '../Sidebar.tsx'
+
+/** Register the header toggle; returns the disposer. */
+export function registerBottomToggle(ctx: Context, store: SidebarStore): () => void {
+ return ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
+ name: 'conversation.session.header.utilities',
+ id: 'dsh-better-sidebar:bottom-toggle',
+ order: 10,
+ registrant: 'dsh-better-sidebar',
+ }, () => ))
+}
diff --git a/src/client/sidebar/free-windows.tsx b/src/client/sidebar/free-windows.tsx
deleted file mode 100644
index 56e13b8..0000000
--- a/src/client/sidebar/free-windows.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * Free windows (extracted from Sidebar.tsx, behavior identical): the
- * drag-out gesture that floats a tab onto the conversation column, plus the
- * render layer for the floating windows and its drop-zone hint overlay.
- */
-import { useEffect, useRef, useState, type ReactNode } from 'react'
-import type { Context } from '../../context-types.ts'
-import {
- dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
- type FloatWindow, type SidebarStore, type SidebarTab,
-} from '../state.ts'
-import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
-import { FreeWindow } from '../FreeWindow.tsx'
-import { t } from '../locales.ts'
-import css from '../sidebar.module.css'
-
-/** The dashed drop-zone hint's geometry (viewport coordinates). */
-export interface FloatDropHint {
- left: number
- top: number
- width: number
- height: number
-}
-
-/**
- * Free windows — drag-out detection. The tab strips already drive HTML5
- * DnD (payload application/x-dsh-tab) with drops owned by the panes
- * (split/merge); this hook watches the DOCUMENT (capture) for the same
- * drag hovering OUTSIDE the panel host: while the pointer is over the
- * conversation column it arms the drop (preventDefault) and shows a hint
- * overlay there, and the drop floats the tab at the release point. Targets
- * inside the host are ignored here, so pane drops keep their behavior
- * untouched. Only OUR tab drags count (the body flag is the tab strip's;
- * OS file drags and any DSH drags pass through). Narrow viewports skip
- * the gesture — the merged drawer covers the conversation, leaving
- * nothing to drop onto (the tab context menu entry still floats tabs).
- */
-export function useFloatDragout(input: {
- narrow: boolean
- sessionId: string | undefined
- store: SidebarStore
- /** The AppFrame center column (from useCenterColumn); stable ref object. */
- centerColRef: { readonly current: HTMLElement | null }
-}): { floatHint: FloatDropHint | null } {
- const { narrow, sessionId, store, centerColRef } = input
- const [floatHint, setFloatHint] = useState(null)
- const floatHintRef = useRef(false)
- useEffect(() => {
- if (narrow || sessionId === undefined) return
- const inPanelHost = (target: EventTarget | null): boolean =>
- target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
- /** The conversation column's rect when the pointer is over it (and not
- * over our own surfaces); null otherwise. */
- const overConversation = (event: DragEvent): DOMRect | null => {
- if (inPanelHost(event.target)) return null
- const col = centerColRef.current
- if (col === null || !col.isConnected) return null
- const rect = col.getBoundingClientRect()
- if (rect.width === 0 || rect.height === 0) return null
- const { clientX: x, clientY: y } = event
- if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
- return rect
- }
- const onDragOver = (event: DragEvent): void => {
- if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
- const rect = overConversation(event)
- if (rect !== null) {
- // preventDefault on dragover is what makes the browser deliver the
- // drop (and drop the "no" cursor) over the conversation area.
- event.preventDefault()
- setFloatHint((prev) => {
- const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
- if (prev !== null && prev.left === next.left && prev.top === next.top
- && prev.width === next.width && prev.height === next.height) return prev
- return next
- })
- floatHintRef.current = true
- } else if (floatHintRef.current) {
- floatHintRef.current = false
- setFloatHint(null)
- }
- }
- const onDrop = (event: DragEvent): void => {
- if (!floatHintRef.current) return
- floatHintRef.current = false
- setFloatHint(null)
- const rect = overConversation(event)
- if (rect === null) return
- event.preventDefault()
- event.stopPropagation()
- const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
- if (payload === null) return
- store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
- }
- const clear = (): void => {
- if (!floatHintRef.current) return
- floatHintRef.current = false
- setFloatHint(null)
- }
- document.addEventListener('dragover', onDragOver, true)
- document.addEventListener('drop', onDrop, true)
- window.addEventListener('dragend', clear, true)
- window.addEventListener('blur', clear)
- return () => {
- document.removeEventListener('dragover', onDragOver, true)
- document.removeEventListener('drop', onDrop, true)
- window.removeEventListener('dragend', clear, true)
- window.removeEventListener('blur', clear)
- }
- // centerColRef is a stable ref object from useCenterColumn (it lost its
- // useRef provenance crossing the hook boundary, so it is listed here).
- }, [narrow, sessionId, store, centerColRef])
-
- return { floatHint }
-}
-
-/**
- * The free-window render layer: tabs dragged out onto the conversation area
- * (or floated from the tab context menu). They live in the panel host like
- * the panels (viewport coordinates, immune to desktop-shell transforms) but
- * are independent of panel state — a window stays up while panels collapse.
- * The floats array's order is the stacking order; the content reuses the
- * regular tab renderer, so every tab type floats unchanged. Renders a
- * fragment so the DOM is exactly the floats followed by the drag-out hint.
- */
-export function FreeWindowLayer(props: {
- floats: readonly FloatWindow[]
- hint: FloatDropHint | null
- renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
- getTabIcon: (tab: SidebarTab) => ReactNode
- store: SidebarStore
- ctx: Context
- sessionId: string
- cwd: string | undefined
-}) {
- const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
- return (
- <>
- {floats.map(float => (
- renderTab(tab, active, paneId, 'float')}
- getTabIcon={getTabIcon}
- onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
- onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
- onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
- onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
- onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
- />
- ))}
- {/*
- The drag-out hint: while a tab drag hovers the conversation column,
- a dashed overlay marks the drop zone there (pointer-transparent — it
- must not disturb the drag it describes).
- */}
- {hint !== null && (
-
- {t('floatDropHint')}
-
- )}
- >
- )
-}
diff --git a/src/client/sidebar/use-center-column.ts b/src/client/sidebar/use-center-column.ts
index 2b5d076..66243f1 100644
--- a/src/client/sidebar/use-center-column.ts
+++ b/src/client/sidebar/use-center-column.ts
@@ -1,13 +1,12 @@
/**
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
- * the bottom panel spans ONLY the app shell's 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 directly from the AppFrame's center
- * column DOM (the parent of the [data-slot="conversation"] wrapper —
- * layout.css's center column) so the bottom panel tracks the column's real
- * horizontal edges — including the animated AppFrame padding reservation
- * while the right panel opens/closes; a frame that never appears keeps the
+ * the bottom workbench spans ONLY the app shell's 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. Measured directly from the
+ * AppFrame's center column DOM (the parent of the [data-slot="conversation"]
+ * wrapper — layout.css's center column) so the workbench tracks the column's
+ * real horizontal edges, including the animated track moves while DSH's
+ * native right Sidebar opens/closes; a frame that never appears keeps the
* initial zero-size fallback (the panel renders at 0 width until measured).
* The rect lives in a REF (not state): the open/close transition resizes
* the center column EVERY frame for its duration, and reacting per frame
diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts
index 0028690..5b8505c 100644
--- a/src/client/sidebar/use-host-feeds.ts
+++ b/src/client/sidebar/use-host-feeds.ts
@@ -7,8 +7,7 @@
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
-import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts'
-import { isNarrowWidth } from '../breakpoints.ts'
+import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { t } from '../locales.ts'
@@ -197,17 +196,7 @@ export function useHostFeeds(feeds: {
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
- // Read the viewport when the delayed activation fires: a resize while
- // the debounce is armed must not let background activity force the
- // narrow full-screen drawer open over the chat.
- if (!isNarrowWidth(window.innerWidth)) {
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
- }
- // Choose the right panel as the landing pane for a newly created Tasks
- // tab. Single-instance dedupe still activates an existing pane tab in
- // place or raises an existing free window.
- store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
- ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
+ ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
@@ -237,11 +226,7 @@ export function useHostFeeds(feeds: {
if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
- if (!isNarrowWidth(window.innerWidth)) {
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
- }
- store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
- ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
+ ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, [sessionList, sessionId, store, ctx])
/**
@@ -260,9 +245,7 @@ export function useHostFeeds(feeds: {
const pending = subagentJumpRef.current
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.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
+ ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, [sessionId, store, ctx])
return { subagentJumpRef }
diff --git a/src/client/sidebar/use-pinned-tabs.ts b/src/client/sidebar/use-pinned-tabs.ts
index ce75248..689b503 100644
--- a/src/client/sidebar/use-pinned-tabs.ts
+++ b/src/client/sidebar/use-pinned-tabs.ts
@@ -1,13 +1,13 @@
/**
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
- * leaf of the right panel's split tree, and the workbench actions are
+ * leaf of the bottom workbench's split tree, and the workbench actions are
* wrapped so virtual ids route back to the HOME session. The shell only
* consumes the augmented tree and the wrapped actions.
*/
import { useMemo, useState } from 'react'
import {
- agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
+ agentUuidOf, closeTab, isAgentTabId, leafWithTab, setTabPin,
type SidebarSnapshot, type SidebarStore, type SplitNode,
} from '../state.ts'
import {
@@ -29,8 +29,8 @@ export function usePinnedTabs(input: {
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
- * inject as VIRTUAL tabs into the first leaf of the right panel's split
- * tree. The virtual tabs have unique ids (prefixed with the home session)
+ * inject as VIRTUAL tabs into the first leaf of the bottom workbench's
+ * split tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
@@ -65,11 +65,11 @@ export function usePinnedTabs(input: {
[pinnedEntries],
)
- /** The right panel's split tree with pinned virtual tabs injected into the
- * first leaf. When `activePinnedTabId` is set, that leaf's `active` is
- * overridden so the pinned tab's content is visible. */
+ /** The bottom workbench's split tree with pinned virtual tabs injected
+ * into the first leaf. When `activePinnedTabId` is set, that leaf's
+ * `active` is overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
- () => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
+ () => state === undefined ? undefined : injectPinnedIntoTree(state.bottomSplits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
@@ -89,10 +89,8 @@ export function usePinnedTabs(input: {
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
store.reduceFor(homeSessionId, s => {
- const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
- if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
- if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
- return s
+ const leaf = leafWithTab(s.bottomSplits, originalId)
+ return leaf === undefined ? s : closeTab(s, leaf.id, originalId)
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
@@ -131,10 +129,6 @@ export function usePinnedTabs(input: {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
- floatTab: (tabId) => {
- if (isPinnedVirtualId(tabId)) return
- actions.floatTab(tabId)
- },
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return
diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx
index 2ed592a..7894c11 100644
--- a/src/client/split-pane.tsx
+++ b/src/client/split-pane.tsx
@@ -29,8 +29,6 @@ export interface WorkbenchActions {
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
- /** Float a docked tab out as a free window (tab context menu entry). */
- floatTab: (tabId: string) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). The shell snapshots the home cwd
* at pin time; null clears the pin. Optional: when undefined the tab
@@ -216,7 +214,6 @@ function LeafView(props: {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
- onFloatTab={actions.floatTab}
onPinTab={actions.pinTab}
/>
{leaf.tabs.length > 0 ? (
@@ -319,7 +316,7 @@ export function Workbench(props: {
return (
` so
- * a reload restores the exact layout of the session it belongs to — switching
+ * Per-session sidebar state: the bottom workbench's split-pane tree, open
+ * tabs, and the explorer expansion set. One state instance per conversation
+ * id, persisted to localStorage under `dsh-sidebar:v1:` so a reload
+ * restores the exact layout of the session it belongs to — switching
* conversations swaps the whole state (memory + isolation).
*
* The split tree is a recursive structure: a leaf holds a tab group, a split
@@ -10,7 +10,6 @@
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
-import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (editor / git / terminal
@@ -64,30 +63,9 @@ export interface SidebarSplit {
export type SplitNode = SidebarLeaf | SidebarSplit
-/**
- * One free window: a tab dragged out of the workbench onto the conversation
- * area floats in the panel host at viewport coordinates. The tab is OWNED by
- * the window exactly like a pane owns its tabs (moved, not copied); geometry
- * persists with the session so a reload restores the window in place.
- * Stacking order is the array order (last = topmost).
- */
-export interface FloatWindow {
- id: string
- tab: SidebarTab
- /** Viewport coordinates of the window's top-left corner. */
- x: number
- y: number
- w: number
- h: number
-}
-
/** The full per-session state. */
export interface SidebarState {
- panelOpen: boolean
- width: number
- /** 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}. */
+ /** The pane receiving newly opened tabs (the last pane the user touched). */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
@@ -101,9 +79,7 @@ export interface SidebarState {
* unhighlighted.
*/
revealed: string[]
- /** The right sidebar's split tree (the original workbench). */
- splits: SplitNode
- /** Whether the bottom panel (a second, independent workbench) is open. */
+ /** Whether the bottom panel (the plugin's one workbench) is open. */
bottomOpen: boolean
/** The bottom panel's height (clamped to the contract range). */
bottomHeight: number
@@ -113,31 +89,18 @@ export interface SidebarState {
* 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). */
+ /** The bottom workbench's split tree. */
bottomSplits: SplitNode
- /** Free windows (tabs dragged out onto the conversation area). */
- floats: FloatWindow[]
}
-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}). */
+/** Bottom panel geometry contract (the upper bound is the viewport, enforced
+ * by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
-/** Free-window geometry contract: the floor keeps the window usable (a
- * header plus some content), the ceiling is the viewport. */
-export const FLOAT_MIN_W = 320
-export const FLOAT_MIN_H = 200
-/** Geometry a fresh free window starts with: a phone-like portrait ratio
- * (390×780 ≈ 1:2). The creation path additionally caps the size to the
- * viewport (minus a 24px margin), so a short viewport gets a shorter —
- * not overflowing — window instead of an exact ratio. */
-export const FLOAT_DEFAULT_W = 390
-export const FLOAT_DEFAULT_H = 780
+/** The conversation column keeps at least this much height when the bottom
+ * workbench claims space (see {@link setBottomHeight}). */
+export const CONVERSATION_MIN = 280
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -155,7 +118,7 @@ export function mintTabId(): string {
/**
* The largest numeric suffix across a raw persisted state's counter ids
- * (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and
+ * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
* resets on every reload, so a split minted AFTER a reload would collide
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
* mapLeaf would then visit BOTH leaves and every open would land in both
@@ -166,7 +129,7 @@ function maxCounterId(parsed: unknown): number {
let max = 0
const consider = (id: unknown): void => {
if (typeof id !== 'string') return
- const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id)
+ const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
if (match !== null) max = Math.max(max, Number(match[1]))
}
const walk = (node: unknown): void => {
@@ -182,75 +145,31 @@ function maxCounterId(parsed: unknown): number {
for (const child of record.children) walk(child)
}
}
- walk((parsed as Record | null)?.splits)
walk((parsed as Record | null)?.bottomSplits)
- // Free windows mint through the same shared counter; their ids must seed it
- // too (a persisted float:N with a lower pane max would collide otherwise).
- const floats = (parsed as Record | null)?.floats
- if (Array.isArray(floats)) {
- for (const float of floats) {
- if (float !== null && typeof float === 'object') consider((float as Record).id)
- }
- }
return max
}
-/** The default tab a fresh session seeds. */
-export type DefaultSeed = 'editor-home' | 'none'
-
-/** A fresh default state: one seeded tab in one pane, open per the caller's
- * preference. `width` is the caller's preferred panel width (default
- * PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
- * true); the store seeds new sessions from the user's side card prefs.
- * `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
- * (an editor tab with no path whose tree panel starts open,
- * `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
- * file explorer page — and 'none' starts with an empty pane (the store
- * passes it when the user disabled the editor tab type in settings). */
-export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
- const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
- if (seed === 'editor-home') {
- // No path: the editor host renders its empty-state hint and the docked
- // tree panel (treeOpen defaults open for path-less tabs; meta pins it).
- leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
- 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).
+/**
+ * A fresh default state: one empty pane in the bottom workbench, closed.
+ * (The right column belongs to DSH's native Sidebar, so this plugin's own
+ * layout has nothing to seed — its welcome cards offer the openable types on
+ * first expansion.)
+ */
+export function makeDefaultState(): SidebarState {
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return {
- panelOpen,
- width,
- activePane: leaf.id,
+ activePane: bottomLeaf.id,
nextTerminal: 1,
nextBrowser: 1,
expanded: [],
revealed: [],
- splits: leaf,
bottomOpen: false,
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
- floats: [],
}
}
-/** 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') {
@@ -275,44 +194,6 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
return firstLeaf(node.children[0]!)
}
-/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
-function clearAllTabs(node: SplitNode): SplitNode {
- if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
- return { ...node, children: node.children.map(clearAllTabs) }
-}
-
-/**
- * Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
- * right sidebar — the "merged display" on mobile is the right panel alone,
- * whose tab strips now carry the bottom tree's tabs (depth-first order,
- * appended to the right tree's FIRST leaf). The bottom tree is emptied (its
- * structure stays — the desktop bottom panel re-renders its welcome cards)
- * and the panel closes. The active pane moves to the right tree's first
- * leaf so every new tab lands in the visible panel.
- *
- * Idempotent: a bottom tree with no tabs and a closed panel returns the
- * same reference. Runs when the viewport enters narrow (see the Sidebar
- * shell); migrating is permanent for the session — the tabs now live in the
- * right tree, exactly like the user "threw them in".
- */
-export function migrateBottomTabs(state: SidebarState): SidebarState {
- const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
- const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
- if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
- const target = firstLeaf(state.splits)
- return {
- ...state,
- activePane: target.id,
- bottomOpen: false,
- splits: bottomTabs.length > 0
- ? mapLeaf(state.splits, target.id, leaf => {
- leaf.tabs = [...leaf.tabs, ...bottomTabs]
- })
- : state.splits,
- bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
- }
-}
-
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
@@ -331,22 +212,9 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
-/** Whether a tab exists anywhere in a state (either tree, any pane, or any
- * free window — a floating tab is as open as a docked one). */
+/** Whether a tab is open anywhere in the session's workbench. */
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))
- || state.floats.some(float => float.tab.id === tabId)
-}
-
-/** The free window holding a tab id, if any. */
-export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
- return state.floats.find(float => float.tab.id === tabId)
-}
-
-/** The free window with the given window id, if any. */
-export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
- return state.floats.find(float => float.id === floatId)
+ return 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. */
@@ -402,9 +270,6 @@ 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,
@@ -417,38 +282,7 @@ export function moveTabToEdge(
// Dropped back onto its own pane's center: reorder to the end.
return moveTab(state, fromPane, tabId, toPane, -1)
}
- 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 node = state.bottomSplits
const source = leafWithTab(node, tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
@@ -464,11 +298,11 @@ export function moveTabToEdge(
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
- return { ...state, [key]: splits, activePane: toPane }
+ return { ...state, bottomSplits: splits, activePane: toPane }
}
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
- return { ...state, [key]: result.node, activePane: result.leafId }
+ return { ...state, bottomSplits: result.node, activePane: result.leafId }
}
/**
@@ -491,7 +325,7 @@ 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)
+ const key = 'bottomSplits'
let emptied = false
const splits = mapLeaf(state[key], paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
@@ -503,7 +337,7 @@ export function closeTab(state: SidebarState, paneId: string, tabId: string): Si
/** 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)
+ const key = 'bottomSplits'
return {
...state,
activePane: paneId,
@@ -541,17 +375,14 @@ export function patchTab(
const children = node.children.map(walk)
return children === node.children ? node : { ...node, children }
}
- const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
- const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
- return changed ? { ...state, splits, bottomSplits, floats } : state
+ return changed ? { ...state, bottomSplits } : state
}
/**
* Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is
* structural metadata (NOT display fields like title/path), so it walks
- * both split trees AND the free windows exactly like {@link patchTab} —
- * the tab may live in either tree or float. Passing `null` clears the pin
+ * the workbench's split tree exactly like {@link patchTab}. Passing `null` clears the pin
* (the tab stays open in its home session); passing a `{ scope, homeCwd }`
* object sets it. An unknown tab id is a strict no-op (same reference
* returned) so a stale pin request never churns the state or rewrites
@@ -606,80 +437,26 @@ export function setTabPin(
if (children.every((child, i) => child === node.children[i])) return node
return { ...node, children }
}
- const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
- const floatIdx = state.floats.findIndex(f => f.tab.id === tabId)
- const floats = floatIdx < 0 ? state.floats : (() => {
- const oldFloat = state.floats[floatIdx]!
- const newTab = apply(oldFloat.tab)
- if (newTab === oldFloat.tab) return state.floats
- const next = state.floats.slice()
- next[floatIdx] = { ...oldFloat, tab: newTab }
- return next
- })()
- return changed ? { ...state, splits, bottomSplits, floats } : state
+ return changed ? { ...state, bottomSplits } : state
}
/**
- * Land a tab in the active pane (or focus its existing instance by id).
- * Dedup strategies (single-instance, per-path, per-change) are owned by the
- * tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
- * this reducer only handles the id-based safety net (reconcile and
- * 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 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 — in a
- // pane (activate) or in a free window (raise, no panel switch).
- 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)
- }
- const floated = floatWithTab(state, tab.id)
- if (floated !== undefined) return raiseFloat(state, floated.id)
- return {
- ...state,
- activePane: targetId,
- [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
- leaf.tabs = [...leaf.tabs, tab]
- leaf.active = tab.id
- }),
- }
-}
-
-/**
- * Land a tab in the BOTTOM workbench's first pane (the bottom panel's own
- * opens — its + menu, the auto-terminal): the plugin's right tree is no longer
- * rendered on 0.1.5 (DSH's native sidebar is the right column), so an open
- * that must land in the bottom dock cannot follow the active pane, which may
- * point at the retired tree.
+ * Land a tab in the workbench's first pane — the plugin's own opens (its
+ * bottom-panel + menu, the auto-terminal, and every open when no native
+ * surface is installed): the plugin owns no right column any more (DSH's
+ * native sidebar is the right one), so the bottom workbench is the only tree.
* @param state - the session state.
* @param tab - the tab to land.
* @returns the next state, with the bottom panel open.
*/
export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): SidebarState {
const targetId = firstLeaf(state.bottomSplits).id
- // Id-based safety net: if a tab with the same id exists, focus it — in a
- // pane (activate) or in a free window (raise, no panel switch).
- for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
+ // Id-based safety net: if a tab with the same id exists, focus it.
+ for (const leaf of allLeaves(state.bottomSplits)) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
- const floated = floatWithTab(state, tab.id)
- if (floated !== undefined) return raiseFloat(state, floated.id)
return {
...state,
bottomOpen: true,
@@ -695,8 +472,8 @@ export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): Sideb
* 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)
+ const fromKey = 'bottomSplits'
+ const toKey = 'bottomSplits'
if (fromKey !== toKey) {
let moved: SidebarTab | undefined
let emptied = false
@@ -743,8 +520,8 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to
/** 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
- const key = treeOf(state, paneId)
+ const paneId = state.activePane ?? firstLeaf(state.bottomSplits).id
+ const key = 'bottomSplits'
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
}
@@ -763,14 +540,14 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
* @returns the new state, with the diff pane active.
*/
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
- const existingLeaf = leafWithTab(state.splits, tab.id)
+ const existingLeaf = leafWithTab(state.bottomSplits, tab.id)
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
- const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
+ const diffLeaf = allLeaves(state.bottomSplits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
if (diffLeaf !== undefined) {
return {
...state,
activePane: diffLeaf.id,
- splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
+ bottomSplits: mapLeaf(state.bottomSplits, diffLeaf.id, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
@@ -779,37 +556,25 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side
// First diff: split the source pane, the diff tab in the new LOWER leaf.
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
// regular open path instead of dropping the tab into an orphaned leaf.)
- if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
- return openTabInActivePane(state, tab)
+ if (!allLeaves(state.bottomSplits).some(leaf => leaf.id === sourcePaneId)) {
+ return openTabInBottomPane(state, tab)
}
- const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
- return { ...state, splits: result.node, activePane: result.leafId }
+ const result = insertLeafAt(state.bottomSplits, sourcePaneId, 'col', tab, false)
+ return { ...state, bottomSplits: result.node, activePane: result.leafId }
}
-/** Toggle the panel open/closed (opening restores the previous layout). */
-export function togglePanel(state: SidebarState): SidebarState {
- return { ...state, panelOpen: !state.panelOpen }
-}
-
-/** Toggle the bottom panel open/closed (independent of the right panel). */
+/** Expand/collapse the bottom workbench. */
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 {
- const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
- 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. */
+/** Set the bottom workbench height (clamped to the contract range). The
+ * upper bound leaves the conversation column at least {@link CONVERSATION_MIN}
+ * tall — without the cap the workbench 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)
+ const max = Math.max(BOTTOM_MIN, viewport - CONVERSATION_MIN)
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
}
@@ -874,147 +639,10 @@ 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)
+ const key = 'bottomSplits'
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
-// ── Free windows ────────────────────────────────────────────────────────────
-
-/** The viewport size, or Infinity where there is no (usable) window — unit
- * tests stub partial window objects, and a NaN bound would poison geometry. */
-function viewportW(): number {
- return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
-}
-
-function viewportH(): number {
- return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
-}
-
-/** Clamp free-window geometry: sizes respect the floor and the viewport, and
- * the position keeps the whole window inside the viewport. Without a window
- * (unit tests) only the floor applies — the caller's values pass through. */
-export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick {
- const vw = viewportW()
- const vh = viewportH()
- const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
- const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
- return {
- x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
- y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
- w: width,
- h: height,
- }
-}
-
-/**
- * Float a docked tab: remove it from its pane (either tree; an emptied pane
- * collapses like any move) and append a free window centered on the drop
- * point, with the default size clamped to the viewport. The stacking order
- * is the array order, so a fresh window is born topmost. An unknown tab id
- * (or one already floating) is a strict no-op.
- */
-export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
- let source: SidebarLeaf | undefined
- let key: 'splits' | 'bottomSplits' | undefined
- for (const treeKey of ['splits', 'bottomSplits'] as const) {
- source = leafWithTab(state[treeKey], tabId)
- if (source !== undefined) { key = treeKey; break }
- }
- if (key === undefined || source === undefined) return state
- const tab = source.tabs.find(candidate => candidate.id === tabId)!
- let emptied = false
- let node = 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) node = removeLeafAt(node, source.id)
- // Phone-ratio default, capped to the viewport before centering so the
- // clamped position never leaves the window's bottom past the fold.
- const vw = viewportW()
- const vh = viewportH()
- const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24))
- const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24))
- const window = clampFloatGeometry(
- x - width / 2,
- y - height / 2,
- width,
- height,
- )
- const next: SidebarState = {
- ...state,
- [key]: node,
- floats: [...state.floats, { id: uid('float'), tab, ...window }],
- }
- // The pane the user was working in may have just collapsed with the tab.
- if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
- return next
-}
-
-/** Move a free window (clamped to the viewport); unknown ids are a no-op. */
-export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
- const float = floatById(state, floatId)
- if (float === undefined) return state
- const geo = clampFloatGeometry(x, y, float.w, float.h)
- if (geo.x === float.x && geo.y === float.y) return state
- return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
-}
-
-/** Resize a free window from its SE corner: the top-left corner stays
- * anchored, sizes clamp to the floor and to the viewport's remaining room. */
-export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
- const float = floatById(state, floatId)
- if (float === undefined) return state
- const vw = viewportW()
- const vh = viewportH()
- const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
- const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
- if (width === float.w && height === float.h) return state
- return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
-}
-
-/** Bring a free window to the top (the array's end). Already topmost (or the
- * only window) returns the same reference — no persist churn on every click. */
-export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
- if (state.floats.length < 2) return state
- const index = state.floats.findIndex(f => f.id === floatId)
- if (index < 0 || index === state.floats.length - 1) return state
- const floats = [...state.floats]
- const [raised] = floats.splice(index, 1)
- floats.push(raised!)
- return { ...state, floats }
-}
-
-/** Dock a free window back into a pane (center merge): the tab joins the
- * target pane and activates. `toPane` defaults to the active pane with the
- * right tree's first leaf as the stale-id fallback (mirrors
- * {@link openTabInActivePane}). Unknown window ids are a no-op. */
-export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
- const float = floatById(state, floatId)
- if (float === undefined) return state
- let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
- if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
- targetId = firstLeaf(state.splits).id
- }
- const targetKey = treeOf(state, targetId)
- return {
- ...state,
- floats: state.floats.filter(f => f.id !== floatId),
- activePane: targetId,
- [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
- leaf.tabs = [...leaf.tabs, float.tab]
- leaf.active = float.tab.id
- }),
- }
-}
-
-/** Close the free window holding a tab (the tab closes WITH the window —
- * the caller fires the descriptor's onClose lifecycle). */
-export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
- if (!state.floats.some(f => f.tab.id === tabId)) return state
- return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
-}
-
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
@@ -1049,8 +677,7 @@ export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
- const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
- .concat(state.floats.map(float => float.tab))
+ const existingTabs = 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))
@@ -1064,29 +691,25 @@ export function reconcileAgentTerminals(
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined)
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
- // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
- // a FLOATED agent terminal leaves with its window.
- let splits = state.splits
- let floats = state.floats
+ // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
+ let bottomSplits = state.bottomSplits
for (const tab of toRemove) {
- const leaf = leafWithTab(splits, tab.id)
+ const leaf = leafWithTab(bottomSplits, tab.id)
if (leaf !== undefined) {
- splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
- }
- if (floats.some(float => float.tab.id === tab.id)) {
- floats = floats.filter(float => float.tab.id !== tab.id)
+ bottomSplits = closeTab({ ...state, bottomSplits }, leaf.id, tab.id).bottomSplits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
- // active pane via openTabInActivePane; the next reconcile is a no-op for them.
- let next: SidebarState = { ...state, splits, floats }
+ // workbench's landing pane via openTabInBottomPane; the next reconcile is a
+ // no-op for them.
+ let next: SidebarState = { ...state, bottomSplits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
type: 'terminal',
title: terminal.title,
}
- next = openTabInActivePane(next, tab)
+ next = openTabInBottomPane(next, tab)
}
return next
}
@@ -1102,37 +725,6 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
* cache-hit session switches, so a drag in one conversation carries to all
* the others (last drag wins).
*/
-const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width'
-
-/** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */
-function clampWidth(width: number): number {
- const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
- return Math.min(max, Math.max(PANEL_MIN, Math.round(width)))
-}
-
-/** Read the cross-session panel width (undefined when never dragged). */
-function readGlobalWidth(): number | undefined {
- try {
- const raw = localStorage.getItem(GLOBAL_WIDTH_KEY)
- if (raw !== null) {
- const parsed = Number(raw)
- if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed)
- }
- } catch {
- // Storage unavailable: fall back to the per-session behavior.
- }
- return undefined
-}
-
-/** Persist the cross-session panel width (best-effort, like the session states). */
-function writeGlobalWidth(width: number): void {
- try {
- localStorage.setItem(GLOBAL_WIDTH_KEY, String(width))
- } catch {
- // Storage full or unavailable: layout memory is best-effort.
- }
-}
-
/** Immutable snapshot handed to React (replaced only on real changes). */
export interface SidebarSnapshot {
sessionId: string | undefined
@@ -1145,13 +737,6 @@ export interface SidebarSnapshot {
prefs: SidebarPrefs
}
-/** Default panel width for one viewport: the prefs percent of the window,
- * clamped to the panel floor (a tiny percent must stay usable) and to the
- * viewport (a large one must never cover the whole window). */
-export function defaultWidthFor(viewport: number, percent: number): number {
- return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
-}
-
/**
* URL escape hatch (#369): loading the app with `?dsh-sidebar-reset` drops
* the persisted layout for the session instead of restoring it. When a
@@ -1171,21 +756,15 @@ function resetRequested(): boolean {
}
}
-function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
+function loadState(sessionId: string): SidebarState {
const reset = resetRequested()
- const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
if (reset) {
try {
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
- localStorage.removeItem(GLOBAL_WIDTH_KEY)
} catch {
// Storage unavailable: the default layout below is still the escape.
}
}
- // The panel width is a cross-session preference: the last dragged width
- // wins over a session's own persisted value, so switching conversations
- // keeps the width the user chose anywhere.
- const globalWidth = reset ? undefined : readGlobalWidth()
if (!reset) {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
@@ -1195,38 +774,13 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// sanitize re-ids any duplicates the pre-seeding counter left behind.
nextIdCounter = maxCounterId(parsed)
const sanitized = sanitizeState(parsed)
- if (sanitized !== undefined) {
- const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
- // A narrow panel is a full-screen drawer, so every page/session load
- // starts it closed even when the prior desktop/mobile state persisted
- // `panelOpen: true`. Preserve the entire restored workbench (panes,
- // tabs, bottom panel, and free windows); visibility is the sole
- // viewport-derived override.
- return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
- ? { ...restored, panelOpen: false }
- : restored
- }
+ if (sanitized !== undefined) return sanitized
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
}
- // New sessions seed from the user's side card prefs: the width is the
- // chosen percent of the window (clamped to the panel floor and the
- // viewport so a huge percent can never crush the app shell), the panel
- // starts open only when the preference says so, and the seed tab is the
- // empty files window (tree panel open) in BOTH editorExplorer modes — a
- // disabled editor type seeds nothing. On a NARROW viewport a brand-new
- // session starts collapsed instead — the panel is a full-screen drawer
- // there, and auto-opening it on first paint would cover the conversation
- // before the user asked. Persisted layouts follow the same narrow-load
- // visibility rule above, while their workbench contents remain intact.
- const width = globalWidth ?? (viewport === undefined
- ? PANEL_DEFAULT
- : defaultWidthFor(viewport, prefs.defaultWidthPercent))
- const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
- const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
- return makeDefaultState(width, openByDefault, seed)
+ return makeDefaultState()
}
/**
@@ -1240,8 +794,6 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record
- if (typeof record.panelOpen !== 'boolean') return undefined
- if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
@@ -1253,67 +805,36 @@ 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.
+ // Pane/split ids must be globally unique (the runtime uid counter is
+ // shared), so a duplicate id gets a fresh one.
const seen = new Set()
const reid = new Map()
- const restoredSplits = sanitizeNode(record.splits, seen, reid)
- if (restoredSplits === undefined) return undefined
- const splits = pruneEmptyPanes(restoredSplits)
- // 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.
+ // Workbench 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).
+ // Cap the persisted height so the conversation column (the agent output
+ // area) keeps a usable minimum (a stale full-height panel from an older
+ // build must never squeeze the conversation to zero). The cap is
+ // setBottomHeight's own contract, or a restored height would snap on the
+ // first drag.
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
- const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
+ const bottomCap = Math.max(BOTTOM_MIN, maxHeight - CONVERSATION_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 = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
- // Free windows arrived with the float feature: a missing field on an older
- // persisted state defaults to none (like the bottom-panel fields above).
- // Malformed ENTRIES drop individually — unlike the split trees, where
- // corruption resets the whole state, a bad window must not cost the user
- // their whole layout. Geometry clamps into the CURRENT viewport so a stale
- // off-screen window stays reachable; the window ids join the same
- // uniqueness set as the pane/split ids (the uid counter mints for both).
- const floats: FloatWindow[] = []
- if (Array.isArray(record.floats)) {
- for (const entry of record.floats) {
- if (entry === null || typeof entry !== 'object') continue
- const candidate = entry as Record
- if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
- const tab = sanitizePersistedTab(candidate.tab)
- if (tab === undefined || tab === 'diff') continue
- if (
- typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
- || typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
- || typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
- || typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
- ) continue
- seen.add(candidate.id)
- floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
- }
- }
const requestedActivePane = typeof record.activePane === 'string'
? (reid.get(record.activePane) ?? record.activePane)
: null
const activePane = requestedActivePane === null
? null
- : treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
+ : allLeaves(bottomSplits).some(leaf => leaf.id === requestedActivePane)
? requestedActivePane
- : firstLeaf(splits).id
- const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
+ : firstLeaf(bottomSplits).id
return {
- panelOpen: record.panelOpen,
- width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane,
@@ -1321,7 +842,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
nextBrowser,
expanded: record.expanded as string[],
revealed: [],
- splits,
bottomOpen,
bottomHeight,
// An older persisted state never expanded the bottom panel (the field
@@ -1329,7 +849,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
- floats,
}
}
@@ -1520,20 +1039,13 @@ export class SidebarStore {
} else {
let state = this.bySession.get(sessionId)
if (state === undefined) {
- state = loadState(sessionId, this.prefs)
+ state = loadState(sessionId)
this.bySession.set(sessionId, state)
} else {
// Cache hit: another session's load/ops may have left the uid
// counter below THIS session's persisted ids — re-seed so fresh
// pane/split ids can never collide with its tree.
nextIdCounter = maxCounterId(state)
- // The panel width is cross-session: adopt the latest dragged width
- // (a cached session keeps its own layout otherwise).
- const globalWidth = readGlobalWidth()
- if (globalWidth !== undefined && state.width !== globalWidth) {
- state = { ...state, width: globalWidth }
- this.bySession.set(sessionId, state)
- }
}
this.snapshot = { sessionId, state, prefs: this.prefs }
}
@@ -1624,7 +1136,7 @@ export class SidebarStore {
const counterBefore = nextIdCounter
let state = this.bySession.get(sessionId)
if (state === undefined) {
- state = loadState(sessionId, this.prefs)
+ state = loadState(sessionId)
this.bySession.set(sessionId, state)
} else {
// Re-seed the uid counter past THIS session's persisted ids, exactly
@@ -1641,14 +1153,6 @@ export class SidebarStore {
}
private schedulePersist(sessionId: string, state: SidebarState): void {
- // Keep the cross-session width in sync: any width change (drag, fullscreen
- // toggle) on the ACTIVE session becomes the shared width for every
- // conversation. A targeted open persists an INACTIVE session (reduceFor)
- // and must not clobber that global — its width is stale by definition,
- // so writing it would break "last drag wins".
- if (sessionId === this.snapshot.sessionId) {
- writeGlobalWidth(state.width)
- }
// Per-session debounce timers: one session's pending write must never
// cancel another's (targeted opens schedule writes for INACTIVE
// sessions while the active session may already have one pending —
diff --git a/src/client/tree-mutations.ts b/src/client/tree-mutations.ts
index 8b1719c..6fa1641 100644
--- a/src/client/tree-mutations.ts
+++ b/src/client/tree-mutations.ts
@@ -1,8 +1,8 @@
/**
* Open-tab reconciliation after a file-tree mutation (rename/delete).
*
- * The tree owns the rows; the TABS live in the sidebar state (either split
- * tree, the bottom panel, or free windows). A rename must retarget every
+ * The tree owns the rows; the TABS live in the sidebar state (the bottom
+ * workbench's split tree). A rename must retarget every
* tab whose `path` is the renamed file (the editor content survives and
* later saves land on the new path); a delete must close every tab at or
* under the removed path — files and anything inside a removed directory
@@ -16,15 +16,12 @@ import { baseName } from './FileTree.tsx'
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
import { isWithinWorkspace } from './paths.ts'
-/** Every open tab that carries a file path (either split tree, the bottom
- * panel, and free windows — a floating tab is as open as a docked one). */
+/** Every open tab that carries a file path. */
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
const state = snapshot.state
if (state === undefined) return []
const tabs: SidebarTab[] = []
- for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
- for (const float of state.floats) tabs.push(float.tab)
return tabs.filter(tab => tab.path !== undefined)
}
diff --git a/src/config.ts b/src/config.ts
index 48c3c02..07e5d4a 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -13,9 +13,6 @@ import {
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
- WIDTH_PERCENT_DEFAULT,
- WIDTH_PERCENT_MAX,
- WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from './prefs-shared.ts'
@@ -28,9 +25,6 @@ export {
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
- WIDTH_PERCENT_DEFAULT,
- WIDTH_PERCENT_MAX,
- WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from './prefs-shared.ts'
@@ -115,8 +109,6 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
export const PrefsSchema: z = z.object({
- openByDefault: z.boolean().default(false),
- defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
@@ -125,7 +117,6 @@ export const PrefsSchema: z = z.object({
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
editorExplorer: z.boolean().default(false),
- changesDiffFloat: z.boolean().default(true),
workspaceFence: z.boolean().default(true),
terminalShell: z.string().default(''),
terminalShellArgs: z.string().default(''),
diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts
index e8b2494..d08a9aa 100644
--- a/src/prefs-shared.ts
+++ b/src/prefs-shared.ts
@@ -9,24 +9,17 @@
/** The user-settings namespace holding the side card preferences. */
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
-/** User-facing side card preferences (new-conversation defaults). */
+/** User-facing side card preferences. */
export interface SidebarPrefs {
- /** Whether a brand-new conversation opens the side card by default. */
- openByDefault: boolean
- /** Default panel width as a percent of the window width (20–60). */
- defaultWidthPercent: number
/**
* Whether the sidebar auto-activates the Tasks page when the current
- * conversation spawns a new subagent. Wide viewports also open the panel;
- * narrow viewports prepare the tab without opening the full-screen drawer.
+ * conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the sidebar auto-activates the Tasks page containing the
* background-jobs section when a NEW job appears for the current
- * conversation (any new job id, not just the first one). Wide viewports also
- * open the panel; narrow viewports prepare the tab without opening the
- * full-screen drawer.
+ * conversation (any new job id, not just the first one).
*/
autoOpenJobs: boolean
/**
@@ -72,14 +65,6 @@ export interface SidebarPrefs {
* Side card settings; off restores the pre-merge editor exactly.
*/
editorExplorer: boolean
- /**
- * Where the changes tab's "expand to a diff tab" action lands the diff:
- * true (default) floats it as a free window centered on the viewport;
- * false docks it into the shell's diff pane below the source panel (the
- * pre-float behavior). The select lives under the changes card's gear in
- * the Side card settings.
- */
- changesDiffFloat: boolean
/**
* Whether the sidebar's filesystem routes enforce the workspace fence:
* every client-supplied path must resolve (through symlinks) inside the
@@ -238,11 +223,6 @@ export interface SidebarPrefs {
pluginSettings: Record>
}
-/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
-export const WIDTH_PERCENT_MIN = 20
-export const WIDTH_PERCENT_MAX = 60
-export const WIDTH_PERCENT_DEFAULT = 35
-
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
@@ -259,8 +239,6 @@ export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number]
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
- openByDefault: false,
- defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
@@ -269,7 +247,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
editorExplorer: false,
- changesDiffFloat: true,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
@@ -290,11 +267,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
pluginSettings: {},
}
-/** Clamp one width percent into the contract range (shared by schema and client reads). */
-export function clampWidthPercent(value: number): number {
- return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
-}
-
/** Clamp one terminal font size into the contract range (shared by schema and client reads). */
export function clampTerminalFontSize(value: number): number {
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts
index e9fdf65..ce15b79 100644
--- a/tests/agent-terminal-reconcile.spec.ts
+++ b/tests/agent-terminal-reconcile.spec.ts
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
- allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals,
+ allLeaves, makeDefaultState, openTabInBottomPane, reconcileAgentTerminals,
setTabPin, tabOpenIn, toggleBottomPanel,
} from '../src/client/state.ts'
describe('agent terminal reconciliation', () => {
it('adds tabs for new agent terminals', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'dev server' },
{ uuid: 'bbb-222', title: 'python repl' },
@@ -14,14 +14,14 @@ describe('agent terminal reconciliation', () => {
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The titles are preserved.
- const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
+ const tabs = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs)
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
expect(agentTab?.title).toBe('dev server')
expect(agentTab?.type).toBe('terminal')
})
it('removes tabs for agent terminals that vanished from the server list', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'remove' },
@@ -34,22 +34,22 @@ describe('agent terminal reconciliation', () => {
})
it('is a no-op (same reference) when the lists already match', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(next).toBe(s)
})
it('does not touch non-agent terminal tabs', () => {
- let s = makeDefaultState(400, true)
- s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
+ let s = makeDefaultState()
+ s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
})
it('handles an empty server list (removes all agent tabs)', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'a' },
{ uuid: 'bbb-222', title: 'b' },
@@ -59,20 +59,19 @@ describe('agent terminal reconciliation', () => {
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)
+ it('lands new agent terminals in the workbench pane', () => {
+ let s = makeDefaultState()
s = toggleBottomPanel(s)
- const bottomPane = (s.bottomSplits as { id: string }).id
- s = { ...s, activePane: bottomPane }
+ const pane = (s.bottomSplits as { id: string }).id
+ s = { ...s, activePane: pane }
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('pinned agent terminal exemption (v0.17.0)', () => {
it('keeps a pinned agent tab when its uuid vanishes from the server list', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'pinned' },
@@ -84,13 +83,13 @@ describe('agent terminal reconciliation', () => {
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The pin marker is intact.
- const tabs = allLeaves(s.splits).flatMap(l => l.tabs)
+ const tabs = allLeaves(s.bottomSplits).flatMap(l => l.tabs)
const pinned = tabs.find(t => t.id === 'agent:bbb-222')!
expect(pinned.pin).toEqual({ scope: 'global' })
})
it('still removes an unpinned agent tab when the uuid vanishes', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'pinned' },
{ uuid: 'bbb-222', title: 'unpinned' },
@@ -103,7 +102,7 @@ describe('agent terminal reconciliation', () => {
})
it('a pinned tab whose uuid RETURNS in a later push is a no-op (already exists)', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'gone' }])
s = setTabPin(s, 'agent:aaa-111', { scope: 'global' })
// First push without it: exempted, stays.
@@ -117,7 +116,7 @@ describe('agent terminal reconciliation', () => {
})
it('exemption does not block the toAdd path (new uuids still land)', () => {
- let s = makeDefaultState(400, true)
+ let s = makeDefaultState()
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'first' }])
s = setTabPin(s, 'agent:aaa-111', { scope: 'global' })
// aaa-111 vanishes (exempted), bbb-222 is brand-new.
diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts
index f14ca3f..ab47fa5 100644
--- a/tests/builtins.spec.ts
+++ b/tests/builtins.spec.ts
@@ -44,18 +44,9 @@ describe('built-in tab registrations', () => {
expect(changes?.component).toBeDefined()
})
- it('the changes tab declares the diff-open picker (free window default)', () => {
+ it('the changes tab declares no settings of its own (the diff always docks)', () => {
const { service } = setup()
- const toggles = service.getTab('git')?.settings?.toggles ?? []
- expect(toggles.map(t => t.key)).toEqual(['changesDiffFloat'])
- const picker = toggles[0]
- expect(picker?.type).toBe('select')
- expect(picker?.title).toBeDefined()
- expect(picker?.desc).toBeDefined()
- const options = picker?.options ?? []
- // Float first (the default the reducer and prefs ship), pane second.
- expect(options.map(o => o.value)).toEqual([true, false])
- expect(options.every(o => o.icon !== undefined && o.title !== undefined && o.desc !== undefined)).toBe(true)
+ expect(service.getTab('git')?.settings).toBeUndefined()
})
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
@@ -172,7 +163,7 @@ describe('built-in tab registrations', () => {
service.openTab({ type: 'browser' })
service.openTab({ type: 'browser' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
+ const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.id).toBe('browser:1')
expect(tabs[1]!.id).toBe('browser:2')
@@ -185,7 +176,7 @@ describe('built-in tab registrations', () => {
service.openTab({ type: 'terminal' })
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
+ const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.title).toBe('bash')
expect(tabs[1]!.title).toBe('bash')
@@ -199,7 +190,7 @@ describe('built-in tab registrations', () => {
store.setSession('s1')
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
- const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
+ const tab = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
expect(tab?.title).toBe(t('terminal'))
})
diff --git a/tests/consumer-types.ts b/tests/consumer-types.ts
index 19fe249..028097a 100644
--- a/tests/consumer-types.ts
+++ b/tests/consumer-types.ts
@@ -46,7 +46,7 @@ const tab: TabDescriptor = {
icon: (_size: number) => null,
order: 50,
hidden: false,
- available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null,
+ available: (ctx, scope, state) => scope.sessionId !== '' && state.bottomOpen && ctx !== null,
single: false,
dedupeKey: (t: SidebarTab) => t.id,
createTab: (state: SidebarState) => ({
diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts
index d073f06..366ac10 100644
--- a/tests/e2e/mount.e2e.ts
+++ b/tests/e2e/mount.e2e.ts
@@ -13,8 +13,8 @@
* plugin's `[data-dsh-better-sidebar]` host mount;
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
- * 4. expands the collapsed panel (openByDefault defaults off), sweeps every
- * built-in tab (Files / Changes / Tasks / Terminal / Browser) —
+ * 4. expands DSH's native right Sidebar, sweeps every built-in tab type
+ * through its guide page (Files / Changes / Tasks / Terminal / Browser) —
* including the lazily-fetched terminal chunk — and then opens seeded
* files through the Files window's tree (separate mode: each file opens
* its own new tab, the seeded home "Files" tab stays the explorer),
@@ -211,7 +211,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
}
// The seeded session must give the sidebar a session scope: without it the
- // shell renders a disabled toggle cluster and the tab sweep is impossible.
+ // workbench has no pane to render and the tab sweep is impossible.
const tabBar = sidebar.locator('[title]')
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
@@ -257,6 +257,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
// starts blank, so give it one message first.
await sendFirstMessage(page)
+ // The plugin's own surface is the bottom workbench: its expand/collapse
+ // control is registered into DSH's session-header utilities (the header's
+ // corner belongs to the native sidebar), and the workbench host itself is
+ // mounted. Both are stable addressing surfaces for user CSS / presets.
+ await expect(page.locator('[data-dsh-bottom-toggle]')).toBeAttached({ timeout: 30_000 })
+ await expect(page.locator('[data-dsh-bottom-panel]')).toBeAttached()
+
// DSH 0.1.5 owns the right column: the plugin contributes tab TYPES to the
// host's native right Sidebar instead of drawing its own panel. Open it
// through the host's own control (the conversation header's corner) and
@@ -485,10 +492,10 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
.toBe('')
// The stable addressing surface for presets / custom CSS is mounted: the
- // plugin's own host plus its bottom workbench (the right column belongs to
- // DSH's native Sidebar on 0.1.5).
- await expect(page.locator('[data-dsh-toggle-cluster]')).toBeAttached()
- await expect(page.locator('[data-dsh-bottom-panel]')).toBeAttached()
+ // plugin's own host (its bottom workbench and the header toggle live in
+ // DSH's session header, which this stamp-only page has no session for —
+ // the native-surface sweep asserts those once a session exists).
+ await expect(page.locator('[data-dsh-panel-host]')).toBeAttached()
// The plugin's interactive chrome opts out of Electron drag regions
// (issues #103/#111) — inert in plain browsers, present in the bundle
// (the bundler minifies the property's whitespace, so match loosely).
diff --git a/tests/e2e/perf.e2e.ts b/tests/e2e/perf.e2e.ts
index a6d8152..11a2c21 100644
--- a/tests/e2e/perf.e2e.ts
+++ b/tests/e2e/perf.e2e.ts
@@ -14,7 +14,8 @@
* for everything sidebar-shaped the page fetched;
* 4. bottom-drag frames — rAF frame-interval p95/max while dragging the
* bottom strip (the layout-push hot path), plus the width-leak guard
- * from the drag lane (a closed right panel must push 0).
+ * from the drag lane (this shell pushes no width at all: the right
+ * column belongs to DSH's native Sidebar).
*
* Results print as single-line `PERF_JSON` records (plus a `PERF_SUMMARY`
* aggregate) so a shell run can `tee` the log and grep them out; the
@@ -186,10 +187,16 @@ test('measure: bottom-strip drag frame pacing', async ({ page }) => {
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
- // The right panel stays CLOSED on purpose: the bottom drag must not push
- // the host layout in that pose (the width-leak regression the drag lane
- // locks; recorded here as a number-adjacent guard for the perf story).
- const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
+ // The workbench's expand/collapse control is registered into DSH's
+ // session-header utilities, which DSH renders only for a session with
+ // content — seed one message first, then address the toggle by its stable
+ // data attribute (it is NOT inside the plugin's own host).
+ await sendFirstMessage(page)
+
+ // The plugin pushes no width ever (the right column is DSH's native
+ // Sidebar), so the drag must never write --dsh-sidebar-width — the guard
+ // the drag lane locks, recorded here for the perf story.
+ const bottomExpand = page.locator('[data-dsh-bottom-toggle]')
await expect(bottomExpand).toHaveCount(1)
await bottomExpand.click()
await expect
@@ -273,5 +280,5 @@ test('measure: bottom-strip drag frame pacing', async ({ page }) => {
`PERF_SUMMARY drag-frames=${record.frames} p50=${record.intervalMedianMs}ms p95=${record.intervalP95Ms}ms `
+ `max=${record.intervalMaxMs}ms widthLeak=${record.pushWidthLeakMax}px`,
)
- expect(record.pushWidthLeakMax, 'the closed right panel must push 0 width during the bottom drag').toBe(0)
+ expect(record.pushWidthLeakMax, 'the plugin must never push a width during the bottom drag').toBe(0)
})
diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx
index 707c08e..47de70e 100644
--- a/tests/editor-host.spec.tsx
+++ b/tests/editor-host.spec.tsx
@@ -34,8 +34,13 @@ function setup(): {
// The openTab path needs a registered editor descriptor (dedupe by path).
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
+ // The workbench seeds EMPTY (the right panel that used to carry the default
+ // files window is DSH's native Sidebar now), so these editor-host scenarios
+ // open the path-less files window explicitly — exactly what the shell does
+ // when the files page is opened.
+ service.openTab({ type: 'editor', title: 'Files', meta: { treeOpen: true } })
const homeTab = (): SidebarTab =>
- allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.type === 'editor' && tab.path === undefined)!
// openSidebarFile reads the session cwd from ctx.sessions.
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
@@ -149,14 +154,14 @@ describe('EditorHost (files window)', () => {
typeAndCommit(container.querySelector('input')!, '/tmp/a.ts')
// The same tab id now carries the file (homeTab's path-less finder no
// longer matches — look the tab up by id).
- const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ const after = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.id === before.id)!
expect(after.id).toBe(before.id)
expect(after.path).toBe('/tmp/a.ts')
expect(after.title).toBe('a.ts')
expect(after.meta).toEqual({ treeOpen: true })
// No new tab landed.
- expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
+ expect(allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
} finally {
unmount()
}
@@ -167,12 +172,12 @@ describe('EditorHost (files window)', () => {
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
- allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts')
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
// home + a.ts + b.ts
expect(tabs).toHaveLength(3)
expect(fileTab().path).toBe('/tmp/a.ts')
@@ -210,7 +215,7 @@ describe('EditorHost (files window)', () => {
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
})
const fileTab = (): SidebarTab =>
- allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
@@ -281,7 +286,7 @@ describe('EditorHost (files window)', () => {
})
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
const fileTab = (): SidebarTab =>
- allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/x.fake')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
@@ -311,7 +316,7 @@ describe('EditorHost (files window)', () => {
meta: { dir: true },
}, { sessionId: 'editor-home-session' })
const dirTab = (): SidebarTab =>
- allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/work/src')!
const { container, unmount } = mountHost(ctx, store, dirTab)
try {
diff --git a/tests/editor-refresh.spec.tsx b/tests/editor-refresh.spec.tsx
index 6fcedb7..a0166a9 100644
--- a/tests/editor-refresh.spec.tsx
+++ b/tests/editor-refresh.spec.tsx
@@ -84,7 +84,7 @@ function setup(initialMode: 'preview' | 'edit' = 'preview', dirty = false): {
} as unknown as Context
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
- allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
+ allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
return { ctx, fileTab }
}
diff --git a/tests/free-window-restored-id.spec.ts b/tests/free-window-restored-id.spec.ts
deleted file mode 100644
index 904ab82..0000000
--- a/tests/free-window-restored-id.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-// @vitest-environment jsdom
-import { beforeEach, describe, expect, it } from 'vitest'
-import { createSidebarStore, floatTab } from '../src/client/state.ts'
-
-beforeEach(() => {
- localStorage.clear()
-})
-
-describe('restored free-window ids', () => {
- it('seeds the shared uid counter past persisted float ids before minting another window', () => {
- localStorage.setItem('dsh-sidebar:v1:s1', JSON.stringify({
- panelOpen: true,
- width: 400,
- activePane: 'pane:1',
- nextTerminal: 1,
- nextBrowser: 1,
- expanded: [],
- splits: {
- kind: 'leaf',
- id: 'pane:1',
- active: 'tab:2',
- tabs: [{ id: 'tab:2', type: 'browser', title: 'Docked' }],
- },
- bottomOpen: false,
- bottomHeight: 220,
- bottomOpenedOnce: false,
- bottomSplits: {
- kind: 'leaf',
- id: 'pane:3',
- active: null,
- tabs: [],
- },
- floats: [{
- id: 'float:5',
- tab: { id: 'tab:4', type: 'browser', title: 'Existing float' },
- x: 0,
- y: 0,
- w: 390,
- h: 500,
- }],
- }))
-
- const store = createSidebarStore()
- store.setSession('s1')
- store.reduce(state => floatTab(state, 'tab:2', 400, 300))
-
- const ids = store.getSnapshot().state!.floats.map(float => float.id)
- expect(ids).toEqual(['float:5', 'float:6'])
- expect(new Set(ids).size).toBe(ids.length)
- })
-})
diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx
deleted file mode 100644
index 4f27842..0000000
--- a/tests/free-window.spec.tsx
+++ /dev/null
@@ -1,418 +0,0 @@
-/**
- * Free-window interaction tests, against the REAL Sidebar shell + real
- * store/service (the sidebar-crash spec's mount pattern).
- *
- * 1. Drag-out detection: while a tab drag (the body flag the tab strips
- * maintain) hovers the conversation column, a hint overlay marks the drop
- * zone; the drop floats the tab at the release point and the pane loses
- * it. Targets inside the panel host stay the panes' own business.
- * 2. The tab context menu's "move to free window" floats the tab at the
- * conversation column's center (no drop point exists).
- * 3. The window itself: header drag moves (rAF direct writes, store commit
- * on release), the SE corner resizes, releasing the header over a pane
- * docks the tab back (the pane highlights live), and the X closes the tab
- * with its window.
- */
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createElement } from 'react'
-import { createRoot, type Root } from 'react-dom/client'
-import { act } from 'react-dom/test-utils'
-
-// The act() environment flag (React 18.2 reads it before flushing effects).
-import { setupReactAct } from './test-utils.ts'
-setupReactAct()
-
-import { Sidebar } from '../src/client/Sidebar.tsx'
-import { createSidebarStore, togglePanel, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts'
-import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
-import { serializeDrag } from '../src/client/TabBar.tsx'
-
-/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
-class FakeWebSocket {
- onmessage: ((event: { data: unknown }) => void) | null = null
- onclose: (() => void) | null = null
- onerror: (() => void) | null = null
- close = (): void => {}
- constructor(_url: string) {}
-}
-
-/** The app shell's center column: #root > col > [data-slot="conversation"],
- * with a real-looking rect so the drag-out zone can be hit-tested. */
-const CONV_RECT = { left: 100, right: 700, top: 50, bottom: 600, width: 600, height: 550 }
-
-function fakeConversationColumn(): HTMLElement {
- const rootEl = document.createElement('div')
- rootEl.id = 'root'
- const col = document.createElement('div')
- const conv = document.createElement('div')
- conv.setAttribute('data-slot', 'conversation')
- col.append(conv)
- rootEl.append(col)
- document.body.append(rootEl)
- col.getBoundingClientRect = () => CONV_RECT as DOMRect
- return col
-}
-
-interface Mounted {
- container: HTMLDivElement
- store: SidebarStore
- service: BetterSidebarService
- unmount: () => void
-}
-
-/** Unique per-test session ids (see the comment inside). */
-let sessionSeq = 0
-
-function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted {
- vi.stubGlobal('WebSocket', FakeWebSocket)
- if (!('ResizeObserver' in globalThis)) {
- vi.stubGlobal('ResizeObserver', class {
- observe(): void {}
- unobserve(): void {}
- disconnect(): void {}
- })
- }
- const container = document.createElement('div')
- document.body.append(container)
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({
- id: 'notes',
- title: 'Notes',
- single: true,
- // Pins the visibility contract into the DOM: plugin components honor
- // `visible` to pause work, so the float tests can assert what a
- // registering plugin would actually receive.
- component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'),
- })
- store.setPrefs({ ...store.getPrefs(), openByDefault: true })
- // The default session id is unique per test: the store persists
- // per-session state to localStorage on a 200ms debounce, and a shared id
- // let a PREVIOUS test's late timer write leak into this store's
- // setSession restore (the portaled-menu/resize flakes — a stale float at
- // the old drop point won the querySelector race on slow runners). The
- // persistence round-trip test passes its own fixed id instead.
- store.setSession(sessionId)
- const localeSnapshot = { active: 'en' }
- const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } }
- const ctx = {
- locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
- sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
- betterSidebar: service,
- get: (name: string) => name === 'betterSidebar' ? service : undefined,
- }
- const root: Root = createRoot(container)
- act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
- return {
- container,
- store,
- service,
- unmount: () => {
- act(() => { root.unmount() })
- container.remove()
- },
- }
-}
-
-/** A document-level drag event with a payload-carrying dataTransfer (jsdom
- * has no DragEvent/DataTransfer). Dispatched AT document, where the shell's
- * capture listeners live. */
-function dispatchDrag(type: 'dragover' | 'drop', x: number, y: number, payload?: string): void {
- const event = new Event(type, { bubbles: true, cancelable: true })
- Object.defineProperty(event, 'clientX', { value: x })
- Object.defineProperty(event, 'clientY', { value: y })
- Object.defineProperty(event, 'dataTransfer', { value: { getData: () => payload ?? '' } })
- document.dispatchEvent(event)
-}
-
-const flushFrame = async (): Promise => {
- await act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve())) })
-}
-
-const paneTabs = (store: SidebarStore): string[] => {
- const state = store.getSnapshot().state!
- const leaves = [state.splits, state.bottomSplits]
- return leaves.flatMap(tree => (tree.kind === 'leaf' ? [tree] : allLeafList(tree)))
- .flatMap(leaf => leaf.tabs.map(tab => tab.id))
-}
-const allLeafList = (node: import('../src/client/state.ts').SplitNode): Array<{ tabs: Array<{ id: string }> }> =>
- node.kind === 'leaf' ? [node] : node.children.flatMap(allLeafList)
-
-afterEach(() => {
- document.body.innerHTML = ''
- document.documentElement.style.cssText = ''
- vi.unstubAllGlobals()
- // Belt and braces (after unstub, so a test's storage stub never sees it):
- // drop any persisted layout a pending 200ms debounce write left behind
- // between tests — unique session ids already isolate the stores.
- localStorage.clear()
-})
-
-describe('free windows: drag-out detection', () => {
- it('hovering the conversation column shows the hint; the drop floats the tab there', () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- const before = paneTabs(store)
- expect(before).toContain('notes')
- // A tab drag is active (the tab strips maintain this body flag).
- document.body.setAttribute('data-dsh-tab-dragging', '')
- // Outside the column: no hint.
- act(() => { dispatchDrag('dragover', 50, 20) })
- expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
- // Over the column: the hint overlay appears at the column's rect.
- act(() => { dispatchDrag('dragover', 400, 300) })
- const hint = container.querySelector('[class*="floatDropHint"]')!
- expect(hint).not.toBeNull()
- expect(hint.style.left).toBe('100px')
- expect(hint.style.width).toBe('600px')
- // The drop floats the tab at the release point.
- act(() => {
- dispatchDrag('drop', 400, 300, serializeDrag({ tabId: 'notes', paneId: 'pane:1' }))
- })
- const state = store.getSnapshot().state!
- expect(state.floats).toHaveLength(1)
- expect(state.floats[0]!.tab.id).toBe('notes')
- expect(paneTabs(store)).not.toContain('notes')
- // The hint is gone and the window is rendered.
- expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
- expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull()
- // dragend clears any lingering state.
- document.body.setAttribute('data-dsh-tab-dragging', '')
- act(() => { dispatchDrag('dragover', 400, 300) })
- expect(container.querySelector('[class*="floatDropHint"]')).not.toBeNull()
- act(() => { window.dispatchEvent(new Event('dragend')) })
- expect(container.querySelector('[class*="floatDropHint"]')).toBeNull()
- document.body.removeAttribute('data-dsh-tab-dragging')
- } finally {
- unmount()
- }
- })
-
- it('drops without the hint armed are left to the panes (no float)', () => {
- fakeConversationColumn()
- const { store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- document.body.setAttribute('data-dsh-tab-dragging', '')
- // A drop with NO preceding dragover over the column (e.g. directly on
- // a pane) must not float anything.
- act(() => {
- dispatchDrag('drop', 400, 300, serializeDrag({ tabId: 'notes', paneId: 'pane:1' }))
- })
- expect(store.getSnapshot().state!.floats).toHaveLength(0)
- expect(paneTabs(store)).toContain('notes')
- document.body.removeAttribute('data-dsh-tab-dragging')
- } finally {
- unmount()
- }
- })
-})
-
-describe('free windows: the window', () => {
- it('the tab context menu floats the tab at the conversation center', () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- // The strip holds the seeded home tab first; Notes is the LAST tab.
- const tabs = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')]
- const tab = tabs.at(-1)!
- act(() => { tab.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })) })
- // The float entry is the FIRST menu row.
- const items = [...document.querySelectorAll('[role="menuitem"]')]
- expect(items.length).toBeGreaterThan(0)
- act(() => { items[0]!.click() })
- const state = store.getSnapshot().state!
- expect(state.floats).toHaveLength(1)
- expect(state.floats[0]!.tab.id).toBe('notes')
- // Born at the conversation column's center with the phone-ratio
- // default (390x780), created capped to the jsdom viewport (h 744) and
- // clamped in (the centered y would be negative).
- expect(state.floats[0]).toMatchObject({ x: 400 - 195, y: 0, w: 390, h: 744 })
- expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull()
- } finally {
- unmount()
- }
- })
-
- it('header drag moves the window (rAF direct writes, store commit on release)', async () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- const win = container.querySelector('[data-dsh-float-window]')!
- const header = win.querySelector('[class*="floatHeader"]')!
- expect(win.style.left).toBe('317px')
- // Grab at (300, 220) — the drag records the window's own origin, so
- // moving the pointer to (200, 250) lands the window at (217, 42) —
- // clamped back up to y 24 (the 744-tall window must stay in the
- // 768-tall viewport: 768 - 744 = 24).
- act(() => {
- header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
- header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 }))
- })
- await flushFrame()
- expect(win.style.left).toBe('217px')
- expect(win.style.top).toBe('24px')
- // The store is untouched mid-drag (DOM is the only record).
- expect(store.getSnapshot().state!.floats[0]!.x).toBe(317)
- act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200, clientY: 250, button: 0 })) })
- expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 217, y: 24 })
- } finally {
- unmount()
- }
- })
-
- it('the SE corner resizes the window and persists on release', async () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- const win = container.querySelector('[data-dsh-float-window]')!
- const handle = win.querySelector('[class*="floatResize"]')!
- act(() => {
- handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 700, clientY: 750, button: 0 }))
- handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 800, clientY: 800 }))
- })
- await flushFrame()
- // +100/+50 from the created 390x744: height clamps to the viewport
- // ceiling (768 - y 12 = 756).
- expect(win.style.width).toBe('490px')
- expect(win.style.height).toBe('756px')
- act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 800, clientY: 800, button: 0 })) })
- expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 490, h: 756 })
- } finally {
- unmount()
- }
- })
-
- it('a pointerdown on the portaled header menu must not start a header drag', async () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- const win = container.querySelector('[data-dsh-float-window]')!
- const header = win.querySelector('[class*="floatHeader"]')!
- expect(win.style.left).toBe('317px')
- act(() => { header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 220 })) })
- // The menu list is PORTALED to document.body, yet its events still
- // bubble through the React tree into the header's onPointerDown —
- // without the containment guard (real-browser regression, e2e-caught:
- // the hijacked drag preventDefaults the row's pointerdown and captures
- // the pointer, so the row's click never fires) this starts a move.
- const dockRow = [...document.querySelectorAll('[role="menuitem"]')]
- .find(row => /回到侧边栏|Dock Back to Sidebar/.test(row.textContent ?? ''))!
- act(() => {
- dockRow.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
- header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 }))
- })
- await flushFrame()
- expect(win.style.left).toBe('317px')
- act(() => { dockRow.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
- expect(store.getSnapshot().state!.floats).toHaveLength(0)
- expect(paneTabs(store)).toContain('notes')
- } finally {
- unmount()
- }
- })
-
- it('a floated plugin tab stays visible while the panel collapses (placement contract)', () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- const win = container.querySelector('[data-dsh-float-window]')!
- expect(win.querySelector('[data-visible]')!.dataset.visible).toBe('true')
- // Collapse the sidebar: the pane's seeded home tab goes invisible
- // (panel closed), but the float is its own surface — a registering
- // plugin's component must keep receiving visible=true (AGENTS §7.5).
- act(() => { store.reduce(togglePanel) })
- expect(win.querySelector('[data-visible]')!.dataset.visible).toBe('true')
- // (No pane-side control: the pane's remaining tab is the builtin editor
- // home tab, which does not surface `visible` into the DOM.)
- } finally {
- unmount()
- }
- })
-
- it('releasing the header over a pane docks the tab back (live pane highlight)', async () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- const win = container.querySelector('[data-dsh-float-window]')!
- const header = win.querySelector('[class*="floatHeader"]')!
- // The workbench pane covers (600, 400) for the hit-test.
- const pane = container.querySelector('[data-dsh-pane]')!
- pane.getBoundingClientRect = () => ({ left: 550, right: 700, top: 350, bottom: 500, width: 150, height: 150 }) as DOMRect
- act(() => {
- header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 }))
- header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 600, clientY: 400 }))
- })
- await flushFrame()
- // The dock target highlights while hovered.
- expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(true)
- act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 600, clientY: 400, button: 0 })) })
- const state = store.getSnapshot().state!
- expect(state.floats).toHaveLength(0)
- expect(paneTabs(store)).toContain('notes')
- expect(pane.hasAttribute('data-dsh-float-dock-over')).toBe(false)
- expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
- } finally {
- unmount()
- }
- })
-
- it('the X button closes the tab with its window', () => {
- fakeConversationColumn()
- const { container, store, service, unmount } = mountSidebar()
- try {
- act(() => { service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- const win = container.querySelector('[data-dsh-float-window]')!
- const close = win.querySelector('[class*="floatClose"]')!
- act(() => { close.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
- const state = store.getSnapshot().state!
- expect(state.floats).toHaveLength(0)
- expect(paneTabs(store)).not.toContain('notes')
- expect(container.querySelector('[data-dsh-float-window]')).toBeNull()
- } finally {
- unmount()
- }
- })
-
- it('a reload round-trip restores the window (localStorage persistence)', () => {
- fakeConversationColumn()
- const saved = new Map()
- const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
- const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
- vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
- const first = mountSidebar('s1')
- try {
- act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
- act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
- // The debounced persist fires on a later tick — run it synchronously.
- const state = first.store.getSnapshot().state!
- saved.set('dsh-sidebar:v1:s1', JSON.stringify(state))
- } finally {
- first.unmount()
- }
- const second = mountSidebar('s1')
- try {
- const state = second.store.getSnapshot().state!
- expect(state.floats).toHaveLength(1)
- expect(state.floats[0]!.tab.id).toBe('notes')
- expect(second.container.querySelector('[data-dsh-float-window]')).not.toBeNull()
- } finally {
- second.unmount()
- }
- })
-})
diff --git a/tests/layout-push.spec.ts b/tests/layout-push.spec.ts
index b9052d2..0e2df09 100644
--- a/tests/layout-push.spec.ts
+++ b/tests/layout-push.spec.ts
@@ -1,99 +1,37 @@
/**
* Layout-push size contract: the conversation column (output + composer)
- * must keep at least PANEL_MIN of the viewport after the bottom panel
- * claims its height. A stale persisted bottomHeight (or a cap at 100vh)
- * used to crush the composer off-screen.
+ * must keep at least CONVERSATION_MIN of the viewport after the bottom
+ * workbench claims its height. A stale persisted bottomHeight (or a cap at
+ * 100vh) used to crush the composer off-screen. The right column belongs to
+ * DSH's native Sidebar, so this shell pushes no width at all.
*/
import { describe, expect, it } from 'vitest'
-import { BOTTOM_MIN, PANEL_MIN } from '../src/client/state.ts'
-import { layoutPushSize } from '../src/client/layout-push.ts'
+import { BOTTOM_MIN, CONVERSATION_MIN } from '../src/client/state.ts'
+import { bottomPushHeight } from '../src/client/layout-push.ts'
-describe('layoutPushSize', () => {
- it('pushes nothing on a narrow drawer viewport', () => {
- expect(layoutPushSize({
- narrow: true,
- panelOpen: true,
- bottomOpen: true,
- width: 400,
- bottomHeight: 400,
- viewportWidth: 390,
- viewportHeight: 700,
- })).toEqual({ width: 0, height: 0 })
+describe('bottomPushHeight', () => {
+ it('pushes nothing while the workbench is collapsed', () => {
+ expect(bottomPushHeight({ open: false, height: 400, viewportHeight: 800 })).toBe(0)
})
- it('pushes nothing when both panels are collapsed', () => {
- expect(layoutPushSize({
- narrow: false,
- panelOpen: false,
- bottomOpen: false,
- width: 400,
- bottomHeight: 400,
- viewportWidth: 1280,
- viewportHeight: 800,
- })).toEqual({ width: 0, height: 0 })
- })
-
- it('caps an oversize bottom panel so the conversation keeps PANEL_MIN', () => {
+ it('caps an oversize workbench so the conversation keeps CONVERSATION_MIN', () => {
const viewportHeight = 800
- const size = layoutPushSize({
- narrow: false,
- panelOpen: false,
- bottomOpen: true,
- width: 0,
- bottomHeight: 10_000,
- viewportWidth: 1280,
- viewportHeight,
- })
- expect(size.height).toBe(viewportHeight - PANEL_MIN)
- expect(size.height).toBeGreaterThanOrEqual(BOTTOM_MIN)
- expect(viewportHeight - size.height).toBeGreaterThanOrEqual(PANEL_MIN)
+ const height = bottomPushHeight({ open: true, height: 10_000, viewportHeight })
+ expect(height).toBe(viewportHeight - CONVERSATION_MIN)
+ expect(height).toBeGreaterThanOrEqual(BOTTOM_MIN)
+ expect(viewportHeight - height).toBeGreaterThanOrEqual(CONVERSATION_MIN)
})
- it('caps an oversize right panel at the viewport width', () => {
- expect(layoutPushSize({
- narrow: false,
- panelOpen: true,
- bottomOpen: false,
- width: 10_000,
- bottomHeight: 0,
- viewportWidth: 1280,
- viewportHeight: 800,
- })).toEqual({ width: 1280, height: 0 })
+ it('passes an in-range open height through', () => {
+ expect(bottomPushHeight({ open: true, height: 220, viewportHeight: 800 })).toBe(220)
})
- it('passes through in-range open sizes', () => {
- expect(layoutPushSize({
- narrow: false,
- panelOpen: true,
- bottomOpen: true,
- width: 400,
- bottomHeight: 220,
- viewportWidth: 1280,
- viewportHeight: 800,
- })).toEqual({ width: 400, height: 220 })
- })
-
- it('never pushes beyond a viewport smaller than the normal panel minima', () => {
- expect(layoutPushSize({
- narrow: false,
- panelOpen: true,
- bottomOpen: true,
- width: 400,
- bottomHeight: 220,
- viewportWidth: 200,
- viewportHeight: 200,
- })).toEqual({ width: 200, height: 0 })
+ it('never pushes beyond a viewport smaller than the normal minima', () => {
+ expect(bottomPushHeight({ open: true, height: 220, viewportHeight: 200 })).toBe(0)
})
it('turns non-finite geometry into a safe zero push', () => {
- expect(layoutPushSize({
- narrow: false,
- panelOpen: true,
- bottomOpen: true,
- width: Number.NaN,
- bottomHeight: Number.POSITIVE_INFINITY,
- viewportWidth: Number.NaN,
- viewportHeight: Number.POSITIVE_INFINITY,
- })).toEqual({ width: 0, height: 0 })
+ expect(bottomPushHeight({ open: true, height: Number.POSITIVE_INFINITY, viewportHeight: Number.NaN })).toBe(0)
+ expect(bottomPushHeight({ open: true, height: Number.NaN, viewportHeight: 800 })).toBe(0)
})
})
diff --git a/tests/markdown-manual-refresh.spec.tsx b/tests/markdown-manual-refresh.spec.tsx
index d2c0d18..5160686 100644
--- a/tests/markdown-manual-refresh.spec.tsx
+++ b/tests/markdown-manual-refresh.spec.tsx
@@ -58,7 +58,7 @@ function setup(): {
component: FakeMarkdownViewer,
})
store.setSession('markdown-manual-refresh-session')
- const home = allLeaves(store.getSnapshot().state!.splits)
+ const home = allLeaves(store.getSnapshot().state!.bottomSplits)
.flatMap(leaf => leaf.tabs)
.find(candidate => candidate.type === 'editor')!
const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' }
diff --git a/tests/menu-flip.spec.tsx b/tests/menu-flip.spec.tsx
index d6281e2..c9742fc 100644
--- a/tests/menu-flip.spec.tsx
+++ b/tests/menu-flip.spec.tsx
@@ -60,7 +60,7 @@ describe('submenuFlipTokens', () => {
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
})
- it('flips left throughout a narrow panel viewport (PANEL_MIN 280)', () => {
+ it('flips left throughout a narrow viewport', () => {
// A 600px window: any cursor past x=200 flips; the left strip cannot
// fit a right-growing submenu beside the 218px card either way.
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
diff --git a/tests/orphaned-tab.spec.tsx b/tests/orphaned-tab.spec.tsx
index 64d092f..e7ed0be 100644
--- a/tests/orphaned-tab.spec.tsx
+++ b/tests/orphaned-tab.spec.tsx
@@ -23,12 +23,10 @@ function typesOf(node: unknown): string[] {
describe('sanitize keeps unregistered tab types', () => {
it('preserves a persisted tab of an unknown type with its title/path', () => {
const raw = {
- panelOpen: true,
- width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
- splits: {
+ bottomSplits: {
kind: 'leaf',
id: 'pane:1',
tabs: [
@@ -40,21 +38,19 @@ describe('sanitize keeps unregistered tab types', () => {
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
- expect(typesOf(clean!.splits)).toContain('my-plugin:db')
+ expect(typesOf(clean!.bottomSplits)).toContain('my-plugin:db')
// The kept tab keeps its payload so a later registration can reuse it.
- const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array> }
+ const leaf = clean!.bottomSplits as unknown as { kind: 'leaf'; tabs: Array> }
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
})
it('an unknown type alone in a pane still sanitizes cleanly', () => {
const raw = {
- panelOpen: true,
- width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
- splits: {
+ bottomSplits: {
kind: 'leaf',
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
@@ -63,7 +59,7 @@ describe('sanitize keeps unregistered tab types', () => {
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
- expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
+ expect(typesOf(clean!.bottomSplits)).toEqual(['some-plugin:page'])
})
})
diff --git a/tests/pinned.spec.ts b/tests/pinned.spec.ts
index 3250a5c..4a28131 100644
--- a/tests/pinned.spec.ts
+++ b/tests/pinned.spec.ts
@@ -3,11 +3,11 @@
* states. Covers the visibility matrix (global / workspace × cwd match /
* mismatch / both undefined / viewer-only undefined) and the cross-session
* collection (multi-session, exclusion of the viewer's own session, stable
- * tree order, floats).
+ * tree order).
*/
import { describe, expect, it } from 'vitest'
import {
- floatTab, makeDefaultState, openTabInActivePane, setTabPin, toggleBottomPanel,
+ makeDefaultState, openTabInBottomPane, setTabPin, splitPane,
type SidebarState,
} from '../src/client/state.ts'
import { collectPinnedTabs, pinnedVisibleTo, type PinnedViewer,
@@ -21,7 +21,7 @@ function stateWithPinnedTerminal(
pin: { scope: 'workspace' | 'global'; homeCwd?: string },
): SidebarState {
let s = makeDefaultState()
- s = openTabInActivePane(s, { id, type: 'terminal', title: id })
+ s = openTabInBottomPane(s, { id, type: 'terminal', title: id })
return setTabPin(s, id, pin)
}
@@ -100,25 +100,20 @@ describe('collectPinnedTabs', () => {
expect(entries.map(e => e.tab.id)).toEqual(['terminal:2'])
})
- it('collects in stable tree order: splits → bottomSplits → floats', () => {
+ it('collects in stable tree order (the workbench split tree, depth-first)', () => {
let home = makeDefaultState()
- // Right tree tab first.
- home = openTabInActivePane(home, { id: 'terminal:right', type: 'terminal', title: 'R' })
- home = setTabPin(home, 'terminal:right', { scope: 'global' })
- // Bottom tree tab second.
- home = toggleBottomPanel(home)
- const bottomPane = (home.bottomSplits as { id: string }).id
- home = { ...home, activePane: bottomPane }
- home = openTabInActivePane(home, { id: 'terminal:bottom', type: 'terminal', title: 'B' })
- home = setTabPin(home, 'terminal:bottom', { scope: 'global' })
- // Float last.
- home = openTabInActivePane(home, { id: 'terminal:float', type: 'terminal', title: 'F' })
- home = setTabPin(home, 'terminal:float', { scope: 'global' })
- home = floatTab(home, 'terminal:float', 50, 50)
+ home = openTabInBottomPane(home, { id: 'terminal:first', type: 'terminal', title: 'A' })
+ home = setTabPin(home, 'terminal:first', { scope: 'global' })
+ // A second leaf to the right of the first one: its tabs collect after.
+ const firstPane = (home.bottomSplits as { id: string }).id
+ home = splitPane(home, 'row')
+ home = openTabInBottomPane(home, { id: 'terminal:second', type: 'terminal', title: 'B' })
+ home = setTabPin(home, 'terminal:second', { scope: 'global' })
+ expect(firstPane).not.toBe('')
const bySession = new Map([['home', home]])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
- expect(entries.map(e => e.tab.id)).toEqual(['terminal:right', 'terminal:bottom', 'terminal:float'])
+ expect(entries.map(e => e.tab.id)).toEqual(['terminal:first', 'terminal:second'])
})
it('keeps stable insertion order across multiple home sessions', () => {
@@ -133,10 +128,10 @@ describe('collectPinnedTabs', () => {
it('ignores unpinned terminals and non-terminal tabs in other sessions', () => {
let home = makeDefaultState()
- home = openTabInActivePane(home, { id: 'terminal:unpinned', type: 'terminal', title: 'U' })
- home = openTabInActivePane(home, { id: 'editor:1', type: 'editor', title: 'E', path: '/e' })
+ home = openTabInBottomPane(home, { id: 'terminal:unpinned', type: 'terminal', title: 'U' })
+ home = openTabInBottomPane(home, { id: 'editor:1', type: 'editor', title: 'E', path: '/e' })
home = setTabPin(home, 'editor:1', { scope: 'global' }) // defensive: pin only targets terminals
- home = openTabInActivePane(home, { id: 'terminal:pinned', type: 'terminal', title: 'P' })
+ home = openTabInBottomPane(home, { id: 'terminal:pinned', type: 'terminal', title: 'P' })
home = setTabPin(home, 'terminal:pinned', { scope: 'global' })
const bySession = new Map([['home', home]])
const entries = collectPinnedTabs(bySession, viewer('viewer', '/p'))
@@ -206,7 +201,7 @@ describe('createPinnedVirtualTab', () => {
describe('injectPinnedIntoTree', () => {
it('returns the original tree when no pinned tabs and no active override', () => {
- const tree = makeDefaultState().splits
+ const tree = makeDefaultState().bottomSplits
expect(injectPinnedIntoTree(tree, [], null)).toBe(tree)
})
@@ -216,7 +211,7 @@ describe('injectPinnedIntoTree', () => {
tab: { id: 'terminal:3', type: 'terminal', title: 'T3', pin: { scope: 'global' } },
homeSessionId: 'home',
})
- const result = injectPinnedIntoTree(s.splits, [vtab], null)
+ const result = injectPinnedIntoTree(s.bottomSplits, [vtab], null)
expect(result.kind).toBe('leaf')
if (result.kind === 'leaf') {
expect(result.tabs[result.tabs.length - 1]!.id).toBe('pinned:home:terminal:3')
@@ -224,12 +219,12 @@ describe('injectPinnedIntoTree', () => {
})
it('overrides the first leaf active when activePinnedId is set', () => {
- const s = openTabInActivePane(makeDefaultState(), { id: 'terminal:1', type: 'terminal', title: 'T1' })
+ const s = openTabInBottomPane(makeDefaultState(), { id: 'terminal:1', type: 'terminal', title: 'T1' })
const vtab = createPinnedVirtualTab({
tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'global' } },
homeSessionId: 'home',
})
- const result = injectPinnedIntoTree(s.splits, [vtab], vtab.id)
+ const result = injectPinnedIntoTree(s.bottomSplits, [vtab], vtab.id)
if (result.kind === 'leaf') {
expect(result.active).toBe(vtab.id)
expect(result.tabs.map(t => t.id)).toContain(vtab.id)
@@ -239,18 +234,18 @@ describe('injectPinnedIntoTree', () => {
it('injects into the first child of a split tree', () => {
let s = makeDefaultState()
- s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T1' })
+ s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'T1' })
// Create a split (splits the active pane into two)
- const leafId = s.splits.kind === 'leaf' ? s.splits.id : ''
- s = { ...s, splits: { kind: 'split', id: 'split:1', dir: 'row', sizes: [0.5, 0.5], children: [
- { kind: 'leaf', id: leafId, tabs: s.splits.kind === 'leaf' ? s.splits.tabs : [], active: 'terminal:1' },
+ const leafId = s.bottomSplits.kind === 'leaf' ? s.bottomSplits.id : ''
+ s = { ...s, bottomSplits: { kind: 'split', id: 'split:1', dir: 'row', sizes: [0.5, 0.5], children: [
+ { kind: 'leaf', id: leafId, tabs: s.bottomSplits.kind === 'leaf' ? s.bottomSplits.tabs : [], active: 'terminal:1' },
{ kind: 'leaf', id: 'pane:2', tabs: [], active: null },
] } }
const vtab = createPinnedVirtualTab({
tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'global' } },
homeSessionId: 'home',
})
- const result = injectPinnedIntoTree(s.splits, [vtab], null)
+ const result = injectPinnedIntoTree(s.bottomSplits, [vtab], null)
expect(result.kind).toBe('split')
if (result.kind === 'split') {
const first = result.children[0]!
diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts
index 6fb64aa..331a260 100644
--- a/tests/plugin-shape.spec.ts
+++ b/tests/plugin-shape.spec.ts
@@ -52,13 +52,14 @@ describe('dsh-better-sidebar plugin export shape', () => {
})
it('registers the side card preferences schema with the documented defaults', async () => {
- const { PrefsSchema, SIDEBAR_PREFS_NS } = await import('../src/config.ts')
+ const { PrefsSchema, SIDEBAR_PREFS_NS, SIDEBAR_PREFS_DEFAULTS } = await import('../src/config.ts')
expect(SIDEBAR_PREFS_NS).toBe('dsh-better-sidebar')
const resolved = (PrefsSchema as unknown as {
(input: Record | undefined): Record
})(undefined)
- expect(resolved.openByDefault).toBe(false)
- expect(resolved.defaultWidthPercent).toBe(35)
+ expect(resolved.openByDefault).toBeUndefined()
+ expect(resolved.defaultWidthPercent).toBeUndefined()
+ expect(resolved.changesDiffFloat).toBeUndefined()
expect(resolved.autoOpenSubagent).toBe(true)
// A new background job auto-opens the Jobs page too.
expect(resolved.autoOpenJobs).toBe(true)
@@ -96,7 +97,17 @@ describe('dsh-better-sidebar plugin export shape', () => {
// default when the stored document predates it.
const overridden = (PrefsSchema as unknown as {
(input: Record | undefined): Record
- })({ openByDefault: false, defaultWidthPercent: 45 })
- expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
+ })({ openByDefault: false, defaultWidthPercent: 45, changesDiffFloat: true })
+ // Schemastery's object schema is OPEN: a document written by an older
+ // plugin version still carrying the retired keys resolves them through
+ // verbatim. They are inert — the typed value the client consumes
+ // (parsePrefs) drops them (tests/prefs.spec.ts) — and the defaults no
+ // longer declare them.
+ // titleBarScheme / titleBarPresetId / customCss are declared WITHOUT a
+ // schema default (the client's parsePrefs supplies them), so they are
+ // absent from a resolved document that never stored them.
+ const { titleBarScheme, titleBarPresetId, customCss, ...schemaDefaults } = SIDEBAR_PREFS_DEFAULTS
+ void titleBarScheme; void titleBarPresetId; void customCss
+ expect(overridden).toEqual({ ...schemaDefaults, openByDefault: false, defaultWidthPercent: 45, changesDiffFloat: true })
})
})
diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts
index 6ace6f5..05c4053 100644
--- a/tests/prefs.spec.ts
+++ b/tests/prefs.spec.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
-import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState, setWidth } from '../src/client/state.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** A fake settings wire face whose settingsGet resolves to one raw value. */
@@ -30,18 +29,16 @@ describe('side card preferences', () => {
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
- it('parses a valid value and clamps the percent into the contract range', async () => {
- expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
+ it('parses a valid value', async () => {
+ expect(await loadPrefs(wire({ autoOpenSubagent: false, agentTerminalTools: true })))
.toEqual({
- openByDefault: false,
- defaultWidthPercent: 60,
autoOpenSubagent: false,
autoOpenJobs: true,
agentTerminalTools: true, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
- editorExplorer: false, changesDiffFloat: true,
+ editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
@@ -64,17 +61,15 @@ describe('side card preferences', () => {
})
it('falls back per-field when a stored field is malformed', async () => {
- expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
+ expect(await loadPrefs(wire({ autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
.toEqual({
- openByDefault: false,
- defaultWidthPercent: 33,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
- editorExplorer: false, changesDiffFloat: true,
+ editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
@@ -97,17 +92,15 @@ describe('side card preferences', () => {
})
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
- expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
+ expect(await loadPrefs(wire({})))
.toEqual({
- openByDefault: false,
- defaultWidthPercent: 40,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
- editorExplorer: false, changesDiffFloat: true,
+ editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
@@ -127,26 +120,26 @@ describe('side card preferences', () => {
viewersEnabled: {},
pluginSettings: {},
})
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
+ expect((await loadPrefs(wire({ autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
+ expect((await loadPrefs(wire({}))).agentTerminalTools)
.toBe(false)
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
+ expect((await loadPrefs(wire({ agentTerminalTools: 1 }))).agentTerminalTools)
.toBe(false)
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
+ expect((await loadPrefs(wire({ agentTerminalTools: true }))).agentTerminalTools)
.toBe(true)
// The sidebar-open tool is OFF by default too; only an explicit true turns it on.
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentOpenTools)
+ expect((await loadPrefs(wire({}))).agentOpenTools)
.toBe(false)
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: 1 }))).agentOpenTools)
+ expect((await loadPrefs(wire({ agentOpenTools: 1 }))).agentOpenTools)
.toBe(false)
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: true }))).agentOpenTools)
+ expect((await loadPrefs(wire({ agentOpenTools: true }))).agentOpenTools)
.toBe(true)
// The job auto-open is ON by default; only an explicit false turns it off.
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
+ expect((await loadPrefs(wire({ autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true)
- expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
+ expect((await loadPrefs(wire({ autoOpenJobs: false }))).autoOpenJobs)
.toBe(false)
})
@@ -265,128 +258,6 @@ describe('side card preferences', () => {
expect(parsed.viewersEnabled).toEqual({ image: false })
})
- it('seeds new-session defaults from the store prefs (open flag + width)', () => {
- 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, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
- store.setSession('fresh-session')
- expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
- const snapshot = store.getSnapshot()
- expect(snapshot.sessionId).toBe('fresh-session')
- expect(snapshot.state?.panelOpen).toBe(false)
- expect(snapshot.state?.width).toBe(400)
- // The default prefs keep the panel closed (openByDefault defaults off).
- const openStore = createSidebarStore()
- openStore.setSession('another-fresh')
- expect(openStore.getSnapshot().state?.panelOpen).toBe(false)
- })
-
- it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
- // Stub a narrow window (the file otherwise runs without one): only
- // innerWidth is read while seeding a fresh session.
- const original = (globalThis as Record).window
- ;(globalThis as Record).window = {
- innerWidth: 390,
- clearTimeout: () => {},
- setTimeout: (_fn: () => void) => 0,
- }
- try {
- const store = createSidebarStore()
- // The narrow viewport keeps a fresh session collapsed. Persisted
- // sessions follow the same load-time visibility rule in state.spec.ts.
- store.setSession('narrow-fresh')
- expect(store.getSnapshot().state?.panelOpen).toBe(false)
- // The width seeding still follows the window (clamped to the floor).
- expect(store.getSnapshot().state?.width).toBe(280)
- } finally {
- if (original === undefined) delete (globalThis as Record).window
- else (globalThis as Record).window = original
- }
- })
-
- it('skips the default seed tab when the editor (files window) type is disabled', () => {
- const store = createSidebarStore()
- store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
- store.setSession('no-editor')
- const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
- expect(tabs).toHaveLength(0)
- expect(state.splits.kind).toBe('leaf')
- // Re-enabling seeds the files window (editor home tab) again — in BOTH
- // editorExplorer modes.
- for (const editorExplorer of [true, false]) {
- const openStore = createSidebarStore()
- openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
- openStore.setSession(`with-editor-${editorExplorer}`)
- const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
- expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
- }
- })
-
- it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
- for (const editorExplorer of [true, false]) {
- const store = createSidebarStore()
- store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
- store.setSession(`fresh-${editorExplorer}`)
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
- expect(tabs).toHaveLength(1)
- expect(tabs[0]!.type).toBe('editor')
- expect(tabs[0]!.title).toBe('Files')
- expect(tabs[0]!.path).toBeUndefined()
- expect(tabs[0]!.meta).toEqual({ treeOpen: true })
- }
- })
-
- it('derives the default width from the window percent with clamps', () => {
- expect(defaultWidthFor(1440, 30)).toBe(432)
- expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
- expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
- })
-
- it('makeDefaultState honors the open flag', () => {
- expect(makeDefaultState().panelOpen).toBe(true)
- expect(makeDefaultState(400, false).panelOpen).toBe(false)
- expect(makeDefaultState(400, false).width).toBe(400)
- // The 'none' seed starts with an empty pane (no default tab).
- expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf')
- expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0)
- })
-
- it('shares the panel width across sessions (last drag wins)', () => {
- // A real localStorage stub so the cross-session width actually persists
- // (the default no-op mock would silently drop the write).
- const storage = new Map()
- const savedWindow = (globalThis as Record).window
- const savedStorage = (globalThis as Record).localStorage
- ;(globalThis as Record).window = {
- innerWidth: 1280,
- clearTimeout: () => {},
- setTimeout: () => 0,
- }
- ;(globalThis as Record).localStorage = {
- getItem: (k: string) => storage.get(k) ?? null,
- setItem: (k: string, v: string) => { storage.set(k, String(v)) },
- removeItem: (k: string) => { storage.delete(k) },
- key: () => null,
- get length() { return storage.size },
- }
- try {
- const store = createSidebarStore()
- store.setSession('A')
- store.reduce(s => setWidth(s, 500))
- // A fresh session adopts the width dragged in A, not its own default.
- store.setSession('B')
- expect(store.getSnapshot().state?.width).toBe(500)
- // A later drag in B carries back to the cached session A.
- store.reduce(s => setWidth(s, 600))
- store.setSession('A')
- expect(store.getSnapshot().state?.width).toBe(600)
- } finally {
- ;(globalThis as Record).window = savedWindow
- ;(globalThis as Record).localStorage = savedStorage
- }
- })
})
describe('external disable (aionui-panel provider choice)', () => {
@@ -408,13 +279,13 @@ describe('boot decision (one fetch for prefs + external disable)', () => {
it('answers both decisions from a single settingsGet call', async () => {
let calls = 0
const counting = (): SidebarSettingsClient => ({
- settingsGet: async () => { calls += 1; return { value: { openByDefault: true, defaultWidthPercent: 40 }, revision: 1, externalDisable: true } },
+ settingsGet: async () => { calls += 1; return { value: { autoOpenSubagent: false, editorExplorer: true }, revision: 1, externalDisable: true } },
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const decision = await loadBootDecision(counting())
expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1)
expect(decision.suspended).toBe(true)
- expect(decision.prefs.openByDefault).toBe(true)
+ expect(decision.prefs.autoOpenSubagent).toBe(false)
})
it('falls back to the defaults + not suspended on any failure', async () => {
@@ -423,8 +294,8 @@ describe('boot decision (one fetch for prefs + external disable)', () => {
})
it('reads suspended false when the flag is absent', async () => {
- const decision = await loadBootDecision(wire({ defaultWidthPercent: 50 }))
+ const decision = await loadBootDecision(wire({ terminalFontSize: 20 }))
expect(decision.suspended).toBe(false)
- expect(decision.prefs.defaultWidthPercent).toBe(50)
+ expect(decision.prefs.terminalFontSize).toBe(20)
})
})
diff --git a/tests/service.spec.ts b/tests/service.spec.ts
index 7282803..b11d32c 100644
--- a/tests/service.spec.ts
+++ b/tests/service.spec.ts
@@ -2,7 +2,7 @@
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
*/
-import { describe, it, expect, vi } from 'vitest'
+import { describe, it, expect } from 'vitest'
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
const g = globalThis as Record
@@ -21,7 +21,7 @@ if (g.localStorage === undefined) {
}
import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
-import { createSidebarStore, allLeaves, floatTab, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts'
+import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInBottomPane, sanitizeState } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
@@ -108,8 +108,8 @@ describe('enable switches (declarative settings)', () => {
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
store.setSession('s1')
service.openTab({ type: 'explorer', title: 'Explorer' })
- // The seeded files-window home tab stays; no EXPLORER tab landed.
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
+ // No EXPLORER tab landed.
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'explorer')).toHaveLength(0)
})
@@ -287,7 +287,7 @@ describe('service.openTab dedupe', () => {
service.openTab({ type: 'singleton', title: 'Singleton' })
service.openTab({ type: 'singleton', title: 'Singleton' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
})
@@ -303,7 +303,7 @@ describe('service.openTab dedupe', () => {
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
})
@@ -319,7 +319,7 @@ describe('service.openTab dedupe', () => {
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1)
})
@@ -339,7 +339,7 @@ describe('service.openTab dedupe', () => {
service.openTab({ type: 'counter', title: 'Counter' })
service.openTab({ type: 'counter', title: 'Counter' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.id).toBe('counter:1')
expect(tabs[1]!.id).toBe('counter:2')
@@ -353,8 +353,8 @@ describe('service.openTab dedupe', () => {
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
- // Find by path: the seeded files-window home tab is an editor tab too.
- const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
+ // The editor tab is identified by its path.
+ const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts')
expect(tab?.title).toBe('main.ts')
})
@@ -373,7 +373,7 @@ describe('service.openTab dedupe', () => {
store.setSession('s1')
service.openTab({ type: 'browser', url: 'https://example.com/x', title: 'example.com' })
const state = store.getSnapshot().state!
- const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'browser')
+ const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'browser')
expect(tab?.id).toBe('browser:1')
expect(tab?.path).toBe('https://example.com/x')
expect(tab?.title).toBe('example.com')
@@ -387,7 +387,7 @@ describe('service.openTab dedupe', () => {
store.setSession('s1')
service.openTab({ type: 'plain' })
const state = store.getSnapshot().state!
- const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
+ const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'plain')
expect(tab?.title).toBe('Plain')
})
@@ -399,7 +399,7 @@ describe('service.openTab dedupe', () => {
service.openTab({ type: 'singleton' })
service.openTab({ type: 'singleton', id: 'singleton:extra' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
expect(tabs).toHaveLength(1)
})
@@ -417,11 +417,11 @@ describe('service.openTab dedupe', () => {
service.openTab({ type: 'multi', id: 'multi:1' })
service.openTab({ type: 'multi', id: 'multi:2' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
expect(tabs).toHaveLength(2)
service.openTab({ type: 'multi', id: 'multi:1' })
const state2 = store.getSnapshot().state!
- const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
+ const tabs2 = allLeaves(state2.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
expect(tabs2).toHaveLength(2)
})
@@ -461,45 +461,20 @@ describe('service.openTab dedupe', () => {
// (the descriptor's dedupeKey is the same per-change id rule).
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
const state = store.getSnapshot().state!
- const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
+ const tabs = allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
expect(tabs).toHaveLength(1)
})
})
-describe('service.openTab across the two panels', () => {
- it('openTab lands in the bottom tree when the active pane lives there', () => {
+describe('service.openTab in the bottom workbench', () => {
+ it('openTab lands the tab in the bottom tree (the plugin workbench)', () => {
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', () => {
@@ -507,7 +482,6 @@ describe('service.openTab across the two panels', () => {
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')!
@@ -518,134 +492,44 @@ describe('service.openTab across the two panels', () => {
})
describe('service.openTab auto-expand for content opens', () => {
- /** The window stub is a plain object (see the file header), so the width is writable. */
- const setWidth = (width: number): void => {
- ;(g.window as { innerWidth: number }).innerWidth = width
- }
- /** Collapse the right panel (the store defaults it open). */
- const collapseRightPanel = (store: ReturnType): void => {
- store.reduce(s => ({ ...s, panelOpen: false }))
+ /** Collapse the bottom workbench (the plugin's only panel). */
+ const collapseWorkbench = (store: ReturnType): void => {
+ store.reduce(s => ({ ...s, bottomOpen: false }))
}
- it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
- setWidth(390)
- try {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
- store.setSession('s1')
- store.reduce(s => ({ ...s, panelOpen: false }))
- service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
- expect(store.getSnapshot().state?.panelOpen).toBe(true)
- } finally {
- setWidth(1024)
- }
- })
-
- it('expands the collapsed drawer for a URL (browser) open on a narrow viewport', () => {
- setWidth(390)
- try {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
- store.setSession('s1')
- store.reduce(s => ({ ...s, panelOpen: false }))
- service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
- expect(store.getSnapshot().state?.panelOpen).toBe(true)
- } finally {
- setWidth(1024)
- }
- })
-
- it('keeps a collapsed drawer for a type-only open on a narrow viewport', () => {
- setWidth(390)
- try {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
- store.setSession('s1')
- store.reduce(s => ({ ...s, panelOpen: false }))
- service.openTab({ type: 'explorer', title: 'Explorer' })
- expect(store.getSnapshot().state?.panelOpen).toBe(false)
- } finally {
- setWidth(1024)
- }
- })
-
- it('expands the collapsed right panel for a path (file) open on a wide viewport', () => {
+ it('expands the collapsed workbench for a path (file) open', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
- collapseRightPanel(store)
+ collapseWorkbench(store)
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
- expect(state.panelOpen).toBe(true)
- expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
+ expect(state.bottomOpen).toBe(true)
+ expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
})
- it('expands the collapsed right panel for a URL (browser) open on a wide viewport', () => {
+ it('expands the collapsed workbench for a URL (browser) open', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
store.setSession('s1')
- collapseRightPanel(store)
+ collapseWorkbench(store)
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
- expect(store.getSnapshot().state!.panelOpen).toBe(true)
+ expect(store.getSnapshot().state!.bottomOpen).toBe(true)
})
- it('a wide-viewport path open landing in the bottom tree expands the bottom panel instead', () => {
+ it('expands the collapsed workbench even when the open focuses an existing tab (id dedupe)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
- // The last-touched pane lives in the bottom tree and BOTH panels are
- // collapsed: the open must surface the bottom panel, not the right one.
- store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id, panelOpen: false, bottomOpen: false }))
+ service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
+ collapseWorkbench(store)
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
expect(state.bottomOpen).toBe(true)
- expect(state.panelOpen).toBe(false)
- expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
- })
-
- it('keeps a collapsed panel for a type-only open on a wide viewport', () => {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
- store.setSession('s1')
- collapseRightPanel(store)
- service.openTab({ type: 'explorer', title: 'Explorer' })
- expect(store.getSnapshot().state?.panelOpen).toBe(false)
- })
-
- it('expands on a narrow viewport even when the open focuses an existing tab (id dedupe)', () => {
- setWidth(390)
- try {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
- store.setSession('s1')
- service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
- store.reduce(s => ({ ...s, panelOpen: false }))
- service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
- expect(store.getSnapshot().state?.panelOpen).toBe(true)
- } finally {
- setWidth(1024)
- }
- })
-
- it('expands on a wide viewport even when the open focuses an existing tab (id dedupe)', () => {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
- store.setSession('s1')
- service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
- collapseRightPanel(store)
- service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
- const state = store.getSnapshot().state!
- expect(state.panelOpen).toBe(true)
- expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
+ expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1)
})
})
@@ -673,7 +557,7 @@ describe('state subscription (v0.12.0)', () => {
const snapshot = service.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
expect(snapshot.state).toBeDefined()
- expect(snapshot.prefs.openByDefault).toBe(false)
+ expect(snapshot.prefs.agentTerminalTools).toBe(false)
})
it('subscribeState fires on state changes but NOT on registry changes', () => {
@@ -701,7 +585,7 @@ describe('updateTab (v0.12.0)', () => {
store.setSession('s1')
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1' })
service.updateTab('doc:1', { title: 'Compiling…', meta: { progress: 42 } })
- const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
+ const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
expect(tab.title).toBe('Compiling…')
expect(tab.meta).toEqual({ progress: 42 })
// A missing tab id is a no-op (does not throw).
@@ -710,7 +594,7 @@ describe('updateTab (v0.12.0)', () => {
})
describe('activateTab (v0.12.0)', () => {
- it('activates a tab in either tree and fires onActivate with the session scope', () => {
+ it('activates a tab and fires onActivate with the session scope', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const seen: Array<{ tab: string; sessionId: string }> = []
@@ -722,15 +606,13 @@ describe('activateTab (v0.12.0)', () => {
component: () => null,
})
store.setSession('s1')
- // Land in the bottom tree by switching the active pane.
- store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
service.openTab({ type: 'git', title: 'Git' })
const gitTab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
expect(gitTab).toBeDefined()
service.activateTab(gitTab.id)
expect(seen).toEqual([{ tab: gitTab.id, sessionId: 's1' }])
- // The active pane followed the tab into the bottom tree.
- expect(store.getSnapshot().state!.activePane).toBe(gitTab.id === '' ? null : allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
+ // The active pane is the bottom leaf hosting the tab.
+ expect(store.getSnapshot().state!.activePane).toBe(allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
})
})
@@ -741,14 +623,13 @@ describe('targeted openTab (v0.12.0)', () => {
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
store.setSession('s1')
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
- // The UI snapshot still shows s1, untouched (its default files-window tab
- // is the only one — no notes tab landed there).
+ // The UI snapshot still shows s1, untouched (no notes tab landed there).
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('s1')
- expect(allLeaves(snapshot.state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
+ expect(allLeaves(snapshot.state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
// Switching to s2 reveals the tab.
store.setSession('s2')
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs)
expect(tabs.map(t => t.id)).toContain('notes:1')
})
@@ -761,7 +642,7 @@ describe('targeted openTab (v0.12.0)', () => {
store.subscribe(() => { calls++ })
service.openTab({ type: 'explorer', title: 'Explorer' }, { sessionId: 's1' })
expect(calls).toBe(1)
- expect(store.getSnapshot().state!.splits).not.toBe(undefined)
+ expect(store.getSnapshot().state!.bottomSplits).not.toBe(undefined)
})
it('dedupe runs against the TARGET session (opens there focus an existing tab of that session)', () => {
@@ -772,7 +653,7 @@ describe('targeted openTab (v0.12.0)', () => {
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
store.setSession('s2')
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
expect(tabs).toHaveLength(1)
})
})
@@ -785,13 +666,13 @@ describe('openFile (v0.12.0)', () => {
store.setSession('s1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
const state = store.getSnapshot().state!
- // Find by path: the seeded files-window home tab is an editor tab too.
- const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
+ // The editor tab is identified by its path.
+ const tab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined)
expect(tab?.title).toBe('main.ts')
expect(tab?.path).toBe('/p/src/main.ts')
// Windows separators are handled too.
service.openFile({ sessionId: 's1' }, 'C:\\x\\y\\spec.ts', 'custom title')
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
expect(tabs[tabs.length - 1]?.title).toBe('custom title')
expect(tabs[tabs.length - 1]?.path).toBe('C:\\x\\y\\spec.ts')
})
@@ -828,7 +709,7 @@ describe('tab lifecycle callbacks (v0.12.0)', () => {
const { store, service, events } = setup()
store.setSession('s1')
service.openTab({ type: 'life', title: 'Life' })
- const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
+ const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'life')!
events.length = 0
service.closeTab(tab.id)
expect(events).toEqual(['close'])
@@ -865,7 +746,7 @@ describe('tab lifecycle callbacks (v0.12.0)', () => {
})
store.setSession('s1')
expect(() => service.openTab({ type: 'boom', title: 'Boom' })).not.toThrow()
- const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
+ const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
expect(() => service.closeTab(tab.id)).not.toThrow()
})
@@ -875,7 +756,7 @@ describe('tab lifecycle callbacks (v0.12.0)', () => {
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { life: false } })
service.openTab({ type: 'life', title: 'Life' })
expect(events).toEqual([])
- expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
+ expect(allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
})
})
@@ -886,19 +767,19 @@ describe('tab meta (v0.12.0)', () => {
service.registerTab({ id: 'db', title: 'DB', component: () => null })
store.setSession('s1')
service.openTab({ type: 'db', title: 'DB', id: 'db:1', meta: { table: 'users', page: 3 } })
- const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
+ const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
expect(tab.meta).toEqual({ table: 'users', page: 3 })
// Reload round-trip: the persisted shape sanitizes back with meta intact.
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
- const restored = allLeaves(sanitized!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
+ const restored = allLeaves(sanitized!.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
expect(restored.meta).toEqual({ table: 'users', page: 3 })
})
it('older persisted tabs (no meta) sanitize unchanged', () => {
- const state = makeDefaultState(400, true, 'none')
- const withTab = openTabInActivePane(state, { id: 'tab:old', type: 'git', title: 'Git' })
+ const state = makeDefaultState()
+ const withTab = openTabInBottomPane(state, { id: 'tab:old', type: 'git', title: 'Git' })
const sanitized = sanitizeState(JSON.parse(JSON.stringify(withTab)))
- const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
+ const tabs = allLeaves(sanitized!.bottomSplits).flatMap(l => l.tabs)
expect(tabs[0]?.meta).toBeUndefined()
})
})
@@ -928,7 +809,7 @@ describe('lifecycle classification vs dedupe (codex review fixes)', () => {
{ kind: 'open', tabId: 'doc:1' },
{ kind: 'activate', tabId: 'doc:1' },
])
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'doc')
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'doc')
expect(tabs.map(t => t.id)).toEqual(['doc:1'])
})
@@ -979,7 +860,7 @@ describe('independent CR follow-up fixes', () => {
store.setSession('s1')
service.openTab({ type: 'web', title: 'first', url: 'https://a.example' })
service.openTab({ type: 'web', title: 'second', url: 'https://b.example' })
- const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'web')
+ const tabs = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).filter(t => t.type === 'web')
expect(tabs).toHaveLength(1)
// The focused tab keeps its ORIGINAL url — the second open must not
// repoint it.
@@ -998,21 +879,6 @@ describe('independent CR follow-up fixes', () => {
expect(calls).toBe(0)
})
- it('a targeted open into an INACTIVE session never auto-expands its panels', () => {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
- store.setSession('s1')
- // The target session starts collapsed.
- store.reduceFor('s2', s => ({ ...s, panelOpen: false, bottomOpen: false }))
- service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }, { sessionId: 's2' })
- // Nothing is in sight for the user — the open must not expand s2.
- store.setSession('s2')
- expect(store.getSnapshot().state?.panelOpen).toBe(false)
- expect(store.getSnapshot().state?.bottomOpen).toBe(false)
- expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
- })
-
it('closeTab/activateTab accept an optional scope that rides to the callback', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
@@ -1027,7 +893,7 @@ describe('independent CR follow-up fixes', () => {
})
store.setSession('s1')
service.openTab({ type: 'life', title: 'Life' })
- const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
+ const tab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'life')!
service.activateTab(tab.id, { sessionId: 's1', cwd: '/work' })
service.closeTab(tab.id, { sessionId: 's1', cwd: '/work' })
expect(seen).toEqual([
@@ -1055,70 +921,4 @@ describe('independent CR follow-up fixes', () => {
service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })
expect(() => service.registerFileViewer({ id: 'csv', exts: ['csv'], fetchStrategy: 'custom', component: () => null })).toThrow(/already registered/)
})
-
- describe('free windows (v0.16.0)', () => {
- it('openTab dedupe focuses a FLOATING tab by raising its window (no duplicate, no panel expansion)', () => {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- service.registerTab({ id: 'singleton', title: 'S', dedupeKey: () => 'singleton', component: () => null })
- store.setSession('s1')
- service.openTab({ type: 'singleton', title: 'S' })
- // Float the singleton out, then float a second tab above it and
- // collapse the panel — the focus must raise the window in place
- // without reopening a tab or expanding anything.
- store.reduce(s => floatTab(s, 'singleton', 100, 100))
- service.openTab({ type: 'singleton', title: 'S' })
- store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 100, 100))
- const before = store.getSnapshot().state!
- expect(before.floats).toHaveLength(2)
- store.reduce(s => ({ ...s, panelOpen: false }))
- service.openTab({ type: 'singleton', title: 'S' })
- const after = store.getSnapshot().state!
- // Raised to the top, not duplicated.
- expect(after.floats).toHaveLength(2)
- expect(after.floats.at(-1)!.tab.type).toBe('singleton')
- // The panel stays collapsed (a floating tab is already in sight).
- expect(after.panelOpen).toBe(false)
- expect(allLeaves(after.splits).some(l => l.tabs.some(t => t.type === 'singleton'))).toBe(false)
- })
-
- it('closeTab on a floating tab closes it WITH the window and fires onClose', () => {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- const onClose = vi.fn()
- service.registerTab({ id: 'notes', title: 'Notes', single: true, onClose, component: () => null })
- store.setSession('s1')
- service.openTab({ type: 'notes', title: 'Notes' })
- store.reduce(s => floatTab(s, 'notes', 50, 50))
- expect(store.getSnapshot().state!.floats).toHaveLength(1)
- service.closeTab('notes', { sessionId: 's1' })
- const after = store.getSnapshot().state!
- expect(after.floats).toHaveLength(0)
- expect(onClose).toHaveBeenCalledTimes(1)
- // Unknown ids stay a strict no-op.
- service.closeTab('notes')
- expect(store.getSnapshot().state).toBe(after)
- })
-
- it('activateTab on a floating tab raises the window and fires onActivate', () => {
- const store = createSidebarStore()
- const service = createBetterSidebarService(store)
- const onActivate = vi.fn()
- service.registerTab({ id: 'notes', title: 'Notes', single: true, onActivate, component: () => null })
- store.setSession('s1')
- service.openTab({ type: 'notes', title: 'Notes' })
- service.openTab({ type: 'notes', title: 'Notes' })
- store.reduce(s => floatTab(s, 'notes', 50, 50))
- store.reduce(s => floatTab(s, (s.splits as { tabs: Array<{ id: string }> }).tabs[0]!.id, 60, 60))
- const before = store.getSnapshot().state!
- expect(before.floats).toHaveLength(2)
- expect(before.floats.at(-1)!.tab.type).not.toBe('notes')
- service.activateTab('notes')
- const after = store.getSnapshot().state!
- expect(after.floats.at(-1)!.tab.type).toBe('notes')
- // Two activations total: the second openTab's dedupe focus (before the
- // float) plus THIS explicit activateTab — both legitimate focuses.
- expect(onActivate).toHaveBeenCalledTimes(2)
- })
- })
})
diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx
index d538250..e8e381a 100644
--- a/tests/side-card-section.spec.tsx
+++ b/tests/side-card-section.spec.tsx
@@ -90,9 +90,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
- // Default prefs: no general switch is on (openByDefault and agentOpenTools
- // both default off), and both tabs + the image viewer cards pressed
- // (3 aria-pressed cards).
+ // Default prefs: the general switch is off (agentOpenTools defaults off),
+ // and both tabs + the image viewer cards are pressed (3 aria-pressed
+ // cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0)
@@ -178,9 +178,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
expect(html).not.toContain('