PR2 交互平面:给内核补上「端内交互」这一层
buffin(产品仓) · 68f53d1(#160 内核基线)…HEAD · feat/pr2-interaction-plane · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这份走读讲整体重构逻辑与前后差异,不逐条串某个 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 主题)明显撑大,理解交互平面架构时可略过它们。
| 设计重心(要细读) | 可放心略过(机械/正交) |
|---|---|
runtime/shell/(place/focus/sidebar 内核)、runtime/commands/、runtime/board/、runtime/invariants/coordinator.ts、runtime/tasks/tasks.ts(桥)、model/replication.ts + write-client.ts、packages/api/model/domain-events.ts、components/shell/sidebar/*、routes/board.tsx、main.tsx、features/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.bench、useClient().model.write→.write 等消费点机械改名 |
features/workbench/ 布局引擎搬去 runtime/workspace/ 的 rename,不是净删。真正承载新设计的是 runtime/ 内核那 ~5,000 行、壳层双 Host 那 ~3,600 行、和 model/api/daemon 的域事件通道那 ~1,400 行。3架构一图流
一句话概括前后差异:以前每个功能各自拉线、靠 React 挂载时机把状态串起来;现在核心功能都从一个稳定的 app.XX 根主动读数据、发意图、注册能力。
以前 · 各路由自行拼装
switchSurface
现在 · app.XX 为根
activePlace
.sidebar 内核
.onDidDelete → toast
四个新增能力对应下文四条旅程:activePlace/focus(旅程 A)、sidebar contribution + 双 Host(旅程 B)、domain event → toast(旅程 C)、app.commands(旅程 D)。它们共享同一个底座:app 上一批「只读 + 只订阅」的面(第 5 章)。
4四概念与数据形状
整个交互平面立在四个边界很硬的概念上。它们各管一件事,越界即错——先把这张表装进脑子,后面每条旅程都在用它。
| 概念 | 回答的问题 | 接收方 | 本轮的实例 |
|---|---|---|---|
| State | 「现在是什么」——可直接读的当前真相 | 谁都可读 | activePlace、focus、app.confirmed |
| Event | 「发生时告诉我」——已发生、0..N 观察者消费的事实 | 0..N,爱听不听 | onDidChangeFocus、app.tasks.onDidDelete |
| Command | 「请把这事做了」——多发起方请求唯一处理方的意图 | 恰好 1 | task.revealInWorkbench |
| Contribution | 「我提供一种能力」——注册后可同步确定查找 | 同步查询 | content kind、sidebar item |
四条铁律:①不通过 Event 请求别人做事(Event 是事实不是指令);②不通过 Command 查询持续状态(状态直接读 State);③不靠事件回放拼当前 UI 状态;④ Contribution 不是 Event——注册后必须能被同步、确定地枚举。还有一条补充规则:没有全局 app.events,事件全部 owner-scoped——onDidDelete 挂 app.tasks 名下、发布权只属对应 owner。
下面把本轮新增/变更的核心数据形状先摆出来(只看形状不讲行为,给后面旅程预载词汇)。
「我在哪」——判别联合,非法态不可构造
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
「我聚焦什么」——跨视图消费者读的唯一值
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 投影
}
看板的选中主体 · 一种能贡献能力的注册项 · 一条业务事实 · 一个命令令牌
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/execute;dispose 整张表是 App 的活,藏在 CommandsRegistry 里,视图够不着。
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 BoardController、Workspace vs WorkspaceController、Tasks vs TasksController。它靠一个新的类型底座实现——把 zustand 的 StoreApi 去掉 setState:
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)保持通用。
// 一个 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 需要能读到确认真相。
(ports) => Definition(不是静态对象);② 组合根 main.tsx 把工厂列表作为 createAppRuntime() 的构造参数传入;③ App 构造时按固定顺序执行——服务先建 → 逐个工厂绑端口写入注册表 →然后才恢复持久化布局,「注册先于查询」由构造顺序在结构上保证;④ 重复 id 注册抛错,不静默覆盖。content kind、sidebar item、命令 handler 全走这一套。6旅程 A:用户导航一下,「我在哪 / 聚焦什么」如何更新
这是整个交互平面的地基旅程。走通它,你就掌握了「place 从哪来、focus 怎么算、为什么中间没有错位窗口」。
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 一次。
// 把 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 把两次写压进一个同步调用,末尾只重算一次投影。
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 的改动都不动这个投影。
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——这是本轮引入的第一处通用「投影签名收窄」手法(对账器也用同一套)。
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}`
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 某路由下 sidebar item 该显未显 / 该隐未隐 | router-place-adapter.ts:placeFromPathname 是否把该 pathname 映射成预期 place(正则锚定,注意尾段) |
| 切项目后详情还显示上个项目的东西 | runtime/shell/controller.ts:setActivePlace 的 enterBoard/clearSelection 分支是否走到 |
| 焦点事件狂 fire / 拖个 pane 就重渲 | runtime/shell/focus.ts:focusSignature 是否把该变化算进签名(不该算的别算) |
| 冷启动 deep link 首帧 place 为 null | router-place-adapter.ts:installRouterPlaceAdapter 的 seed 调用(sync(location.pathname)) |
7旅程 B:从「各路由硬编码侧栏」到「壳层双 Host + item 贡献」
这是本轮最大的一块 UI 重构。以前侧栏内容由每个路由页面自己挂:项目工作台挂 WorkbenchNav、全局面挂 GlobalNav、看板右侧是 board 页面内部一个 motion.aside 三选一渲染的详情列。现在左右两个 Host 是壳层提供的容器,内容是注册进来的 sidebar item——任务导航、会话导航、详情,都成了 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 编排数据。
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」的纯投影。设计文档列了四条状态机规则,其中三条就是这个纯函数算出来的:
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 }
}
- 规则1 激活项变不可见 → fallback:
active只在visible.some(...)时存活,否则回落visible[0]。 - 规则2 被禁用 → 保留显示空态:
enablement不影响 visibility 也不影响activeId——「可见但被禁用」的激活项仍然是激活项,面板保留,不塌陷。 - 规则3 拖到另一侧 → 激活跟随:靠 store 的
moveItem(见 B.3)改写 placement + 目的侧 active。 - 规则4 持久化里的未注册 id → 静默丢弃:在 store 的
merge里对每个 id 过注册表(插件卸载后残留的 id 直接丢),与布局恢复对 unknown content kind 的处置一致。
B.3左右 Host 共用三块内脏,一个 dnd 上下文跨整行
并没有一个「Host 内核组件」被左右各套薄壳;左 chrome(rail 联动 + 单 item 标题头)和右 chrome(横向 tab 条 + 动作区 + 收起钮)完全不同,共用的是三块子件(item body、动作区槽、resize 手柄)。SidebarHosts 是唯一读 app.workspace.sidebar 与设备偏好 store 的地方,往下层层传 resolved view + 回调。
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,让暂不可用的图标仍可聚焦、仍可拖:
<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。
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: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,只套一层「窄密度」上下文。
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
}
useState<TaskSelection>motion.aside 里在三个面板间三选一useDetailPanelStore,动画编排在 board 页app.board.select({kind:'task'})(选中入内核)revealItem('detail') 解析停靠侧、开那侧 Host排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 某 item 该出现在 rail 上却没有 | sidebar/resolution.ts:resolveSidebarDock 的 effectiveDock === dock && isVisible(place) 过滤 |
| 激活的 item 突然不可见后面板塌了 | sidebar/resolution.ts:activeId 的 fallback(应回落 visible[0] 而非 null) |
| 把详情拖到左边、点卡片却开出空右 Host | sidebar/controller.ts:revealItem 读的是 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。
daemon write-seam→ 信封广播
api/domain-events.ts→ 对账 + fire
model/replication.ts→ 校验 + 分发
runtime/tasks/tasks.ts→ toast
remote-deletion-toast.ts
C.1daemon 声明语义事实,changes 与 domainEvents 是两条刻意分开的通道
一个 daemon 写事务提交后广播成一个「信封」:{ revision, changes, domainEvents }。changes 是复制通道(哪些行变成了什么,形状可随 schema/性能演进);domainEvents 是语义通道(发生了什么业务事实,是稳定得多的契约)。首个成员是 task.deleted——它是意图级的,root 视角,携带整棵被删集合;project 删除不发此事件(那是另一个用户意图)。这也是本轮信封里第一个被 zod 校验的字段。
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(此前封死)放开为 DomainEvent,tasks.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。
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() 之前。
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 消费者对命中者跳过。
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
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.deleted,随信封广播onDidDelete排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 本机自己删 task 却弹了「已删除」toast | write-client.ts:rememberLocalDeletion 是否在 RPC 前调(entities/task 的 useDeleteTask) |
| 远端删 task 无 toast / tab 没关 | tab 关闭看对账器;toast 看桥的 onDidApplyTransaction 是否 status==='live' 才 fire |
| 某类事件到 renderer 被静默丢 | runtime/tasks/tasks.ts:safeParse 失败会报 '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,并把这组组合换轨到一个命令。
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 事件。
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 从不读「我在哪」去猜目的地。
// 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})execute(revealTask, {target})target.projectId 派生;失败可判别、弹 toast排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 点「在工作台打开」无反应 / 报未注册 | main.tsx:registerRevealTaskCommand 是否在 router 构造后执行 |
| 开了 tab 但没跳到工作台 | reveal-task.ts:navigate 的 rejection 会包成 RevealNavigationFailedError,tab 保持打开 |
| 某个换轨点行为和以前不一样 | 先确认它确实改调 execute(revealTask)——GlobalNav 行不在换轨清单(它直落 tab、无跳转) |
10地基期的几处设计定夺(计划 vs 实现)
这一节不串具体 bug 怎么修——只收让人更好理解架构的几处「计划本来是 X,实际做成 Y,为什么变」。它们多来自实现/审查过程里的定夺,是照计划做的人也未必知道的认知裂缝。
| 计划/直觉 | 实际做成 | 为什么 |
|---|---|---|
app.workspace 一次实体化三命名空间 |
先只交 app.bench/app.board,app.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
ContentSidebar;看板详情是 board 页内一个 motion.aside
左右两个 Host 是壳层容器;导航/详情都是注册进来的 sidebar item,可左右拖动停靠
ContentSidebar、折叠把手、motion.aside 全退役;显隐由 TitleBar 双开关 + 右栏收起钮承担。task.deleted → 桥 → app.tasks.onDidDelete → 弹中性 toast(本机回声按 root id 抑制)
app.commands.execute(revealTask, {target})——多发起方、唯一处理方、可判别失败
app.workspace 是布局引擎(分栏/tab/内容注册表)
app.bench 是布局引擎;app.workspace 是壳层(place + focus + sidebar)
runtime/workspace/ 对应的是 app.bench,不是 app.workspace。按名字 grep 历史会误判。isPresent(target, confirmed) 分发;动作(关 tab/清 selection/扫 runtime)保持通用
app.data(含本机乐观覆盖的 live 集合)
正确性读 app.confirmed(无覆盖的 confirmed 点读面);UI 展示才读 app.data
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 / getEnablement | item 的两档纯谓词:结构轴(在不在 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 shim | packages/api 以裸 TS 消费(strip-types 不把 .js 说明符映射到 .ts),每个 barrel 成员旁要手写一个两行 x.js。 |
13测试与风险地图
本轮测试占比高(~39%),核心态测试相当密。下面分「有兜底的」与「薄冰」两列——纯事实陈述,不是挑刺。
有测试钉住的行为
- place/focus 全域:
shell/controller.test.ts+focus.test.ts覆盖各 place→focus 映射、原子清 selection、跨 board 归属校验,以及误触发全覆盖(pane resize / tab 重排 / 非活跃 surface 改动都不 fire)。 - sidebar 状态机:
resolution.test.ts(四条规则)、store.test.ts(moveItem 身份携带、backend 分区、未注册 id 丢弃)、controller.test.ts(revealItem三情形:停右默认/拖左/不可见 no-op)。 - 对账三路径:
coordinator.test.ts18 例,含 task/session/terminal 各自 absent 关 tab、draft 永不被确认态判死、无谓词回落、board selection 清除、增量/snapshot/resync 一致。 - 删除链路:
remote-deletion-toast.integration.test.ts用「只投信封、不投 receipt」复现「回声先于 receipt」,钉住「按 root id 而非 revision」;write-client.test.ts覆盖窗口溢出/失败撤记录/lifecycle 清空。 - 事件边界:
replication.test.ts钉住「仅 ready 后 fire、catch-up/resync 不 fire」「首 snapshot 与每次 resync fire onDidResync」「malformed 被拒不进 emitter」「ready-before-snapshot 触发 resync」。 - 命令:
commands.test.ts(重复 id 抛错、dispose 后重注册、未注册可判别)、reveal-task.test.ts(未知 kind 不开半个 tab、导航失败带 cause)。 - 详情矩阵:
detail-item.test.tsx覆盖完整 (view × focus) 矩阵 + 「open-in-workbench」动作接线 + bench content 态不挂内容 host。 - 工具守卫:
check-api-shims.test.mjs(barrel shim 缺建)、check-runtime-react-free-canary.test.mjs(内核 React 禁令还活着,含「不过度禁掉 zustand/vanilla」反向断言)。
薄冰(重要逻辑无测试 / 已知遗留)
- 🟡详情停左侧的边缘布局:
revealItem已按停靠侧解析(遗留 A1 的正解已落地),但「把详情拖到左 dock 且收起左 Host」这类非默认布局下的组合,缺集成测试兜底。 - 🟡fat envelope 的远端删除无 toast:删一大棵子树触发 fat envelope 时,daemon 只发
resyncRequired、丢掉 domainEvents——tab 靠 resync 后重对账关掉,但不会弹 toast。这是 toast「best-effort / at-most-once」契约的已知缺口,各段分别有测试、接缝无端到端测试。 - ⚪双 Host 拖拽无集成测试:
resolveDockDrop/moveItem各有单测,但「从左 Host 拖到右 Host」的完整 dnd-kit 流程无集成测试(jsdom 驱动 dnd-kit 又脆又贵,宜用 Playwright e2e)。遗留 A7。 - ⚪
BenchTaskMetaPanel无专属测试:它是SavedTaskPanel展示部分的只读复刻,在detail-item.test里被 stub 掉,其 property rows / task 未 resolve 分支未直接测。 - ⚪同 root 并发删的回声抑制竞态:抑制用
Set<rootId>,同 root 并发删两次去重成一条,若其一失败撤掉唯一条,另一条自删回声会多弹一次如实的 toast(绝不会错误吞掉真删)。遗留 A6,判为「加 refcount = 过度设计」。 - ⚪
rewriteSelectionTarget(draft→真 id 改写):board/controller.ts里这条分支无直接/间接断言命中。
onDidResync fire 时机的 design note。这些是文档批次,不动码。14验收提示(别被这些吓到)
- 命名交叉不是 bug:
runtime/workspace/目录 =app.bench,runtime/shell/的WorkspaceController=app.workspace。持久化 key 仍是buffin.workspace.*(物理名有意不改,见第 10 章)。 Dock类型两处声明是有意的:lib/device-chrome-store.ts(shared 层)和runtime/shell/sidebar/types.ts(runtime-core)各一份'left' | 'right'——eslint boundary 禁两层互 import,没有共同可达的家,两处已加交叉引用注释、保持 lockstep。别为一个两值 union 造新共享层。遗留 A10。- diff 里裹着几个正交 PR:对
main看这条 diff 会包含 #160(内核解耦,本报告的基线)、#161(xterm 6)、#162(写路由测试)、#159(会话控件 + assistant 链接)以及 system 主题——它们经一次merge main进来,不是交互平面的设计负载。features/session和工具/配置的体量主要来自这些。 ContentKind.Detail?不存在不是遗漏:详情内容一律走「tab 同款 Component + 窄密度」回退,那个 opt-in 扩展点在收窄时被当投机预留删掉了。revealItem对未知/不可见 item 是 no-op:这是刻意的——绝不揭示到一个空 Host 上。- 右 Host 没有常驻 rail:右 Host 隐藏后靠 TitleBar 右开关或把 item 拖回来重现;
retainWhenEmpty(工作视图下保留空 drop 靶)是弥补右侧无 rail 的机制。 - 会话 tab 的
TabActions是 DEV-only:session-registration里import.meta.env.DEV ? { TabActions } : {},生产不挂,属会话控件特性(#159)尚未定稿的部分。
15覆盖声明
本报告以设计定稿指定的基线 68f53d1(#160 内核)称重,覆盖 68f53d1...HEAD 全量 diff(312 文件)。分工:7 个 subagent 按子系统全量精读(内核面 / sidebar+workspace+对账 runtime / model+域事件+daemon / 壳层组件+路由+组合根 / features 层 / 工具配置兜底 / 文档矿工),确保每一行改动都在某个 agent 视野内。本文出现的每一段代码,均由我(编排者)亲自 Read 过其所在文件后亲手裁剪——约 25 个核心文件(router-place-adapter.ts、shell/controller.ts、shell/focus.ts、board/controller.ts、sidebar/{controller,resolution,store,types}.ts、commands/{commands,reveal-task}.ts、invariants/coordinator.ts、replication.ts、write-client.ts、tasks/tasks.ts、remote-deletion-toast.ts、notification-service.ts、domain-events.ts、SidebarHosts.tsx、SidebarRail.tsx、dock-drag.ts、AppShell.tsx、board.tsx、detail-item.tsx、task-nav-item.tsx、main.tsx 等),未引用二手转述的代码。
诚实边界:本文不逐条走读 #159(会话控件/assistant 链接)、xterm 6 补丁、system 主题这些同车但正交的工作,只在称重与验收提示里点名它们撑大了哪些目录;runtime/workspace/layout/* 的纯 rename 只认出、不逐行读。这是一份一次性理解辅助,不维护、不作为真相源。