PR #88:给桌面端装上「不随视图卸载而失忆」的 Agent 会话界面

figuretu/eyrie · main...pr-88(HEAD 4925ba2) · 2026-06-21 · 自包含,读完即弃

21 commits
61 文件
+3537 / −87
~11% 是测试代码
作者 ifeichuan

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 pr-88 分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

相对 main,这个 PR 是净新增apps/desktop/src/renderer/features/session/ 整个目录在 main 上一个文件都没有。换句话说,桌面端这是第一次有了能跟 Agent 对话的会话界面——消息线程、输入框、工具卡片、approval/ask-user 交互,全是新的。

核心设计只有一句话:会话的规约态(有哪些消息、Agent 是否在跑、token 用量)不放进 React 组件,而是放进一个「连接级 store」——它的寿命跟着 WebSocket 连接走,比任何组件都活得久。于是你从会话切回看板、再切回来,状态不丢、中途的事件也没漏。

配套还有两件事:后端多发一种 message.user 事件,让用户自己发的话也进时间线、渲染成右侧气泡(以前时间线只有 Agent 的输出);以及一个小修——建 task↔repo 关系时若没指定工作目录,从 git 仓库根推一个默认值。

关于 PR 标题里的「refactor phase 1/2」:那是分支内部的演进(先写了视图本地的 useSessionRuntime + replay/catchup/live 阶段机,又把它们删掉换成 store)。这段历史对 main 不可见——diff 里你看到的就是最终形态,没有「旧管线」残留可对照。本文因此按「新功能」来讲,只在关键设计点借代码里的注释点一句「为什么没用更直觉的做法」。

2变更地图(称重)

3624 改动行里,真正承载设计的高度集中在 desktop 的 session feature;其余是依赖锁文件、后端的一小段事件发射、和零散的 task/board 接线。

desktop/session
~2300 行 · 设计重心
bun.lock
471 行 · 机械
daemon/tests
257 行
desktop/styles
116 行
daemon/src
91 行
desktop/tasks+board
~150 行
packages/*
~70 行
设计重心(要细读)可放心略过
session/api/session-runtime-store.ts — 连接级 store 本体
session/models/session-store.ts — 事件 reducer + 全部状态形状
session/components/SessionThread.tsx — 线程渲染 + 三层错误隔离
session/components/SessionView/SessionContent — 智能层/纯渲染层拆分
session/components/FloatingComposer.tsx — 输入框四态机
daemon/src/agent/{service,event-codec,types}.tsmessage.user 发射链路
bun.lock(加 zustand / @incremark/* / react-query-devtools)
packages/db/migrations/*(snapshot 同步,无新列)
各类 tool 卡组件(BashTool / EditTool / ApprovalTool / AskUserTool)是直白的展示组件
三个 use-*-scroll hook 是独立的滚动行为
vitest.config.ts / 各 daemon 测试的 1-3 行同步

测试占比约 11%(388 / 3624 行)。集中在两处:store 的规约/hydration 单测,和 daemon 的 message.user 编解码 + 发射顺序测试。整个组件层(线程、输入框、错误边界)没有任何组件测试——详见第 11 节。

3架构一图流

变化的本质是「多了一层、且这层不在组件树里」。before 侧看板路由里只有看板;after 侧看板能切到会话视图,而会话的状态住在组件之外的 store 里,由 React Query 缓存的事件流喂养。

以前 · 没有会话界面

board.tsx
左栏只渲染
<Board>
session feature
main 上不存在
store
daemon
timeline 只含 Agent 事件
RQ cache

现在 · 看板 ⇄ 会话,状态住在树外

board.tsx
openSession 切换
SessionView
RQ cache
订阅 cache 事件 → ingest
连接级 store
daemon
+ message.user 事件
timeline

关键点:store 不挂在 SessionView 下,而是登记在一张以 QueryClient(= 一条连接)为键的注册表里。组件卸载 ≠ store 销毁——这正是「切回来不丢」的物理基础,下一节展开。

4数据与状态先行

先把形状摆出来,后面旅程才有词汇。只看形状,不讲行为。

规约态:会话被折叠成什么样子

整个会话最终被折叠成一个 SessionState:一串消息、是否在跑、用量。每条消息由若干 part(片段)组成,part 用一个 type 字段区分文本 / 推理 / 工具调用 / 审批 / 问询。

renderer/features/session/models/session-store.ts真实代码(节选)
export interface Message {
  id: string                          // stable identifier
  role: 'user' | 'assistant'
  parts: MessagePart[]
  status: 'complete' | 'running'      // 「running」= 这条还在接 delta,可继续往里追加 part
}

export interface SessionState {
  messages: Message[]
  isRunning: boolean                  // 驱动输入框「中止」按钮、底部跳动的圆点
  usage: UsageInfo | null
}

事件联合体:reducer 认得的 12 种 SSE 事件

后端通过 WebSocket 推来的每个事件都有个 type。reducer 只认这 12 种,其余(如 heartbeat)一律跳过。注意倒数第二种 message.user——这是本 PR 新加的,让用户消息也能进规约。

renderer/features/session/models/session-store.ts真实代码(节选)
export type SessionEvent =
  | { type: 'run.started'; ... }
  | { type: 'run.completed'; status?: string; ... }
  | { type: 'message.delta'; role: string; text: string; ... }   // role 为 'reasoning' 时归到推理 part
  | { type: 'message.completed'; ... }
  | { type: 'tool.started'; toolCallId: string; name: string; input: unknown; ... }
  | { type: 'tool.completed'; toolCallId: string; output: unknown; status: ...; ... }
  | { type: 'approval.requested'; approvalId: string; toolName: string; options: ...; ... }
  | { type: 'approval.resolved'; approvalId: string; optionId: string; effect: 'approve' | 'deny'; ... }
  | { type: 'input.requested'; inputRequestId: string; questions: ...; title?: string; ... }
  | { type: 'input.resolved'; inputRequestId: string; action: 'accept' | 'decline'; ... }
  | { type: 'usage.updated'; inputTokens?: number; ...; ... }
  | { type: 'message.user'; text: string; parts?: Array<...>; ... }   // ← 本 PR 新增

运行态外壳:在规约态上加一个 isHydrating

store 对外暴露的状态在 SessionState 之上多一个布尔 isHydrating——「首轮回放还没结算完」。它存在的唯一目的:回放历史时把进入动画压住,别让一屏老消息逐条淡入。

renderer/features/session/api/session-runtime-store.ts真实代码(节选)
export interface SessionRuntimeState extends SessionState {
  isHydrating: boolean // first replay burst still settling — suppress enter animation while true
}

后端侧:message.user 事件 + 「全是文本才留 parts」守卫

后端的事件类型同样加了 message.user。它可以带 parts(为将来的图文混排预留),但有个守卫 allTextParts:只有当所有 part 都是文本时才保留 parts,一旦混了图片就丢弃——避免把宿主机文件路径写进时间线。

apps/daemon/src/agent/types.ts真实代码(节选)
export type AgentInputPart =
  | { type: 'text'; text: string }
  | { type: 'image'; path: string; mimeType: string }

/** Narrows a parts list to text-only blocks, the only shape safe to persist on a user event today. */
export function allTextParts(
  parts: AgentInputPart[] | undefined,
): parts is Array<{ type: 'text'; text: string }> {
  return parts !== undefined && parts.length > 0 && parts.every((part) => part.type === 'text')
}

5底座:连接级 store + reducer

三条旅程都踩在同一个机制上:一个活在组件树之外、订阅 React Query 缓存、把事件增量折叠成 SessionState 的 store。先把这台「发动机」讲透,旅程里就只讲各自特有的部分。

5.1连接级注册表:store 的寿命挂在连接上

问题:组件卸载了,状态怎么不丢?答案是状态根本不在组件里。store 登记在一张 WeakMap 里,键是 QueryClient(在 Eyrie 里一个 QueryClient 对应一条 daemon 连接)。同一个 session 第二次访问拿到的是同一个 store 对象;只有当整条连接的 QueryClient 被回收时,这批 store 才随之被 GC。

renderer/features/session/api/session-runtime-store.ts真实代码(节选)
// Connection-scoped registry: stores live as long as their QueryClient, surviving view unmounts so a
// reopened session reads current state instead of rebuilding. GC'd with the connection's QueryClient.
const registry = new WeakMap<QueryClient, Map<string, SessionRuntimeStore>>()

export function getOrCreateSessionStore(queryClient, connectionId, sessionId): SessionRuntimeStore {
  let byId = registry.get(queryClient)
  if (!byId) { byId = new Map(); registry.set(queryClient, byId) }
  const existing = byId.get(sessionId)
  if (existing) return existing          // 第二次进同一个 session → 复用,不重建
  const created = createSessionStore(queryClient, connectionId, sessionId)
  byId.set(sessionId, created)
  return created
}

「为什么不直接用组件 state」:组件 state 随卸载销毁,切回来只能从头重放整段历史;而这里 store 在树外,读的是当前折叠结果。代价是要自己管订阅和销毁(见 dispose),换来卸载零成本。

5.2ingest:append-only 缓存的增量规约

store 创建时订阅 React Query 的 timeline 缓存。每次缓存变动触发 ingest。它维护一个游标 processed,只处理游标之后的新事件——这就是「增量」:一条新 delta 进来,不会把整段历史重折一遍。

缓存只增不减,除非一次 resync(重新同步)把它清成 []。所以 ingest 用一个朴素的判据识别 resync:缓存长度比游标还小 = 它被重置过 → 推倒重建,并重新进入 hydration(让这次重放也是静默的)。

renderer/features/session/api/session-runtime-store.ts真实代码(节选)
const ingest = () => {
  const timeline = queryClient.getQueryData(queryKey) ?? emptyTimeline
  // length below the cursor means it was reset — rebuild from scratch and re-enter hydration.
  if (timeline.length < processed) {
    reduced = initialSessionState
    processed = 0
    store.setState({ ...reduced, isHydrating: true })
    armCap()                                  // 重新拉起 2s 兜底计时
  }
  let changed = false
  while (processed < timeline.length) {
    const envelope = timeline[processed]
    const event = envelope.event as { type: string }
    if (isSessionEvent(event)) {              // 跳过 heartbeat 等非会话事件
      reduced = sessionReducer(reduced, event, envelope.id)   // envelope.id 当作消息的稳定 id
      changed = true
    }
    processed++
  }
  if (changed) {
    store.setState({ messages: reduced.messages, isRunning: reduced.isRunning, usage: reduced.usage })
    armQuiet()                                // 每收一批就重置「安静」计时
  }
}

注意 envelope.id 被直接当作消息的 id 传给 reducer。这是本 PR 的一个明确选择:消息 id 用事件信封的稳定 id,而不是渲染时临时生成的 UUID——重放同一段历史会得到同一批 id,React 的 key 稳定,不会因为重挂载而整段重绘。

5.3hydration:用「安静 + 兜底」两条计时器判断回放结束

「回放什么时候算放完」没有明确信号,所以这里用启发式:两个计时器,谁先到谁结束。安静计时(quiet, 200ms)每收到一批事件就重置——一旦 200ms 没有新事件,说明回放猝发已经停了;兜底计时(cap, 2000ms)从不重置——即使事件一直流(一个从打开就在直播的会话),2 秒后也强制结束 hydration,免得动画永远被压着。

renderer/features/session/api/session-runtime-store.ts真实代码(节选)
// ponytail: heuristic hydration window. quiet settles once the replay burst goes idle; cap bounds a
// session that streams from first open. Replace both with a daemon replay.done signal if one lands.
const HYDRATION_QUIET_MS = 200
const HYDRATION_CAP_MS = 2000

const settle = () => {
  clearTimeout(quietTimer); clearTimeout(capTimer)
  if (store.getState().isHydrating) store.setState({ isHydrating: false })
}
const armQuiet = () => { clearTimeout(quietTimer); quietTimer = setTimeout(settle, HYDRATION_QUIET_MS) } // 每个事件重启
const armCap   = () => { clearTimeout(capTimer);   capTimer   = setTimeout(settle, HYDRATION_CAP_MS) }   // 绝对上限,不重启

源码注释里这句 ponytail: 是作者自用的标记(相当于 TODO/备注),意思是「等 daemon 有了 replay.done 信号就把这套启发式换掉」。本 PR 里它就是会话首屏不闪动画的全部依据。

5.4reducer:所有改动都落在「最后那条还开着的」消息上

reducer 是纯函数:(state, event, eventId) => state,按 type 查一张 handler 表分发。关键不在分发,而在两个小工具——mapOpeneditParts:增量内容(delta、工具调用、审批……)永远只往「最后一条 role=assistant 且 status=running」的消息里塞。

renderer/features/session/models/session-store.ts真实代码(节选)
/** Applies a transform to the last running assistant message in the array */
function mapOpen(messages: Message[], fn: (m: Message) => Message): Message[] {
  let idx = -1
  for (let i = messages.length - 1; i >= 0; i--) {
    const msg = messages[i]
    if (msg && msg.role === 'assistant' && msg.status === 'running') { idx = i; break }
  }
  if (idx < 0) return messages           // 没有开着的消息 → 原样返回(事件被无害忽略)
  const next = [...messages]; next[idx] = fn(messages[idx]); return next
}

于是会话的生命周期就清晰了:run.started 一条新的 running assistant 消息;中间的 delta/tool/approval 都被 mapOpen 投递进这条;run.completed 把它成 complete。用户消息则是另一条完整消息,直接 append。

renderer/features/session/models/session-store.ts真实代码(节选)
function handleRunStarted(state, _event, eventId): SessionState {
  return { ...state, isRunning: true,
    messages: [...state.messages, { id: eventId, role: 'assistant', parts: [], status: 'running' }] }
}

function handleMessageDelta(state, event): SessionState {
  const partType = event.role === 'reasoning' ? 'reasoning' : 'text'
  return editParts(state, (parts) => {
    const last = parts[parts.length - 1]
    if (last?.type === partType) parts[parts.length - 1] = { type: partType, text: (last.text ?? '') + event.text } // 同类型续写
    else parts.push({ type: partType, text: event.text })  // 换了类型就起新 part
  })
}
配方 · 让一种新 SSE 事件显示出来:① 在 SessionEvent 联合体加成员 + 把 type 字符串塞进 SESSION_EVENT_TYPES(否则 isSessionEvent 直接过滤掉);② 写一个 handleXxx 把它折进某条消息的 parts,并登记到 handler 表;③ 在 SessionThreadgroupParts + 渲染分支里给这种 part 一个组件。三步缺一,事件要么被丢、要么进了状态但渲染不出来。

6旅程 A:从会话切走、再切回来,状态不丢

这是 PR 的招牌行为。走通这条,你就明白「卸载不失忆」是哪几块拼出来的:看板里的挂载点、一个故意不清理的订阅、和上一节那个树外 store。

全景 · 涉及 5 个文件
挂载
board.tsx
激活订阅
use-session-activation
读 store
use-session-store
ingest/reduce
§5
渲染
SessionThread

A.1挂载点,与那个「黏住」的订阅

唯一的接入点是看板路由。board.tsx 多了个 openSession 局部状态:非空时,左栏不再渲染 <Board> 而是渲染 <SessionView>,顶上加一条「← Board」返回栏。用 key 绑 sessionId,保证切换不同会话时整棵子树重建。

renderer/routes/board.tsx真实代码(节选)
{openSession ? (
  <div className="flex min-h-0 flex-1 flex-col">
    <button onClick={() => setOpenSession(null)}>← Board</button>   // 返回 = 仅卸载视图,不动 store
    {'sessionId' in openSession
      ? <SessionView key={openSession.sessionId} sessionId={openSession.sessionId} />
      : <SessionView key={`draft-${openSession.taskId}`} taskId={openSession.taskId}
          onSessionCreated={(id) => setOpenSession({ sessionId: id })} />}
  </div>
) : (
  <Board ... />
)}

会话视图一挂载,useSessionActivation 就把「调度器唯一的 agent-event 订阅」指向这个 session。这里藏着整条旅程的关键——它没有 cleanup:卸载时不把订阅清掉。源码注释把意图写得很清楚。

renderer/features/session/hooks/use-session-activation.ts真实代码(全文)
// The selection is sticky: unmount does NOT clear it, so navigating away keeps the stream live and
// events keep filling the timeline cache — the connection-scoped store reduces them in the background,
// so reopening reads current state with no gap. Opening a different session swaps the selection;
// clearing it is reserved for an explicit close (not wired yet).
export function useSessionActivation(sessionId: string | null) {
  const { setActiveAgentSession } = useClient()
  useEffect(() => {
    setActiveAgentSession(sessionId)
  }, [sessionId, setActiveAgentSession])   // 注意:return 处没有清理函数
}

读取侧极简:useSessionStore 通过注册表拿到(或惰性创建)该 session 的 store,用 zustand 的 useStore 订阅它。组件要的只是「当前折叠结果」,至于这结果是这次刚算的还是上次切走时背景算好的,组件并不关心。

renderer/features/session/api/use-session-store.ts真实代码(全文)
export function useSessionStore(sessionId: string): SessionRuntimeState {
  const { queryClient, connection } = useClient()
  const { store } = getOrCreateSessionStore(queryClient, connection.id, sessionId)
  return useStore(store)   // 重挂载 = 重新订阅同一个 store,读到的是当前态
}

A.2卸载之后,事件照样被折叠

把两件事拼起来,「不失忆」就成立了。直觉做法(也是分支早期写过、后来删掉的那套)是把规约逻辑放进组件 hook:组件在,就规约;组件没了,规约也停。本 PR 把规约搬到树外的 store + 黏住的订阅,于是卸载期间事件继续进缓存、store 继续折叠。

直觉做法(被否决)
视图本地 hook 规约 timeline
切走 → 组件卸载 → 规约停止
中途事件堆在缓存里没人折
切回 → 从头重放整段历史
本 PR
store 在树外订阅 RQ 缓存
切走 → 订阅黏住,store 不销毁
中途事件继续进缓存 → ingest 持续折叠
切回 → 读当前折叠结果,无缝续上

再切回来时,SessionThread 还会顺手用 isHydrating 决定要不要播放进入动画——不过此时 store 早已 settle,所以切回是「直接出现」而非「逐条淡入」。淡入只在这个 store 第一次建立、首轮回放那 200ms~2s 窗口里发生。

renderer/features/session/components/SessionThread.tsx真实代码(节选)
<motion.div
  key={msg.id}                              // 用 envelope.id,重挂载 key 不变 → 不重新触发动画
  initial={isHydrating ? false : { opacity: 0 }}   // hydrating 时直接最终态,不淡入
  animate={{ opacity: 1 }}
>
排查路标 · 旅程 A
症状从哪下手
切回会话后 timeline 是空的 / 卡在旧状态session-runtime-store.tsingest 的游标 processed、cache 订阅是否还活着
切走再切回,中途的消息丢了use-session-activation.ts:确认 effect 没被加上 cleanup(清理 = 订阅断 = 丢事件)
同一会话被建了两个 store / 状态对不上getOrCreateSessionStore 的注册表命中逻辑,键是 QueryClient
首屏老消息逐条淡入很碍眼 / 或该淡入却没淡isHydratingarmQuiet/armCapSessionThreadinitial
换会话后旧会话还在后台收事件这是设计如此(订阅黏住,靠「选中另一个 session」切换);显式关闭清理「尚未接线」

7旅程 B:发一条消息

这条旅程横跨前后端:从一个还不存在的会话开始,到用户气泡出现在屏幕上。途中你会看到「智能层/纯渲染层」的拆分、输入框的四态机、以及后端新发的 message.user 如何绕一圈回到前端。

全景 · 涉及 6 个文件
输入框
FloatingComposer
状态机
SessionContent
建会话+发
SessionView
后端发事件
agent/service.ts
编码
event-codec.ts
折成气泡
§5 reducer

B.1creating 阶段:第一条消息要先把会话建出来

SessionView 是「智能层」:它只管一件事——这是个已存在的会话(ready),还是一个待创建的草稿(creating)。两种情况渲染同一个纯组件 SessionContent,区别只在传进去的 actions

creating 下,「发送」不是简单发消息,而是一段两步编排:先 sessions.create 建会话,再 agent.startTurn 发第一轮,成功后回调 onCreated 让看板把 openSession 换成真实 sessionId——于是视图切到 ready,store 接管。

renderer/features/session/components/SessionView.tsx真实代码(节选)
function CreatingPhase({ taskId, onCreated }) {
  const { trpcClient } = useClient()
  const send = useCallback(async (text, config) => {
    const created = await trpcClient.sessions.create.mutate({
      taskId,
      providerId: config?.providerId ?? 'builtin-claude-code',
      model: config?.model,
      sessionConfig: config ? { effort: config.reasoning, permissionMode: config.permission } : undefined,
    })
    await trpcClient.agent.startTurn.mutate({ sessionId: created.id, input: [{ type: 'text', text }] })
    onCreated(created.id)   // 看板据此把 openSession 换成真 id → 切到 ready,§A 的 store 接手
  }, [taskId, trpcClient, onCreated])
  ...
}

注意 creating 阶段的 interrupt/resolveApproval/respondInput 都是 noop——还没有会话,自然没东西可中止或审批。这些动作要到 ready 阶段(useSessionActions)才接上真正的 tRPC 调用。

B.2输入框的四态机:idle / sending / running / error

输入框不再是「能点 / 不能点」的布尔,而是四个状态。状态由谁驱动?一半来自本地(点发送 → sending,失败 → error),一半来自规约态 isRunning 的边沿(Agent 开跑 → running,跑完 → idle)。SessionContent 用一个 effect 把 isRunning 的「翻边」翻译成状态切换。

renderer/features/session/components/SessionContent.tsx真实代码(节选)
const prevIsRunning = useRef(isRunning)
useEffect(() => {
  const wasRunning = prevIsRunning.current
  prevIsRunning.current = isRunning
  if (isRunning && !wasRunning) { setComposerStatus('running'); setComposerError(null) } // 上升沿
  if (wasRunning && !isRunning) { setComposerStatus('idle') }                            // 下降沿
}, [isRunning])

const handleSend = useCallback(async (text) => {
  setComposerStatus('sending')              // 等服务端 ack 这段先锁住
  try { await actions.send(text, config) }
  catch (e) { setComposerStatus('error'); setComposerError(e instanceof Error ? e.message : 'Send failed') }
}, [actions, phase])

四态最终落到 FloatingComposer 的可交互性与右下角按钮上:idle/error 可输入、显示发送按钮;sending 锁定、转圈;running 锁定、显示红色「中止」方块。一个派生量 isDisabled 统管输入禁用。

renderer/features/session/components/FloatingComposer.tsx真实代码(节选)
const isDisabled = status !== 'idle' && status !== 'error'   // 仅这两态可输入
const placeholder =
  status === 'sending' ? 'Sending…'
  : status === 'running' ? 'Agent is working…'
  : 'Send a message…'

{status === 'running' && onInterrupt ? <InterruptButton/>     // 红色 Square:中止
  : status === 'sending' ? <Spinner/>                          // 转圈:等 ack
  : <SendButton disabled={!text.trim() || isDisabled}/>}

一个易被当成 bug 的细节:按 Enter 发送前会查 composingRef,输入法合成(中文拼音等)中途的 Enter 不触发发送——靠 onCompositionStart/End 维护这个 ref。

B.3后端把用户这句话也发成事件,绕一圈回来变气泡

以前时间线只装 Agent 的输出,用户说了什么不在里头。本 PR 让 startTurn启动 runner 之前先发一个 message.user 事件。它走的是和 Agent 事件同一条 sink,于是会被持久化、广播、最终回到前端缓存。

apps/daemon/src/agent/service.ts真实代码(节选)
const liveHandle = this.runnerManager.get(sessionId)
const userEventSink = liveHandle?.sink ?? new SessionSink(session.id, session.providerId, this.repo, this.broadcaster)
userEventSink.setCurrentRun(run.id)
await userEventSink.emit(buildUserMessageEvent(normalized))   // ← 先发用户消息

const handle = await this.runnerManager.getOrCreate(...)       // 再启动 runner

function buildUserMessageEvent(input): Extract<AgentProviderEvent, { type: 'message.user' }> {
  const parts = allTextParts(input.parts) ? input.parts : undefined  // 混了图片就只留 text
  return { type: AgentEventType.MessageUser, text: input.text, ...(parts ? { parts } : {}) }
}

编码落库时,encodeMessageUserrole 固定为 'user'rawJson 写死 null。注释解释了取舍:用户这一轮没有 provider 敏感数据,正文逐字保存;图片引用在「不透明上传契约」就绪前一律不落盘,免得泄露宿主机路径。

apps/daemon/src/agent/event-codec.ts真实代码(节选)
function encodeMessageUser(providerId, event, _context, _rawJson): EncodedEvent {
  return eventResult(providerId, {
    role: 'user',
    payload: { text: event.text, parts: allTextParts(event.parts) ? event.parts : undefined },
    // a user turn carries no provider-sensitive data, so the text is persisted verbatim ...
    // parts persist only when every block is text.
    rawJson: null,
  })
}

回到前端,这个事件被 §5 的 reducer 接住:handleMessageUser append 一条 role: 'user'、status: 'complete' 的完整消息(不走 mapOpen,因为它不是「开着的」assistant 消息)。SessionThread 把它渲染成右对齐的气泡。

renderer/features/session/models/session-store.ts真实代码(节选)
function handleMessageUser(state, event, eventId): SessionState {
  const parts = event.parts?.length
    ? event.parts.map((p) => ({ type: 'text', text: p.type === 'text' ? p.text : `[${p.mimeType ?? p.type}]` }))
    : [{ type: 'text', text: event.text }]   // 非文本块降级成 [image/png] 占位
  return { ...state, messages: [...state.messages, { id: eventId, role: 'user', parts, status: 'complete' }] }
}
排查路标 · 旅程 B
症状从哪下手
发完消息输入框一直转圈不回弹SessionContentisRunning 边沿 effect;确认 run.completed 有到、reducer 把 isRunning 翻回 false
用户气泡不显示,只有 Agent 回复后端 service.ts 是否 emit 了 message.user;前端 SESSION_EVENT_TYPES 是否含该 type
第一条消息发不出 / 没建会话SessionView.CreatingPhase.send 的 create→startTurn→onCreated 三步
中文输入按 Enter 就提前发出去FloatingComposercomposingRefonCompositionStart/End
带图片发送后图片路径被写进时间线allTextParts 守卫(types.ts)+ encodeMessageUser 的 parts 过滤

8旅程 C:一段坏数据不该拖垮整条 thread

Agent 的输出是半结构化的,某个工具的 input/output 可能是意料之外的形状,渲染时抛错。本 PR 的策略是「就地隔离」:单个 part 或单行工具渲染崩了,只在那一格显示小 fallback,其余照常;等内容变了再自动重试。

全景 · 涉及 2 个文件
分组
SessionThread.groupParts
路由
ToolGroupRouter
三层错误边界
SessionThread / ToolGroup

C.1先把连续同类 part 合成组

一条 assistant 消息的 parts 可能是 [text, text, tool, tool, text…]。渲染前先 groupParts 折叠:连续文本合一组、连续工具调用并进一个 tool-group,ask-user/approval 各自单独成组。这样错误边界包在「组」粒度,既减少实例数,又保住隔离粒度。

renderer/features/session/components/SessionThread.tsx真实代码(节选)
function groupParts(parts: MessagePart[]): PartGroup[] {
  const groups = []; let groupIndex = 0
  for (const part of parts) {
    if (part.type === 'text') groups.push({ type: 'text', key: `text-${groupIndex++}`, text: part.text ?? '' })
    else if (part.type === 'reasoning') groups.push({ type: 'reasoning', ... })
    else if (part.type === 'ask-user') groups.push({ type: 'ask-user', key: `ask-${part.inputRequestId ?? groupIndex++}`, part })
    else if (part.type === 'approval') groups.push({ type: 'approval', ... })
    else if (part.type === 'tool-call') {
      const last = groups[groups.length - 1]
      if (last?.type === 'tool-group') last.tools.push(part)   // 并进上一个工具组
      else groups.push({ type: 'tool-group', key: `tool-${part.toolCallId ?? groupIndex++}`, tools: [part] })
    } else groups.push({ type: 'unknown', ... })              // 不认识的 part 也有兜底渲染
  }
  return groups
}

工具组再经 ToolGroupRouter 分流:单个 Bash → 专用 BashTool(终端样式);全是 Edit/Write → 逐个 EditTool(diff 样式);其余 → 通用 ToolGroup 折叠卡。每个分支内部都各自包错误边界。

C.2三层错误边界,加一把「内容变了就重试」的钥匙

本 PR 有三个几乎同构的 class 错误边界——PartErrorBoundary(非工具 part)、ToolRendererErrorBoundary(Bash/Edit 专用渲染器)、ToolRowErrorBoundary(通用卡里的单行)。它们的共同套路是 React 标准的 getDerivedStateFromError,但多了一手关键设计:resetKey

普通错误边界一旦 hasError 就永远显示 fallback,除非整棵子树重建。但流式场景里同一个 part 的内容一直在变(下一个 delta、工具补上了 result)——很可能下次就能渲染成功。resetKey 把「当前内容指纹」当 prop 传进来,一旦它变化就清掉错误、重试一次。

renderer/features/session/components/SessionThread.tsx真实代码(节选)
class PartErrorBoundary extends Component<PartErrorBoundaryProps, PartErrorBoundaryState> {
  static getDerivedStateFromError() { return { hasError: true } }

  componentDidUpdate(prevProps) {
    // Retries rendering when the grouped part content changes.
    if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
      this.setState({ hasError: false })   // 内容指纹变了 → 清错重试
    }
  }
  render() { return this.state.hasError ? <PartRenderError partType={this.props.partType} /> : this.props.children }
}

指纹怎么算?文本/推理用「key + 全文」,其它 part 用「key + 整体 JSON 序列化」(safeStringify 兜底循环引用)。工具行的指纹则额外把 result.status 拼进去——所以一个工具从 running 到 completed,指纹必变,崩过的行会自动再试一次。

renderer/features/session/components/SessionThread.tsx真实代码(节选)
function getPartGroupResetKey(group) {
  if (group.type === 'text' || group.type === 'reasoning') return `${group.key}:${group.text}`
  return `${group.key}:${safeStringify(group.part)}`
}
function getToolResetKey(tool, target, fallbackIndex = 0) {
  return `${tool.toolCallId ?? fallbackIndex}:${tool.toolName ?? ''}:${target}:${tool.result?.status ?? 'running'}` // status 入指纹 → 完成时重试
}

不认识的 part 类型不会让线程崩——UnknownPart 渲染一个可展开「Show raw part」的灰盒子,把原始 JSON 摊出来。这条配合 §5 的「新事件三步」配方:即便忘了给新 part 写渲染分支,它也只是降级成可读的原始块,而非白屏。

排查路标 · 旅程 C
症状从哪下手
整条会话白屏 / 某条消息整块消失八成不是隔离失效,先看 SessionThread 顶层(map 外)有没有抛错;隔离只保 part/工具行粒度
某个工具卡一直显示「Unable to render」getToolResetKey 的指纹有没有随内容变;不变就永远不重试
出现「Show raw part」灰盒子正常兜底:该 part 的 typegroupParts 没有对应分支(见 §5 配方第③步)
Bash/Edit 该走专用样式却走了通用卡ToolGroupRouter 的判据:Bash 要求「组内仅 1 个」,Edit/Write 要求「全组都是」

9心智模型补丁

读完三条旅程,下面几条是你对这个 codebase 需要更新的假设。

desktop 没有 Agent 会话界面,看板路由左栏永远是 <Board> 看板左栏可切换到 <SessionView>;整个 session feature 是这个 PR 引入的第一版
一个 React feature 的运行态活在它的组件/hook 里,卸载即销毁 会话规约态活在「连接级 store」(树外,键为 QueryClient),卸载不销毁,靠注册表复用
想找会话当前状态,别在组件里翻 useState,去 session-runtime-store.ts 的 store。
订阅类 effect 都应该在 cleanup 里取消 agent-event 订阅故意不在卸载时取消(sticky),靠「选中另一个 session」切换;显式关闭清理尚未接线
时间线只记录 Agent 产出的事件 用户自己发的每一轮也作为 message.user 事件进时间线(先于 runner 启动发出)
消息渲染用的 id 是渲染时生成的 消息 id = 事件信封 id(envelope.id),重放同段历史得同批 id,React key 稳定
某个 part 渲染抛错 = 整条消息(甚至整屏)挂掉 part / 工具行各自包错误边界,崩了只塌一格,内容指纹一变就自动重试
建 task↔repo 关系必须显式给工作目录 不给就从 repo 的 gitCommonDir 推默认值(去掉尾部 /.git 得仓库根),仍走根目录白名单校验

10新词表

状态与底座
连接级 store属于一条连接(QueryClient)、登记在 WeakMap 注册表里的 zustand store;寿命跟连接走,比组件长
ingest缓存每次变动时跑的增量规约:用游标 processed 只折新事件,识别 resync 则推倒重建
isHydrating「首轮回放还没结算」标记;为 true 时压住进入动画。由 quiet(200ms)/cap(2000ms) 两计时器结束
sticky 订阅agent-event 订阅在视图卸载后不清除,使后台事件继续进缓存
event envelope单个事件的信封 { id, sessionSeq, createdAt, event };其 id 当作消息稳定 id
mapOpen / editPartsreducer 工具:把增量内容只投递到「最后一条 running 的 assistant 消息」
UI 层
SessionView / SessionContent智能层(管 creating→ready、接 store/订阅/actions)/ 纯渲染层(线程 + 输入框,按 phase 布局)
ComposerStatus输入框四态 idle/sending/running/error,由本地动作 + isRunning 边沿共同驱动
resetKey错误边界的「内容指纹」prop;变化即清错重试,让流式内容崩过还能恢复
groupParts / ToolGroupRouter把连续同类 part 折成组 / 把工具组分流到 Bash·Edit·通用渲染器
IncremarkContent第三方 @incremark/react 的流式 Markdown 组件,用 isFinished 区分「还在流」与「已完成」
后端
message.user 事件记录用户一轮输入的事件,role 固定 user、rawJson 为 null;在 runner 启动前发出
allTextParts类型守卫:parts 全是文本才返回真,是「图片不落盘」策略的闸门
gitCommonDirgit 仓库的标准标识(常规仓库为 <root>/.git),用来推默认工作目录
ponytail:作者自用的源码备注标记(≈ TODO),如 hydration 处「等 daemon 出 replay.done 再替换」

11测试与风险地图

纯事实陈述:哪些行为被测试钉住了,哪些重要逻辑目前是薄冰。

有兜底的(测试钉住)薄冰(无测试 / 已知遗留)
store 规约session-runtime-store.test.ts):已缓存历史折叠并跳过 heartbeat;缓存 append 时只折新事件;resync(清空)后从头重建;isHydrating 的 quiet(200ms) 与 cap(2000ms) 两条结束路径;getOrCreateSessionStore 同 session 复用 / 跨 session 各异。

后端 message.useragent-service-methods.test.ts + agent-event-codec.test.ts):用户消息在 runner 启动之前发出;全文本 parts 持久化、混图片 parts 被剔除;runner 创建失败时用户消息仍保留;编解码往返。
🟠整个组件层零测试SessionThread / SessionContent / SessionView / FloatingComposer 及全部 tool 卡都没有 .test.tsx。四态机切换、错误边界的 resetKey 重试、groupParts、ToolGroupRouter 分流——全靠人工与将来 e2e。

🟡reducer 多数 handler 无直接单测:store 测试只间接覆盖了 run.started / message.delta / run.completedtool.* / approval.* / input.* / usage.updated 的折叠逻辑没有断言钉住。

🟡sticky 订阅的卸载行为无回归:「切走仍收事件、切回不丢」这条招牌行为,本 PR 没有 e2e/集成测试保护,靠 hook 里那条注释和人工验证。

task 默认工作目录defaultWorkingDirFromGitCommonDir 无直接单测(可能由建 task 流程间接覆盖)。
合并前可留意(非阻塞):① 招牌行为「切走再切回不丢状态」是这个 PR 的卖点,却没有自动化测试守着——一次手误给 useSessionActivation 加上 cleanup,或改坏 ingest 游标,都会静默回归。建议补一条针对 sticky 订阅 + store 持续规约的集成测试。② 组件层完全无测试,输入框四态机与错误隔离这类有状态逻辑值得至少一两条最小用例。

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

13覆盖声明

本报告基于 main...pr-88(HEAD 4925ba2)全量 diff。称重与子系统边界由三个并行 subagent 摸图(状态层 / 组件层 / daemon+杂项),报告中每段代码均由主笔亲自 Read 对应文件后裁剪,未直接引用 subagent 转述。

亲自精读(逐文件):session-runtime-store.ts 及其测试、session-store.ts(reducer 全文)、use-session-store.tsuse-session-activation.tsuse-session-actions.tsSessionView.tsxSessionContent.tsxSessionThread.tsx(全文)、ToolGroup.tsxFloatingComposer.tsxroutes/board.tsx diff,以及 daemon 侧 service.ts/event-codec.ts/types.ts diff、dto.ts/trpc.ts/repos.ts 相关段、package.json diff。

抽查(未逐行):各 tool 卡组件(BashTool/EditTool/ApprovalTool/AskUserTool/ReasoningBlock/MarkdownPart/ToolRow,确认是展示组件,行为已在旅程 C 覆盖其错误隔离)、三个滚动 hook、daemon 各测试的 1-3 行同步改动、迁移 snapshot、bun.lock。这些不影响三条核心旅程的结论。