Session Thread 展示状态机:从布尔流式标志到可推导播放策略
Buffin · main@fc9cf3f → local working tree · 2026-07-15 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自工作区、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这组改动没有触碰 daemon 事件协议。它在 Desktop 内新增一层 ThreadPresentation,把 run、replay、审批/输入阻塞、tab/window 可见性和 reduced-motion 合成为一个播放决定。
Thread 不再把 isRunning 下发给所有消息。只有最后一条 running assistant message、其中最后一个尚未关闭的 text/reasoning part,才可能获得 revealing 或 frozen。
字符推进改由 Incremark 原生 typewriter 承担。Buffin 保留产品语义所有权:决定何时 resume()、pause()、skip(),但不再维护一套 30ms 定时器和字符串窗口。
2状态词汇预载
读后续旅程前,只需先记住两组状态。execution 回答“Session 现在在做什么”,playback 回答“当前开放内容应该怎么出现”。两者分开后,暂停不再被误解为完成,历史静态也不再被误解为没有 run。
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 / isRunning | Session reducer | run 是否结束,最新交互是否在等人 |
isHydrating | runtime store | 当前是否仍处于 replay/resync 静默窗口 |
visible | Workbench host + document | tab/pane 与应用窗口是否都可见 |
reduceMotion | Framer Motion hook | 是否把所有运动降级为静态 |
3旅程 A:切换 Session 后,谁有资格播放
这条旅程从 Workbench 已经知道的 visible 开始,走到最后一个 Markdown part。核心变化不是“多了一个动画”,而是动画资格只在一个纯函数里生成,再逐级收窄。
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。
{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 静态渲染。
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。渲染组件不再分别猜测这些信号的组合含义。
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。
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.tsx 的 isLast 下发与 findActiveNarrativeGroupIndex() |
| 审批卡出现时文字突然补全 | deriveExecutionPhase() 是否识别最新 running message 里的 pending interaction |
| 完成 Session 仍有入场或脉冲 | animateEntries 是否为 false,以及 showRunningIndicator 的三重条件 |
4旅程 B:Markdown 如何播放、暂停与静态落盘
这条旅程解释 presentation 到达 MarkdownPart 后发生什么。现在解析器持续接收真实增量,typewriter 只控制这些 block 何时暴露;文本不再先被 React 截成一个不断增长的 substring。
session-store.ts→ playback 控制
MarkdownPart.tsx→ Incremark blocks
B.1从自研字符串窗口回到 Incremark typewriter
useStreamingReveal 每 30ms 放出 4 个字符im.render()im.append()fade-in 推进 blockpause/resume/skip 映射 frozen/revealing/static初始化参数本身就带 enabled: playback === 'revealing'。这不是重复设置:历史 Session 挂载时,必须在第一批 block 进入 transformer 前就禁用 typewriter;只在 effect 里晚一步 skip() 会留下历史内容延迟播放的窗口。
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。
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。
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,
)
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.ts 的 findOpenCompletedPartIndex() 与 canAppendDelta() |
| 代码高亮在 Electron 中空白 | apps/desktop/index.html 与 scripts/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-in | React 不再反复构造 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心智模型补丁
message.completed 没有 text 就可以忽略。
空正文 completion 仍然关闭匹配 span,它改变后续 delta 的归属。
7新词表
| 展示域 | |
|---|---|
ThreadPresentation | SessionContent 根据多路状态一次性派生、供 Thread 只读消费的展示快照。 |
execution | 当前 run 是执行、等审批、等输入、空闲还是已落定。 |
playback | 当前唯一开放叙事 part 的出现方式:静态、渐进或冻结。 |
animateEntries | 最后一个消息外壳是否允许 Framer Motion 入场,不控制 Markdown 字符。 |
| 内容域 | |
closed | 一个 text/reasoning span 已收到 completion,后续 delta 必须另开 part。 |
active narrative group | 从后向前找到的最后一个未 closed 的 text 或 reasoning group。 |
| 宿主域 | |
ContentVisibilityProvider | Workbench 把 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。
MarkdownPartDevReplay 只在 import.meta.env.DEV 出现在 Thread 底部;它是人工观察流式 Markdown 的 fixture,不是生产消息。'wasm-unsafe-eval' 是 WebAssembly 编译权限,不等同于允许任意 JavaScript eval()。
9覆盖声明
本文全量阅读了限定范围内 20 个文件的每一行 diff,并二次精读了 SessionContent、SessionThread、MarkdownPart、session-store、thread-presentation、ContentHost 与 content-visibility 的相关上下文;before 代码取自 main@fc9cf3f。随附的 playback 方案、Session UI roadmap 与上一版 handoff 仅用于核对计划偏差。
工作区中同时存在 Composer 圆角、根级 PostCSS、半径检查、视觉文档和旧报告等未提交变更;它们不属于本次 Session Thread 展示状态机范围,因此未计入统计,也未作结论。