From d052bd7fb2b20e5875b28b01448030b428bfbe39 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 26 Aug 2026 18:21:43 +0800 Subject: [PATCH] docs: pinned terminal design + implementation plan --- .../2026-08-26-pinned-terminal-design.md | 153 ++++++++++++++++++ docs/plans/2026-08-26-pinned-terminal-plan.md | 108 +++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 docs/plans/2026-08-26-pinned-terminal-design.md create mode 100644 docs/plans/2026-08-26-pinned-terminal-plan.md diff --git a/docs/plans/2026-08-26-pinned-terminal-design.md b/docs/plans/2026-08-26-pinned-terminal-design.md new file mode 100644 index 0000000..3932bc1 --- /dev/null +++ b/docs/plans/2026-08-26-pinned-terminal-design.md @@ -0,0 +1,153 @@ +# 固定终端(Pinned Terminals)设计 + +> 2026-08-26 · 状态:设计已评审,待实施 +> 需求:现所有 Tab 跟随会话,切换会话后终端仍留在原会话(不可见但 PTY 保活)。 +> 希望右键终端 Tab 可「固定」→ 二级菜单「固定到工作区 / 固定到全局」,固定后切换会话不消失。 + +## 0. 决策记录(已确认) + +| 决策点 | 结论 | +|---|---| +| 可 pin 的终端类型 | **UI 终端 + Agent 终端均可**(Agent 终端被 reconcile 移除时保留为「已断开」tab) | +| 跨会话呈现形态 | **独立「固定区」列表**(PinnedRail,面板顶部紧凑条),不投影进目标会话 tab 条 | +| Menu 二级菜单 | `ui-primitives` 的 `Menu` **原生支持 `submenu`**(`MenuItem.submenu`,hover/focus 展开右侧子卡),零改动可用 | + +## 1. 现状关键事实(实施依据) + +- **状态模型**(`src/client/state.ts`):`SidebarStore.bySession: Map`;`setSession()` 整体换快照。`SidebarTab = { id, type, title, path?, diff?, meta? }`。终端 tab:`type: 'terminal'`。 +- **终端两类**(id 前缀判别,无需新字段): + - `terminal:` —— UI 终端(用户 + 菜单开,`createTab` 铸造) + - `agent:` —— Agent 终端(模型 `terminal_create` 工具,`reconcileAgentTerminals()` 跟宿主 WS 列表同步;`isAgentTabId()` / `agentUuidOf()` / `agentTabId()` 已存在) +- **PTY 保活**(`src/client/TerminalView.tsx` 卸载三分支):tab 关闭 → `{type:'close'}` 杀 PTY;**切会话 → `{type:'park'}` 宿主机无限保活**;同会话重挂载 → bare drop(30s 重连宽限)。Agent 终端 bare drop 已无限保活,无需 park。 +- **关闭路径**(`src/client/Sidebar.tsx` `actions.closeTab`):service `closeTab` + 终端额外 `api.ptyClose({sessionId,cwd},tabId)` / `api.agentPtyClose(uuid)`。 +- **持久化**:`localStorage` `dsh-sidebar:v1:`,`sanitizeState` 白名单校验;`sanitizePersistedTab` 逐 tab 校验(未知 type → OrphanedTab 占位)。 +- **TabBar 右键菜单**(`src/client/TabBar.tsx`):flat `items`,`onSelect(id)` 分发。Menu `MenuEntry = MenuItem | MenuSeparator | MenuLabel`。 +- **宿主会话切换**:Sidebar 只读 `ctx.sessions.list` 的 `current`(`store.setSession(current)` 跟随);「跳转到子代理会话」先例 = `subagentJumpRef` 记录目标 + 宿主列表侧点击导航,effect 在 `sessionId === pending` 时执行后续动作(PinnedRail 点击沿用此模式)。 +- **cwd 来源**:`sessionList.byId[sessionId]?.cwd`(`Sidebar.tsx:303` 先例)。 + +## 2. 数据模型 + +```ts +// state.ts — SidebarTab 新增可选字段 +interface SidebarTab { + // ...现有字段 + /** 固定标记(v0.x+)。scope=工作区|全局;homeCwd=pin 时刻会话 cwd(workspace 可见性判据)。 */ + pin?: { scope: 'workspace' | 'global'; homeCwd?: string } +} +``` + +- `sanitizePersistedTab` 白名单校验 `pin`(`scope` 枚举值校验,`homeCwd` string 可选;非法值降级为无 pin,不丢 tab)。缺字段 = 未 pin,旧持久化状态向后兼容。 +- pin 元数据**随宿主会话 state 持久化**,无独立 localStorage 注册表(YAGNI)。 +- **权威副本唯一**:pinned tab 只存在于宿主会话(home session)的 state 里,其他会话仅跨会话渲染——现有 park/保活语义零改动。 + +## 3. 跨会话解析层(新模块 `src/client/pinned.ts`,纯函数) + +```ts +/** 该 pinned tab 对 viewer 会话是否可见。cwd 未知(undefined)时保守可见,避免水合期闪烁消失。 */ +export function pinnedVisibleTo( + tab: SidebarTab, + viewer: { sessionId: string; cwd: string | undefined }, +): boolean + +/** 从所有缓存会话的 state 收集对 viewer 可见的 pinned 终端 tab。 + * 返回 { tab, homeSessionId }[],按 pin 先后稳定排序;排除 viewer 自己会话的 + * (那些本就在 tab 条上,避免双渲染)。 */ +export function collectPinnedTabs( + bySession: ReadonlyMap, + viewer: { sessionId: string; cwd: string | undefined }, +): { tab: SidebarTab; homeSessionId: string }[] +``` + +可见性规则: + +| pin.scope | 可见条件 | +|---|---| +| `global` | 任意会话(不同 cwd 也可见) | +| `workspace` | `viewer.cwd === tab.pin.homeCwd`(两者皆 undefined 视为匹配;仅 viewer.cwd 未知时保守可见) | + +`SidebarStore` 新增只读方法: + +```ts +/** 当前所有会话 state 的只读视图(供 collectPinnedTabs)。 */ +getSessionStates(): ReadonlyMap +``` + +渲染期解析,不写跨会话投影——每会话 state 保持纯净,reduce/reduceFor 语义不变。store 任意会话更新已全局 notify,rail 随 uSES 自动重渲染。 + +## 4. UI:PinnedRail(新组件 `src/client/PinnedRail.tsx`) + +- **挂载**:`Sidebar.tsx` 内、右面板 tab 区之上(右/底面板共享一条,pinned tab 不归属任何树)。仅当可见 pinned ≥ 1 时渲染。稳定寻址面 `[data-dsh-pinned-rail]`、`[data-dsh-pinned-tab=""]`。样式走 CSS Modules + `--dsw-alias-*` 令牌(§8 皮肤契约),hairline 分隔。 +- **条目**:pin 图标(📌 风格 SVG,icons.tsx 新增 `IconPinOutline16`)+ 终端类型图标(Agent 终端用机器人/思考图标,UI 终端用终端图标)+ 标题 + tooltip(`类型 + 范围 + 来源 cwd`,如「Agent 终端 · 固定到工作区 · D:\proj\x」)。 +- **交互**: + - **点击**:当前会话含该 tab id → `activateTab`;否则记录 `pinnedJumpRef`(沿用 subagentJumpRef 模式)→ 触发宿主导航到 home 会话 → effect 命中后 `reduceFor` 激活 tab(面板折叠则展开)。 + - **右键菜单**(复用 Menu portal):`取消固定` / `关闭终端`。 + - **中键**:关闭(同右键关闭)。 +- **关闭语义**:走 home 会话路径——`reduceFor(homeSessionId, closeTab)` + `api.ptyClose({sessionId: homeSessionId, cwd: homeCwd}, tabId)` / `api.agentPtyClose(uuid)`。正处 home 会话时退化为现有 closeTab 路径。关闭同时清 pin(tab 消失即 pin 消失,无需单独解 pin 步骤)。 + +## 5. TabBar 右键菜单扩展(`src/client/TabBar.tsx`) + +终端类型 tab(`tab.type === 'terminal'`,含 UI 与 Agent)菜单项变为: + +``` +移动到自由窗口 +固定 ▸ ← 未 pin;MenuItem.submenu + ├ 固定到工作区 + └ 固定到全局 +取消固定 ← 已 pin(替换「固定 ▸」行) +───────────── +关闭 / 关闭其他页签 / 关闭左侧 / 关闭右侧 +``` + +- 菜单 label 按终端类型区分:`固定终端`(UI)/ `固定 Agent 终端`(Agent)。 +- TabBar 新增 props:`onPinTab(tabId: string, scope: 'workspace' | 'global' | null): void`(null = 取消)。`isPinned` 从 tab 对象自身读(`tab.pin !== undefined`),无需新 prop。 +- pin 实现(Sidebar 层):`store.reduce(s => patchTab(s, tabId, { meta: ... }))` 不够——`patchTab` 只 patch title/path/meta。**新增 reducer `setTabPin(state, tabId, pin | null)`**(纯函数,walk 两树 + floats,与 patchTab 同构),pin 时从当前会话 cwd 快照 homeCwd。 + +## 6. 生命周期与安全 + +| 场景 | 行为 | +|---|---| +| 关闭 pinned tab(tab 条 X 或 rail 关闭) | 清 pin + 正常关闭 + 杀 PTY(home 路径) | +| Agent 终端被 reconcile 移除(宿主 uuid 消失) | `reconcileAgentTerminals` **豁免 pinned tab**:保留 tab,标题渲染时追加 `(已断开)`(不改持久化 title,reconcile uuid 匹配不受影响)。xterm 重连失败走现有 fatal banner | +| UI 终端 PTY 死亡 | 现有行为(xterm 显示退出输出);pinned 不改变重连/重开语义 | +| 取消固定 | 仅清 `tab.pin`;tab 留在 home 会话 tab 条,切走恢复旧行为 | +| 刷新/重载 | pin 随会话 state 持久化恢复;rail 从各会话缓存 state 重建 | +| 底部面板/窄屏迁移 | pinned tab 若在 bottomSplits,`migrateBottomTabs` 照常迁移(pin 语义不受影响) | +| 自由窗口中的 pinned tab | 允许;rail 点击切回 home 会话后 `raiseFloat`(floatWithTab 命中走置顶,openTabInActivePane 已有此分支) | + +## 7. 测试计划(Unit + 组件,vitest) + +| 文件 | 覆盖 | +|---|---| +| `tests/pinned.spec.ts`(新) | `pinnedVisibleTo` 全组合(global/workspace × cwd 匹配/不匹配/双方 undefined/单方 undefined);`collectPinnedTabs` 跨会话收集、排除 viewer 自身会话、稳定排序、home 会话 id 携带 | +| `tests/state.spec.ts`(追加) | `sanitizePersistedTab` 接受合法 pin / 拒绝非法 scope 降级 / 缺字段兼容;`setTabPin` reducer(设置/取消/未知 tabId no-op/浮动窗口 tab);`reconcileAgentTerminals` 豁免 pinned agent tab(uuid 消失仍保留)+ 未 pin 的照旧移除 | +| `tests/tab-bar-context-menu.spec.tsx`(追加) | 终端 tab 出现「固定 ▸」子菜单(UI 与 Agent 文案区分);非终端 tab 不出现;已 pin 显示「取消固定」;onPinTab 回调参数正确 | +| `tests/pinned-rail.spec.tsx`(新) | rail 渲染条件(0 个不渲染);类型图标与 tooltip;点击切回 home 会话(reduceFor 激活);右键取消固定/关闭;「已断开」后缀渲染 | + +回归:`pnpm typecheck && pnpm test`;终端相关改动加跑 `tests/agent-terminal-reconcile.spec.ts`、`tests/terminal-*.spec.ts`、`tests/bottom-auto-terminal.spec.tsx`。 + +## 8. i18n(19 语言词典同步,遵循 locales-*.ts 模式) + +新增 key:`pinTerminal` / `pinAgentTerminal` / `pinToWorkspace` / `pinToGlobal` / `unpinTerminal` / `pinnedTerminalTooltip`(类型/范围/cwd 插值)/ `terminalDisconnected`。 +zh-CN 基准 + en;ja 必须同步(better-locale 契约:缺 ja key 回退 en)。其余语言沿用现有词典文件的同步方式。 + +## 9. 文档同步 + +- `README.md` / `README_EN.md`:功能清单加「固定终端」 +- `AGENTS.md` §3.4 内置 tab 表:terminal 行补充 pin 能力说明;§7 陷阱表视情况加「pinned tab 跨会话解析在渲染期,勿在 reducer 里投影」 +- 实施偏差记录在本文档追加 + +## 10. YAGNI(明确不做) + +- ❌ 独立 localStorage pin 注册表 +- ❌ pinned tab 投影进目标会话 tab 条(独立固定区已选定) +- ❌ pin 非终端 tab +- ❌ 宿主机(host 半)改动——PTY 保活现有逻辑完备(park + agent 无限保活) +- ❌ pin 数量上限(终端本身有 `TERMINAL_LIMIT` 配额兜底) + +## 11. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| rail 点击需触发**宿主**会话切换(非 sidebar store) | 沿用 subagentJumpRef 模式;PinnedRail 点击项渲染为宿主会话列表可识别的导航(或经 `ctx.sessions` 可用 API),effect 在 sessionId 命中后激活 tab。实施时先验证该链路(PoC 先行) | +| 「已断开」标题与后续 reconcile push 兼容性 | 后缀在**渲染期**追加,不改持久化 title;reconcile 按 uuid 匹配不受影响 | +| bySession 未缓存的会话(从未加载)其 pinned tab 不可见 | 接受:会话 state 在首次切换时 loadState 进缓存;rail 只覆盖本次运行内访问过的会话 + 当前会话。如需全覆盖需扫 localStorage 全量 key(复杂且慢),YAGNI | diff --git a/docs/plans/2026-08-26-pinned-terminal-plan.md b/docs/plans/2026-08-26-pinned-terminal-plan.md new file mode 100644 index 0000000..bb1adf0 --- /dev/null +++ b/docs/plans/2026-08-26-pinned-terminal-plan.md @@ -0,0 +1,108 @@ +# 固定终端(Pinned Terminals)实施计划 + +> 2026-08-26 · 设计文档:[2026-08-26-pinned-terminal-design.md](2026-08-26-pinned-terminal-design.md) +> 仓库:dsh-better-sidebar · 分支策略:`feat/pinned-terminal`(仓库硬约束:代码改动必须走 PR) + +## 里程碑总览 + +| # | 里程碑 | 产出 | 验证 | +|---|---|---|---| +| M1 | 数据模型 + 解析层 | `SidebarTab.pin`、`setTabPin` reducer、`sanitizePersistedTab` 校验、`src/client/pinned.ts`、`SidebarStore.getSessionStates()` | `tests/state.spec.ts` + `tests/pinned.spec.ts` 绿 | +| M2 | TabBar 右键菜单 | 「固定 ▸」二级菜单(UI/Agent 文案区分)、「取消固定」、`onPinTab` prop 接线 | `tests/tab-bar-context-menu.spec.tsx` 绿 | +| M3 | reconcile 豁免 + 断开标注 | pinned agent tab 豁免移除 + 渲染期「已断开」后缀 | `tests/agent-terminal-reconcile.spec.ts` 追加用例绿 | +| M4 | PinnedRail 组件 | rail 渲染、类型图标、tooltip、点击切回 home 会话、右键取消固定/关闭 | `tests/pinned-rail.spec.tsx` 绿 | +| M5 | i18n + 文档 + 收尾 | 19 语言 key、README/AGENTS.md、全量回归 | `pnpm typecheck && pnpm test` 全绿 | + +## M1:数据模型 + 解析层 + +**改动文件**: +- `src/client/state.ts`:`SidebarTab.pin?: { scope: 'workspace' | 'global'; homeCwd?: string }`;`sanitizePersistedTab` 白名单校验 pin(非法 scope → 降级无 pin,不丢 tab);新 reducer `setTabPin(state, tabId, pin | null)`(walk splits + bottomSplits + floats,同构 `patchTab`;未知 tabId 严格同引用 no-op) +- `src/client/pinned.ts`(新):`pinnedVisibleTo(tab, viewer)` + `collectPinnedTabs(bySession, viewer)`(排除 viewer 自身会话、按收集顺序稳定) +- `src/client/state.ts` `SidebarStore`:新增 `getSessionStates(): ReadonlyMap`(暴露 `bySession` 只读视图) + +**测试**: +- `tests/state.spec.ts`:pin 字段 sanitize(合法/非法 scope/缺字段)、`setTabPin`(设置/取消/未知 id/浮动窗口 tab/同引用 no-op) +- `tests/pinned.spec.ts`(新):`pinnedVisibleTo` 矩阵(global × 任意 cwd;workspace × cwd 匹配/不匹配/双方 undefined/仅 viewer undefined);`collectPinnedTabs`(多会话收集、排除自身、空 map) + +**提交**:`feat: pin data model + cross-session resolver` + +## M2:TabBar 右键菜单 + +**改动文件**: +- `src/client/TabBar.tsx`:新增 prop `onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void`;终端 tab(`tab.type === 'terminal'`)菜单插入 pin 行——未 pin 用 `MenuItem.submenu`(`固定到工作区` / `固定到全局`),已 pin 用单行「取消固定」;label 经 t() 且按 `isAgentTabId` 区分文案 +- `src/client/Sidebar.tsx`:实现 `onPinTab` —— `store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))`(cwd 取当前会话 summaryCwd);传入 WorkbenchActions → 各 TabBar 实例 +- 检查 `FreeWindow.tsx` 头部右键菜单是否也需 pin 项(与 TabBar 保持一致;若 FreeWindow 菜单独立实现则同步加) + +**测试**(`tests/tab-bar-context-menu.spec.tsx` 追加): +- 终端 tab 菜单含「固定 ▸」且 hover 展开两项;点击回调 `(tabId, 'workspace' | 'global')` +- 已 pin tab 显示「取消固定」,回调 `(tabId, null)` +- 非终端 tab 无 pin 行;Agent 终端 label 为「固定 Agent 终端」 + +**提交**:`feat: tab context menu pin submenu` + +## M3:reconcile 豁免 + 断开标注 + +**改动文件**: +- `src/client/state.ts` `reconcileAgentTerminals`:`toRemove` 过滤掉 `tab.pin !== undefined` 的 tab(豁免移除) +- 渲染期「已断开」:pinned agent tab 的标题后缀在**读取侧**追加——`TabBar`/rail 渲染处无法知道宿主列表,故在 `reconcileAgentTerminals` 豁免时给 tab meta 打标?**否**(改 meta 会写持久化)。方案:rail/tab 标题渲染保持原 title;「已断开」状态由 TerminalView 现有重连失败 banner 承担(WS 连接 1011/超时已有 fatal UI)。**收敛**:M3 只做豁免,断开的用户可见信号 = xterm banner,不加标题后缀(避免渲染期需要宿主列表的耦合)。设计文档 §6 表相应行实施时修正为「banner 承担断开信号」。 + +**测试**(`tests/agent-terminal-reconcile.spec.ts` 追加): +- pinned agent tab 在 uuid 消失后保留;未 pin 的照旧移除;豁免不影响 toAdd 路径 + +**提交**:`feat: reconcile exempts pinned agent terminals` + +## M4:PinnedRail 组件 + +**改动文件**: +- `src/client/PinnedRail.tsx`(新):props `{ entries: { tab, homeSessionId }[], currentSessionId, onFocus(homeSessionId, tabId), onUnpin(tabId), onClose(homeSessionId, tabId) }`;`[data-dsh-pinned-rail]` 锚点;pin 图标 + 类型图标 + tooltip;右键 Menu(portal);中键关闭(沿用 TabBar middlePressed 模式) +- `src/client/PinnedRail.module.css`(新):紧凑条样式,`--dsw-alias-*` 令牌,hairline 分隔,面板折叠时隐藏 +- `src/client/Sidebar.tsx`: + - 渲染期 `collectPinnedTabs(store.getSessionStates(), { sessionId, cwd: summaryCwd })`(useMemo on snapshot + sessionList) + - `pinnedJumpRef`(沿用 subagentJumpRef 模式)+ effect:`sessionId === pending` 时 `reduceFor` 激活 tab(`activateTab` 命中 float 则 raiseFloat)+ 面板折叠则 `togglePanel` + - onClose:home 路径 `reduceFor(home, s => closeTab(...))` + `api.ptyClose({ sessionId: home, cwd: homeCwd }, tabId)` / `api.agentPtyClose(uuid)`;当前会话退化为现有 `actions.closeTab` + - rail 挂载点:右面板内、第一个 TabBar 之上;窄视口(合并抽屉)同样渲染 +- `src/client/icons.tsx`:`IconPinOutline16`(若图标库无现成 pin) + +**宿主会话切换链路(关键风险,先 PoC)**:rail 点击 → 宿主会话列表导航。调研 `ctx.sessions` 客户端 API 是否有主动切换方法(参考 subagent 跳转实际触发方式);若无公开 API,用 `location.hash` 导航(与宿主路由一致)。PoC 验证后再写组件。 + +**测试**(`tests/pinned-rail.spec.tsx` 新): +- 0 条目不渲染;≥1 渲染条目(图标/tooltip/标题) +- 点击非当前会话条目 → 记录 jump + 导航调用(stub);effect 命中后 reduceFor 激活 +- 右键取消固定(onUnpin)、关闭(onClose 参数 home 路径);中键关闭 +- 浮动 pinned tab 点击 → raiseFloat 分支 + +**提交**:`feat: pinned rail component + cross-session focus` + +## M5:i18n + 文档 + 收尾 + +**i18n**(`src/client/locales.ts` + 19 个 `locales-*.ts`): +- 新 key:`pinTerminal` / `pinAgentTerminal` / `pinToWorkspace` / `pinToGlobal` / `unpinTerminal` / `pinnedTerminalTooltip`(插值:类型/范围/cwd) +- zh-CN 基准 + en 必写;ja 必同步(better-locale 契约);其余语言沿用既有同步惯例 +- `tests/locales.spec.ts` 守护同步性 + +**文档**: +- `README.md` / `README_EN.md` 功能清单加「固定终端」 +- `AGENTS.md` §3.4 内置 tab 表 terminal 行补 pin 说明 +- 设计文档追加「实施偏差」节(如 M3 的断开信号收敛) + +**回归门禁**: +```powershell +pnpm typecheck; pnpm test +# 重点回归:agent-terminal-reconcile / terminal-* / bottom-auto-terminal / tab-bar-* / state / prefs +``` + +**版本**:`package.json` minor bump(新功能);`src/client/service.ts` `features` 数组若对外暴露 pin 能力则加 `'pinnedTerminals'`(消费插件 gate 用)——本功能不开放服务 API,**不加**。 + +**提交**:`feat: pinned terminal i18n + docs` → PR `feat/pinned-terminal` + +## 依赖与顺序 + +M1 →(M2 ∥ M3)→ M4 → M5。M2/M3 互不依赖可并行;M4 依赖 M1 的 pin 字段与 reducer;M5 最后。 + +## 风险登记 + +| 风险 | 缓解 | 状态 | +|---|---|---| +| 宿主会话切换无公开 API | M4 开工先做 PoC(读 subagent 跳转链路 + `ctx.sessions` 面);实在不行用 hash 导航 | 待验证 | +| rail 在窄视口/底面板合并态的布局 | 复用现有面板 CSS 变量;`tests/breakpoints*.spec.*` 观察 | 实施期 | +| bySession 未缓存会话的 pin 不可见 | 设计已接受(YAGNI 全量扫 localStorage) | 已确认 |