Session Thread 展示状态机:从布尔流式标志到可推导播放策略

Buffin · main@fc9cf3f → local working tree · 2026-07-15 · 自包含,读完即弃

local diff 未提交
20 文件
+655 / −242
48.6% 是测试代码

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

1TL;DR

这组改动没有触碰 daemon 事件协议。它在 Desktop 内新增一层 ThreadPresentation,把 run、replay、审批/输入阻塞、tab/window 可见性和 reduced-motion 合成为一个播放决定。

Thread 不再把 isRunning 下发给所有消息。只有最后一条 running assistant message、其中最后一个尚未关闭的 text/reasoning part,才可能获得 revealingfrozen

字符推进改由 Incremark 原生 typewriter 承担。Buffin 保留产品语义所有权:决定何时 resume()pause()skip(),但不再维护一套 30ms 定时器和字符串窗口。

2状态词汇预载

读后续旅程前,只需先记住两组状态。execution 回答“Session 现在在做什么”,playback 回答“当前开放内容应该怎么出现”。两者分开后,暂停不再被误解为完成,历史静态也不再被误解为没有 run。

features/session/models/thread-presentation.ts新增的展示状态形状
export type ThreadExecutionPhase =
  | 'idle'
  | 'running'
  | 'waiting-approval'
  | 'waiting-input'
  | 'settled'

export type ThreadPlaybackMode = 'static' | 'revealing' | 'frozen'

export interface ThreadPresentation {
  execution: ThreadExecutionPhase
  playback: ThreadPlaybackMode
  animateEntries: boolean
}
输入归属它决定什么
messages / isRunningSession reducerrun 是否结束,最新交互是否在等人
isHydratingruntime store当前是否仍处于 replay/resync 静默窗口
visibleWorkbench host + documenttab/pane 与应用窗口是否都可见
reduceMotionFramer Motion hook是否把所有运动降级为静态

3旅程 A:切换 Session 后,谁有资格播放

这条旅程从 Workbench 已经知道的 visible 开始,走到最后一个 Markdown part。核心变化不是“多了一个动画”,而是动画资格只在一个纯函数里生成,再逐级收窄。

全景 · 涉及 8 个文件
tab 可见性
ContentHost.tsx
window 可见性
content-visibility.tsx
展示裁决
thread-presentation.ts
最后消息/part
SessionThread.tsx

A.1可见性从 Workbench 进入内容树

Buffin 的 tab 切换不会卸载旧 Session;旧 Session 只是 CSS 隐藏。因此组件“仍挂载”不能代表用户“正在看”。ContentHost 已经掌握每个 tab 的 visible 值,现在它通过 context 把这个事实送进注册内容,而无需扩展所有 content kind 的公共 props。

features/workbench/components/ContentHost.tsxhost 把 tab 状态下发
{registration ? (
  <ContentVisibilityProvider visible={visible}>
    <ContentErrorBoundary resetKey={JSON.stringify(tab.target)}>
      <registration.Component target={tab.target} />
    </ContentErrorBoundary>
  </ContentVisibilityProvider>
) : (
  <UnknownContent kind={tab.target.kind} />
)}

context 内部再与 document.visibilityState 相交。结果是:tab 隐藏或整个 Electron window 进入后台,任一条件都足以让 Session 静态渲染。

features/workbench/host/content-visibility.tsxsurface 与 window 取交集
export function useContentVisibility(): boolean {
  const surfaceVisible = useContext(ContentSurfaceVisibilityContext)
  const windowVisible = useSyncExternalStore(
    subscribeWindowVisibility,
    getWindowVisibility,
    () => true,
  )
  return surfaceVisible && windowVisible
}

A.2展示状态机做一次集中裁决

SessionContent 收齐 messages、run、hydration、visibility 与 reduced-motion 后,调用纯函数派生 presentation。渲染组件不再分别猜测这些信号的组合含义。

features/session/models/thread-presentation.ts唯一的播放策略入口
export function deriveThreadPresentation(input: ThreadPresentationInput) {
  const execution = deriveExecutionPhase(input.messages, input.isRunning)
  return {
    execution,
    playback: derivePlaybackMode(input, execution),
    animateEntries:
      execution === 'running' &&
      input.visible &&
      !input.isHydrating &&
      !input.reduceMotion,
  }
}

function derivePlaybackMode(input, execution): ThreadPlaybackMode {
  if (!input.visible || input.isHydrating || input.reduceMotion) return 'static'
  if (execution === 'waiting-approval' || execution === 'waiting-input') return 'frozen'
  return execution === 'running' ? 'revealing' : 'static'
}

这里有一条容易忽略的优先级:不可见、回放和 reduced-motion 先压成 static;只有表面可见时,pending approval/input 才表现为 frozen。隐藏 tab 不保留待播放队列,它直接追上当前全文。

A.3Session 资格继续收口到最后一轮、最后一个开放 part

Session 级 revealing 只是上限,不代表所有消息都能动画。消息层先要求 isLast 且 status 仍为 running;part 层再寻找最后一个未关闭的 text/reasoning group。旧轮次即便残留 running 状态,也会收到 static。

features/session/components/SessionThread.tsx消息级与 part 级双重收口
const isLast = i === messages.length - 1

<AssistantMsg
  playback={isLast && msg.status === 'running'
    ? presentation.playback
    : 'static'}
  showRunningIndicator={
    isLast &&
    msg.status === 'running' &&
    presentation.execution === 'running'
  }
/>

function findActiveNarrativeGroupIndex(groups: PartGroup[]): number {
  for (let index = groups.length - 1; index >= 0; index--) {
    const group = groups[index]
    if ((group?.type === 'text' || group?.type === 'reasoning') && !group.closed)
      return index
  }
  return -1
}

Framer Motion 只负责最后一个消息壳的 180ms opacity/y 入场。文字的逐步 reveal 不由 Framer Motion 驱动;它在下一条旅程交给 Incremark。

排查路标 · 旅程 A
症状从哪下手
切到旧 tab 后历史内容重新播放content-visibility.tsx 的 window/surface 交集;derivePlaybackMode() 的 static 优先级
多轮 assistant 同时闪动SessionThread.tsxisLast 下发与 findActiveNarrativeGroupIndex()
审批卡出现时文字突然补全deriveExecutionPhase() 是否识别最新 running message 里的 pending interaction
完成 Session 仍有入场或脉冲animateEntries 是否为 false,以及 showRunningIndicator 的三重条件

4旅程 B:Markdown 如何播放、暂停与静态落盘

这条旅程解释 presentation 到达 MarkdownPart 后发生什么。现在解析器持续接收真实增量,typewriter 只控制这些 block 何时暴露;文本不再先被 React 截成一个不断增长的 substring。

全景 · 涉及 7 个文件
message.delta / completed span 开闭
session-store.ts
playback 控制
MarkdownPart.tsx
Incremark blocks

B.1从自研字符串窗口回到 Incremark typewriter

以前
useStreamingReveal 每 30ms 放出 4 个字符
每次把 revealed 全文交给 im.render()
streaming 变 false 时直接 snap 全文
现在
只把新增 suffix 交给 im.append()
Incremark 以 fade-in 推进 block
pause/resume/skip 映射 frozen/revealing/static

初始化参数本身就带 enabled: playback === 'revealing'。这不是重复设置:历史 Session 挂载时,必须在第一批 block 进入 transformer 前就禁用 typewriter;只在 effect 里晚一步 skip() 会留下历史内容延迟播放的窗口。

features/session/components/MarkdownPart.tsxIncremark 初始化与播放映射
const typewriterOptions = {
  charsPerTick: [1, 3],
  tickInterval: 24,
  effect: 'fade-in',
  pauseOnHidden: true,
} satisfies TypewriterOptions

const im = useIncremark({
  gfm: true,
  htmlTree: true,
  typewriter: { ...typewriterOptions, enabled: playback === 'revealing' },
})

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])

B.2增量 append,权威替换 reset,完成时 finalize

previousTextRef 把输入分成三类。前缀增长是正常 streaming,只 append 后缀;文本清空就 reset;新文本不再以前文为前缀,代表 completed 的权威校正或重建,先 reset 再 append 全文并静态 skip。

features/session/components/MarkdownPart.tsx解析器生命周期边界
const previous = previousTextRef.current
if (text === previous) return

if (text.length === 0) {
  im.reset()
} else if (previous === undefined || text.startsWith(previous)) {
  im.append(text.slice(previous?.length ?? 0))
} else {
  im.reset()
  im.append(text)
  im.typewriter.setEnabled(false)
  im.typewriter.skip()
}

if (finished && !finalizedRef.current) {
  im.finalize()
  im.typewriter.skip()
}

finished 来自 part 的 closed 或整个 message 的 complete。它确保 parser 只 finalize 一次;尚未完成的实例在 unmount 时也会 finalize,避免留下开放解析状态。

B.3message.completed 没有 text 也会关闭 span

provider 的 completed 不一定重复携带全文。旧 reducer 遇到空 text 直接返回,导致 part 永远保持开放,后续 delta 可能错误并入旧 span。现在它按 itemId 优先寻找匹配的开放 part,并设置 closed: true

features/session/models/session-store.ts无正文 completion 的关闭语义
if (!completedText) {
  const index = findOpenCompletedPartIndex(parts, partType, event.itemId)
  const existing = parts[index]
  if (index >= 0 && existing) parts[index] = { ...existing, closed: true }
  return
}

const exact = findLastPartIndex(
  parts,
  (part) =>
    part.type === partType &&
    part.itemId === itemId &&
    !part.closed,
)
CSP 配套:Incremark 的语法高亮使用 oniguruma WebAssembly。Desktop 的 script-src 增加窄权限 'wasm-unsafe-eval',仓库 CSP 棄查只豁免这个关键字,仍拒绝真正的 'unsafe-eval'
排查路标 · 旅程 B
症状从哪下手
历史消息切回来后逐段补播MarkdownPart.tsx 初始化的 typewriter.enabled 与 static 分支
审批暂停后文字一次性跳满MarkdownPart.tsx frozen 分支是否只 pause(),以及 append 是否仍入队
completed 校正出现旧文闪烁previousTextRef 的非前缀 replacement 分支
新 delta 接到已经完成的段落session-store.tsfindOpenCompletedPartIndex()canAppendDelta()
代码高亮在 Electron 中空白apps/desktop/index.htmlscripts/check-csp.mjs 的 wasm 权限配对

5计划与实现的偏差

这是一组从原型反馈中收敛出来的本地改动。随附方案文档描述的是长期目标,handoff 记录的是上一版实验;当前实现选择了更小的 MVP 边界。

计划或上一版实际做成偏差的可观察结果
Streamdown 参考 adapter + Incremark 比较 adapter直接在 MarkdownPart 内封装 Incremark没有 parser 抽象层;当前代码只维护一条 Markdown 路径
自研 30ms/4 chars reveal + CSS blur删除 hook 和 5 条定时器测试,使用 Incremark fade-inReact 不再反复构造 substring;pause/resume 由 transformer 保持
完整 SyncState 与 caught-up marker继续使用现有 isHydrating 200ms quiet / 2000ms cap无需后端改动,但 replay/live 的精确边界仍由启发式信号表示
基于 sessionSeq 的 activation barrier依赖 tab 持续挂载、static 时立即 skip、重新可见后只动画后续 append满足当前单窗口 mounted-tab 模型;presentation 中没有 cursor
Playback 只有 static/live/reduced-motion增加 frozen,专门表达审批或输入等待暂停时可继续接收文本但不 reveal,恢复后续播

6心智模型补丁

running message 自然等于 streaming UI。 running 只是 domain 状态;是否播放还取决于 hydration、visibility、reduced-motion 与 interaction。
tab 隐藏后组件会卸载,因此 timer 会自然停止。 Workbench 保持所有 tab 挂载;host 必须显式向内容树提供 visible。
Session 级 isRunning 可以直接传给所有 assistant message。 只有最后一条 running assistant message 的最后一个开放 narrative part 能消费 playback。
暂停等于把 isStreaming 改成 false 并展示全文。 暂停是 frozen:parser 继续 append,typewriter 停在当前 offset,不执行 skip。
Markdown 动画由 Buffin 的字符串窗口产生。 Incremark 产生 fade-in;Buffin 只拥有 eligibility、暂停和静态策略。
message.completed 没有 text 就可以忽略。 空正文 completion 仍然关闭匹配 span,它改变后续 delta 的归属。

7新词表

展示域
ThreadPresentationSessionContent 根据多路状态一次性派生、供 Thread 只读消费的展示快照。
execution当前 run 是执行、等审批、等输入、空闲还是已落定。
playback当前唯一开放叙事 part 的出现方式:静态、渐进或冻结。
animateEntries最后一个消息外壳是否允许 Framer Motion 入场,不控制 Markdown 字符。
内容域
closed一个 text/reasoning span 已收到 completion,后续 delta 必须另开 part。
active narrative group从后向前找到的最后一个未 closed 的 text 或 reasoning group。
宿主域
ContentVisibilityProviderWorkbench 把 tab/pane 的可见性送进注册内容,不改变 content registry 的 target 接口。

8测试与风险地图

有兜底的

  • visible live run → revealing;replay/hidden/reduced-motion → static。
  • approval/input pending → frozen;resolved → revealing。
  • settled Session → static 且不允许 entry animation。
  • 两条 running message → 只有最后一条 revealing,只有一个 pulse。
  • static Markdown 首次挂载时关闭 typewriter。
  • append suffix、replacement reset、frozen queue、finalize once。
  • 无正文 message.completed 关闭累计 span。
  • Workbench hidden tab 的 context 值为 false。

事实上的薄冰

  • 🟡Incremark 在组件测试中被 mock;真实 DOM 的 fade-in、pause offset 与复杂 Markdown 结构没有自动化断言。
  • 🟡document.visibilityState 的 visibilitychange 路径没有独立测试;现有测试覆盖 provider 的 surface 值。
  • 🟡replay/live 仍依赖 hydration quiet/cap timer,没有后端 caught-up marker。
  • activation cursor barrier 未进入 presentation;当前语义依赖隐藏 tab 始终挂载。
  • CSP 检查允许 wasm 关键字,但这组 diff 没有新增针对该豁免的专门 fixture。
已运行验证:定向 23/23;Desktop 117 个测试文件、757/757;Desktop typecheck;目标文件 ESLint;comments gate;Desktop production build。Renderer dev server 返回 HTTP 200。
验收时别误判MarkdownPartDevReplay 只在 import.meta.env.DEV 出现在 Thread 底部;它是人工观察流式 Markdown 的 fixture,不是生产消息。'wasm-unsafe-eval' 是 WebAssembly 编译权限,不等同于允许任意 JavaScript eval()

9覆盖声明

本文全量阅读了限定范围内 20 个文件的每一行 diff,并二次精读了 SessionContentSessionThreadMarkdownPartsession-storethread-presentationContentHostcontent-visibility 的相关上下文;before 代码取自 main@fc9cf3f。随附的 playback 方案、Session UI roadmap 与上一版 handoff 仅用于核对计划偏差。

工作区中同时存在 Composer 圆角、根级 PostCSS、半径检查、视觉文档和旧报告等未提交变更;它们不属于本次 Session Thread 展示状态机范围,因此未计入统计,也未作结论。