chore/incremark-streamdown-rehype:三管齐下的 session 渲染管线替换
buffin · 43403797...f02b7dbe · 2026-08-01 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这条分支做三件事,彼此独立但共同服务于"session transcript 渲染从自研走向 StreamDown"这一目标:
- 移除 Incremark:删掉
@incremark/react集成、它的 patch、DevTools 逻辑,和incremark-adapter.tsx整个文件。 - 引入 StreamDown:基于 rehype 的流式 Markdown 渲染器,接管
MarkdownPart的职责。附带新的 block spacing、heading type scale、rehype-harden 安全策略。 - 服务端 replay/live seam(SyncedMarker):
@buffin/api的resumableSubscription在 replay 与 live 的交接点发出一个服务端排序的标记,desktop 端isHydrating从"200ms 静默 + 2s 上限"的计时器猜测改为读这个确定性标记。
同时搭车:TabStrip 等宽 + overflow menu、ExplorerBreadcrumb crumb fold、session event 类型上提到 @buffin/api。
2变更地图
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| apps/desktop | MarkdownPart.tsx、streamdown-components.tsx、session-runtime-store.ts、TabStrip.tsx、strip-overflow.ts、drop-geometry.ts、ExplorerBreadcrumb.tsx、crumb-fold.ts | incremark-adapter.tsx(纯删除)、CSS indent/spacing 调整、测试文件(验证行为而非引入设计) |
| packages/api | session-events.ts(新文件)、resumable-subscription.ts(seam 发射)、event-feed.ts(SyncedMarker 类型) | index.ts re-export、trpc.ts 1 行 marker 透传 |
| packages/client | dispatcher.ts(markAgentSynced/Unsynced + 路由表解包)、session.ts(gcTime pin) | electron-renderer re-export |
| apps/daemon | 全部是测试修正(适配 seam marker 的新 stream shape) | — |
| 杂项 | — | bun.lock(dep 变动)、HANDOFF.md(过程文档)、patches/@incremark patch 删除、docs glossary |
3架构一图流
变化集中在 session transcript 渲染 和 hydration 判定 两条通道:
以前 · session Markdown 经 Incremark
现在 · StreamDown + server-ordered seam
4数据与状态先行
SyncedMarker(新类型 · packages/api/event-feed.ts)
subscription 流里的"replay 结束、接下来全是 live"的标记。不占 cursor 位——它是流的元数据,不是事件。
export type SyncedMarker = {
// Discriminant flag identifying the history-complete boundary.
readonly __synced: true
}
ResumableItem(新类型 · packages/api/resumable-subscription.ts)
subscription 能 yield 的所有形状:domain 事件的 Tracked 包装 + 两个 out-of-band marker。
export type ResumableItem<E> = Tracked<E | SyncedMarker> | ResyncMarker | SyncedMarker
AgentSessionEvent(新文件 · packages/api/session-events.ts)
从 desktop 的 session-store.ts 原封上提的 discriminated union。desktop 侧改为 type SessionEvent = AgentSessionEvent 的 re-alias。15 种事件类型,每种带 [k: string]: unknown 索引签名允许 wire 附加字段透过而不焊入合约。
SessionRuntimeState.isHydrating(改义 · desktop)
从"由计时器猜测的 boolean"变为"由服务端 synced marker 驱动的确定性 boolean"。读法:queryClient.getQueryData<boolean>(syncedKey) !== true。
5底座:SyncedMarker 从服务端到 UI 的全链路
这是三条旅程共用的底层机制。seam 的核心承诺:marker 不可能在最后一条 replay 事件之前到达,也不可能在第一条 live 事件之后到达——由发射位点的代码位置保证,不依赖网络计时。
5.1发射位点:resumableSubscription
persistent 路径中,replay 遍历结束后、live tail yield 之前,恰好在这两者之间 yield 一次 seam:
// replay 已 drain,live 尚未 yield——正是 seam 的唯一合法位置
yield maxCursor === null
? syncedMarker() // 无 replay 历史 → 裸 marker
: trackedEvent(maxCursor, syncedMarker()) // 重复上一条 cursor 包裹 marker
yield* liveTracked(feed, liveIterator, signal, maxCursor)
包裹的原因:tRPC 的 wsLink 从每条 subscription data message 的 id 字段更新 lastEventId。如果 marker 是裸发(无 id),客户端的 resume cursor 被清空,下次重连从头 replay。repeating last cursor 保住了 resume position。
ephemeral 路径更简单——没有 replay 历史,resync 之后立刻出 seam:
const live = feed.live(signal)
yield resyncMarker()
yield syncedMarker() // 无历史 → 立即 synced
yield* trackedEphemeral(live, signal)
5.2客户端落地:dispatcher → session-runtime-store
dispatcher route table 的 agent.events 分支现在先 unwrap tracked envelope,再判断 payload 是否为 seam:
apply(ctx, input, item) {
if (isResyncMarker(item)) return resyncAgentTimeline(ctx, input)
const payload = 'data' in item ? item.data : item
// seam 可能在 tracked 里(有 cursor),也可能裸发
if (isSyncedMarker(payload)) return markAgentSynced(ctx, input)
appendAgentEvent(ctx, input, payload)
}
markAgentSynced 写一个 per-session boolean cache entry(agentSyncedQueryKey),resyncAgentTimeline 在清 timeline 的同时调 markAgentUnsynced 归 false。
session-runtime-store 订阅两个 query cache key(timeline + synced),无论哪个先到,都同步 isHydrating:
const readHydrating = () => queryClient.getQueryData<boolean>(syncedKey) !== true
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
const { queryHash } = event.query
if (queryHash === timelineHash) ingest()
// 两个 key 都可能最后到:timeline batch 先到、synced 后到是常态
if (queryHash === timelineHash || queryHash === syncedHash) syncHydrating()
})
排查路标 · SyncedMarker 全链路
| 症状 | 从哪下手 |
|---|---|
| 重连后整个 session 从头 replay | resumable-subscription.ts:检查 seam 有没有被 tracked 包裹(maxCursor 是否为 null) |
| reopening session 时 transcript 逐条动画而非 instant paint | session-runtime-store.ts:syncHydrating() 是否被调到、syncedKey 的 cache 值 |
| session 永远停在 hydrating 不退出 | dispatcher.ts:isSyncedMarker 是否匹配到 payload(注意 tracked 包裹的 seam 也要能穿透) |
| resync 后 hydration 没有重新进入 | dispatcher.ts:resyncAgentTimeline 末尾的 markAgentUnsynced |
6旅程 A:一段 Markdown 文本从 source delta 到屏幕像素
这条旅程覆盖 session transcript 的叙事文本渲染管线——从 reducer 侧的 message.delta 到用户看到的带动画字符。管线整体替换,旧管线彻底删除。
SessionThread.tsx→ MarkdownPart
MarkdownPart.tsx→ usePlaybackBuffer
use-playback-buffer.ts→ Streamdown
streamdown (lib)→ streamdownComponents
streamdown-components.tsx→ CodeBlock / SessionLink
A.1MarkdownPart:从 Incremark 代理到 StreamDown 直连
MarkdownPart 的新入口非常薄——决策逻辑只有两行:
export function MarkdownPart({ text, reveal, sealed }: MarkdownPartProps) {
const policy = reveal ?? 'skip'
const sourceSealed = sealed ?? false
const frame = usePlaybackBuffer(text, policy, sourceSealed)
const draining = frame.text !== text || frame.reveals.length > 0
const mode = sourceSealed && !draining && policy === 'skip' ? 'static' : 'streaming'
// static = 不再有新字符进来,可以 finalize parser;streaming = 还在追
// ...
}
rehype 管线由三层 plugin 组成:raw(允许 inline HTML pass-through)→ sanitize(默认白名单)→ harden(Buffin 安全策略:链接只允许 http/https/mailto,图片 textOnly,打开外部链接用 sentinel origin http://buffin.invalid 做中间态,后续由 streamdownUrlTransform 把 file 路径还原回去)。
A.2StreamDown 组件映射:streamdown-components.tsx
一个稳定的 Components 对象挂在模块顶层,每个 Markdown 元素映射到对应的 Buffin 渲染:
function StreamdownCode({ node: _node, className, children }: SdProps<'code'>) {
const isIncomplete = useIsCodeFenceIncomplete()
const code = (typeof children === 'string' ? children : '').replace(/\n$/, '')
const language = className?.match(/language-([\w-]+)/)?.[1]
if (language === undefined && !code.includes('\n')) {
return <SessionInlineCode>{children}</SessionInlineCode>
// 无 language 且无换行 → 内联 code,不是 fence
}
return (
<CodeBlock
className={cn(BLOCK_SPACING.code, 'rounded-composer border-border bg-composer')}
code={code}
completed={!isIncomplete}
language={language ?? 'text'}
/>
)
}
block spacing 双层节奏(定义在 streamdown-spacing.ts):paragraph/list 用 my-3(12px),code/table/blockquote/rule 用 my-4(16px),heading 按级别顶部递增。所有值靠 Tailwind utility 实现,不依赖全局 CSS。
A.3isHydrating 驱动:动画 vs 静默 paint
MarkdownPart 传给 StreamDown 的 isAnimating prop 决定字符是否有 fade-in 动画。而 isHydrating——由上游 session-runtime-store 从 synced marker 读出——控制 reveal 策略:hydrating 时 reveal = skip,不 pace,transcript 静默 paint。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| Markdown 渲染白屏 / 报错 | MarkdownPart.tsx:rehypePlugins 配置(harden blockPolicy);streamdownUrlTransform 返回值 |
| 代码块无语法高亮 / 样式塌陷 | streamdown-components.tsx:StreamdownCode 里的 language 解析和 CodeBlock props |
| 文件链接点击无反应 | streamdown-components.tsx:StreamdownLink,看 buffin.invalid 前缀是否被正确截取 |
| streaming 文本有闪烁/remount | MarkdownPart.tsx:mode 从 streaming 跳到 static 的时机(draining 没走完就切了会触发 full reparse) |
| paced 输出没有动画 | MarkdownPart.tsx:isAnimating 条件——policy 是否正确传入、frame.text 是否落后于 text |
7旅程 B:TabStrip 等宽分配与 Overflow
这条旅程是新增功能:workbench 的 tab 条从以前的「tab 按内容宽度排列 → 窄到一定程度开始滚动」变为「tab 等宽共享条宽 → 窄于下限 6rem 开始隐入 overflow menu → wheel 在全序中步进切换 active tab」。
测量 + frozen widths→ strip-overflow.ts
partitionStrip→ TabOverflowMenu.tsx
badge + 菜单→ drop-geometry.ts
resolveDrop + order
B.1partitionStrip:谁画、谁藏
核心纯函数——给定 tab id 列表、active tab、strip 宽度(rem),返回 visible + hidden 两个数组:
export function partitionStrip(
tabIds: readonly string[],
activeTabId: string | null,
widthRem: number,
): StripPartition {
if (tabIds.length * TAB_MIN_REM <= widthRem) return { visible: tabIds, hidden: [] }
const slots = visibleSlots(widthRem)
if (slots >= tabIds.length) return { visible: tabIds, hidden: [] }
const leading = tabIds.slice(0, slots)
const visible =
activeTabId !== null && !leading.includes(activeTabId) && tabIds.includes(activeTabId)
? [...leading.slice(0, slots - 1), activeTabId]
: leading
// active tab 若被 overflow 掉,占最后一个 slot;被它顶掉的 tab 回到 hidden
const drawn = new Set(visible)
return { visible, hidden: tabIds.filter((id) => !drawn.has(id)) }
}
设计要点:drawn set 从 pane order 的头部截取,overflow 从尾部丢入 menu。active tab 是例外——它永远可见,顶掉 leading run 的末位。sameStripCapacity 做 debounce:只在 floor slot 数变化时 re-render。
B.2drop-geometry:overflow 世界里的 insertion index
drag-drop 的 index 以前直接数"pointer 跨过了几个 tab 的中点"。现在 strip 只画了部分 tab,指针在画出来的最后一个 tab 之后放下,实际语义是"在那个 tab 之后"——不是"在 pane 末尾",因为 menu 里还有 tab。修正做法:TabRect 新增 order 字段(tab 在 pane 全序中的 index),insertionIndex 用 following.order 而非跨越计数。
function insertionIndex(
following: TabRect | undefined,
tail: TabRect | undefined,
dragged: TabRect | undefined,
): number {
const landing = following?.order ?? (tail === undefined ? 0 : tail.order + 1)
// tab 往前移时自己的旧位置先腾出,landing 要 -1
return dragged !== undefined && dragged.order < landing ? landing - 1 : landing
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 窗口缩窄后 tab 消失但 menu badge 不出现 | strip-overflow.ts:partitionStrip 的 hidden 输出;TabOverflowMenu 的渲染条件 |
| wheel 步进不动或跳过 tab | strip-overflow.ts:tabByStep——用 pane 全序而非 visible 序 |
| drag-drop 后 tab 落在错误位置 | drop-geometry.ts:insertionIndex 的 order 字段、forward-move 的 -1 修正 |
| tab 拖动时宽度跳变 | TabStrip.tsx:frozen widths(pointer down 时 pin 宽度) |
8计划 vs 实现偏差
| 计划 | 实际 | 原因 |
|---|---|---|
| Incremark 移除后留一个"plain post-Incremark renderer"作为过渡,StreamDown 后续独立 PR 引入 | StreamDown 在同一分支紧跟引入,两步 commit 但合并在一个 PR | plain renderer 无法覆盖 streaming mode + 动画,等于要维护两个渲染管线,合并更干净 |
| hydration settle 靠"daemon replay.done signal if one lands"(原 FIXME) | 本 PR 就做了——SyncedMarker 即 replay.done signal | StreamDown 对 static/streaming mode 切换敏感,计时器猜测导致 mode 跳变 remount,必须同步解决 |
| Incremark patch 预期在上游 2.x 修复后删除 | 随 Incremark 整体删除——不是上游修复了,而是不需要了 | StreamDown 替代整个 Incremark 栈 |
| HANDOFF 描述"视觉验收待下次 session" | 后续 commit 调了 typography 和 spacing(fix(desktop): tune StreamDown transcript typography),验收部分完成 |
typography tuning 在同一分支追提 |
9心智模型补丁
streamdown 库完成,通过 rehype plugin 管线,Buffin 只提供 Components 映射和 url transform
agentSyncedQueryKey 的 cache,由 daemon 的 SyncedMarker 在 replay/live 交接时确定性翻转
@buffin/api/session-events.ts,desktop 端 type SessionEvent = AgentSessionEvent
TabRect.order 换算回 pane 全序
Tracked<E> | ResyncMarker
yield 类型是 ResumableItem<E> = Tracked<E | SyncedMarker> | ResyncMarker | SyncedMarker
10新词表
| 流与标记 | |
|---|---|
SyncedMarker | subscription 流里一次性出现的"replay 已发完,从这里开始全是 live"的标记。占 __synced: true discriminant。 |
ResumableItem<E> | subscription 能 yield 的所有形状的 union。 |
seam | replay 和 live 的交界点——marker 的发射位置。 |
| 渲染管线 | |
StreamDown / Streamdown | 基于 rehype 的流式 Markdown 渲染器(第三方库),替代 Incremark。 |
streamdownComponents | Buffin 的 Components 映射对象,把每个 HTML 元素映射到 Buffin UI 原语。 |
rehype-harden | rehype 安全插件,控制链接/图片/协议白名单。 |
BLOCK_SPACING / HEADING_SPACING | 两张 Tailwind margin class 查表,定义 transcript 的 block 节奏。 |
mode: static / streaming | StreamDown 的解析模式——static 允许 finalize parser(性能好),streaming 容忍不完整 Markdown。 |
| UI 组件 | |
partitionStrip | 纯函数,把 tab 列表切成 visible + hidden 两组。 |
TabOverflowMenu | 显示被隐藏 tab 的菜单 + badge。 |
frozenWidths | pointer down 期间 pin 住 tab 宽度的 Map,防 drag 时 reflow。 |
foldedCrumbCount | 纯函数,算 breadcrumb 要折叠几段中间路径。 |
11测试与风险地图
| 有兜底的 | 薄冰 |
|---|---|
SyncedMarker 的 placement(persistent replay 后、ephemeral 立即、OutOfRange 后立即、cursor 继承、只出现一次)— resumable-subscription.test.ts 6 条新 case |
🟠dispatcher 的 isSyncedMarker 解包逻辑(tracked 里的 seam vs 裸 seam)仅间接覆盖于 daemon ws 集成测试 |
SessionEvent type guard 和类型清单 — session-events.test.ts |
⚪新的 session-runtime-store 没有对 synced cache miss(query 被 gc)场景做单元测试(但 gcTime=Infinity pin 兜底) |
partitionStrip edge cases(empty, single, active bumped out)— strip-overflow.test.ts |
🟡TabStrip frozen widths + wheel step 跨 overflow 边界——无自动化覆盖 |
foldedCrumbCount 边界 — crumb-fold.test.ts |
⚪ExplorerBreadcrumb ResizeObserver 交互仅依赖 happy-path stub |
MarkdownPart mode/sealed 驱动 — MarkdownPart.test.tsx |
🟡StreamDown harden 配置(allowedProtocols 等)无 dedicated render test,依赖库自身保证 |
drop-geometry insertionIndex + forward-move — drop-geometry.test.ts |
|
daemon broadcaster 适配(gap recovery + seam filter)— agent-broadcaster.test.ts |
|
tRPC ws resume across reconnect — trpc-wss.test.ts |
12验收提示
incremark-adapter.tsx全文删除(246 行),不是搬运——没有对应的新文件。patches/@incremark%2Freact@1.0.2.patch删除:不需要了,不是漏提。MarkdownPart.real.test.tsx删除(293 行):旧 Incremark 集成测试,功能由MarkdownPart.test.tsx的新 case 覆盖。- TabOverflowMenu 目前没有生产端的数据注入(badge watch 待 upstream pane model 接入),只有测试驱动——属于 ahead-of-integration 的组件。
- daemon test 中
take/nextTracked新增了 seam filter 逻辑,不影响被测行为。
13覆盖声明
全部 84 个变更文件的 diff 已被覆盖:packages/api、packages/client、apps/daemon 逐文件精读 diff;apps/desktop 通过 subagent 全量精读后主力二次确认核心文件(MarkdownPart.tsx、streamdown-components.tsx、session-runtime-store.ts、strip-overflow.ts、drop-geometry.ts、crumb-fold.ts、session-store.ts)的 after 状态。HANDOFF.md、glossary、patch、commit log 全部读过。杂项(bun.lock、CSS indent-only 变更、re-export 文件、docs 中文镜像)以 diff stat 确认为机械变更未逐行读。