main:Thread 流式播放不再靠“最后一条”猜测

buffin-ai/buffin · fc9cf3f...557014c · 2026-07-15 · 自包含,读完即弃

4 commits
27 文件
+1142 / −273
59.9% 是测试代码

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

1TL;DR

Session Thread 的麻烦在于,历史重放、实时增量、后台 tab、审批暂停和多轮对话会挤在同一条消息流里。旧实现手上只有 isRunningisHydrating,于是只能用“数组最后一条、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 的行为。

tests
847 行 · 59.9%
components
279 行 · 19.7%
models
151 行 · 10.7%
other
68 行 · 4.8%
workbench
49 行 · 3.5%
api
21 行 · 1.5%
区域设计重心(要细读)可放心略过
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 库本身没有换。

以前 · 组件从布尔值猜动画

Messages
数组最后一条
SessionThread
isRunning
group 最后一项
isStreaming
完整文本
30ms / 4 chars
截短文本

现在 · projection 决策,adapter 执行

Timeline
sessionReducer
Messages
Domain
active target + policy
Presentation
完整文本
append / pause / skip
Incremark
旧管线
isRunning + isHydrating
Thread 猜最后 message / group
useStreamingReveal 生成 substring
Incremark 反复 render(revealed)
新管线
AgentTimeline → SessionState
SessionDomain → ThreadPresentation
唯一 active message / part 获得 playback
Incremark 接收完整文本并执行播放器命令

4数据与状态先行

SessionState 是事件折叠后的结果。它按顺序保存 message/part,也记着 daemon run 是否还开着。这里没有动画状态。

apps/desktop/.../models/session-store.ts原始材料
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 都从这里取状态。

apps/desktop/.../models/session-domain.ts原始材料
export type SessionExecutionPhase =
  | 'idle'
  | 'running'
  | 'waiting-approval'
  | 'waiting-input'
  | 'settled'

export interface SessionDomain {
  execution: SessionExecutionPhase
  activeMessageId: string | null
}

ThreadPresentation 描述当前这个界面该怎么画。活动 message、活动 part、文字播放方式和 message 入场动画各占一个字段,互不冒充。

apps/desktop/.../models/thread-presentation.ts原始材料
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,也没有历史消息。
runningdaemon run 开放,当前 assistant 没有等待中的人工交互。
waiting-approval当前 assistant message 内存在 pending approval。
waiting-input当前 assistant message 内存在 pending ask-user。
settledrun 已关闭,且 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

全景 · 涉及 4 个环节
snapshot + subscription
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。

apps/desktop/.../api/session-runtime-store.ts原始材料
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 够直接,但它不精确。

apps/desktop/.../api/session-runtime-store.ts真实代码(节选)
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。

apps/desktop/.../models/session-domain.ts真实代码(节选)
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 也使用这份判断。

旧理解
看到任意 pending 卡片
Session 似乎需要人工处理
当前规则
先确定最后一个 running assistant
只扫描该 message 的 pending interaction
排查路标 · 旅程 A
症状从哪下手
重连后消息重复、缺失或顺序不对session-runtime-store.ts:看 processedingest
后台已完成 Session 仍被标成 runningsession-store.ts:看 handleRunCompleted 是否把 isRunning 与 open message 一起关闭。
历史审批让当前 tab 显示 attentionsession-domain.ts:看 findLastRunningMessage 和 pending 扫描范围。

6旅程 B:多 Thread 中谁能播放

一个 Session 可以挂着、订阅着、还在跑,但用户此刻根本没看它。要不要播放动画,还得看宿主 tab、浏览器窗口、hydration 和 reduced motion

全景 · 涉及 5 个环节
Host visibility
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,组件才能分清“还挂着”和“用户看得见”。

apps/desktop/.../workbench/components/ContentHost.tsx原始材料
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。

apps/desktop/.../workbench/host/content-visibility.tsx原始材料
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,把显示位置冻在那里。

apps/desktop/.../models/thread-presentation.ts真实代码(节选)
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。

apps/desktop/.../components/SessionThread.tsx原始材料
{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。

apps/desktop/.../models/thread-presentation.ts真实代码(节选)
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'
}
条件playbackmessage 入场动画
hidden / hydrating / reduced-motionstatic关闭
visible + waiting approval/inputfrozen关闭
visible + live runningrevealing仅 active message 可启用
idle / settledstatic关闭
排查路标 · 旅程 B
症状从哪下手
切到别的 tab 后后台仍播放ContentHost.tsxcontent-visibility.tsx:确认 surface/window visibility。
多轮对话同时动画session-domain.ts:看 active message;SessionThread.tsx:看非 active 是否强制 static。
工具运行时前一段文字还在 revealthread-presentation.ts:看 findActiveNarrativePartIndex 的 tail 规则。
审批出现时文字瞬间补全thread-presentation.ts:确认 waiting 映射为 frozen,而非 static。

7旅程 C:playback 如何驱动 Incremark

MarkdownPart 瘦了很多。它收下完整 Markdown、播放模式和 finished,随即交给 IncremarkAdapter。原来的 useStreamingReveal 连同 timer 都删了。

全景 · 涉及 4 个环节
Playback policy
SessionThread.tsx
MarkdownPart Adapter
incremark-adapter.tsx
Parser + typewriter
@incremark/react
以前
完整 text 进入 hook
每 30ms 多放 4 个字符
反复 render(substring)
unmount 时 finalize
现在
完整 text 进入 adapter
前缀增长只 append suffix
playback 控制 typewriter
只有 finished 才 finalize

C.1三个 playback 对应三组命令

命令映射很直白:revealing 开启并恢复 typewriter;frozen 只暂停,队列和当前位置都留下;static 关闭 typewriter,直接 skip 到当前完整内容。

apps/desktop/.../components/incremark-adapter.tsx原始材料
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 只决定播还是不播,不再自己数时间和字符。

apps/desktop/.../components/incremark-adapter.tsx真实代码(节选)
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 动画。

apps/desktop/.../components/incremark-adapter.tsx真实代码(节选)
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。

apps/desktop/.../components/incremark-adapter.tsx真实代码(节选)
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 到完整内容。

apps/desktop/.../components/incremark-adapter.tsx真实代码(节选)
useEffect(() => {
  if (!finished || finalizedRef.current) return
  const im = imRef.current
  im.finalize()
  im.typewriter.skip()
  finalizedRef.current = true
}, [finished])

SessionThreadgroup.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
开发模式第一次渲染后不再 appendMarkdownPart.real.test.tsx:看 StrictMode 真实库回归;再看 adapter 的 finalize 条件。

8计划 vs 实现的偏差

设计稿比这 4 个提交大得多,里面还包括 transport sync、共享事件契约和可替换 Markdown adapter。这次只做了前端播放这一段。代码能说明哪些东西做了、哪些没做,却没有交代为何收窄范围;原因统一记为 uncertain。(原始材料:session-event-projection-playback.mdhandoff.md

设计稿计划本次实际落地
四个正交轴:ContentLifecycleSyncState、surface visibility、playback。新增 SessionExecutionPhaseThreadPlaybackMode;继续使用既有 isRunningisHydrating、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
代码目前做到这里:前端已经有独立的 presentation seam;transport 仍用原来的启发式区分 replay 和 live。设计稿的其余部分没有藏在别处。

9读完要改掉的旧假设

running 就意味着 Thread 应该播放动画。running 只描述 daemon run;动画还要经过 hydration、可见性、reduced-motion 和活动目标筛选。
messages 数组最后一条就是当前 agent 回复。当前回复是最后一个 role=assistant && status=running 的 message;后面可以跟 user 或其他非运行节点。
只要是最后一个 text group,就能流式 reveal。只有 active message 中被 presentation 指定的 active part 能 reveal;tool 尾部会夺走 live tail。
暂停动画等于切换成静态全文。frozen 保留当前 reveal 光标;static 才会 skip 到权威全文。
切 tab 会卸载 Session,重开时再恢复。tab keep-alive;宿主只显式注入 visible=false,projection 与订阅继续更新。
Markdown 组件负责计时、截字符串和解析。presentation 决定 playback,adapter 同步完整文本,Incremark 自己负责 parser 与 typewriter。
unmount 是 parser 的自然完成点。只有领域内容 finished 才 finalize;unmount 可能只是 StrictMode rehearsal 或视图生命周期。
领域投影
SessionDomain从 messages 与 isRunning 得到的、与具体 UI 无关的 Session 当前阶段。
activeMessageId开放 run 当前拥有展示权的 assistant message。
closed一个 text/reasoning span 已被 completed 事件封口,后续 delta 应新开 part。
展示投影
ThreadPresentation某个挂载表面此刻应该如何显示 Thread 的纯数据快照。
activePartIndexactive message 中唯一可以拥有 playback 的 narrative part 下标。
playback把内容呈现为静态、渐进 reveal 或冻结的策略。
同步与渲染
hydration前端认为 snapshot/replay burst 尚未稳定的阶段,期间历史内容静态渲染。
authoritative replacementcompleted 提供的最终文本与 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验收提示与覆盖声明

验收时不要误判

覆盖声明

报告读完了 fc9cf3f...557014c 的 27 个变更文件,共 1415 行增删,没有抽样。文中的代码片段来自 HEAD 原文件;旧管线则直接读取 base 版本的 SessionThread.tsxMarkdownPart.tsxuse-streaming-reveal.ts。runtime、domain、presentation、visibility、真实 Incremark 测试、设计稿与 handoff 也都逐项核过。

这是一份代码走读,不是 code review。未落地的设计目标只记作范围差异,启发式边界也没有被写成必然故障。代码事实都能从 GitHub 完整 diff 回查。