feat/workbench-architecture:workbench 重做成「两个模型,一条投影,一组命令」
buffin · f13ec7c…e362cbd · 2026-07-27 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支 feat/workbench-architecture、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一次理解辅助,只帮你重建脑内地图,不做 code review。
1TL;DR
这个 PR 把 desktop 的 workbench(分屏 Tab 工作区,类比 VS Code 的编辑器区)从一堆互相渗透的可变状态,重做成一条清晰的边界:领域模型(daemon/SQLite 持有的 Project→Task→Session/Terminal)和视图模型(renderer 持有的 Surface→Pane→Tab)之间,只允许「读投影单向流出、写操作显式命令流入」。
落到可观测的行为上,它同时做了三件事,外加一层交互返修:
- 写路径收口:布局 store 从「自带 9 个动作方法、任何 feature 都能
getState().openTab()直写」变成一坨纯数据,所有写都必须走唯一的命令面BenchCommands。「内容只能由open创建」从一句口号变成类型上绕不过去的事实。 - 关 tab 不再动资源:关一个 session/terminal tab 只收走视图,后台进程照跑;「东西还在不在」由一套独立的生命周期裁决(三态
alive/close/tombstone)说了算,而不是「tab 挂没挂着」。 - 临时状态有了归宿:你在输入框里没发出去的字,从组件
useState(切个面就没)搬进了 session 的 runtime store,活得和 session 一样久。 - 交互返修:拖拽 4px 就触发、分隔线 1px 命中、resize 每帧写 store 卡顿——这些全部重写,换成一套自研的、拖动全程不经过 React 的指针引擎。
动机来自设计文档 workbench-architecture-design.md(3 稿定稿)。这是作者自己的 PR、带完整设计文档,所以第 11 节「计划 vs 实现」值得细读——真正的认知缺口都在那些「计划这么写、实际那么做」的地方。
2变更地图
16,576 行改动里,53% 是测试——这是一次「先立契约、每条不变量配编译期或运行期断言」的重构,测试密度本身是它的一个特征,不是附赠。97% 的改动集中在 apps/desktop/src/renderer/ 一个目录,daemon、api、协议层零改动。
| 值得细读(设计重心) | 可以略过(机械/样板) |
|---|---|
registry/types.ts 的两分类判别联合与 LifecycleVerdict;host/operations.ts 的 open/close 全链路;invariants/coordinator.ts 的三触发单飞;drop-geometry.ts 的落点几何;tab-drag.tsx 的每帧绘制纪律。 |
几十处 getState().xxx() → bench.commands.xxx 的批量改名;board 选择术语 'activity' → 'content';registration 结尾 as unknown as AnyContentKind 的统一擦除;i18n 三语言各 +20 个 key;深 import 换 barrel。 |
下面的顺序是刻意的:先给你新的类型词汇(§4),再讲两个模型之间的接缝怎么搭(§5),然后五条旅程带你沿真实调用链走一遍(§6–§10)。没读过这块代码的同事,从 §3 的一张对比图开始最省力。
3架构一图流
一句话看懂这次改动改了什么通道:写入方向被收窄成一条,判存活的依据从「布局」换成了「确认状态」。
以前 · 布局 store 自带动作,谁都能直写
现在 · 唯一命令面 + 三态裁决
左边三条虚线是这次要拆掉的三个毛病:动作方法散在 store 上导致「唯一入口」守不住;关 tab 的语义和「停资源」纠缠;用一个布尔判死区分不了「进程自己退了」和「task 被删了」这两种完全不同的处置。右边是它们各自的落点——注意协调器判死的依据从布局变成了 daemon 的 confirmed 读模型:布局再也不能反推「东西还在不在」。
4数据与状态先行
这一节只讲形状,不讲行为——先把新词汇的样子摆出来,后面旅程用到时就不必停下来解释。四个新东西:一个内容目标的判别联合、内容契约的两分类、一个三态裁决、以及 pane 上多出来的两个指针。
4.1ContentTarget:一个 tab 装的是什么
每个 tab 持有一个可序列化的 ContentTarget——引擎从不拆看某个具体分支,只整体存储、整体透传。这一轮往联合里加了三种:session-draft(正在组合、还没发首段的会话)、file、diff。其中 draft 的锚点用一条链式联合表达,让「有 task 却没 project」这种非法组合在类型上根本拼不出来:
// 草稿落定到哪一档目的地的链:什么都没选(从全局入口开始)→ 只选了 project → 选到了某个 task。
// 用联合而非两个可选字段,是为了让「有 task 无 project」这一档不可构造,而不只是「不推荐」。
export type DraftAnchor =
| { projectId?: never; taskId?: never }
| { projectId: string; taskId?: never }
| { projectId: string; taskId: string }
export type ContentTarget =
| { kind: 'session'; projectId: string; taskId: string; sessionId: string } // sessionId 现在永远是服务端 id
| { kind: 'terminal'; projectId: string; taskId: string; terminalId: string }
| ({ kind: 'session-draft'; draftId: string } & DraftAnchor) // draft 升格为独立 kind
| { kind: 'file'; projectId: string; taskId: string; path: string } // explorer 轮落地,本轮只落形状
| { kind: 'diff'; projectId: string; taskId: string } // diff 轮落地,本轮只落形状
一个派生类型贯穿全 PR:TaskScopedTarget = Extract<ContentTarget, { taskId: string }>——除了「还没选 task 的草稿」,其余全部成员天然携带 taskId。activity 列表、task 详情、锚点推断都只收它,不必各自再判一次「这个 target 有没有 taskId」。
4.2ContentKind 一分为二:类别机械决定语义
内容契约从一个扁平接口拆成两类,类别不是标签,是开关——它机械地决定 activity 资格、关闭语义、多视图政策。自相矛盾的注册(activity 却没发现面、viewer 却声明了 activity)在类型层拼不出来,在注册期也会 throw。
interface ActivityContentKind<K, R> extends ContentKindBase<K, R> {
class: 'activity'
activity: ActivityCapability<K> // 必填:运行中资源的保底发现面(卡片/详情/navigator)
openPolicy?: never // 去重固定 surface 级,不可声明
useDirty?: never // 视图背后是常驻资源,没有「未存」概念
confirmClose?: never // 关 tab 从不销毁东西,无需确认
}
interface ViewerContentKind<K, R> extends ContentKindBase<K, R> {
class: 'viewer'
activity?: never // 禁止:没有后台资源可再发现
openPolicy: { dedupe: 'surface' | 'pane'; supportsPreview?: boolean }
useDirty?(target): boolean // 读自己的 owner store,是否有未存临时态
confirmClose?(target): Promise<boolean> // 只能与 useDirty 同时声明(构造期校验)
releaseOwnerState?(target): void // 最后一个 mount 离场时销毁临时态
}
怎么记:activity = 背后有独立于视图在跑的资源(session、terminal),关 tab 只关视图、资源照跑、activity 列表保证你还能找回它——这是「关 tab 不危险」的信任来源。viewer = 只是「看某个东西的一种方式」(session-draft、file、diff),关掉即彻底消失,它拥有的临时状态(行内评论、未发文本)住在 App 的 owner store 里,靠 dirty 面拿到关闭确认。
4.3LifecycleVerdict:三态取代布尔判死
旧的 isPresent(): boolean 换成 lifecycle(): LifecycleVerdict。为什么需要三态而不是两态:PTY 自己退出和 task 被删除,在 daemon 侧都表现为 descriptor 行消失,但前者要把退出画面留在屏上、后者要把视图收走——一个布尔表达不了这个区别。
// R 是该 kind 私有的「死因词汇」,引擎从不读,只回传给 kind 自己的 Tombstone 占位组件——
// 所以不存在一张引擎级的「所有死法」枚举。缺省 never,使 tombstone 分支根本构造不出来:
// 一个没声明死因词汇的 kind,想返回 tombstone 都返回不了。
export type LifecycleVerdict<R extends JsonValue = never> =
| { state: 'alive' } // 活着,正常渲染
| { state: 'close' } // 收走视图
| { state: 'tombstone'; reason: R } // 留 tab,内容区换成 kind 的占位组件
配套还有一个条件类型 TombstoneSlot:R = never 的 kind 不许声明 Tombstone 组件,声明了死因词汇的 kind 则必须声明占位组件——「一个 kind 能立碑却没有占位组件可渲染」在类型层就被堵死。
4.4Pane 多出的两个指针
布局树整体变成深度只读(编译期禁止原地写,见 §5.3),并且每个 Pane 多了两个字段——它们是 pane 的状态,不是 tab 的标志,把 tab 挪到别的 pane 不会带走:
interface Pane {
readonly id: PaneId
readonly tabIds: readonly TabId[]
readonly activeTabId: TabId | null
readonly previewTabId: TabId | null // 预览槽:浏览手势复用的那一个 tab(§6/§jA-3)
readonly recentTabIds: readonly TabId[] // 最近激活顺序:关 tab 后落点用(§jB)
}
另有一个独立的第二 store TabLifecycleState { tombstoned: Record<TabId, TabTombstone> }:只由协调器写、不持久化(重启后为空),三个消费者读它——TabStrip 把 tab 灰化、ContentHost 换占位组件、terminal runtime 释放 socket。它和布局 store 分开,是因为 tombstone 是「上一轮判定的产物」,不该混进布局真相。
5底座:两个模型之间的接缝
五条旅程都会踩到同一组底座机制。先把它们讲透,旅程里就只需引用。三个东西:写路径怎么收口、协调器怎么被触发、身份和签名为什么要分开。
5.1写路径:store 卸下动作,命令层用一个 update 收口
「打开的唯一入口是 open」这条纪律,要守得住,前提是绕行路径在类型上不存在。以前 9 个动作方法挂在 zustand state 上,getState().openTab(...) 对任何 feature 可达。现在 store 只剩 { bySurface } 纯数据,所有写经过命令层一个私有 update——它还带一个「变换没改动就不发布」的空转短路:
function update(surface, transform: (layout: SurfaceLayout) => SurfaceLayout): void {
const { bySurface } = store.getState()
const current = bySurface[surface]
if (!current) return // 未被 open 播种的 surface:no-op
const next = transform(current)
// 布局纯函数在「树里没有可改的东西」时会原样返回入参(stale pane、非预览槽的 tab);
// 为这种情况发布一份新 bucket,会把每个订阅者叫醒却什么也没得看
if (next === current) return
store.setState({ bySurface: { ...bySurface, [surface]: next } })
}
这条 next === current 短路依赖布局纯函数的一个契约:无变化必须返回原对象引用。命令层多处(activate、focusPane、split、reportDirty)靠它避免多余的 persist 与收敛。UI 组件从此只拿到两样东西:store(只读)和 commands(写);forceClose 这类原语级写只对协调器和 host 内部可见(挂在 BenchOwnerCommands 上)。
5.2协调器:三个触发源,汇入一个单飞循环
createAppInvariantCoordinator 是唯一的 reconciler,不新建第二个。它被三个源触发,全都汇入同一个 reconcile()——后者用 running/dirty 循环把「关 tab 引发的同步重入」收敛到不动点,不靠栈递归:
为什么不订阅 envelope 而是每次 apply 都全量重判?源码注释给了理由,值得记住:一次「断线期间发生的删除」不会作为 delete envelope 到达——重连时它被重放成一次整快照采纳。所以订阅 envelope 有 resync 盲区,而「拿 confirmed 真相去比对当前 UI 状态」能同时覆盖增量、快照、resync 三条路径。
firstSnapshotApplied,再跑第一遍全量校准。5.3身份(keyOf)与签名(signature)为什么要分家
这是全 PR 最容易被误解的一处设计。keyOf 和「变更侦测签名」回答的是两个不同问题,所以不共用一个函数:
encodeContentKey('session-draft', draftId)export function encodeContentKey(kind: string, ...parts: string[]): string {
return JSON.stringify([kind, ...parts]) // JSON 元组:转义各段 + 带上 kind 命名空间
} // 手搓 `file:${root}:${path}` 会在含冒号的 path 上碰撞并聚焦到错的内容
export function contentTargetSignature(target: ContentTarget): string {
const fields = Object.entries(target)
fields.sort(([a], [b]) => (a < b ? -1 : 1)) // 字段名排序,抹平构造顺序差异
return JSON.stringify(Object.fromEntries(fields)) // 覆盖全字段:创建页改草稿锚点这种本地 retarget 会被它捕获
}
后果很具体:在创建页里给一个草稿选定 task,它的 keyOf(只含 draftId)不变,但 signature 变了——于是协调器当场按新锚点重判这个草稿的生死,shell 的焦点投影也当场重发。要是变更侦测走的是 keyOf,这个改动会被吞掉,草稿就会带着旧锚点错活着。
ContentTarget 联合加一个 variant;② 写一份 registration:class + Component + keyOf(用 encodeContentKey)+ parseTarget(用 decodeTargetFields 或自写解码器)+ lifecycle;③ activity 类补 activity 发现面,viewer 类补 openPolicy。注册期 assertRegistrationContract 会把类型层的约束再运行时复核一遍——因为 contribution 入表时经 as unknown as 擦了类型,光靠编译器守不住。6旅程 A:打开一块内容
从「用户在 board 上双击一个 session」到「它在 workbench 里出现在眼前」。走通这条,你会知道去重、落位、导航三件事分别归谁,以及「预览位」这个新机制怎么复用一个 tab。
board / navigator→ reveal 命令
commands/reveal.ts→ open:去重 + 落位
host/operations.ts→ 纯数据 store→ 导航到 surface
reveal.ts
A.1open 干三件事:按 kind 的政策去重、落位、绝不导航
先把场景摆清楚:一份内容可能已经开着(要聚焦而非再开一个),也可能没开(要落到某个 pane)。open 的顺序是先解析落点 pane,再按这个 kind 声明的去重范围去找已有 tab——activity 类和 surface 级 viewer 扫整个 surface,pane 级 viewer 只扫目标 pane(好让同一个文件在两个 pane 并排):
open(target, opts = {}) {
const key = keyOfTarget(registry, target)
if (key === null) return null // 未注册的 kind:无从 key,也就无从落位
const surfaceId = opts.surfaceId ?? surfaceOfTarget(target)
// surface 在「有东西被开到它上面」的那一刻才诞生,所以缺席的 surface 就地播种一个空的
const layout = store.getState().bySurface[surfaceId] ?? createEmptySurfaceLayout()
const pane = resolveOpenPane(layout, opts.paneId) // opts.paneId → focused → 第一个,全无则抛错
const existing = findTabByKey(
registry, layout, key,
dedupeScopeOf(registry.get(target.kind)) === 'pane' ? pane : undefined, // pane 级只扫这一个 pane
)
const preview = opts.mode === 'preview' && supportsPreview(target.kind) // 不支持槽的 kind 降级为常驻
if (existing) return focusExisting(surfaceId, existing, preview, opts.opener)
return placeFresh(surfaceId, layout, target, pane, preview, opts)
}
命中已有 tab 时走 focusExisting;没命中走 placeFresh。最后一行注释里那句「never navigates」是关键:open 只返回落点,从不碰路由。把内容放到某 surface 上是「落位」,而当前渲染哪个 surface 是「路由」——两件事分开,谁需要两样一起(board 双击这种非 bench 视图的入口)就走下一跳的 reveal。
A.2reveal:按「你人在哪」决定要不要导航
问题在于:光 open 一下,可能激活了一个当前根本没渲染的 surface 上的 tab——用户看不见。所以非 bench 视图里的打开一律走 content.reveal:它先 open,再看你现在的 place 是不是已经就是那块 bench。
commands.register(revealContent, async ({ target }) => {
const placed = ports.open(target)
if (placed === null) throw new UnknownContentKindError(target.kind) // 未知 kind:open 什么都没放,直接报错
const place = ports.currentPlace()
// 检查的是整个 place,不只是 context:同一个 project 的 board 和 bench 是两个不同视图,
// 所以在 project 自己的 board 上 reveal 它的内容,surface 相同也要切到 bench
if (place?.view === 'bench' && surfaceOfContext(place.context) === placed.surfaceId) return
await ports.navigate(contextOfSurface(placed.surfaceId)) // 按 open 实际落到的 surface 导航,跟随 global 覆盖
})
这里有个易错点:导航目的地取自 placed.surfaceId(open 实际落到哪),不是 target.projectId。这样当 global 监控台把一个 project 内容开到 global surface 时,导航会跟着重定向过去,而不是傻傻切到 project surface。
A.3预览位:浏览一棵树,只留一个 tab
预览位是 pane 层的语义,不是 tab 的属性:Tab 结构不动,Pane 多一个 previewTabId 指针。当你以 mode: 'preview' 打开(比如单击文件树节点)时,如果槽是空的就新建一个 tab 认领它;如果槽已有 tab,就就地把那个 tab 的 target 改写成新内容——TabId 和条位都不变:
export function openPreviewTab(layout, target, requestedPane?) {
const pane = resolveOpenPane(layout, requestedPane)
const slot = pane.previewTabId
if (slot === null || !pane.tabIds.includes(slot)) { // 无槽:走普通 openTab 再认领为槽
const opened = openTab(layout, target, pane.id)
const withSlot = updatePane(opened.layout, pane.id, (next) => ({ ...next, previewTabId: opened.tabId }))
return { layout: withSlot, tabId: opened.tabId, paneId: pane.id }
}
const root = replacePane(layout.root, pane.id, withActiveTab(pane, slot))
return { layout: { ...layout, root, focusedPaneId: pane.id,
tabs: { ...layout.tabs, [slot]: { id: slot, target } } }, // 就地改写槽内 target,条位不变
tabId: slot, paneId: pane.id }
}
「就地改写不丢东西」靠一个不变量撑着:预览位的 tab 恒不 dirty。一旦内容变脏(比如给文件加了行内评论),它立刻被提升出槽变常驻(见 §jB 的 reportDirty),于是槽里永远只装「无临时状态、可以被安全覆盖」的视图。双击标题、拖动它、以它分屏、或再次以常驻模式打开——任一发生也提升。
还有一层保护在 ContentHost:预览位改写复用了 TabId(布局身份不变),但内容子树必须按内容身份重挂,否则旧文件的 state、滚动、在途请求会漏进新文件。做法是在组件外包一层以 keyOf 为 React key 的边界:<ContentInstance key={registration.keyOf(target)}>——file A 改写成 file B 时整树卸载重建,A 的晚到响应写不进 B。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 点了打开,激活了却看不见(切不到对的面) | commands/reveal.ts:导航条件那行,是不是把 placed.surfaceId 错当成 target 的 project |
| 同一份内容开出了两个 tab | host/operations.ts · open:dedupeScopeOf 与 findTabByKey 的 scope;或对应 kind 的 keyOf 是否漏了身份字段 |
| 预览一个文件,上一个文件的滚动/状态串进来了 | ContentHost.tsx · RegisteredContentInstance:key={registration.keyOf(target)} 那道内容身份边界 |
| 预览位一直在,双击/编辑都不转常驻 | layout/operations.ts · promotePreviewTab;脏驱动的提升在 host/operations.ts · reportDirty |
7旅程 B:关一个 tab
关闭以前是「同步一步、直接改 store」。现在它是一条可 await、可否决、可并发合流的异步流程,而且关掉之后焦点落回哪里也有讲究。这条旅程还揭示本 PR 最重要的一条产品不变量:关 tab 永不停资源。
TabStrip.tsx→ requestClose 单飞
host/operations.ts→ 仅 dirty 才问 confirmClose
consentToClose→ forceClose
收视图 + 释放 owner state→ 焦点落点
layout · showNextTab
B.1组件侧极薄:一行 fire-and-forget
先看用户入口。TabStrip 里以前那套 closing state、closingRef、await confirmClose 全删了,close 收成一行——X、中键、右键菜单、kind 自带的 TabAction 全走它:
function close(): void { void commands.requestClose(surfaceId, tabId) }
所有分支——单飞、确认、并发保护、焦点回退——都搬进了命令层。这是「写路径收口」的直接红利:组件不再各自实现一遍关闭逻辑,也就不会各自实现错。
B.2命令层:单飞 + 只对脏内容发问
requestClose 本身只做单飞记账;真正的「问不问、关不关」在 closeWithConsent:
requestClose(surface, tabId) {
const pending = closing.get(tabId)
if (pending) return pending // 第二次按合流到同一个 Promise,不会弹两个确认框
const request = closeWithConsent(surface, tabId).finally(() => closing.delete(tabId))
closing.set(tabId, request)
return request
},
async function consentToClose(target, tabId): Promise<boolean> {
// 只有脏 tab 才值得一问——对没未存内容的东西弹确认,是个没有问题的问题
const confirm = dirtyTabs.has(tabId) && registry.get(target.kind)?.class === 'viewer'
? registry.get(target.kind).confirmClose : undefined
if (!confirm) return true // clean tab 或 activity 类:默许
try { return await confirm(target) } catch { return false } // 抛错当否决,不外泄
}
async function closeWithConsent(surface, tabId) {
const target = store.getState().bySurface[surface]?.tabs[tabId]?.target
if (!target) return
if (!(await consentToClose(target, tabId))) return
// 问的这段时间里布局可能变了——收敛把 tab 收走了,或 promote 把它改写成了别的内容
const held = store.getState().bySurface[surface]?.tabs[tabId]?.target
if (!held || keyOfTarget(registry, held) !== keyOfTarget(registry, target)) return // 答复作废,什么都不关
commands.forceClose(surface, tabId)
}
注意最后那道再校验:异步确认框还开着的时候,协调器可能已经把这个 tab 收走、或 promote 把它改写到了真实资源上。等确认返回时,它同意的是「这个 tab 当时装的那份内容」,如果 tab 现在装的是别的,这次同意就作废——不会误关。
B.3forceClose:收视图、送焦点回来处、销毁 owner state
forceClose 是不问内容层的最终执行(用户确认通过后,或协调器直接调用)。它顺手做两件容易被忽略的事——把焦点送回 opener,以及在「最后一个 mount 离场」时销毁 viewer 的临时状态:
forceClose(surface, tabId) {
const target = store.getState().bySurface[surface]?.tabs[tabId]?.target // 关之前读出来,好在关掉后还能点名这份内容
const opener = commands.openerOf(surface, tabId) // 这个 tab 是从哪个 tab 打开来的
update(surface, (layout) => closeTab(layout, tabId, opener ?? undefined)) // 焦点优先落回来处
openerByTab.delete(tabId)
dirtyTabs.delete(tabId)
if (target !== undefined) commands.releaseUnmountedOwnerState(target) // 若这是最后一个 mount,销毁临时态
}
关闭后 pane 该显示谁,落点顺序是「你从哪来 → 关之前在看什么 → 滑进空位的邻居」,全在布局纯函数 showNextTab 里:
function showNextTab(pane, preferred, freed): Pane {
const held = new Set(pane.tabIds)
const next =
(preferred !== undefined && held.has(preferred) ? preferred : undefined) ?? // ① opener(successor)
pane.recentTabIds.find((id) => held.has(id)) ?? // ② 最近激活过的(MRU)
pane.tabIds[freed] ?? // ③ 滑进空位的邻居
pane.tabIds.at(-1)
return next === undefined ? { ...pane, activeTabId: null } : withActiveTab(pane, next)
}
opener 是一份 renderer 内存里的 transient 映射(tabId → openerTabId),由 open 的显式 opener 选项在发起时捕获——不建模为持久化字段,它是导航历史不是布局结构,重启丢失是正确语义。
B.4为什么关 tab 不停资源
把上面串起来看:关一个 session tab 走的是 forceClose → closeTab,它只动布局树,从头到尾没有任何一行去停 daemon 侧的资源。session 的 daemon 会话照跑,terminal 的 PTY 照跑——你随时能从 activity 列表(卡片/详情/navigator)把它找回来重开。这正是 §4.2 里「activity 类必须声明 activity 发现面」的用意:发现面是运行中资源的保底,是「关 tab 不危险」的信任来源。「停止资源」是 activity/详情面板里一个显式的领域动作,永远不由 tab 上的 X 承担。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 关了个 tab,后台资源跟着没了 | 不该发生——查是不是有入口绕过 requestClose 直接调了领域停止动作;forceClose 只碰布局 |
| 快速双击 X 弹了两次确认 | host/operations.ts · requestClose:closing map 的单飞是否命中 |
| 确认框还开着,tab 已被收走,之后重复关了一次 | closeWithConsent:await 后那道 keyOfTarget 再校验 |
| 关掉 tab 后焦点跳到了奇怪的地方 | layout/operations.ts · showNextTab(opener→MRU→邻居);opener 记录在 openerByTab |
| clean 的 tab 也弹了确认 | consentToClose:dirtyTabs.has(tabId) 的判断,以及 dirty 记账 reportDirty |
8旅程 C:内容死在你眼前
「东西没了,tab 怎么办」是这次架构最难的一块。走通这条你会明白:为什么需要三态而不是两态、协调器怎么区分「谁见过它活着」、以及一个终端退出为什么要拆成两层来渲染。
confirmed 读模型→ kind 判死
terminal-registration · lifecycle→ 协调器收敛
coordinator · collectFindings→ tab 灰化 / 换占位
TabLifecycleState
C.1kind 判死:descriptor 缺失 ≠ 一种死法
每个 kind 自己声明生死规则,只读 confirmed(不可逆清理绝不读混了 optimistic overlay 的 app.data)。terminal 的判定最能体现三态的必要——一个 descriptor 行是「期望态」,它消失可能是「PTY 自己退了」也可能是「task 把它连锅端了」,靠 owning task 区分:
function lifecycle(target, confirmed): LifecycleVerdict<'exited'> {
const terminal = confirmed.getTerminal(target.terminalId)
if (terminal !== undefined && terminal.taskId !== target.taskId) return { state: 'close' } // 行还在时行是权威:锚点不符即收
const task = confirmed.getTask(target.taskId)
if (task === undefined || task.archivedAt !== null) return { state: 'close' } // task 没了/归档 → task 端掉了它 → 收
if (task.projectId !== target.projectId) return { state: 'close' }
return terminal === undefined ? { state: 'tombstone', reason: 'exited' } : { state: 'alive' }
// 行没了但 task 健在 → PTY 自己退的 → 留退出现场(tombstone)
}
注意判定顺序里的锚点校验:row 存在时,row 才是「这个 terminal 归哪个 task」的真相,一个 target 谎报了别的 task 会先被收掉。等到 row 没了、无链可查,才退而信任 target 自己的 task 锚点——而此时若它撒过谎,早在 row 还在时就被收走了。session 的 lifecycle 结构相同,但永不 tombstone:transcript 在服务端,结束的会话从 activity 重开即可,没有「退出屏」要留。
C.2协调器:只给「见过它活着」的 tab 立碑
协调器一次 pass 扫所有 tab,把裁决翻译成动作。tombstone 是一块给「有人在场目睹的退出」立的碑——所以它只发给本会话至少见过一次 alive 的 tab;预览槽里的 tab(借来的、下次浏览会被改写)也不立碑:
const verdict = judge(tab.target)
if (verdict.state === 'alive') { findings.seenAlive.add(tab.id); continue }
// tombstone 只给这个协调器亲眼看着死的内容;一个复活出来就已经是死的(旧持久化残留)
// 没有退出现场可展示,按普通缺席关掉。预览槽里的 tab 也不留碑,直接收。
if (verdict.state === 'tombstone' && seenAlive.has(tab.id) && !previewed.has(tab.id)) {
findings.seenAlive.add(tab.id)
findings.tombstoned[tab.id] = { reason: verdict.reason }
continue
}
findings.closingTabs.push({ surface: surface as SurfaceId, tabId: tab.id })
// 内核唯一故意的 kind 耦合:只有 session 自己也死了,才释放它的 timeline cache——
// 一个「锚点坏了但 session 活得好好的」tab 被判 close,不该毁掉其它正确 mount 还在用的 composer 文本
if (tab.target.kind === 'session' && !isSessionActive(tab.target.sessionId)) {
findings.closingSessionRuntimes.add(tab.target.sessionId)
}
seenAlive 是模块级的一个 Set<TabId>,每 pass 重建、只保留布局仍持有的 id——关掉的 tab 历史随之消失,后开的 tab 绝不继承前任的存活史。由此重启后一个已死的 target 会被直接 close 而不是复活成一个空占位(那纯属噪音)。
C.3一个执行顺序陷阱:先 close,再发布 tombstone 表
收敛一次 pass 的动作次序不是随意的。forceClose 必须在重新发布 tombstone 表之前跑完——否则一个正从 tombstone 跨到 close 的 tab(墓碑下的 task 又被归档了)会引发一次幽灵 socket 复活:
// 关闭先于重发 tombstone 表:一个跨 tombstone→close 的 tab 必须在表停止点名它之前离开布局,
// 否则 terminal runtime(为每个「已布局、非 tombstone」的 terminal tab 持一个 socket)会目睹
// 这个仍开着的 tab 脱掉 tombstone、朝死 pty 重开一个 socket、一个通知后又释放它。
for (const { surface, tabId } of findings.closingTabs) bench.commands.forceClose(surface, tabId)
publishTombstones(findings.tombstoned)
这类「谁先谁后」的顺序约束,正是把所有收敛塞进一个 reconciler 的价值:跨 tab、跨 socket 的时序只有一个地方定义,不会有两个 reconciler 互相打架。
C.4终端退出的双层渲染:面板冻结 + 引擎占位
terminal 的死亡在 UI 上是两层,各管一段。面板层收到 gone 帧时不换占位、不卸载 xterm,而是「冻结」——关键是这个冻结不能经过一次 render,因为挂着的 xterm 实例是退出画面唯一的holder:
let ptyGone = false // 闭包旗标而非 state:冻结不能经过 render,挂着的 xterm(退出屏的唯一持有者)必须原地不动
const inputDisposable = terminal.onData((data) => {
if (!ptyGone) connection.write(data) // 死 pty 上打字被吞,免得冻结屏暗示 prompt 还在听
})
const unsubscribeControl = connection.onControl((frame) => {
if (frame.type === 'gone') {
ptyGone = true
terminal.options.cursorBlink = false // 光标停闪,不再暗示存活
setFrozen(true) // 仅为给 host 打一个 data-terminal-frozen 属性
}
...
})
引擎层则在需要重挂载时(切面回来、或 inactive 卸载后回来,此时那块屏已经丢了)渲染 kind 的 Tombstone 占位组件、并把 tab 灰化。这个「最后一帧还在不在」的判定在 ContentHost 里,用 render-time 派生 state 而非 effect——因为答案必须在裁决出现的那一次 render 就定,用 effect 会闪一下占位盖住本该留住的屏:
const [screenState, setScreenState] = useState(() => ({ tombstoned, lastScreen: false }))
let { lastScreen } = screenState
if (tombstoned !== screenState.tombstoned) {
lastScreen = tombstoned && shouldMount // 进入 tombstone:只闩住此刻已挂载的那个实例
setScreenState({ tombstoned, lastScreen })
} else if (lastScreen && !shouldMount) {
lastScreen = false // 一旦卸载,最后一帧永久失去——重挂不能冒充「看着它死的那个实例」
setScreenState({ tombstoned, lastScreen })
}
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| PTY 退了,tab 却直接消失(本该留退出现场) | terminal-registration.ts · lifecycle:terminal === undefined 那支返回的是 tombstone 还是 close |
| task 删了/归档了,tab 却还挂着一个死终端 | 同上——task 缺失/archived 分支应返回 close;协调器 collectFindings 的 close 分支 |
| 重启后冒出一个空的墓碑占位 tab | coordinator.ts:seenAlive 记账——没见过 alive 的应按 close,不立碑 |
| tombstone 的 terminal,socket/引用没释放 | terminals/runtime.ts · collectTerminalIds:owner 集合已从「布局里存在」改成「非 tombstone」 |
| 切回来终端一片空白而非退出内容 | 预期内的诚实边界(快照协议前无恢复);确认走的是 TerminalTombstone 占位分支 |
9旅程 D:你没发出去的那段话
这是一条短旅程,但它是本 PR 里最容易被用户直接感知的行为变化。以前你在输入框打了半段话、切个面回来就没了;现在它还在。走通它你会理解「所有权机制」和「确认机制」解决的是两个不同问题。
D.1为什么确认框救不了它
先看清死法。未发送文本以前住在 composer 组件的 useState 里。而内容卸载有好几条路径——inactive 两分钟定时卸载、切 surface、切路由——这些路径全都不经过任何 close 管线,组件 useState 里的东西直接蒸发。所以 boolean dirty(一种确认机制)在这里没用:你需要的是所有权机制——让状态活得比视图久。
useState('')SessionRuntimeState.composerTextD.2文本搬进 session runtime slot
做法是把未发文本挂进 session 已有的 runtime store——它本就有一套「confirmed 不再保活才 sweep」的回收,文本搭这趟车即可:
export interface SessionRuntimeState extends SessionState {
isHydrating: boolean
// 未发送的 composer 文本。归这里持有而非 composer 组件,是因为视图消失的那几条路——
// inactive 卸载、关 tab、离开 surface——都不经过任何能问一句的 close 管线,组件 state 会静默
// 丢掉你打的字。它的生命等同 session:重开 session 的 tab 能找回文本,只有 session 自己死亡
// (或重启,本轮不持久化)才清空。
composerText: string
}
还有一处细节值得留意:session 的 timeline 在重连时会被清空重建(resync),但 ingest 的重建只重写 reduced 那一片,composerText 穿越 resync 不受影响——用源码的话说,「重连不是你正在打的字的生命中的一个事件」。读取端 useSessionComposerText 只订阅这一个槽,所以打字不会唤醒整个 transcript 重渲。
confirmClose?: never 的底气。(draft 侧的未发文本仍走组件 useState、卸载即丢,那是有意的——见 §15。)排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 切个面回来,输入框里的字没了 | session-runtime-store.ts:文本是否真在 composerText slot;读取走 use-session-store.ts · useSessionComposerText |
| 重连一次,正在打的字被清了 | session-runtime-store.ts · ingest:resync 重建应只写 reduced slice,不碰 composerText |
| 打字很卡 / 整个 transcript 跟着重渲 | useSessionComposerText 是否只订阅 composerText 单槽(SessionTimelineState 按包含定义,把 view-slot 挡在订阅面外) |
| session 都归档了,文本还占着内存 | 协调器 sessions.reconcile(isSessionActive);释放走 registry sweep |
10旅程 E:把 tab 拖走 / 拖分隔线
交互不是架构的附属品——「拖动很奇怪」的直接原因(4px 就触发、无 Overlay、同 pane 排序被当 no-op、1px 分隔线即命中、resize 每帧写 store)全是交互层缺口。这条旅程走一次拖拽落点和一次 resize,重点是它们共守的一条硬纪律:拖动热路径零 store 写、零每帧 React 重渲。
tab-drag.tsx→ 移过 8px 激活
measureDropZones 一次量全场→ 每帧命中测试
drop-geometry · resolveDrop→ 松手:唯一一次 store 写
commands.move / dropToEdge
E.1落点几何:纯函数,拒绝区不亮
「拖到这里会发生什么」被抽成一组不碰 DOM 的纯函数(可脱离浏览器单测)。每帧的命中测试入口是 resolveDrop:strip 命中优先于 body,body 内按允许的边过滤后算落点,树会拒绝的区域直接返回 null(不亮热区、松手也不动):
export function resolveDrop(zones, x, y, draggedTabId): DropResolution | null {
const zone = zones.find((z) => contains(z.rect, x, y))
if (!zone) return null
if (contains(zone.strip, x, y)) return stripInsertion(zone, x, draggedTabId) // strip 胜过 body:精确插入
const allowed = {
left: zone.intents.left !== 'none', right: zone.intents.right !== 'none',
top: zone.intents.top !== 'none', bottom: zone.intents.bottom !== 'none',
}
const edge = edgeForPoint(pointToPaneRelative(x, y, zone.rect), allowed)
if (zone.intents[edge] === 'none') return null // 拒绝区:不解析,也就不亮、不落
return { kind: 'zone', paneId: zone.paneId, edge }
}
每个 pane 的「四边分别会 split/stack/none」是拖拽开始那一刻就从布局分类器 dropIntent 算好、缓存进 PaneDropZone.intents 的——所以「树会拒绝的手势永远不会亮成可落靶」。contains 对远边半开,相邻 pane 不抢共享边界像素;corner 平手按 left/right/top/bottom 顺序严格 < 做确定性 tie-break。
E.2每帧不走 React:ref 上写 transform,落点变了才重绘
拖动全程的视觉更新走 rAF 帧里的一个 paint():直接改 DOM 的 transform,而且只在落点解析变化时才重绘指示器——指针挪但落点没变的大量帧被跳过:
const paint = (): void => {
if (!active) return
const { overlay, highlight, line } = elements.current
if (overlay) overlay.style.transform =
`translate3d(${active.last.x - active.grab.x}px, ${active.last.y - active.grab.y}px, 0)` // 直接写 DOM,不经 zustand
const resolution = resolveDrop(active.zones, active.last.x, active.last.y, tabId)
if (sameResolution(resolution, active.painted)) return // 落点没变就跳过 DOM 写
if (!highlight || !line) return
active.painted = resolution
paintIndicators(resolution, active.zones, highlight, line)
}
拖拽期整个 surface 的内容区被冻结(pointerEvents: 'none',顺带解决 WebView 吞事件),命中测试用激活时缓存的 rect 集(measureDropZones 一次量全场),绝不逐帧 getBoundingClientRect。store 只在松手那一刻写一次:
const commit = (resolution: DropResolution | null): void => {
const { commands } = bench
if (resolution === null) {
commands.promotePreview(surfaceId, tabId) // 松在无目标:不移动,但拖过一个预览位 tab 本身就是提升它的承诺
return
}
if (resolution.kind === 'strip') { commands.move(surfaceId, tabId, resolution.paneId, resolution.index); return }
commands.dropToEdge(surfaceId, tabId, resolution.paneId, resolution.edge) // 整场拖拽唯一一次 store 写
}
激活距离从旧的 4px 提到 8px(4px 太敏感,单击易误判为拖拽)。松手还会 swallowNextClick() 吞掉 release 合成的那次 click——那次 click 属于拖拽,不该触发 tab 的激活 handler。三种取消源(pointercancel、本 surface 布局换引用、window resize)都走 stop,不写不提交。
E.3Resize:CSS 变量写宽度,一条注入规则锁光标
Resize 是连续几何输入,和 tab 拖拽(离散意图选择)是两套机制、分开实现。拖动帧把宽度写进容器 ref 上的 CSS 变量(pane 内容组件全程零重渲),松手才提交一次 store,且只在边界真动过时提交:
onUp(upEvent) {
lastPointer = row ? upEvent.clientX : upEvent.clientY
const sizes = moved ? currentSizes() : null
lastPressMovedRef.current = moved
stop() // stop 里先把 CSS 变量 snap 回已提交值
if (sizes && sizes[index] !== startSizes[index]) { // 只有边界真动过才写——手滑点一下不发布
bench.commands.resize(surfaceId, group.id, sizes)
}
},
一个容易被忽略的细节:拖动期锁光标用的是往 <head> 注入一条 * { cursor: col-resize !important } 规则,不是给 body 设内联 cursor。原因很实在——内联 cursor 只对继承者生效,指针一旦冲出分隔线、越过 surface 边到达一个声明了自己 cursor 的按钮或输入框,就会被抢走;一条 !important 规则在整个手势期间压过一切声明。分隔线本身也从「1px 线可拖」变成「1px 视觉线 + ±4px 隐形命中区(约 9px 宽)+ 延迟 200ms 亮起的提示条」。
排查路标 · 旅程 E
| 症状 | 从哪下手 |
|---|---|
| 拖动卡顿 / 掉帧 | tab-drag.tsx · paint(是否每帧写 store 或 getBoundingClientRect);SplitContainer.tsx · onUp(是否拖动帧就提交) |
| 拖到某条边亮了热区,松手却没反应 | drop-geometry.ts · resolveDrop 的 intents[edge] === 'none';分类器 layout/operations.ts · dropIntent |
| 同 pane 内拖着换序换不动 | drop-geometry.ts · stripInsertion(index 计算,被拖 tab 自身排除) |
| resize 时鼠标滑到按钮上光标变了 | SplitContainer.tsx:注入的 data-resize-cursor-lock 规则是否在整个手势期存在 |
| 轻点一下分隔线布局就变了 | onUp 里 sizes[index] !== startSizes[index] 的「真动过才提交」判断 |
11计划 vs 实现的偏差
这是作者自己的 PR、有完整设计文档,所以这一节最值钱——真正的认知缺口,都在「设计这么写、实现那么做」的地方。下面每条是实质偏差,从 runlog 各阶段「实现偏差」段、review 剔除/推翻记录、commit message 里挖出。
| 设计/计划怎么写 | 实际做成什么 | 为什么变 |
|---|---|---|
isPresent 布尔换 lifecycle verdict,暗示存在 LifecycleVerdict<ReasonOf<K>> 这样一张 kind→reason 中心映射 |
改为在 ContentKind<K, R = never> 上加第二个类型参数,由 kind 自己在注册处声明(terminal 即 ContentKind<'terminal','exited'>) |
与决策台账「引擎不维护跨 kind 死法枚举」一致——不建中心表,死因词汇是 kind 局部的 |
阶段一保留 isPresent 原样,不留 verdict/Tombstone 类型占位 |
严格照做,直到阶段 3-4 才整体替换 | 「留一个类型有声明、引擎不消费的槽位」比缺席更糟——过渡态会撒谎 |
| 协调器的草稿存活判定「改看 session-draft tab/selection 引用」 | 把协调器的草稿概念整体删除(isSessionReferenced、注入的 isDraftSessionId 依赖、装配全删) |
抽取后草稿不再持 sessionId,没有任何 runtime 以 draft 为键,「按引用判存活」在新世界没有判定对象 |
| 放弃 dnd-kit、自研 pointer DnD(§10.5 的零每帧重渲与 dnd-kit 的 context 模型不相容) | 整体替换为自研 pointer 控制器;@dnd-kit/core 依赖保留(board/sidebar 仍用),删除 drag-ids.ts |
性能纪律是硬约束;dnd-kit 的 context 驱动每帧过 React,做不到 |
reviveSurface 传入 isKnownKind/isRevivable 两个闭包 |
直接传 ContentRegistry |
同一条 tab 上四个问题(解码/去重/复活/身份)都出自同一次 registry.get,四闭包更绕更易漂移 |
阶段二 OpenOptions 形状字面写了 mode? |
2-2 实现不加 mode,推迟到 3-1 预览位整体落地时 | 此刻接受一个 open() 会忽略的 mode,就是「签名撒谎」;arch review 曾按缺陷提,被判为对账问题剔除 |
ActivityContentKind 靠 excess-property 检查兜住,不加 ?: never |
阶段一交叉审查推翻,补上 openPolicy?: never/useDirty?: never/confirmClose?: never 三个显式否定 |
审查轮之间的自我修正——显式否定比隐式兜底更能守住类别边界 |
retarget 与 promote 合用(草稿改锚点走 promoteContentTarget) |
阶段二交叉审查从「retarget 分支已无生产调用者、三个测试靠它假装 retarget」的角度推翻,把 retarget 拆成独立命令 |
拆出后 retarget 零生产消费者——是为「创建页改锚点」预留的扩展点(见 §15) |
1-2 只点名 surfaceOfTarget/focusTaskId 两个身份收窄消费面 |
实际另有 6+ 处编译不通过的真实消费面必须一并收窄(reveal、ActivityListing.target、board 选中、bench 详情、协调器 owning-task 兜底) |
projectId/taskId 变非全量必填后的连锁——类型收窄把隐藏依赖逼了出来 |
MIN_CHILD_FRACTION 分隔线最小占比 |
从旧内联 0.05 上调到 0.15,并从组件内联移到 runtime/workspace 导出,per-frame 与 commit 共用一个常量 |
终轮二交叉审查以「旧版持久化升级后直接违反运行时不变量」重提,e362cbd 修复(此前轮一曾剔除) |
过程元信息:4 阶段串行 + 终轮全局 review,共 27 commit(15 实现 + 8 阶段 fix + 4 终轮 fix)。无人值守 workflow 编排,每阶段双视角审查(Claude ∥ Codex 双腿)→ 归并去假阳性 → 无上下文 agent 分诊修复;编排方在阶段间用真 Bash 复跑 bun run check && bun run verify 验收才进下一阶段。Claude 全程实现,Codex 只读审。
12心智模型补丁
如果你脑子里还装着改动前的 workbench,下面这些假设需要就地替换。
bench.store.getState().openTab(...) 直接改布局。
store 是纯数据,只能读;一切写只经 bench.commands,内容创建只有 open 一个入口。
draft: 前缀假 id 的 session。
草稿是独立的 session-draft kind(viewer 类);session kind 只含真实服务端会话,isDraftSessionId 特判已全部消失。
keyOf 和变更侦测是一回事。
分家:keyOf 只答「哪块内容」(可少字段),contentTargetSignature 答「target 变没变」(全字段)。锚点本地变化零 envelope 也当场触发重判。
13新词表
只列本 PR 里新出现、且读代码时会挡路的词。
| 内容契约 | |
|---|---|
ContentKind 的 class | 'activity'|'viewer'。activity = 背后有独立运行的资源(session/terminal),关 tab 只关视图、必有发现面;viewer = 一种看法(file/diff/session-draft),关掉即弃,临时状态住 owner store。 |
LifecycleVerdict | 三态判死:alive / close(收视图)/ tombstone(留 tab 换占位)。取代旧布尔 isPresent。 |
tombstone / reason | 内容已从 confirmed 消失但 tab 留在原地、body 换成占位组件。reason 是 kind 私有词汇(terminal 只有 'exited'),引擎不读只回传。 |
keyOf vs contentTargetSignature | 前者是打开去重身份(可少字段),后者是变更侦测签名(覆盖全字段)。分家是本 PR 核心决策之一。 |
parseTarget / decodeTargetFields | 持久化句柄的运行时解码钩子,缺字段则拒绝复活;引擎从不 cast 未验证的 target 进树。 |
| 目标与锚点 | |
session-draft | 新的 viewer 类 kind,代表「正在组合、还没发首段」的会话。取代旧的 draft: 前缀假 session id。 |
DraftAnchor | 草稿三档可选锚(无 / project / project+task)的链式联合,让「有 task 无 project」类型上不可构造。不参与 draft 身份。 |
TaskScopedTarget / isTaskScoped | 锚在 task 之上的 target(除未选 task 的草稿外全部);task 级消费面统一收它。 |
| 布局与命令 | |
BenchCommands / BenchOwnerCommands | 布局的唯一写面;前者用户可及,后者多出 forceClose/reportDirty/openerOf/releaseUnmountedOwnerState 只给引擎 owner。 |
preview slot / previewTabId / promote | pane 里可复用的「预览」tab 槽,下次预览打开就地改写它;双击/拖动/变脏/常驻打开则「提升」为常驻。 |
recentTabIds / opener | 关 tab 落点用:opener = 从哪个 tab 打开来的(transient,不持久化),recentTabIds = pane 内最近激活顺序。 |
owner state / releaseOwnerState | viewer 存在 App-owned store 里的临时态(行内评论、未发文本),最后一个 mount(含 board 选中)离场时销毁。 |
DropIntent / dropIntent | 'split'|'stack'|'none',布局分类器对「拖某 tab 到某 pane 某边会怎样」的裁决;none = 树会拒绝,热区不亮。 |
freeze(终端冻结) | PTY 死后面板层保留挂着的 xterm 最后一屏、停输入、打 data-terminal-frozen 标记;区别于引擎层的 tombstone 占位。 |
14测试与风险地图
纯事实陈述,不是评判。这是一次测试密度极高(53%)的改动,多数不变量都配了编译期或运行期断言。
有测试撑着的行为
- 身份与打开:activity 类同 surface 重开只聚焦;global 与 project surface 并存不去重;board 上 reveal 同 project 内容会切 bench;含冒号/Unicode 的 path 无 key 碰撞;本地 retarget 改锚点、零 envelope → 判定与 focus 当场触发。
- 关闭与临时态:X/中键/批量/TabAction 全走
requestClose;非 dirty 不弹确认;confirm 悬置期被 forceClose 不重复操作;composer 文本活过视图卸载与关 tab、随 session 死亡 reap;声明confirmClose未声明useDirty的注册构造时 throw。 - lifecycle:首快照前不清理任何复活 tab;optimistic overlay 不触发不可逆关闭;PTY 自退 → tombstone 且可灰化,task 删/归档 → close;锚点与 row 不符的 target 被判 close;tombstone 后 tab 仍在、terminal refCount 归零;收敛级联到不动点不递归溢出。
- revive:未知 kind/坏 target 只丢对应 tab;pane 级 viewer 两 pane 并排复活不误删;结构损坏局部 prune;诊断经实例 sink 聚合一次提示且不含 host path。
- 交互性能:模拟拖动全程断言 store 零写入、drop 一次提交;resize 拖动帧断言 pane 内容零重渲染;深度只读契约用
@ts-expect-error变成编译期测试。
薄冰区(重要逻辑,当前无生产触发或无直接单测)
- 🟠草稿侧未发文本仍是组件
useState:session 侧文本已迁 owner store,但DraftTextComposer仍用useState、重挂不恢复(有测试断言现状)。draft 文本迁 owner store 归创建轮。 - 🟡「预览位 tab 不 tombstone、直接 close」无生产触发 kind:需要同时
supportsPreview且可 tombstone 的 kind,当前只有 kernel 测试替身覆盖。 - 🟡协调器「未注册 kind 判 alive」分支只有注释无测试:生产不可达(revive 丢未知 kind、open 拒未知 kind);若未来出现动态注销 kind 需补测。
- ⚪
floorSizes的「太挤 → 均分」分支(≥7 子 group)按二叉 split 不可达、无独立用例;resolveOpenPane无 pane 时的 throw 分支同样未见断言。 - ⚪strip 退化断点、15% resize 地板等视觉参数,dogfood 后可调。
registry/types.ts 的两分类契约再扫一眼。15验收提示
下面这些「看起来像半成品」的东西是有意为之,别当缺陷报上来。
file/diffkind 只有 target 形状、没有 registration:本轮只落联合里的 variant,让引擎接缝能对着类型实现;真正的 Component/lifecycle/parseTarget 归 explorer 轮与 diff 轮。所以你在 registry 里找不到 file/diff 的注册,是正常的。- 无锚草稿的整条路径走不到:
DraftAnchor的「无锚 / 只 project」两档、SessionDraftTabView里if (!isTaskScoped) return null——这些是为尚未实现的「创建页」预留,当前所有草稿都是 task-scoped 铸出的。 retarget命令零生产消费者:它已从 promote 拆出、有测试,但目前没有任何生产代码调它——它是为「创建页改草稿锚点」预留的扩展点。session-draft是 viewer 却没声明useDirty:所以「draft 里打了字没发就关 tab」当前不弹确认——draft 文本本就走组件 state、卸载即丢,是有意的;dirty 面接线归创建轮。- 终端退出后切回来是占位而非退出屏:快照协议(跨挂载恢复)是后续资源优化轮的事,本轮无恢复能力。此条在审查中被反复当 blocker 重报过 5 次、全部判伪——再报请先读设计 §8.2 的限定条款。
- draft tab 标题就是「Session」:与 summary 未加载的真 session tab 同名,尚未区分,属已知项非缺陷。
16覆盖声明
本报告基于对分支 feat/workbench-architecture(f13ec7c…e362cbd,138 文件)的全量阅读:6 路并行深读覆盖布局引擎、命令/host/shell、协调器+注册表、workbench UI 组件、session/terminal features、以及设计文档矿工,无采样。报告正文里每一段代码都由编排者亲自二次打开对应文件、手工裁剪(非子读者转述)——覆盖 registry/types.ts、host/operations.ts、invariants/coordinator.ts、commands/reveal.ts、registry/registry.ts、terminal-registration.ts、session-runtime-store.ts、drop-geometry.ts、tab-drag.tsx、SplitContainer.tsx、ContentHost.tsx、TerminalPanel.tsx、layout/operations.ts 及设计文档全文。未逐条核对 i18n 三语言翻译(只确认了新增 key 集合),未展开 board/tasks feature 侧的机械改名细节。