feat/kanban-redesign:把已批准的原型复刻进看板,并沉淀一套「活动」地基

eyrie(apps/desktop 渲染层) · a1e7a40c…3424db2b · 2026-07-08 · 自包含,读完即弃

16 commits
65 文件
+2591 / −813
37% 是测试代码
PR #126 · CI 绿

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一次视觉重构,但它顺手立了一层新契约,所以正文重心在「新契约怎么流动」而非「哪些像素变了」。

1TL;DR

这个 PR 把看板的视觉照一份 owner 已逐帧认可的原型重做了一遍:列不再整列染色、卡片丢掉描述段、右栏面板从扁平键值堆改成可折叠属性组。但它真正的分量不在改皮——为了让卡片、任务面板、右栏预览三处都能不认识具体类型(session / terminal)地列出一个任务下的「活动」,它扩展了一层 ActivityDescriptor 契约(给每个活动加上 kind / 状态 / 是否等人 / 时间戳四个字段),并把「按活跃度排序 + 前 3 折叠」这件事从每行内部下沉到列表层。

动机(规格 §0 反复强调的一条铁律):原型是像素级真相源。文字描述和 hex 数值只是导航约束,最终呈现一律以浏览器渲染出的原型为准。整个改动是「沿现有缝重塑 + 补两条前端数据契约」,不是重写——右栏三态外壳、workbench registry 抽象在改动前就已存在。

2变更地图(称重)

全部 65 个文件都落在 apps/desktop 渲染层,无一行碰 daemon、API 或 DB。churn 按子系统分布如下(条长 = 相对最大子系统):

workbench 引擎 + 活动契约
~1480 行 · 含删除
tasks 卡 / 列 / 面板
~836 行
6 个 UI 原语(新增)
~492 行
session / terminal 适配
~388 行
labels(chip / picker)
~154 行
i18n / theme / lockfile
~133 行
设计重心(要细读)可放心略过(机械)
  • registry/types.ts —— 新增 ActivityDescriptor / ActivityListing / ActivityCapability.useDescriptor 契约
  • host/activity-aggregate.ts —— 跨 kind recency 排序
  • activity-list-model.ts —— 前 3 折叠决策
  • lib/relative-time.ts —— per-locale 缓存 + 活标签
  • DetailPreviewHost.tsx —— 预览身份区 + key-by-kind 重挂
  • 6 个原语:status-light / status-dot / segmented-progress / panel-section / property-row / tooltip
  • PriorityIndicator a11y 双模、projectInitials 纯函数
  • ActivityChips.tsx(−318)—— 旧「活动 chip」组件整体删除,去抖机逐字搬进 ActivityList(改名 chip→row),非新逻辑
  • SavedTaskPanel.tsx 的多数增删 —— 把已有 JSX 从 Field平移PanelSection/PropertyRow 壳;并发同步逻辑逐字节未动
  • bun.lock(+61)—— 只加了 @radix-ui/react-tooltip 一个依赖
  • 全域注释/prop 文案 "chip"→"row" 改名
  • .test 的 stub 适配(补 useDescriptor 桩、{target,timestamp} 返回形)

诚实的称重:workbench 那条最长的条里,约三分之一是 ActivityChips → ActivityList 的逐字搬运(改名),真正新增的设计约 380 行。tasks 那条里约六成是 Field → PanelSection 的渲染层平移。所以「+2591」的观感偏大——真正需要理解的新机制集中在契约层与 6 个原语。

3架构一图流

没有进程/通信结构变化,唯一「架构级」的改动是:「一个活动长什么样」的 live 数据契约多了一层,且排序信号从行内下沉到列表层

以前 · 活动 = 一行 chip,无序

useTaskActivities
拼接(registry 序)
ContentTarget[]
ActivityChips
chip 读 TabDescriptor
title / badge
recency
无排序、无折叠

现在 · 活动 = 有序的行,独立契约

useTaskActivities
拼接 + recency 降序
ContentTarget[](有序)
ActivityList
行读 ActivityDescriptor
status / attention / timestamp
foldActivityRows
卡片保留最新 3
前 3 / N more

关键差别在最后那条:以前一个活动的时间戳(如果有的话)只活在它自己的行渲染里,列表拿不到,所以既排不了序也折叠不了。现在时间戳提前一层挂到 ActivityListing 上,列表在任何行 resolve 自己的 live 状态之前就知道全序——这是「卡片只留最新 3 行」能成立的前提。

4数据与状态先行

先把这次新立的类型形状过一遍,后面三条旅程都用它们。三个都在同一个文件,是这次改动的契约心脏。

features/workbench/registry/types.ts新增契约(节选)
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 要在 ActivityListingActivityDescriptor 里各放一份、而不是只放行内?源码注释把理由讲透了:排序和折叠是列表级决策,发生在任何行 resolve 自己 descriptor 之前;只放 descriptor 会让列表对 recency「失明」。

features/workbench/registry/types.tsActivityCapability —— 一个 kind 成为「活动」的能力
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 }>>
}

注意这里现在有两个 useDescriptorContentKind 级的返回 TabDescriptor(title/icon/badge,给 tab 条用,词表是 running/attention/unread);ActivityCapability 级的返回 ActivityDescriptor(给活动行和预览头用,词表是 running/attention/idle)。两者并存、各管一处——旧的 chip 读前者,新的行读后者,所以 live 状态词表悄悄从「有 unread」换成了「有 idle」。

features/workbench/components/activity-list-model.ts折叠决策的返回形
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,但故意做成不同形状/来源,让眼睛一扫就分得开。第一颗——优先级——是点,故意方形好让它绝不被读成状态灯。它还带这次新加的无障碍双模:

features/tasks/components/PriorityPicker.tsxPriorityIndicator(方点 + a11y 双模)
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 颜色:

components/ui/status-light.tsx(左)· components/ui/status-dot.tsx(右)呼吸灯 vs 染色点
// 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 级缓存和定时强制重渲染。

renderer/lib/relative-time.tsper-locale 缓存 + 活标签
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 返回 ActivityListinguseDescriptor 返回 ActivityDescriptor、可选 create);③ 在 boot 的 register-content-kindsregisterContent卡片、任务面板、右栏预览、导航都不用改——它们全走 useTaskActivities 遍历注册表,唯一按 kind 分叉的地方是 ActivityMarker(terminal 画 ❯_,其余画状态灯)。

6旅程 A:在卡片上点一个活动行

一张任务卡下面挂着几行「活动」(会话、终端)。单击 = 在右栏预览它,双击(或点行尾的 )= 把它作为工作台 tab 打开。走通这条,你就掌握了新活动行的全部交互,以及它藏着的一个能炸掉整个看板的坑。

全景 · 涉及 4 个文件
卡片行
ActivityList.tsx
去抖分流
RowBody
写 previewTarget 预览外壳
DetailPreviewHost.tsx
身份区
PreviewIdentity

A.1单击和双击抢同一次点击

浏览器的原生 dblclick 之前一定先派一个 click。如果单击直接预览、双击直接打开,那双击会先预览再打开——两件事都干。解法是让单击等一小会儿(约平台双击阈值),这窗口内若来了第二击,就取消这个待发的预览、改成打开。

features/workbench/components/ActivityList.tsxRowBody —— 单击去抖 + 双击取消
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,于是整个看板路由报废,复现只要两次单击。

features/workbench/components/DetailPreviewHost.tsx修复:给身份区打 key,换 kind = 重挂
{/* 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
症状从哪下手
双击活动行既预览又打开了 tabActivityList.tsxRowBodysingleClickTimer / DOUBLE_CLICK_WINDOW_MS
切换预览的活动类型后整个看板白屏 / 报 hook 错DetailPreviewHost.tsxPreviewIdentitykey={target.kind} 是否还在
行尾 点了没反应 / 预览误触发ActivityList.tsx 直接调 onOpen,不过去抖;确认没被 stopPropagation 吃掉
点活动行连带把整张卡也选中了TaskCard.tsx:活动区 wrapper 的 onClick/onDoubleClick/onKeyDown stopPropagation

7旅程 B:一个 session 在事件流里转成 running / attention

一个 agent 会话开始跑(running),或停下来等你点批准(attention)。它的活动行状态灯要实时变,而且——按原型——它该浮到卡片活动列表的最前面。这条旅程揭示一个「双源」设计,以及一个已知的、暂缓修的边缘缺口。

全景 · 涉及 3 个文件
事件流 reduced store
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.listupdatedAt

features/session/api/use-session-descriptor.ts双源拼装
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 补真正的「最后输出时间」)。

features/terminal/terminal-registration.tsterminal 恒 idle
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 沉底:

features/workbench/host/activity-aggregate.ts跨 kind recency 排序
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 按钮(否则留个折不动的按钮)。面板态永不折叠。

以前
session 列表、terminal 列表首尾拼接
registry 顺序,无排序
flex-wrap 平铺全部 chip,无折叠
现在
拼平 + 读 listing 的 timestamp
recency 降序稳定排序,null 沉底
卡片保留最新 3 行,其余折进「N more」
🟠 已知缺口(PR review 抓出,暂缓修):排序和折叠只吃 timestamp,而 session 的 timestamp 来自 sessions.list 缓存,这个缓存只在新建 session 时才失效重取。于是一个已经在列表里的 session 后续来了事件——状态灯(来自另一个源)会实时变绿/变琥珀,但用来排序的时间戳是旧的。后果:一个刚转 attention、原本排在第 4+ 位的 session,会卡在「还有 N 个」折叠里浮不上来,你看不到它在喊你。触发窄(同一任务 ≥4 个活动行才折叠)+ 有天然缓解(refetchOnWindowFocus 默认开,点回窗口即刷新),故暂缓。修法见 §9-I 与 §12。
排查路标 · 旅程 B
症状从哪下手
session 状态灯不变绿/琥珀use-session-descriptor.tsuseSessionStoreisRunning / pending 审批扫描
活动行时间显示对,但没按活跃度排activity-aggregate.ts:sort 读的是 listing 的 timestamp,不是 descriptor 的
刚变 attention 的会话被折叠埋住、点回窗口才冒头§9-I 那条 followup:sessions.list 只在新建时 invalidate
terminal 行永远不呼吸、永远显示「创建时间」terminal-registration.tsstatus:'idle' 是有意的(决策①),非 bug

8旅程 C:在任务面板里改标题

这条旅程要澄清一个「观感陷阱」:SavedTaskPanel.tsx 显示 +343 行、churn 最大,但它的并发同步逻辑逐字节没动——改的全是把控件从旧 Field 壳搬进新 PanelSection/PropertyRow 壳。这里把那套没动、但值得你知道的逻辑讲清,免得将来误以为它随重构变过。

面板里 title/description/priorityNote 是本地受控输入。后台随时可能 refetch 到新服务端值。规则是:只有该字段「干净」(本地值仍等于上次同步的服务端值,没有未提交编辑)且当前未聚焦时,才用新值刷新它。这样后台刷新既不打断你正在敲的字,又能让一个干净字段接住别人的并发改动。

features/tasks/components/SavedTaskPanel.tsxpristine 同步 effect(本 PR 未改)
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.tsxtitleFocused ref + pristine effect 的聚焦判断
别人的并发改动没被接住 / blur 后覆盖回旧值同上:syncedTitle 基线在 commitTitle 里是否对齐
+Session / +Terminal 点了没落到预览SavedTaskPanel.tsxcreateSessionAndPreview(草稿 lazy)/ createTerminalAndPreview(真 pty)
创建面板(草稿)字段样式和已存面板不一致有意的:draft 态仍用旧 Field(§13 验收提示 + §12 遗留头号)

9计划 vs 实现的偏差

照计划做成的部分你已知,裂缝在偏差里。以下每条 = 计划原本怎么想 / 实际做成什么 / 为什么变。素材来自两轮 review 收敛与执行记录。

A · recency 只放在每行的 descriptor 里,聚合层按 registry 顺序拼接(先全部 session、再全部 terminal) 把 timestamp 抬到 ActivityListing(list 级),聚合层按 recency 降序稳定排序
架构层唯一 must-fix。原型明确按活跃度混排,而排序信号只活在行内、列表拿不到,导致排序和「前 3 折叠」都消费不了 recency。不修的话「按活跃度排」这类后续迭代永远被这道缝挡住。
B · 预览身份区假设「host 挂载后 previewed kind 固定」,直接调 descriptor hook PreviewIdentitykey={target.kind},换 kind 整体重挂 + 回归测试
交叉审查抓出的 must-fix(corr-01)。session/terminal 的 hook 数不同,同帧切换会崩 hook 序,且错在 error boundary 之外 → 炸整个看板路由。见旅程 A.2。
C · 预览外壳统一渲染「预览模式」脚注 脚注从外壳删掉、下移进 SessionContent 并 keyed on narrow 密度,只在 session 预览的回复框下出现、右对齐
亲读原型确认脚注是会话独有物,terminal 预览底部到状态栏全空白;外壳不该渲染 per-kind chrome。
D · attention 英文文案 "Needs input" 改成 "Needs attention"
attention 由「pending approval 或 ask-user」触发,审批不是文本输入,"Needs input" 语义窄;对齐 zh 的「需介入」。
E · 只把 dark 盘的 state 色调成绿/琥珀 light / paper 两套盘的 stateWait 调琥珀、stateIdle 调中性灰,镜像 dark 的语义关系
交叉审查发现 light/paper 下 idle 灯呈琥珀金、attention 反而不对——静息 vs 需介入语义颠倒。fix2 一并核查所有 state-wait 消费方读感仍对。
F · 任务面板标签 chip 的移除「×」常驻 「×」改成 chip hover / 键盘聚焦时才显现(宽度折叠、保持 tab 序)
验收期对照原型发现原型是干净 chip 形态,编排方直接改掉(commit 576ecfd1)。
G · 只想把已存面板的标签添加入口改成 icon-only「+」 LabelPicker 是共享组件,改动连带改变了草稿创建面板的标签添加形态
备案为「同一 affordance、两处都成立」,非缺陷,但属于溢出到 draft 面板的副作用。
H · 仓库键沿用 "Repositories" en 缩短为 "Repos"(zh 两份未动)
塞不进 PropertyRow 固定 60px 键列。固定键宽约束下的取舍,键列宽度若调整可回评。
I · session 活跃度靠 timestamp 排序即可 暂缓:timestamp 来自只在新建时失效的 sessions.list 缓存,活跃 session 可能被折叠埋住
PR review 抓出(Codex+Claude 均 P2)。触发窄 + 有 window-focus 缓解,列为 followup。倾向修法:让 attention/running 无视 timestamp 直接浮到折叠线之上(比 bump 缓存更贴产品直觉)。

10心智模型补丁

一个「活动」的 live 数据就是它 tab 上那份(title/icon/badge) 活动有独立的 ActivityDescriptor(kind/status/attention/timestamp);tab 那份是另一个 useDescriptor。一个 kind 现在有两个描述符 hook
卡片/面板/预览里想列活动,要认识 session 和 terminal 它们全走 useTaskActivities 遍历注册表,kind-agnostic;唯一按 kind 分叉的地方是 ActivityMarker
活动的排序/折叠可以在渲染每行时决定 排序和折叠是 list 级决策,读 ActivityListing.timestamp,发生在任何行 resolve descriptor 之前
8px 的圆点就是状态点 三颗都 8px:=优先级、呼吸圆=活动实时状态(token)、染色圆=daemon 状态色(inline)。形状/来源都不同
右栏详情面板用扁平 Field 堆键值 已存任务 + 项目两态迁到 PanelSection/PropertyRowField 仍在,草稿/创建面板还用它——同一右栏现在两套 chrome 并存
terminal 活动行会像 session 一样显示实时忙碌 terminal 恒 idle、永不 attention、时间用 createdAt——控制面不带忙碌信号,这是决策①的有意近似
切换预览的活动只影响下方内容区 预览头身份区也随 kind 重挂(key),否则跨 kind 切换会崩 hook 序、连累整个路由

11新词表

契约层
Activity(活动)一个任务名下的可交互实例——会话、终端,或未来任何 kind——被卡片/面板/预览统一列出。
ActivityDescriptor「一个活动长什么样」的 live 契约:kind / 标题 / 状态 / 是否等人 / 时间戳。行和预览头都读它。
ActivityListinguseList 返回的一条 = target + timestamp;时间戳单独挂 listing 上,专供列表排序/折叠(不必等行 resolve)。
ActivityStatus活动三态 running/attention/idle,直接别名 shared 层的 StatusLightStatus 防两处漂移。
ActivityMarker活动行/预览头最左的 14px 记号槽;terminal 画 ❯_ 字形、其余画状态灯——全系统唯一按 kind 分叉的呈现点。
视觉原语(新增到 components/ui)
status-light8px 正圆,代表活的资源状态,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 行)。地基原语和契约逻辑钉得相当密;薄冰主要在纯视觉组件和一条设计缝上。

有兜底(测试钉住的行为)薄冰(无测试 / 已知遗留)
  • 排序:跨 kind 交错、null 沉底、同 recency 保 registry 序(activity-aggregate.test
  • 折叠:≤limit 不折 / 超限折 / 展开可 collapse / shrink-under-limit 撤 collapse(activity-list-model.test
  • 行交互:单击 deferred 预览、双击开 tab、 直开、unmount 取消 pending(ActivityList.test
  • corr-01 回归:跨 kind rerender 不违 hook 序、内容崩溃被 boundary 兜住(DetailPreviewHost.test
  • 双源描述符:session running/attention 优先级、terminal 恒 idle(use-session-descriptor.test / use-terminal-descriptor.test
  • 相对时间:量级选择、numeric auto 词形、fake-timer 跨分钟刷新、null 短路
  • 6 个原语各有 test(呼吸/inline 色/丢 0 slice/折叠/hint 位/focus 才出 tooltip)
  • projectInitials 8 例(含非 BMP 按 code point 切分)
  • 🔴draft 面板两套 chrome 并存:草稿/创建面板仍用旧 Field,每加新面板都要二选一、必然漂移。前置:先出 draft 态设计稿。头号遗留。
  • 🟠session 活跃度被折叠埋住:见旅程 B / §9-I。倾向修法 = attention/running 浮到折叠线之上。
  • 🟡pristine 同步 effect 无测试:SavedTaskPanel.test 完全没覆盖三字段的刷新/不刷新分支(改动前也没有,这套并发逻辑一直裸奔)。
  • 🟡纯视觉组件无渲染测试:TaskCard、PriorityIndicator 的 a11y 双模、StatusPills、ProjectInfoPanel、BoardColumn 列头、empty:hidden hairline 都无断言。
  • 拖拽 overlay 丢父任务 chip:克隆卡写死 childCount=0,拖动瞬间「N subtasks」chip 消失。base 既有缺口,非回归。
  • 两个 timestamp 同源无强制ActivityListingActivityDescriptor 的时间戳靠各 kind 实现自觉一致,无编译/运行期保证。
合并前值得留意:CI 已绿、E2E 已过、review 已收敛,无阻塞项。若要在合并后继续迭代,头两件事按优先级是——① 先出 draft 态设计稿再一次迁完并删 Field(消掉两套 chrome);② 修 session 折叠埋没(小改,独立 PR)。两者都已在执行记录 §8 立项。

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

14覆盖声明

本报告基于对分支 a1e7a40c…3424db2b 全量 diff 的精读,无抽样。编排方(本 agent)亲自 Read 了报告中引用的每一个文件:registry/types.tsactivity-aggregate.tsactivity-list-model.tsActivityList.tsxDetailPreviewHost.tsxrelative-time.tsstatus-light/status-dot/property-row/panel-section/segmented-progress.tsxPriorityPicker.tsxTaskCard.tsxBoardColumn.tsxProjectInfoPanel.tsxSavedTaskPanel.tsxproject-initials.tsLabelChip.tsxLabelPicker.tsxsession-registration.tsuse-session-descriptor.tsterminal-registration.tsuse-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 门禁保证,已绿)。