main:Thread 流式播放不再靠“最后一条”猜测
buffin-ai/buffin · fc9cf3f...557014c · 2026-07-15 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。带点线下划线的名词可悬停查看脚注。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
Session Thread 的麻烦在于,历史重放、实时增量、后台 tab、审批暂停和多轮对话会挤在同一条消息流里。旧实现手上只有 isRunning 与 isHydrating,于是只能用“数组最后一条、group 最后一项”来猜动画该落在哪。
这 4 个提交没碰 daemon,也没改事件协议。改动全在前端:先把事件折叠成消息,接着算出 Session 当前处于运行、等待还是结束,再指定哪个 message、哪个 part 可以播放。Incremark 最后执行 static / revealing / frozen。
结果很具体。历史消息直接显示,后台完成的 Session 不会补播动画;多轮对话只动当前 run 的一段内容;遇到审批或输入请求,文字停在已经显示的位置;run 一结束,整条 Thread 归于静态。
边界也要先说清楚:这次只改了前端播放模型。重放结束仍靠 200ms quiet / 2s cap 猜测,设计稿里的 caught-up 标记和 activation barrier 都还没实现。提交材料没有说明为何收窄范围,这部分是 uncertain。(原始材料:完整比较、设计稿)
2变更地图
一共改了 1415 行,其中 847 行是测试。真正需要慢慢读的是 Session components 和 models。WorkBench 只负责把可见性传下去,runtime store 补上了 resync 后重新进入 hydration 的行为。
| 区域 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
models/ | session-domain.ts 定义 execution;thread-presentation.ts 选择活动目标与播放模式。 | 多数新增行是相同状态矩阵的纯函数测试。 |
components/ | SessionContent 装配输入;SessionThread 分发策略;incremark-adapter 翻译播放器命令。 | Tool、receipt 与错误边界的既有渲染路径没有改语义。 |
workbench/ | keep-alive tab 通过 context 告知子树“已挂载但不可见”。 | Workbench 的 tab 注册、订阅 union 与布局没有重写。 |
api/ | resync 清空 timeline 后重新进入 hydration。 | snapshot、cursor、WebSocket 和 daemon 事件契约保持原样。 |
3架构一图流
这次把播放决策从组件内部挪了出来。过去,Thread 和 Markdown 组件各猜一遍;现在,事件投影负责整理事实,presentation 选播放对象,第三方渲染器照着策略执行。Markdown 库本身没有换。
以前 · 组件从布尔值猜动画
现在 · projection 决策,adapter 执行
isRunning + isHydratinguseStreamingReveal 生成 substringrender(revealed)AgentTimeline → SessionStateSessionDomain → ThreadPresentation4数据与状态先行
SessionState 是事件折叠后的结果。它按顺序保存 message/part,也记着 daemon run 是否还开着。这里没有动画状态。
export interface Message {
id: string
role: 'user' | 'assistant'
parts: MessagePart[]
status: 'complete' | 'running'
}
export interface SessionState {
messages: Message[]
isRunning: boolean
usage: UsageInfo | null
}
SessionDomain 给这份数据一个统一解释:当前 run 正在工作、等人处理,还是已经结束;开放 run 又属于哪条 assistant message。Thread、tab badge 和 Activity 都从这里取状态。
export type SessionExecutionPhase =
| 'idle'
| 'running'
| 'waiting-approval'
| 'waiting-input'
| 'settled'
export interface SessionDomain {
execution: SessionExecutionPhase
activeMessageId: string | null
}
ThreadPresentation 描述当前这个界面该怎么画。活动 message、活动 part、文字播放方式和 message 入场动画各占一个字段,互不冒充。
export type ThreadPlaybackMode = 'static' | 'revealing' | 'frozen'
export interface ThreadPresentation {
execution: SessionExecutionPhase
activeMessageId: string | null
activePartIndex: number | null
playback: ThreadPlaybackMode
animateEntries: boolean
}
| execution:Session 现在在做什么 | |
|---|---|
idle | 没有开放 run,也没有历史消息。 |
running | daemon run 开放,当前 assistant 没有等待中的人工交互。 |
waiting-approval | 当前 assistant message 内存在 pending approval。 |
waiting-input | 当前 assistant message 内存在 pending ask-user。 |
settled | run 已关闭,且 transcript 非空。 |
| playback:当前 narrative part 怎么出现 | |
static | 立即展示权威内容,不追播历史动画。 |
revealing | 允许 Incremark 的 typewriter 继续 reveal 新增内容。 |
frozen | 保留已 reveal 的位置;parser 可继续接收内容,但 typewriter 暂停。 |
5旅程 A:一串事件如何成为 execution
先补一个背景。Session 不是 React 组件里的一段聊天数组;上游 dispatcher 把 snapshot 和订阅事件写进 TanStack Query 的 AgentTimeline,每个 Session 再用自己的 Zustand vanilla store 增量折叠这条时间线。(原始材料:dispatcher.ts)
dispatcher.ts→ AgentTimeline
session-runtime-store.ts→ Message projection
session-store.ts→ SessionDomain
session-domain.ts
A.1时间线只增量折叠,resync 才从头重建
processed 记着已经读到哪里。timeline 正常增长时,store 只处理后面的新 envelope。resync 会先清空 cache,这时数组变短,projection 才从头重建,UI 也重新进入 hydrating。
const ingest = () => {
const timeline = queryClient.getQueryData<AgentTimeline>(queryKey) ?? emptyTimeline
if (timeline.length < processed) {
reduced = initialSessionState
processed = 0
store.setState({ ...reduced, isHydrating: true }) // resync 重新静态回放
armCap()
}
while (processed < timeline.length) {
const envelope = timeline[processed]
if (!envelope) break
const event = envelope.event as { type: string }
if (isSessionEvent(event)) {
reduced = sessionReducer(reduced, event, envelope.id)
}
processed++
}
}
isHydrating 不是 daemon 发来的状态,是前端自己猜的。每来一个事件就重启 quiet timer;安静 200ms,或首次打开已经过了 2 秒,前端便认为这批历史数据收完了。做 MVP 够直接,但它不精确。
const HYDRATION_QUIET_MS = 200
const HYDRATION_CAP_MS = 2000
const armQuiet = () => {
clearTimeout(quietTimer)
quietTimer = setTimeout(settle, HYDRATION_QUIET_MS)
}
const armCap = () => {
clearTimeout(capTimer)
capTimer = setTimeout(settle, HYDRATION_CAP_MS)
}
A.2领域状态只看开放 run 与它自己的 pending interaction
判断很短。daemon run 已关闭时,空 transcript 叫 idle,有历史就叫 settled。run 还开着时,从后往前找最后一条 status === running 的 assistant message。
export function deriveSessionDomain(messages: Message[], isRunning: boolean): SessionDomain {
if (!isRunning) {
return {
execution: messages.length === 0 ? 'idle' : 'settled',
activeMessageId: null,
}
}
const activeMessage = findLastRunningMessage(messages)
const pending = activeMessage ? findLatestPendingInteraction(activeMessage.parts) : undefined
return {
execution:
pending?.type === 'approval'
? 'waiting-approval'
: pending?.type === 'ask-user'
? 'waiting-input'
: 'running',
activeMessageId: activeMessage?.id ?? null,
}
}
pending interaction 只查 active message。旧 message 里即使残留一个 pending receipt,也不会把新 run 误判为 waiting。tab badge 和 Activity 也使用这份判断。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 重连后消息重复、缺失或顺序不对 | session-runtime-store.ts:看 processed 与 ingest。 |
| 后台已完成 Session 仍被标成 running | session-store.ts:看 handleRunCompleted 是否把 isRunning 与 open message 一起关闭。 |
| 历史审批让当前 tab 显示 attention | session-domain.ts:看 findLastRunningMessage 和 pending 扫描范围。 |
6旅程 B:多 Thread 中谁能播放
一个 Session 可以挂着、订阅着、还在跑,但用户此刻根本没看它。要不要播放动画,还得看宿主 tab、浏览器窗口、hydration 和 reduced motion。
ContentHost.tsx→ Window visibility
content-visibility.tsx→ Presentation
thread-presentation.ts→ Message routing
SessionThread.tsx→ Part renderer
B.1Tab keep-alive,但隐藏状态显式下传
ContentHost 没有卸载后台 tab。DOM 和相关状态都留着,只用 CSS 隐藏。这种 keep-alive 方式需要额外传入 visible,组件才能分清“还挂着”和“用户看得见”。
function TabContent({ tab, visible }: { tab: Tab; visible: boolean }) {
return (
<div
className={cn('absolute inset-0 overflow-hidden',
!visible && 'pointer-events-none invisible')}
aria-hidden={!visible}
>
<ContentVisibilityProvider visible={visible}>
<registration.Component target={tab.target} />
</ContentVisibilityProvider>
</div>
)
}
useContentVisibility 还会检查 document.visibilityState。tab 和整个窗口都可见时,它才返回 true。
export function useContentVisibility(): boolean {
const surfaceVisible = useContext(ContentSurfaceVisibilityContext)
const windowVisible = useSyncExternalStore(
subscribeWindowVisibility,
getWindowVisibility,
() => true,
)
return surfaceVisible && windowVisible
}
B.2先选唯一 message,再选唯一 narrative part
deriveSessionDomain 先选出最后一个 running assistant。presentation 接着从这条 message 的尾部找一段尚未 closed 的 text 或 reasoning。
正常 running 时,这段 narrative 必须正好在 parts 尾部。末尾如果是 tool,动画权就交给 tool,前面的文字停下。waiting 稍有不同:它会找到审批或输入卡之前的开放 narrative,把显示位置冻在那里。
function findActiveNarrativePartIndex(messages: Message[], domain: Domain): number | null {
const message = messages.find((candidate) => candidate.id === domain.activeMessageId)
if (!message) return null
for (let index = message.parts.length - 1; index >= 0; index--) {
const part = message.parts[index]
if (!part || !isOpenNarrative(part)) continue
if (domain.execution === 'running' && index !== message.parts.length - 1) return null
return index
}
return null
}
function isOpenNarrative(part: MessagePart): boolean {
return (part.type === 'text' || part.type === 'reasoning') && part.closed !== true
}
SessionThread 现在认 ID,不认数组位置。message 对不上 activeMessageId 就是 static;对得上的 message 里,也只有 partIndex === activePartIndex 的 group 能拿到 playback。
{messages.map((msg) => {
const isActive = msg.id === presentation.activeMessageId
return msg.role === 'user' ? (
<UserMsg parts={msg.parts} />
) : (
<AssistantMsg
parts={msg.parts}
playback={isActive ? presentation.playback : 'static'}
activePartIndex={isActive ? presentation.activePartIndex : null}
showRunningIndicator={
isActive && msg.status === 'running' && presentation.execution === 'running'
}
/>
)
})}
status: running 的旧 message,但 domain 只会选中最后一个 running assistant。其余 message,包括 user message,全部静态。这正是并行动画问题的落点。B.3哪些情况会直接关掉动画
presentation 每次都用同一组输入重算。隐藏、hydrating 和 reduced-motion 会直接得到 static。界面可见且历史已经收稳后,等人处理用 frozen,普通 running 才用 revealing。
function derivePlaybackMode(input: ThreadPresentationInput, execution: SessionExecutionPhase) {
if (!input.visible || input.isHydrating || input.reduceMotion) return 'static'
if (execution === 'waiting-approval' || execution === 'waiting-input') return 'frozen'
return execution === 'running' ? 'revealing' : 'static'
}
| 条件 | playback | message 入场动画 |
|---|---|---|
| hidden / hydrating / reduced-motion | static | 关闭 |
| visible + waiting approval/input | frozen | 关闭 |
| visible + live running | revealing | 仅 active message 可启用 |
| idle / settled | static | 关闭 |
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 切到别的 tab 后后台仍播放 | ContentHost.tsx 与 content-visibility.tsx:确认 surface/window visibility。 |
| 多轮对话同时动画 | session-domain.ts:看 active message;SessionThread.tsx:看非 active 是否强制 static。 |
| 工具运行时前一段文字还在 reveal | thread-presentation.ts:看 findActiveNarrativePartIndex 的 tail 规则。 |
| 审批出现时文字瞬间补全 | thread-presentation.ts:确认 waiting 映射为 frozen,而非 static。 |
7旅程 C:playback 如何驱动 Incremark
MarkdownPart 瘦了很多。它收下完整 Markdown、播放模式和 finished,随即交给 IncremarkAdapter。原来的 useStreamingReveal 连同 timer 都删了。
SessionThread.tsx→ MarkdownPart→ Adapter
incremark-adapter.tsx→ Parser + typewriter
@incremark/react
render(substring)C.1三个 playback 对应三组命令
命令映射很直白:revealing 开启并恢复 typewriter;frozen 只暂停,队列和当前位置都留下;static 关闭 typewriter,直接 skip 到当前完整内容。
useLayoutEffect(() => {
const typewriter = imRef.current.typewriter
if (playback === 'revealing') {
typewriter.setEnabled(true)
typewriter.resume()
} else if (playback === 'frozen' && typewriter.enabled) {
typewriter.pause()
} else {
typewriter.setEnabled(false)
typewriter.skip()
}
}, [playback])
打字速度交给 Incremark 的 typewriter:每 24ms 放出 1–3 个字符,效果是 fade-in,document hidden 时自动暂停。Buffin 只决定播还是不播,不再自己数时间和字符。
const typewriterOptions = {
charsPerTick: [1, 3],
tickInterval: 24,
effect: 'fade-in',
pauseOnHidden: true,
} satisfies TypewriterOptions
C.2前缀增长 append,权威替换 reset
普通 delta 总是在旧文本后面接内容,adapter 只需 append 新 suffix,parser 可以接着工作。message.completed.text 则可能变短、等长,或者改掉中间一段。这属于 权威替换,adapter 会 reset 后重建,并直接 skip 动画。
useEffect(() => {
const im = imRef.current
const previous = previousTextRef.current
if (text === previous) return
const initialContent = previous === undefined
finalizedRef.current = false
if (text.length === 0) {
im.reset()
} else if (initialContent || text.startsWith(previous)) {
im.append(text.slice(previous?.length ?? 0)) // 只追加 suffix
} else {
im.reset()
im.append(text)
im.typewriter.setEnabled(false)
im.typewriter.skip() // 权威替换静态落地
}
previousTextRef.current = text
}, [text])
这里还有一处针对 Incremark 1.0.2 的处理。completed block 会被缓存;短前缀先播完后,旧缓存可能截住同一段的后续文字。adapter 因此改用 parser 当前的 completed/pending block 来渲染同一个 id。
const sourceBlocks = new Map(
[...im.completedBlocks, ...im.pendingBlocks].map((block) => [block.id, block]),
)
const renderedIncremark = {
...im,
blocks: im.blocks.map((block) =>
block.status === 'completed' ? (sourceBlocks.get(block.id) ?? block) : block,
),
}
C.3不能拿 unmount 当完成
keep-alive tab 和 React StrictMode 都可能触碰组件生命周期,unmount 不能代表内容真的结束。只有 part closed 或 message complete 时,adapter 才收到 finished,随后 finalize 一次并 skip 到完整内容。
useEffect(() => {
if (!finished || finalizedRef.current) return
const im = imRef.current
im.finalize()
im.typewriter.skip()
finalizedRef.current = true
}, [finished])
SessionThread 用 group.closed || isComplete 计算 finished。live parser 不再随 effect cleanup finalize,所以 StrictMode 在开发期做一次 setup/cleanup rehearsal,也不会把后续 append 提前封死。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 标题或段落只显示短前缀,后续内容被截断 | incremark-adapter.tsx:看 sourceBlocks 的同 id block 替换。 |
| 审批暂停后文字瞬间补全或继续走 | incremark-adapter.tsx:看 frozen 是否只调用 pause。 |
| completed 修正文案出现二次打字 | incremark-adapter.tsx:看 replacement 分支的 reset + skip。 |
| 开发模式第一次渲染后不再 append | MarkdownPart.real.test.tsx:看 StrictMode 真实库回归;再看 adapter 的 finalize 条件。 |
8计划 vs 实现的偏差
设计稿比这 4 个提交大得多,里面还包括 transport sync、共享事件契约和可替换 Markdown adapter。这次只做了前端播放这一段。代码能说明哪些东西做了、哪些没做,却没有交代为何收窄范围;原因统一记为 uncertain。(原始材料:session-event-projection-playback.md、handoff.md)
| 设计稿计划 | 本次实际落地 |
|---|---|
四个正交轴:ContentLifecycle、SyncState、surface visibility、playback。 | 新增 SessionExecutionPhase 与 ThreadPlaybackMode;继续使用既有 isRunning、isHydrating、boolean visibility。 |
transport 在 replay 后发送明确 caught-up marker。 | 后端与共享协议未改;仍用 200ms quiet / 2s cap 判断 hydration 结束。 |
| visibility/live 切换时记录 cursor 级 activation barrier。 | 没有 cursor barrier。hidden 时直接 static/skip;重新可见后,已有内容已静态落地,后续 append 才可能继续 reveal。 |
完整 Agent event union 移入 @buffin/api,Desktop 不再自建子集。 | session-store.ts 的本地事件 union 仍然存在,本次只增强 text span 的 closed 语义。 |
| Streamdown 参考 adapter 与 Incremark adapter 做 fixture benchmark。 | 删除手写 reveal hook,只保留一个 Incremark adapter;没有引入 Streamdown。 |
| 完整终态区分 settled / interrupted / failed / stale / offline。 | Thread presentation 只暴露 idle / running / waiting-* / settled。 |
9读完要改掉的旧假设
role=assistant && status=running 的 message;后面可以跟 user 或其他非运行节点。frozen 保留当前 reveal 光标;static 才会 skip 到权威全文。| 领域投影 | |
|---|---|
SessionDomain | 从 messages 与 isRunning 得到的、与具体 UI 无关的 Session 当前阶段。 |
activeMessageId | 开放 run 当前拥有展示权的 assistant message。 |
closed | 一个 text/reasoning span 已被 completed 事件封口,后续 delta 应新开 part。 |
| 展示投影 | |
ThreadPresentation | 某个挂载表面此刻应该如何显示 Thread 的纯数据快照。 |
activePartIndex | active message 中唯一可以拥有 playback 的 narrative part 下标。 |
playback | 把内容呈现为静态、渐进 reveal 或冻结的策略。 |
| 同步与渲染 | |
hydration | 前端认为 snapshot/replay burst 尚未稳定的阶段,期间历史内容静态渲染。 |
authoritative replacement | completed 提供的最终文本与 delta 累积不构成前缀增长,adapter 必须重建。 |
activation barrier | 设计稿中的 cursor 门槛,用来区分切换前历史与切换后 live;本次没有实现。 |
10测试与风险地图
生产代码改了 568 行,测试改了 847 行。纯函数测试负责状态矩阵,组件测试检查策略有没有传对;另有一条跨层状态序列,以及两条直接运行真实 Incremark 的回归。
| 有兜底的行为 | 对应测试材料 |
|---|---|
| 最后一个 running assistant 成为 active;历史 pending 不污染当前 run。 | session-domain.test.ts |
| replay/hidden/reduced-motion 静态;approval/input 冻结;settled 静态;tool tail 不选 narrative。 | thread-presentation.test.ts |
| 多轮只最后 active message reveal;trailing user 不夺走 active;settled 全部 static。 | SessionThread.test.tsx |
| replay → live → waiting → resumed → hidden → settled 的装配序列。 | SessionContent.presentation.test.tsx |
host hidden 与真实 visibilitychange 都能改变 visibility。 | content-visibility.test.tsx |
| suffix append、冻结排队、权威替换、finished finalize。 | MarkdownPart.test.tsx |
| 真实 Incremark 在短前缀完成后仍可扩展;StrictMode rehearsal 后仍可 append。 | MarkdownPart.real.test.tsx |
中:Replay/live 分界仍靠启发式。 200ms quiet 后若历史事件继续到达,前端没有服务端 marker 能证明它属于 replay 还是 live。实际影响取决于传输节奏,是 uncertain。
低:没有 cursor activation barrier。 hidden/hydrating 时,现实现用 static + skip 把当下内容全部显示出来,但没有记录从哪个 envelope 开始才允许动画。
低:终态只有 settled。 presentation 无法单独表达 failed、interrupted、stale 或 offline。本次 Thread 动画也没有使用这些状态。
低:Reasoning 和 Markdown 的执行方式不同。 presentation 都会选择 text/reasoning part。普通 Markdown 走 typewriter adapter;ReasoningBlock 只用 playback 控制 Thinking 动效,内容仍交给 IncremarkContent。
提示:缓存修补针对 Incremark 1.0.2。 真实库测试证明当前版本不会截断。以后升级 Incremark 时,上游缓存语义可能变化,届时要重新核对。
11验收提示与覆盖声明
验收时不要误判
- 隐藏 tab 仍保持挂载并更新 projection,这是原有的 keep-alive 设计。新增逻辑只关掉 presentation motion。
isHydrating继续存在。daemon 没有改,它仍负责估算 replay burst 的结束时间。- 审批前的文字使用
frozen,这样光标停在原处;换成static会立即补出全文。 - running tool 位于 parts 尾部时,
activePartIndex=null。此时 live output 由 tool renderer 展示。 MarkdownPartDevReplay只在 DEV 环境挂载,是本地回放工具,不参与状态机。
覆盖声明
报告读完了 fc9cf3f...557014c 的 27 个变更文件,共 1415 行增删,没有抽样。文中的代码片段来自 HEAD 原文件;旧管线则直接读取 base 版本的 SessionThread.tsx、MarkdownPart.tsx 和 use-streaming-reveal.ts。runtime、domain、presentation、visibility、真实 Incremark 测试、设计稿与 handoff 也都逐项核过。
这是一份代码走读,不是 code review。未落地的设计目标只记作范围差异,启发式边界也没有被写成必然故障。代码事实都能从 GitHub 完整 diff 回查。