Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/design/web-shell-stream-render-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Web Shell streaming render performance

## Problem

Thinking and assistant deltas currently wake the transcript on every animation
frame. Each accepted snapshot runs transcript projection and downstream list
work, while the growing Markdown document is parsed again at every streaming
flush. Although `ChatEditor` is memoized, this main-thread work still competes
with editor input and becomes increasingly expensive as the active response
grows.

## Evidence

The transcript projector is linear, but browser profiling with 50,000 retained
messages attributes only 2.5% of sampled time to projection. The dominant
104 ms long task spends 52.1 ms in `applyTurnCollapse`; repeated full-history
derivation in `MessageList` also includes final-answer collection, agent
grouping, pinning, and display-index generation.

After the tail-only path, two CPU samples reduced `applyTurnCollapse` from
467.8 ms total self time to 26.7–51.5 ms, final-answer collection from 247.2 ms
to 11.4–26.9 ms, grouping from 54 ms to 2.4–7.6 ms, and display-index
generation from 67.5 ms to 3.7–12 ms. The mock SSE disconnected after replay
in that rerun, so these samples establish hotspot reduction but are not used as
end-to-end completion or long-task acceptance evidence.

Markdown has the opposite shape: every streamed append changes the complete
source string and reparses the complete growing document. Throttling bounds how
often that happens but not the cost of each parse.

## Design

1. Batch provider transcript events into a 16 ms macrotask window, with
synchronous flushes before control and terminal events and when the stream
ends. Downstream, coalesce transcript notifications and admit at most one
snapshot every 50 ms.
2. Defer transcript snapshots with session and block-index identities. Urgent
editor work can commit against the previous snapshot, while session switches
and same-session store resets immediately reject stale deferred blocks.
3. Preserve normalized tool-content references with a `WeakMap`, allowing the
existing row comparator's JSON cache to avoid reserializing unchanged
historical tool output.
4. Keep the thinking elapsed timer alive across streamed content appends.
5. Keep live Markdown for short responses so closed charts and ordinary
formatting retain their existing behavior. Once a streaming document
exceeds a fixed parse budget, render its throttled source as escaped plain
text with preserved whitespace. When streaming ends, render the complete
Markdown once. This bounds repeated parsing while only delaying formatting
for responses large enough to cause the observed problem.
6. Preserve projected history object identity when every prior transcript block
is unchanged and only the final ordinary streaming text block grows. Reuse
completed-history `MessageList` derivations under the same narrow condition,
replacing only the rendered tail row. Any earlier block change, terminal
transition, tool/background update, usage change, translation change, or
view-option change takes the existing full calculation path.

## Non-goals

- No general incremental transcript projector. Projection is not the measured
bottleneck, and the narrow tail path avoids new invalidation machinery.
- No incremental Markdown AST or Web Worker. Plain streaming text removes the
repeated parse with less code and no cross-thread serialization.
- No changes to daemon event ordering, transcript persistence, or public block
shapes.

## Verification

- Unit tests cover notification coalescing, the 50 ms window, cancellation,
session switching, stable projection identity, streamed-tail rendering and
invalidation, stable tool normalization, timer reuse, and the
streaming-text-to-settled-Markdown transition.
- `npm run test:e2e:perf --workspace=@qwen-code/web-shell` deterministically
replays 5,000 historical turns, streams 400 Markdown-heavy chunks while
typing, verifies the final output and composer contents, and records input
latency and browser long-task metrics in the Playwright report.
3 changes: 2 additions & 1 deletion packages/sdk-typescript/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ const rootDir = join(__dirname, '..');
// Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and
// the reference-only replay placeholder.
// Bumped from 196KB to 197KB for the workspace session live-state daemon
// surface (catalog version + live snapshot accessors).
// surface (catalog version + live snapshot accessors) and immutable,
// identity-stable transcript block indexes used by browser renderers.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
Expand Down
58 changes: 34 additions & 24 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,17 @@ export function appendLocalUserTranscriptMessage(
return trimTranscriptState(next);
}

// Freeze retained blocks at the dispatch boundary to catch consumers that
// mutate a COW-shared blocks array in place (see reduceDaemonTranscriptEvents).
// This is a dev/CI safety net; in production it is pure O(blocks) overhead on
// every dispatch and the reducer's own mutation discipline (takeBlocksOwnership)
// does not depend on it, so skip it there. App bundlers statically replace
// `process.env.NODE_ENV`, folding the check to `false`. The `typeof process`
// guard keeps an unbundled browser consumer from throwing a ReferenceError —
// Freeze retained COW collections at the dispatch boundary to catch consumers
// that mutate a shared snapshot (see reduceDaemonTranscriptEvents). This is a
// dev/CI safety net; the reducer's own ownership discipline does not depend on
// it, so skip the O(blocks) freeze in production. App bundlers statically
// replace `process.env.NODE_ENV`, folding the check to `false`. The `typeof
// process` guard keeps an unbundled browser consumer from throwing a
// ReferenceError —
// this module sits on the browser-hostile `daemon/ui` surface and Vite lib
// builds preserve `process.env.NODE_ENV` in their output — matching the
// existing SDK idiom (see ProcessTransport, cliPath).
const FREEZE_TRANSCRIPT_BLOCKS =
const FREEZE_TRANSCRIPT_COLLECTIONS =
typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';

export function reduceDaemonTranscriptEvents(
Expand All @@ -151,17 +151,12 @@ export function reduceDaemonTranscriptEvents(
const next = cloneTranscriptState(state, opts);
for (const event of events) applyDaemonTranscriptEvent(next, event);
const result = trimTranscriptState(next);
// With lazy COW, `state.blocks` is shared across
// sidechannel-only snapshots. A misbehaving consumer doing
// `(state.blocks as DaemonTranscriptBlock[]).sort()` would corrupt
// EVERY snapshot that shares the reference (previously only the
// current one). Freeze the array at the dispatch boundary so external
// in-place mutation throws in strict mode instead of silently
// poisoning future snapshots. Internal reducer mutation goes through
// `takeBlocksOwnership` which copies BEFORE mutating, so the frozen
// shared reference is never touched in-place by the next dispatch.
if (FREEZE_TRANSCRIPT_BLOCKS) {
// With lazy COW, blocks and their index can be shared across snapshots.
// Freeze both at the dispatch boundary so external in-place mutation throws
// in strict mode instead of poisoning every snapshot sharing the reference.
if (FREEZE_TRANSCRIPT_COLLECTIONS) {
Object.freeze(result.blocks);
Object.freeze(result.blockIndexById);
}
return result;
}
Expand All @@ -173,6 +168,7 @@ export function finalizeOfflineDaemonTranscriptState(
finishAssistant(next);
next.activeUserBlockId = undefined;
Object.freeze(next.blocks);
Object.freeze(next.blockIndexById);
return next;
}

Expand Down Expand Up @@ -1002,6 +998,8 @@ function discardToolBlock(
takeBlocksOwnership(state);
state.blocks = state.blocks.filter((block) => block.id !== blockId);
state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks);
ownedBlocks.set(state, state.blocks);
ownedBlockIndexes.set(state, state.blockIndexById);
delete state.toolBlockByCallId[toolCallId];
delete state.toolProgress[toolCallId];
if (state.currentToolCallId === toolCallId) {
Expand Down Expand Up @@ -1443,10 +1441,10 @@ function trimTranscriptState(
const keptIds = new Set(blocks.map((block) => block.id));
state.blocks = blocks;
state.blockIndexById = rebuildDaemonTranscriptBlockIndex(blocks);
// Trim replaces both arrays with fresh objects; register that this
// state now owns its blocks so future appends in the same dispatch
// don't double-copy.
// Trim replaces both collections with fresh objects; register ownership so
// future appends in the same dispatch don't copy them again.
ownedBlocks.set(state, state.blocks);
ownedBlockIndexes.set(state, state.blockIndexById);
for (const [toolCallId, blockId] of Object.entries(state.toolBlockByCallId)) {
if (!keptIds.has(blockId)) {
state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID;
Expand Down Expand Up @@ -1520,7 +1518,7 @@ function shouldRecreateTrimmedToolBlock(
}

/**
* Lazy copy-on-write for `state.blocks` / `state.blockIndexById`.
* Lazy copy-on-write for `state.blocks`.
*
* `cloneTranscriptState` shares the parent's `blocks` reference (not
* eager-copies) so non-block-mutating events keep the same array
Expand All @@ -1538,14 +1536,23 @@ const ownedBlocks = new WeakMap<
DaemonTranscriptState,
readonly DaemonTranscriptBlock[]
>();
const ownedBlockIndexes = new WeakMap<
DaemonTranscriptState,
Readonly<Record<string, number>>
>();

function takeBlocksOwnership(state: DaemonTranscriptState): void {
if (ownedBlocks.get(state) === state.blocks) return;
state.blocks = [...state.blocks];
state.blockIndexById = createIndex(state.blockIndexById);
ownedBlocks.set(state, state.blocks);
}

function takeBlockIndexOwnership(state: DaemonTranscriptState): void {
if (ownedBlockIndexes.get(state) === state.blockIndexById) return;
state.blockIndexById = createIndex(state.blockIndexById);
ownedBlockIndexes.set(state, state.blockIndexById);
}

// Applies a daemon rewind event to this in-memory transcript only. The target
// user turn and everything after it are removed so the rendered session view
// matches the already-rewound backend state.
Expand Down Expand Up @@ -1579,6 +1586,7 @@ function truncateTranscriptBeforeBlock(
state.blocks = state.blocks.slice(0, blockIndex);
ownedBlocks.set(state, state.blocks);
rebuildTranscriptIndexes(state);
ownedBlockIndexes.set(state, state.blockIndexById);
}

function rebuildTranscriptIndexes(state: DaemonTranscriptState): void {
Expand Down Expand Up @@ -1617,7 +1625,9 @@ function appendBlock(
block: DaemonTranscriptBlock,
): void {
takeBlocksOwnership(state);
state.blockIndexById[block.id] = state.blocks.length;
takeBlockIndexOwnership(state);
(state.blockIndexById as Record<string, number>)[block.id] =
state.blocks.length;
(state.blocks as DaemonTranscriptBlock[]).push(block);
}

Expand Down
7 changes: 4 additions & 3 deletions packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1038,16 +1038,17 @@ export interface DaemonTranscriptState
// lazy COW). Match the runtime contract at the type level so
// consumers get a compile-time error for `state.blocks.sort()` /
// `.push()` instead of a runtime `TypeError`. Internal reducer
// mutation goes through `takeBlocksOwnership` which casts away
// readonly after copying — the only place that's allowed.
// mutation goes through the ownership helpers which cast away readonly after
// copying — the only place that's allowed. The block index follows the same
// COW contract.
blocks: readonly DaemonTranscriptBlock[];
lastEventId?: number;
activeUserBlockId?: string;
activeAssistantBlockId?: string;
activeThoughtBlockId?: string;
activeAssistantBlockByParent: Record<string, string>;
activeThoughtBlockByParent: Record<string, string>;
blockIndexById: Record<string, number>;
blockIndexById: Readonly<Record<string, number>>;
toolBlockByCallId: Record<string, string>;
trimmedToolNotificationByCallId: Record<string, true>;
permissionBlockByRequestId: Record<string, string>;
Expand Down
37 changes: 37 additions & 0 deletions packages/sdk-typescript/test/unit/daemonUi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5808,6 +5808,43 @@ describe('transcriptBlockToTerminalText (wenshao review — coverage)', () => {
});

describe('daemon UI WeakMap memo hits (wenshao glm-5.1 review)', () => {
it('shares the block index for text updates and copies it for appends', () => {
let state = createDaemonTranscriptState({ now: 1 });
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: 'first' } as never],
{ now: 2 },
);
const firstState = state;

state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: ' second' } as never],
{ now: 3 },
);

expect(state.blocks).not.toBe(firstState.blocks);
expect(state.blockIndexById).toBe(firstState.blockIndexById);
expect(Object.isFrozen(state.blockIndexById)).toBe(true);
expect(
() =>
((state.blockIndexById as Record<string, number>)['assistant-1'] = 99),
).toThrow(TypeError);
expect(state.blocks[0]).toMatchObject({ text: 'first second' });
expect(firstState.blocks[0]).toMatchObject({ text: 'first' });

const updatedState = state;
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'status', text: 'done' } as never],
{ now: 4 },
);

expect(state.blockIndexById).not.toBe(updatedState.blockIndexById);
expect(updatedState.blockIndexById).not.toHaveProperty('status-2');
expect(state.blockIndexById).toHaveProperty('status-2', 1);
});

// wenshao 5-23 13:03: lazy COW means non-block-mutating dispatches
// preserve `state.blocks` reference, so the WeakMap caches actually hit
// across renders. Verify by checking reference identity.
Expand Down
2 changes: 1 addition & 1 deletion packages/web-shell/client/adapters/messageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface DaemonMessageToolCall {
status: DaemonMessageToolCallStatus;
parentToolCallId?: string;
title?: string;
content?: DaemonMessageToolCallContent[];
content?: readonly DaemonMessageToolCallContent[];
rawOutput?: unknown;
locations?: DaemonMessageToolCallLocation[];
kind?: DaemonMessageToolKind;
Expand Down
38 changes: 38 additions & 0 deletions packages/web-shell/client/adapters/transcriptToMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,44 @@ describe('transcriptBlocksToDaemonMessages', () => {
});
});

it('normalizes an unchanged tool block content to a stable reference', () => {
const block = toolBlock('t1', 'call-1', 'running', 0, {
content: [{ type: 'content', content: { type: 'text', text: 'body' } }],
});
const first = transcriptBlocksToDaemonMessages([block]);
const second = transcriptBlocksToDaemonMessages([block]);

const firstContent = (first[0] as { tools: { content: unknown }[] })
.tools[0].content;
const secondContent = (second[0] as { tools: { content: unknown }[] })
.tools[0].content;
// The normalizer caches by the original block reference, so a block that
// did not change yields the same content array across frames,
// allowing MessageItem's JSON cache to avoid re-serializing the output.
expect(secondContent).toBe(firstContent);
expect(Object.isFrozen(firstContent)).toBe(true);
});

it('renormalizes content when a caller replaces the tool block', () => {
const block = toolBlock('t1', 'call-1', 'running', 0, {
content: [{ type: 'content', content: { type: 'text', text: 'before' } }],
});
const first = transcriptBlocksToDaemonMessages([block]);
const content = block.content as Array<{
type: 'content';
content: { type: 'text'; text: string };
}>;
content[0].content.text = 'after';
const second = transcriptBlocksToDaemonMessages([{ ...block }]);

expect(
(first[0] as { tools: { content: unknown }[] }).tools[0].content,
).toMatchObject([{ content: { text: 'before' } }]);
expect(
(second[0] as { tools: { content: unknown }[] }).tools[0].content,
).toMatchObject([{ content: { text: 'after' } }]);
});

it('preserves user file attachment metadata', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('user-1', 'user', 'check this', 1, false, {
Expand Down
23 changes: 19 additions & 4 deletions packages/web-shell/client/adapters/transcriptToMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,7 +1107,7 @@ function daemonToolBlockToToolCall(
): DaemonMessageToolCall {
const rawOutput = getToolRawOutput(block);
const isBackgroundAgent = isBackgroundAgentBlock(block, rawOutput);
const content = normalizeToolContent(block.content);
const content = normalizeToolContent(block);
const statusMap: Record<string, DaemonMessageToolCallStatus> = {
running: 'in_progress',
pending: 'pending',
Expand Down Expand Up @@ -1278,11 +1278,23 @@ function getToolRawOutput(block: DaemonToolTranscriptBlock): unknown {
};
}

// The transcript store uses copy-on-write: an unchanged tool keeps its block
// identity across frames. Keying by the block, rather than its content array,
// also handles callers that replace a block while reusing its content array.
const normalizedToolContentCache = new WeakMap<
DaemonToolTranscriptBlock,
readonly DaemonMessageToolCallContent[]
>();

function normalizeToolContent(
value: unknown,
): DaemonMessageToolCallContent[] | undefined {
block: DaemonToolTranscriptBlock,
): readonly DaemonMessageToolCallContent[] | undefined {
const value = block.content;
if (!Array.isArray(value)) return undefined;

const cached = normalizedToolContentCache.get(block);
if (cached !== undefined) return cached;

const content = value.flatMap((entry): DaemonMessageToolCallContent[] => {
const item = getRecord(entry);
if (!item) return [];
Expand Down Expand Up @@ -1328,7 +1340,10 @@ function normalizeToolContent(
return [];
});

return content.length > 0 ? content : undefined;
if (content.length === 0) return undefined;
const frozen = Object.freeze(content);
normalizedToolContentCache.set(block, frozen);
return frozen;
}

function isAskUserQuestionBlock(block: DaemonToolTranscriptBlock): boolean {
Expand Down
Loading
Loading