PR #88:给桌面端装上「不随视图卸载而失忆」的 Agent 会话界面
figuretu/eyrie · main...pr-88(HEAD 4925ba2) · 2026-06-21 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 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 接线。
| 设计重心(要细读) | 可放心略过 |
|---|---|
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}.ts — message.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 缓存的事件流喂养。
以前 · 没有会话界面
现在 · 看板 ⇄ 会话,状态住在树外
关键点:store 不挂在 SessionView 下,而是登记在一张以 QueryClient(= 一条连接)为键的注册表里。组件卸载 ≠ store 销毁——这正是「切回来不丢」的物理基础,下一节展开。
4数据与状态先行
先把形状摆出来,后面旅程才有词汇。只看形状,不讲行为。
规约态:会话被折叠成什么样子
整个会话最终被折叠成一个 SessionState:一串消息、是否在跑、用量。每条消息由若干 part(片段)组成,part 用一个 type 字段区分文本 / 推理 / 工具调用 / 审批 / 问询。
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 新加的,让用户消息也能进规约。
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——「首轮回放还没结算完」。它存在的唯一目的:回放历史时把进入动画压住,别让一屏老消息逐条淡入。
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,一旦混了图片就丢弃——避免把宿主机文件路径写进时间线。
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。
// 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(让这次重放也是静默的)。
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,免得动画永远被压着。
// 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 表分发。关键不在分发,而在两个小工具——mapOpen 和 editParts:增量内容(delta、工具调用、审批……)永远只往「最后一条 role=assistant 且 status=running」的消息里塞。
/** 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。
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
})
}
SessionEvent 联合体加成员 + 把 type 字符串塞进 SESSION_EVENT_TYPES(否则 isSessionEvent 直接过滤掉);② 写一个 handleXxx 把它折进某条消息的 parts,并登记到 handler 表;③ 在 SessionThread 的 groupParts + 渲染分支里给这种 part 一个组件。三步缺一,事件要么被丢、要么进了状态但渲染不出来。
6旅程 A:从会话切走、再切回来,状态不丢
这是 PR 的招牌行为。走通这条,你就明白「卸载不失忆」是哪几块拼出来的:看板里的挂载点、一个故意不清理的订阅、和上一节那个树外 store。
board.tsx→ 激活订阅
use-session-activation→ 读 store
use-session-store→ ingest/reduce
§5→ 渲染
SessionThread
A.1挂载点,与那个「黏住」的订阅
唯一的接入点是看板路由。board.tsx 多了个 openSession 局部状态:非空时,左栏不再渲染 <Board> 而是渲染 <SessionView>,顶上加一条「← Board」返回栏。用 key 绑 sessionId,保证切换不同会话时整棵子树重建。
{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:卸载时不把订阅清掉。源码注释把意图写得很清楚。
// 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 订阅它。组件要的只是「当前折叠结果」,至于这结果是这次刚算的还是上次切走时背景算好的,组件并不关心。
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 继续折叠。
再切回来时,SessionThread 还会顺手用 isHydrating 决定要不要播放进入动画——不过此时 store 早已 settle,所以切回是「直接出现」而非「逐条淡入」。淡入只在这个 store 第一次建立、首轮回放那 200ms~2s 窗口里发生。
<motion.div
key={msg.id} // 用 envelope.id,重挂载 key 不变 → 不重新触发动画
initial={isHydrating ? false : { opacity: 0 }} // hydrating 时直接最终态,不淡入
animate={{ opacity: 1 }}
>
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 切回会话后 timeline 是空的 / 卡在旧状态 | session-runtime-store.ts:ingest 的游标 processed、cache 订阅是否还活着 |
| 切走再切回,中途的消息丢了 | use-session-activation.ts:确认 effect 没被加上 cleanup(清理 = 订阅断 = 丢事件) |
| 同一会话被建了两个 store / 状态对不上 | getOrCreateSessionStore 的注册表命中逻辑,键是 QueryClient |
| 首屏老消息逐条淡入很碍眼 / 或该淡入却没淡 | isHydrating 的 armQuiet/armCap 与 SessionThread 的 initial |
| 换会话后旧会话还在后台收事件 | 这是设计如此(订阅黏住,靠「选中另一个 session」切换);显式关闭清理「尚未接线」 |
7旅程 B:发一条消息
这条旅程横跨前后端:从一个还不存在的会话开始,到用户气泡出现在屏幕上。途中你会看到「智能层/纯渲染层」的拆分、输入框的四态机、以及后端新发的 message.user 如何绕一圈回到前端。
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 接管。
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 的「翻边」翻译成状态切换。
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 统管输入禁用。
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,于是会被持久化、广播、最终回到前端缓存。
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 } : {}) }
}
编码落库时,encodeMessageUser 把 role 固定为 'user'、rawJson 写死 null。注释解释了取舍:用户这一轮没有 provider 敏感数据,正文逐字保存;图片引用在「不透明上传契约」就绪前一律不落盘,免得泄露宿主机路径。
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 把它渲染成右对齐的气泡。
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
| 症状 | 从哪下手 |
|---|---|
| 发完消息输入框一直转圈不回弹 | SessionContent 的 isRunning 边沿 effect;确认 run.completed 有到、reducer 把 isRunning 翻回 false |
| 用户气泡不显示,只有 Agent 回复 | 后端 service.ts 是否 emit 了 message.user;前端 SESSION_EVENT_TYPES 是否含该 type |
| 第一条消息发不出 / 没建会话 | SessionView.CreatingPhase.send 的 create→startTurn→onCreated 三步 |
| 中文输入按 Enter 就提前发出去 | FloatingComposer 的 composingRef 与 onCompositionStart/End |
| 带图片发送后图片路径被写进时间线 | allTextParts 守卫(types.ts)+ encodeMessageUser 的 parts 过滤 |
8旅程 C:一段坏数据不该拖垮整条 thread
Agent 的输出是半结构化的,某个工具的 input/output 可能是意料之外的形状,渲染时抛错。本 PR 的策略是「就地隔离」:单个 part 或单行工具渲染崩了,只在那一格显示小 fallback,其余照常;等内容变了再自动重试。
SessionThread.groupParts→ 路由
ToolGroupRouter→ 三层错误边界
SessionThread / ToolGroup
C.1先把连续同类 part 合成组
一条 assistant 消息的 parts 可能是 [text, text, tool, tool, text…]。渲染前先 groupParts 折叠:连续文本合一组、连续工具调用并进一个 tool-group,ask-user/approval 各自单独成组。这样错误边界包在「组」粒度,既减少实例数,又保住隔离粒度。
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 传进来,一旦它变化就清掉错误、重试一次。
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,指纹必变,崩过的行会自动再试一次。
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 的 type 在 groupParts 没有对应分支(见 §5 配方第③步) |
| Bash/Edit 该走专用样式却走了通用卡 | ToolGroupRouter 的判据:Bash 要求「组内仅 1 个」,Edit/Write 要求「全组都是」 |
9心智模型补丁
读完三条旅程,下面几条是你对这个 codebase 需要更新的假设。
session-runtime-store.ts 的 store。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 / editParts | reducer 工具:把增量内容只投递到「最后一条 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 全是文本才返回真,是「图片不落盘」策略的闸门 |
gitCommonDir | git 仓库的标准标识(常规仓库为 <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.user( agent-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.completed;tool.* / approval.* / input.* / usage.updated 的折叠逻辑没有断言钉住。🟡sticky 订阅的卸载行为无回归:「切走仍收事件、切回不丢」这条招牌行为,本 PR 没有 e2e/集成测试保护,靠 hook 里那条注释和人工验证。 ⚪task 默认工作目录: defaultWorkingDirFromGitCommonDir 无直接单测(可能由建 task 流程间接覆盖)。
|
useSessionActivation 加上 cleanup,或改坏 ingest 游标,都会静默回归。建议补一条针对 sticky 订阅 + store 持续规约的集成测试。② 组件层完全无测试,输入框四态机与错误隔离这类有状态逻辑值得至少一两条最小用例。
12验收提示(别被这些吓到)
- 整个 session 目录都是「新增」:diff 里这些文件全是 +N/−0,不是删了重写。因为
main上根本没有 session feature。PR 描述说的「refactor phase 1/2」是分支内部演进(早期的useSessionRuntime+ 阶段机已在分支内被删除),对main不可见——所以你在 diff 里看不到任何「旧管线」,这是正常的。 - 一条过时的注释:
use-session-actions.ts里写着「// ponytail: no tRPC route for approval resolve yet」,但紧跟着的代码就在调trpcClient.agent.resolveApproval.mutate(...),而该路由确实已存在于packages/api/src/trpc.ts(转发到respondToApproval)。注释是旧的,approval 解析功能其实接通了,不是缺口。 - 「显式关闭会话的清理尚未接线」是设计声明,不是 bug:换会话靠「选中另一个 session」覆盖 sticky 订阅;关 tab 式的显式清理留给将来。PR 描述与
use-session-activation.ts注释都明说了。 - creating 阶段的 interrupt/approval/input 是 noop:会话还没建出来,自然没东西可操作;真正接线在 ready 阶段的
useSessionActions。不是漏实现。 configRef只在 creating 阶段挂上:模型/推理/权限三个 Pill 的选择只在「建会话的第一发」用得上(写进sessions.create),ready 阶段这些 Pill 被actionsDisabled隐藏。所以你会看到{...(phase === 'creating' && { configRef })}这种条件挂 prop。- bun.lock 的 471 行:纯依赖锁文件变更,对应
package.json新增zustand、@incremark/{core,react,theme}、以及 dev 依赖@tanstack/react-query-devtools。无需逐行读。
13覆盖声明
本报告基于 main...pr-88(HEAD 4925ba2)全量 diff。称重与子系统边界由三个并行 subagent 摸图(状态层 / 组件层 / daemon+杂项),报告中每段代码均由主笔亲自 Read 对应文件后裁剪,未直接引用 subagent 转述。
亲自精读(逐文件):session-runtime-store.ts 及其测试、session-store.ts(reducer 全文)、use-session-store.ts、use-session-activation.ts、use-session-actions.ts、SessionView.tsx、SessionContent.tsx、SessionThread.tsx(全文)、ToolGroup.tsx、FloatingComposer.tsx、routes/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。这些不影响三条核心旅程的结论。