PR2 交互平面:给内核补上「端内交互」这一层

buffin(产品仓) · 68f53d1(#160 内核基线)…HEAD · feat/pr2-interaction-plane · 自包含,读完即弃

4 模块 · 18 实现 commit
312 文件
+15,750 / −4,646
~39% 是测试代码
基线 = React 解耦内核(PR1/#160)

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这份走读讲整体重构逻辑与前后差异,不逐条串某个 bug 怎么修。

1TL;DR

PR1 已经把 renderer 的内核 App 从 React 里剥出来——连接、复制、布局、session/terminal 运行时的所有权都归一个「无头」内核,React 只是薄消费者。但内核这时只有「数据」和「运行时」,缺三样端内交互能力:事件(一件事确认发生后广播给任意多观察者)、命令(多个发起方请求唯一处理方执行一个意图)、注册(「我提供一种视图/入口」的声明与查找),外加一个具体的状态缺口——「用户当前聚焦什么」这个纯端内状态根本不存在。

PR2 把这层补上,并顺手兑现了三处一直想做的收敛:把每个路由页面各自硬编码的侧栏,换成壳层拥有的左右双 Host + item 贡献机制;把「我在哪」从各路由的挂载副作用,收敛成由 URL 派生的唯一真相 activePlace;把远端删除从「静默关 tab、用户毫无解释」,接上一条 daemon 声明 → 信封广播 → renderer 桥接 → owner 事件 → toast 的语义通道。动机来自设计定稿(.ai_docs/design/2026-07-23-pr2-interaction-plane.md)与执行计划的 4 模块拆分。

2变更地图(称重)

先说清基线,否则数字会骗人。本分支的第一个 commit 是 68f53d1(PR #160「把内核 App 从 React 解耦」);而本地 main 还停在 PR3 数据平面(ec74db0),并不含 #160。所以「main...HEAD」这条 diff(424 文件 / 3.2 万行)把 #160 的内核骨架、以及后来经一次 merge main 带进来的 #161(xterm 6)、#162(写路由测试)、#159(会话控件与 assistant 链接)全裹了进去——那些是各自独立编号、单独审过的工作,不是交互平面的设计负载。

本报告一律以设计定稿指定的基线 68f53d1 称重:内核(runtime/ 目录)在这个基线上已存在,diff 只剩交互平面本身——312 文件、+15,750 / −4,646 行、约 39% 是测试。诚实地说:其中 features/session工具/配置 两块被上述「同车但正交」的特性(#159 会话控件、xterm 6 补丁、system 主题)明显撑大,理解交互平面架构时可略过它们。

features 视图
~6,360 行 · 含 #159 正交
runtime/ 内核
~5,000 行 · 设计重心
壳层组件+路由
~3,620 行 · 设计重心
工具/配置/文档
~2,190 行 · 多为守卫/补丁
lib(壳 store)
~940 行 · 设计
daemon+api+client
~800 行 · 域事件+删端点
model(复制层)
~610 行 · 设计
entities
~360 行 · 多为机械改名
设计重心(要细读)可放心略过(机械/正交)
runtime/shell/(place/focus/sidebar 内核)、runtime/commands/runtime/board/runtime/invariants/coordinator.tsruntime/tasks/tasks.ts(桥)、model/replication.ts + write-client.tspackages/api/model/domain-events.tscomponents/shell/sidebar/*routes/board.tsxmain.tsxfeatures/workbench/detail-item.tsx runtime/workspace/layout/*(逐字节从 features/workbench/ 搬来,git 识别为 rename);bun.lock@lobehub/icons 的 peer 树);packages/api/**/x.js 两行 shim;features/session/assistant-links/*、composer 重写(#159);xterm 6 补丁(#161);system 主题(正交);全树 app.workspace→app.benchuseClient().model.write→.write 等消费点机械改名
一句称重结论:约 4,600 行「删除」其实是 features/workbench/ 布局引擎搬去 runtime/workspace/ 的 rename,不是净删。真正承载新设计的是 runtime/ 内核那 ~5,000 行、壳层双 Host 那 ~3,600 行、和 model/api/daemon 的域事件通道那 ~1,400 行。

3架构一图流

一句话概括前后差异:以前每个功能各自拉线、靠 React 挂载时机把状态串起来;现在核心功能都从一个稳定的 app.XX 根主动读数据、发意图、注册能力。

以前 · 各路由自行拼装

路由页面
mount effect
switchSurface
布局 store
路由页面
各自挂载
ContentSidebar
board.tsx
三选一渲染
motion.aside 详情
daemon 删除
feed → 关 tab
(静默,无通知)

现在 · app.XX 为根

router 位置
place adapter
app.workspace
activePlace
壳层 Hosts
resolve / setActive
app.workspace
.sidebar 内核
发起方
execute(token)
app.commands
daemon 事务
domainEvent → 桥
app.tasks
.onDidDelete → toast

四个新增能力对应下文四条旅程:activePlace/focus(旅程 A)、sidebar contribution + 双 Host(旅程 B)、domain event → toast(旅程 C)、app.commands(旅程 D)。它们共享同一个底座:app 上一批「只读 + 只订阅」的面(第 5 章)。

4四概念与数据形状

整个交互平面立在四个边界很硬的概念上。它们各管一件事,越界即错——先把这张表装进脑子,后面每条旅程都在用它。

概念回答的问题接收方本轮的实例
State「现在是什么」——可直接读的当前真相谁都可读activePlacefocusapp.confirmed
Event「发生时告诉我」——已发生、0..N 观察者消费的事实0..N,爱听不听onDidChangeFocusapp.tasks.onDidDelete
Command「请把这事做了」——多发起方请求唯一处理方的意图恰好 1task.revealInWorkbench
Contribution「我提供一种能力」——注册后可同步确定查找同步查询content kind、sidebar item

四条铁律:①不通过 Event 请求别人做事(Event 是事实不是指令);②不通过 Command 查询持续状态(状态直接读 State);③不靠事件回放拼当前 UI 状态;④ Contribution 不是 Event——注册后必须能被同步、确定地枚举。还有一条补充规则:没有全局 app.events,事件全部 owner-scoped——onDidDeleteapp.tasks 名下、发布权只属对应 owner。

下面把本轮新增/变更的核心数据形状先摆出来(只看形状不讲行为,给后面旅程预载词汇)。

「我在哪」——判别联合,非法态不可构造

runtime/shell/controller.tsActivePlace / PlaceContext
export type PlaceContext =
  | { readonly kind: 'project'; readonly projectId: string }
  | { readonly kind: 'global' }

// 判别联合而非松散的 {view, context}:board 只能带 project context,bench 可带 project 或 global。
// 不存在的 board×global(没有跨项目看板)因此“无法构造”,而不是每个 reader 都要防御性拒绝的状态。
export type ActivePlace =
  | { readonly view: 'board'; readonly context: Extract<PlaceContext, { kind: 'project' }> }
  | { readonly view: 'bench'; readonly context: PlaceContext }
  | null   // 非工作路由(列表/新建/health/dev 工具/根重定向)没有 place

「我聚焦什么」——跨视图消费者读的唯一值

runtime/shell/focus.tsFocusSubject / SidebarContext
export type FocusSubject =
  | { readonly kind: 'content'; readonly target: ContentTarget }          // 一个具体 Activity 内容
  | { readonly kind: 'task'; readonly taskId: string; readonly projectId: string }
  | { readonly kind: 'project'; readonly projectId: string }
  | null

// sidebar item 的两个谓词读的就是这两件东西的组合,命名到一起让谓词只取一个入参。
export interface SidebarContext {
  readonly place: ActivePlace | null   // 你在哪;null = 非工作路由
  readonly focus: FocusSubject          // 你聚焦什么,从 place 投影
}

看板的选中主体 · 一种能贡献能力的注册项 · 一条业务事实 · 一个命令令牌

runtime/board/controller.ts · shell/sidebar/types.ts · api/model/domain-events.ts · runtime/commands/commands.ts四个形状
export type BoardSelection =                                // app.board.selection
  | { kind: 'task'; taskId: string } | { kind: 'activity'; target: ContentTarget } | null

export interface SidebarItemDefinition {                   // 侧栏 item 契约
  id: string; icon: IconName; title: string; section: 'contextual' | 'global'
  defaultDock: 'left' | 'right'; order: number
  isVisible: (place: ActivePlace | null) => boolean        // 结构轴:在不在 rail 上
  getEnablement: (ctx: SidebarContext) => SidebarItemEnablement  // 焦点轴:可不可用 + 原因
  Component: ComponentType<{ dock: 'left' | 'right' }>      // item 只拿 dock,其余自取 app.XX
}

export type DomainEvent =                                  // 首个成员:一次显式任务删除
  { type: 'task.deleted'; taskId: string; title: string; deletedTaskIds: string[] }

export interface CommandDescriptor<Args, Result> {         // 类型随令牌流动,无中央接口
  readonly id: string; readonly signature: (args: Args) => Result  // signature 是幽灵字段
}

5底座:App 面与对账器

四条旅程都从 app 上取东西。这个 app 不是一个大杂烩对象——本轮它被一次排正 + 一次收窄,形成一批命名清晰、能力受限的「面」。先讲清这个底座,旅程里就不必反复解释。

命名交叉(读代码前务必记住):物理目录 runtime/workspace/ 里的 WorkbenchController 对外叫 app.bench(布局引擎:分栏/tab/内容注册表/host 操作);而 runtime/shell/controller.ts 里那个WorkspaceController 对外才叫 app.workspace(壳层:place + focus + sidebar)。目录名 workspace/ 对应的是 app.bench,控制器名 WorkspaceController 对应的是 app.workspace——两处名字交叉,按目录名 grep 历史极易误判。

5.1App 面的所有权拆分:只读 + 只订阅 + owner 才能拆

问题:内核里每个作用域控制器都持有「写 store」和「dispose」的能力,但把整个 app 交给 React 视图后,任何一个视图理论上都能 raw-write 别人的 store、或把共享内核 dispose 掉。本轮的做法是给每个作用域拆成两层接口:消费者拿到的是「只读 + 只订阅」的窄面,owner(App 自己)才持有完整的写/拆能力。

最能说明这个手法的是命令面。消费者拿到的 Commands 只能 register/executedispose 整张表是 App 的活,藏在 CommandsRegistry 里,视图够不着。

runtime/commands/commands.ts消费者面 vs owner 面
export interface Commands {
  // 绑一个命令的唯一 handler,返回 IDisposable;重复 id 抛错(两 owner 抢一个命令是接线错误)。
  register<A, R>(descriptor: CommandDescriptor<A, R>, handler: (a: A) => R | Promise<R>): IDisposable
  // 始终返回 Promise:未注册→拒 CommandNotRegisteredError,handler 抛→rejection,永不同步抛。
  execute<A, R>(descriptor: CommandDescriptor<A, R>, args: A): Promise<R>
}
// owner 面多一个 dispose——藏在这里,所以持有 registry 的视图无法把所有 handler 中途拆掉。
export interface CommandsRegistry extends Commands, IDisposable {}

同一个「subscribe-only / owner-disposable」拆法,本轮铺满了每个面:Board vs BoardControllerWorkspace vs WorkspaceControllerTasks vs TasksController。它靠一个新的类型底座实现——把 zustand 的 StoreApi 去掉 setState

runtime/shell/controller.ts为什么 place 只能被 router 写
readonly place: ReadableStore<ActivePlaceState>   // ReadableStore = Omit<StoreApi, 'setState'>
// place 只经 setActivePlace 移动,它把切换与 board 作用域挂/清配对成原子。这里“扣住”setState,
// 是为了不让某个持有 App 的人在“旧 board selection 旁边”硬装一个 place——那正是原子迁移要防的。

更进一步:连「唯一能写 place 的」setActivePlace 都从消费者的 Workspace 面上摘出去了,另立一个专用端口 app.placeRouter,只给组合根的 router adapter 用(旅程 A)。理由很直白:place 有外部真相(URL),任何非 adapter 的写都是 bug。这一批收窄同时删掉了两处投机预留——一个没人声明的 ContentKind.Detail? 扩展点,和 BenchController 那个什么都不做的 dispose(详见第 10 章)。

5.2对账器:删除后「必须发生的协调」,按 kind 判存在

AppInvariantCoordinator 是「必须发生的客户端协调」的落点:每次信封应用 / snapshot 采纳后,拿确认态app.confirmed 点读,不是含乐观覆盖的 app.data)把 workbench tabs 与 board selection 收敛上去,关掉指向已删实体的孤儿 tab、清掉落空的 selection、释放不再活跃的 session runtime——全部幂等。它不订阅删除信封:断线期发生的删除不会以「删除信封」到达(重连时以整份 snapshot 重放),所以订阅信封有 resync 盲区,而「拿确认真相对现状」天然覆盖增量/snapshot/resync 三条路径。

本轮的关键改造是:判「在不在」按 content kind 经注册表分发,取代内核里按 kind 硬编码的分支;而做的动作(关 tab、清 selection、扫 runtime)保持通用。

runtime/invariants/coordinator.ts判定按 kind 分发,动作保持通用
// 一个 target 的内容还在不在,经注册表分发,判定保持 kind-agnostic:kind 自己的谓词衡量它的
// confirmed 行(session 看 draft/summary/task,terminal 看 descriptor),没声明谓词的回落“owning task
// 在且未归档”。workbench tab 与 board selection 用同一条路径判,注册的 kind 无需在此加分支。
function judgePresent(target: ContentTarget): boolean {
  const kind = bench.registry.get(target.kind)
  if (kind?.isPresent) return kind.isPresent(target, confirmed)     // ← 分发点
  const task = confirmed.getTask(target.taskId)
  return task !== undefined && task.archivedAt === null            // 缺省回落
}

这条分发让 session kind 的谓词吸收了过去内核硬编码的 draft-session 豁免(内核从此不认识 draft 概念),terminal kind 的谓词读 confirmed.getTerminal。这也是为什么本轮要把 confirmed 点读面公开为 app.confirmed——存在性判定按 kind 分发后,contribution 需要能读到确认真相。

新增一个 sidebar item / content kind 的标准步骤:① feature 导出一个工厂 (ports) => Definition(不是静态对象);② 组合根 main.tsx 把工厂列表作为 createAppRuntime() 的构造参数传入;③ App 构造时按固定顺序执行——服务先建 → 逐个工厂绑端口写入注册表 →然后才恢复持久化布局,「注册先于查询」由构造顺序在结构上保证;④ 重复 id 注册抛错,不静默覆盖。content kind、sidebar item、命令 handler 全走这一套。

6旅程 A:用户导航一下,「我在哪 / 聚焦什么」如何更新

这是整个交互平面的地基旅程。走通它,你就掌握了「place 从哪来、focus 怎么算、为什么中间没有错位窗口」。

全景 · 涉及 4 个文件
URL 变化
router
映射成 place
router-place-adapter.ts
原子迁移
shell/controller.ts
投影 focus
shell/focus.ts
onDidChangeFocus → 消费者

A.1route location 是唯一真相源,adapter 在导航「装载前」写 place

先说旧世界的病。「我在哪」以前没有一个统一状态;bench 视图靠布局 store 里的 activeSurfaceId,而它的对齐是每个路由组件在挂载后调一句 switchSurface。这留下一个错位窗口:route 内容的首帧已经画出来了,但 store 里的 surface 还是旧的——位于 route 外层的新 sidebar 宿主会读到「新 route + 旧 surface」的组合。冷启动 deep link 更糟:要等首帧之后才能对齐。

新世界把这件事翻过来:route location 是 place 的唯一真相源,组合根装一个 router adapter,在每次导航装载之前就把 place 写好,并在启动时先按当前 location seed 一次。

router-place-adapter.ts纯函数映射 + 装载前订阅
// 把 pathname 映射成它代表的 place,非工作路由返回 null。纯而全:adapter 对每次 location 变化调用它、
// 结果直接交给 setActivePlace,所以它必须只凭 pathname 分类每个注册路由,绝不把上一个 place 带过来。
export function placeFromPathname(pathname: string): ActivePlace | null {
  if (pathname === '/global') return { view: 'bench', context: { kind: 'global' } }
  const match = PROJECT_PLACE_ROUTE.exec(pathname)   // /^\/projects\/([^/]+)\/(board|workbench)\/?$/
  if (match === null) return null
  const context = { kind: 'project', projectId: decodeProjectId(match[1]) } as const
  return match[2] === 'workbench' ? { view: 'bench', context } : { view: 'board', context }
}

export function installRouterPlaceAdapter(router, placeRouter): () => void {
  const sync = (pathname) => placeRouter.setActivePlace(placeFromPathname(pathname))
  sync(router.state.location.pathname)                       // seed:让冷启动 deep link 在首帧前定好 place
  return router.subscribe('onBeforeLoad', (e) => sync(e.toLocation.pathname))  // 装载“前”写
}

A.2setActivePlace:place 切换 + board 作用域挂/清,压成一次原子迁移

place store 和 board 作用域是两个独立 store。若分两步写,观察者会看到「新 place 旁边挂着旧 board 的 selection」这个中间态——正是 §5.1 扣住 setState 要防的。setActivePlace 把两次写压进一个同步调用,末尾只重算一次投影。

runtime/shell/controller.ts一次同步调用完成两件事
setActivePlace(next) {
  if (placesEqual(place.getState().activePlace, next)) return   // 同 place no-op:同路径导航不 churn board
  place.setState({ activePlace: next })
  // board 一定带 project context(联合排除 board×global);进 board 会清掉旧 selection;
  // 其它去处(bench 或非工作路由 null)也清 selection,不让旧 subject 活过它所属的 place。
  if (next?.view === 'board') board.enterBoard(next.context.projectId)
  else board.clearSelection()
  recompute()   // place store 故意不被订阅,这一句才是把 place 变化转成“单次” focus 迁移的地方
}

这里有个刻意的设计:焦点投影只订阅 board.selection 与 bench 布局,故意不订阅 place store。因为一旦订阅 place,place 一变就会在「新 place / 还没清的旧 selection」的半态触发投影,把原子迁移刚关上的窗口又打开。所以 place 的变化由 setActivePlace 末尾这一句手动 recompute() 收口。

A.3统一投影 + 签名收窄:只有「聚焦真的变了」才 fire

focus 从「place 选作用域、作用域出主体」两步确定性推出——board place 读 board 选中,bench place 读它那个 surface 上聚焦 pane 的活跃 tab。关键在 bench 分支只读 place 自己的 surface,所以对任何别的 surface 的改动都不动这个投影。

runtime/shell/focus.ts两步确定性投影
export function projectFocus(place, selection, bench): FocusSubject {
  if (place === null) return null                            // 非工作路由无焦点
  return place.view === 'board'
    ? projectBoardFocus(place.context, selection)            // activity→content / task→task / null→project
    : projectBenchFocus(place.context, bench)                // 该 surface 聚焦 pane 的活跃 tab
}

function projectBenchFocus(context, bench): FocusSubject {
  const layout = bench.bySurface[surfaceOfContext(context)]  // 只看 place 自己的 surface
  if (!layout || layout.focusedPaneId === null) return null
  const pane = collectPanes(layout.root).find((p) => p.id === layout.focusedPaneId)
  const tab = pane?.activeTabId ? layout.tabs[pane.activeTabId] : undefined
  return tab ? { kind: 'content', target: tab.target } : null
}

但布局 store 每秒都在抖动(pane 拖宽、tab 重排、非活跃 surface 改动)。若这些都 fire 焦点事件,订阅方会被淹。做法是把投影哈希成一个稳定「签名」字符串,签名没变就不 fire——这是本轮引入的第一处通用「投影签名收窄」手法(对账器也用同一套)。

runtime/shell/controller.ts · focus.ts签名收窄
const recompute = () => {
  const focus = currentFocus()
  const signature = focusSignature(focus, contentKeyOf)
  if (signature === lastSignature) return    // pane 拖宽 / tab 重排 / 非活跃 surface 改动 → 签名不变 → 不 fire
  lastSignature = signature
  revision += 1
  focusEmitter.fire(focus)
}

export function focusSignature(focus, keyOf): string {
  if (focus === null) return 'null'
  if (focus.kind === 'content') return `content:${keyOf(focus.target)}`  // 经注册表规范化,非 JSON.stringify
  if (focus.kind === 'task') return `task:${focus.taskId}:${focus.projectId}`
  return `project:${focus.projectId}`
}
以前:切到 B 项目工作台
路由切换,route 内容首帧画出
route 组件 mount effect 才调 switchSurface
错位窗口:外层 sidebar 读到「B route + 旧 surface」
现在:切到 B 项目工作台
onBeforeLoad 触发,装载前先 setActivePlace(B)
原子:写 place + 清 selection + 重算,一步到位
签名变了才 fire,消费者拿到已 settle 的 focus
排查路标 · 旅程 A
症状从哪下手
某路由下 sidebar item 该显未显 / 该隐未隐router-place-adapter.tsplaceFromPathname 是否把该 pathname 映射成预期 place(正则锚定,注意尾段)
切项目后详情还显示上个项目的东西runtime/shell/controller.tssetActivePlaceenterBoard/clearSelection 分支是否走到
焦点事件狂 fire / 拖个 pane 就重渲runtime/shell/focus.tsfocusSignature 是否把该变化算进签名(不该算的别算)
冷启动 deep link 首帧 place 为 nullrouter-place-adapter.tsinstallRouterPlaceAdapter 的 seed 调用(sync(location.pathname)

7旅程 B:从「各路由硬编码侧栏」到「壳层双 Host + item 贡献」

这是本轮最大的一块 UI 重构。以前侧栏内容由每个路由页面自己挂:项目工作台挂 WorkbenchNav、全局面挂 GlobalNav、看板右侧是 board 页面内部一个 motion.aside 三选一渲染的详情列。现在左右两个 Host 是壳层提供的容器,内容是注册进来的 sidebar item——任务导航、会话导航、详情,都成了 item。

全景 · 涉及 8 个文件
item 工厂
features/*/…-item.tsx
注册表 + 持久化
sidebar/store.ts
纯函数解析
sidebar/resolution.ts
双 Host 渲染
SidebarHosts.tsx
揭示
sidebar/controller.ts

B.1item contribution:Component 只拿 dock,其余自取

一个 item 向壳层声明「我叫什么、停哪侧、什么 place 下出现、什么焦点下可用、拿什么渲染」。两个纯同步谓词是关键:isVisible(place)(结构轴,只随导航翻转)和 getEnablement(ctx)(焦点轴,可能每几秒翻转)——它们让「任一时刻的 rail 完全由当前状态决定」,冷启动没有历史依赖。item 的 Component 只收到一个 dock prop,其余一切从 app.XX 自取,壳层从不为 item 编排数据。

features/workbench/task-nav-item.tsx一个 item 就是一个工厂
function TaskNavBody(): React.JSX.Element | null {
  const place = useStore(useApp().workspace.place, (s) => s.activePlace)   // 自读 place,不靠 props
  if (place === null || place.context.kind !== 'project') return null
  return <WorkbenchNav projectId={place.context.projectId} />
}
export const taskNavItem: SidebarItemContribution = () => ({    // () => definition,与 content kind 同形制
  id: 'tasks', icon: 'tasks', title: 'workbench.nav.heading',
  section: 'contextual', defaultDock: 'left', order: 0,
  isVisible: (place) => place?.view === 'bench' && place.context.kind === 'project',  // 只在项目工作台出现
  getEnablement: () => ({ enabled: true }),
  Component: TaskNavBody,
})

B.2宿主状态机:四条规则全落在一个纯函数里

任一侧 Host 的视图 =「注册表 + 持久化 placement/active + 当前 context」的纯投影。设计文档列了四条状态机规则,其中三条就是这个纯函数算出来的:

runtime/shell/sidebar/resolution.tsresolveSidebarDock
export function resolveSidebarDock(dock, items, placement, active, ctx): SidebarDockView {
  const visible = items
    .filter((item) => effectiveDock(item, placement) === dock && item.isVisible(ctx.place))  // effectiveDock = placement[id] ?? defaultDock
    .sort((a, b) => a.order - b.order)
  const resolved = visible.map((def) => ({ id: def.id, definition: def, enablement: def.getEnablement(ctx) }))
  // 持久化的 active 仅在“仍可见”时存活;否则回落到该侧 order 最小的可见项,全不可见则 null(空态)。
  const activeId = active !== null && visible.some((i) => i.id === active)
    ? active : (visible[0]?.id ?? null)
  return { items: resolved, activeId }
}

B.3左右 Host 共用三块内脏,一个 dnd 上下文跨整行

并没有一个「Host 内核组件」被左右各套薄壳;左 chrome(rail 联动 + 单 item 标题头)和右 chrome(横向 tab 条 + 动作区 + 收起钮)完全不同,共用的是三块子件(item body、动作区槽、resize 手柄)。SidebarHosts唯一app.workspace.sidebar 与设备偏好 store 的地方,往下层层传 resolved view + 回调。

components/shell/sidebar/SidebarHosts.tsx唯一读点 + 拖拽落位
const { left, right, setActive, moveItem } = useSidebarHosts()
const anyItemVisible = left.items.length > 0 || right.items.length > 0   // 非工作视图两侧皆空 → 右 Host 收起,不留死家具

const onSelectLeft = (id) => {
  if (left.activeId === id && leftVisible) { setDockVisible('left', false); return }  // 点已开的 → 收起
  setActive('left', id); setDockVisible('left', true)                                  // 否则激活并展开
}
const onDragEnd = (e) => {
  const drop = resolveDockDrop(String(e.active.id), e.over ? String(e.over.id) : null)
  if (!drop) return
  moveItem(drop.itemId, drop.dock); setDockVisible(drop.dock, true)   // 落哪侧就重停靠哪侧并展开
}
// 一个 DndContext 跨整行,所以左 rail 拖到右 Host 能重停靠;路由自己的拖拽上下文嵌在 children 内不受影响。
return <DndContext onDragEnd={onDragEnd}><div className="flex min-h-0">
  <SidebarRail … /><LeftSidebarHost … />{children}<RightSidebarHost retainWhenEmpty={anyItemVisible} … />
</div></DndContext>

「两档可用性」的呈现也值得看一眼:结构不可见走收合动画(rail 图标 height 0↔auto ~180ms),可见但被焦点禁用则置灰但保留——而且刻意用 aria-disabled 而非原生 disabled,让暂不可用的图标仍可聚焦、仍可拖

components/shell/sidebar/SidebarRail.tsxaria-disabled 压过 dnd-kit
<button onClick={() => { if (enabled) onSelect(item.id) }}   // 激活在 onClick 里 guard
  title={enabled ? t(item.definition.title) : t(item.enablement.reason)}  // 禁用时 tooltip 显原因
  className={cn(railButtonClass, !enabled && 'opacity-40')}
  {...attributes} {...listeners}
  // aria-disabled 放在 drag 属性“之后”以压过 dnd-kit 自带:暂不可用的 item 仍可聚焦(键盘用户能读 reason)、
  // 仍可拖(还能换 dock),把瞬时可用性轴与结构放置轴解耦。原生 disabled 会两者都堵死。
  aria-disabled={!enabled} />

B.4revealItem:调用方不必知道 item 被拖到了哪侧

看板点一张卡要「亮出详情」。但详情 item 是可拖的——用户可能把它拖到左 Host。旧代码写死 setDockVisible('right', true),一旦详情停在左边就会揭示一个空的右 Host。新做法是一个内核基元 revealItem(id):它自己解析 item 当前停哪侧、激活它、开那侧 Host。

runtime/shell/sidebar/controller.ts解析停靠侧 → 激活 → 开那侧
revealItem(id) {
  const definition = registry.get(id)
  if (definition === undefined) return                        // 未知 id:no-op
  if (!definition.isVisible(deps.getContext().place)) return  // 当前 place 隐藏它:no-op,绝不揭示到空 Host
  const dock = store.getState().placement[id] ?? definition.defaultDock  // 读被拖到的侧,不假设 default
  store.getState().setActive(dock, id)   // 先设为那侧 active,否则 Host 开出来是别的 item
  deps.setDockVisible(dock, true)        // 再经“注入的 chrome 端口”开那侧 Host
}

这里藏着一个跨 boundary 的巧思。setActive 在 runtime-core,而「开/关一个 Host」的显隐逻辑住在 renderer/lib(shared 层),eslint boundary 禁止内核 import shared。解法是组合根注入一个用内核自己 Dock 类型签名的端口 setDockVisible,内核永不 import shared。这也是为什么设备偏好 store 现在改在 App 之前构造——好让这个端口能桥到它。

main.tsx · routes/board.tsx组合根接线 + board 收敛
// main.tsx:device chrome 先于 App 构造,把 setDockVisible 作为端口注入
const deviceChrome = createDeviceChromeStore(window.localStorage)
const app = createAppRuntime({ workspace: {
  sidebarItems: [taskNavItem, sessionNavItem, detailItem],
  setDockVisible: (dock, visible) => deviceChrome.getState().setDockVisible(dock, visible),  // ← 桥
}, /* … */ })

// board.tsx:reveal 走内核基元,不再硬编码“开右 Host”
const sidebar = useApp().workspace.sidebar
const revealDetail = useCallback(() => sidebar.revealItem('detail'), [sidebar])
function selectTaskCard(taskId) { board.select({ kind: 'task', taskId }); revealDetail() }

详情 item 本身是 f(view, focus) 而非 f(focus)——同一个 content 焦点在两个视图里要的东西相反:board 态渲染内容本体/任务详情/项目信息三选一,bench 态渲染「聚焦 tab 所属 task 的只读元信息」。值得留意:并不存在一个 ContentKind.Detail? 专用详情渲染(那个扩展点在 review 里被当投机预留删了,见第 10 章);board 态的内容详情复用 tab 同款 Component,只套一层「窄密度」上下文。

features/workbench/detail-item.tsxf(view, focus) + 复用 tab Component
function DetailItemBody() {
  const place = useStore(useApp().workspace.place, (s) => s.activePlace)
  const focus = useWorkspaceFocus()
  if (place === null) return null
  return place.view === 'board' ? <BoardDetail focus={focus} /> : <BenchDetail focus={focus} />
}
function BoardContentDetail({ target }) {
  const registration = useBench().registry.get(target.kind)
  useHostBarActions(registration ? [{ id: 'open-in-workbench', icon: 'open',
    title: 'workbench.activity.open', onClick: () => reveal(target) }] : [])   // 动作区按钮,只对已注册 kind 声明
  return registration
    ? <ContentDensityProvider value="narrow"><registration.Component target={target} /></ContentDensityProvider>  // 复用 tab Component,窄密度
    : <div>{t('workbench.preview.unavailable')}</div>   // stale handle:静默提示,不 blank
}
以前:看板点卡片看详情
board 页本地 useState<TaskSelection>
motion.aside 里在三个面板间三选一
宽度存 useDetailPanelStore,动画编排在 board 页
现在:看板点卡片看详情
app.board.select({kind:'task'})(选中入内核)
revealItem('detail') 解析停靠侧、开那侧 Host
右 Host 的 detail item 读 focus 渲染,宽度归 Host
排查路标 · 旅程 B
症状从哪下手
某 item 该出现在 rail 上却没有sidebar/resolution.tsresolveSidebarDockeffectiveDock === dock && isVisible(place) 过滤
激活的 item 突然不可见后面板塌了sidebar/resolution.tsactiveId 的 fallback(应回落 visible[0] 而非 null)
把详情拖到左边、点卡片却开出空右 Hostsidebar/controller.tsrevealItem 读的是 placement[id] ?? defaultDock 吗(这正是遗留 A1 的修法)
重启后侧栏排布丢失 / 两个 daemon 串了排布sidebar/store.ts:持久化 key 前缀 buffin.sidebar. + backend identity;merge 的 prune
拖 item 到另一侧后原侧没递补sidebar/store.ts moveItem 改写 placement + 目的侧 active;原侧靠 resolution 的 fallback 递补

8旅程 C:另一台设备删了一个 task,本机怎么从「静默」变成「弹 toast」

这是一条从无到有的新旅程。以前别的设备删了 task,本机的对账器静默关掉 tab,用户毫无解释。本轮铺了一条语义通道:daemon 在写事务里声明一个业务事实,随信封原子广播,renderer 在信任边界校验后分发成 owner 事件,最后一个观察者弹一条中性 toast。

全景 · 涉及 6 个文件
声明事实
daemon write-seam
信封广播
api/domain-events.ts
对账 + fire
model/replication.ts
校验 + 分发
runtime/tasks/tasks.ts
toast
remote-deletion-toast.ts

C.1daemon 声明语义事实,changesdomainEvents 是两条刻意分开的通道

一个 daemon 写事务提交后广播成一个「信封」:{ revision, changes, domainEvents }changes 是复制通道(哪些行变成了什么,形状可随 schema/性能演进);domainEvents 是语义通道(发生了什么业务事实,是稳定得多的契约)。首个成员是 task.deleted——它是意图级的,root 视角,携带整棵被删集合;project 删除不发此事件(那是另一个用户意图)。这也是本轮信封里第一个被 zod 校验的字段。

packages/api/src/model/domain-events.tszod schema 与类型同源
const taskDeletedEventSchema = z.strictObject({
  type: z.literal('task.deleted'),
  taskId: idSchema,                       // 本次删除意图的 root task
  title: z.string(),                      // 在删除事务内趁行还在读出,供展示
  deletedTaskIds: z.array(idSchema).min(1), // root + 全部 descendant;空数组即畸形,边界处拒收
}).refine((e) => e.deletedTaskIds.includes(e.taskId), { // 契约是“root + descendant”,root 必须在集合里
  message: 'deletedTaskIds must contain the root taskId', path: ['deletedTaskIds'],
})
export const domainEventSchema = z.discriminatedUnion('type', [taskDeletedEventSchema])
export type DomainEvent = z.infer<typeof domainEventSchema>   // schema 与 type 同源,边界据此 parse

daemon 侧:写 seam 上的 tx.domainEvent() 参数类型从 never(此前封死)放开为 DomainEventtasks.delete 的写路径声明首个事件;信封的提交判据也从「有行变更才 bump」放宽为「有行变更有事件才 bump」,天然覆盖将来「只有事件、无行变更」的纯语义事实。

C.2桥:先 zod 校验(信任边界),再按 owner 分发

renderer 侧打开 PR1 预留的信封通道——ReplicationCoordinator 新增一个内核私有onDidApplyTransaction,只在越过 ready 边界的真·增量事务上 fire(catch-up/resync 的 apply 是在重建状态、不是重放历史,桥对它们静默),且永远在 reconcilers 跑完之后。桥订阅它,对每条 domain event 先 safeParse——wire 静态上已把 domainEvents 标成 DomainEvent[],但 daemon 是数据源,可能漂移,所以在这里重新校验,畸形的报到 sink 并跳过,绝不进 emitter。

runtime/tasks/tasks.ts信任边界 + 全 map 分发
function bridgeEnvelope(envelope, fanout, onBackgroundError) {
  for (const raw of envelope.domainEvents) {
    const parsed = domainEventSchema.safeParse(raw)
    if (!parsed.success) { onBackgroundError(parsed.error, 'app.tasks.bridge'); continue }  // 畸形→报 sink+跳过
    const handler = fanout[parsed.data.type] as (e: DomainEvent) => void
    handler(parsed.data)
  }
}
// fanout 是对 DomainEvent['type'] 的“全 map”(mapped type):wire union 新增成员时此处不给分支就编译不过——
// 单成员 union 用裸 if + never default 拿不到的穷尽性。
type DomainEventFanout = { readonly [K in DomainEvent['type']]: (e: Extract<DomainEvent, { type: K }>) => void }

分发的落点是 owner 事件 app.tasks.onDidDelete。它的 payload 去掉了 wire 的 type 判别位(渠道已按类型分流),也不带 revision——这一点是下面 C.4 回声抑制的关键。

C.3「必须协调」先于「可选观察」,靠调用顺序保证而非约定

删除到达时,必须发生的是对账器把孤儿 tab 关掉;可选的是弹一条解释性 toast。二者的先后不靠约定,靠 applyTransaction 里的物理调用顺序——对账器经 registerReconciler 注册,在 runReconcilers 里跑,而这一步永远在 fire() 之前。

model/replication.tsreconcilers 先跑,再 fire 语义通道
ctx.appliedRevision = message.revision
settleWaiters(ctx)
runReconcilers(ctx)                     // ← 先:对账器关掉孤儿 tab(必须协调)
// Live 边界:只有越过 ready 标记的事务才是语义观察;catch-up/resync 重建状态而不重放历史,
// 所以桥只见 live 信封、且总在 reconcilers 之后。
if (ctx.status === 'live') ctx.applyTransactionEmitter.fire({ ...message, changes })  // ← 后:fire(可选观察)

配套还有一个 onDidResync 信号,报的是「confirmed 状态刚被整体替换」(首份 snapshot 与每次 resync 都 fire)。所有断连/溢出/fat-envelope 缺口都以一次 resync 收场,所以观察者的补偿动作唯一且明确:整表重读。这条与「连接状态」的 SyncStatus 不重叠——一个答「现在通不通」,一个报「刚发生了整体替换」。

C.4本机回声抑制:为什么按 root taskId 而非 revision

daemon 在事务提交内广播删除信封,所以在单条有序 socket 上,本客户端会先看到自己删除的 task.deleted 事件,后才拿到 RPC receipt——它根本来不及知道这次 commit 的 revision。而 root task id 在手势发起时就在手里。于是删除手势在 RPC 派发前先记下 root id,toast 消费者对命中者跳过。

model/write-client.ts手势时刻记入的有界窗口
const RECENT_LOCAL_DELETION_WINDOW = 64
const recentLocalDeletions = new Set<string>()   // 本机近期删除的 root id,手势时刻记入(RPC 之前)
// daemon 在 commit 内广播,客户端先于 RPC receipt 观察到自己删除的事件——按尚不可知的 revision 关联
// 不可能,但 root id 一开始就在手。Set 迭代是插入序,最旧的先被溢出淘汰。
function rememberLocalDeletion(rootTaskId) { recentLocalDeletions.add(rootTaskId); /* 超 64 淘汰最旧 */ }
function forgetLocalDeletion(rootTaskId) { recentLocalDeletions.delete(rootTaskId) }  // 删除失败→撤记录,否则会吞掉之后一次真·远端删除的 toast
remote-deletion-toast.ts命中即跳过,剩下的就是远端删除
export function installRemoteDeletionToast(ports) {
  return ports.onDidDelete((event) => {
    if (ports.wasRecentLocalDeletion(event.taskId)) return   // 本机自删:tab 已被对账器关掉,toast 是噪音
    ports.notify({ key: 'notification.taskDeleted', values: { title: event.title } })  // 中性文案:即使来源判定的边缘情况也不说谎
  })
}

这个观察者装配在组合根(观察者是 UI 编排,不进内核),toast 本体是一个壳层拥有的最小通知服务——组合根持有、非 App 成员、非包级单例,dispose 时清光 timer/队列/订阅。

以前:远端删了一个 task
feed 送来删除,对账器关掉本机的 tab
没有任何通知——用户盯着 tab 消失,不知为何
现在:远端删了一个 task
daemon 事务内声明 task.deleted,随信封广播
对账器先关 tab,桥再 fire onDidDelete
回声抑制放行(非本机)→ 弹中性 toast
排查路标 · 旅程 C
症状从哪下手
本机自己删 task 却弹了「已删除」toastwrite-client.tsrememberLocalDeletion 是否在 RPC 前调(entities/taskuseDeleteTask
远端删 task 无 toast / tab 没关tab 关闭看对账器;toast 看桥的 onDidApplyTransaction 是否 status==='live' 才 fire
某类事件到 renderer 被静默丢runtime/tasks/tasks.tssafeParse 失败会报 'app.tasks.bridge' 到 background sink
删一大棵子树(触发 fat envelope)远端无 toast已知缺口:fat envelope 只发 resyncRequired、丢 domainEvents,tab 靠 resync 后重对账关掉但不弹 toast(见风险地图)
断线重连后收到重复/过期删除通知不会——catch-up/resync 不重放事件,onDidApplyTransaction 仅 live 路径 fire

9旅程 D:把「host-op + 手写导航」换轨到一个 command

最后一条旅程小而精。以前看板侧「打开某 task 的某内容并跳到工作台」这个动作,是各调用点手写重复的「openInWorkbench(target) + navigate({to: workbench})」组合。当发起方跨层(详情动作区、将来的 palette/插件)时,缺一个唯一处理方派发机制。本轮立了 app.commands,并把这组组合换轨到一个命令。

全景 · 涉及 3 个文件
发起方 execute
detail-item / board
令牌派发
commands/commands.ts
唯一 handler
commands/reveal-task.ts
open + navigate

机制的精髓是类型随令牌流动:没有中央 command 接口,没有字符串查表。Args/Result 钉在 defineCommand 铸出的令牌值本身上,register/execute 从令牌泛型推断。存储时擦成 (args: never) => unknown,在持有 descriptor 的调用点再窄回。规则也刻意极简:一个 id 一个 handler、重复注册抛错(不做栈式覆盖)、execute 始终返回 Promise、没有 middleware/veto/通用 command-executed 事件。

runtime/commands/commands.ts令牌 + 永不同步抛的 execute
export function defineCommand<Args = void, Result = void>(id: string): CommandDescriptor<Args, Result> {
  return { id } as CommandDescriptor<Args, Result>   // 幽灵 signature 从不存在,这个 cast 是唯一断言其存在处
}
execute(descriptor, args) {
  const handler = handlers.get(descriptor.id)
  if (handler === undefined) return Promise.reject(new CommandNotRegisteredError(descriptor.id))  // 未注册→可判别错误,不同步抛
  try { return Promise.resolve(run(args)) }        // 同步 handler 抛错也变 rejection,统一成一种 shape
  catch (error) { return Promise.reject(error) }
}

首个命令 task.revealInWorkbench 的 handler 需要 router 才能导航,所以由组合根在 router 构造后注册(把 router 闭包进 handler)。handler 端口化(hasContentKind/open/navigate),让真 router 与测试的 spy 走同一 body,并给出三种可判别错误——未知 kind(校验先于副作用,绝不开半个 tab)、导航失败(tab 已开、原 rejection 挂 cause)。目的地只从 target.projectId 派生,handler 从不读「我在哪」去猜目的地。

runtime/commands/reveal-task.ts · main.tsx端口化 handler + 组合根注册
// reveal-task.ts:校验先于副作用
export function registerRevealTaskCommand(commands, ports) {
  return commands.register(revealTask, async ({ target }) => {
    if (!ports.hasContentKind(target.kind)) throw new UnknownContentKindError(target.kind)  // 未知 kind→不开 tab
    ports.open(target)
    try { await ports.navigate(target.projectId) } catch (cause) {
      throw new RevealNavigationFailedError(target.projectId, { cause })   // tab 已开,导航失败可判别
    }
  })
}
// main.tsx:router 构造后,是唯一能把 TanStack router 闭进 handler 的窗口
registerRevealTaskCommand(app.commands, {
  hasContentKind: (kind) => app.bench.registry.get(kind) !== undefined,
  open: (target) => { app.bench.open(target) },
  navigate: (projectId) => Promise.resolve(router.navigate({ to: '/projects/$projectId/workbench', params: { projectId } })),
})
以前:详情里「在工作台打开」
各调用点手写 openInWorkbench(target)
紧接一句 navigate({to: workbench})
目的地可能读 ambient place 猜;错误漏成 unhandled
现在:详情里「在工作台打开」
动作区按钮 execute(revealTask, {target})
唯一 handler:校验 → open → navigate
目的地只从 target.projectId 派生;失败可判别、弹 toast
排查路标 · 旅程 D
症状从哪下手
点「在工作台打开」无反应 / 报未注册main.tsxregisterRevealTaskCommand 是否在 router 构造后执行
开了 tab 但没跳到工作台reveal-task.tsnavigate 的 rejection 会包成 RevealNavigationFailedError,tab 保持打开
某个换轨点行为和以前不一样先确认它确实改调 execute(revealTask)——GlobalNav 行不在换轨清单(它直落 tab、无跳转)

10地基期的几处设计定夺(计划 vs 实现)

这一节不串具体 bug 怎么修——只收让人更好理解架构的几处「计划本来是 X,实际做成 Y,为什么变」。它们多来自实现/审查过程里的定夺,是照计划做的人也未必知道的认知裂缝。

计划/直觉实际做成为什么
app.workspace 一次实体化三命名空间 先只交 app.bench/app.boardapp.workspace 腾空,到装 activePlace 时才有内容 一个零消费者的空 interface 触 biome noEmptyInterface,也违反「不为不存在的需求预留」
host-op closeContent → 短名 close 直接接口成员 + 实现 + 测试 它零调用点,是 dead API,删比改名干净
content kind 可声明 Detail? 专用详情渲染 该扩展点被;详情内容一律回退「tab 同款 Component + 窄密度」 没有任何 kind 声明它,全走回退——「加了没人用」的死扩展点 + 永久 dead branch,收窄时删掉
ActivePlace 是笛卡尔 {view, context} 改成 view 判别联合,board×global 编译期不可构造 笛卡尔积逼出防御性 null 返回和脆弱的 guard 顺序;让非法态不可表达更省
回声抑制按本机提交的 revision 关联 改按手势时刻的 root taskId 窗口 daemon 在 commit 内广播,回声先于 RPC receipt 到达,此刻 revision 尚不可知(旅程 C.4)
activeSurfaceId/switchSurface 保留(降级为注释) 正式退役:删字段/action、WorkbenchView.surfaceId 改必填、删 Breadcrumb 4 处冗余写 router adapter 就位后,它成了「我在哪」的第二个冗余答案;open() 从 target 自己的 project 派生落点,不再需要它
sidebar Host 挂在 ModelReadyGate Gate 上移包住整个 shell body(rail + 两 Host + 路由内容) item body 从 App roots 读数据,冷启动 deep link 未就绪时会闪一帧空内容;TitleBar 留在 Gate 外(常驻窗口 chrome)
物理目录随对外名一起改(workspace/→bench/ 不改:引擎仍住 runtime/workspace/,持久化 key 仍 buffin.workspace.* 只改对外命名空间和 App 成员名;物理重组要 churn ~15 个测试文件、无产品收益,留后续 PR
barrel 新增 domain-events.ts 即可 漏建配套的 .js runtime shim,被门禁抓到后补上,并新增一条 check-api-shims 守卫 packages/api 以裸 TS 消费,漏 shim 只在 verify 才炸;新 check 把失败从慢的 verify 提前到快的 check

另有一批「本机删除失败要撤记录」「malformed 事务不能当 delete 应用」这类正确性修复,属实现细节、owner 已清楚,此处不展开。

11心智模型补丁

读完上面,你对这个 renderer 的假设要改这几处:

「我在哪」靠各路由挂载时调 switchSurface 对齐布局 store route location 是唯一真相源,组合根 router adapter 在装载前原子写 app.workspace.activePlace
再没有「新 route + 旧 surface」的错位窗口;冷启动 deep link 在首帧前就定好 place。
侧栏内容由每个路由页面各自挂 ContentSidebar;看板详情是 board 页内一个 motion.aside 左右两个 Host 是壳层容器;导航/详情都是注册进来的 sidebar item,可左右拖动停靠
ContentSidebar、折叠把手、motion.aside 全退役;显隐由 TitleBar 双开关 + 右栏收起钮承担。
远端删掉一个 task,本机静默关 tab daemon 声明 task.deleted → 桥 → app.tasks.onDidDelete → 弹中性 toast(本机回声按 root id 抑制)
跨设备删除第一次有了可见反馈;正确性仍不建在事件上(事件是及时性,完备性靠 resync 重读)。
跨层动作手写「host-op + navigate」组合 app.commands.execute(revealTask, {target})——多发起方、唯一处理方、可判别失败
app.workspace 是布局引擎(分栏/tab/内容注册表) app.bench 是布局引擎;app.workspace 是壳层(place + focus + sidebar)
⚠️ 目录 runtime/workspace/ 对应的是 app.bench,不是 app.workspace。按名字 grep 历史会误判。
存在性判定在内核里按 kind 硬编码分支(session/terminal/draft 各一段) 按 kind 经注册表 isPresent(target, confirmed) 分发;动作(关 tab/清 selection/扫 runtime)保持通用
正确性判定读 app.data(含本机乐观覆盖的 live 集合) 正确性读 app.confirmed(无覆盖的 confirmed 点读面);UI 展示才读 app.data
三件套:owner event(及时)+ app.confirmed 点读(判定)+ onDidResync 整表重读(补缺口)。

12新词表

焦点与位置
place / activePlace「用户在哪」= 视图轴(board/bench) × 语境轴(project/global) 的判别联合;非工作路由为 null。
focus / FocusSubject「用户聚焦什么」的统一投影(content/task/project/null),由 place 选作用域、作用域出主体。
placeRouter唯一带 setActivePlace 的窄端口,只给组合根 router adapter 用(place 有 URL 这个外部真相)。
签名收窄把投影哈希成稳定字符串,签名不变就不 fire 事件——挡掉 pane 拖宽/tab 重排这类无关抖动。
壳层与侧栏
Host / dock壳层拥有的左右侧栏容器;dock = 'left' | 'right',既指 item 停靠侧也指 Host,是传给 item 的唯一 prop。
sidebar item contribution把「侧栏显示什么」封成工厂 (ports) => definition,构造期注册;导航、详情都是它。
isVisible / getEnablementitem 的两档纯谓词:结构轴(在不在 rail 上,随导航翻转、收合动画)与焦点轴(可不可用、置灰 + tooltip)。
revealItem内核基元:解析 item 当前停哪侧、激活它、开那侧 Host——调用方不必知道它被拖到了哪。
命令与事件
command descriptor token带幽灵 signature 字段的类型化命令令牌,类型随它流动,无中央接口/字符串查表。
owner-scoped 事件owner 持 Emitter、只交出只订阅的 .event;发布权在类型层锁死,无全局 app.events
domain event(task.deleted一笔提交事务在「行变更」之外携带的业务事实;语义通道,比复制通道稳定得多。
onDidResync「confirmed 状态刚被整体替换」的事实事件(首 snapshot + 每次 resync);观察者补偿 = 整表重读。
app.confirmed只读 confirmed 行的点读面(无乐观覆盖),与 app.data 并列为「读模型的两个面」,供正确性判定。
底座
ReadableStore<T>Omit<StoreApi, 'setState'>——所有「只读面」收窄的类型底座。
runtime-base / core / react内核三层:base 是 React-free 原语(Emitter/Disposable)、core 是控制器(禁 React、禁 shared)、react 是 <AppProvider> 视图适配层。eslint boundary 结构性焊死。
runtime shimpackages/api 以裸 TS 消费(strip-types 不把 .js 说明符映射到 .ts),每个 barrel 成员旁要手写一个两行 x.js

13测试与风险地图

本轮测试占比高(~39%),核心态测试相当密。下面分「有兜底的」与「薄冰」两列——纯事实陈述,不是挑刺。

有测试钉住的行为

薄冰(重要逻辑无测试 / 已知遗留)

合并前值得一提(非阻断):几条零代码的文档对齐已被 review 认定该做——设计 §7.4 的回声抑制段应从「revision-window」改写为「gesture-time root-taskId window」(代码是对的,陈旧的是文档);§7.3 应记一条 onDidResync fire 时机的 design note。这些是文档批次,不动码。

14验收提示(别被这些吓到)

15覆盖声明

本报告以设计定稿指定的基线 68f53d1(#160 内核)称重,覆盖 68f53d1...HEAD 全量 diff(312 文件)。分工:7 个 subagent 按子系统全量精读(内核面 / sidebar+workspace+对账 runtime / model+域事件+daemon / 壳层组件+路由+组合根 / features 层 / 工具配置兜底 / 文档矿工),确保每一行改动都在某个 agent 视野内。本文出现的每一段代码,均由我(编排者)亲自 Read 过其所在文件后亲手裁剪——约 25 个核心文件(router-place-adapter.tsshell/controller.tsshell/focus.tsboard/controller.tssidebar/{controller,resolution,store,types}.tscommands/{commands,reveal-task}.tsinvariants/coordinator.tsreplication.tswrite-client.tstasks/tasks.tsremote-deletion-toast.tsnotification-service.tsdomain-events.tsSidebarHosts.tsxSidebarRail.tsxdock-drag.tsAppShell.tsxboard.tsxdetail-item.tsxtask-nav-item.tsxmain.tsx 等),未引用二手转述的代码。

诚实边界:本文逐条走读 #159(会话控件/assistant 链接)、xterm 6 补丁、system 主题这些同车但正交的工作,只在称重与验收提示里点名它们撑大了哪些目录;runtime/workspace/layout/* 的纯 rename 只认出、不逐行读。这是一份一次性理解辅助,不维护、不作为真相源。