8.0 KiB
固定终端(Pinned Terminals)实施计划
2026-08-26 · 设计文档: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);新 reducersetTabPin(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.tsSidebarStore:新增getSessionStates(): ReadonlyMap<string, SidebarState>(暴露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:新增 proponPinTab?: (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.tsreconcileAgentTerminals: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 的断开信号收敛)
回归门禁:
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) | 已确认 |