feat/kanban-redesign:把已批准的原型复刻进看板,并沉淀一套「活动」地基
eyrie(apps/desktop 渲染层) · a1e7a40c…3424db2b · 2026-07-08 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一次视觉重构,但它顺手立了一层新契约,所以正文重心在「新契约怎么流动」而非「哪些像素变了」。
1TL;DR
这个 PR 把看板的视觉照一份 owner 已逐帧认可的原型重做了一遍:列不再整列染色、卡片丢掉描述段、右栏面板从扁平键值堆改成可折叠属性组。但它真正的分量不在改皮——为了让卡片、任务面板、右栏预览三处都能不认识具体类型(session / terminal)地列出一个任务下的「活动」,它扩展了一层 ActivityDescriptor 契约(给每个活动加上 kind / 状态 / 是否等人 / 时间戳四个字段),并把「按活跃度排序 + 前 3 折叠」这件事从每行内部下沉到列表层。
动机(规格 §0 反复强调的一条铁律):原型是像素级真相源。文字描述和 hex 数值只是导航约束,最终呈现一律以浏览器渲染出的原型为准。整个改动是「沿现有缝重塑 + 补两条前端数据契约」,不是重写——右栏三态外壳、workbench registry 抽象在改动前就已存在。
2变更地图(称重)
全部 65 个文件都落在 apps/desktop 渲染层,无一行碰 daemon、API 或 DB。churn 按子系统分布如下(条长 = 相对最大子系统):
| 设计重心(要细读) | 可放心略过(机械) |
|---|---|
|
|
诚实的称重:workbench 那条最长的条里,约三分之一是 ActivityChips → ActivityList 的逐字搬运(改名),真正新增的设计约 380 行。tasks 那条里约六成是 Field → PanelSection 的渲染层平移。所以「+2591」的观感偏大——真正需要理解的新机制集中在契约层与 6 个原语。
3架构一图流
没有进程/通信结构变化,唯一「架构级」的改动是:「一个活动长什么样」的 live 数据契约多了一层,且排序信号从行内下沉到列表层。
以前 · 活动 = 一行 chip,无序
现在 · 活动 = 有序的行,独立契约
关键差别在最后那条:以前一个活动的时间戳(如果有的话)只活在它自己的行渲染里,列表拿不到,所以既排不了序也折叠不了。现在时间戳提前一层挂到 ActivityListing 上,列表在任何行 resolve 自己的 live 状态之前就知道全序——这是「卡片只留最新 3 行」能成立的前提。
4数据与状态先行
先把这次新立的类型形状过一遍,后面三条旅程都用它们。三个都在同一个文件,是这次改动的契约心脏。
export type ActivityStatus = StatusLightStatus // 'running' | 'attention' | 'idle',别名 shared 层的灯状态
export interface ActivityDescriptor {
kind: ContentTarget['kind'] // 选行首那个 14px 记号
title: string
status: ActivityStatus // 驱动状态灯的颜色 / 是否呼吸
attention: boolean // 尾槽是否亮「需介入」
timestamp: number | null // 相对时间;null = 该 target 无 recency 信号
}
export interface ActivityListing<K extends ContentTarget['kind']> {
target: Extract<ContentTarget, { kind: K }>
timestamp: number | null // 与 descriptor 同源,但排序/折叠只读这一份(列表级)
}
为什么 timestamp 要在 ActivityListing 和 ActivityDescriptor 里各放一份、而不是只放行内?源码注释把理由讲透了:排序和折叠是列表级决策,发生在任何行 resolve 自己 descriptor 之前;只放 descriptor 会让列表对 recency「失明」。
export interface ActivityCapability<K extends ContentTarget['kind']> {
useList(taskId: string, projectId: string): ActivityListing<K>[] // 旧版返回 ContentTarget[],现在带上 timestamp
useDescriptor(target: Extract<ContentTarget, { kind: K }>): ActivityDescriptor // 新增:活动行的 live 描述符
create?(taskId: string, projectId: string): Promise<Extract<ContentTarget, { kind: K }>>
}
注意这里现在有两个 useDescriptor:ContentKind 级的返回 TabDescriptor(title/icon/badge,给 tab 条用,词表是 running/attention/unread);ActivityCapability 级的返回 ActivityDescriptor(给活动行和预览头用,词表是 running/attention/idle)。两者并存、各管一处——旧的 chip 读前者,新的行读后者,所以 live 状态词表悄悄从「有 unread」换成了「有 idle」。
export const CARD_ROW_LIMIT = 3
export interface ActivityFold<T> {
visible: T[] // 要渲染的行
hiddenCount: number // >0 就渲染「N more」
collapsible: boolean // 已展开且超限 → 渲染 collapse
}
5底座:三颗点 + 一个契约
这次沉淀的原语里,最容易看花眼的是「三颗一样大的 8px 圆/方点」。它们语义完全不同,值得一次讲清——之后旅程里再遇到就不迷糊。
5.1三颗 8px 记号:方点、呼吸圆、染色圆
同样 8px,但故意做成不同形状/来源,让眼睛一扫就分得开。第一颗——优先级——是方点,故意方形好让它绝不被读成状态灯。它还带这次新加的无障碍双模:
export function PriorityIndicator({ priority, className, label }) {
return (
// 有 label(卡片,旁边没有 P{n} 文字)→ 暴露成 role="img" 给读屏;
// 无 label(picker,旁边已有可见 P0·Critical 文字)→ aria-hidden,免得读两遍
<span
role={label != null ? 'img' : undefined}
aria-label={label ?? undefined}
aria-hidden={label != null ? undefined : true}
className={cn('inline-block size-2 shrink-0 rounded-[2.5px]', PRIORITY_DOT_CLASSES[priority], className)}
/>
)
}
另两颗是圆点,一颗自定 3 态、一颗吃 daemon 颜色:
// status-light:活的资源状态,running 会呼吸,颜色走 --color-state-* token
<span className={cn('inline-block size-2 shrink-0 rounded-full',
status === 'running' && 'animate-status-breathe bg-state-run',
status === 'attention' && 'bg-state-wait',
status === 'idle' && 'bg-state-idle')} />
// status-dot:颜色是 daemon 下发的任意 CSS color,内联 style
<span className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: color ?? 'rgb(var(--color-ink-faint))' }} /> // token-only 规则的「数据驱动」豁免
三者归纳成一句:方 = 优先级(静态色阶);呼吸圆 = 活动的实时状态(自定 3 态);染色圆 = 服务端状态色(看板列头、项目进度 legend、StatusPills 激活态共用)。后两者的内联颜色是「值来自 daemon 而非样式表」这条受准例外——同款豁免也用在 SegmentedProgress(按各列 count 切成彩色份的进度条)和 LabelChip(标签自己的颜色)上。
5.2relative-time:一个会自己变老的标签
活动行尾槽显示「2 分钟前」。这有两个坑:Intl.RelativeTimeFormat 构造昂贵,而每个挂载的标签都会按刷新节律重新 format;且标签依赖墙上时钟——数据一动不动,它也该从「now」慢慢变成「1 分钟前」。解法分别是 module 级缓存和定时强制重渲染。
const formatters = new Map<string, Intl.RelativeTimeFormat>() // 每个 locale 只建一次 formatter,全局复用
function formatterFor(locale: string): Intl.RelativeTimeFormat {
let formatter = formatters.get(locale)
if (!formatter) { formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); formatters.set(locale, formatter) }
return formatter
}
export function useRelativeTime(timestamp: number | null): string | null {
const [, bump] = useReducer((tick: number) => tick + 1, 0)
useEffect(() => {
if (timestamp === null) return undefined
// setInterval:标签依赖墙钟,没有 React 状态跟踪它,只能周期性重渲染让它变老
const id = setInterval(bump, REFRESH_INTERVAL_MS) // 30s 一跳 = 最小可见步进(1 分钟)的一半
return () => clearInterval(id)
}, [timestamp])
if (timestamp === null) return null
return formatRelativeTime(timestamp, i18n.language)
}
5.3配方:新增一个 Activity kind
ContentTarget 联合里加一个 { kind: 'x', projectId, taskId, xId } 变体;② 写一个 ContentKind<'x'> 注册,填 Component / useDescriptor(Tab) / keyOf,并挂上 activity 能力(useList 返回 ActivityListing、useDescriptor 返回 ActivityDescriptor、可选 create);③ 在 boot 的 register-content-kinds 里 registerContent。卡片、任务面板、右栏预览、导航都不用改——它们全走 useTaskActivities 遍历注册表,唯一按 kind 分叉的地方是 ActivityMarker(terminal 画 ❯_,其余画状态灯)。
6旅程 A:在卡片上点一个活动行
一张任务卡下面挂着几行「活动」(会话、终端)。单击 = 在右栏预览它,双击(或点行尾的 ↗)= 把它作为工作台 tab 打开。走通这条,你就掌握了新活动行的全部交互,以及它藏着的一个能炸掉整个看板的坑。
ActivityList.tsx→ 去抖分流
RowBody→ 写 previewTarget→ 预览外壳
DetailPreviewHost.tsx→ 身份区
PreviewIdentity
A.1单击和双击抢同一次点击
浏览器的原生 dblclick 之前一定先派一个 click。如果单击直接预览、双击直接打开,那双击会先预览再打开——两件事都干。解法是让单击等一小会儿(约平台双击阈值),这窗口内若来了第二击,就取消这个待发的预览、改成打开。
function handleClick() {
if (singleClickTimer.current) clearTimeout(singleClickTimer.current)
// 把预览推迟一个双击窗口,好让第二击解析成双击并取消掉它
singleClickTimer.current = setTimeout(() => { singleClickTimer.current = null; onPreview() }, DOUBLE_CLICK_WINDOW_MS) // 200ms
}
function handleDoubleClick() {
if (singleClickTimer.current) { clearTimeout(singleClickTimer.current); singleClickTimer.current = null } // 取消待发预览
onOpen()
}
还有一个易漏的收尾:这个待发定时器如果在窗口内因为「切了任务 / 列表 refetch」把行卸载了,那迟到的预览会把 previewTarget 写成一个用户已经离开的目标。所以 RowBody 用一个 unmount cleanup 清掉 pending timer。这套去抖机、清理、双击取消,是从被删的 ActivityChips 逐字搬来的——交互语义没变,变的只是「行长什么样」。
A.2预览头换 kind 会把整个看板路由炸掉(corr-01)
这是这次 review 抓出的最重的一个坑,值得完整重演。预览头新加了「身份区」,它要调对应 kind 的 activity.useDescriptor 拿 live 标题/状态。问题是:从一条 session 预览单击切到一条 terminal 预览时,看板会在同一次 React 更新里 clearPreview() 再 previewInDetail(),被批处理成一帧。
而 session 的 descriptor 链里有 useTRPC + useQuery + useSessionStore(后者又叠 context/zustand hook),terminal 的只有 useTRPC + useQuery——两者的 hook 数量必然不同。如果是同一个 fiber 先按 session 跑一遍 hook、再按 terminal 跑,React 会抛 "Rendered fewer hooks than expected"。更糟的是这个错发生在头部身份区(在只包 body 的 ContentErrorBoundary 之外),renderer 又没有更上层的 boundary,于是整个看板路由报废,复现只要两次单击。
{/* keyed by kind: previewing another kind remounts the identity, so its descriptor hook never
swaps inside a live fiber (rules of hooks — each kind's hook has its own hook count) */}
<PreviewIdentity key={target.kind} target={target} /> // kind 变 → 整个组件卸载重建 → 每个 kind 的 hook 计数各自独立
PreviewIdentity 内部再按「有没有 activity 能力」分叉成两支,每支都无条件只调一次 descriptor hook,双保险守住 rules of hooks。这条修复带了一个专门的回归测试:造两个「hook 数不同」的 kind,rerender 从一个切到另一个,断言无 key 抛错、有 key 通过。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 双击活动行既预览又打开了 tab | ActivityList.tsx:RowBody 的 singleClickTimer / DOUBLE_CLICK_WINDOW_MS |
| 切换预览的活动类型后整个看板白屏 / 报 hook 错 | DetailPreviewHost.tsx:PreviewIdentity 的 key={target.kind} 是否还在 |
行尾 ↗ 点了没反应 / 预览误触发 | ActivityList.tsx:↗ 直接调 onOpen,不过去抖;确认没被 stopPropagation 吃掉 |
| 点活动行连带把整张卡也选中了 | TaskCard.tsx:活动区 wrapper 的 onClick/onDoubleClick/onKeyDown stopPropagation |
7旅程 B:一个 session 在事件流里转成 running / attention
一个 agent 会话开始跑(running),或停下来等你点批准(attention)。它的活动行状态灯要实时变,而且——按原型——它该浮到卡片活动列表的最前面。这条旅程揭示一个「双源」设计,以及一个已知的、暂缓修的边缘缺口。
use-session-descriptor.ts→ 状态 vs 时间 双源→ 跨 kind 排序
activity-aggregate.ts→ 前 3 折叠
activity-list-model.ts
B.1状态来自事件流,时间来自控制面缓存
一个 session 的活动描述符是两个不同数据源拼出来的:live 的 status/attention 来自事件流喂养的 reduced store(useSessionStore),而 timestamp(recency)来自任务会话列表 sessions.list 的 updatedAt。
const { data } = useQuery(trpc.sessions.list.queryOptions({ taskId }, { staleTime: SESSION_LIST_STALE_TIME })) // 时间源:控制面列表缓存
const session = data?.items.find((entry) => entry.id === sessionId)
const { messages, isRunning } = useSessionStore(sessionId) // 状态源:事件流 reduced store
// attention 压过 running:一个停在审批上的回合在 reducer 里仍是 running,但对用户更该先喊出「它在等你」
const status = awaitingUser ? 'attention' : isRunning ? 'running' : 'idle'
return {
title: session?.title ?? 'Session',
status, attention: awaitingUser,
timestamp: session != null ? Date.parse(session.updatedAt) : null, // ← 和 status 不同源
}
对照 terminal 侧:控制面只带「存活」不带「忙碌」,所以 terminal 的活动描述符恒为 idle、永不 attention,时间戳用 createdAt(这是锁定决策①:本期只做前端逻辑,不下探 daemon 给 terminal 补真正的「最后输出时间」)。
function useActivityDescriptor(target: TerminalTarget): ActivityDescriptor {
const { title, timestamp } = useTerminalDescriptor(target.taskId, target.terminalId)
return { kind: 'terminal', title, status: 'idle', attention: false, timestamp } // 控制面只有存活信号
}
B.2排序在聚合层做,折叠只保留最新 3
「活跃度降序」这件事,实现放在聚合层——遍历注册表把每个 kind 的 useList 拼平,然后按 listing 上的 timestamp 稳定排序,null 沉底:
return listings
.sort(
// most recent first; null recency reads as -Infinity so unresolved rows sink below dated ones,
// and the stable sort keeps ties (and the all-null case) in registry order
(a, b) => (b.timestamp ?? Number.NEGATIVE_INFINITY) - (a.timestamp ?? Number.NEGATIVE_INFINITY),
)
.map((listing) => listing.target)
卡片再拿这个有序列表走 foldActivityRows:≤3 行不折,超了切前 3 + 出「N more」,展开后若被删到 ≤3 行要记得撤掉 collapse 按钮(否则留个折不动的按钮)。面板态永不折叠。
timestamp,而 session 的 timestamp 来自 sessions.list 缓存,这个缓存只在新建 session 时才失效重取。于是一个已经在列表里的 session 后续来了事件——状态灯(来自另一个源)会实时变绿/变琥珀,但用来排序的时间戳是旧的。后果:一个刚转 attention、原本排在第 4+ 位的 session,会卡在「还有 N 个」折叠里浮不上来,你看不到它在喊你。触发窄(同一任务 ≥4 个活动行才折叠)+ 有天然缓解(refetchOnWindowFocus 默认开,点回窗口即刷新),故暂缓。修法见 §9-I 与 §12。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| session 状态灯不变绿/琥珀 | use-session-descriptor.ts:useSessionStore 的 isRunning / pending 审批扫描 |
| 活动行时间显示对,但没按活跃度排 | activity-aggregate.ts:sort 读的是 listing 的 timestamp,不是 descriptor 的 |
| 刚变 attention 的会话被折叠埋住、点回窗口才冒头 | §9-I 那条 followup:sessions.list 只在新建时 invalidate |
| terminal 行永远不呼吸、永远显示「创建时间」 | terminal-registration.ts:status:'idle' 是有意的(决策①),非 bug |
8旅程 C:在任务面板里改标题
这条旅程要澄清一个「观感陷阱」:SavedTaskPanel.tsx 显示 +343 行、churn 最大,但它的并发同步逻辑逐字节没动——改的全是把控件从旧 Field 壳搬进新 PanelSection/PropertyRow 壳。这里把那套没动、但值得你知道的逻辑讲清,免得将来误以为它随重构变过。
面板里 title/description/priorityNote 是本地受控输入。后台随时可能 refetch 到新服务端值。规则是:只有该字段「干净」(本地值仍等于上次同步的服务端值,没有未提交编辑)且当前未聚焦时,才用新值刷新它。这样后台刷新既不打断你正在敲的字,又能让一个干净字段接住别人的并发改动。
useEffect(() => {
if (!task.data) return
if (!titleFocused.current && (syncedTitle.current === null || title === syncedTitle.current)) {
syncedTitle.current = task.data.title // 干净 + 未聚焦 → 用服务端值刷新,并对齐 synced 基线
setTitle(task.data.title)
}
// description / priorityNote 同构
}, [task.data, title, description, priorityNote])
提交侧配套:还原成服务端真值时会把 synced 基线重新对齐(保持干净、能继续接并发改动);成功 mutate 则把提交值设为新基线。换任务时靠「新挂载字段 synced 基线为 null → 视作干净未聚焦」自然重种。这次唯一相关的改动,是把标题输入从正文 Field 挪进面板 header 变成 inline 可编辑(同一 state、同一 commit/keydown handler,只是 DOM 位置变了)。
顺带一提:PR review 提过「这个 effect 的依赖数组带了 title/description/priorityNote,会每敲一键重跑一次」。裁决是有意不改——exhaustive-deps 要求带这些依赖,重跑成本只是三次 ref 相等比较、全对未变值 no-op;绕开需把这套逻辑重构成 ref/事件驱动,为零收益引入时序风险(详见 §9 尾)。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 正在敲的标题被后台刷新吞掉 | SavedTaskPanel.tsx:titleFocused ref + pristine effect 的聚焦判断 |
| 别人的并发改动没被接住 / blur 后覆盖回旧值 | 同上:syncedTitle 基线在 commitTitle 里是否对齐 |
| +Session / +Terminal 点了没落到预览 | SavedTaskPanel.tsx:createSessionAndPreview(草稿 lazy)/ createTerminalAndPreview(真 pty) |
| 创建面板(草稿)字段样式和已存面板不一致 | 有意的:draft 态仍用旧 Field(§13 验收提示 + §12 遗留头号) |
9计划 vs 实现的偏差
照计划做成的部分你已知,裂缝在偏差里。以下每条 = 计划原本怎么想 / 实际做成什么 / 为什么变。素材来自两轮 review 收敛与执行记录。
ActivityListing(list 级),聚合层按 recency 降序稳定排序
PreviewIdentity 打 key={target.kind},换 kind 整体重挂 + 回归测试
SessionContent 并 keyed on narrow 密度,只在 session 预览的回复框下出现、右对齐
576ecfd1)。PropertyRow 固定 60px 键列。固定键宽约束下的取舍,键列宽度若调整可回评。sessions.list 缓存,活跃 session 可能被折叠埋住
10心智模型补丁
ActivityDescriptor(kind/status/attention/timestamp);tab 那份是另一个 useDescriptor。一个 kind 现在有两个描述符 hook
useTaskActivities 遍历注册表,kind-agnostic;唯一按 kind 分叉的地方是 ActivityMarker
ActivityListing.timestamp,发生在任何行 resolve descriptor 之前
Field 堆键值
已存任务 + 项目两态迁到 PanelSection/PropertyRow;Field 仍在,草稿/创建面板还用它——同一右栏现在两套 chrome 并存
key),否则跨 kind 切换会崩 hook 序、连累整个路由
11新词表
| 契约层 | |
|---|---|
Activity(活动) | 一个任务名下的可交互实例——会话、终端,或未来任何 kind——被卡片/面板/预览统一列出。 |
ActivityDescriptor | 「一个活动长什么样」的 live 契约:kind / 标题 / 状态 / 是否等人 / 时间戳。行和预览头都读它。 |
ActivityListing | useList 返回的一条 = target + timestamp;时间戳单独挂 listing 上,专供列表排序/折叠(不必等行 resolve)。 |
ActivityStatus | 活动三态 running/attention/idle,直接别名 shared 层的 StatusLightStatus 防两处漂移。 |
ActivityMarker | 活动行/预览头最左的 14px 记号槽;terminal 画 ❯_ 字形、其余画状态灯——全系统唯一按 kind 分叉的呈现点。 |
| 视觉原语(新增到 components/ui) | |
status-light | 8px 正圆,代表活的资源状态,running 会呼吸,颜色走 --color-state-* token。 |
status-dot | 同尺寸圆点,但颜色是 daemon 下发的任意 CSS color(inline,token-only 的数据驱动豁免)。 |
segmented-progress | 细进度条按各 slice 的 count 切成彩色份,全 0 退化成一条中性轨。表达「整体由几部分组成」。 |
panel-section | 详情面板里可折叠分组,标题+chevron,右侧 action 槽刻意放 toggle 外(点 action 不误折叠),折叠时 body 卸载。 |
property-row | 一行 key/value,60px 定宽 key 列 + 38px 行高,堆叠成对齐小表。 |
| tasks 侧 | |
pristine 同步 | SavedTaskPanel 的规则:字段干净(本地==synced 基线)且未聚焦,才用服务端值刷新本地输入。本 PR 未改。 |
TaskCardOverlay | 拖拽时跟指针的克隆卡,纯展示无 hook、不挂活动行,避免重复注册 dnd id / 重复订阅。 |
projectInitials | 项目名 → 头像 monogram(前两词首字母 or 单词前两字符,大写,按 code point 切分容非 BMP 字符)。 |
12测试与风险地图
测试占 37%(1273 / 3404 行)。地基原语和契约逻辑钉得相当密;薄冰主要在纯视觉组件和一条设计缝上。
| 有兜底(测试钉住的行为) | 薄冰(无测试 / 已知遗留) |
|---|---|
|
|
Field(消掉两套 chrome);② 修 session 折叠埋没(小改,独立 PR)。两者都已在执行记录 §8 立项。
13验收提示(别被这些吓到)
ActivityChips.tsx显示「−168」不是丢功能:它是被删的旧「活动 chip」组件,去抖机逐字搬进了ActivityList;TaskActivityChips全仓消失是重命名的结果。- terminal 活动行永远不呼吸、永远显示「创建时间」:这是决策①有意为之(控制面不带忙碌/最后输出信号),不是没接上。
- 草稿/创建面板和已存任务面板长得不一样:有意分期。本 PR 只迁 saved + project 两态,draft 态没有原型可依,留在旧
Field。 SavedTaskPanel.tsxchurn 最大但并发逻辑没动:+343 里多数是把控件从Field平移进PanelSection/PropertyRow;pristine 同步、commit handler 逐字节未变。TaskPanelChrome的FieldTSDoc 现在半真:注释仍写「两态共用」,但 saved/project 已迁走,实际只剩 draft/create 用——本 PR 造成的文档漂移,非功能问题。- PR review 的 5 条已处置:修 3(优先级点 sr 名、标签项
aria-pressed、预览注释去 plan-speak)/ 驳 1(effect 依赖,有意保留)/ 留 1 followup(session 折叠)。全在3424db2b。
14覆盖声明
本报告基于对分支 a1e7a40c…3424db2b 全量 diff 的精读,无抽样。编排方(本 agent)亲自 Read 了报告中引用的每一个文件:registry/types.ts、activity-aggregate.ts、activity-list-model.ts、ActivityList.tsx、DetailPreviewHost.tsx、relative-time.ts、status-light/status-dot/property-row/panel-section/segmented-progress.tsx、PriorityPicker.tsx、TaskCard.tsx、BoardColumn.tsx、ProjectInfoPanel.tsx、SavedTaskPanel.tsx、project-initials.ts、LabelChip.tsx、LabelPicker.tsx、session-registration.ts、use-session-descriptor.ts、terminal-registration.ts、use-terminal-descriptor.ts,以及被删的 ActivityChips.tsx base 原貌与全部变更测试文件的用例标题——报告中所有代码片段均出自这些第一手 Read,非二手转述。
子系统「地图」由 4 个并行子 agent 建立(workbench 引擎、tasks 面板、labels/session/terminal 适配、随附文档挖掘),用于定位重心与交叉核对;偏差清单(§9)、锁定决策、遗留清单来自对规格 kanban-redesign-spec.md 与执行记录 kanban-redesign-run-log.md 的挖掘。labels/i18n 侧文件由编排方第一手补读。未逐字覆盖的:各 .test 文件的断言体内部(只读了用例标题)、locale bundle 三语键的逐条对齐(由 CI 的 check:i18n 门禁保证,已绿)。