Files
apes-sidebar/docs/plans/2026-08-26-pinned-terminal-design.md
T
HuanLinOTO e7c4578791 feat: inline pinned tabs in TabBar (replaces PinnedRail)
M4 rewrite per user feedback: pinned terminals should display in the
Tabs bar itself, not a separate rail. Clicking activates in-place
(cross-session terminal rendering), no session jump.

Changes:
- Delete PinnedRail.tsx + tests/pinned-rail.spec.tsx
- pinned.ts: add createPinnedVirtualTab (virtual id = pinned:<sess>:<id>,
  meta.__pinnedHome = { sessionId, cwd, tabId }), injectPinnedIntoTree
  (appends virtual tabs to first leaf, overrides active), isPinnedVirtualId,
  parsePinnedVirtualId, isPinnedVirtualTab, getPinnedHomeScope
- tab-content-memo.ts: add effectiveTabId to memo key — TabContent passes
  the original tab id (not the virtual id) to the descriptor component so
  TerminalView's WS URL resolves to the home PTY
- Sidebar.tsx: augmented tree (injectPinnedIntoTree), activePinnedTabId
  state, wrappedActions (intercept virtual ids for activate/close/unpin/
  drag/float), renderTab passes home scope + effectiveTabId for pinned tabs,
  pinnedRevision replaces railRevision
- TabBar.tsx: pinned virtual tabs non-draggable, pin icon prefix, stripped
  context menu (Unpin/Close only, no float/close-others/left/right)
- sidebar.module.css: remove rail styles, add .pinnedTab (italic + tertiary)
- Docs: README/README_EN/AGENTS.md/design doc updated for inline approach

157/157 client-side tests pass; typecheck + build clean
2026-08-26 21:40:32 +08:00

13 KiB
Raw Blame History

固定终端(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<sessionId, SidebarState>;setSession() 整体换快照。SidebarTab = { id, type, title, path?, diff?, meta? }。终端 tab:type: 'terminal'。
  • 终端两类(id 前缀判别,无需新字段):
    • terminal:<uuid> —— UI 终端(用户 + 菜单开,createTab 铸造)
    • agent:<uuid> —— 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:<sessionId>,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. 数据模型

// 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,纯函数)

/** 该 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<string, SidebarState>,
  viewer: { sessionId: string; cwd: string | undefined },
): { tab: SidebarTab; homeSessionId: string }[]

可见性规则:

pin.scope 可见条件
global 任意会话(不同 cwd 也可见)
workspace viewer.cwd === tab.pin.homeCwd(两者皆 undefined 视为匹配;仅 viewer.cwd 未知时保守可见)

SidebarStore 新增只读方法:

/** 当前所有会话 state 的只读视图(供 collectPinnedTabs)。 */
getSessionStates(): ReadonlyMap<string, SidebarState>

渲染期解析,不写跨会话投影——每会话 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="<tabId>"]。样式走 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 已有此分支)

12. 实施偏差记录

M3:断开信号收敛

设计 §6 原表「Agent 终端被 reconcile 移除」行提到「标题渲染时追加 (已断开)」。实施时收敛为:M3 只做豁免,断开的用户可见信号由 xterm 现有重连失败 banner 承担。原因:rail/tab 标题渲染处无法知道宿主列表(reconcileAgentTerminals 的 uuid 匹配是 state 级的,渲染层不持有该信息),改 meta 会写持久化(违反「reconcile uuid 匹配不受影响」)。xterm 的 WS 连接 1011/超时已有 fatal UI,足以承担断开信号。设计 §6 表相应行实施时修正为「banner 承担断开信号」。

M4:内联虚拟 Tab(取代 PinnedRail)

设计 §3 原描述「PinnedRail 组件——面板顶部的紧凑条,渲染其他会话的 pinned tab」。实施时改为内联虚拟 Tab:pinned tab 作为虚拟 SidebarTab 注入到当前会话 split tree 的第一个 leaf 的 tabs 数组尾部,与普通 tab 并排在 TabBar 中渲染。

变更原因:用户反馈「固定的终端是永远显示在 Tabs 栏处,而不是单独有一个地方让我点击后回到那个会话」。PinnedRail 是面板顶部的独立条,点击跳回宿主会话——这两个设计点都被否定。

新设计:

  • 虚拟 Tab 注入:injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId) 将虚拟 tab 追加到第一个 leaf,并在 activePinnedTabId 设置时覆盖 leaf 的 active
  • 就地激活:点击虚拟 tab 设 activePinnedTabId(本地 state),TerminalView 用 home session 的 scope(sessionId + cwd)连接宿主 PTY 的 WS(/sidebar/ws/terminal?sessionId=<home>&tab=<originalId>),不跳转会话
  • effectiveTabId:TabContent 新增 effectiveTabId prop,将虚拟 tab 的原始 id 传给 descriptor component(TerminalView 的 tabId 参数),虚拟 id 仅作 React key
  • 不可拖拽:TabBar 检测 isPinnedVirtualTab(tab) → draggable={false},跳过 drop handler
  • 右键菜单精简:pinned 虚拟 tab 右键只有 Unpin / Close(无 float / close-others / close-left / close-right)
  • reduceFor 不 notify:pinnedRevision state bump 替代 railRevision,force pinnedEntries useMemo 重算

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