feat/workbench-architecture:workbench 重做成「两个模型,一条投影,一组命令」

buffin · f13ec7c…e362cbd · 2026-07-27 · 自包含,读完即弃

27 commits
138 文件
+13,273 / −3,303
53% 是测试代码
类型:大型重构 + 交互

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支 feat/workbench-architecture、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一次理解辅助,只帮你重建脑内地图,不做 code review。

1TL;DR

这个 PR 把 desktop 的 workbench(分屏 Tab 工作区,类比 VS Code 的编辑器区)从一堆互相渗透的可变状态,重做成一条清晰的边界:领域模型(daemon/SQLite 持有的 Project→Task→Session/Terminal)和视图模型(renderer 持有的 Surface→Pane→Tab)之间,只允许「读投影单向流出、写操作显式命令流入」。

落到可观测的行为上,它同时做了三件事,外加一层交互返修:

动机来自设计文档 workbench-architecture-design.md(3 稿定稿)。这是作者自己的 PR、带完整设计文档,所以第 11 节「计划 vs 实现」值得细读——真正的认知缺口都在那些「计划这么写、实际那么做」的地方。

2变更地图

16,576 行改动里,53% 是测试——这是一次「先立契约、每条不变量配编译期或运行期断言」的重构,测试密度本身是它的一个特征,不是附赠。97% 的改动集中在 apps/desktop/src/renderer/ 一个目录,daemon、api、协议层零改动

features/workbench
5,855 行 · UI + 拖拽引擎
workspace/layout
2,633 行 · 布局树纯函数
workspace/host
1,625 行 · 命令层
invariants
1,173 行 · 收敛协调器
features/session
1,168 行 · draft + composer
workspace/registry
1,023 行 · 内容契约
features/terminal
496 行 · 双层死亡
commands/reveal
409 行 · 打开即导航
值得细读(设计重心)可以略过(机械/样板)
registry/types.ts 的两分类判别联合与 LifecycleVerdicthost/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 自带动作,谁都能直写

任意 feature
getState().openTab / closeTab …(9 个动作挂在 state 上)
layout store
关 tab
可能隐式停资源
daemon 资源
协调器
isPresent() 布尔:在 / 不在
关 tab

现在 · 唯一命令面 + 三态裁决

任意 feature
只读 store
bench.commands
BenchCommands
唯一写入口
纯数据 store
协调器
lifecycle() 三态:活 / 收 / 立碑
confirmed 读模型

左边三条虚线是这次要拆掉的三个毛病:动作方法散在 store 上导致「唯一入口」守不住;关 tab 的语义和「停资源」纠缠;用一个布尔判死区分不了「进程自己退了」和「task 被删了」这两种完全不同的处置。右边是它们各自的落点——注意协调器判死的依据从布局变成了 daemon 的 confirmed 读模型:布局再也不能反推「东西还在不在」。

4数据与状态先行

这一节只讲形状,不讲行为——先把新词汇的样子摆出来,后面旅程用到时就不必停下来解释。四个新东西:一个内容目标的判别联合、内容契约的两分类、一个三态裁决、以及 pane 上多出来的两个指针。

4.1ContentTarget:一个 tab 装的是什么

每个 tab 持有一个可序列化的 ContentTarget——引擎从不拆看某个具体分支,只整体存储、整体透传。这一轮往联合里加了三种:session-draft(正在组合、还没发首段的会话)、filediff。其中 draft 的锚点用一条链式联合表达,让「有 task 却没 project」这种非法组合在类型上根本拼不出来

runtime/workspace/registry/types.ts真实代码(节选)
// 草稿落定到哪一档目的地的链:什么都没选(从全局入口开始)→ 只选了 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。

runtime/workspace/registry/types.ts真实代码(节选,两个类别的差异字段)
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 行消失,但前者要把退出画面留在屏上、后者要把视图收走——一个布尔表达不了这个区别。

runtime/workspace/registry/types.ts真实代码(节选)
// 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 的占位组件

配套还有一个条件类型 TombstoneSlotR = never 的 kind 不许声明 Tombstone 组件,声明了死因词汇的 kind 则必须声明占位组件——「一个 kind 能立碑却没有占位组件可渲染」在类型层就被堵死。

4.4Pane 多出的两个指针

布局树整体变成深度只读(编译期禁止原地写,见 §5.3),并且每个 Pane 多了两个字段——它们是 pane 的状态,不是 tab 的标志,把 tab 挪到别的 pane 不会带走:

runtime/workspace/layout/types.ts真实代码(节选)
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——它还带一个「变换没改动就不发布」的空转短路:

runtime/workspace/host/operations.ts · 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 引发的同步重入」收敛到不动点,不靠栈递归:

① model 每次 apply增量 envelope / 整快照 / 重连 resync 三条路径都覆盖——正因 resync 有盲区才不订阅 envelope
② bench store 变化按「投影签名」去抖:只有可判定的 target 集合变了才跑,resize/focus/重排静默
③ board selection 变化selection 是内容的共平等 mount;它离开某 target 时补报 owner-state 释放

为什么不订阅 envelope 而是每次 apply 都全量重判?源码注释给了理由,值得记住:一次「断线期间发生的删除」不会作为 delete envelope 到达——重连时它被重放成一次整快照采纳。所以订阅 envelope 有 resync 盲区,而「拿 confirmed 真相去比对当前 UI 状态」能同时覆盖增量、快照、resync 三条路径。

ready gate:两个订阅在首快照落地前都沉默。首快照前 confirmed 视图是空的,每个复活出来的 tab 都会被误判成孤儿——所以协调器先等 firstSnapshotApplied,再跑第一遍全量校准。

5.3身份(keyOf)与签名(signature)为什么要分家

这是全 PR 最容易被误解的一处设计。keyOf 和「变更侦测签名」回答的是两个不同问题,所以不共用一个函数

keyOf · 打开去重身份
encodeContentKey('session-draft', draftId)
回答「是哪块内容」——同 key 即同一份,命中即聚焦
可以少覆盖字段:draft 的 key 只含 draftId,锚点不进 key
contentTargetSignature · 变更侦测
整个 target 逐字段 key 排序后序列化
回答「target 变没变」——任一字段动了都算
覆盖全字段:锚点变化零 envelope 也当场触发重判
runtime/workspace/registry/registry.ts真实代码(两个函数并列)
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,这个改动会被吞掉,草稿就会带着旧锚点错活着。

加一个新 content kind 的标准步骤(引擎零改动):① 往 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。

全景 · 涉及 4 个文件
发起
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 并排):

runtime/workspace/host/operations.ts · open真实代码
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。

runtime/commands/reveal.ts · handler真实代码
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.surfaceIdopen 实际落到哪),不是 target.projectId。这样当 global 监控台把一个 project 内容开到 global surface 时,导航会跟着重定向过去,而不是傻傻切到 project surface。

A.3预览位:浏览一棵树,只留一个 tab

预览位是 pane 层的语义,不是 tab 的属性:Tab 结构不动,Pane 多一个 previewTabId 指针。当你以 mode: 'preview' 打开(比如单击文件树节点)时,如果槽是空的就新建一个 tab 认领它;如果槽已有 tab,就就地把那个 tab 的 target 改写成新内容——TabId 和条位都不变:

runtime/workspace/layout/operations.ts · openPreviewTab真实代码
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
同一份内容开出了两个 tabhost/operations.ts · opendedupeScopeOffindTabByKey 的 scope;或对应 kind 的 keyOf 是否漏了身份字段
预览一个文件,上一个文件的滚动/状态串进来了ContentHost.tsx · RegisteredContentInstancekey={registration.keyOf(target)} 那道内容身份边界
预览位一直在,双击/编辑都不转常驻layout/operations.ts · promotePreviewTab;脏驱动的提升在 host/operations.ts · reportDirty

7旅程 B:关一个 tab

关闭以前是「同步一步、直接改 store」。现在它是一条可 await、可否决、可并发合流的异步流程,而且关掉之后焦点落回哪里也有讲究。这条旅程还揭示本 PR 最重要的一条产品不变量:关 tab 永不停资源。

全景 · 一次用户关闭
X / 中键 / 菜单
TabStrip.tsx
requestClose 单飞
host/operations.ts
仅 dirty 才问 confirmClose
consentToClose
forceClose
收视图 + 释放 owner state
焦点落点
layout · showNextTab

B.1组件侧极薄:一行 fire-and-forget

先看用户入口。TabStrip 里以前那套 closing state、closingRefawait confirmClose 全删了,close 收成一行——X、中键、右键菜单、kind 自带的 TabAction 全走它:

features/workbench/components/TabStrip.tsx真实代码(节选)
function close(): void { void commands.requestClose(surfaceId, tabId) }

所有分支——单飞、确认、并发保护、焦点回退——都搬进了命令层。这是「写路径收口」的直接红利:组件不再各自实现一遍关闭逻辑,也就不会各自实现错。

B.2命令层:单飞 + 只对脏内容发问

requestClose 本身只做单飞记账;真正的「问不问、关不关」在 closeWithConsent

runtime/workspace/host/operations.ts真实代码(三段拼合)
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 的临时状态:

runtime/workspace/host/operations.ts · forceClose真实代码
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 里:

runtime/workspace/layout/operations.ts · 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 走的是 forceClosecloseTab,它只动布局树,从头到尾没有任何一行去停 daemon 侧的资源。session 的 daemon 会话照跑,terminal 的 PTY 照跑——你随时能从 activity 列表(卡片/详情/navigator)把它找回来重开。这正是 §4.2 里「activity 类必须声明 activity 发现面」的用意:发现面是运行中资源的保底,是「关 tab 不危险」的信任来源。「停止资源」是 activity/详情面板里一个显式的领域动作,永远不由 tab 上的 X 承担。

排查路标 · 旅程 B
症状从哪下手
关了个 tab,后台资源跟着没了不该发生——查是不是有入口绕过 requestClose 直接调了领域停止动作;forceClose 只碰布局
快速双击 X 弹了两次确认host/operations.ts · requestCloseclosing map 的单飞是否命中
确认框还开着,tab 已被收走,之后重复关了一次closeWithConsent:await 后那道 keyOfTarget 再校验
关掉 tab 后焦点跳到了奇怪的地方layout/operations.ts · showNextTab(opener→MRU→邻居);opener 记录在 openerByTab
clean 的 tab 也弹了确认consentToClosedirtyTabs.has(tabId) 的判断,以及 dirty 记账 reportDirty

8旅程 C:内容死在你眼前

「东西没了,tab 怎么办」是这次架构最难的一块。走通这条你会明白:为什么需要三态而不是两态、协调器怎么区分「谁见过它活着」、以及一个终端退出为什么要拆成两层来渲染。

全景 · 一次 PTY 退出
daemon:descriptor 消失
confirmed 读模型
kind 判死
terminal-registration · lifecycle
协调器收敛
coordinator · collectFindings
tab 灰化 / 换占位
TabLifecycleState

C.1kind 判死:descriptor 缺失 ≠ 一种死法

每个 kind 自己声明生死规则,只读 confirmed(不可逆清理绝不读混了 optimistic overlay 的 app.data)。terminal 的判定最能体现三态的必要——一个 descriptor 行是「期望态」,它消失可能是「PTY 自己退了」也可能是「task 把它连锅端了」,靠 owning task 区分:

features/terminal/terminal-registration.ts · lifecycle真实代码
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(借来的、下次浏览会被改写)也不立碑:

runtime/invariants/coordinator.ts · collectFindings真实代码(节选)
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 复活:

runtime/invariants/coordinator.ts · reconcileOnce真实代码(节选 + 原注释)
// 关闭先于重发 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:

features/terminal/TerminalPanel.tsx真实代码(节选)
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 会闪一下占位盖住本该留住的屏:

features/workbench/components/ContentHost.tsx真实代码(节选)
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 })
}
诚实边界:退出画面只活在仍挂载的 xterm 实例里。panel 一旦卸载(切 surface、关 tab),画面即失,回来看到的是占位而非屏幕内容。这是设计明示接受的——终端快照协议(能跨挂载恢复退出屏)是后续「资源优化轮」的事,本轮无恢复能力,会话结束即弃。🟡 别把这当缺陷(详见 §15)。
排查路标 · 旅程 C
症状从哪下手
PTY 退了,tab 却直接消失(本该留退出现场)terminal-registration.ts · lifecycleterminal === undefined 那支返回的是 tombstone 还是 close
task 删了/归档了,tab 却还挂着一个死终端同上——task 缺失/archived 分支应返回 close;协调器 collectFindings 的 close 分支
重启后冒出一个空的墓碑占位 tabcoordinator.tsseenAlive 记账——没见过 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(一种确认机制)在这里没用:你需要的是所有权机制——让状态活得比视图久。

以前 · 组件持有
文本 = composer 组件的 useState('')
inactive 卸载 / 切面 / 切路由
不经过任何 close 管线,文本静默蒸发
现在 · session runtime 持有
文本 = SessionRuntimeState.composerText
视图卸载只释放视图,store 由 registry 按 sessionId 持有
重挂即恢复;只随 session 本身死亡才 reap

D.2文本搬进 session runtime slot

做法是把未发文本挂进 session 已有的 runtime store——它本就有一套「confirmed 不再保活才 sweep」的回收,文本搭这趟车即可:

features/session/api/session-runtime-store.ts真实代码(节选 + 原注释)
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 重渲。

由此 activity 类不需要 confirmClose:「关 tab 只关视图、无损」从此由所有权机制保证——文本在 session runtime 里,关 tab 再重开还在。这正是 §4.2 里 activity 类 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 拖拽落点
pointerdown
tab-drag.tsx
移过 8px 激活
measureDropZones 一次量全场
每帧命中测试
drop-geometry · resolveDrop
松手:唯一一次 store 写
commands.move / dropToEdge

E.1落点几何:纯函数,拒绝区不亮

「拖到这里会发生什么」被抽成一组不碰 DOM 的纯函数(可脱离浏览器单测)。每帧的命中测试入口是 resolveDrop:strip 命中优先于 body,body 内按允许的边过滤后算落点,树会拒绝的区域直接返回 null(不亮热区、松手也不动):

features/workbench/components/drop-geometry.ts · resolveDrop真实代码
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,而且只在落点解析变化时才重绘指示器——指针挪但落点没变的大量帧被跳过:

features/workbench/components/tab-drag.tsx · paint真实代码
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 只在松手那一刻写一次:

features/workbench/components/tab-drag.tsx · commit真实代码
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,且只在边界真动过时提交:

features/workbench/components/SplitContainer.tsx真实代码(节选)
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 亮起的提示条」。

这一条是硬纪律不是优化建议:违反任何一条,拖动体验必卡。测试直接对它下断言——模拟拖动全程断言 store 零写入(订阅 spy)、drop 一次提交;resize 拖动帧断言 pane 内容零重渲染。改这块代码时,先看对应测试再动手。
排查路标 · 旅程 E
症状从哪下手
拖动卡顿 / 掉帧tab-drag.tsx · paint(是否每帧写 store 或 getBoundingClientRect);SplitContainer.tsx · onUp(是否拖动帧就提交)
拖到某条边亮了热区,松手却没反应drop-geometry.ts · resolveDropintents[edge] === 'none';分类器 layout/operations.ts · dropIntent
同 pane 内拖着换序换不动drop-geometry.ts · stripInsertion(index 计算,被拖 tab 自身排除)
resize 时鼠标滑到按钮上光标变了SplitContainer.tsx:注入的 data-resize-cursor-lock 规则是否在整个手势期存在
轻点一下分隔线布局就变了onUpsizes[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,下面这些假设需要就地替换。

任何 feature 拿到 App 就能 bench.store.getState().openTab(...) 直接改布局。 store 是纯数据,只能读;一切写只经 bench.commands,内容创建只有 open 一个入口。
结构命令(split/move/dropToEdge)只搬已有 TabId,类型上无法凭空造内容。
关一个 session/terminal 的 tab,可能把后台资源也停了。 关 tab 永远只收视图,资源照跑,从 activity 列表可原样找回;「停资源」是详情面板里另一个显式动作。
「东西还在不在」看它有没有挂着 tab。 存活的唯一依据是 daemon 的 confirmed 读模型;布局永远不反推领域存在性。协调器判死只读 confirmed,绝不读 optimistic overlay。
一个内容「在不在」是个布尔。 是三态裁决:alive / close(收视图)/ tombstone(留 tab 换占位)。PTY 自退与 task 删除都表现为 descriptor 消失,但处置相反。
session 草稿是一个带 draft: 前缀假 id 的 session。 草稿是独立的 session-draft kind(viewer 类);session kind 只含真实服务端会话,isDraftSessionId 特判已全部消失。
输入框没发的字是组件 state,切面就没。 未发文本住在 session runtime slot,视图卸载只释放视图,重开还在,只随 session 死亡才 reap。
去重身份 keyOf 和变更侦测是一回事。 分家:keyOf 只答「哪块内容」(可少字段),contentTargetSignature 答「target 变没变」(全字段)。锚点本地变化零 envelope 也当场触发重判。
拖拽/resize 期间照常走 React 状态更新。 拖动热路径零 store 写、零每帧重渲:Overlay 走 ref transform,命中测试用缓存 rect,store 只在 drop 提交一次。这是硬纪律,有测试盯着。

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 / promotepane 里可复用的「预览」tab 槽,下次预览打开就地改写它;双击/拖动/变脏/常驻打开则「提升」为常驻。
recentTabIds / opener关 tab 落点用:opener = 从哪个 tab 打开来的(transient,不持久化),recentTabIds = pane 内最近激活顺序。
owner state / releaseOwnerStateviewer 存在 App-owned store 里的临时态(行内评论、未发文本),最后一个 mount(含 board 选中)离场时销毁。
DropIntent / dropIntent'split'|'stack'|'none',布局分类器对「拖某 tab 到某 pane 某边会怎样」的裁决;none = 树会拒绝,热区不亮。
freeze(终端冻结)PTY 死后面板层保留挂着的 xterm 最后一屏、停输入、打 data-terminal-frozen 标记;区别于引擎层的 tombstone 占位。

14测试与风险地图

纯事实陈述,不是评判。这是一次测试密度极高(53%)的改动,多数不变量都配了编译期或运行期断言。

有测试撑着的行为

薄冰区(重要逻辑,当前无生产触发或无直接单测)

合入前值得确认的一点:阶段一架构审查那一轮 Codex 腿不健康,是本轮唯一接近「单腿覆盖」的审查缺口(阶段二起已要求对阶段一类型地基顺带复查,其余七条审查腿健康)。类型地基是全 PR 的承重墙,合入前可对 registry/types.ts 的两分类契约再扫一眼。

15验收提示

下面这些「看起来像半成品」的东西是有意为之,别当缺陷报上来。

16覆盖声明

本报告基于对分支 feat/workbench-architecturef13ec7c…e362cbd,138 文件)的全量阅读:6 路并行深读覆盖布局引擎、命令/host/shell、协调器+注册表、workbench UI 组件、session/terminal features、以及设计文档矿工,无采样。报告正文里每一段代码都由编排者亲自二次打开对应文件、手工裁剪(非子读者转述)——覆盖 registry/types.tshost/operations.tsinvariants/coordinator.tscommands/reveal.tsregistry/registry.tsterminal-registration.tssession-runtime-store.tsdrop-geometry.tstab-drag.tsxSplitContainer.tsxContentHost.tsxTerminalPanel.tsxlayout/operations.ts 及设计文档全文。未逐条核对 i18n 三语言翻译(只确认了新增 key 集合),未展开 board/tasks feature 侧的机械改名细节。