chore/incremark-streamdown-rehype:三管齐下的 session 渲染管线替换

buffin · 43403797...f02b7dbe · 2026-08-01 · 自包含,读完即弃

10 commits
84 文件
+3565 / −1728 行
43% 是测试代码

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

1TL;DR

这条分支做三件事,彼此独立但共同服务于"session transcript 渲染从自研走向 StreamDown"这一目标:

  1. 移除 Incremark:删掉 @incremark/react 集成、它的 patch、DevTools 逻辑,和 incremark-adapter.tsx 整个文件。
  2. 引入 StreamDown:基于 rehype 的流式 Markdown 渲染器,接管 MarkdownPart 的职责。附带新的 block spacing、heading type scale、rehype-harden 安全策略。
  3. 服务端 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
4254 行 · 80%
packages/api
455 行 · 9%
packages/client
157 行 · 3%
apps/daemon
130 行 · 2%
杂项
192 行 · 4%
子系统设计重心(要细读)可放心略过
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

MarkdownPart
text + reveal
IncremarkAdapter
IncremarkAdapter
AST slice + reveal chunks
@incremark/react
session-runtime-store
200ms quiet + 2s cap timer
isHydrating = false

现在 · StreamDown + server-ordered seam

MarkdownPart
frame.text + mode
Streamdown (rehype)
resumableSubscription
SyncedMarker at seam
dispatcher → cache
session-runtime-store
watch syncedKey
isHydrating = !synced

4数据与状态先行

SyncedMarker(新类型 · packages/api/event-feed.ts)

subscription 流里的"replay 结束、接下来全是 live"的标记。不占 cursor 位——它是流的元数据,不是事件。

packages/api/src/event-feed.ts
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。

packages/api/src/resumable-subscription.ts
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:

packages/api/src/resumable-subscription.tsseam 发射(persistent 路径)
    // 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:

packages/api/src/resumable-subscription.tsephemeral
  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:

packages/client/src/dispatcher.ts路由表处理
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:

apps/desktop/src/renderer/features/session/api/session-runtime-store.ts
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 从头 replayresumable-subscription.ts:检查 seam 有没有被 tracked 包裹(maxCursor 是否为 null)
reopening session 时 transcript 逐条动画而非 instant paintsession-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 到用户看到的带动画字符。管线整体替换,旧管线彻底删除。

全景 · 涉及 6 个文件
SessionThread
SessionThread.tsx
→ MarkdownPart
MarkdownPart.tsx
→ usePlaybackBuffer
use-playback-buffer.ts
→ Streamdown
streamdown (lib)
→ streamdownComponents
streamdown-components.tsx
→ CodeBlock / SessionLink

A.1MarkdownPart:从 Incremark 代理到 StreamDown 直连

以前
MarkdownPart 传 text + reveal + sealed + devtoolsId
↓
IncremarkAdapter 持有 useIncremark(),sliceAst 按 cursor 裁剪
↓
<Incremark> 组件递归渲染 AST 节点,通过 patched 的 ComponentMap 注入 Buffin 的 code/link/text
现在
MarkdownPart 仅 3 个 props:text, reveal, sealed
↓
usePlaybackBuffer(text, policy, sealed) → frame(已有 hook,未变)
↓
<Streamdown> 接收 frame.text,mode 切换 streaming / static,components 映射到 Buffin 表面

MarkdownPart 的新入口非常薄——决策逻辑只有两行:

apps/desktop/src/renderer/features/session/components/MarkdownPart.tsx
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 渲染:

apps/desktop/src/renderer/features/session/components/streamdown-components.tsx核心设计:代码块
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 文本有闪烁/remountMarkdownPart.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」。

全景 · 涉及 4 个文件
TabStrip.tsx
测量 + frozen widths
→ strip-overflow.ts
partitionStrip
→ TabOverflowMenu.tsx
badge + 菜单
→ drop-geometry.ts
resolveDrop + order

B.1partitionStrip:谁画、谁藏

核心纯函数——给定 tab id 列表、active tab、strip 宽度(rem),返回 visible + hidden 两个数组:

apps/desktop/src/renderer/features/workbench/components/strip-overflow.ts
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 而非跨越计数。

apps/desktop/src/renderer/features/workbench/components/drop-geometry.ts
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 步进不动或跳过 tabstrip-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心智模型补丁

session transcript 渲染依赖 @incremark/react,有 DevTools、ComponentMap patch、sliceAst 渲染由 streamdown 库完成,通过 rehype plugin 管线,Buffin 只提供 Components 映射和 url transform
标准做法:修改渲染 → streamdown-components.tsx;修改安全策略 → rehypePlugins 配置。
isHydrating 靠 200ms quiet + 2s cap 计时器从 timeline 变化推测 isHydrating 读 agentSyncedQueryKey 的 cache,由 daemon 的 SyncedMarker 在 replay/live 交接时确定性翻转
排查 hydration 不退出时,看 dispatcher 有没有把 marker 写入 cache,不再是"计时器为什么没 fire"。
SessionEvent 类型定义在 desktop 侧的 session-store.ts AgentSessionEvent 定义在 @buffin/api/session-events.ts,desktop 端 type SessionEvent = AgentSessionEvent
其它 consumer(CLI、未来的 web)直接 import @buffin/api。
TabStrip 按内容宽度排列,窄到一定宽度开始滚动,drag index = "指针跨了几个 tab" Tab 等宽(6–14.375 rem),overflow menu 收尾部 tab,drag index 用 TabRect.order 换算回 pane 全序
resumableSubscription yield 的类型是 Tracked<E> | ResyncMarker yield 类型是 ResumableItem<E> = Tracked<E | SyncedMarker> | ResyncMarker | SyncedMarker
消费侧需要多一步判断——先 isResync,再 isSynced(可能在 tracked 里),最后才是 domain event。
MarkdownPart 有 6 个 props(含 deprecated playback/finished + devtoolsId + sessionMessageId) MarkdownPart 只有 3 个 props:text, reveal, sealed

10新词表

流与标记
SyncedMarkersubscription 流里一次性出现的"replay 已发完,从这里开始全是 live"的标记。占 __synced: true discriminant。
ResumableItem<E>subscription 能 yield 的所有形状的 union。
seamreplay 和 live 的交界点——marker 的发射位置。
渲染管线
StreamDown / Streamdown基于 rehype 的流式 Markdown 渲染器(第三方库),替代 Incremark。
streamdownComponentsBuffin 的 Components 映射对象,把每个 HTML 元素映射到 Buffin UI 原语。
rehype-hardenrehype 安全插件,控制链接/图片/协议白名单。
BLOCK_SPACING / HEADING_SPACING两张 Tailwind margin class 查表,定义 transcript 的 block 节奏。
mode: static / streamingStreamDown 的解析模式——static 允许 finalize parser(性能好),streaming 容忍不完整 Markdown。
UI 组件
partitionStrip纯函数,把 tab 列表切成 visible + hidden 两组。
TabOverflowMenu显示被隐藏 tab 的菜单 + badge。
frozenWidthspointer 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
合并前无必办项:所有 commit 已通过 typecheck、lint(除 app-managed file)、focused tests。视觉验收在 HANDOFF 中标为 uncertain 待人工确认,不阻塞合并。

12验收提示

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 确认为机械变更未逐行读。