workbench-engine-navigator:从「看板加一块预览」到一套内容无关的分屏工作台
eyrie(apps/desktop + apps/daemon) · fe3b67e..HEAD · 2026-06-28 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。不贴行号,位置到文件/函数粒度。
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 和一处原型目录删除外,几乎每一块都是新建的承重结构,不是搬运。按目录分布:
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| 工作台引擎 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.ts、WorkbenchNav/Row.tsx、nav-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 布局,内容由注册表解析」。终端则是全新的两面通道——控制面建/杀进程,数据面只搬字节。
工作台:右栏从写死到引擎驱动
以前 · 看板自己挂会话
现在 · 引擎 + 注册表
终端:控制面与数据面分家
控制面 · tRPC over WS
数据面 · 裸 WebSocket
关键分工:控制面拥有 pty 的生死(create/kill 走 tRPC 过程),数据面只附着到一条已经存在的 pty 上搬字节。这条分界线是后面「关掉 tab 进程不死」的全部根据——关 tab 只是数据面 detach,控制面没收到 kill,pty 照活。
4数据与状态先行
四条旅程共用三组数据形状。先把它们的「样子」摆清楚,旅程里就只讲行为。
4.1内容句柄与注册契约
ContentTarget 是一个 tab「装的是什么」的可序列化句柄。它是个可辨识联合,但引擎从不读它的内部字段,只整体存、整体传——这是「内容无关」的物理基础。
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 能力」。加一种内容 = 实现这个接口 + 一个组件,零引擎改动。
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 一棵独立的树。
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,再喂字节」,正确还原颜色、光标、清屏序列和换行。
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/terminal 和 features/session——在引擎之外。
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 都丢掉)。
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 放置字段);② 写一个渲染组件;③ 实现 ContentKind(Component/useDescriptor/keyOf 必填,要列到 task 上就再给 activity.useList);④ 在它自己的 *-registration.ts 里 registerContent(...);⑤ 把这个注册加进 register-content-kinds.ts。引擎、布局树、导航器、预览全都不用改。
6旅程 A:开一个终端,且关 tab 不杀进程
这是最硬的一条纵切:一次点击穿过 renderer、客户端连接、daemon 控制面与数据面,落到一个真 pty,再把字节流回屏幕。走通它,你就掌握了终端的全部生命周期,以及这套设计的招牌不变式——关掉 tab,进程照活。
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,比「没有终端」更危险。
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 自动从索引里摘掉。
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 引用」撑着不断流。
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。
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 暂停到另一个头上。
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。
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、不空白、不假装在线,给一个显式「重新打开」,就像编辑器里一个被删的文件。
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.ts 的 recorder(录制是否还挂在 pty 上) |
| 重开终端起步空白、不回放 | terminal-ws.ts attachAuthenticatedSession 的 scrollback.replay();注意「第二个 viewer 起步空白」是已知延后项 |
| 切 surface 回来终端断了 | connection-sync.ts:owner 引用是否还在(应跨所有 surface 收集 id) |
| 历史换行错位 | 三侧 resize:daemon recordResize → client parseControlFrame 认 resize → TerminalPanel terminal.resize |
| 终端崩把整个 app 打白屏 | TerminalPanel.tsx 的 requestAnimationFrame 延迟挂 WebGL + cleanup 的 try/catch(见 §10 偏差) |
7旅程 B:同一份内容只开一个 tab,拖到边就分屏
这条旅程讲两个引擎语义:从看板/导航器打开内容时怎么做到「有就聚焦、没有才新建」,以及把一个 tab 拖到另一个 pane 的边缘时,什么时候真分屏、什么时候其实是合并。
B.1open-or-focus:按 keyOf 去重
导航行点击、看板 chip 双击,最终都走 host 的 openInWorkbench。它先用注册表的 keyOf 把 target 化成「规范键」,在目标 surface 上扫一遍——同键的 tab 已存在就聚焦它,否则才新建。所以重复打开同一份内容是「重新附着」,不是堆出第二个 tab。
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 上」的直觉。
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 dropTabToEdge 的 siblingGroupDir === 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。
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)setAgentSubscriptions(ids[])C.2看板预览:还没开 tab 就已经在直播
看板卡片上的 Activity chip 单击会进右侧预览态,用注册表同一个 Component 渲染这个会话(和 tab 是同内容双挂载、各自 DOM、共享同 id 的 store)。关键设计:预览的订阅会被并进上面那个 active 并集,所以预览一打开就开始 streaming,哪怕还没开成 tab。预览本身不持久化,reload 回到任务详情。
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——提升逻辑集中在一处,而不是散在每种内容里。
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 渲染器的用法。@incremark 的 render() 是「reset + append + finalize」一次性操作,对一个还在流的消息用它,会在第一个 delta 就 finalize 整段流,之后每个 append 全被丢弃——气泡冻在第一个 token,直到 reload 才重渲完整文本。
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-provider 的 setAgentSubscriptions 并集(是否塌回了单选) |
| 预览里的会话不收流 | host/operations.ts previewInDetail + 引擎 subscriptions/active 的并集是否含 preview |
| 新会话发了首条后还卡在草稿、重试又建一个 | SessionView 的提升时序(先 onCreated 再 startTurn)+ promoteContentTarget |
| assistant 气泡只显示头一两个字 | MarkdownPart.tsx:首块必须 append 不 render |
审批卡显示 [object Object] | ApprovalTool.tsx 的 formatDetailValue(对象走 JSON) |
9旅程 D:工作台不再是一片空白
这条是用户最直接的诉求:打开工作台不该什么都没有。两件东西回应它——一个按任务分组的左导航器,和一个指向导航器的空态提示。
导航器列出项目的全部任务(按看板列序拍平),每个任务下把它的可打开 Activity(会话 + 终端)列成行,点击 openInWorkbench 去开或聚焦。聚合靠一个走注册表、逐 kind 调 activity.useList 的 hook——新注册一种 Activity 自动出现,无需改导航器。
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 开/关时精确重渲染。
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 打开」就退回提示,指向导航器。
// 既无 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 开关 | WorkbenchNavRow 的 useWorkbenchStore(() => 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.sql 加 message.user 约束,Drizzle 按 hash 记账不重放,旧库静默炸在 turn 中途。用户拍板 pre-release 阶段删库重建即可。 |
11心智模型补丁
board.tsx 本地 useState)。
右栏内容由工作台引擎驱动:看板只调 previewInDetail / openInWorkbench,渲染什么由注册表解析。
TerminalPanel,要看 connection-registry + connection-sync。kill 或自己 exit 才死,死了才发 gone 翻墓碑。
ContentKind 注册 + 一个组件 + 一行加进 register-content-kinds;引擎不认识具体 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)。
有兜底的
- 布局树全部操作(
operations.test.ts):分屏/合并/移动/关闭、lone-tab 同轴折叠特例、深度上限、空 anchor 折叠回归。 - 持久化复活(
revive.test.ts):丢未知 kind、丢isRevivable否掉的 target、坏分支只塌一支、重复 tab id 只落第一处。 - 终端连接(
connection-registry/-sync.test):共享/最后引用才 dispose、gone 后换新 socket、切后台 surface 不断流。 - 终端面板(
TerminalPanel.test.tsx):gone 翻墓碑且保 tab、dispose 抛错被吞不炸 app、replay resize 应用、error 帧内联。 - daemon(
terminal-registry/-ws/-router/-service.test):scrollback 分段/裁剪、一终端一 viewer 驱逐、背压 pause/resume、删 task 级联杀、control 过程。 - 会话(
subscription-binding / task-deletion-cascade / MarkdownPart / ApprovalTool / SessionView.test):并集去重、陈旧快照不误删、边沿才关、流式只 append、对象 input 渲 JSON、首轮失败仍提升。 - 真机 E2E:开终端→分屏→关 tab→重 attach 回放→切 surface→坏 tab reload 优雅丢弃→删 task 墓碑,7 步纵切全绿。
薄冰
- 🔴终端 WebGL 渲染后端无门禁覆盖:真机 E2E 强制 pin 到 DOM 后端,WebGL 挂载/卸载路径(正是连炸两次的地方)至今没有自动化 smoke。改
TerminalPanel的渲染时序要格外当心。 - 🔴Node 22 CI 未实证:本机在 Node 23 全绿,CI 目标 Node 22 需 push 触发才能证(已 push 到
feat/workbench-engine-navigator,结果待看)。 - 🟠删 task 后孤儿终端 tab 持久化泄漏(backlog 头号):删 task 时终端是 tombstone 不关 tab,跨重启会滞留一排「重开会失败」的墓碑。是否让删 task 也级联关终端 tab 仍是待用户定夺的 UX 决策。
- 🟠实时 agent token 流不在门禁:没有确定性 fake provider,流式累加这条路径靠真机验(首帧 bug 正是从这漏的);间接由 binding/订阅单测覆盖。
- 🟡第二个 viewer 起步空白:daemon 只在 socket open 时回放一次 scrollback,挂到已开连接的第二个 panel 起步空白、只随新输出填充。已知延后项(需客户端回放缓存)。
- 🟡pane 内精确 tab reorder 是 no-op:同 pane center drop 当前不工作(指针代理给不出插入位),
WorkbenchView注释自承待 P6/P7。 - 🟡终端 tab 的 running badge 恒为 null:控制面 list 只带 liveness 不带 busy。失败/空会话不回收,多试会累积空 chip。
- ⚪WorkbenchView 整条 dnd 拖拽集成链路(光标还原、center no-op)无渲染级测试,靠 operations 单测间接覆盖 + 真机 CDP。
14验收提示
这些是读代码/验收时容易误判成缺陷、其实是有意设计的点,别被吓到:
- 工作台引擎本身没有「+加 tab」入口:
TabStrip不含「+」,开内容的真入口全在看板任务详情面板(+Terminal / +Session / 「…」菜单)和 Activity chip。这是有意的——内容从任务来,不从空 tab 条来。 - 面包屑的 Global 段「半生效」:点它只
switchSurface('global')不导航,因为 cockpit 路由还不存在,多数情况视图不变、只面包屑高亮变。占位,非 bug。 playground/整目录 24 文件被删:那是三平面原型脚手架,本 PR 落地生产 shell 后整体移除,配套router.ts从 playground 路由换成真实 workbench 路由。git 会显示一大片纯删除,是机械的。- dev-terminal harness 的
projectId: 'dev-harness'是占位:仅 dev 路由用,生产构建import.meta.env.DEV折成 false 整段被 Vite 丢弃。 - 一条
Viewport.syncScrollArea的dimensions报错是 dev-only console 噪音:StrictMode 丢弃挂载在 dispose 后排程的 scroll sync,异步、非致命、不进 error boundary,生产无 StrictMode 不触发。 - 同一面板两个开终端入口语义不同:「…」菜单的 Open terminal 直接开 tab 进工作台,「+ Terminal」按钮先落预览。是「预览优先」的有意分叉。
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.md 与 workbench-activity-e2e-findings-2026-06-26.md 两份随附文档,结论已内联。
诚实标注:.ai_docs/e2e 的两条脚本与 daemon 的 shell-env / 背压细节、以及 packages/api 的 zod schema 细节按地图归纳、未逐行精读;本报告聚焦四条主旅程的承重路径,没有逐文件覆盖全部 186 个文件,但每一块改动都至少在某个子代理的视野内、无死角。本报告是一次性理解辅助,不维护、不作为真相源。