diff --git a/AGENTS.md b/AGENTS.md index 66fa6d2..41dc2b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # dsh-better-sidebar 仓库规则(AGENTS) > 本文只含**项目全局开发规则**(面向贡献者与 agent)。 -> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、自由窗口、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。 +> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。 --- @@ -67,6 +67,6 @@ ## 6. 文档与测试地图 -- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 自由窗口 / §12 皮肤契约 / §15 真实案例)。 +- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。 - **设计文档**:[docs/plans/](docs/plans/)(30+ 份逐特性设计,含实施偏差记录)。 -- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `free-window.spec.tsx`(自由窗口)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。 +- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `native-surface.spec.ts`(原生右侧栏承载面)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。 diff --git a/README.md b/README.md index 95f54e4..9b1fca1 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,10 @@ - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 -- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口) +- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(落进工作台的 diff 分栏);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 -- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 -- **🪟 自由窗口**:把标签栏的任一 tab 拖到主会话区域——成为可移动 / 缩放 / 置顶的悬浮窗口(默认 390×780),拖回侧边栏 pane 即停靠,随会话持久化;`features` 含 `'floatWindows'`,插件 tab 无差别支持 +- **🖥️ 原生右侧栏 + 底部工作台**:右列交给 DSH 0.1.5 的原生右侧栏——插件把每个 tab 类型注册成原生 tab(文件打开走 `dsh-resource://file/**`,并接管内置「文件」页 / 文件树),插件自己只保留底部工作台(分栏 / 终端 / 随会话持久化),开合按钮挂在会话头右侧 - **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留 - **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化 - **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗 @@ -166,7 +165,7 @@ dsh registry enable dsh-external/dsh-better-sidebar | **🖥️ CodeMirror 代码编辑器**
CodeMirror 代码编辑器
| **🖼️ 图片内联预览**
图片内联预览
| | **💻 真实终端**
xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。
真实终端
| **🌿 文件变动:Git 视角 + 本轮文件**
双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。
文件变动
| | **🌐 内嵌浏览器**
多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。
内嵌浏览器
| **🧩 任务页:子代理拓扑 + 后台任务**
子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动激活任务页,宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉(可关)。
任务页:子代理拓扑
| -| **💬 侧边对话(beta)**
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
侧边对话(beta)
| **🪟 双工作台:右侧栏 + 底部面板 + 分栏**
右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉;把 tab 拖到主会话区域可变为**自由窗口**(悬浮 / 缩放 / 置顶,拖回 pane 停靠)。
双工作台(右侧栏 + 底部面板)
| +| **💬 侧边对话(beta)**
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
侧边对话(beta)
| **🖥️ DSH 原生右侧栏 + 插件底部工作台**
右列是 DSH 自己的右侧栏:插件把每个 tab 类型注册成原生 tab(含接管内置「文件」页与文件预览),聊天里的文件点击直接落到原生栏;插件自有底部面板可与其同时展开,拖 Tab 到分栏边缘**拆分**、拖到中间**合并**,高度拖上缘调节;开合按钮在会话头右侧。
双工作台(右侧栏 + 底部面板)
| | **⚙️ 声明式设置**
设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。
声明式设置:侧边卡片
| **📱 移动端**
窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
移动端全宽抽屉
| ## 🌐 插件生态 @@ -263,6 +262,24 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar **支持的 DSH 版本**:支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+ · 完整发布历史见 [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**
CodeMirror editor
| **🖼️ Inline image preview**
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.
Real terminal
| **🌿 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).
Changes
| | **🌐 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).
Embedded browser
| **🧩 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).
Tasks: subagent topology
| -| **💬 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.
Side Chat (beta)
| **🪟 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).
Dual workbench (right sidebar + bottom panel)
| +| **💬 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.
Side Chat (beta)
| **🖥️ 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.
Dual workbench (right sidebar + bottom panel)
| | **⚙️ 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`.
Declarative settings: side cards
| **📱 Mobile**
On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
Mobile full-width drawer
| ## 🌐 Plugin Ecosystem @@ -267,6 +266,24 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid **Supported DSH versions**: Supported DSH versions (v0.18.1): 0.1.2-rc.1+ · 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} - - { setMenu(null) }} - items={[ - { id: 'dock', label: t('dockToSidebar') }, - { id: 'close', label: t('close') }, - ]} - onSelect={(id) => { - setMenu(null) - if (id === 'dock') onDock(null) - else if (id === 'close') onClose() - }} - portal - compact - align="start" - getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))} - anchor={} - /> -
- {/* 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. */} -
-
{renderTab(float.tab, true, float.id)}
-
-
{ - if (event.button !== 0) return - if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return - event.preventDefault() - capturePointer(event.currentTarget, event.pointerId) - dragRef.current = { - mode: 'resize', - 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('resize') - }} - onPointerMove={(event) => { - const drag = dragRef.current - if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return - scheduleApply(clampResize( - drag.startW + (event.clientX - drag.pointerX), - drag.startH + (event.clientY - drag.pointerY), - )) - }} - onPointerUp={(event) => { - if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return - releasePointer(event.currentTarget, event.pointerId) - flushNow() - const geo = clampResize( - dragRef.current.startW + (event.clientX - dragRef.current.pointerX), - dragRef.current.startH + (event.clientY - dragRef.current.pointerY), - ) - finishDrag('resize', geo, null) - }} - onPointerCancel={() => { abortDrag() }} - onLostPointerCapture={() => { abortDrag() }} - /> -
- ) -} diff --git a/src/client/RenderBoundary.tsx b/src/client/RenderBoundary.tsx index f856adf..32e66b2 100644 --- a/src/client/RenderBoundary.tsx +++ b/src/client/RenderBoundary.tsx @@ -7,7 +7,7 @@ * errors in the sidebar shell itself (Workbench, drag layout, …) — a full * swap keeps the page alive. * - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing - * viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the + * viewer/editor shows a strip inside ITS OWN pane; the workbench shell, the * other tabs, and the panel itself stay alive (issue #31 — a tab crash * must never take down the whole sidebar). * diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index dfdb632..14c865f 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -54,11 +54,8 @@ import clsx from 'clsx' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - clampWidthPercent, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, - WIDTH_PERCENT_MAX, - WIDTH_PERCENT_MIN, type SidebarPrefs, } from '../prefs-shared.ts' import { api } from './api.ts' @@ -572,7 +569,6 @@ export function SettingsBody(props: { */ export function SideCardSection({ store, service }: SideCardSectionProps) { const [prefs, setPrefs] = useState(() => store.getPrefs()) - const [widthDraft, setWidthDraft] = useState(String(store.getPrefs().defaultWidthPercent)) const [error, setError] = useState(null) // Which feature's secondary settings popup is open (null = closed). const [settingsFor, setSettingsFor] = useState(null) @@ -624,9 +620,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { if (cancelled) return revisionRef.current = view.revision if (dirtyRef.current) return - const next = parsePrefs(view.value) - setPrefs(next) - setWidthDraft(String(next.defaultWidthPercent)) + setPrefs(parsePrefs(view.value)) }).catch(() => { /* the store's defaults stay authoritative */ }) return () => { cancelled = true } }, []) @@ -657,9 +651,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { /** Settle one commit: success adopts the server values, failure reverts. */ const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => { - const settled = outcome.ok ? outcome.prefs : previous - setPrefs(settled) - setWidthDraft(String(settled.defaultWidthPercent)) + setPrefs(outcome.ok ? outcome.prefs : previous) } /** Optimistically apply one pref patch, then commit (revert on failure). */ @@ -672,10 +664,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { void commit(patch).then(outcome => applyOutcome(previous, outcome)) } - const onToggle = (next: boolean): void => { - applyPref({ openByDefault: next }) - } - /** Flip one per-tab enable switch (merge into the tabsEnabled map). */ const onToggleTab = (id: string, next: boolean): void => { applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } }) @@ -778,20 +766,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { return raw } - const commitWidth = (): void => { - const parsed = Number(widthDraft) - if (!Number.isFinite(parsed)) { - setWidthDraft(String(prefs.defaultWidthPercent)) - return - } - const clamped = clampWidthPercent(parsed) - const previous = prefs - setPrefs({ ...previous, defaultWidthPercent: clamped }) - setWidthDraft(String(clamped)) - setError(null) - void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome)) - } - /** * One SMALL toggle card for the responsive inventory grid: the card's main * area is the switch (click to flips, visual state IS the state), the icon @@ -867,40 +841,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { {/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
{t('settingsGeneralTitle')}
-
- - {t('settingsOpenTitle')} - {t('settingsOpenDesc')} - - -
-
- - {t('settingsWidthTitle')} - {t('settingsWidthDesc')} - - - { setWidthDraft(event.currentTarget.value) }} - onBlur={commitWidth} - onKeyDown={event => { - if (event.key === 'Enter') event.currentTarget.blur() - }} - /> - {t('settingsWidthSuffix')} - -
{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. - */} -
0 ? `${keyboardInset}px` : undefined, - }} - - data-dragging={anyDragging || undefined} - > - {!narrow && ( -
{ - 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 && ( -
{ - event.preventDefault() - event.currentTarget.setPointerCapture(event.pointerId) - dragCommitted.current = false - cornerDrag.current = { - startX: event.clientX, - startY: event.clientY, - startWidth: state.width, - startHeight: state.bottomHeight, - } - setDraggingCorner(true) - }} - onPointerMove={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - const { startX, startY, startWidth, startHeight } = cornerDrag.current - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) - 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, startY, startWidth, startHeight } = cornerDrag.current - // Up position wins over the rAF pending value (see the width - // strip handler — issue #247). - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) - commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height)) - setDraggingCorner(false) - }} - onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }} - onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }} - /> - )} -
- {/* - 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
{ onNewTab(optionId, 'bottom') }} - renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')} + actions={wrappedActions} + onNewTab={onNewTab} + renderTab={renderTab} getTabIcon={tabIconOf} getTabBadge={tabBadgeOf} />
- )} - {/* - 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('Auto-detect<') - // Two general-row switches remain (openByDefault + agentOpenTools), neither - // on by default — the scheme row is a dropdown, not a switch. - expect(html.match(/type="checkbox"/g)?.length).toBe(2) + // One general-row switch remains (agentOpenTools), off by default — the + // scheme row is a dropdown, not a switch. + expect(html.match(/type="checkbox"/g)?.length).toBe(1) expect(html.match(/checked=""/g)?.length ?? 0).toBe(0) // Auto (default) needs no further settings → no gear. expect(html).not.toContain('Position compatibility mode Feature settings') @@ -319,10 +319,10 @@ describe('mergePluginSetting (v0.12.0, codex review fix)', () => { describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () => { it('plugin rows read from their OWN value source — a plugin key colliding with a host pref never reads the host value', () => { - const prefs = { ...SIDEBAR_PREFS_DEFAULTS, openByDefault: true } - const toggle = { key: 'openByDefault', title: 'My flag' } + const prefs = { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: true } + const toggle = { key: 'agentOpenTools', title: 'My flag' } // valueOf returns undefined (the plugin never wrote this key): the row - // must render UNCHECKED even though the host pref openByDefault is true. + // must render UNCHECKED even though the host pref agentOpenTools is true. let html = renderToString(createElement(FeatureSettingsRows, { toggles: [toggle], prefs, diff --git a/tests/sidebar-auto-activation.spec.tsx b/tests/sidebar-auto-activation.spec.tsx index 548e4f6..c7b7249 100644 --- a/tests/sidebar-auto-activation.spec.tsx +++ b/tests/sidebar-auto-activation.spec.tsx @@ -1,7 +1,9 @@ /** - * Real-Sidebar regressions for issue #162: background activity should activate - * the Tasks page on every viewport, but must not force a narrow full-screen - * drawer open over the chat. + * Real-Sidebar regressions for issue #162: background activity (a new + * subagent, a new background job) activates the Tasks page in the bottom + * workbench. The right column belongs to DSH's native Sidebar, so there is no + * full-screen drawer to guard against any more — the activation lands in the + * docked workbench on every viewport width. */ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -57,7 +59,7 @@ function setViewport(width: number): void { Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }) } -function mountSidebar(width: number, panelOpen = false): MountedSidebar { +function mountSidebar(width: number, bottomOpen = false): MountedSidebar { setViewport(width) vi.stubGlobal('WebSocket', FakeWebSocket) const sessionId = `auto-activation-${++sessionSeq}` @@ -72,7 +74,7 @@ function mountSidebar(width: number, panelOpen = false): MountedSidebar { const store = createSidebarStore() store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true }) store.setSession(sessionId) - store.reduce(state => ({ ...state, panelOpen })) + store.reduce(state => ({ ...state, bottomOpen })) const service = createBetterSidebarService(store) service.registerTab({ id: 'subagent', title: 'Subagent', component: () => null }) const localeSnapshot = { active: 'en' } @@ -143,14 +145,14 @@ function publishJob(sidebar: MountedSidebar): void { }) } -function expectSubagentFocused(sidebar: MountedSidebar, panelOpen: boolean): void { +function expectSubagentFocused(sidebar: MountedSidebar): void { const state = sidebar.store.getSnapshot().state! - const right = firstLeaf(state.splits) - expect(state.panelOpen).toBe(panelOpen) - expect(state.activePane).toBe(right.id) - expect(allLeaves(state.splits).flatMap(leaf => leaf.tabs) + const pane = firstLeaf(state.bottomSplits) + expect(state.bottomOpen).toBe(true) + expect(state.activePane).toBe(pane.id) + expect(allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) .filter(tab => tab.type === 'subagent')).toHaveLength(1) - expect(right.tabs.find(tab => tab.id === right.active)?.type).toBe('subagent') + expect(pane.tabs.find(tab => tab.id === pane.active)?.type).toBe('subagent') } beforeEach(() => { @@ -170,11 +172,11 @@ afterEach(() => { describe('Sidebar background-activity auto-activation (#162)', () => { it.each([ - { source: 'subagent', width: 390, opens: false }, - { source: 'job', width: 390, opens: false }, - { source: 'subagent', width: 1024, opens: true }, - { source: 'job', width: 1024, opens: true }, - ] as const)('$source activation at $width px sets panelOpen=$opens', ({ source, width, opens }) => { + { source: 'subagent', width: 390 }, + { source: 'job', width: 390 }, + { source: 'subagent', width: 1024 }, + { source: 'job', width: 1024 }, + ] as const)('$source activation at $width px focuses the Tasks page in the workbench', ({ source, width }) => { const sidebar = mountSidebar(width) if (source === 'subagent') { publishSubagent(sidebar) @@ -182,10 +184,10 @@ describe('Sidebar background-activity auto-activation (#162)', () => { } else { publishJob(sidebar) } - expectSubagentFocused(sidebar, opens) + expectSubagentFocused(sidebar) }) - it.each(['subagent', 'job'] as const)('%s activation preserves an already-open narrow drawer', (source) => { + it.each(['subagent', 'job'] as const)('%s activation keeps an already-open workbench open', (source) => { const sidebar = mountSidebar(390, true) if (source === 'subagent') { publishSubagent(sidebar) @@ -193,14 +195,14 @@ describe('Sidebar background-activity auto-activation (#162)', () => { } else { publishJob(sidebar) } - expectSubagentFocused(sidebar, true) + expectSubagentFocused(sidebar) }) - it('uses the current viewport when the delayed subagent activation fires', () => { + it('a resize between arming and firing the debounce does not change the landing', () => { const sidebar = mountSidebar(1024) publishSubagent(sidebar) setViewport(390) flushSubagentDebounce() - expectSubagentFocused(sidebar, false) + expectSubagentFocused(sidebar) }) }) diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index bf2bb7b..6997116 100644 --- a/tests/sidebar-crash.spec.tsx +++ b/tests/sidebar-crash.spec.tsx @@ -2,9 +2,10 @@ * Sidebar crash tests — the two failure modes behind issue #31. * * 1. Layout-push leak: the layout-push effect writes - * `--dsh-sidebar-width/--dsh-sidebar-height` on document.documentElement. + * `--dsh-sidebar-height` on document.documentElement (the right column + * belongs to DSH's native Sidebar, so no width is pushed). * Unmounting the Sidebar for ANY reason (error-boundary swap, plugin - * disable, HMR) must clear them — otherwise layout.css keeps squeezing + * disable, HMR) must clear it — otherwise layout.css keeps squeezing * `#root` with a stale margin and "the sidebar cannot be hidden" until a * full page reload. * @@ -57,8 +58,6 @@ function mountSidebar(): MountedSidebar { document.body.append(container) const store = createSidebarStore() const service = createBetterSidebarService(store) - // Fresh-session seed: open the panel explicitly (openByDefault defaults off). - store.setPrefs({ ...store.getPrefs(), openByDefault: true }) // Unique session per test — the store persists per-session state to // localStorage (200ms debounce); a shared id lets a previous test's late // write leak into this store's setSession restore. @@ -100,28 +99,25 @@ afterEach(() => { }) describe('layout-push variable cleanup', () => { - it('clears --dsh-sidebar-width/--dsh-sidebar-height when the sidebar unmounts', () => { - const { store, unmount } = mountSidebar() + it('clears --dsh-sidebar-height when the sidebar unmounts (and never pushes a width)', () => { + const { unmount } = mountSidebar() const htmlStyle = document.documentElement.style - // The seeded session is open: the layout push is applied on mount. - const width = store.getSnapshot().state!.width - expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe(`${width}px`) + // A fresh session seeds the bottom workbench collapsed: the push is 0. expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('0px') + // The right column is DSH's native Sidebar — this shell writes no width. + expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('') // Any unmount (boundary swap, plugin disable, HMR) must release the push. unmount() - expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('') expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('') }) - it('a size change (release commit) re-applies the variables without removing them mid-commit', () => { + it('a size change (release commit) re-applies the variable without removing it mid-commit', () => { const { store, unmount } = mountSidebar() const htmlStyle = document.documentElement.style - const width = store.getSnapshot().state!.width const removeSpy = vi.spyOn(htmlStyle, 'removeProperty') // Simulate a drag release: the bottom panel opens and its height commits. act(() => { store.reduce(toggleBottomPanel) }) act(() => { store.reduce(s => setBottomHeight(s, 300)) }) - expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe(`${width}px`) expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('300px') // The commit must NOT have removed the variables at any point. React // runs every effect cleanup before every effect setup in a commit, so a @@ -134,18 +130,17 @@ describe('layout-push variable cleanup', () => { // the full page then bounce back" flash. const removalProps = removeSpy.mock.calls .map(call => call[0] as string) - .filter(prop => prop === '--dsh-sidebar-width' || prop === '--dsh-sidebar-height') + .filter(prop => prop === '--dsh-sidebar-height') expect(removalProps).toHaveLength(0) - // Only unmounting may remove them (issue #31). + // Only unmounting may remove it (issue #31). unmount() - expect(removeSpy.mock.calls.some(call => - call[0] === '--dsh-sidebar-width' || call[0] === '--dsh-sidebar-height')).toBe(true) + expect(removeSpy.mock.calls.some(call => call[0] === '--dsh-sidebar-height')).toBe(true) removeSpy.mockRestore() }) }) describe('tab crash containment', () => { - it('a crashing tab shows an in-pane strip while the cluster and panel survive', () => { + it('a crashing tab shows an in-pane strip while the panel survives', () => { const { container, service, store } = mountSidebar() service.registerTab({ id: 'crash', @@ -156,11 +151,12 @@ describe('tab crash containment', () => { // The strip lives inside the tab's pane — the crash is contained. expect(container.textContent).toContain('boom') expect(container.textContent).toContain(t('terminalRetry')) - // The toggle cluster and the panel itself survived (no full-tree swap): - // the collapse button is still there and the layout push is still live. - expect(container.querySelector(`[aria-label="${t('collapse')}"]`)).not.toBeNull() - expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')).toBe( - `${store.getSnapshot().state!.width}px`, + // The workbench itself survived (no full-tree swap): the collapse control + // is still there and the layout push is still live (the content open + // expanded the panel). + expect(container.querySelector(`[aria-label="${t('collapseBottomPanel')}"]`)).not.toBeNull() + expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')).toBe( + `${store.getSnapshot().state!.bottomHeight}px`, ) }) diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index 1f0ea1b..4c5b2d7 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -1,19 +1,25 @@ +/** + * The layout-push contract is split between the shell (DOM writes) and + * layout-push.ts (the cap): these source assertions pin the seams a + * refactor silently breaks — every push must ride the shared cap, the + * keyboard inset must extend the PUSH (never the panel's own height), and + * the cap must be measured against the visual viewport. + */ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync('src/client/Sidebar.tsx', 'utf8') describe('Sidebar layout-push integration', () => { - it('does not bypass the shared bottom-height cap during width drags', () => { + it('routes every push through the shared bottom-height cap', () => { + expect(source.match(/bottomPushHeight\(\{/g)).toHaveLength(2) expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') - expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9) }) - it('caps panel geometry against the viewport visible above the keyboard', () => { + it('caps the geometry against the visible height above the keyboard', () => { expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))') expect(source).toContain('visualViewportHeight ?? viewport.height') - expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) - expect(source).toContain('viewport.width, layoutViewportHeight, keyboardInset]') + expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(2) }) it('adds the keyboard inset to the conversation push, not the panel height', () => { @@ -22,9 +28,11 @@ describe('Sidebar layout-push integration', () => { expect(source).not.toContain('height: bottomPanelHeight + keyboardInset') }) - it('reapplies the visible-height cap to every vertical drag result', () => { - expect(source).not.toMatch(/const height = clampHeight\(/) - expect(source).not.toMatch(/height = clampHeight\(/) - expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6) + it('reapplies the height clamp to every vertical drag result', () => { + // Both live pointer paths (move + up) clamp before writing or committing. + expect(source.match(/clampHeight\(startHeight \+ \(startY - event\.clientY\)\)/g)).toHaveLength(2) + // The interrupted-drag paths (pointercancel / lost capture) clamp too. + expect(source.match(/clampHeight\(bottomDrag\.current\.startHeight/g)).toHaveLength(1) + expect(source.match(/clampHeight\(lastDragHeight\.current/g)).toHaveLength(1) }) }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 85b06e6..16caa48 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -992,8 +992,6 @@ describe('side card settings routes', () => { expect(read.ok).toBe(true) expect(read.value).toEqual({ value: { - openByDefault: false, - defaultWidthPercent: 35, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, @@ -1013,7 +1011,6 @@ describe('side card settings routes', () => { browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', - changesDiffFloat: true, // The enable-switch maps default to {} (everything on). tabsEnabled: {}, viewersEnabled: {}, @@ -1024,11 +1021,11 @@ describe('side card settings routes', () => { externalDisable: false, }) - const written = await invoke(route, 'settings.update', { patch: { openByDefault: true } }) + const written = await invoke(route, 'settings.update', { patch: { agentOpenTools: true } }) expect(written.ok).toBe(true) - const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number } - expect(view.value.openByDefault).toBe(true) - expect(view.value.defaultWidthPercent).toBe(35) + const view = written.value as { value: { agentOpenTools: boolean; terminalFontSize: number }; revision: number } + expect(view.value.agentOpenTools).toBe(true) + expect(view.value.terminalFontSize).toBe(13) expect(view.revision).toBe(1) }) @@ -1062,10 +1059,10 @@ describe('side card settings routes', () => { it('refuses a stale write with settings-conflict (409)', async () => { const route = mountWithSettings(createFakeSettings()) - await invoke(route, 'settings.update', { patch: { openByDefault: false } }) + await invoke(route, 'settings.update', { patch: { agentOpenTools: false } }) // The second write carries the pre-write revision: the seam refuses it. const stale = await invoke(route, 'settings.update', { - patch: { defaultWidthPercent: 40 }, + patch: { terminalFontSize: 15 }, expectedRevision: 0, }) expect(stale.ok).toBe(false) diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 55d659d..876cf58 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -1,90 +1,56 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { - activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeFloatByTab, closeTab, createSidebarStore, - dockFloat, FLOAT_MIN_H, FLOAT_MIN_W, floatTab, floatWithTab, insertLeafAt, makeDefaultState, - migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab, - openTabInActivePane, patchTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplit, + activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, CONVERSATION_MIN, createSidebarStore, + insertLeafAt, makeDefaultState, moveTab, moveTabToEdge, openDiffTab, + openTabInBottomPane, patchTab, resizeSplit, resizeSplitIn, revealPaths, sanitizeState, setBottomHeight, setTabPin, - splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, - type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode, + splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, + type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' describe('sidebar state', () => { const state = (): SidebarState => makeDefaultState() - it('makeDefaultState seeds per the seed enum (editor-home / none)', () => { - // Default and explicit 'editor-home' seed an EMPTY editor tab (the files - // window) with the tree panel pinned open. - for (const s of [makeDefaultState(), makeDefaultState(400, true, 'editor-home')]) { - const leaf = s.splits as { tabs: SidebarTab[]; active: string | null } - expect(leaf.tabs).toHaveLength(1) - expect(leaf.tabs[0]!.type).toBe('editor') - expect(leaf.tabs[0]!.title).toBe('Files') - expect(leaf.tabs[0]!.path).toBeUndefined() - expect(leaf.tabs[0]!.meta).toEqual({ treeOpen: true }) - expect(leaf.active).toBe(leaf.tabs[0]!.id) - } - // The seeded home tab survives the persist round-trip (meta intact). - const restored = sanitizeState(JSON.parse(JSON.stringify(makeDefaultState()))) - const restoredLeaf = restored!.splits as { tabs: SidebarTab[] } - expect(restoredLeaf.tabs[0]!.meta).toEqual({ treeOpen: true }) - // 'none' seeds an empty pane. - const bare = makeDefaultState(400, true, 'none') - const bareLeaf = bare.splits as { tabs: SidebarTab[]; active: string | null } - expect(bareLeaf.tabs).toHaveLength(0) - expect(bareLeaf.active).toBeNull() - }) - - it('sanitizeState migrates persisted explorer tabs to editor home tabs (both trees)', () => { + it('sanitizeState migrates persisted explorer tabs to editor home tabs', () => { const valid = sanitizeState({ - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], - splits: { - kind: 'leaf', - id: 'pane:1', - active: 'ex-right', - tabs: [{ id: 'ex-right', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }], - }, bottomSplits: { kind: 'leaf', - id: 'pane:b', + id: 'pane:1', active: 'ex-bottom', - tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer' }], + tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }], }, }) - const right = (valid?.splits as { tabs: SidebarTab[] }).tabs - expect(right).toHaveLength(1) + const tabs = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs + expect(tabs).toHaveLength(1) // Migrated: editor home tab (no path), tree pinned open, prior meta kept. - expect(right[0]).toMatchObject({ id: 'ex-right', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } }) - expect(right[0]!.path).toBeUndefined() - const bottom = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs - expect(bottom).toHaveLength(1) - expect(bottom[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true } }) + expect(tabs[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } }) + expect(tabs[0]!.path).toBeUndefined() }) - it('opens tabs into the active pane and dedupes by id (safety net)', () => { + it('opens tabs into the workbench pane and dedupes by id (safety net)', () => { let s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - s = openTabInActivePane(s, gitTab) - expect(s.splits.kind).toBe('leaf') - expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) + s = openTabInBottomPane(s, gitTab) + expect(s.bottomSplits.kind).toBe('leaf') + expect((s.bottomSplits as { tabs: unknown[] }).tabs).toHaveLength(1) + // Landing a tab opens the bottom workbench. + expect(s.bottomOpen).toBe(true) // Reopening with the SAME id focuses the existing tab instead of duplicating. - const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' }) - expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) + const after = openTabInBottomPane(s, { id: 'git', type: 'git' as const, title: 'Git' }) + expect((after.bottomSplits as { tabs: unknown[] }).tabs).toHaveLength(1) // A different id opens a new tab (type-level dedupe is the service's job). - const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' }) - expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3) + const after2 = openTabInBottomPane(s, { id: 'git2', type: 'git' as const, title: 'Git' }) + expect((after2.bottomSplits as { tabs: unknown[] }).tabs).toHaveLength(2) }) it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => { let s = state() - const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id - s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) - const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) - expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2']) + s = openTabInBottomPane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) + const after = openTabInBottomPane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' }) + expect((after.bottomSplits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual(['e1', 'e2']) }) const diffTab = (id: string): SidebarTab => ({ @@ -97,11 +63,11 @@ describe('sidebar state', () => { it('first diff splits the source pane vertically (diff below)', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - const withGit = openTabInActivePane(s, gitTab) - const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id + const withGit = openTabInBottomPane(s, gitTab) + const sourcePane = (withGit.bottomSplits as { kind: 'leaf'; id: string }).id const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) - expect(after.splits.kind).toBe('split') - const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] } + expect(after.bottomSplits.kind).toBe('split') + const split = after.bottomSplits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] } expect(split.dir).toBe('col') expect(split.children).toHaveLength(2) // The source stays on TOP (first child), the diff lands in the new bottom leaf. @@ -113,13 +79,13 @@ describe('sidebar state', () => { it('reopening the same diff focuses its existing tab', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - const withGit = openTabInActivePane(s, gitTab) - const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id + const withGit = openTabInBottomPane(s, gitTab) + const sourcePane = (withGit.bottomSplits as { kind: 'leaf'; id: string }).id const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts')) // No new panes, no duplicate tabs. - expect(second.splits.kind).toBe('split') - const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] } + expect(second.bottomSplits.kind).toBe('split') + const split = second.bottomSplits as { children: { kind: string; tabs?: SidebarTab[] }[] } const allTabs = split.children.flatMap(child => child.tabs ?? []) expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1) }) @@ -127,13 +93,13 @@ describe('sidebar state', () => { it('subsequent diffs stack into the existing diff pane', () => { const s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } - const withGit = openTabInActivePane(s, gitTab) - const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id + const withGit = openTabInBottomPane(s, gitTab) + const sourcePane = (withGit.bottomSplits as { kind: 'leaf'; id: string }).id const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678')) // Still one split: the second diff joins the bottom leaf instead of splitting again. - expect(second.splits.kind).toBe('split') - const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] } + expect(second.bottomSplits.kind).toBe('split') + const split = second.bottomSplits as { children: { kind: string; tabs?: SidebarTab[] }[] } const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff')) expect(diffLeaves).toHaveLength(1) expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([ @@ -145,18 +111,16 @@ describe('sidebar state', () => { it('openDiffTab degrades to a regular open when the source pane is gone', () => { const s = state() const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts')) - expect(after.splits.kind).toBe('leaf') - expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts') + expect(after.bottomSplits.kind).toBe('leaf') + expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts') }) it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => { const valid = sanitizeState({ - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], - splits: { + bottomSplits: { kind: 'leaf', id: 'pane:1', active: 'd1', @@ -166,38 +130,34 @@ describe('sidebar state', () => { ], }, }) - expect(valid?.splits.kind).toBe('leaf') - const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs + expect(valid?.bottomSplits.kind).toBe('leaf') + const tabs = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab']) // The dropped diff tab was the active one: the leaf falls back to a null // active instead of resetting the whole state. - expect((valid?.splits as { active: string | null }).active).toBeNull() + expect((valid?.bottomSplits as { active: string | null }).active).toBeNull() // A leaf of ONLY diff tabs survives as an empty pane (welcome cards). const onlyDiff = sanitizeState({ - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], - splits: { + bottomSplits: { kind: 'leaf', id: 'pane:1', active: 'd1', tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }], }, }) - expect(onlyDiff?.splits.kind).toBe('leaf') - expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([]) + expect(onlyDiff?.bottomSplits.kind).toBe('leaf') + expect((onlyDiff?.bottomSplits as { tabs: SidebarTab[] }).tabs).toEqual([]) }) it('sanitize removes a pane emptied by ephemeral diff tabs', () => { const valid = sanitizeState({ - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:diff', expanded: [], - splits: { + bottomSplits: { kind: 'split', id: 'split:1', dir: 'col', @@ -208,50 +168,51 @@ describe('sidebar state', () => { ], }, }) - expect(valid?.splits.kind).toBe('leaf') - expect((valid?.splits as { id: string; tabs: SidebarTab[] }).id).toBe('pane:git') + expect(valid?.bottomSplits.kind).toBe('leaf') + expect((valid?.bottomSplits as { id: string; tabs: SidebarTab[] }).id).toBe('pane:git') expect(valid?.activePane).toBe('pane:git') }) it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => { let s = state() - s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) - expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2) + s = openTabInBottomPane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) + expect((s.bottomSplits as { tabs: unknown[] }).tabs).toHaveLength(1) // Reopening (e.g. the auto-activation effect) focuses the existing tab. - const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) - expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2) - const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs + const after = openTabInBottomPane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) + expect((after.bottomSplits as { tabs: unknown[] }).tabs).toHaveLength(1) + const tabs = (after.bottomSplits as { tabs: { type: string; id: string }[] }).tabs expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1) }) it('splits panes and moves tabs between them', () => { let s = state() + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) s = splitPane(s, 'row') - expect(s.splits.kind).toBe('split') - const split = s.splits as Extract + expect(s.bottomSplits.kind).toBe('split') + const split = s.bottomSplits as Extract expect(split.children).toHaveLength(2) - const explorerId = (split.children[0] as { id: string }).id + const sourceId = (split.children[0] as { id: string }).id const otherId = (split.children[1] as { id: string }).id expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0) - const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id - s = moveTab(s, explorerId, explorerTab, otherId) + s = moveTab(s, sourceId, 'git', otherId) // The source pane emptied and was removed; the target leaf is promoted. - expect(s.splits.kind).toBe('leaf') - expect((s.splits as { id: string }).id).toBe(otherId) - expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab]) + expect(s.bottomSplits.kind).toBe('leaf') + expect((s.bottomSplits as { id: string }).id).toBe(otherId) + expect((s.bottomSplits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual(['git']) }) it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => { let s = state() + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) s = splitPane(s, 'row') - const split = s.splits as Extract + const split = s.bottomSplits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string; tabs: { id: string }[] } const tabId = paneA.tabs[0]!.id // 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。 - s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' }) + s = openTabInBottomPane(s, { id: 't2', type: 'terminal', title: 'T2' }) s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right') - const after = s.splits as Extract + const after = s.bottomSplits as Extract // paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。 const bSplit = after.children.find(child => child.kind === 'split') as Extract | undefined expect(bSplit).toBeDefined() @@ -262,52 +223,54 @@ describe('sidebar state', () => { it('dragging a tab to a pane center merges it into the pane', () => { let s = state() + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) s = splitPane(s, 'col') - const split = s.splits as Extract + const split = s.bottomSplits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string; tabs: { id: string }[] } const tabId = paneA.tabs[0]!.id s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center') // paneA 空了被移除,树退化为 paneB(含 tab)。 - expect(s.splits.kind).toBe('leaf') - expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId]) + expect(s.bottomSplits.kind).toBe('leaf') + expect((s.bottomSplits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId]) }) it('dragging a tab back onto its own pane center reorders it', () => { let s = state() - s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' }) - const leaf = s.splits as { id: string; tabs: { id: string }[] } + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) + s = openTabInBottomPane(s, { id: 't2', type: 'terminal', title: 'T2' }) + const leaf = s.bottomSplits as { id: string; tabs: { id: string }[] } const first = leaf.tabs[0]!.id s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center') - const after = s.splits as { tabs: { id: string }[] } + const after = s.bottomSplits as { tabs: { id: string }[] } expect(after.tabs[after.tabs.length - 1]!.id).toBe(first) expect(after.tabs).toHaveLength(2) }) it('closing the last tab removes the pane (promotes the sibling)', () => { let s = state() + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) s = splitPane(s, 'col') - const split = s.splits as Extract + const split = s.bottomSplits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string } - const explorerId = paneA.tabs[0]!.id - // paneA gets a terminal; the explorer moves to paneB; closing the + // paneA gets a terminal; the git tab moves to paneB; closing the // terminal empties paneA, which is removed, promoting paneB. - s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' }) - s = moveTab(s, paneA.id, explorerId, paneB.id) + s = openTabInBottomPane(s, { id: 't', type: 'terminal', title: 'Terminal 1' }) + s = moveTab(s, paneA.id, 'git', paneB.id) s = activateTab(s, paneA.id, 't') s = closeTab(s, paneA.id, 't') - expect(s.splits.kind).toBe('leaf') - expect((s.splits as { id: string }).id).toBe(paneB.id) + expect(s.bottomSplits.kind).toBe('leaf') + expect((s.bottomSplits as { id: string }).id).toBe(paneB.id) }) it('resizes splits within the clamp range', () => { let s = state() s = splitPane(s, 'row') - const split = s.splits as Extract + const split = s.bottomSplits as Extract const id = split.id - s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) } - const after = s.splits as Extract + s = { ...s, bottomSplits: resizeSplit(s.bottomSplits, id, 0, 0.2) } + const after = s.bottomSplits as Extract expect(after.sizes[0]).toBeCloseTo(0.7) expect(after.sizes[1]).toBeCloseTo(0.3) }) @@ -319,26 +282,28 @@ describe('sidebar state', () => { expect(s.expanded).toEqual(['/p/a', '/p/b']) s = toggleExpanded(s, '/p/a') expect(s.expanded).toEqual(['/p/b']) - const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null } + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) + const leaf = s.bottomSplits as { id: string; tabs: { id: string }[]; active: string | null } const tabId = leaf.tabs[0]!.id const after = activateTab(s, leaf.id, tabId) - expect((after.splits as { active: string | null }).active).toBe(tabId) + expect((after.bottomSplits as { active: string | null }).active).toBe(tabId) }) it('patchTab updates the title and path of one open tab (browser persistence)', () => { let s = state() - const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] } - s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' }) + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) + s = openTabInBottomPane(s, { id: 'browser:1', type: 'browser', title: 'Browser' }) const browserId = 'browser:1' s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' }) - const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId) + const tab = (s.bottomSplits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId) expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' }) // A partial patch leaves the other field untouched. s = patchTab(s, browserId, { title: 'example.org' }) - const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId) + const again = (s.bottomSplits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId) expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' }) // Other tabs are untouched. - expect(leaf.tabs[0]).toBeDefined() + const git = (s.bottomSplits as { tabs: { id: string; title: string }[] }).tabs.find(t => t.id === 'git') + expect(git?.title).toBe('Git') }) it('patchTab is a no-op for a missing tab id', () => { @@ -349,12 +314,10 @@ describe('sidebar state', () => { it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => { const base = { - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], - splits: { + bottomSplits: { kind: 'leaf', id: 'pane:1', active: null, @@ -371,38 +334,35 @@ describe('sidebar state', () => { expect(makeDefaultState().nextBrowser).toBe(1) }) - it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => { + it('tabOpenIn: a tab is open until it is truly closed, in any pane', () => { let s = state() - const leaf = s.splits as { id: string; tabs: { id: string }[] } - const explorerId = leaf.tabs[0]!.id - expect(tabOpenIn(s, explorerId)).toBe(true) + s = openTabInBottomPane(s, { id: 'git', type: 'git', title: 'Git' }) + expect(tabOpenIn(s, 'git')).toBe(true) // Moving the tab to another pane keeps it open. s = splitPane(s, 'row') - const split = s.splits as Extract + const split = s.bottomSplits as Extract const paneA = split.children[0] as { id: string; tabs: { id: string }[] } const paneB = split.children[1] as { id: string } - s = moveTab(s, paneA.id, explorerId, paneB.id) - expect(tabOpenIn(s, explorerId)).toBe(true) + s = moveTab(s, paneA.id, 'git', paneB.id) + expect(tabOpenIn(s, 'git')).toBe(true) // Closing it removes it from the whole tree. - const target = s.splits as { id: string; tabs: { id: string }[] } - s = closeTab(s, target.id, explorerId) - expect(tabOpenIn(s, explorerId)).toBe(false) + const target = s.bottomSplits as { id: string; tabs: { id: string }[] } + s = closeTab(s, target.id, 'git') + expect(tabOpenIn(s, 'git')).toBe(false) // A terminal tab added later is open too. - s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' }) + s = openTabInBottomPane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' }) expect(tabOpenIn(s, 'terminal:9')).toBe(true) }) - // ── Bottom panel (the second, independent workbench) ─────────────────── + // ── Bottom workbench (the plugin's only surface) ─────────────────────── - it('toggleBottomPanel flips the bottom panel independently of the right panel', () => { + it('toggleBottomPanel flips the bottom workbench', () => { let s = state() expect(s.bottomOpen).toBe(false) s = toggleBottomPanel(s) expect(s.bottomOpen).toBe(true) - // Collapsing the right panel leaves the bottom panel open (independent toggles). - s = togglePanel(s) - expect(s.panelOpen).toBe(false) - expect(s.bottomOpen).toBe(true) + s = toggleBottomPanel(s) + expect(s.bottomOpen).toBe(false) }) it('setBottomHeight clamps to the contract range', () => { @@ -411,150 +371,29 @@ describe('sidebar state', () => { const previous = g.window g.window = { innerHeight: 800 } try { - // The bottom panel must leave the center column at least PANEL_MIN - // tall (800 - 280), regardless of the right panel's open state. + // The bottom panel must leave the conversation column at least + // CONVERSATION_MIN tall (800 - 280). expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280) - expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280) } finally { if (previous === undefined) delete g.window else g.window = previous } }) - // ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ── - - it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => { + it('resizeSplitIn resizes a divider in the bottom workbench tree', () => { let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - // Two bottom tabs in their own pane; the right pane holds explorer. - s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) - s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' }) - const migrated = migrateBottomTabs(s) - // All tabs now live in the right tree's first leaf, bottom tabs appended. - expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id)) - .toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2']) - // The bottom tree is emptied (structure stays), the panel closes, and - // new tabs land in the right tree. - expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) - expect(migrated.bottomOpen).toBe(false) - expect(migrated.activePane).toBe((migrated.splits as { id: string }).id) - // The migrated tabs are fully functional: closing one works through the - // right tree. - expect(tabOpenIn(migrated, 'terminal:1')).toBe(true) - expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false) - }) - - it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => { - let s = state() - s = splitPane(s, 'row') // splits the active pane into two leaves - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' }) - const migrated = migrateBottomTabs(s) - // The first (leftmost) leaf carries the bottom tab; the second leaf - // keeps its own tabs untouched. - const leaves = allLeaves(migrated.splits) - expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9') - expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0) - }) - - it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => { - const s = state() - expect(migrateBottomTabs(s)).toBe(s) - // With the panel open but no tabs, the migration only closes the panel. - const open = toggleBottomPanel(s) - const migrated = migrateBottomTabs(open) - expect(migrated).not.toBe(open) - expect(migrated.bottomOpen).toBe(false) - expect(migrateBottomTabs(migrated)).toBe(migrated) - }) - - it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => { - let s = state() - const bottomPane = (s.bottomSplits as { id: string }).id - s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed - const migrated = migrateBottomTabs(s) - expect(migrated.activePane).toBe((migrated.splits as { id: string }).id) - // A tab opened after the migration lands in the VISIBLE right tree. - const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' }) - expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git') - }) - - it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => { - let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = { ...s, activePane: bottomPane } - const tab = { id: 'git', type: 'git' as const, title: 'Git' } - s = openTabInActivePane(s, tab) - expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git') - // The right tree is untouched (its seeded files-window home tab stays). - expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['editor']) - expect(s.activePane).toBe(bottomPane) - // The id safety net works across trees: reopening the same id focuses it. - const after = openTabInActivePane(s, tab) - expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git']) - }) - - it('openTabInActivePane falls back to the right tree when the active pane is stale', () => { - let s = state() - s = toggleBottomPanel(s) - s = { ...s, activePane: 'pane:gone' } - const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' }) - expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git') - }) - - it('closeTab routes to the bottom tree', () => { - let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) - expect(tabOpenIn(s, 'terminal:1')).toBe(true) - s = closeTab(s, bottomPane, 'terminal:1') - expect(tabOpenIn(s, 'terminal:1')).toBe(false) - // The right tree is untouched. - expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true) - }) - - it('moveTabToEdge splits within the bottom tree', () => { - let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) - s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right') - expect(s.bottomSplits.kind).toBe('split') - expect(s.splits.kind).toBe('leaf') - expect(tabOpenIn(s, 'terminal:1')).toBe(true) - // The fresh leaf (the drop's active pane) differs from the source pane. - expect(s.activePane).not.toBe(bottomPane) - }) - - it('resizeSplitIn routes a divider to its own tree', () => { - let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = splitPane({ ...s, activePane: bottomPane }, 'row') + s = splitPane(s, 'row') const split = s.bottomSplits as Extract s = resizeSplitIn(s, split.id, 0, 0.1) const next = s.bottomSplits as Extract expect(next.sizes[0]).toBeCloseTo(0.6) - expect(s.splits.kind).toBe('leaf') }) it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => { const base = { - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], - splits: { - kind: 'leaf', - id: 'pane:1', - active: null, - tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }], - }, } // Older persisted states lack the bottom fields: defaults, state kept. const s = sanitizeState(base) @@ -564,8 +403,8 @@ describe('sidebar state', () => { expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) // A malformed bottom tree is replaced with a fresh empty pane. const broken = sanitizeState({ ...base, bottomSplits: 'junk' }) - expect(broken?.splits).toBeDefined() expect(broken?.bottomSplits.kind).toBe('leaf') + expect((broken?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) // A valid persisted bottom tree survives. const withBottom = sanitizeState({ ...base, @@ -583,27 +422,25 @@ describe('sidebar state', () => { expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1']) // Heights are clamped to the contract range. expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN) - // A stale full-height bottom panel must not squeeze the center column - // (the agent output area) to zero: the cap leaves it at least PANEL_MIN - // tall, regardless of the right panel's open state. + // A stale full-height bottom panel must not squeeze the conversation + // column to zero: sanitizeState applies setBottomHeight's own cap + // (viewport - CONVERSATION_MIN), so a restored height never snaps on + // the first drag. const g = globalThis as Record const previous = g.window g.window = { innerHeight: 800 } try { - expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280) - expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280) + expect(sanitizeState({ ...base, bottomHeight: 9999 })?.bottomHeight).toBe(800 - CONVERSATION_MIN) } finally { if (previous === undefined) delete g.window else g.window = previous } }) - it('tabOpenIn and patchTab see tabs in the bottom tree', () => { + it('tabOpenIn and patchTab see tabs in the bottom workbench', () => { let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = openTabInActivePane( - { ...s, activePane: bottomPane }, + s = openTabInBottomPane( + s, { id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' }, ) expect(tabOpenIn(s, 'browser:1')).toBe(true) @@ -612,46 +449,9 @@ describe('sidebar state', () => { expect(tab?.title).toBe('other.com') }) - it('moves a tab across panels (center merge into the other tree)', () => { - let s = state() - s = toggleBottomPanel(s) - const rightPane = (s.splits as { id: string }).id - const bottomPane = (s.bottomSplits as { id: string }).id - const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id - // Drag the explorer tab from the right panel into the bottom panel (center). - s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center') - expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId) - expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) - expect(s.activePane).toBe(bottomPane) - // And back, inserted at an index. - s = moveTab(s, bottomPane, explorerId, rightPane, 0) - expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId) - expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) - }) - - it('moves a tab across panels by splitting the target pane (edge drop)', () => { - let s = state() - s = toggleBottomPanel(s) - const rightPane = (s.splits as { id: string }).id - const bottomPane = (s.bottomSplits as { id: string }).id - const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id - s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right') - // The source tree empties back to a leaf; the target tree splits. - expect(s.splits.kind).toBe('leaf') - expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) - expect(s.bottomSplits.kind).toBe('split') - expect(tabOpenIn(s, explorerId)).toBe(true) - const split = s.bottomSplits as Extract - expect(split.children.some( - child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId), - )).toBe(true) - // The fresh leaf (the drop's active pane) differs from the source pane. - expect(s.activePane).not.toBe(rightPane) - }) - it('moveTab with a non-existent source or target pane is safe', () => { const s = state() - const pane = (s.splits as { id: string }).id + const pane = (s.bottomSplits as { id: string }).id // Missing source: returns unchanged state expect(moveTab(s, 'pane:ghost', 'tab:1', pane)).toBe(s) // Missing target: returns unchanged state @@ -660,75 +460,72 @@ describe('sidebar state', () => { it('closeTab with non-existent tab or pane returns equivalent state without throwing', () => { const s = state() - const pane = (s.splits as { id: string }).id + const pane = (s.bottomSplits as { id: string }).id expect(closeTab(s, 'pane:ghost', 'tab:1')).toEqual(s) expect(closeTab(s, pane, 'tab:ghost')).toEqual(s) }) it('moveTabToEdge with non-existent pane returns unchanged state', () => { const s = state() - const pane = (s.splits as { id: string }).id + const pane = (s.bottomSplits as { id: string }).id expect(moveTabToEdge(s, 'pane:ghost', 'tab:1', pane, 'right')).toBe(s) expect(moveTabToEdge(s, pane, 'tab:1', 'pane:ghost', 'right')).toBe(s) }) }) describe('persisted state sanitization', () => { - it('accepts a well-formed state unchanged (node environment: no width clamp)', () => { - const state = makeDefaultState(400) + it('accepts a well-formed state unchanged (node environment: no geometry clamp)', () => { + const state = makeDefaultState() const clean = sanitizeState(JSON.parse(JSON.stringify(state))) expect(clean).toEqual(state) }) it('accepts a subagent tab as a known type', () => { - const raw = JSON.parse(JSON.stringify(makeDefaultState(400))) - raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' }) - raw.splits.active = 'tab:9' + const raw = JSON.parse(JSON.stringify(makeDefaultState())) + raw.bottomSplits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' }) + raw.bottomSplits.active = 'tab:9' const clean = sanitizeState(raw) expect(clean).toBeDefined() - const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs + const tabs = (clean!.bottomSplits as { tabs: { type: string }[] }).tabs expect(tabs.some(tab => tab.type === 'subagent')).toBe(true) }) - it('clamps undersized widths to the panel minimum', () => { - const state = { ...makeDefaultState(400), width: 10 } - const clean = sanitizeState(JSON.parse(JSON.stringify(state))) - expect(clean?.width).toBe(280) - }) - - it('rejects malformed shapes instead of crashing the panel', () => { + it('rejects malformed top-level shapes and repairs a malformed bottom tree', () => { expect(sanitizeState(null)).toBeUndefined() expect(sanitizeState('nope')).toBeUndefined() expect(sanitizeState({})).toBeUndefined() - expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined() - expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined() - // A split whose sizes do not match its children is rejected. - const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400))) - withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] } - expect(sanitizeState(withSplit)).toBeUndefined() + // A split whose sizes do not match its children is repaired into a fresh + // empty pane (workbench fields degrade instead of resetting the state). + const withSplit = JSON.parse(JSON.stringify(makeDefaultState())) + withSplit.bottomSplits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] } + expect(sanitizeState(withSplit)?.bottomSplits.kind).toBe('leaf') // Unknown tab types (external plugins not yet loaded) are accepted — // they render as at view time and recover if the plugin // loads later. Only diff tabs are dropped (ephemeral). - const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400))) - withExternalTab.splits.tabs[0].type = 'my-plugin:db' + const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState())) + withExternalTab.bottomSplits.tabs.push({ id: 'tab:9', type: 'my-plugin:db', title: 'DB' }) + withExternalTab.bottomSplits.active = 'tab:9' const externalClean = sanitizeState(withExternalTab) expect(externalClean).toBeDefined() - if (externalClean !== undefined && externalClean.splits.kind === 'leaf') { - expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db') + if (externalClean !== undefined && externalClean.bottomSplits.kind === 'leaf') { + expect(externalClean.bottomSplits.tabs[0]!.type).toBe('my-plugin:db') } - // An active id that no tab carries is rejected. - const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400))) - withBadActive.splits.active = 'ghost-tab' - expect(sanitizeState(withBadActive)).toBeUndefined() + // An active id that no tab carries is structural corruption: the tree is + // repaired into a fresh empty pane. + const withBadActive = JSON.parse(JSON.stringify(makeDefaultState())) + withBadActive.bottomSplits.active = 'ghost-tab' + const repaired = sanitizeState(withBadActive) + expect(repaired).toBeDefined() + expect((repaired!.bottomSplits as { tabs: unknown[] }).tabs).toEqual([]) }) it('re-ids stale duplicate pane/split ids and follows the activePane rename', () => { // The pre-seeding counter reset could mint a fresh "pane:1" beside the // persisted "pane:1": mapLeaf then hit BOTH leaves and every open landed // in both panes. Sanitize must give the repeat a fresh id. - const corrupted = JSON.parse(JSON.stringify(makeDefaultState(400))) + const corrupted = JSON.parse(JSON.stringify(makeDefaultState())) corrupted.activePane = 'pane:1' - corrupted.splits = { + corrupted.bottomSplits = { kind: 'split', id: 'split:1', dir: 'col', @@ -740,39 +537,37 @@ describe('persisted state sanitization', () => { } const clean = sanitizeState(corrupted) expect(clean).toBeDefined() - const leaves = allLeaves(clean!.splits) + const leaves = allLeaves(clean!.bottomSplits) // The empty first occurrence is pruned; the populated repeat keeps its // fresh unique id and becomes active. expect(leaves).toHaveLength(1) expect(leaves[0]!.id).not.toBe('pane:1') expect(clean!.activePane).toBe(leaves[0]!.id) // And an open must land in exactly one pane of the healed tree. - const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) - const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts')) + const opened = openTabInBottomPane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) + const owners = allLeaves(opened.bottomSplits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts')) expect(owners).toHaveLength(1) }) it('falls back from a stale active pane instead of dropping the open', () => { let s = makeDefaultState() - const paneA = allLeaves(s.splits)[0]!.id - const seededTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'editor')!.id - s = closeTab(s, paneA, seededTab) - s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) - const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false) - s = { ...s, splits: split.node, activePane: paneA } + const paneA = allLeaves(s.bottomSplits)[0]!.id + s = openTabInBottomPane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) + const split = insertLeafAt(s.bottomSplits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false) + s = { ...s, bottomSplits: split.node, activePane: paneA } // Closing the editor empties paneA; the pane is removed but activePane // still points at it. The next open must land in the surviving pane. s = closeTab(s, paneA, 'editor:/a.ts') - s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' }) - const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts')) + s = openTabInBottomPane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' }) + const owners = allLeaves(s.bottomSplits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts')) expect(owners).toHaveLength(1) expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true) }) it('handles state with deeply corrupted split children gracefully', () => { const corrupted = { - ...makeDefaultState(400), - splits: { + ...makeDefaultState(), + bottomSplits: { kind: 'split', id: 's1', dir: 'row', @@ -783,7 +578,12 @@ describe('persisted state sanitization', () => { ], }, } - expect(sanitizeState(corrupted)).toBeUndefined() + // The whole tree is malformed: it is replaced with a fresh empty pane + // instead of crashing the workbench on mount. + const repaired = sanitizeState(corrupted) + expect(repaired).toBeDefined() + expect(repaired!.bottomSplits.kind).toBe('leaf') + expect((repaired!.bottomSplits as { tabs: unknown[] }).tabs).toEqual([]) }) }) @@ -821,10 +621,10 @@ describe('v0.12.0 store additions', () => { it('loads a fresh state for a never-visited target session', () => { const store = createSidebarStore() store.setSession('s1') - store.reduceFor('brand-new', (state) => ({ ...state, panelOpen: false })) + store.reduceFor('brand-new', (state) => ({ ...state, bottomOpen: true })) store.setSession('brand-new') - expect(store.getSnapshot().state?.panelOpen).toBe(false) - expect(store.getSnapshot().state?.splits).toBeDefined() + expect(store.getSnapshot().state?.bottomOpen).toBe(true) + expect(store.getSnapshot().state?.bottomSplits).toBeDefined() }) it('reduceFor never lowers the shared uid counter below the active session needs (no pane-id collision)', () => { @@ -835,13 +635,13 @@ describe('v0.12.0 store additions', () => { // well above b's max (default pane, plus fresh pane ids from splits). store.setSession('a') store.reduce(s => splitPane(s, 'row')) - const before = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id).sort() + const before = allLeaves(store.getSnapshot().state!.bottomSplits).map(leaf => leaf.id).sort() // A targeted reduce into the OLD, low-id session must not lower the // counter: the next split in the ACTIVE session would otherwise mint // an id that already exists (mapLeaf visits both leaves → corruption). store.reduceFor('b', (state) => state) store.reduce(s => splitPane(s, 'row')) - const after = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id) + const after = allLeaves(store.getSnapshot().state!.bottomSplits).map(leaf => leaf.id) expect(new Set(after).size).toBe(after.length) // The active session's pre-existing pane ids all survived untouched. for (const id of before) expect(after).toContain(id) @@ -871,10 +671,7 @@ describe('v0.12.0 store additions', () => { // timers BOTH writes are pending and both land when they fire. expect(timers.size).toBe(2) for (const [, fn] of [...timers]) fn() - // Each persist also syncs the shared cross-session width key (PR #36). - expect(writes.filter(key => key !== 'dsh-sidebar:v1:width')) - .toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b']) - expect(writes).toContain('dsh-sidebar:v1:width') + expect(writes).toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b']) } finally { delete g.window delete g.localStorage @@ -888,7 +685,7 @@ describe('v0.12.0 store additions', () => { store.setSession('s1') store.reduce(s => ({ ...s, - splits: { + bottomSplits: { kind: 'leaf' as const, id: 'pane:1', tabs: [{ id: 'tab:1', type: 'db', title: 'DB', meta: { q: [1, 2], n: 0 } }], @@ -896,321 +693,36 @@ describe('v0.12.0 store additions', () => { }, })) const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!))) - const tabs = allLeaves(sanitized!.splits).flatMap(leaf => leaf.tabs) + const tabs = allLeaves(sanitized!.bottomSplits).flatMap(leaf => leaf.tabs) expect(tabs[0]?.meta).toEqual({ q: [1, 2], n: 0 }) }) }) }) -describe('free windows (v0.16.0)', () => { - // Node environment: no window → the viewport clamps are Infinity and only - // the size floors apply; the geometry tests stub a window where clamping - // is the point under test. - const state = (): SidebarState => makeDefaultState() - - it('floatTab moves a docked tab into floats (pane keeps its other tabs)', () => { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - const term = { id: 'term:1', type: 'terminal' as const, title: 'Term' } - s = openTabInActivePane(s, git) - s = openTabInActivePane(s, term) - const floated = floatTab(s, git.id, 300, 200) - const leaf = floated.splits as SidebarLeaf - expect(leaf.tabs).toHaveLength(2) - expect(leaf.tabs.some(tab => tab.id === 'git')).toBe(false) - // The pane's active pointer fell to the last remaining tab. - expect(leaf.active).toBe('term:1') - expect(floated.floats).toHaveLength(1) - expect(floated.floats[0]!.tab).toEqual(git) - // Phone-ratio default (390x780) centered on the drop point (no viewport - // → size cap never bites; only the negative y clamps to 0). - expect(floated.floats[0]).toMatchObject({ x: 300 - 195, y: 0, w: 390, h: 780 }) - expect(floated.activePane).toBe(leaf.id) +describe('revealPaths (show in folder)', () => { + it('expands ancestors with their ABSOLUTE path (leading separator preserved)', () => { + // POSIX: the root (/w/src) is not itself expanded, but the subdirs + // below it must be recorded as ABSOLUTE paths so FileTree's + // `expanded.includes(entry.path)` matches. + const base = makeDefaultState() + const next = revealPaths(base, '/w/src', ['/w/src/sub/deep/a.ts']) + expect(next.revealed).toEqual(['/w/src/sub/deep/a.ts']) + expect(next.expanded).toContain('/w/src/sub') + expect(next.expanded).toContain('/w/src/sub/deep') + expect(next.expanded).not.toContain('w/src/sub') + expect(next.expanded).not.toContain('/w/src') }) - it('floatTab collapses a pane it empties and repoints the active pane', () => { - let s = state() - const paneId = (s.splits as SidebarLeaf).id - s = splitPane(s, 'row') - const fresh = allLeaves(s.splits).find(leaf => leaf.id !== paneId)! - s = { ...s, activePane: fresh.id } - s = openTabInActivePane(s, { id: 'term:1', type: 'terminal', title: 'T' }) - expect(allLeaves(s.splits).find(leaf => leaf.id === fresh.id)!.tabs).toHaveLength(1) - const floated = floatTab(s, 'term:1', 100, 100) - expect(floated.floats).toHaveLength(1) - expect(allLeaves(floated.splits).some(leaf => leaf.id === fresh.id)).toBe(false) - expect(floated.activePane).toBe(paneId) + it('keeps a Windows drive-letter root and a UNC prefix', () => { + const drive = revealPaths(makeDefaultState(), 'C:\\work', ['C:\\work\\src\\a.ts']) + expect(drive.expanded).toContain('C:\\work\\src') + const unc = revealPaths(makeDefaultState(), '\\\\server\\share', ['\\\\server\\share\\sub\\a.ts']) + expect(unc.expanded).toContain('\\\\server\\share\\sub') }) - it('floatTab with an unknown or already-floating tab id is a strict no-op', () => { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - s = openTabInActivePane(s, git) - expect(floatTab(s, 'nope', 0, 0)).toBe(s) - const floated = floatTab(s, 'git', 50, 50) - expect(floatTab(floated, 'git', 60, 60)).toBe(floated) - }) - - it('moveFloat / resizeFloat clamp to the viewport (floors always)', () => { - const g = globalThis as Record - const previous = g.window - g.window = { innerWidth: 1024, innerHeight: 768 } - try { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - s = floatTab(openTabInActivePane(s, git), 'git', 512, 384) - const id = s.floats[0]!.id - // Negative position clamps to 0; the window stays fully inside. - let moved = moveFloat(s, id, -50, -50) - expect(moved.floats[0]).toMatchObject({ x: 0, y: 0 }) - // Beyond the right/bottom edge clamps the top-left so the window fits. - moved = moveFloat(moved, id, 5000, 5000) - // The float was CREATED capped to the viewport: w 390, h 768-24=744. - expect(moved.floats[0]!.x).toBe(1024 - 390) - expect(moved.floats[0]!.y).toBe(768 - 744) - // No-op move returns the same reference (no persist churn). - expect(moveFloat(moved, id, moved.floats[0]!.x, moved.floats[0]!.y)).toBe(moved) - // Resize: floors, viewport ceiling, and the SE-corner anchor (x/y keep). - let resized = resizeFloat(moved, id, 10, 10) - expect(resized.floats[0]).toMatchObject({ w: FLOAT_MIN_W, h: FLOAT_MIN_H }) - resized = resizeFloat(resized, id, 5000, 5000) - expect(resized.floats[0]!.w).toBe(1024 - resized.floats[0]!.x) - expect(resized.floats[0]!.h).toBe(768 - resized.floats[0]!.y) - // No-op resize and unknown ids return the same reference. - expect(resizeFloat(resized, id, resized.floats[0]!.w, resized.floats[0]!.h)).toBe(resized) - expect(resizeFloat(resized, 'nope', 400, 400)).toBe(resized) - } finally { - if (previous === undefined) delete g.window - else g.window = previous - } - }) - - it('raiseFloat moves a window to the top and is idempotent at the top', () => { - let s = state() - s = floatTab(openTabInActivePane(s, { id: 'a', type: 'db', title: 'a' }), 'a', 100, 100) - s = floatTab(openTabInActivePane(s, { id: 'b', type: 'db', title: 'b' }), 'b', 100, 100) - s = floatTab(openTabInActivePane(s, { id: 'c', type: 'db', title: 'c' }), 'c', 100, 100) - expect(s.floats.map(f => f.tab.id)).toEqual(['a', 'b', 'c']) - const raised = raiseFloat(s, s.floats[0]!.id) - expect(raised.floats.map(f => f.tab.id)).toEqual(['b', 'c', 'a']) - // Already topmost / unknown id → same reference (no notify churn). - expect(raiseFloat(raised, raised.floats[2]!.id)).toBe(raised) - expect(raiseFloat(raised, 'float:none')).toBe(raised) - // A single window has nothing to raise: same reference. - let single = state() - single = floatTab(single, (single.splits as SidebarLeaf).tabs[0]!.id, 0, 0) - expect(single.floats).toHaveLength(1) - expect(raiseFloat(single, single.floats[0]!.id)).toBe(single) - }) - - it('dockFloat lands the tab in the target pane (or the active pane) and activates it', () => { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - s = floatTab(openTabInActivePane(s, git), 'git', 100, 100) - const floatId = s.floats[0]!.id - // Explicit target pane. - s = splitPane(s, 'row') - const target = allLeaves(s.splits).find(leaf => leaf.tabs.length === 0)! - const docked = dockFloat(s, floatId, target.id) - expect(docked.floats).toHaveLength(0) - expect(allLeaves(docked.splits).find(leaf => leaf.id === target.id)!.tabs.map(tab => tab.id)).toEqual(['git']) - expect(docked.activePane).toBe(target.id) - // Default target: the active pane. - let s2 = state() - const home = (s2.splits as SidebarLeaf).tabs[0]! - s2 = splitPane(s2, 'row') - s2 = floatTab(s2, home.id, 100, 100) - const active = s2.activePane! - const docked2 = dockFloat(s2, s2.floats[0]!.id) - expect(docked2.floats).toHaveLength(0) - expect(allLeaves(docked2.splits).find(leaf => leaf.id === active)!.tabs.some(tab => tab.id === home.id)).toBe(true) - expect(docked2.activePane).toBe(active) - // Stale target falls back to the right tree's first leaf. - let s3 = state() - const home3 = (s3.splits as SidebarLeaf).tabs[0]! - s3 = floatTab(s3, home3.id, 0, 0) - const docked3 = dockFloat(s3, s3.floats[0]!.id, 'pane:gone') - expect(docked3.floats).toHaveLength(0) - expect(allLeaves(docked3.splits).some(leaf => leaf.tabs.some(tab => tab.id === home3.id))).toBe(true) - // Unknown float id is a strict no-op. - expect(dockFloat(docked3, 'float:gone')).toBe(docked3) - }) - - it('tabOpenIn / patchTab / floatWithTab see floating tabs', () => { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - s = floatTab(openTabInActivePane(s, git), 'git', 100, 100) - expect(tabOpenIn(s, 'git')).toBe(true) - expect(floatWithTab(s, 'git')!.id).toBe(s.floats[0]!.id) - const patched = patchTab(s, 'git', { title: 'Git 2', path: '/x' }) - expect(patched.floats[0]!.tab).toMatchObject({ title: 'Git 2', path: '/x' }) - expect(allLeaves(patched.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false) - }) - - it('closeFloatByTab removes exactly the window holding the tab', () => { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - const term = { id: 'term:1', type: 'terminal' as const, title: 'T' } - s = openTabInActivePane(s, git) - s = openTabInActivePane(s, term) - s = floatTab(s, 'git', 50, 50) - s = floatTab(s, 'term:1', 60, 60) - expect(s.floats).toHaveLength(2) - const after = closeFloatByTab(s, 'git') - expect(after.floats.map(f => f.tab.id)).toEqual(['term:1']) - expect(closeFloatByTab(after, 'git')).toBe(after) - }) - - it('openTabInActivePane focuses a floating tab by raising (id safety net)', () => { - let s = state() - const git = { id: 'git', type: 'git' as const, title: 'Git' } - const term = { id: 'term:1', type: 'terminal' as const, title: 'T' } - s = floatTab(openTabInActivePane(s, git), 'git', 50, 50) - s = floatTab(openTabInActivePane(s, term), 'term:1', 60, 60) - // Reopening the floated id must NOT duplicate: it raises the window. - const after = openTabInActivePane(s, { id: 'git', type: 'git', title: 'Git' }) - expect(after.floats.map(f => f.tab.id)).toEqual(['term:1', 'git']) - expect(allLeaves(after.splits).some(leaf => leaf.tabs.some(tab => tab.id === 'git'))).toBe(false) - }) - - it('reconcileAgentTerminals removes a vanished FLOATED agent terminal with its window', () => { - let s = state() - s = openTabInActivePane(s, { id: 'agent:u1', type: 'terminal', title: 'A1' }) - s = floatTab(s, 'agent:u1', 50, 50) - s = openTabInActivePane(s, { id: 'agent:u2', type: 'terminal', title: 'A2' }) - expect(s.floats).toHaveLength(1) - const after = reconcileAgentTerminals(s, [{ uuid: 'u2', title: 'A2' }]) - expect(after.floats).toHaveLength(0) - expect(allLeaves(after.splits).flatMap(leaf => leaf.tabs).some(tab => tab.id === 'agent:u2')).toBe(true) - }) - - describe('revealPaths (show in folder)', () => { - it('expands ancestors with their ABSOLUTE path (leading separator preserved)', () => { - // POSIX: the root (/w/src) is not itself expanded, but the subdirs - // below it must be recorded as ABSOLUTE paths so FileTree's - // `expanded.includes(entry.path)` matches. - const base = makeDefaultState() - const next = revealPaths(base, '/w/src', ['/w/src/sub/deep/a.ts']) - expect(next.revealed).toEqual(['/w/src/sub/deep/a.ts']) - expect(next.expanded).toContain('/w/src/sub') - expect(next.expanded).toContain('/w/src/sub/deep') - expect(next.expanded).not.toContain('w/src/sub') - expect(next.expanded).not.toContain('/w/src') - }) - - it('keeps a Windows drive-letter root and a UNC prefix', () => { - const drive = revealPaths(makeDefaultState(), 'C:\\work', ['C:\\work\\src\\a.ts']) - expect(drive.expanded).toContain('C:\\work\\src') - const unc = revealPaths(makeDefaultState(), '\\\\server\\share', ['\\\\server\\share\\sub\\a.ts']) - expect(unc.expanded).toContain('\\\\server\\share\\sub') - }) - - it('resolves nothing to the same reference (no churn)', () => { - const base = makeDefaultState() - expect(revealPaths(base, '/w', [])).toBe(base) - }) - }) - - describe('sanitizeState (floats)', () => { - const g = globalThis as Record - beforeEach(() => { - g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 768 } - g.localStorage = { getItem: () => null, setItem: () => {} } - }) - afterEach(() => { - delete g.window - delete g.localStorage - }) - - const base = (): Record => ({ - panelOpen: true, - width: 400, - nextTerminal: 1, - activePane: 'pane:1', - expanded: [], - splits: { kind: 'leaf', id: 'pane:1', active: null, tabs: [] }, - }) - - it('a missing floats field defaults to none (older persisted states load)', () => { - const restored = sanitizeState(base())! - expect(restored.floats).toEqual([]) - }) - - it('drops malformed entries individually and keeps the layout (unlike tree corruption)', () => { - const parsed = base() - parsed.floats = [ - 'garbage', - { id: 'float:1' }, // no tab - { id: 'float:2', tab: { id: 't', type: 'git', title: 'G' }, x: 10, y: 10 }, // no w/h - { id: 'float:3', tab: { id: 't3', type: 'git', title: 'G3' }, x: 10, y: 10, w: 500, h: 400 }, - { id: 'float:3', tab: { id: 't3b', type: 'git', title: 'G3b' }, x: 0, y: 0, w: 400, h: 300 }, // duplicate id - ] - const restored = sanitizeState(parsed)! - expect(restored.floats).toHaveLength(1) - expect(restored.floats[0]).toMatchObject({ id: 'float:3', tab: { id: 't3' }, x: 10, y: 10 }) - expect(restored.splits).toBeDefined() - }) - - it('drops ephemeral diff tabs and migrates explorer tabs inside floats', () => { - const parsed = base() - parsed.floats = [ - { id: 'float:1', tab: { id: 'd', type: 'diff', title: 'D' }, x: 0, y: 0, w: 400, h: 300 }, - { id: 'float:2', tab: { id: 'ex', type: 'explorer', title: 'Explorer' }, x: 0, y: 0, w: 400, h: 300 }, - ] - const restored = sanitizeState(parsed)! - expect(restored.floats.map(f => f.tab.type)).toEqual(['editor']) - expect(restored.floats[0]!.tab).toMatchObject({ title: 'Files', meta: { treeOpen: true } }) - }) - - it('clamps stale off-screen geometry into the current viewport', () => { - const parsed = base() - parsed.floats = [ - { id: 'float:1', tab: { id: 't', type: 'git', title: 'G' }, x: -200, y: 900, w: 5000, h: 5000 }, - ] - const restored = sanitizeState(parsed)! - // Sizes cap at the viewport, and the capped window lands at 0,0 (the - // position clamp sees no room beyond it). - expect(restored.floats[0]).toMatchObject({ x: 0, y: 0, w: 1024, h: 768 }) - }) - - it('the uid counter seeds past persisted float ids (no collision on re-float)', () => { - // Persisted: the pane max is 1, but floats reach float:2 — the counter - // must seed to 2 so a fresh float mints float:3+, never a duplicate - // float:2. (Intermediate mints during sanitize may raise it further; - // the guarantee under test is uniqueness, not an exact value.) - const parsed = base() - parsed.floats = [ - { id: 'float:2', tab: { id: 't2', type: 'git', title: 'G2' }, x: 0, y: 0, w: 400, h: 300 }, - ] - const saved = new Map() - saved.set('dsh-sidebar:v1:seedtest', JSON.stringify(parsed)) - g.localStorage = { - getItem: (key: string) => saved.get(key) ?? null, - setItem: (key: string, value: string) => { saved.set(key, value) }, - } - const store = createSidebarStore() - store.setSession('seedtest') - const s = store.getSnapshot().state! - expect(s.floats[0]!.id).toBe('float:2') - store.reduce(cur => openTabInActivePane(cur, { id: 'tab:9', type: 'git', title: 'G9' })) - store.reduce(cur => floatTab(cur, 'tab:9', 30, 30)) - const next = store.getSnapshot().state! - expect(next.floats).toHaveLength(2) - expect(new Set(next.floats.map(f => f.id)).size).toBe(2) - const freshId = next.floats.map(f => f.id).find(id => id !== 'float:2')! - expect(freshId).toMatch(/^float:\d+$/) - expect(Number(freshId.slice('float:'.length))).toBeGreaterThan(2) - }) - - it('floats survive a full persist round-trip (geometry verbatim when in-viewport)', () => { - const parsed = base() - parsed.floats = [ - { id: 'float:1', tab: { id: 't', type: 'git', title: 'G', meta: { k: 1 } }, x: 12, y: 34, w: 480, h: 360 }, - ] - const restored = sanitizeState(parsed)! - const again = sanitizeState(JSON.parse(JSON.stringify(restored)))! - expect(again.floats).toEqual(restored.floats) - }) + it('resolves nothing to the same reference (no churn)', () => { + const base = makeDefaultState() + expect(revealPaths(base, '/w', [])).toBe(base) }) }) @@ -1220,35 +732,20 @@ describe('pinned terminals (v0.17.0)', () => { // only — keep this block window-less for parity with the main describe. const state = (): SidebarState => makeDefaultState() - it('setTabPin marks a docked terminal in the right tree', () => { + it('setTabPin marks a terminal in the workbench', () => { let s = state() - s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) + s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) s = setTabPin(s, 'terminal:1', { scope: 'workspace', homeCwd: '/proj' }) - const tab = allLeaves(s.splits).flatMap(l => l.tabs).find(t => t.id === 'terminal:1')! + const tab = allLeaves(s.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'terminal:1')! expect(tab.pin).toEqual({ scope: 'workspace', homeCwd: '/proj' }) }) - it('setTabPin marks a terminal in the bottom tree and a floated terminal', () => { - let s = state() - s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = { ...s, activePane: bottomPane } - s = openTabInActivePane(s, { id: 'terminal:b', type: 'terminal', title: 'B' }) - s = setTabPin(s, 'terminal:b', { scope: 'global' }) - const bottomTab = allLeaves(s.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'terminal:b')! - expect(bottomTab.pin).toEqual({ scope: 'global' }) - // Float path: a pinned tab that is then floated keeps its pin marker. - s = floatTab(s, 'terminal:b', 50, 50) - s = setTabPin(s, 'terminal:b', { scope: 'workspace', homeCwd: '/x' }) - expect(s.floats.find(f => f.tab.id === 'terminal:b')!.tab.pin).toEqual({ scope: 'workspace', homeCwd: '/x' }) - }) - it('setTabPin with null clears the pin marker but keeps the tab', () => { let s = state() - s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) + s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) s = setTabPin(s, 'terminal:1', { scope: 'global' }) s = setTabPin(s, 'terminal:1', null) - const tab = allLeaves(s.splits).flatMap(l => l.tabs).find(t => t.id === 'terminal:1')! + const tab = allLeaves(s.bottomSplits).flatMap(l => l.tabs).find(t => t.id === 'terminal:1')! expect(tab.pin).toBeUndefined() expect(tabOpenIn(s, 'terminal:1')).toBe(true) }) @@ -1261,7 +758,7 @@ describe('pinned terminals (v0.17.0)', () => { it('setTabPin is idempotent: setting the same pin twice returns the same reference', () => { let s = state() - s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) + s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'T' }) s = setTabPin(s, 'terminal:1', { scope: 'workspace', homeCwd: '/p' }) const once = s s = setTabPin(s, 'terminal:1', { scope: 'workspace', homeCwd: '/p' }) @@ -1274,52 +771,52 @@ describe('pinned terminals (v0.17.0)', () => { g.localStorage = { getItem: () => null, setItem: () => {} } try { const legal = JSON.parse(JSON.stringify(makeDefaultState())) as { - splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } + bottomSplits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } - legal.splits.tabs.push({ + legal.bottomSplits.tabs.push({ id: 'terminal:1', type: 'terminal', title: 'T', pin: { scope: 'workspace', homeCwd: '/proj' }, } as SidebarTab) - legal.splits.active = 'terminal:1' + legal.bottomSplits.active = 'terminal:1' const restored = sanitizeState(legal)! - const tab = (restored.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:1')! + const tab = (restored.bottomSplits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:1')! expect(tab.pin).toEqual({ scope: 'workspace', homeCwd: '/proj' }) // Illegal scope drops the pin, keeps the tab. const illegal = JSON.parse(JSON.stringify(makeDefaultState())) as { - splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } + bottomSplits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } - illegal.splits.tabs.push({ + illegal.bottomSplits.tabs.push({ id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'bogus', homeCwd: '/x' }, } as unknown as SidebarTab) - illegal.splits.active = 'terminal:2' + illegal.bottomSplits.active = 'terminal:2' const cleaned = sanitizeState(illegal)! - const tab2 = (cleaned.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:2')! + const tab2 = (cleaned.bottomSplits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:2')! expect(tab2.pin).toBeUndefined() expect(tab2.id).toBe('terminal:2') // Non-string homeCwd drops homeCwd but keeps a global pin. const weirdHome = JSON.parse(JSON.stringify(makeDefaultState())) as { - splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } + bottomSplits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } - weirdHome.splits.tabs.push({ + weirdHome.bottomSplits.tabs.push({ id: 'terminal:3', type: 'terminal', title: 'T3', pin: { scope: 'global', homeCwd: 42 }, } as unknown as SidebarTab) - weirdHome.splits.active = 'terminal:3' + weirdHome.bottomSplits.active = 'terminal:3' const weird = sanitizeState(weirdHome)! - const tab3 = (weird.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:3')! + const tab3 = (weird.bottomSplits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:3')! expect(tab3.pin).toEqual({ scope: 'global' }) // Older state without pin loads unchanged. const legacy = JSON.parse(JSON.stringify(makeDefaultState())) as { - splits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } + bottomSplits: { kind: 'leaf'; id: string; tabs: SidebarTab[]; active: string | null } } - legacy.splits.tabs.push({ id: 'terminal:4', type: 'terminal', title: 'T4' } as SidebarTab) - legacy.splits.active = 'terminal:4' + legacy.bottomSplits.tabs.push({ id: 'terminal:4', type: 'terminal', title: 'T4' } as SidebarTab) + legacy.bottomSplits.active = 'terminal:4' const legacyRestored = sanitizeState(legacy)! - const tab4 = (legacyRestored.splits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:4')! + const tab4 = (legacyRestored.bottomSplits as { tabs: SidebarTab[] }).tabs.find(t => t.id === 'terminal:4')! expect(tab4.pin).toBeUndefined() } finally { delete g.window @@ -1328,104 +825,6 @@ describe('pinned terminals (v0.17.0)', () => { }) }) -describe('narrow persisted-layout visibility (issue #162)', () => { - const persistedLayout = JSON.stringify({ - panelOpen: true, - width: 420, - nextTerminal: 2, - nextBrowser: 2, - activePane: 'pane:2', - expanded: ['/workspace/src'], - splits: { - kind: 'split', - id: 'split:1', - dir: 'row', - sizes: [0.5, 0.5], - children: [ - { - kind: 'leaf', - id: 'pane:1', - active: 'editor:readme', - tabs: [{ id: 'editor:readme', type: 'editor', title: 'README.md', path: '/workspace/README.md' }], - }, - { - kind: 'leaf', - id: 'pane:2', - active: 'terminal:1', - tabs: [{ id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }], - }, - ], - }, - bottomOpen: true, - bottomHeight: 300, - bottomOpenedOnce: true, - bottomSplits: { - kind: 'leaf', - id: 'pane:3', - active: 'browser:1', - tabs: [{ id: 'browser:1', type: 'browser', title: 'Docs', path: 'https://example.com' }], - }, - floats: [{ - id: 'float:4', - tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } }, - x: 16, - y: 24, - w: 320, - h: 240, - }], - }) - - afterEach(() => { - const g = globalThis as Record - delete g.window - delete g.localStorage - }) - - it.each([ - { width: 390, expectedOpen: false }, - { width: 768, expectedOpen: true }, - ])('restores panelOpen=$expectedOpen at $width px without changing pane or free-window state', ({ width, expectedOpen }) => { - const g = globalThis as Record - g.window = { - clearTimeout: () => {}, - setTimeout: () => 0, - innerWidth: width, - innerHeight: 800, - location: { search: '' }, - } - g.localStorage = { - getItem: (key: string) => key === 'dsh-sidebar:v1:restored' ? persistedLayout : null, - setItem: () => {}, - removeItem: () => {}, - } - - const store = createSidebarStore() - store.setSession('restored') - const state = store.getSnapshot().state! - - expect(state.panelOpen).toBe(expectedOpen) - expect(state.activePane).toBe('pane:2') - expect(allLeaves(state.splits).map(leaf => ({ - id: leaf.id, - active: leaf.active, - tabs: leaf.tabs.map(tab => tab.id), - }))).toEqual([ - { id: 'pane:1', active: 'editor:readme', tabs: ['editor:readme'] }, - { id: 'pane:2', active: 'terminal:1', tabs: ['terminal:1'] }, - ]) - expect(state.bottomOpen).toBe(true) - expect(allLeaves(state.bottomSplits).map(leaf => ({ id: leaf.id, tabs: leaf.tabs.map(tab => tab.id) }))) - .toEqual([{ id: 'pane:3', tabs: ['browser:1'] }]) - expect(state.floats).toEqual([{ - id: 'float:4', - tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } }, - x: 16, - y: 24, - w: 320, - h: 240, - }]) - }) -}) describe('URL reset escape hatch (issue #369)', () => { // Same browser-global stubs as the v0.12.0 block above; loadState reads @@ -1443,13 +842,10 @@ describe('URL reset escape hatch (issue #369)', () => { /** A persisted layout whose restored git tab would re-hang the page. */ const frozenState = JSON.stringify({ - panelOpen: true, - width: 400, nextTerminal: 1, activePane: 'pane:1', expanded: [], - splits: { kind: 'leaf', id: 'pane:1', active: 'g1', tabs: [{ id: 'g1', type: 'git', title: 'Git' }] }, - bottomSplits: { kind: 'leaf', id: 'pane:b', active: null, tabs: [] }, + bottomSplits: { kind: 'leaf', id: 'pane:1', active: 'g1', tabs: [{ id: 'g1', type: 'git', title: 'Git' }] }, }) const searchOf = (): { search: string } => @@ -1464,7 +860,7 @@ describe('URL reset escape hatch (issue #369)', () => { } const store = createSidebarStore() store.setSession('s1') - const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] } + const leaf = store.getSnapshot().state!.bottomSplits as { tabs: { type: string }[] } expect(leaf.tabs.map(tab => tab.type)).toEqual(['git']) }) @@ -1479,20 +875,19 @@ describe('URL reset escape hatch (issue #369)', () => { searchOf().search = '?dsh-sidebar-reset' const store = createSidebarStore() store.setSession('s1') - // The default layout (editor home tab) — NOT the frozen git-only state. - const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] } - expect(leaf.tabs.map(tab => tab.type)).toEqual(['editor']) + // The default layout (an empty workbench) — NOT the frozen git-only state. + const leaf = store.getSnapshot().state!.bottomSplits as { tabs: { type: string }[] } + expect(leaf.tabs.map(tab => tab.type)).toEqual([]) // The stored copy is gone, so reloading without the param cannot restore // the hanging layout either. expect(removed).toContain('dsh-sidebar:v1:s1') - expect(removed).toContain('dsh-sidebar:v1:width') }) it('the reset param tolerates a value (?dsh-sidebar-reset=1)', () => { searchOf().search = '?foo=bar&dsh-sidebar-reset=1' const store = createSidebarStore() store.setSession('s1') - const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] } - expect(leaf.tabs.map(tab => tab.type)).toEqual(['editor']) + const leaf = store.getSnapshot().state!.bottomSplits as { tabs: { type: string }[] } + expect(leaf.tabs.map(tab => tab.type)).toEqual([]) }) }) diff --git a/tests/tab-bar-context-menu.spec.tsx b/tests/tab-bar-context-menu.spec.tsx index 30c6a80..281a79a 100644 --- a/tests/tab-bar-context-menu.spec.tsx +++ b/tests/tab-bar-context-menu.spec.tsx @@ -30,13 +30,12 @@ function stubZh(): void { }) } -const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签'] +const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签'] function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void } = {}): { tabEls: HTMLElement[] onClose: ReturnType onActivate: ReturnType - onFloatTab: ReturnType onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void unmount: () => void } { @@ -44,7 +43,6 @@ function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: document.body.append(container) const onClose = vi.fn() const onActivate = vi.fn() - const onFloatTab = vi.fn() const onPinTab = opts.onPinTab const root: Root = createRoot(container) act(() => { @@ -56,7 +54,6 @@ function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: onClose, onNewTab: () => {}, newTabOptions: [], - onFloatTab, ...(onPinTab !== undefined ? { onPinTab } : {}), onDropTab: () => {}, })) @@ -66,7 +63,6 @@ function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: tabEls, onClose, onActivate, - onFloatTab, ...(onPinTab !== undefined ? { onPinTab } : {}), unmount: () => { act(() => { root.unmount() }) @@ -116,15 +112,14 @@ describe('TabBar right-click context menu', () => { } }) - it('move to free window floats the target tab without closing it', () => { + it('close closes the target tab and dismisses the menu', () => { stubZh() - const { tabEls, onClose, onFloatTab, unmount } = mountBar(fourTabs()) + const { tabEls, onClose, unmount } = mountBar(fourTabs()) try { act(() => { rightClick(tabEls[1]!) }) act(() => { menuItems()[0]!.click() }) - expect(onFloatTab).toHaveBeenCalledTimes(1) - expect(onFloatTab).toHaveBeenCalledWith('t2') - expect(onClose).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledWith('t2') expect(menuItems()).toHaveLength(0) } finally { unmount() @@ -136,7 +131,7 @@ describe('TabBar right-click context menu', () => { const { tabEls, onClose, unmount } = mountBar(fourTabs()) try { act(() => { rightClick(tabEls[1]!) }) - act(() => { menuItems()[1]!.click() }) + act(() => { menuItems()[0]!.click() }) expect(onClose).toHaveBeenCalledTimes(1) expect(onClose).toHaveBeenCalledWith('t2') expect(menuItems()).toHaveLength(0) @@ -150,7 +145,7 @@ describe('TabBar right-click context menu', () => { const { tabEls, onClose, unmount } = mountBar(fourTabs()) try { act(() => { rightClick(tabEls[1]!) }) - act(() => { menuItems()[2]!.click() }) + act(() => { menuItems()[1]!.click() }) expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4']) expect(onClose).not.toHaveBeenCalledWith('t2') expect(menuItems()).toHaveLength(0) @@ -164,7 +159,7 @@ describe('TabBar right-click context menu', () => { const { tabEls, onClose, unmount } = mountBar(fourTabs()) try { act(() => { rightClick(tabEls[2]!) }) - act(() => { menuItems()[3]!.click() }) + act(() => { menuItems()[2]!.click() }) expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2']) expect(menuItems()).toHaveLength(0) } finally { @@ -177,7 +172,7 @@ describe('TabBar right-click context menu', () => { const { tabEls, onClose, unmount } = mountBar(fourTabs()) try { act(() => { rightClick(tabEls[1]!) }) - act(() => { menuItems()[4]!.click() }) + act(() => { menuItems()[3]!.click() }) expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4']) expect(menuItems()).toHaveLength(0) } finally { @@ -194,9 +189,9 @@ describe('TabBar right-click context menu', () => { act(() => { rightClick(single.tabEls[0]!) }) const items = menuItems() // The Menu renders each row as a disabled