workbench-engine-navigator:从「看板加一块预览」到一套内容无关的分屏工作台

eyrie(apps/desktop + apps/daemon) · fe3b67e..HEAD · 2026-06-28 · 自包含,读完即弃

29 commits(不含被并入的 origin/main)
186 文件
+15,632 / −3,957
~46% 是测试代码
类型:功能(含修复)

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。不贴行号,位置到文件/函数粒度。

1TL;DR

这个 PR 在 desktop 桌面端从零搭了一套内容无关(content-agnostic)的可分屏 tab 工作台引擎:引擎只认一棵「分屏树 + tab 表」的纯数据布局和一张内容注册表,至于一个 tab 里装的是终端还是 Agent 会话,引擎一概不知道,全部交给注册项去渲染、去命名、去订阅。

围绕这套引擎,PR 又接上了三样东西:一个全新的终端子系统(daemon 侧的 pty 注册表 + 控制面 tRPC + 数据面裸 WebSocket,renderer 侧的 xterm 内容层),把原本写死在看板里的「开会话」重塑成注册进引擎的一种「Activity」,以及一个按任务分组、可收起的左侧导航器,让工作台不再是一片空白。

动机来自 dogfood:要用 Eyrie 自己迭代自己,就需要在一个窗口里同时开多个终端和会话、并排审查、互不抢流——而之前看板右栏只能用一个本地 useState 临时挂一个会话视图,既不能分屏、也不能多开。

读完这份报告,你应当能在脑子里重建四条路径:开一个终端时字节怎么从 pty 流到屏幕、为什么关掉 tab 进程不死;同一份内容怎么做到只开一个 tab、拖到边怎么分屏;多个会话怎么同时直播不互相偷流;以及导航器和空态怎么把「工作台该放什么」摆到用户面前。

2变更地图(称重)

这是一个设计承载为主的 PR——除 lockfile 和一处原型目录删除外,几乎每一块都是新建的承重结构,不是搬运。按目录分布:

apps/desktop
13,984 行 · 71%
apps/daemon
4,068 行 · 21%
.ai_docs/e2e
1,151 行 · 6%
packages/api
186 行 · 1%
packages/client
112 行 · <1%
子系统设计重心(要细读)可放心略过
工作台引擎
features/workbench
布局树操作 operations.ts(分屏/合并/移动/复活)、注册表 registry/、host 入口 host/operations.ts、渲染层 WorkbenchView/SplitContainer/TabStrip/ContentHost
终端子系统
daemon + features/terminal
daemon terminal-registry.ts(scrollback 环形缓冲)、terminal-ws.ts(数据面)、renderer connection-registry.ts / TerminalPanel.tsx
会话入工作台
features/session
订阅绑定 use-session-subscription-binding.ts、删除级联、draft 提升、density 复用、MarkdownPart.tsx 流式修复 ptyId→terminalId 改名、use-agent-events 仅改注释
导航器
WorkbenchNav*
activity-aggregate.tsWorkbenchNav/Row.tsxnav-store.ts NavCollapseToggle 的半圆样式(纯视觉)
真机 E2E
.ai_docs/e2e
workbench-e2e.mjs(生产 shell 纵切)、terminal-e2e.mjs
机械变更 bun.lock 锁定(xterm-webgl / RTL)、playground/ 原型目录 24 文件 ~2,600 行整目录删除、跟随客户端接口改名的几处测试桩

测试占比近半:186 个文件里约 8,957 行是测试,10,632 行是产品代码。终端、引擎、级联这些承重逻辑每一块都有同名 .test 钉边界,外加两条裸 CDP 真机 E2E 证集成。

3架构一图流

引擎本身的形状变化最值得先看一眼:右栏从「看板写死一段开会话的 UI」变成「一套独立的 surface/tab 布局,内容由注册表解析」。终端则是全新的两面通道——控制面建/杀进程,数据面只搬字节。

工作台:右栏从写死到引擎驱动

以前 · 看板自己挂会话

board.tsx
本地 useState<openSession>
SessionView
分屏 / 多开
不存在
终端

现在 · 引擎 + 注册表

注册表
getContent(kind)
分屏树 / surface
看板 / 导航器
openInWorkbench(target)
tab

终端:控制面与数据面分家

控制面 · tRPC over WS

renderer
terminal.create / list / kill / rename
TerminalRegistry(拥有 pty 寿命)

数据面 · 裸 WebSocket

xterm
字节双向 + resize/gone 控制帧
已存在的 pty(只 attach)

关键分工:控制面拥有 pty 的生死(create/kill 走 tRPC 过程),数据面只附着到一条已经存在的 pty 上搬字节。这条分界线是后面「关掉 tab 进程不死」的全部根据——关 tab 只是数据面 detach,控制面没收到 kill,pty 照活。

4数据与状态先行

四条旅程共用三组数据形状。先把它们的「样子」摆清楚,旅程里就只讲行为。

4.1内容句柄与注册契约

ContentTarget 是一个 tab「装的是什么」的可序列化句柄。它是个可辨识联合,但引擎从不读它的内部字段,只整体存、整体传——这是「内容无关」的物理基础。

features/workbench/registry/types.ts真实代码(节选)
export type ContentTarget =
  | { kind: 'terminal'; projectId: string; taskId: string; terminalId: string }
  | { kind: 'session';  projectId: string; taskId: string; sessionId: string }
  // projectId / taskId 是每个变种都有的「基础放置字段」——引擎靠它定 surface 归属、做删除级联,
  // 因此 surfaceOfTarget / clearPreviewForTask 能不分支 kind

一种内容向引擎注册的全部契约都在 ContentKind 里:怎么渲染、怎么取标题、怎么去重、要不要订阅、能不能复活,以及一个可选的「Activity 能力」。加一种内容 = 实现这个接口 + 一个组件,零引擎改动。

features/workbench/registry/types.ts真实代码(节选)
export interface ContentKind<K extends ContentTarget['kind']> {
  kind: K
  Component: ComponentType<{ target: Extract<ContentTarget, { kind: K }> }>
  useDescriptor(target): TabDescriptor          // 活态标题/图标/badge,hook 因为它跟内容状态走
  keyOf(target): string                         // 去重身份:两 target 同键即同一份内容
  confirmClose?(target): Promise<boolean>        // 关闭拦截,resolve false 否决
  subscription?(target): SubscriptionDescriptor | null  // 终端不声明(走裸 WS),会话声明 agent 流
  isRevivable?(target): boolean                 // 重启复活时按 target 否掉自己的一部分(草稿就不复活)
  activity?: ActivityCapability<K>              // 有它 = 是 Activity,能列到 task 上
}

4.2布局树形状

分屏布局是「树只放 id、target 单独存一张表」:树是薄的排布(pane 是叶子、group 是按轴排开的内部节点),每个 tab 的真实句柄存在 tabs 表里一份。每个 surface 一棵独立的树。

features/workbench/layout/types.ts真实代码(节选)
export interface Pane { id: PaneId; tabIds: TabId[]; activeTabId: TabId | null }   // 一摞 tab,叶子
export interface Group { id: GroupId; direction: 'row' | 'col'; sizes: number[]; children: LayoutNode[] }
export type LayoutNode = { kind: 'pane'; pane: Pane } | { kind: 'group'; group: Group }
export interface SurfaceLayout {
  root: LayoutNode
  focusedPaneId: PaneId | null     // 新 tab 落哪、下次 split 切哪个 pane
  tabs: Record<TabId, Tab>          // 全树唯一的 target 归宿;树里只有 id
}
export type SurfaceId = 'global' | `project:${string}`   // 每个 surface 一棵硬隔离的树

4.3scrollback 回放形状

daemon 给每条终端录 1 MiB 的原始 pty 字节(不是渲染后的屏幕快照),并按 resize 边界切段——这样重新 attach 时 xterm 能逐段「先按当时的列宽 resize,再喂字节」,正确还原颜色、光标、清屏序列和换行。

apps/daemon/src/terminal/terminal-registry.ts真实代码(节选)
export interface ReplaySegment {
  cols: number; rows: number   // 这一段录制时在用的终端尺寸
  data: Buffer                 // 这些尺寸下录到的原始 pty 字节
}
export type ReplayPayload = ReplaySegment[]   // 一次性回放给新 attach 的 socket:resize、字节、resize、字节…

5底座:内容无关引擎

四条旅程都踩在同一块底座上:一张 Map<kind, ContentKind> 注册表,加一组「只按 keyOf 认身份、从不读具体 kind」的 host 操作。把它单独讲一遍,旅程里就只讲各自特有的逻辑。

注册表本身就是一个 Map,注册由内容层调用,引擎只 get/list,永不写 if (kind === 'terminal')。证据是:terminal 和 session 的注册分别住在 features/terminalfeatures/session——在引擎之外

features/workbench/registry/registry.ts真实代码(节选)
const registry = new Map<ContentTarget['kind'], ContentKind<...>>()
export function registerContent(r) { registry.set(r.kind, r) }
export function getContent(kind: string) { return registry.get(kind) }  // 收 string:miss 就是「这个 kind 不再支持」的掉-tab 信号
export function listContent() { return [...registry.values()] }

有一条隐蔽但承重的纪律:内容种类必须在工作台 store 被任何模块拉起之前注册完。因为 store 用 zustand 持久化,hydration 时会做「kind 校验」——只有已注册的 kind 才能让持久化的 tab 复活,未注册的会被当未知类型丢弃。所以有一个专门的启动副作用模块,只 import 直接注册模块、绝不 import feature barrel(barrel 会连带把 store 拉起来,在注册前触发 hydration,把每个持久化 tab 都丢掉)。

apps/desktop/src/renderer/register-content-kinds.ts真实代码(节选)
import { registerSessionContent } from '@/features/session/session-registration'
import { registerTerminalContent } from '@/features/terminal/terminal-registration'
// main.tsx 在拉 client-provider 之前先 import 这个模块,保证注册先于 store hydration
registerTerminalContent()
registerSessionContent()
配方 · 给工作台加一种新内容:① 定义它的 ContentTarget 变种(带 projectId/taskId 放置字段);② 写一个渲染组件;③ 实现 ContentKindComponent/useDescriptor/keyOf 必填,要列到 task 上就再给 activity.useList);④ 在它自己的 *-registration.tsregisterContent(...);⑤ 把这个注册加进 register-content-kinds.ts。引擎、布局树、导航器、预览全都不用改。

6旅程 A:开一个终端,且关 tab 不杀进程

这是最硬的一条纵切:一次点击穿过 renderer、客户端连接、daemon 控制面与数据面,落到一个真 pty,再把字节流回屏幕。走通它,你就掌握了终端的全部生命周期,以及这套设计的招牌不变式——关掉 tab,进程照活

全景 · 涉及 6 个文件
建 pty
terminal-service.ts
开/聚焦 tab
host/operations.ts
取连接
connection-registry.ts
attach + 回放
terminal-ws.ts
xterm
TerminalPanel.tsx

A.1控制面建 pty:拒绝在错目录开

看板任务面板的「+ Terminal」走 terminal.create 控制面过程。它做的第一件不直觉的事是没有默认目录兜底:解析不出任务的唯一工作目录就直接拒绝。理由是安全——一个开在家目录里的终端,用户以为在 worktree 里跑 git,比「没有终端」更危险。

apps/daemon/src/terminal/terminal-service.ts真实代码(节选)
async create(input) {
  // 先探任务是否存在,把「typo 的 id」和「真任务但没工作目录」分开报错
  if (!(await deps.taskExists(input.taskId))) throw new AppError({ code: task.notFound })
  const cwd = await deps.resolveTaskWorkingDir(input.taskId)
  if (!cwd) throw new AppError({ code: task.noWorkingDir })   // 不默认到家目录——宁可不开
  const name = input.name ?? nextTerminalName(deps.registry, input.taskId)  // 「Terminal N」= 现存数+1
  return toTerminalDto(deps.registry.create(input.taskId, { cwd, name }))
}

注册表 create 之后做的事是这条旅程后半段的关键:它 spawn pty 时用 encoding: null原始字节,并持续录制——录制是注册表的职责,不是 attach socket 的,因为录制一旦挂在 socket 上,关 tab 就停录了。同时挂一个 onExit,让自己退出的 shell 自动从索引里摘掉。

apps/daemon/src/terminal/terminal-registry.ts真实代码(节选)
const recorder = pty.onData((chunk) => scrollback.append(toBuffer(chunk)))   // 录满 pty 整个生命,不随 tab 走
const exitWatcher = pty.onExit(() => this.dropFromIndexes(session))         // 用户敲 exit / 崩溃 / 被杀都自清

A.2连接归模块,不归组件

renderer 侧最反直觉的一笔:终端连接的寿命不由渲染组件持有,而由一张模块级的引用计数表持有。同一条终端被两个 tab 打开,共享一条 socket;后台 surface 上的终端 tab 虽然被卸载(工作台只渲染当前 surface),它的 socket 仍被一个 store 驱动的「owner 引用」撑着不断流。

features/terminal/connection-registry.ts真实代码(节选)
const connections = new Map<string, { conn; refs: number; dead: boolean; unsubscribeGone }>()
export function acquireTerminal(terminalId, create) {
  const existing = connections.get(terminalId)
  if (existing?.dead) { /* 墓碑 socket:换新 socket,引用数承前 */
    existing.conn.dispose(); existing.conn = create(terminalId); existing.dead = false
    existing.refs += 1; return existing.conn
  }
  if (existing) { existing.refs += 1; return existing.conn }   // 第二个 tab 复用同一条 socket
  const conn = create(terminalId)
  connections.set(terminalId, { conn, refs: 1, dead: false, ... }); return conn
}

谁来持有那个「后台也不断流」的 owner 引用?一个挂在 shell 根(活得比所有 surface 都长)的 hook,订阅工作台 store,把「所有 surface 里出现过的 terminalId 集合」对账成 owner 引用:开 tab 就 acquire,关 tab 就 release

features/terminal/connection-sync.ts真实代码(节选)
function reconcileOwners(liveIds, create) {
  for (const id of liveIds) if (!owned.has(id)) { acquireTerminal(id, create); owned.add(id) }
  for (const id of owned)  if (!liveIds.has(id)) { releaseTerminal(id); owned.delete(id) }
}
// 跨「每个 surface」收集 terminalId——不只当前 surface——才是「滚出视野的终端仍持连接」的根因

A.3数据面:一个 viewer,先回放再转直播

数据面是一条裸 WebSocket。它的第一帧必须是带 credential 的 open 帧;通过后,它做两件有讲究的事。第一,一条终端只允许一个 viewer:第二次 attach(tab 重挂、StrictMode 双调、快速重连)必须先把上一个 viewer 踢掉,否则两个 forwarder 抢读写同一条 pty,谁的背压都可能把 pty 暂停到另一个头上。

apps/daemon/src/terminal/terminal-ws.ts真实代码(节选)
const session = deps.registry.get(openFrame.terminalId)
if (!session) { sendGone(ws); ws.close(1000); return }   // pty 不在了:直接发 gone,让客户端翻墓碑
attachedSockets.get(session)?.detach()                    // 一终端一 viewer:先踢掉旧 viewer
const attached = attachAuthenticatedSession(ws, session, deps)

第二,attach 时先把 scrollback 逐段回放,再订阅直播——而且每段字节前先发一个 resize 控制帧,把 xterm 调到那段录制时的列宽,这样宽度敏感的历史能正确换行后再收字节。这个 forwarder 是叠在注册表那个常驻 recorder 之上的第二个 onData 监听,所以 detach 只拆这个监听,永不动那个保着 scrollback 的 recorder。

apps/daemon/src/terminal/terminal-ws.ts真实代码(节选)
for (const segment of scrollback.replay()) {            // 先历史:resize 先行,再喂这一段的字节
  sendJson(ws, { type: 'resize', cols: segment.cols, rows: segment.rows })
  if (segment.data.length > 0) sendPtyData(ws, segment.data, backpressure)
}
const dataDisposable = pty.onData((data) => sendPtyData(ws, toBuffer(data), backpressure))  // 再直播
const exitDisposable = pty.onExit(() => { sendGone(ws); detach({ closeSocket: true }) })

A.4关 tab ≠ 杀 pty;pty 真死了才翻墓碑

现在招牌不变式落地:关掉 tab,TerminalPanel 卸载,releaseTerminal 把 viewer 引用减一——但 owner 引用(store 持有)还在,socket 不断;即便 socket 断了,daemon 侧的 pty 也只是没人 attach,照常运行、照常录 scrollback。重新打开同一个 terminalId,就靠回放把关 tab 前的历史搬回来。

真正让 pty 死的只有控制面 kill(删 task 触发 killByTask)或 shell 自己退出。这时数据面的 pty.onExit 发出 gone 帧,面板锁进墓碑态——保留 tab、不空白、不假装在线,给一个显式「重新打开」,就像编辑器里一个被删的文件。

features/terminal/TerminalPanel.tsx真实代码(节选)
const unsubscribeControl = connection.onControl((frame) => {
  if (frame.type === 'gone') setGone(true)                                // 唯一会改变结构的控制帧
  else if (frame.type === 'error') terminal.write(`\r\n${frame.envelope.message}\r\n`)  // 错误内联,不丢终端
  else if (frame.type === 'resize') terminal.resize(frame.cols, frame.rows)  // 回放尺寸帧:历史按原列宽换行
})
return () => {
  releaseTerminal(terminalId)   // 只松开 viewer 引用——socket 等最后一个引用才关,pty 永远不动
  try { terminal.dispose() } catch { /* 含住 xterm 库内部 dispose 竞态,详见旅程偏差 */ }
}
排查路标 · 旅程 A(终端)
症状从哪下手
点「+ Terminal」报错、开不出terminal-service.ts create:先看 taskExists/resolveTaskWorkingDir 是否返回 null(noWorkingDir)
关 tab 后历史没了 / 进程像是被杀了connection-sync.ts 的 owner 对账 + terminal-registry.tsrecorder(录制是否还挂在 pty 上)
重开终端起步空白、不回放terminal-ws.ts attachAuthenticatedSessionscrollback.replay();注意「第二个 viewer 起步空白」是已知延后项
切 surface 回来终端断了connection-sync.ts:owner 引用是否还在(应跨所有 surface 收集 id)
历史换行错位三侧 resize:daemon recordResize → client parseControlFrame 认 resize → TerminalPanel terminal.resize
终端崩把整个 app 打白屏TerminalPanel.tsxrequestAnimationFrame 延迟挂 WebGL + cleanup 的 try/catch(见 §10 偏差)

7旅程 B:同一份内容只开一个 tab,拖到边就分屏

这条旅程讲两个引擎语义:从看板/导航器打开内容时怎么做到「有就聚焦、没有才新建」,以及把一个 tab 拖到另一个 pane 的边缘时,什么时候真分屏、什么时候其实是合并。

B.1open-or-focus:按 keyOf 去重

导航行点击、看板 chip 双击,最终都走 host 的 openInWorkbench。它先用注册表的 keyOf 把 target 化成「规范键」,在目标 surface 上扫一遍——同键的 tab 已存在就聚焦它,否则才新建。所以重复打开同一份内容是「重新附着」,不是堆出第二个 tab。

features/workbench/host/operations.ts真实代码(节选)
export function openInWorkbench(target): string | null {
  const key = keyOfTarget(target); if (key === null) return null   // kind 没注册 → 无可放置
  const surface = surfaceOfTarget(target); const store = useWorkbenchStore.getState()
  const existing = findTabByKey(surface, key)    // 跨 pane 扫描同键 tab
  store.switchSurface(surface)
  if (existing) { store.activateTab(surface, existing.paneId, existing.tabId); return existing.tabId }
  return store.openTab(surface, target)          // 没有才新建
}

这里有个容易看漏的两层去重:host 的 openInWorkbench 做的是surface 级聚焦(按 keyOf),而布局层 openTab 自己还有一层pane 内去重(按整个 target 相等)。差别是故意的——直接调 openTab(分屏播种、拖拽)允许同一份内容有意并排在两个 pane 里看,走 host 才会塌成一个。要理解「为什么这个终端开了两个 tab」,得两层合看。

B.2拖拽分屏,与「同轴 lone-tab 折叠成合并」的特例

把一个 tab 拖到某个 pane 的四条边,正常是沿那个方向劈出一块新 pane。但有一个退化情形必须特判:如果被拖的 pane 只有这一个 tab,而你丢的方向正好是它和目标当前已经共享的那条轴,那「分屏」只会把两个 pane 调个位置(源 pane 被抽空剪掉、新 pane 又落在同一条轴上),根本不是 edge drop 承诺的「开一块新区」。于是把这个手势折叠成「叠进目标 tab 堆」,符合「拖到 pane 上」的直觉。

features/workbench/layout/operations.ts真实代码(节选)
const { dir, side } = splitPlacement(edge)
// 同轴的 lone-tab 边拖会退化成换位(抽空源 + 新 pane 落同轴),不是新分区——折叠成 stack
if (source.tabIds.length === 1 && siblingGroupDir(layout.root, source.id, target) === dir) {
  return moveTab(layout, tab, target, dest.tabIds.length)
}
// 跨轴的边、或目标在另一个 group 下,才真分屏:那时抽掉源确实留下一块新 pane
const root = wrapPaneInGroup(detached, target, dir, newPane, side)

这条特判依赖一个小工具 siblingGroupDir(a, b):返回「同时把 a、b 当直接孩子的那个 group 的方向」,否则 null。它专门用来分辨「同轴退化换位」和「真跨轴/跨父分屏」——比「只看是不是 lone-tab」更精确,因为跨轴的 lone-tab 边拖(比如竖排里把一个拖到另一个的右边)是真要变成横排的。

分屏之所以不会断掉后台终端,靠的是渲染层的 keep-alive:一个 pane 里所有 tab 全部挂载,非激活的只用 CSS 藏起来(invisible + aria-hidden),不卸载。配套地,SplitContainer 给子节点的 React key 用稳定的布局身份(pane:<id>)而不是数组下标——否则剪掉一个空 pane 会让幸存兄弟重排到更低下标,下标 key 会让 React 误把它卸载重挂,正好破坏 keep-alive。

排查路标 · 旅程 B(去重 / 分屏)
症状从哪下手
同一会话/终端开出了两个 tab看是走 host/operations.ts openInWorkbench(应去重)还是直接 store.openTab(pane 内才去重);并核对该 kind 的 keyOf 单射性
拖到边没分屏、反而叠进去了operations.ts dropTabToEdgesiblingGroupDir === dir 同轴折叠分支
分屏后某个终端断流ContentHost.tsx keep-alive(非激活 tab 是否被卸载)+ SplitContainer 的稳定 childKey
分屏越分越深到不能用operations.ts MAX_DEPTH = 4 深度上限(超了 no-op)

8旅程 C:多个会话同时直播,互不偷流

这条旅程是会话从「看板写死的一个视图」变成「引擎里可多开、可预览、可分屏的内容」之后,最关键的行为转变:多个会话能同时收流。

C.1订阅从「单选」变「并集」

以前全 app 只能有一个 active 会话在收流,开第二个会把第一个的流偷走。现在引擎把「所有 surface 的会话 tab + 右侧预览」并起来去重,全部同时订阅。喂给 dispatcher 的这条绑定挂在 shell 根,读引擎派生的订阅并集,滤出 agent 类,推给 setAgentSubscriptions

features/session/api/use-session-subscription-binding.ts真实代码(节选)
const subscriptions = useActiveSubscriptions()   // 引擎派生:每个会话 tab + 预览的订阅并集
const sessionIdsKey = subscriptions
  .filter((d) => d.kind === 'agent').map((d) => d.id).sort().join('\n')   // 排序 join 成稳定 key
const sessionIds = useMemo(() => (sessionIdsKey === '' ? [] : sessionIdsKey.split('\n')), [sessionIdsKey])
useEffect(() => { setAgentSubscriptions(sessionIds) }, [sessionIds, setAgentSubscriptions])
// 用「排序后的 id 串」当 effect key:无关的 store 变化(改布局、动别的 surface)不触发 dispatcher 重订阅
以前
点开会话 → setActiveAgentSession(id)
dispatcher 只订这一个
开第二个 → 偷走第一个的流
现在
所有 tab + 预览 → 引擎并集去重
setAgentSubscriptions(ids[])
每个会话各一条 agent.events 流,并存不互抢

C.2看板预览:还没开 tab 就已经在直播

看板卡片上的 Activity chip 单击会进右侧预览态,用注册表同一个 Component 渲染这个会话(和 tab 是同内容双挂载、各自 DOM、共享同 id 的 store)。关键设计:预览的订阅会被并进上面那个 active 并集,所以预览一打开就开始 streaming,哪怕还没开成 tab。预览本身不持久化,reload 回到任务详情。

features/workbench/host/operations.ts真实代码(节选)
export function previewInDetail(target) { useWorkbenchStore.getState().setPreviewTarget(target) }
// 引擎把 preview target 并入 active 订阅集(subscriptions/active),所以预览即直播;
// back 按钮调 clearPreview(),它的订阅随即离开并集,除非同资源也有 tab 开着(并集会去重)

C.3草稿会话与跨挂载点的提升

「+ Session」不立刻建 daemon session:它先落一个 draft: 前缀的客户端本地 id 进预览,不调 daemon、不订阅任何流、reload 不复活(isRevivable 拒绝)。第一轮对话才真正 sessions.create,然后 host 把所有挂载点(可能不止一个 tab,还有预览)上的句柄就地改写成真 id——提升逻辑集中在一处,而不是散在每种内容里。

features/workbench/host/operations.ts真实代码(节选)
export function promoteContentTarget(from, to) {
  const fromKey = keyOfTarget(from); if (fromKey === null) return
  for (const surface of Object.keys(store.bySurface))
    store.retargetTabs(surface, (t) => keyOfTarget(t) === fromKey, to)   // 改写每个 surface 上的草稿 tab
  if (store.previewTarget && keyOfTarget(store.previewTarget) === fromKey)
    store.setPreviewTarget(to)   // 「+New Session」把草稿落在预览里、没有 tab,漏了这步它永远拿不到真 id、永不直播
}

C.4一个真流式 bug:只渲染首帧

会话渲染里修了一个真实的流式冻结 bug,值得单独看,因为根因不在传输也不在 store——那三层都正确拼出完整文本,bug 在 markdown 渲染器的用法。@incremarkrender() 是「reset + append + finalize」一次性操作,对一个还在流的消息用它,会在第一个 delta 就 finalize 整段流,之后每个 append 全被丢弃——气泡冻在第一个 token,直到 reload 才重渲完整文本。

features/session/components/MarkdownPart.tsx真实代码(节选)
useEffect(() => {
  if (!mountedRef.current) {
    if (text) im.append(text)   // 首块用 append,绝不 render()——后者会 finalize 掉还在流的消息
    prevLenRef.current = text.length; mountedRef.current = true
  }
}, [im, text])
useEffect(() => {            // 流停了(或静态/持久化挂载)才 finalize,冲掉打字机缓冲、撤光标
  if (mountedRef.current && !isStreaming) im.finalize()
}, [isStreaming, im])
排查路标 · 旅程 C(会话)
症状从哪下手
开第二个会话第一个停流了use-session-subscription-binding.ts + client-providersetAgentSubscriptions 并集(是否塌回了单选)
预览里的会话不收流host/operations.ts previewInDetail + 引擎 subscriptions/active 的并集是否含 preview
新会话发了首条后还卡在草稿、重试又建一个SessionView 的提升时序(先 onCreatedstartTurn)+ promoteContentTarget
assistant 气泡只显示头一两个字MarkdownPart.tsx:首块必须 appendrender
审批卡显示 [object Object]ApprovalTool.tsxformatDetailValue(对象走 JSON)

9旅程 D:工作台不再是一片空白

这条是用户最直接的诉求:打开工作台不该什么都没有。两件东西回应它——一个按任务分组的左导航器,和一个指向导航器的空态提示。

导航器列出项目的全部任务(按看板列序拍平),每个任务下把它的可打开 Activity(会话 + 终端)列成行,点击 openInWorkbench 去开或聚焦。聚合靠一个走注册表、逐 kind 调 activity.useList 的 hook——新注册一种 Activity 自动出现,无需改导航器。

features/workbench/host/activity-aggregate.ts真实代码(节选)
export function useTaskActivities(taskId, projectId): ContentTarget[] {
  const result = []
  for (const kind of listContent()) {
    if (!kind.activity) continue
    result.push(...kind.activity.useList(taskId, projectId))   // 每个 Activity kind 一次 hook 调用
  }
  return result   // 注册顺序在 boot 期固定,所以「循环里调 hook」的数量/顺序跨渲染稳定
}

因为这个聚合 hook 自己不能在循环里被调用,导航器把「每个任务调一次」拆进了 per-task 子组件 WorkbenchNavTask;行的「已开高亮」也用一个 store 选择器活态反映,tab 开/关时精确重渲染。

features/workbench/components/WorkbenchNavRow.tsx真实代码(节选)
const open = useWorkbenchStore(() => isContentOpen(target))   // 选择器返回布尔,store 每变化重算,tab 开关时行高亮跟着翻
<button data-nav-row data-nav-row-open={open || undefined} aria-current={open || undefined}
        onClick={() => openInWorkbench(target)}>            // 只 open-or-focus,绝不新建

收起靠一个半圆 pull-tab(不是细图标条,因为左边已经有一条 3rem 的全局 ActivityRail),收起态把面板宽度归零、只留半圆贴在内容区左缘。收起与否是一个全局布尔,用独立的 localStorage key 持久化,所以 reload 后仍保持收起。

空态这次顺手补全了 item ③ 自己的承诺。原来只有「surface 从没建过布局」(!layout) 才显示提示;但「开过 tab 再全部关掉」会留下一个活着但空的 pane,那条路径会渲染成空白。改判「没有任何 tab 打开」就退回提示,指向导航器。

features/workbench/components/WorkbenchView.tsx真实代码(节选)
// 既无 layout、又「所有 tab 关光只剩空 pane」,都读作空态
if (!layout || Object.keys(layout.tabs).length === 0) {
  return <EmptyWorkbench />   // i18n: workbench.empty.hint「从左侧选择一个会话或终端在此打开。」
}
排查路标 · 旅程 D(导航器 / 空态)
症状从哪下手
导航器某任务下不显示会话/终端activity-aggregate.ts useTaskActivities + 各 kind 的 activity.useList(草稿会话提升前本就无 chip)
行高亮不跟随 tab 开关WorkbenchNavRowuseWorkbenchStore(() => isContentOpen(target)) 选择器
reload 后收起状态丢了store/nav-store.ts 的 persist key eyrie.workbench.nav
关掉最后一个 tab 后右边一片白WorkbenchView.tsx 空态条件(必须判 layout.tabs 为空,不只判 !layout)+ operations.ts reconcile 的空 anchor 处理

10计划 vs 实现的偏差

照技术方案做成的部分(P1–P7 八阶段:daemon 注册表 → 控制面 → 数据面 → 布局树 → 引擎组件 → 终端内容层 → 生产 shell 接线)这里不复述。下面只收实质偏差——主要来自真机 E2E 抓出、随后修掉的 bug,以及一处方案被否决。这些是「计划里没有、读代码也猜不到」的认知裂缝。整个落地是「Codex/Claude 协作 + 真机 CDP 续测,测出问题当场修」的多轮节奏。

计划/直觉实际做成为什么变
终端内容层挂载时同步 attachRenderer(加载 WebGL addon) 改成 requestAnimationFrame 延迟 + cancelled 守卫 + cleanup cancelAnimationFrame 真机首测整个终端功能崩(_isDisposed):StrictMode 那次注定被丢的挂载会同步 dispose 一个还没初始化完的 WebGL addon。延迟到下一帧,让被丢的挂载根本不加载 WebGL。
关 tab / 删 task 时直接 terminal.dispose() cleanup 用 try/catch 包住 dispose 删一个开着终端的 task 又把整 app 打白屏——同 _isDisposed 但在拆卸期:xterm 库内部 dispose 顺序竞态,core 先拆、WebGL addon 后拆时读到 undefined。吞掉这个库内抛错(实例正被丢弃)才不让一个垂死终端带走整窗。
会话渲染用 im.render(firstChunk) 首块 im.append(),流停才 finalize() 真 agent 回 pong,气泡只显示「p」。render() 在首个 delta 就 finalize,丢掉后续。后端/store 全对,纯渲染器用法错。
删除级联用「task 不在看板快照里」推断已删(level-trigger) 改成 seenOnBoard 的 present→absent 边沿触发 陈旧快照(task 刚建、delta 没到)和真删除分不开,会把刚 openTab 的 live session 瞬间误关。代价:跨重启的孤儿 tab 会滞留,但远好于误关活会话。
看板内容崩由顶层路由 error boundary 兜 ContentHost 给每个 tab 包内容级 ContentErrorBoundary 顶层 boundary 让一个 tab 崩 = 整 app 白屏。改成单 tab 崩只塌它自己(占位 + 重载),tab 条/兄弟/外壳都活。
审批卡按 ${k}: ${String(v)} 渲染 detail formatDetailValue:对象走格式化 JSON、过滤空字段 String(对象) = [object Object]。审批卡是安全敏感 UI,等于让用户批准一条看不见的命令。
居中合并后空出的 anchor pane 自动折叠 reconcile 只在全空时保留 anchor(anyTabs ? '' : firstPaneId 把第一个 pane 的最后一个 tab 合并走,空 pane 残留成半屏死面板——原逻辑把 keepPane 永远钉成 firstPane。本次会话顺手补的回归。
旧 dev 库兼容(DB schema 漂移) 方案否决:开发阶段直接删库重建,不写前向迁移、不加启动守卫 就地编辑已应用的 0000_init.sqlmessage.user 约束,Drizzle 按 hash 记账不重放,旧库静默炸在 turn 中途。用户拍板 pre-release 阶段删库重建即可。
门禁盲区(值得记):真机 E2E 为了让 CDP 读得到 xterm 文本,强制把渲染器 pin 到 DOM 后端——于是「15 步全绿」的 E2E 从没跑过 WebGL 路径,那个生产态必崩的挂载 bug 一路绿灯过门。教训:终端真实渲染后端需要一条「不读文本、只验挂载/卸载不抛错」的 smoke 进门禁。这条至今未补(见风险地图)。

11心智模型补丁

右栏「开会话」是看板自己的一段 UI(board.tsx 本地 useState)。 右栏内容由工作台引擎驱动:看板只调 previewInDetail / openInWorkbench,渲染什么由注册表解析。
一个 tab 的内容寿命跟它的组件挂载走。 终端连接由模块级 refcount 表持有、由 store 驱动 owner 引用;后台 surface 的终端 tab 卸载了,流还在。
改终端断流问题别只看 TerminalPanel,要看 connection-registry + connection-sync
关掉终端 tab 等于结束这个终端。 关 tab 只 detach 数据面;pty 由控制面 kill 或自己 exit 才死,死了才发 gone 翻墓碑。
同一时刻只有一个会话在收流(active selection)。 所有打开的会话(tab + 预览)并集订阅,各一条流,互不偷。
加一种新内容要动引擎/布局/看板。 加一种内容 = 一个 ContentKind 注册 + 一个组件 + 一行加进 register-content-kinds;引擎不认识具体 kind。
但注册必须先于 store hydration,否则持久化的 tab 会被当未知 kind 丢掉——这是 register-content-kinds.ts 只 import 直接注册模块的原因。
空工作台 = !layout 才显示提示。 关光所有 tab 会留一个空 pane(layout 还在),所以空态判 layout.tabs 为空,指向导航器而非留白。

12新词表

工作台引擎
surface一套独立的工作台布局命名空间;global(驾驶舱)一套、每个 project:<id> 一套,互相硬隔离不串 tab。
ContentTarget一个 tab「装什么」的可序列化句柄;引擎当不透明 JSON 看,只比相等不读字段。
content kind / 注册表把一类内容(terminal/session)注册进引擎的契约;引擎只问注册表、从不硬编码 kind。
Activity一种「能挂到 task 上、能从 task 创建」的内容(声明了 activity 能力);没声明的叫纯 Content,能开 tab 但不列到 task。
keyOf / open-or-focus内容的去重身份;开 tab 前按它找现存 tab,有就聚焦、没有才新建。
keep-alive后台 tab 保持挂载只隐藏、不卸载,让终端等连接不断流。
anchor pane文档序第一个叶子 pane,全空时永远保留,给空 surface 一个焦点落点。
终端
控制面 / 数据面控制面(tRPC over WS)建/杀/列/改名 pty,拥有进程寿命;数据面(裸 WS)只附着到已有 pty 搬字节。
scrollback 环形缓冲每终端 1 MiB 原始 pty 字节,按 resize 边界分段,重新 attach 时一次性回放重建屏幕。
replay resize 帧回放每段字节前先发的尺寸帧,把 xterm 调到那段录制时的列宽,让宽度敏感历史正确换行。
refcounted connection一条数据面 socket 按指向它的挂载数计数;owner 引用(store 驱动)+ viewer 引用(panel 挂载)两层,归零才真关。
tombstone(墓碑)pty 死后(gone 帧)的占位态——保留 tab、显「已结束 + 重新打开」,不空白不假装在线。
会话
订阅并集引擎把所有打开会话(tab + 预览)合并去重成一个订阅集,每个会话各一条流。
draft session / 提升draft: 前缀的本地 id 代表「还没建 daemon session 的新会话」,不订阅不复活;首轮对话后 host 把所有挂载点的句柄就地改写成真 id。
density(wide/narrow)宿主给内容的横向空间档;看板窄预览切 narrow,会话组件据此换布局——同一组件复用于全宽 tab 和窄预览两种宿主。
present→absent edge删除级联判删的依据:task 必须先在看板见过、之后消失才算真删,避开陈旧快照误删。

13测试与风险地图

纯事实陈述:哪些行为有测试钉住、哪些是薄冰。测试很密——153+ 测试文件、约 8,957 行测试,外加两条裸 CDP 真机 E2E(隔离栈,不碰本机 daemon)。

有兜底的

薄冰

合并前值得确认:① push 后看 Node 22 CI 是否真绿(唯一靠对外动作关闭的待证项);② 删 task 的终端 tombstone-on-delete 是 UX 决策,没拍板就会留孤儿墓碑;③ 终端 WebGL smoke 缺失,是这套终端唯一没有自动化守住的生产路径。

14验收提示

这些是读代码/验收时容易误判成缺陷、其实是有意设计的点,别被吓到:

15覆盖声明

本报告对比基线 fe3b67e(被并入的 origin/main 的 #88/#78/#54 等共享提交不计入,它们不是本分支的设计)。

素材采集:renderer 侧的 19 个核心文件(注册表/host/布局/终端连接/会话/导航器/启动注册)由编排者逐个一手精读,本文所有代码片段均出自亲自 Read 过的文件、亲手裁剪(用 // ... 略过无关行);daemon 侧的 terminal-registry / terminal-ws / terminal-service 与客户端 terminal-connection 同样一手读过。子系统全景由四个并行子代理(terminal+session、杂项兜底、引擎布局层、引擎注册/导航层)精读后提供为地图,编排者据此定旅程、再回到源码裁剪。「计划 vs 实现」一节取自 workbench-engine-e2e-report.mdworkbench-activity-e2e-findings-2026-06-26.md 两份随附文档,结论已内联。

诚实标注:.ai_docs/e2e 的两条脚本与 daemon 的 shell-env / 背压细节、以及 packages/api 的 zod schema 细节按地图归纳、未逐行精读;本报告聚焦四条主旅程的承重路径,没有逐文件覆盖全部 186 个文件,但每一块改动都至少在某个子代理的视野内、无死角。本报告是一次性理解辅助,不维护、不作为真相源。