Files
apes-sidebar/docs/plans/2026-08-26-pinned-terminal-plan.md
T

8.0 KiB
Raw Blame History

固定终端(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);新 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<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:新增 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 的断开信号收敛)

回归门禁:

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) 已确认