feat: optimize chat think

This commit is contained in:
imccyu
2026-08-04 14:37:35 +08:00
parent a44c8797b6
commit 6f1d443c59
13 changed files with 296 additions and 66 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 8eaa039caa8af8f2c30424b007edab5e9a2a91ad
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: e09371502a96f5770057d14d59bddeb0a36c3ddf
2026-08-03-opt-in-reasoning-chunk-browser-stress.md: f4247b3b69342aec0ae63f940a370771252eb6ae
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: de4fb51df88f5932aba6f5dad78b1a929ba7b9a0
@@ -1,4 +1,4 @@
# Agent Note: Opt-in reasoning chunk browser stress lane
# Agent Note: Frame-coalesced reasoning-chunk publication and browser stress validation
Status: implemented
@@ -6,28 +6,38 @@ English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
## Problem
The browser freeze caused by a long reasoning stream emerges across the fixture async stream, client session reduction, React reconciliation, and the live Think row. Small unit tests prove event semantics but do not expose renderer starvation, while putting a 100,000-chunk scenario in the required [web browser lane](2026-07-24-web-gui-browser-e2e-lane.md) would add a slow, intentionally red reproduction to every pull request. A producer paced by `requestAnimationFrame` also gives the renderer implicit backpressure: when the page stalls, production stalls with it and hides the network-arrival condition that triggers the regression.
Long reasoning streams continuously produce large numbers of `assistant/chunk` events. Each raw event must be ordered, logged, and folded into `PartialAccumulator` to preserve replay fidelity and the completeness of the final content; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
## Decision
`pnpm run test:web:stress` is the explicit entry point for browser performance reproductions. Its dedicated `vitest.web-stress.config.ts` includes only `apps/web/stress-tests/**/*.stress.ts`; the default unit and web configurations do not collect that suffix. The command builds first because Chromium consumes emitted client artifacts, and `tsconfig.host.json` owns the stress spec because the spec boots the host scaffold.
`Session.acceptLiveEvent()` appends every raw event immediately and synchronously updates the transcript, `PartialAccumulator`, and other session-derived state. Visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks publish through `Notifier.markFrameDirty()`: the first change schedules one `requestAnimationFrame`, later chunks only continue updating the accumulator, and the frame callback rebuilds one accumulated snapshot from the latest state and notifies subscribers once. `usage`, `finish`, and unknown invisible chunks remain in the event window but trigger no redundant React notifications. Session and history checks share the same visible-chunk classification.
The reasoning scenario selects the deterministic `?fixture` session and asks an opt-in fixture timing hook to emit exactly 100,000 individual `reasoning-delta` events. The producer has a nominal external cadence of 128 events every 16 milliseconds and repays elapsed-interval debt after a main-thread stall. This approximates bytes continuing to arrive outside the renderer without synchronously preloading a giant fixture queue. A terminal marker proves that the events crossed session reduction and reached the live Think row.
`Notifier` tracks pending publication work with a scheduling kind and generation marker. Ordinary structural events continue to publish in a microtask through `markDirty()`; if a finalized message, tool event, or error arrives while a frame publication is pending, the microtask supersedes it and the old frame callback is invalidated by its generation mismatch. `notifyNow()` likewise invalidates the old schedule to preserve synchronous echo for controlled inputs. Environments without `requestAnimationFrame` fall back to microtask batching. A finalization event may skip one intermediate partial that has not yet appeared, while the published final content and raw event sequence remain complete.
The browser installs a 50-millisecond heartbeat and schedules a DOM event before starting the stream. The final report includes emitted chunks, maximum heartbeat delay, scheduled-interaction delay, and heartbeat samples. Both delay measurements have a 250-millisecond budget. While the regression remains, the opt-in command prints the measurements and exits nonzero; it becomes the acceptance check for the renderer fix without making an already-known performance failure a default CI gate. `DSH_WEB_STRESS_HEADFUL=1 pnpm run test:web:stress` exposes the same scenario in a visible browser.
Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
The default fixture unit suite exercises the timing hook with three chunks and fake timers. That small contract pins input validation, interval pacing, concurrency rejection, exact event count, and terminal-marker delivery without carrying the 100,000-chunk workload into `pnpm test`, `pnpm run test:gui`, or `pnpm run test:web`.
`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. The deterministic `?fixture` session emits 100,000 `reasoning-delta` events at a cadence independent of painting, and a terminal marker proves that the events cross production session reduction and reach the live Think row; a 50-millisecond heartbeat and a pre-scheduled DOM event measure main-thread stalls and interaction latency, respectively, with a 250-millisecond budget for identifying clear regressions. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel; the Vite web shell and tsdown client-plugin bundle always generate source maps, and the module host keeps `client.js.map` alongside every dynamic `client.js` in the build tree, allowing the browser to map samples back to TS/TSX source. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests.
Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. Small fixture unit tests continue to pin input validation, external arrival pacing, concurrency rejection, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites.
## Alternatives considered
**Required web browser scenario.** Rejected: the reproduction currently takes tens of seconds and is expected to fail its responsiveness budget, so making it required would block unrelated work before the production fix exists.
**React transitions, deferred values, or component throttling applied to snapshots.** Rejected: the session source would still notify `useSyncExternalStore` for every chunk, the React render has already occurred before a component decides to defer display, and multiple components consuming the same snapshot would each need to implement the strategy. Visual tail-following throttling for the Think summary occurs after snapshot publication and only reduces the frequency of synchronous layout; it does not implement the data-publication policy.
**Animation-frame pacing.** Rejected after measurement: tying production to paint kept the maximum observed delay below the budget because the producer slowed whenever rendering slowed. That tests a backpressured source rather than the reported continuous-stream workload.
**Dropping, sampling, or concatenating raw chunks at the ingestion or logging layer.** Rejected: raw `assistant/chunk` events are replayable session facts; changing them would reduce diagnostic and UI fidelity and mix display-frequency policy into the authoritative data layer.
**One synchronous 100,000-event enqueue.** Rejected: it would block the page in the producer itself and would load `FxInbox` with a huge array drained by `shift()`, confounding renderer cost with fixture queue mechanics.
**Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk.
**A live model or recorded HTTP byte stream.** Rejected for this reproduction: a live stream is nondeterministic, and HTTP-level recording adds fixture cost without improving the target assertion. The in-memory fixture omits HTTP/SSE byte framing but preserves individual asynchronous session events and the production client reduction and React rendering path where the freeze is observed.
**Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation.
**A live model or recorded HTTP byte stream.** Rejected: live models are nondeterministic, and an HTTP/SSE recording would not improve the target assertion. The in-memory fixture preserves individual asynchronous session events, production client reduction, and the React rendering path while controlling the workload and arrival cadence.
## Consequences
Developers now have a keyless, repeatable command that reproduces the long-reasoning freeze with an exact workload and emits machine-readable responsiveness evidence. The default suites remain fast and green, but the stress lane must be invoked explicitly during diagnosis and before accepting a renderer fix. Its timing threshold is a browser responsiveness guard rather than a throughput target; hardware changes can alter total duration, while a multi-second heartbeat or interaction delay remains an unambiguous failure.
The publication rate of streaming `ConversationSnapshot` objects is bounded by the browser's paint rate, so React handles at most one accumulated partial containing all received text per frame; structural events can still publish sooner. Ingestion, ordering, logging, string concatenation, and accumulator updates still run for every raw chunk, so this decision reduces snapshot rebuilding and React work without pretending to solve raw-stream parsing cost.
Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions.
The browser stress lane continues to provide a responsiveness signal from the real assembled application and an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast.
@@ -1,4 +1,4 @@
# Agent Note: 需显式启用的推理(reasoning)分片浏览器压力测试车道
# Agent Note: 推理分片的逐帧累计发布与浏览器压力验证
Status: implemented
@@ -6,28 +6,38 @@ Status: implemented
## 问题
长推理流引发的浏览器卡死,需要贯穿 fixture(测试前置数据)异步流、客户端会话归并、React 协调过程和实时 Think 行的完整场景才能显现。小型单元测试可以证明事件语义,却无法暴露渲染器饥饿问题;如果把一个包含 100,000 个分片的场景放入必需的 [Web 浏览器测试车道](2026-07-24-web-gui-browser-e2e-lane.md),则每个 PRPull Request)都会增加一项缓慢且有意保持失败状态的复现场景。如果生产方按 `requestAnimationFrame` 的节奏发出事件,渲染器还会对生产方施加隐式背压:页面一旦停滞,生产也随之停滞,从而掩盖触发该回归的实际条件,即网络数据仍会持续到达
长推理流会连续产生大量 `assistant/chunk`。这些原始事件必须逐个完成排序、日志记录和 `PartialAccumulator` 折叠,以保持重放保真度和最终内容完整;但 React 只需要看到当前累计结果,不需要观察同一浏览器帧内的每个中间态
异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。
## 决策
`pnpm run test:web:stress` 是浏览器性能复现场景的显式入口。其专用 `vitest.web-stress.config.ts` 只纳入 `apps/web/stress-tests/**/*.stress.ts`;默认单元测试与 Web 测试配置不会收集该后缀。该命令会先执行构建,因为 Chromium 消费编译生成的客户端产物;压力测试文件会启动宿主脚手架,因此由 `tsconfig.host.json` 纳管
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start``text-delta``reasoning-delta``tool-call-delta``block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage``finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类
推理场景选择确定性的 `?fixture` 会话,并让一个需显式启用的 fixture 计时钩子精确发出 100,000 个相互独立的 `reasoning-delta` 事件。生产方设定的外部到达节奏为每 16 毫秒发出 128 个事件,并在主线程停顿后补发停顿期间本应发出的事件。这样便能近似模拟独立于渲染器而持续到达的字节流,同时无需同步预装载一个巨大的 fixture 队列。一枚结尾标记证明这些事件经过会话归并,并抵达实时 Think 行
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial,但发布的定稿内容和原始事件序列保持完整
浏览器端启动一个间隔 50 毫秒的心跳,并在启动该流之前调度一个 DOM 事件。最终报告包含已发出的分片数、最大心跳延迟、已调度交互的延迟及心跳样本。两项延迟指标的预算均为 250 毫秒。在回归仍然存在期间,此显式启用命令会打印测量值并以非零状态退出;它由此成为渲染器修复的验收检查,同时不会把已知性能故障设为默认 CI 门禁。`DSH_WEB_STRESS_HEADFUL=1 pnpm run test:web:stress` 会在可见浏览器中展示同一场景
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth``clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`
默认 fixture 单元测试套件使用三个分片和假定时器来演练该计时钩子。这个小型契约固定输入校验、间隔节奏、并发拒绝、精确事件数及结尾标记交付,而不会把包含 100,000 个分片的工作负载带入 `pnpm test``pnpm run test:gui``pnpm run test:web`
`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行;50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟,250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景;Vite Web 壳层和 tsdown 客户端插件 bundle 始终生成 sourcemap,模块宿主在构建树中每个动态 `client.js` 旁托管 `client.js.map`,因此浏览器可把采样映射回 TS/TSX 源码。该压力车道是手动性能诊断与修复验收证据,不是默认 CI 门禁,也不替代确定性的调度单元测试
聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture 的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
## 曾考虑的替代方案
**必需的 Web 浏览器场景。** 不予采纳:该复现场景目前耗时数十秒,且预期无法满足响应性预算;因此,在生产修复交付之前把它设为必需项会阻塞无关工作
**在 React 内对快照使用 transition、deferred value 或组件节流。** 不予采纳:会话源仍会逐分片通知 `useSyncExternalStore`React render 在组件决定延后展示之前已经发生,且多个消费同一快照的组件需要重复实现策略。Think 摘要的视觉跟尾节流位于快照发布之后,只减少同步布局频率,不承担数据发布策略
**按动画帧控制节奏** 测量后不予采纳:生产节奏一旦与绘制绑定,生产方就会在渲染变慢时同步减速,使观测到的最大延迟保持在预算以内。这测试的是受背压约束的数据源,而不是问题报告中的连续流工作负载
**在接收或日志层丢弃、抽样或拼接原始分片** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层
**同步入队 100,000 个事件。** 不予采纳:它会让生产方自身阻塞页面,并让 `FxInbox` 装入一个通过 `shift()` 排空的巨大数组,从而把渲染器成本与 fixture 队列机制混为一谈
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使一个微任务调度近似退化为一次分片一次通知
**真实模型或录制的 HTTP 字节流。** 本复现场景不予采纳:实时数据流不具确定性,而 HTTP 层录制会增加 fixture 成本,却无法改进目标断言。内存 fixture 省略 HTTP/SSEServer-Sent Events)字节分帧,但保留逐个异步会话事件、生产客户端的会话归并过程,以及观察到卡死的 React 渲染路径
**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿
**真实模型或录制的 HTTP 字节流。** 不予采纳:实时模型不具确定性,HTTP/SSEServer-Sent Events)录制也不会改进目标断言。内存 fixture 保留逐个异步会话事件、生产客户端归并和 React 渲染路径,同时控制工作负载与到达节奏。
## 后果
开发者现在拥有一条无密钥且可重复执行的命令,它以精确工作负载复现长推理卡死,并输出机器可读的响应性证据。默认测试套件仍然快速且保持通过,但在诊断期间以及接受渲染器修复之前,都必须显式运行该压力测试车道。其计时阈值是浏览器响应性防线,而非吞吐量目标;硬件差异可能改变总时长,但长达数秒的心跳或交互延迟仍明确表示失败
流式 `ConversationSnapshot` 的发布频率受浏览器绘制频率约束,React 每帧至多处理一个包含全部已接收文本的累计 partial;结构事件仍可更快发布。接收、排序、日志记录、字符串拼接和累积器更新仍按原始分片执行,因此该决策降低的是快照重建与 React 工作,不把原始流解析成本伪装成已解决
折叠 Think 摘要的横向布局读写最多每三帧执行一次,并直接追上该时刻的最新位置;React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。
浏览器压力车道继续提供真实组装应用上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。
+1 -1
View File
@@ -51,7 +51,7 @@ Non-negotiables across the layers:
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (plugin packages)
@@ -8,7 +8,7 @@ import type {
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -431,11 +431,3 @@ export class SessionHistorySource implements SessionHistoryFace {
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
@@ -1,5 +1,6 @@
// Notifier: subscription + microtask-batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
// Notifier: subscription + batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
// N markFrameDirty calls collapse into one animation-frame flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
@@ -9,12 +10,13 @@
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled = false
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
private scheduleGeneration = 0
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
@@ -35,19 +37,16 @@ export class Notifier {
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
})
if (this.scheduled === 'microtask') return
this.schedule('microtask')
}
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
markFrameDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled !== 'none') return
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
}
/**
@@ -57,11 +56,8 @@ export class Notifier {
notifyNow(): void {
this.dirty = true
this.notifyPending = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.notifyPending = false
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
this.invalidateSchedule()
this.flush()
}
/**
@@ -73,4 +69,35 @@ export class Notifier {
this.dirty = false
this.rebuild()
}
private schedule(kind: 'microtask' | 'frame'): void {
const generation = ++this.scheduleGeneration
this.scheduled = kind
const publish = () => {
if (generation !== this.scheduleGeneration) return
this.scheduled = 'none'
this.flush()
}
if (kind === 'frame') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
private invalidateSchedule(): void {
this.scheduleGeneration++
this.scheduled = 'none'
}
private flush(): void {
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
}
}
@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
* @param type - Stream chunk discriminant.
* @returns Whether publishing the accumulated partial can change the visible snapshot.
*/
export function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
@@ -687,6 +687,10 @@ export class Session implements SessionFace {
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
return
}
this.notifier.markDirty()
}
+58 -3
View File
@@ -1,13 +1,17 @@
/**
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
* laziness, synchronous notifyNow, and unsubscribe.
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
* no-listener laziness, synchronous notifyNow, and unsubscribe.
*/
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
@@ -60,6 +64,57 @@ describe('Notifier', () => {
expect(rebuilds).toBe(1)
})
it('collapses frame-dirty changes into one cumulative frame publication', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markFrameDirty()
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(order).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(order).toEqual(['rebuild', 'notify'])
})
it('lets a structural microtask publication supersede a pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markDirty()
await microtask()
expect(notifications).toBe(1)
frames.shift()!(0)
expect(notifications).toBe(1)
})
it('falls back to microtask batching when animation frames are unavailable', async () => {
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(notifications).toBe(0)
await microtask()
expect(notifications).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)
+39 -1
View File
@@ -6,7 +6,7 @@
* enough.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
}
@@ -163,6 +167,40 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const { session } = await opened()
const published: Array<string | null> = []
session.subscribe(() => {
const block = session.getSnapshot().partial?.blocks[0]
published.push(block?.kind === 'text' ? block.text : null)
})
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.chunkStart(6, 1))
feed(ev.chunkText(7, 1, '累'))
feed(ev.chunkText(8, 1, '计'))
expect(published).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(published).toEqual(['累计'])
feed(ev.chunkText(9, 1, '完成'))
feed(ev.assistant(10, 1, '累计完成'))
await Promise.resolve()
expect(published).toEqual(['累计', null])
frames.shift()!(0)
expect(published).toEqual(['累计', null])
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -20,7 +20,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -33,6 +33,7 @@ import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
import css from './ToolRow.module.css'
export interface ToolRowProps {
@@ -176,13 +177,17 @@ export function ToolRow({
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
const followSummaryEnd = isThink && state === 'running' && !open
useLayoutEffect(() => {
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
const summaryElement = summaryRef.current
if (summaryElement === null) return
summaryElement.scrollLeft = followSummaryEnd
? summaryElement.scrollWidth - summaryElement.clientWidth
: 0
}, [followSummaryEnd, summaryText])
})
useEffect(() => {
if (!isThink) return
scheduleSummaryScroll()
}, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -0,0 +1,42 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
* Repeated calls retain the latest callback, and unmount cancels pending work.
* @param update - DOM alignment to run after the throttle interval.
* @param intervalFrames - Frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
update: () => void,
intervalFrames = DEFAULT_INTERVAL_FRAMES,
): () => void {
const updateRef = useRef(update)
updateRef.current = update
const pendingFrameRef = useRef<number | null>(null)
useLayoutEffect(() => () => {
if (pendingFrameRef.current === null) return
cancelAnimationFrame(pendingFrameRef.current)
pendingFrameRef.current = null
}, [])
return useCallback(() => {
if (pendingFrameRef.current !== null) return
let remainingFrames = intervalFrames
const advance = (): void => {
remainingFrames -= 1
if (remainingFrames > 0) {
pendingFrameRef.current = requestAnimationFrame(advance)
return
}
pendingFrameRef.current = null
updateRef.current()
}
pendingFrameRef.current = requestAnimationFrame(advance)
}, [intervalFrames])
}
@@ -1,8 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -12,6 +11,36 @@ import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
@@ -341,6 +370,10 @@ describe('ThinkRow', () => {
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
@@ -351,6 +384,7 @@ describe('ThinkRow', () => {
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)