perf(web-shell): optimize streaming transcript rendering (#9672)

* perf(web-shell): optimize streaming transcript rendering

* test(web-shell): pin streaming fast paths

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-08-22 06:12:54 +00:00
1 parent 4188486419
commit 6bbb273a86
27 files changed
+1896 -278

No files matched your search

@@ -0,0 +1,64 @@
# Web Shell thinking stream performance
## Problem
Streaming thought deltas update the collapsed "Thinking" row without showing
the thought body, but each visible transcript tick still projects the complete
transcript. MessageList also limits its existing tail-only cache to assistant
messages, so thinking streams repeat compact-message merging and display-item
derivation. Both costs grow with retained history and compete with the
paint-bound thinking shimmer on the browser main thread.
## Design
First, extend MessageList's committed tail cache to thinking messages. In
non-compact mode the new thinking message replaces the previous tail directly.
In compact mode the streaming thought is represented by the final synthetic
tool summary, so update only that summary and its final thought while preserving
all earlier message, tool, and display-item identities. Any dependency or
structural change uses the complete derivation path.
Second, expose an optional transcript block change summary from the SDK store.
The summary identifies its source store and advances a barrier for every change
except a validated append to the active top-level assistant or thought block.
Web Shell carries the summary with the throttled block snapshot. Equal barriers
from the same source prove that skipped revisions were pure tail appends, so the
message hook can append only the new text to its committed tail message instead
of invoking the complete projector.
Reconciliation-derived keys and resolved background-agent history use the same
barrier, connection session, and resolution snapshot identities. They are
reused only for a proven tail append; tool, permission, notification, history,
reset, session, metadata, and terminal changes rebuild through the existing
paths.
Third, top-level assistant and thought deltas share reducer side indexes that
they cannot mutate, including historical tool, permission, parent, and progress
indexes. The normal cloning path remains in place for nested deltas, mixed
batches, and any update that can cross the effective transcript block limit.
Finally, a virtualized MessageList drives bottom-follow and overflow reporting
from its measured total height and item count instead of message identity.
Content-only updates with unchanged geometry therefore perform no scroll
layout reads or writes. Non-virtual transcripts keep the existing per-message
follow behavior because their row height is not tracked by the virtualizer.
## Compatibility
The store method is optional. Older store implementations retain the existing
reference-scan fallback. There are no daemon protocol, persistence, route, or
animation changes.
## Verification
- Compare incremental compact and non-compact thinking results with complete
derivation.
- Prove pure assistant and thought appends preserve the store barrier while
mixed, structural, terminal, reset, and bounded-text changes advance it.
- Prove pure tail ticks skip the complete projector and reconciliation scans.
- Prove top-level text deltas reuse populated side indexes without sharing
across nested or transcript-trimming paths.
- Prove content-only virtual transcript updates perform no scroll geometry
reads or writes while the streamed tail still updates.
- Extend the deterministic browser performance scenario to stream thought
events and record animation-frame gaps.
@@ -173,9 +173,9 @@ describe('slash completion during commandContext churn (#9494)', () => {
});
it('argument completion still receives the latest commandContext', async () => {
const completionSpy = vi.fn<
(context: CommandContext, argString: string) => Promise<string[]>
>().mockResolvedValue(['arg-a', 'arg-b']);
const completionSpy = vi
.fn<(context: CommandContext, argString: string) => Promise<string[]>>()
.mockResolvedValue(['arg-a', 'arg-b']);
const markerOf = (callIndex: number): string =>
(
completionSpy.mock.calls[callIndex]![0] as unknown as {
+3 -1
View File
@@ -113,7 +113,9 @@ const rootDir = join(__dirname, '..');
// its own delta in isolation, and the combined feature sets land here. The
// attachment read/remove + binary hydration feature that separately bumped
// main to 199KB merges within this headroom, so no further bump is needed.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 206 * 1024;
// Bumped from 206KB to 208KB for transcript block change summaries used to
// avoid complete Web Shell projection on every streamed text update.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 208 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't
@@ -155,6 +155,7 @@ export type {
DaemonToolPreview,
DaemonToolTranscriptBlock,
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptBlockKind,
DaemonTranscriptQuestion,
DaemonTranscriptQuestionOption,
@@ -84,6 +84,7 @@ export type {
DaemonToolPreview,
DaemonToolTranscriptBlock,
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptBlockKind,
DaemonTranscriptQuestion,
DaemonTranscriptQuestionOption,
+134 -2
View File
@@ -5,9 +5,11 @@
*/
import type {
DaemonTextDeltaMeta,
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptReducerOptions,
DaemonTranscriptState,
DaemonTextDeltaMeta,
DaemonTranscriptStore,
DaemonUiEvent,
} from './types.js';
@@ -30,6 +32,12 @@ export function createDaemonTranscriptStore(
? { onTruncation }
: {};
let state = createState(stateSeed);
const blockChangeSource = {};
let blockChangeSummary: DaemonTranscriptBlockChangeSummary = {
source: blockChangeSource,
revision: 0,
tailAppendBarrierRevision: 0,
};
const listeners = new Set<() => void>();
let notifyScheduled = false;
@@ -55,6 +63,9 @@ export function createDaemonTranscriptStore(
getSnapshot() {
return state;
},
getBlockChangeSummary() {
return blockChangeSummary;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
@@ -64,7 +75,14 @@ export function createDaemonTranscriptStore(
dispatch(event: DaemonUiEvent | DaemonUiEvent[]) {
const events = Array.isArray(event) ? event : [event];
if (events.length === 0) return;
state = reduceDaemonTranscriptEvents(state, events, reducerOptions);
const previous = state;
state = reduceDaemonTranscriptEvents(previous, events, reducerOptions);
blockChangeSummary = nextBlockChangeSummary(
blockChangeSummary,
previous,
state,
events,
);
scheduleNotify();
},
appendLocalUserMessage(
@@ -85,6 +103,7 @@ export function createDaemonTranscriptStore(
files,
...reducerOptions,
});
blockChangeSummary = invalidateTailAppend(blockChangeSummary);
scheduleNotify();
},
reset(nextSeed: Partial<DaemonTranscriptState> = {}) {
@@ -95,6 +114,7 @@ export function createDaemonTranscriptStore(
nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks,
...nextSeed,
});
blockChangeSummary = invalidateTailAppend(blockChangeSummary);
scheduleNotify();
},
// wenshao R4-R6 (qwen3.7-max): explicit recovery from the
@@ -135,6 +155,118 @@ export function createDaemonTranscriptStore(
};
}
function nextBlockChangeSummary(
current: DaemonTranscriptBlockChangeSummary,
previous: DaemonTranscriptState,
next: DaemonTranscriptState,
events: readonly DaemonUiEvent[],
): DaemonTranscriptBlockChangeSummary {
if (previous.blocks === next.blocks) return current;
const revision = current.revision + 1;
const tailBlockId = streamingTailAppendBlockId(previous, next, events);
return tailBlockId
? {
source: current.source,
revision,
tailAppendBarrierRevision: current.tailAppendBarrierRevision,
tailBlockId,
}
: {
source: current.source,
revision,
tailAppendBarrierRevision: revision,
};
}
function invalidateTailAppend(
current: DaemonTranscriptBlockChangeSummary,
): DaemonTranscriptBlockChangeSummary {
const revision = current.revision + 1;
return {
source: current.source,
revision,
tailAppendBarrierRevision: revision,
};
}
function streamingTailAppendBlockId(
previous: DaemonTranscriptState,
next: DaemonTranscriptState,
events: readonly DaemonUiEvent[],
): string | undefined {
const first = events[0];
if (
!first ||
(first.type !== 'assistant.text.delta' &&
first.type !== 'thought.text.delta') ||
first.parentToolCallId !== undefined ||
events.some(
(event) =>
event.type !== first.type ||
('parentToolCallId' in event && event.parentToolCallId !== undefined),
) ||
previous.blocks.length === 0 ||
previous.blocks.length !== next.blocks.length ||
previous.blockIndexById !== next.blockIndexById
) {
return undefined;
}
const blockId =
first.type === 'assistant.text.delta'
? previous.activeAssistantBlockId
: previous.activeThoughtBlockId;
const nextBlockId =
first.type === 'assistant.text.delta'
? next.activeAssistantBlockId
: next.activeThoughtBlockId;
const before = previous.blocks[previous.blocks.length - 1];
const after = next.blocks[next.blocks.length - 1];
const appendedTextLength = events.reduce(
(length, event) =>
length +
(event.type === 'assistant.text.delta' ||
event.type === 'thought.text.delta'
? event.text.length
: 0),
0,
);
if (
!blockId ||
nextBlockId !== blockId ||
before?.id !== blockId ||
after?.id !== blockId ||
before.kind !== after.kind ||
!isTextBlock(before) ||
!isTextBlock(after) ||
after.streaming !== true ||
before.parentToolCallId !== after.parentToolCallId ||
before.meta !== after.meta ||
before.usage !== after.usage ||
before.branchRecordId !== after.branchRecordId ||
before.clientReceivedAt !== after.clientReceivedAt ||
before.promptId !== after.promptId ||
before.sourceRecordIds !== after.sourceRecordIds ||
after.text.length !== before.text.length + appendedTextLength
) {
return undefined;
}
return blockId;
}
function isTextBlock(
block: DaemonTranscriptBlock,
): block is Extract<
DaemonTranscriptBlock,
{ kind: 'assistant' | 'thought' | 'user' }
> {
return (
block.kind === 'assistant' ||
block.kind === 'thought' ||
block.kind === 'user'
);
}
function reportListenerError(error: unknown): void {
const reporter = (
globalThis as typeof globalThis & {
@@ -197,15 +197,33 @@ export function reduceDaemonTranscriptEvents(
opts: DaemonTranscriptReducerOptions = {},
): DaemonTranscriptState {
if (events.length === 0) return state;
const next = cloneTranscriptState(state, opts);
const maxBlocks = opts.maxBlocks ?? state.maxBlocks;
const shareSideIndexes =
state.blocks.length + events.length <= maxBlocks &&
events.every(
(event) =>
(event.type === 'assistant.text.delta' ||
event.type === 'thought.text.delta') &&
event.parentToolCallId === undefined,
);
const next = cloneTranscriptState(state, opts, shareSideIndexes);
for (const event of events) applyDaemonTranscriptEvent(next, event);
const result = trimTranscriptState(next);
// 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.
const result = trimTranscriptState(next, shareSideIndexes);
// With lazy COW, these collections can be shared across snapshots. Freeze
// them 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);
Object.freeze(result.toolBlockByCallId);
Object.freeze(result.activeAssistantBlockByParent);
Object.freeze(result.activeThoughtBlockByParent);
Object.freeze(result.trimmedToolNotificationByCallId);
Object.freeze(result.permissionBlockByRequestId);
for (const progress of Object.values(result.toolProgress)) {
Object.freeze(progress);
}
Object.freeze(result.toolProgress);
}
return result;
}
@@ -1540,6 +1558,7 @@ function createTextBlock(
function cloneTranscriptState(
state: DaemonTranscriptState,
opts: DaemonTranscriptReducerOptions,
shareSideIndexes = false,
): DaemonTranscriptState {
const next: DaemonTranscriptState = {
...state,
@@ -1560,26 +1579,32 @@ function cloneTranscriptState(
// consumers + the WeakMap caches want.
blocks: state.blocks,
blockIndexById: state.blockIndexById,
toolBlockByCallId: createIndex(state.toolBlockByCallId),
activeAssistantBlockByParent: createIndex(
state.activeAssistantBlockByParent,
),
activeThoughtBlockByParent: createIndex(state.activeThoughtBlockByParent),
trimmedToolNotificationByCallId: createIndex(
state.trimmedToolNotificationByCallId,
),
permissionBlockByRequestId: createIndex(state.permissionBlockByRequestId),
// Deep-clone the inner progress records.
// The outer spread alone shares `{ ratio?, step? }` references between
// snapshots — once `tool.progress` event handlers start mutating in
// place, the prior snapshot leaks. Pre-empt that here; cost is bounded
// by `Object.keys(state.toolProgress).length` which is small (only
// in-flight tools).
toolProgress: createIndex(
Object.fromEntries(
Object.entries(state.toolProgress).map(([k, v]) => [k, { ...v }]),
),
),
// Top-level streamed text cannot mutate these side indexes. Sharing them
// avoids work proportional to historical tool count on every delta.
toolBlockByCallId: shareSideIndexes
? state.toolBlockByCallId
: createIndex(state.toolBlockByCallId),
activeAssistantBlockByParent: shareSideIndexes
? state.activeAssistantBlockByParent
: createIndex(state.activeAssistantBlockByParent),
activeThoughtBlockByParent: shareSideIndexes
? state.activeThoughtBlockByParent
: createIndex(state.activeThoughtBlockByParent),
trimmedToolNotificationByCallId: shareSideIndexes
? state.trimmedToolNotificationByCallId
: createIndex(state.trimmedToolNotificationByCallId),
permissionBlockByRequestId: shareSideIndexes
? state.permissionBlockByRequestId
: createIndex(state.permissionBlockByRequestId),
// Other reducer events may mutate the inner progress records, so those
// paths still deep-clone them before applying updates.
toolProgress: shareSideIndexes
? state.toolProgress
: createIndex(
Object.fromEntries(
Object.entries(state.toolProgress).map(([k, v]) => [k, { ...v }]),
),
),
lastResyncRequired:
state.lastResyncRequired !== undefined
? { ...state.lastResyncRequired }
@@ -1612,6 +1637,7 @@ function sharesSourceRecordId(
function trimTranscriptState(
state: DaemonTranscriptState,
sideIndexesShared = false,
): DaemonTranscriptState {
const overByteBudget = state.retainedBytes > state.maxRetainedBytes;
if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state;
@@ -1692,6 +1718,21 @@ function trimTranscriptState(
// future appends in the same dispatch don't copy them again.
ownedBlocks.set(state, state.blocks);
ownedBlockIndexes.set(state, state.blockIndexById);
if (sideIndexesShared) {
state.toolBlockByCallId = createIndex(state.toolBlockByCallId);
state.activeAssistantBlockByParent = createIndex(
state.activeAssistantBlockByParent,
);
state.activeThoughtBlockByParent = createIndex(
state.activeThoughtBlockByParent,
);
state.trimmedToolNotificationByCallId = createIndex(
state.trimmedToolNotificationByCallId,
);
state.permissionBlockByRequestId = createIndex(
state.permissionBlockByRequestId,
);
}
for (const [toolCallId, blockId] of Object.entries(state.toolBlockByCallId)) {
if (!keptIds.has(blockId)) {
state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID;
@@ -1153,6 +1153,21 @@ export interface DaemonTranscriptReducerOptions {
onTruncation?: (detail: DaemonTranscriptTruncationDetail) => void;
}
export interface DaemonTranscriptBlockChangeSummary {
/** Opaque identity of the transcript store that produced this summary. */
source: object;
/** Monotonic version of the store's block projection state. */
revision: number;
/**
* The latest revision that was not a pure append to one streaming tail
* block. For summaries from the same source, equal values therefore prove
* that every intervening revision was such an append.
*/
tailAppendBarrierRevision: number;
/** The streaming tail block changed at this revision, when eligible. */
tailBlockId?: string;
}
export interface DaemonTranscriptTruncationDetail {
kind: 'blocks' | 'text';
blockId?: string;
@@ -1185,6 +1200,7 @@ export interface DaemonTranscriptTruncationDetail {
export interface DaemonTranscriptStore {
getSnapshot(): DaemonTranscriptState;
getBlockChangeSummary?(): DaemonTranscriptBlockChangeSummary;
subscribe(listener: () => void): () => void;
dispatch(event: DaemonUiEvent | DaemonUiEvent[]): void;
appendLocalUserMessage(
@@ -0,0 +1,255 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
createDaemonTranscriptState,
createDaemonTranscriptStore,
reduceDaemonTranscriptEvents,
type DaemonTranscriptStore,
} from '../../src/daemon/index.js';
function requireSummary(store: DaemonTranscriptStore) {
const summary = store.getBlockChangeSummary?.();
if (!summary) throw new Error('block change summary unavailable');
return summary;
}
describe('daemon transcript store block change summary', () => {
it.each(['assistant', 'thought'] as const)(
'keeps the barrier stable across consecutive %s tail appends',
(kind) => {
const store = createDaemonTranscriptStore();
const otherStore = createDaemonTranscriptStore();
const type = `${kind}.text.delta` as const;
store.dispatch({ type, text: 'one' });
const initial = requireSummary(store);
const blockId =
kind === 'assistant'
? store.getSnapshot().activeAssistantBlockId
: store.getSnapshot().activeThoughtBlockId;
expect(initial.source).not.toBe(requireSummary(otherStore).source);
store.dispatch({ type, text: ' two' });
store.dispatch({ type, text: ' three' });
const summary = requireSummary(store);
expect(summary.source).toBe(initial.source);
expect(summary).toEqual({
source: initial.source,
revision: initial.revision + 2,
tailAppendBarrierRevision: initial.tailAppendBarrierRevision,
tailBlockId: blockId,
});
},
);
it('invalidates a tail append after a local user message', () => {
const store = createDaemonTranscriptStore();
store.dispatch({ type: 'assistant.text.delta', text: 'one' });
const before = requireSummary(store);
store.appendLocalUserMessage('next');
const summary = requireSummary(store);
expect(summary.revision).toBe(before.revision + 1);
expect(summary.tailAppendBarrierRevision).toBe(summary.revision);
expect(summary.tailBlockId).toBeUndefined();
});
it('advances the barrier for non-tail mutations and resets', () => {
const store = createDaemonTranscriptStore();
store.dispatch({ type: 'assistant.text.delta', text: 'one' });
store.dispatch({ type: 'assistant.text.delta', text: ' two' });
const tailAppend = requireSummary(store);
store.dispatch({
type: 'tool.update',
toolCallId: 'call-1',
toolName: 'read_file',
status: 'running',
});
const toolUpdate = requireSummary(store);
expect(toolUpdate.revision).toBe(tailAppend.revision + 1);
expect(toolUpdate.tailAppendBarrierRevision).toBe(toolUpdate.revision);
expect(toolUpdate.tailBlockId).toBeUndefined();
store.reset();
const reset = requireSummary(store);
expect(reset.revision).toBe(toolUpdate.revision + 1);
expect(reset.tailAppendBarrierRevision).toBe(reset.revision);
});
it('invalidates a mixed batch instead of hiding a tool update', () => {
const store = createDaemonTranscriptStore();
store.dispatch({ type: 'assistant.text.delta', text: 'one' });
store.dispatch([
{ type: 'assistant.text.delta', text: ' two' },
{
type: 'tool.update',
toolCallId: 'call-1',
toolName: 'read_file',
status: 'running',
},
]);
const summary = requireSummary(store);
expect(summary.tailAppendBarrierRevision).toBe(summary.revision);
expect(summary.tailBlockId).toBeUndefined();
});
it('invalidates when bounded text can no longer append the full delta', () => {
const store = createDaemonTranscriptStore();
store.dispatch({
type: 'assistant.text.delta',
text: 'x'.repeat(100_000),
});
const before = requireSummary(store);
store.dispatch({ type: 'assistant.text.delta', text: 'y' });
const summary = requireSummary(store);
expect(summary.revision).toBe(before.revision + 1);
expect(summary.tailAppendBarrierRevision).toBe(summary.revision);
expect(summary.tailBlockId).toBeUndefined();
});
it('invalidates when a text delta also changes projection metadata', () => {
const store = createDaemonTranscriptStore();
store.dispatch({ type: 'assistant.text.delta', text: 'one' });
const before = requireSummary(store);
store.dispatch({
type: 'assistant.text.delta',
text: ' two',
meta: { source: 'background_notification' },
});
const summary = requireSummary(store);
expect(summary.revision).toBe(before.revision + 1);
expect(summary.tailAppendBarrierRevision).toBe(summary.revision);
expect(summary.tailBlockId).toBeUndefined();
});
it.each(['assistant', 'thought'] as const)(
'reuses unrelated indexes for top-level %s deltas',
(kind) => {
const initial = reduceDaemonTranscriptEvents(
createDaemonTranscriptState(),
[
{
type: 'tool.update',
toolCallId: 'call-1',
toolName: 'read_file',
status: 'completed',
},
{
type: 'thought.text.delta',
text: 'nested',
parentToolCallId: 'parent-1',
},
{ type: `${kind}.text.delta`, text: 'one' },
],
);
const before = {
...initial,
toolProgress: { 'call-1': { ratio: 0.5 } },
};
const progress = before.toolProgress['call-1'];
const sideIndexContents = {
toolBlockByCallId: { ...before.toolBlockByCallId },
activeAssistantBlockByParent: {
...before.activeAssistantBlockByParent,
},
activeThoughtBlockByParent: { ...before.activeThoughtBlockByParent },
trimmedToolNotificationByCallId: {
...before.trimmedToolNotificationByCallId,
},
permissionBlockByRequestId: { ...before.permissionBlockByRequestId },
toolProgress: { 'call-1': { ...progress } },
};
const after = reduceDaemonTranscriptEvents(before, [
{ type: `${kind}.text.delta`, text: ' two' },
]);
expect(after.blocks).not.toBe(before.blocks);
expect(after.toolBlockByCallId).toBe(before.toolBlockByCallId);
expect(after.activeAssistantBlockByParent).toBe(
before.activeAssistantBlockByParent,
);
expect(after.activeThoughtBlockByParent).toBe(
before.activeThoughtBlockByParent,
);
expect(after.trimmedToolNotificationByCallId).toBe(
before.trimmedToolNotificationByCallId,
);
expect(after.permissionBlockByRequestId).toBe(
before.permissionBlockByRequestId,
);
expect(after.toolProgress).toBe(before.toolProgress);
expect(after.toolProgress['call-1']).toBe(progress);
for (const key of Object.keys(sideIndexContents) as Array<
keyof typeof sideIndexContents
>) {
expect(after[key]).toEqual(sideIndexContents[key]);
expect(Object.isFrozen(after[key])).toBe(true);
}
expect(Object.isFrozen(after.toolProgress['call-1'])).toBe(true);
},
);
it('does not share side indexes when a reducer maxBlocks override trims', () => {
const before = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ maxBlocks: 10 }),
[
{
type: 'tool.update',
toolCallId: 'call-1',
toolName: 'read_file',
status: 'completed',
},
],
);
const beforeToolBlockId = before.toolBlockByCallId['call-1'];
const after = reduceDaemonTranscriptEvents(
before,
[{ type: 'thought.text.delta', text: 'one' }],
{ maxBlocks: 1 },
);
expect(after.blocks).toHaveLength(1);
expect(after.toolBlockByCallId).not.toBe(before.toolBlockByCallId);
expect(before.toolBlockByCallId['call-1']).toBe(beforeToolBlockId);
});
it('does not mutate shared side indexes when byte trimming starts', () => {
const before = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ maxBlocks: 10 }),
[
{
type: 'tool.update',
toolCallId: 'call-1',
toolName: 'read_file',
status: 'completed',
},
{ type: 'thought.text.delta', text: 'one' },
],
);
const beforeToolBlockId = before.toolBlockByCallId['call-1'];
const after = reduceDaemonTranscriptEvents(
before,
[{ type: 'thought.text.delta', text: 'x'.repeat(1_000) }],
{ maxRetainedBytes: before.retainedBytes + 1 },
);
expect(after.blocks).toHaveLength(1);
expect(after.toolBlockByCallId).not.toBe(before.toolBlockByCallId);
expect(before.toolBlockByCallId['call-1']).toBe(beforeToolBlockId);
});
});
+4 -1
View File
@@ -394,6 +394,7 @@ const {
attachmentId?: string;
}) => void;
isResponding?: boolean;
transcriptReloadPaused?: boolean;
activeTurnStartedAt?: number;
terminalBackgroundShellTaskIds?: ReadonlySet<string>;
} | null,
@@ -579,7 +580,7 @@ vi.mock('./hooks/useMessages', () => ({
}));
vi.mock('./hooks/useAnimationFrameTranscriptBlocks', () => ({
useAnimationFrameTranscriptBlocks: () => testState.blocks,
useAnimationFrameTranscriptSnapshot: () => ({ blocks: testState.blocks }),
}));
vi.mock('./hooks/useBackgroundTasks', () => ({
@@ -801,6 +802,7 @@ vi.mock('./components/MessageList', async () => {
attachmentId?: string;
}) => void;
isResponding?: boolean;
transcriptReloadPaused?: boolean;
activeTurnStartedAt?: number;
terminalBackgroundShellTaskIds?: ReadonlySet<string>;
welcomeHeader?: React.ReactNode;
@@ -13224,6 +13226,7 @@ describe('App session callbacks', () => {
testState.streamingState = 'responding';
rerender();
expect(testState.latestMessageListProps?.isResponding).toBe(false);
expect(testState.latestMessageListProps?.transcriptReloadPaused).toBe(true);
expect(
testState.latestMessageListProps?.activeTurnStartedAt,
).toBeUndefined();
+6 -3
View File
@@ -207,7 +207,7 @@ import {
skillDescriptionKey,
} from './constants/localCommands';
import { mergeCommands } from './hooks/daemonSessionMappers';
import { useAnimationFrameTranscriptBlocks } from './hooks/useAnimationFrameTranscriptBlocks';
import { useAnimationFrameTranscriptSnapshot } from './hooks/useAnimationFrameTranscriptBlocks';
import { useBackgroundTasks } from './hooks/useBackgroundTasks';
import { isSessionDisconnectedError } from './utils/sessionErrors';
import { useMessagesFromBlocks } from './hooks/useMessages';
@@ -2090,7 +2090,7 @@ export function App({
const CustomComposerHeader = renderComposerHeader;
const CustomComposerFooter = renderComposerFooter;
const store = useTranscriptStore();
const blocks = useAnimationFrameTranscriptBlocks();
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot();
const connection = useConnection();
const logicalSessionKey = getLogicalSessionKey(
connection.sessionId,
@@ -2514,7 +2514,7 @@ export function App({
});
}, []);
const messages = useMessagesFromBlocks(t, blocks);
const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
const messagesRef = useRef(messages);
messagesRef.current = messages;
const [failedPrompt, setFailedPrompt] = useState<FailedPrompt | null>(null);
@@ -12441,6 +12441,9 @@ export function App({
streamingState !== 'idle' &&
!suppressFailedPromptRetryStreaming
}
transcriptReloadPaused={
streamingState !== 'idle'
}
activeTurnStartedAt={
suppressFailedPromptRetryStreaming
? undefined
@@ -159,7 +159,7 @@ vi.mock('../hooks/useMessages', () => ({
}));
vi.mock('../hooks/useAnimationFrameTranscriptBlocks', () => ({
useAnimationFrameTranscriptBlocks: () => [],
useAnimationFrameTranscriptSnapshot: () => ({ blocks: [] }),
}));
vi.mock('../adapters/transcriptAdapter', () => ({
@@ -38,7 +38,7 @@ import {
SESSION_MONITOR_TOOL_CORRELATION_FEATURE,
SESSION_TRANSCRIPT_PAGINATION_FEATURE,
} from '../constants/sessions';
import { useAnimationFrameTranscriptBlocks } from '../hooks/useAnimationFrameTranscriptBlocks';
import { useAnimationFrameTranscriptSnapshot } from '../hooks/useAnimationFrameTranscriptBlocks';
import { useMessagesFromBlocks } from '../hooks/useMessages';
import { useSessionArtifacts } from '../hooks/useSessionArtifacts';
import { extractPendingPermission } from '../adapters/transcriptAdapter';
@@ -261,14 +261,13 @@ export function ChatPane({
const sessionCatalogController = useSessionCatalogController(
workspace.client,
);
const sessionHasActivePrompt = useSessionHasActivePrompt(
workspace.client,
workspaceCwd ?? connection.workspaceCwd,
connection.sessionId,
);
const blocks = useAnimationFrameTranscriptBlocks();
const messages = useMessagesFromBlocks(t, blocks);
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot();
const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
const transcriptHistory = useTranscriptHistory();
const store = useTranscriptStore();
const streamingState = useStreamingState();
@@ -29,6 +29,9 @@ const virtualizerTestState = vi.hoisted(() => ({
resizeItem: vi.fn(),
renderItems: true,
}));
const messageItemTestState = vi.hoisted(() => ({
toolArrays: [] as unknown[][],
}));
// Mock the App context and the heavy row children so this test exercises only
// MessageList's own collapse + deferred-scroll logic, not the whole render tree.
@@ -61,6 +64,9 @@ vi.mock('./MessageItem', async () => {
sendFailed?: boolean;
onRetrySend?: () => void;
}) => {
if (message.role === 'tool_group') {
messageItemTestState.toolArrays.push(message.tools);
}
const { renderAssistantTurnFooter } = useWebShellCustomization();
const assistantTurnFooter = assistantTurnFooterInfo
? renderAssistantTurnFooter?.(assistantTurnFooterInfo)
@@ -79,6 +85,10 @@ vi.mock('./MessageItem', async () => {
message.role === 'tool_group'
? message.tools.map((tool) => tool.callId).join(',')
: undefined,
'data-thought-content':
message.role === 'tool_group'
? message.thoughts?.map((thought) => thought.content).join('|')
: undefined,
},
sendFailed
? React.createElement(
@@ -193,6 +203,7 @@ afterEach(() => {
virtualizerTestState.resizeItem.mockClear();
virtualizerTestState.renderItems = true;
virtualizerTestState.getItemKeys.length = 0;
messageItemTestState.toolArrays.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -533,6 +544,34 @@ describe('MessageList — failed prompt retry', () => {
});
describe('MessageList — compact mode', () => {
it('updates a streaming thinking tail inside the existing compact summary', () => {
const user = userMsg('u1');
const thinking = {
...thinkingMsg('t1'),
content: 'first',
isStreaming: true,
};
const container = mount([user, thinking], undefined, {
compactMode: true,
isResponding: true,
});
const summary = container.querySelector('[data-testid="msg-summary-t1"]');
const tools = messageItemTestState.toolArrays.at(-1);
expect(summary?.getAttribute('data-thought-content')).toBe('first');
rerenderMessages(
container,
[user, { ...thinking, content: 'first second' }],
{ isResponding: true },
);
expect(container.querySelector('[data-testid="msg-summary-t1"]')).toBe(
summary,
);
expect(messageItemTestState.toolArrays.at(-1)).toBe(tools);
expect(summary?.getAttribute('data-thought-content')).toBe('first second');
});
it('keeps thinking without adjacent tools visible in compact mode', () => {
const container = mount(
[userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')],
@@ -699,42 +738,107 @@ describe('MessageList — compact mode', () => {
});
describe('MessageList — turn collapse (DOM)', () => {
it('reloads an oversized transcript after 120 quiet seconds at the tail', async () => {
it('does not reload a responding transcript when pause is implicit', async () => {
vi.useFakeTimers();
const onReloadTranscript = vi.fn().mockResolvedValue(undefined);
mount([userMsg('u1'), asstMsg('a1')], undefined, {
transcriptBlockCount: WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1,
onReloadTranscript,
isResponding: true,
});
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(onReloadTranscript).not.toHaveBeenCalled();
});
it('reloads an oversized transcript after 15 quiet seconds at the tail', async () => {
vi.useFakeTimers();
const onReloadTranscript = vi.fn().mockResolvedValue(undefined);
let lastEventId = 10;
let notifyActivity = () => undefined;
mount([userMsg('u1'), asstMsg('a1')], undefined, {
transcriptBlockCount: WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1,
transcriptActivity: {
getSnapshot: () => ({ lastEventId }),
subscribe: (listener) => {
notifyActivity = listener;
return () => undefined;
},
const transcriptActivity = {
getSnapshot: () => ({ lastEventId }),
subscribe: (listener: () => void) => {
notifyActivity = listener;
return () => undefined;
},
onReloadTranscript,
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({
root,
container,
transcriptRenderMode: 'interactive',
compactMode: false,
});
const render = (
transcriptBlockCount: number,
transcriptReloadPaused = false,
) => {
root.render(
<I18nProvider language="en">
<MessageList
messages={[userMsg('u1'), asstMsg('a1')]}
pendingApproval={null}
transcriptBlockCount={transcriptBlockCount}
transcriptActivity={transcriptActivity}
onReloadTranscript={onReloadTranscript}
transcriptReloadPaused={transcriptReloadPaused}
/>
</I18nProvider>,
);
};
act(() => render(WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1));
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(7_500);
lastEventId++;
notifyActivity();
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(7_500);
});
expect(onReloadTranscript).not.toHaveBeenCalled();
await act(async () => vi.advanceTimersByTimeAsync(60_000));
await act(async () => vi.advanceTimersByTimeAsync(7_500));
expect(onReloadTranscript).toHaveBeenCalledOnce();
await act(async () => vi.advanceTimersByTimeAsync(120_000));
act(() => render(WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 2));
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(onReloadTranscript).toHaveBeenCalledOnce();
lastEventId++;
notifyActivity();
await act(async () => vi.advanceTimersByTimeAsync(120_000));
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(onReloadTranscript).toHaveBeenCalledTimes(2);
lastEventId++;
notifyActivity();
const clearTimeout = vi
.spyOn(window, 'clearTimeout')
.mockImplementation(() => undefined);
act(() => render(WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 2, true));
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(onReloadTranscript).toHaveBeenCalledTimes(2);
clearTimeout.mockRestore();
let reloadSignal: AbortSignal | undefined;
let resolveReload = () => undefined;
onReloadTranscript.mockImplementationOnce((signal: AbortSignal) => {
reloadSignal = signal;
return new Promise<void>((resolve) => {
resolveReload = resolve;
});
});
act(() => render(WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 2));
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(onReloadTranscript).toHaveBeenCalledTimes(3);
expect(reloadSignal?.aborted).toBe(false);
act(() => render(WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 2, true));
expect(reloadSignal?.aborted).toBe(true);
await act(async () => resolveReload());
});
it('aborts an in-flight transcript reload when the reader leaves the tail', async () => {
@@ -765,7 +869,7 @@ describe('MessageList — turn collapse (DOM)', () => {
onReloadTranscript,
});
await act(async () => vi.advanceTimersByTimeAsync(120_000));
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(reloadSignal?.aborted).toBe(false);
const list = container.firstElementChild as HTMLElement;
@@ -3665,6 +3769,48 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(onLoadOlderHistory).toHaveBeenCalledWith({ force: true });
});
it('does not scroll again for a content-only update in a virtual transcript', async () => {
let scrollTop = 0;
const getScrollHeight = vi.fn(() => 20_000);
const setScrollTop = vi.fn((value: number) => {
scrollTop = value;
});
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: getScrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: setScrollTop,
});
const messages = simpleTurns(101);
messages[messages.length - 1] = {
...(messages[messages.length - 1] as AssistantMessage),
isStreaming: true,
};
const container = mount(messages, undefined, { isResponding: true });
await nextFrame();
await nextFrame();
getScrollHeight.mockClear();
setScrollTop.mockClear();
const updated = messages.slice();
updated[updated.length - 1] = {
...(updated[updated.length - 1] as AssistantMessage),
content: 'answer with one more streamed token',
};
rerenderMessages(container, updated, { isResponding: true });
await nextFrame();
expect(getScrollHeight).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
it('does not smooth-scroll when existing session history loads after an empty render', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
@@ -21,6 +21,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type {
ToolGroupMessage as DaemonToolGroupMessage,
ThinkingMessage as DaemonThinkingMessage,
Message,
ACPToolCall,
TurnCollapseHead,
@@ -64,7 +65,7 @@ import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions';
import type { AttachmentPreviewRequest } from '../adapters/messageTypes';
const noopTurnOutputAction = () => undefined;
const RELOAD_TRANSCRIPT_DELAY_MS = 120_000;
const RELOAD_TRANSCRIPT_DELAY_MS = 15_000;
const TURN_LAYOUT_ANIMATION_MS = 180;
const AGENT_SUMMARY_COLLAPSE_DELAY_MS = 400;
// A reconciled-terminal sibling whose completion notification is delayed (not
@@ -97,6 +98,7 @@ interface MessageListProps {
subscribe(listener: () => void): () => void;
};
onReloadTranscript?: (signal: AbortSignal) => Promise<void>;
transcriptReloadPaused?: boolean;
/**
* True while the agent is still answering. The newest turn then stays
* expanded and un-collapsible so streaming output is never hidden.
@@ -427,6 +429,36 @@ function mergeCompactToolGroups(
return result;
}
function updateCompactStreamingThinkingTail(
messages: readonly Message[],
tail: DaemonThinkingMessage,
): Message[] | undefined {
const group = messages[messages.length - 1];
if (
group?.role !== 'tool_group' ||
!group.id.startsWith('summary-') ||
!group.thoughts?.length
) {
return undefined;
}
const thought = group.thoughts[group.thoughts.length - 1];
if (thought?.isStreaming !== true) return undefined;
const thoughts = group.thoughts.slice();
thoughts[thoughts.length - 1] = {
...thought,
content: tail.content,
isStreaming: true,
};
const result = messages.slice();
result[result.length - 1] = {
...group,
thoughts,
...(group.id === `summary-${tail.id}` ? { timestamp: tail.timestamp } : {}),
};
return result;
}
export function groupParallelAgents(messages: Message[]): DisplayItem[] {
const items: DisplayItem[] = [];
let i = 0;
@@ -2702,6 +2734,7 @@ export const MessageList = memo(
transcriptActivity,
onReloadTranscript,
isResponding = false,
transcriptReloadPaused = isResponding,
activeTurnStartedAt,
welcomeHeader,
centerWelcomeHeader = false,
@@ -2759,22 +2792,28 @@ export const MessageList = memo(
const mergedMessages = useMemo(() => {
const cached = mergedMessagesCache.current;
const tail = messages[messages.length - 1];
let value: Message[];
let value: Message[] | undefined;
if (
streamingTailContentOnly &&
tail?.role === 'assistant' &&
cached?.sourceMessages === previousMessagesRef.current &&
cached?.compactMode === compactMode &&
cached.pendingApproval === pendingApproval &&
cached.value[cached.value.length - 1]?.id === tail.id
cached.pendingApproval === pendingApproval
) {
value = cached.value.slice();
value[value.length - 1] = tail;
} else {
value = compactMode
? mergeCompactToolGroups(messages, pendingApproval)
: messages;
if (
tail?.role === 'assistant' &&
cached.value[cached.value.length - 1]?.id === tail.id
) {
value = cached.value.slice();
value[value.length - 1] = tail;
} else if (tail?.role === 'thinking') {
value = compactMode
? updateCompactStreamingThinkingTail(cached.value, tail)
: messages;
}
}
value ??= compactMode
? mergeCompactToolGroups(messages, pendingApproval)
: messages;
mergedMessagesCache.current = {
sourceMessages: messages,
compactMode,
@@ -2809,7 +2848,6 @@ export const MessageList = memo(
if (
streamingTailContentOnly &&
isResponding &&
tail?.role === 'assistant' &&
cached?.sourceMessages === previousMessagesRef.current &&
cached?.compactMode === compactMode &&
cached.pendingApproval === pendingApproval &&
@@ -3263,14 +3301,13 @@ export const MessageList = memo(
const transcriptReloadBaseline = useRef<
| {
lastEventId?: number;
blockCount: number;
}
| undefined
>(undefined);
const transcriptBlockCountRef = useRef(transcriptBlockCount);
const isRespondingRef = useRef(isResponding);
const transcriptReloadPausedRef = useRef(transcriptReloadPaused);
transcriptBlockCountRef.current = transcriptBlockCount;
isRespondingRef.current = isResponding;
transcriptReloadPausedRef.current = transcriptReloadPaused;
const lastUnderfillAutoLoad = useRef<{
loader: typeof onLoadOlderHistory;
totalVirtualSize: number;
@@ -3646,25 +3683,27 @@ export const MessageList = memo(
const baseline = transcriptReloadBaseline.current;
if (baseline) {
const lastEventId = transcriptActivity?.getSnapshot().lastEventId;
if (
lastEventId === baseline.lastEventId &&
transcriptBlockCountRef.current <= baseline.blockCount
) {
return;
}
if (lastEventId === baseline.lastEventId) return;
transcriptReloadBaseline.current = undefined;
}
if (
!onReloadTranscript ||
reloadTranscriptAbort.current !== undefined ||
followPausedByUserRef.current ||
isRespondingRef.current ||
transcriptReloadPausedRef.current ||
transcriptBlockCountRef.current <= WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS
) {
return;
}
reloadTranscriptTimer.current = window.setTimeout(() => {
reloadTranscriptTimer.current = undefined;
if (
transcriptReloadPausedRef.current ||
followPausedByUserRef.current ||
transcriptBlockCountRef.current <= WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS
) {
return;
}
const el = containerRef.current;
if (!el) return;
const distanceFromBottom =
@@ -3680,8 +3719,6 @@ export const MessageList = memo(
...(snapshot?.lastEventId !== undefined
? { lastEventId: snapshot.lastEventId }
: {}),
blockCount:
snapshot?.blocks?.length ?? transcriptBlockCountRef.current,
};
})
.catch((error: unknown) => {
@@ -3717,8 +3754,17 @@ export const MessageList = memo(
}, [transcriptActivity, scheduleTranscriptReload, cancelTranscriptReload]);
useEffect(() => {
if (transcriptReloadPaused) {
cancelTranscriptReload();
return;
}
scheduleTranscriptReload();
}, [isResponding, scheduleTranscriptReload, transcriptBlockCount]);
}, [
cancelTranscriptReload,
transcriptReloadPaused,
scheduleTranscriptReload,
transcriptBlockCount,
]);
const scheduleFollowRecheck = useCallback(() => {
pendingFollowRecheck.current = true;
@@ -5147,6 +5193,9 @@ export const MessageList = memo(
item ? getChatRowClassName(item) : undefined,
[],
);
const autoScrollContentSignal = useVirtualScroll
? totalVirtualSize
: messages;
// ── Single auto-scroll driver (rules 1, 5, 6) ──────────────────────
// Fires whenever the virtualizer's total content height changes —
@@ -5183,8 +5232,7 @@ export const MessageList = memo(
pendingNewUserSmoothScroll.current = false;
}
}, [
totalVirtualSize,
messages,
autoScrollContentSignal,
totalCount,
catchingUp,
scrollToBottom,
@@ -5193,7 +5241,7 @@ export const MessageList = memo(
useLayoutEffect(() => {
scheduleScrollOverflowReport();
}, [messages, scheduleScrollOverflowReport, totalCount, totalVirtualSize]);
}, [autoScrollContentSignal, scheduleScrollOverflowReport, totalCount]);
return (
<div
@@ -66,7 +66,9 @@ vi.mock('../../hooks/useMessages', () => ({
}));
vi.mock('../../hooks/useAnimationFrameTranscriptBlocks', () => ({
useAnimationFrameTranscriptBlocks: () => animationFrameBlocks,
useAnimationFrameTranscriptSnapshot: () => ({
blocks: animationFrameBlocks,
}),
}));
vi.mock('../../hooks/useSessionArtifacts', () => ({
@@ -7,7 +7,7 @@ import {
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type { ACPToolCall, Message } from '../../adapters/types';
import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from '../../constants/sessions';
import { useAnimationFrameTranscriptBlocks } from '../../hooks/useAnimationFrameTranscriptBlocks';
import { useAnimationFrameTranscriptSnapshot } from '../../hooks/useAnimationFrameTranscriptBlocks';
import { useMessagesFromBlocks } from '../../hooks/useMessages';
import { useSessionArtifacts } from '../../hooks/useSessionArtifacts';
import { useI18n } from '../../i18n';
@@ -133,8 +133,8 @@ function SubagentDetailContent({
}) {
const { t } = useI18n();
const connection = useConnection();
const blocks = useAnimationFrameTranscriptBlocks();
const messages = useMessagesFromBlocks(t, blocks);
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot();
const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
const { artifacts } = useSessionArtifacts();
const artifactsByTurn = useMemo(
() =>
@@ -271,8 +271,13 @@ export function SubagentDetail({
const { t } = useI18n();
const workspace = useWorkspace();
const parentConnection = useConnection();
const parentBlocks = useAnimationFrameTranscriptBlocks();
const parentMessages = useMessagesFromBlocks(t, parentBlocks);
const { blocks: parentBlocks, blockChangeSummary: parentBlockChangeSummary } =
useAnimationFrameTranscriptSnapshot();
const parentMessages = useMessagesFromBlocks(
t,
parentBlocks,
parentBlockChangeSummary,
);
const rootTool =
(parentConnection.sessionId === sessionId
? findSubagentRootTool(parentMessages, rootToolCallId)
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
vi.mock('../../App', async () => {
const { createContext } = await import('react');
return {
CompactModeContext: createContext(false),
};
});
// The summary header's only per-render side effects are its `useI18n` calls:
// the expand/collapse title. Counting those calls distinguishes "the header
// re-rendered" from "the parent re-rendered" (the parent re-evaluates the
// running label via `t` on every chunk, the header must not).
const t = vi.hoisted(() =>
vi.fn((key: string, vars?: Record<string, string | number>) =>
vars && vars.duration !== undefined
? `${key}:${String(vars.duration)}`
: key,
),
);
vi.mock('../../i18n', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({ language: 'en' as const, t }),
}));
const { ThinkingMessage } = await import('./AssistantMessage');
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
function headerTitleCallCount(): number {
return t.mock.calls.filter(
([key]) => key === 'thinking.expand' || key === 'thinking.collapse',
).length;
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.useRealTimers();
vi.restoreAllMocks();
t.mockClear();
});
describe('ThinkingMessage streaming memo boundary', () => {
it('skips the collapsed summary header on streamed thought chunks', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
const tree = (content: string) => (
<ThinkingMessage content={content} isStreaming timestamp={0} />
);
act(() => root.render(tree('a')));
const headerCallsAfterMount = headerTitleCallCount();
expect(headerCallsAfterMount).toBeGreaterThan(0);
act(() => root.render(tree('ab')));
act(() => root.render(tree('abc')));
act(() => root.render(tree('abcd')));
// The header did not re-render: its title key was evaluated once at mount.
expect(headerTitleCallCount()).toBe(headerCallsAfterMount);
// The parent still re-evaluates the running label on every chunk, so the
// test would catch a header that rendered along with the parent.
expect(
t.mock.calls.filter(([key]) => key === 'thinking.running').length,
).toBeGreaterThan(headerCallsAfterMount);
// The shine animation class stays applied while streaming.
expect(
container.querySelector('[class*="thinkingSummaryTextActive"]'),
).not.toBeNull();
// Collapsed: the growing thought body stays hidden.
expect(container.textContent).not.toContain('abc');
});
it('still streams the expanded body while the summary header stays memoized', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
const tree = (content: string) => (
<ThinkingMessage content={content} isStreaming timestamp={0} />
);
act(() => root.render(tree('a')));
const toggle = container.querySelector<HTMLButtonElement>(
'button[title="thinking.expand"]',
);
act(() => toggle?.parentElement?.click());
const headerCallsAfterExpand = headerTitleCallCount();
act(() => root.render(tree('ab')));
act(() => root.render(tree('abc')));
// Markdown throttles streamed content by 80ms; flush the pending update.
act(() => vi.advanceTimersByTime(80));
expect(container.textContent).toContain('abc');
expect(headerTitleCallCount()).toBe(headerCallsAfterExpand);
});
});
@@ -223,6 +223,88 @@ function cacheThinkingTranslation(
if (oldestKey !== undefined) thinkingTranslationCache.delete(oldestKey);
}
interface ThinkingSummaryHeaderProps {
thinkingActive: boolean;
thinkingExpanded: boolean;
/** Pre-localized running/done label, including the elapsed duration. */
summaryText: string;
/**
* Thought content for the zh-CN translate button. Omitted while streaming —
* the button is hidden then — so streamed content growth does not defeat the
* summary header's memo boundary.
*/
translateContent?: string;
showTranslateButton: boolean;
generateContent?: SessionContentGenerator;
onToggle: () => void;
}
/**
* Collapsed thinking row: label, elapsed duration, and the streaming shine.
* Memoized so streamed thought deltas re-render only the expanded body (or
* nothing when collapsed) instead of this header on every chunk.
*/
const ThinkingSummaryHeader = memo(function ThinkingSummaryHeader({
thinkingActive,
thinkingExpanded,
summaryText,
translateContent,
showTranslateButton,
generateContent,
onToggle,
}: ThinkingSummaryHeaderProps) {
const { t } = useI18n();
return (
<div
className={`${styles.thinkingHeader}${
thinkingExpanded ? ` ${styles.thinkingHeaderExpanded}` : ''
}`}
onClick={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
onToggle();
}
}}
>
<button
type="button"
className={styles.thinkingSummary}
aria-expanded={thinkingExpanded}
title={thinkingExpanded ? t('thinking.collapse') : t('thinking.expand')}
>
<span className={styles.thinkingSummaryIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={
thinkingActive
? `${styles.thinkingSummaryText} ${styles.thinkingSummaryTextActive}`
: styles.thinkingSummaryText
}
>
{summaryText}
</span>
</button>
{showTranslateButton &&
translateContent !== undefined &&
generateContent && (
<ThinkingTranslateButton
content={translateContent}
generateContent={generateContent}
className={styles.translateButton}
/>
)}
<span
className={
thinkingExpanded
? styles.thinkingChevronDown
: styles.thinkingChevronRight
}
aria-hidden="true"
/>
</div>
);
});
export const ThinkingMessage = memo(function ThinkingMessage({
content,
isStreaming,
@@ -278,6 +360,11 @@ export const ThinkingMessage = memo(function ThinkingMessage({
setThinkingExpanded((v) => !v);
}, []);
const summaryText = t(
thinkingSummaryKey,
thinkingDuration ? { duration: thinkingDuration } : {},
);
return (
<div
className={`${styles.message}${
@@ -287,58 +374,19 @@ export const ThinkingMessage = memo(function ThinkingMessage({
{content && (
<div className={styles.thinking}>
<div className={styles.thinkingBody}>
<div
className={`${styles.thinkingHeader}${
thinkingExpanded ? ` ${styles.thinkingHeaderExpanded}` : ''
}`}
onClick={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
handleToggle();
}
}}
>
<button
type="button"
className={styles.thinkingSummary}
aria-expanded={thinkingExpanded}
title={
thinkingExpanded
? t('thinking.collapse')
: t('thinking.expand')
}
>
<span className={styles.thinkingSummaryIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={
thinkingActive
? `${styles.thinkingSummaryText} ${styles.thinkingSummaryTextActive}`
: styles.thinkingSummaryText
}
>
{t(
thinkingSummaryKey,
thinkingDuration ? { duration: thinkingDuration } : {},
)}
</span>
</button>
{language === 'zh-CN' && !thinkingActive && generateContent && (
<ThinkingTranslateButton
content={content}
generateContent={generateContent}
className={styles.translateButton}
/>
)}
<span
className={
thinkingExpanded
? styles.thinkingChevronDown
: styles.thinkingChevronRight
}
aria-hidden="true"
/>
</div>
<ThinkingSummaryHeader
thinkingActive={thinkingActive}
thinkingExpanded={thinkingExpanded}
summaryText={summaryText}
translateContent={thinkingActive ? undefined : content}
showTranslateButton={
language === 'zh-CN' &&
!thinkingActive &&
generateContent !== undefined
}
generateContent={generateContent}
onToggle={handleToggle}
/>
{thinkingExpanded && (
<div className={styles.thinkingExpandedClip}>
<div className={styles.thinkingExpandedInner}>
@@ -1,6 +1,7 @@
import {
Fragment,
memo,
useCallback,
useContext,
useEffect,
useMemo,
@@ -1526,7 +1527,77 @@ export const ToolLine = memo(function ToolLine({
);
}, areToolLinePropsEqual);
function ThoughtLine({
interface ThoughtLineHeaderProps {
isStreaming?: boolean;
expanded: boolean;
/**
* Thought content for the zh-CN translate button. Omitted while streaming —
* the button is hidden then — so streamed content growth does not defeat the
* header's memo boundary.
*/
translateContent?: string;
generateContent?: SessionContentGenerator;
onToggle: () => void;
}
const ThoughtLineHeader = memo(function ThoughtLineHeader({
isStreaming,
expanded,
translateContent,
generateContent,
onToggle,
}: ThoughtLineHeaderProps) {
const { language, t } = useI18n();
return (
<div
className={`${styles.chatSummaryThoughtHeader}${
expanded ? ` ${styles.chatSummaryThoughtHeaderExpanded}` : ''
}`}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onToggle}
onKeyDown={(event) => {
// Only the container itself toggles; keys pressed inside nested
// controls (the translate button) keep their own behavior.
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onToggle();
}}
>
<span className={styles.chatSummaryThoughtIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={`${styles.chatSummaryThoughtLabel}${
isStreaming ? ` ${styles.chatSummaryThoughtLabelActive}` : ''
}`}
>
{t(isStreaming ? 'thinking.running' : 'thinking.done')}
</span>
{language === 'zh-CN' &&
translateContent !== undefined &&
generateContent && (
<ThinkingTranslateButton
content={translateContent}
generateContent={generateContent}
className={styles.chatSummaryThoughtTranslate}
/>
)}
<span
className={
expanded
? styles.chatSummaryThoughtChevronDown
: styles.chatSummaryThoughtChevronRight
}
aria-hidden="true"
/>
</div>
);
});
const ThoughtLine = memo(function ThoughtLine({
content,
isStreaming,
generateContent,
@@ -1535,53 +1606,17 @@ function ThoughtLine({
isStreaming?: boolean;
generateContent?: SessionContentGenerator;
}) {
const { language, t } = useI18n();
const [expanded, setExpanded] = useState(false);
const handleToggle = useCallback(() => setExpanded((value) => !value), []);
return (
<div className={styles.chatSummaryThought}>
<div
className={`${styles.chatSummaryThoughtHeader}${
expanded ? ` ${styles.chatSummaryThoughtHeaderExpanded}` : ''
}`}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
onKeyDown={(event) => {
// Only the container itself toggles; keys pressed inside nested
// controls (the translate button) keep their own behavior.
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
setExpanded((value) => !value);
}}
>
<span className={styles.chatSummaryThoughtIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={`${styles.chatSummaryThoughtLabel}${
isStreaming ? ` ${styles.chatSummaryThoughtLabelActive}` : ''
}`}
>
{t(isStreaming ? 'thinking.running' : 'thinking.done')}
</span>
{language === 'zh-CN' && !isStreaming && generateContent && (
<ThinkingTranslateButton
content={content}
generateContent={generateContent}
className={styles.chatSummaryThoughtTranslate}
/>
)}
<span
className={
expanded
? styles.chatSummaryThoughtChevronDown
: styles.chatSummaryThoughtChevronRight
}
aria-hidden="true"
/>
</div>
<ThoughtLineHeader
isStreaming={isStreaming}
expanded={expanded}
translateContent={isStreaming ? undefined : content}
generateContent={generateContent}
onToggle={handleToggle}
/>
{expanded && (
<div className={styles.chatSummaryThoughtContent}>
<Markdown content={content} source="thinking" />
@@ -1589,7 +1624,7 @@ function ThoughtLine({
)}
</div>
);
}
});
export const ToolGroup = memo(function ToolGroup({
tools,
@@ -18,7 +18,7 @@ export const SESSION_MONITOR_TOOL_CORRELATION_FEATURE =
export const SESSION_SIDE_TASK_FEATURE = 'session_side_task';
export const WEB_SHELL_SESSION_SOURCE_TYPE = 'default';
export const WEB_SHELL_SIDE_TASK_SOURCE_TYPE = 'side_task';
export const WEB_SHELL_HISTORY_PAGE_SIZE = 100;
export const WEB_SHELL_HISTORY_PAGE_SIZE = 200;
export const WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS = 500;
/**
@@ -509,6 +509,20 @@ export function assistantTextEvent(
);
}
export function thoughtTextEvent(
text: string,
options: { id?: number; sessionId?: string } = {},
): DaemonEvent {
return sessionUpdateEvent(
{
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
...(options.sessionId ? { sessionId: options.sessionId } : {}),
},
options.id,
);
}
export function turnCompleteEvent(
promptId: string,
options: { id?: number; sessionId?: string } = {},
@@ -5,6 +5,7 @@ import {
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
thoughtTextEvent,
turnCompleteEvent,
userTextEvent,
type MockDaemonController,
@@ -14,6 +15,7 @@ import {
interface BrowserPerformanceMetrics {
inputEvents: number[];
longTasks: number[];
frameGaps: number[];
}
declare global {
@@ -27,6 +29,10 @@ const streamChunks = Number(process.env['WEB_SHELL_PERF_CHUNKS'] ?? 400);
const streamIntervalMs = Number(
process.env['WEB_SHELL_PERF_INTERVAL_MS'] ?? 10,
);
const streamKind =
process.env['WEB_SHELL_PERF_STREAM_KIND'] === 'thought'
? 'thought'
: 'assistant';
test.skip(
process.env['WEB_SHELL_PERF'] !== '1',
@@ -58,9 +64,11 @@ test('keeps the composer responsive during deterministic streaming @perf', async
const streamStartedAt = Date.now();
let nextEventId = history.length + 1;
let typingMs = 0;
const streamTextEvent =
streamKind === 'thought' ? thoughtTextEvent : assistantTextEvent;
const streamEvents = Array.from({ length: streamChunks }, (_, index) => {
const text = `${index}: ${'deterministic streaming text '.repeat(8)}\n`;
return assistantTextEvent(text, { id: nextEventId++ });
return streamTextEvent(text, { id: nextEventId++ });
});
streamEvents.push(
assistantTextEvent('STREAM_COMPLETE_SENTINEL', { id: nextEventId++ }),
@@ -91,6 +99,7 @@ test('keeps the composer responsive during deterministic streaming @perf', async
const metrics = await readPerformanceMetrics(page);
const result = {
historyTurns,
streamKind,
streamChunks,
streamIntervalMs,
replayMs,
@@ -100,6 +109,9 @@ test('keeps the composer responsive during deterministic streaming @perf', async
longTaskCount: metrics.longTasks.length,
longTaskTotalMs: sum(metrics.longTasks),
longTaskMaxMs: max(metrics.longTasks),
frameGapP95Ms: percentile(metrics.frameGaps, 0.95),
frameGapMaxMs: max(metrics.frameGaps),
frameGapOver34MsCount: metrics.frameGaps.filter((gap) => gap > 34).length,
slowInputEventCount: metrics.inputEvents.length,
slowInputEventP95Ms: percentile(metrics.inputEvents, 0.95),
slowInputEventMaxMs: max(metrics.inputEvents),
@@ -136,8 +148,18 @@ async function installPerformanceObservers(page: Page): Promise<void> {
const metrics: BrowserPerformanceMetrics = {
inputEvents: [],
longTasks: [],
frameGaps: [],
};
window.__webShellPerformanceMetrics = metrics;
let previousFrame: number | undefined;
const sampleFrame = (timestamp: number) => {
if (previousFrame !== undefined) {
metrics.frameGaps.push(timestamp - previousFrame);
}
previousFrame = timestamp;
requestAnimationFrame(sampleFrame);
};
requestAnimationFrame(sampleFrame);
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
new PerformanceObserver((list) => {
@@ -173,6 +195,7 @@ async function resetPerformanceMetrics(page: Page): Promise<void> {
if (metrics) {
metrics.inputEvents.length = 0;
metrics.longTasks.length = 0;
metrics.frameGaps.length = 0;
}
});
}
@@ -205,6 +228,7 @@ async function readPerformanceMetrics(
window.__webShellPerformanceMetrics ?? {
inputEvents: [],
longTasks: [],
frameGaps: [],
},
),
);
@@ -3,33 +3,71 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
import { useAnimationFrameTranscriptBlocks } from './useAnimationFrameTranscriptBlocks';
import type {
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
} from '@qwen-code/sdk/daemon';
import { useAnimationFrameTranscriptSnapshot } from './useAnimationFrameTranscriptBlocks';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const testStore = vi.hoisted(() => {
let blocks: readonly DaemonTranscriptBlock[] = [];
let blockIndexById: Readonly<Record<string, number>> = {};
const blockChangeSource = {};
let blockChangeSummary = {
source: blockChangeSource,
revision: 0,
tailAppendBarrierRevision: 0,
};
const listeners = new Set<() => void>();
return {
getSnapshot: () => ({ blocks, blockIndexById }),
getBlockChangeSummary: () => blockChangeSummary,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
update(nextBlocks: readonly DaemonTranscriptBlock[]) {
blocks = nextBlocks;
blockChangeSummary = {
source: blockChangeSource,
revision: blockChangeSummary.revision + 1,
tailAppendBarrierRevision: blockChangeSummary.revision + 1,
};
listeners.forEach((listener) => listener());
},
appendTail(
nextBlocks: readonly DaemonTranscriptBlock[],
tailBlockId: string,
) {
blocks = nextBlocks;
blockChangeSummary = {
source: blockChangeSource,
revision: blockChangeSummary.revision + 1,
tailAppendBarrierRevision: blockChangeSummary.tailAppendBarrierRevision,
tailBlockId,
};
listeners.forEach((listener) => listener());
},
resetBlocks(nextBlocks: readonly DaemonTranscriptBlock[] = []) {
blocks = nextBlocks;
blockIndexById = {};
blockChangeSummary = {
source: blockChangeSource,
revision: blockChangeSummary.revision + 1,
tailAppendBarrierRevision: blockChangeSummary.revision + 1,
};
listeners.forEach((listener) => listener());
},
reset() {
blocks = [];
blockIndexById = {};
blockChangeSummary = {
source: blockChangeSource,
revision: 0,
tailAppendBarrierRevision: 0,
};
listeners.clear();
},
};
@@ -48,12 +86,25 @@ let root: Root | null = null;
let container: HTMLDivElement | null = null;
let renderCount = 0;
let latestBlocks: readonly DaemonTranscriptBlock[] = [];
let renderLog: string[][] = [];
let latestSummary: DaemonTranscriptBlockChangeSummary | undefined;
let renderLog: Array<{
ids: string[];
texts: Array<string | undefined>;
revision?: number;
}> = [];
function Harness() {
latestBlocks = useAnimationFrameTranscriptBlocks();
const snapshot = useAnimationFrameTranscriptSnapshot();
latestBlocks = snapshot.blocks;
latestSummary = snapshot.blockChangeSummary;
renderCount += 1;
renderLog.push(latestBlocks.map((block) => block.id));
renderLog.push({
ids: latestBlocks.map((block) => block.id),
texts: latestBlocks.map((block) =>
'text' in block ? block.text : undefined,
),
revision: latestSummary?.revision,
});
return null;
}
@@ -64,13 +115,53 @@ afterEach(() => {
container = null;
renderCount = 0;
latestBlocks = [];
latestSummary = undefined;
renderLog = [];
testStore.reset();
testConnection.sessionId = 'session-a';
vi.restoreAllMocks();
});
describe('useAnimationFrameTranscriptBlocks', () => {
describe('useAnimationFrameTranscriptSnapshot', () => {
it('keeps coalesced tail blocks paired with their change summary', () => {
let pendingFrame: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
pendingFrame = callback;
return 1;
});
vi.spyOn(performance, 'now').mockReturnValue(1_000);
const first = { id: 'thought', text: 'a' } as DaemonTranscriptBlock;
testStore.update([first]);
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<Harness />));
const barrier = latestSummary?.tailAppendBarrierRevision;
const second = { ...first, text: 'ab' };
const third = { ...second, text: 'abc' };
act(() => {
testStore.appendTail([second], 'thought');
testStore.appendTail([third], 'thought');
});
act(() => pendingFrame?.(1_000));
expect(latestBlocks).toEqual([third]);
expect(latestSummary).toMatchObject({
revision: 3,
tailAppendBarrierRevision: barrier,
tailBlockId: 'thought',
});
const revisionByText = new Map([
['a', 1],
['ab', 2],
['abc', 3],
]);
for (const entry of renderLog) {
expect(entry.revision).toBe(revisionByText.get(entry.texts[0] ?? ''));
}
});
it('coalesces transcript notifications into one render per frame', () => {
let pendingFrame: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
@@ -234,7 +325,7 @@ describe('useAnimationFrameTranscriptBlocks', () => {
// session's snapshot.
expect(renderLog.length).toBeGreaterThan(0);
for (const entry of renderLog) {
expect(entry).toEqual(['b1']);
expect(entry.ids).toEqual(['b1']);
}
expect(latestBlocks).toEqual([blockB]);
});
@@ -257,7 +348,7 @@ describe('useAnimationFrameTranscriptBlocks', () => {
act(() => testStore.resetBlocks());
act(() => pendingFrame?.(1_000));
expect(renderLog).not.toContainEqual(['old']);
expect(renderLog.some((entry) => entry.ids.includes('old'))).toBe(false);
expect(latestBlocks).toEqual([]);
});
});
@@ -4,7 +4,10 @@ import {
useMemo,
useSyncExternalStore,
} from 'react';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
import type {
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
} from '@qwen-code/sdk/daemon';
import {
useConnection,
useTranscriptStore,
@@ -28,7 +31,12 @@ function hasPendingInput(): boolean {
return scheduling?.isInputPending?.() === true;
}
export function useAnimationFrameTranscriptBlocks(): readonly DaemonTranscriptBlock[] {
interface AnimationFrameTranscriptSnapshot {
blocks: readonly DaemonTranscriptBlock[];
blockChangeSummary?: DaemonTranscriptBlockChangeSummary;
}
export function useAnimationFrameTranscriptSnapshot(): AnimationFrameTranscriptSnapshot {
const store = useTranscriptStore();
const { sessionId } = useConnection();
const subscribe = useCallback(
@@ -76,18 +84,22 @@ export function useAnimationFrameTranscriptBlocks(): readonly DaemonTranscriptBl
| {
blocks: readonly DaemonTranscriptBlock[];
blockIndexById: Readonly<Record<string, number>>;
blockChangeSummary: DaemonTranscriptBlockChangeSummary | undefined;
}
| undefined;
return () => {
const state = store.getSnapshot();
const blockChangeSummary = store.getBlockChangeSummary?.();
if (
!cached ||
cached.blocks !== state.blocks ||
cached.blockIndexById !== state.blockIndexById
cached.blockIndexById !== state.blockIndexById ||
cached.blockChangeSummary !== blockChangeSummary
) {
cached = {
blocks: state.blocks,
blockIndexById: state.blockIndexById,
blockChangeSummary,
};
}
return cached;
@@ -104,8 +116,13 @@ export function useAnimationFrameTranscriptBlocks(): readonly DaemonTranscriptBl
// same-session store reset without blocking ordinary streamed text updates.
const snapshot = useMemo(() => ({ sessionId, ...live }), [live, sessionId]);
const deferred = useDeferredValue(snapshot);
return deferred.sessionId === sessionId &&
const current =
deferred.sessionId === sessionId &&
deferred.blockIndexById === live.blockIndexById
? deferred.blocks
: live.blocks;
? deferred
: live;
return {
blocks: current.blocks,
blockChangeSummary: current.blockChangeSummary,
};
}
@@ -7,14 +7,15 @@ import {
DaemonHttpError,
type DaemonStatusTranscriptBlock,
type DaemonTranscriptBlock,
type DaemonTranscriptBlockChangeSummary,
} from '@qwen-code/sdk/daemon';
import {
type BackgroundAgentResolution,
getBackgroundAgentNotificationKey,
getPendingBackgroundAgentKey,
projectStreamingTailMessages,
reconcileBackgroundAgentResolutions,
transcriptBlocksToLocalizedMessages,
useMessages,
useMessagesFromBlocks,
} from './useMessages';
import type { Message } from '../adapters/types';
@@ -34,6 +35,22 @@ const hookState = vi.hoisted(() => {
};
});
const adapterSpies = vi.hoisted(() => ({ project: vi.fn() }));
vi.mock('../adapters/transcriptToMessages', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../adapters/transcriptToMessages')>();
return {
...actual,
transcriptBlocksToDaemonMessages: (
...args: Parameters<typeof actual.transcriptBlocksToDaemonMessages>
) => {
adapterSpies.project();
return actual.transcriptBlocksToDaemonMessages(...args);
},
};
});
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => hookState.connection,
useTranscriptBlocks: () => hookState.blocks,
@@ -98,53 +115,343 @@ describe('transcriptBlocksToLocalizedMessages', () => {
]);
});
it('preserves projected history identity for a streaming tail update only', async () => {
it.each(['assistant', 'thought'] as const)(
'skips complete projection for a streaming %s tail update',
async (kind) => {
const container = document.createElement('div');
const root = createRoot(container);
const t = (key: string) => key;
const source = {};
let latest: Message[] = [];
const user = baseBlock({ id: 'user', kind: 'user', text: 'hello' });
const tail = baseBlock({
id: kind,
kind,
text: 'a',
streaming: true,
serverTimestamp: 1_001,
});
function Consumer({
blocks,
summary,
}: {
blocks: DaemonTranscriptBlock[];
summary: DaemonTranscriptBlockChangeSummary;
}) {
latest = useMessagesFromBlocks(t, blocks, summary);
return null;
}
adapterSpies.project.mockClear();
await act(async () =>
root.render(
createElement(Consumer, {
blocks: [user, tail],
summary: {
source,
revision: 1,
tailAppendBarrierRevision: 1,
},
}),
),
);
const firstProjection = latest;
const initialProjectionCount = adapterSpies.project.mock.calls.length;
const grownTail = {
...tail,
text: 'ab',
updatedAt: 2,
serverTimestamp: 1_002,
};
await act(async () =>
root.render(
createElement(Consumer, {
blocks: [user, grownTail],
summary: {
source,
revision: 2,
tailAppendBarrierRevision: 1,
tailBlockId: kind,
},
}),
),
);
expect(adapterSpies.project).toHaveBeenCalledTimes(
initialProjectionCount,
);
expect(latest[0]).toBe(firstProjection[0]);
expect(latest[1]).not.toBe(firstProjection[1]);
expect(latest[1]).toMatchObject({
content: 'ab',
isStreaming: true,
timestamp: 1_002,
});
const changedUser = { ...user, text: 'changed', updatedAt: 2 };
await act(async () =>
root.render(
createElement(Consumer, {
blocks: [changedUser, grownTail],
summary: {
source,
revision: 3,
tailAppendBarrierRevision: 3,
},
}),
),
);
expect(adapterSpies.project).toHaveBeenCalledTimes(
initialProjectionCount + 1,
);
expect(latest[0]).not.toBe(firstProjection[0]);
await act(async () => root.unmount());
},
);
it('does not scan transcript history for a summarized tail append', async () => {
const container = document.createElement('div');
const root = createRoot(container);
const t = (key: string) => key;
let latest: Message[] = [];
const source = {};
const history = Array.from({ length: 100 }, (_, index) =>
baseBlock({ id: `user-${index}`, kind: 'user', text: `${index}` }),
);
const tail = baseBlock({
id: 'thought',
kind: 'thought',
text: 'a',
streaming: true,
});
let indexedReads = 0;
const countReads = (blocks: DaemonTranscriptBlock[]) =>
new Proxy(blocks, {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) {
indexedReads += 1;
}
return Reflect.get(target, property, receiver);
},
});
function Consumer({
blocks,
summary,
}: {
blocks: DaemonTranscriptBlock[];
summary: DaemonTranscriptBlockChangeSummary;
}) {
useMessagesFromBlocks(t, blocks, summary);
return null;
}
await act(async () =>
root.render(
createElement(Consumer, {
blocks: countReads([...history, tail]),
summary: {
source,
revision: 1,
tailAppendBarrierRevision: 1,
},
}),
),
);
indexedReads = 0;
await act(async () =>
root.render(
createElement(Consumer, {
blocks: countReads([...history, { ...tail, text: 'ab' }]),
summary: {
source,
revision: 2,
tailAppendBarrierRevision: 1,
tailBlockId: tail.id,
},
}),
),
);
expect(indexedReads).toBeLessThan(10);
await act(async () => root.unmount());
});
it('rejects matching revisions from a different transcript store', () => {
const t = (key: string) => key;
const thought = baseBlock({
id: 'thought',
kind: 'thought',
text: 'a',
streaming: true,
});
const messages = transcriptBlocksToLocalizedMessages([thought], t);
expect(
projectStreamingTailMessages(
{
blocks: [thought],
messages,
t,
blockChangeSummary: {
source: {},
revision: 1,
tailAppendBarrierRevision: 1,
},
},
[{ ...thought, text: 'ab' }],
t,
{
source: {},
revision: 2,
tailAppendBarrierRevision: 1,
tailBlockId: thought.id,
},
),
).toBeUndefined();
});
it('reuses only valid streaming history without a block change summary', () => {
const t = (key: string) => key;
const user = baseBlock({ id: 'user', kind: 'user', text: 'hello' });
const assistant = baseBlock({
id: 'assistant',
kind: 'assistant',
text: 'a',
streaming: true,
serverTimestamp: 1_001,
});
function Consumer({ blocks }: { blocks: DaemonTranscriptBlock[] }) {
latest = useMessagesFromBlocks(t, blocks);
const messages = transcriptBlocksToLocalizedMessages([user, assistant], t);
const projected = projectStreamingTailMessages(
{ blocks: [user, assistant], messages, t },
[user, { ...assistant, text: 'ab' }],
t,
);
expect(projected?.[0]).toBe(messages[0]);
expect(projected?.[1]).not.toBe(messages[1]);
expect(projected?.[1]).toMatchObject({ content: 'ab' });
expect(
projectStreamingTailMessages(
{ blocks: [user, assistant], messages, t },
[
{ ...user, text: 'changed' },
{ ...assistant, text: 'ab' },
],
t,
),
).toBeUndefined();
expect(
projectStreamingTailMessages(
{ blocks: [user, assistant], messages, t },
[user, { ...assistant, text: 'replacement' }],
t,
),
).toBeUndefined();
});
it('falls back when an insight marker spans the appended-text boundary', async () => {
const container = document.createElement('div');
const root = createRoot(container);
const t = (key: string) => key;
const source = {};
const thought = baseBlock({
id: 'thought',
kind: 'thought',
text: 'prefix "insi',
streaming: true,
});
function Consumer({
blocks,
summary,
}: {
blocks: DaemonTranscriptBlock[];
summary: DaemonTranscriptBlockChangeSummary;
}) {
useMessagesFromBlocks(t, blocks, summary);
return null;
}
await act(async () =>
root.render(createElement(Consumer, { blocks: [user, assistant] })),
);
const firstProjection = latest;
const grownAssistant = {
...assistant,
text: 'ab',
updatedAt: 2,
serverTimestamp: 1_002,
};
await act(async () =>
root.render(createElement(Consumer, { blocks: [user, grownAssistant] })),
);
expect(latest[0]).toBe(firstProjection[0]);
expect(latest[1]).not.toBe(firstProjection[1]);
expect(latest[1]).toMatchObject({ content: 'ab', isStreaming: true });
const changedUser = { ...user, text: 'changed', updatedAt: 2 };
await act(async () =>
root.render(
createElement(Consumer, { blocks: [changedUser, grownAssistant] }),
createElement(Consumer, {
blocks: [thought],
summary: {
source,
revision: 1,
tailAppendBarrierRevision: 1,
},
}),
),
);
adapterSpies.project.mockClear();
await act(async () =>
root.render(
createElement(Consumer, {
blocks: [{ ...thought, text: 'prefix "insight_progress":{}' }],
summary: {
source,
revision: 2,
tailAppendBarrierRevision: 1,
tailBlockId: thought.id,
},
}),
),
);
expect(latest[0]).not.toBe(firstProjection[0]);
expect(adapterSpies.project).toHaveBeenCalledOnce();
await act(async () => root.unmount());
});
it('falls back when a later append completes an insight message', () => {
const t = (key: string) => key;
const assistant = baseBlock({
id: 'assistant',
kind: 'assistant',
text: '{"insight_progress":{"stage":"scanning","progress":10}',
streaming: true,
});
const completedAssistant = {
...assistant,
text: `${assistant.text}}`,
};
const messages = transcriptBlocksToLocalizedMessages([assistant], t);
const source = {};
expect(
projectStreamingTailMessages(
{
blocks: [assistant],
messages,
t,
blockChangeSummary: {
source,
revision: 1,
tailAppendBarrierRevision: 1,
},
},
[completedAssistant],
t,
{
source,
revision: 2,
tailAppendBarrierRevision: 1,
tailBlockId: assistant.id,
},
),
).toBeUndefined();
expect(
projectStreamingTailMessages(
{ blocks: [assistant], messages, t },
[completedAssistant],
t,
),
).toBeUndefined();
expect(
transcriptBlocksToLocalizedMessages([completedAssistant], t),
).toMatchObject([{ role: 'insight_progress' }]);
});
it.each([undefined, ''])(
'falls back safely when a streaming block has empty text (%j)',
async (text) => {
@@ -229,12 +536,23 @@ function deferred<T>() {
return { promise, resolve, reject };
}
function mountStatusConsumer(options: { allTools?: boolean } = {}) {
function mountStatusConsumer(
options: {
allTools?: boolean;
getBlockChangeSummary?: () =>
| DaemonTranscriptBlockChangeSummary
| undefined;
} = {},
) {
const container = document.createElement('div');
const root = createRoot(container);
const t = (key: string) => key;
function Consumer() {
const messages = useMessages(t);
const messages = useMessagesFromBlocks(
t,
hookState.blocks,
options.getBlockChangeSummary?.(),
);
const status = options.allTools
? messages
.flatMap((message) =>
@@ -257,6 +575,77 @@ function mountStatusConsumer(options: { allTools?: boolean } = {}) {
}
describe('background agent task reconciliation', () => {
it('reuses reconciled history across a streaming thought tail append', async () => {
const container = document.createElement('div');
const root = createRoot(container);
const t = (key: string) => key;
const source = {};
const agent = backgroundAgentBlock('agent-call');
const tail = baseBlock({
id: 'thought',
kind: 'thought',
text: 'a',
streaming: true,
});
let latest: Message[] = [];
hookState.connection.status = 'connected';
hookState.resolveSubagentSession.mockReset();
hookState.resolveSubagentSession.mockResolvedValue(
backgroundAgentResolution('completed'),
);
function Consumer({
blocks,
summary,
}: {
blocks: DaemonTranscriptBlock[];
summary: DaemonTranscriptBlockChangeSummary;
}) {
latest = useMessagesFromBlocks(t, blocks, summary);
return null;
}
await act(async () =>
root.render(
createElement(Consumer, {
blocks: [agent, tail],
summary: {
source,
revision: 1,
tailAppendBarrierRevision: 1,
},
}),
),
);
await vi.waitFor(() =>
expect(latest[0]).toMatchObject({
role: 'tool_group',
tools: [{ status: 'completed' }],
}),
);
adapterSpies.project.mockClear();
await act(async () =>
root.render(
createElement(Consumer, {
blocks: [agent, { ...tail, text: 'ab' }],
summary: {
source,
revision: 2,
tailAppendBarrierRevision: 1,
tailBlockId: tail.id,
},
}),
),
);
expect(adapterSpies.project).not.toHaveBeenCalled();
expect(latest).toMatchObject([
{ role: 'tool_group', tools: [{ status: 'completed' }] },
{ role: 'thinking', content: 'ab' },
]);
await act(async () => root.unmount());
});
it('uses terminal agent notifications as a reconciliation trigger without requiring toolUseId', () => {
expect(
getBackgroundAgentNotificationKey([
@@ -833,6 +1222,12 @@ describe('background agent task reconciliation', () => {
it('resets accumulated missing-agent misses when an approval engages', async () => {
vi.useFakeTimers();
const source = {};
let summary: DaemonTranscriptBlockChangeSummary = {
source,
revision: 1,
tailAppendBarrierRevision: 1,
};
// Phase 1: no permission yet — one probe fires and 404s (miss 1).
hookState.blocks = [
baseBlock({
@@ -854,7 +1249,9 @@ describe('background agent task reconciliation', () => {
'not found',
),
);
const { container, render, unmount } = mountStatusConsumer();
const { container, render, unmount } = mountStatusConsumer({
getBlockChangeSummary: () => summary,
});
await act(async () => render());
await vi.waitFor(() =>
@@ -883,6 +1280,11 @@ describe('background agent task reconciliation', () => {
}),
hookState.blocks[0],
];
summary = {
source,
revision: 2,
tailAppendBarrierRevision: 2,
};
await act(async () => render());
expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(1);
@@ -896,6 +1298,11 @@ describe('background agent task reconciliation', () => {
},
hookState.blocks[1],
];
summary = {
source,
revision: 3,
tailAppendBarrierRevision: 3,
};
await act(async () => render());
await vi.waitFor(() =>
expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(2),
+197 -44
View File
@@ -4,6 +4,7 @@ import {
isSessionLevelNotFound,
isSubagentSessionNotFound,
type DaemonTranscriptBlock,
type DaemonTranscriptBlockChangeSummary,
} from '@qwen-code/sdk/daemon';
import {
useConnection,
@@ -38,11 +39,24 @@ const MISSING_BACKGROUND_AGENT_GRACE_MISSES = 2;
// messages, so prefix identity reuse is unsafe once its marker appears.
const INSIGHT_CONTENT_MARKER = '"insight_';
interface MessageProjection {
blocks: readonly DaemonTranscriptBlock[];
messages: Message[];
t: Translator;
blockChangeSummary?: DaemonTranscriptBlockChangeSummary;
}
export interface BackgroundAgentResolution {
status: string;
durationMs?: number;
}
interface CommittedMessageProjection extends MessageProjection {
connectionSessionId?: string;
resolutionSessionId?: string;
resolutions?: ReadonlyMap<string, BackgroundAgentResolution>;
}
interface ReconciliationRound {
resolutions: Map<string, BackgroundAgentResolution>;
errors: ReadonlyArray<{ callId: string; error: unknown }>;
@@ -65,13 +79,7 @@ export function transcriptBlocksToLocalizedMessages(
}
function reuseUnchangedProjectedPrefix(
previous:
| {
blocks: readonly DaemonTranscriptBlock[];
messages: Message[];
t: Translator;
}
| undefined,
previous: MessageProjection | undefined,
blocks: readonly DaemonTranscriptBlock[],
messages: Message[],
t: Translator,
@@ -137,6 +145,84 @@ function reuseUnchangedProjectedPrefix(
return result;
}
export function projectStreamingTailMessages(
previous: MessageProjection | undefined,
blocks: readonly DaemonTranscriptBlock[],
t: Translator,
blockChangeSummary?: DaemonTranscriptBlockChangeSummary,
): Message[] | undefined {
if (
!previous ||
previous.t !== t ||
previous.blocks.length !== blocks.length ||
blocks.length === 0 ||
previous.messages.length === 0
) {
return undefined;
}
const previousSummary = previous.blockChangeSummary;
let summaryProvesTailAppend = false;
if (previousSummary && blockChangeSummary) {
if (
blockChangeSummary.source !== previousSummary.source ||
blockChangeSummary.revision <= previousSummary.revision ||
blockChangeSummary.tailAppendBarrierRevision !==
previousSummary.tailAppendBarrierRevision
) {
return undefined;
}
summaryProvesTailAppend = true;
} else {
for (let i = 0; i < blocks.length - 1; i += 1) {
if (previous.blocks[i] !== blocks[i]) return undefined;
}
}
const before = previous.blocks[blocks.length - 1];
const after = blocks[blocks.length - 1];
const previousTail = previous.messages[previous.messages.length - 1];
if (
(before.kind !== 'assistant' && before.kind !== 'thought') ||
after.kind !== before.kind ||
before.id !== after.id ||
(blockChangeSummary !== undefined &&
after.id !== blockChangeSummary.tailBlockId) ||
before.streaming !== true ||
after.streaming !== true ||
before.parentToolCallId !== undefined ||
after.parentToolCallId !== undefined ||
before.meta !== after.meta ||
before.usage !== after.usage ||
before.branchRecordId !== after.branchRecordId ||
before.clientReceivedAt !== after.clientReceivedAt ||
before.promptId !== after.promptId ||
before.sourceRecordIds !== after.sourceRecordIds ||
typeof before.text !== 'string' ||
typeof after.text !== 'string' ||
after.text.length < before.text.length ||
(!summaryProvesTailAppend && !after.text.startsWith(before.text)) ||
after.text.includes(INSIGHT_CONTENT_MARKER) ||
(previousTail.role !== 'assistant' && previousTail.role !== 'thinking') ||
previousTail.isStreaming !== true ||
(after.kind === 'assistant') !== (previousTail.role === 'assistant')
) {
return undefined;
}
const messages = previous.messages.slice();
const appendedText = after.text.slice(before.text.length);
messages[messages.length - 1] = {
...previousTail,
content: previousTail.content + appendedText,
isStreaming: true,
...(previousTail.id === after.id
? { timestamp: after.serverTimestamp ?? after.clientReceivedAt }
: {}),
};
return messages;
}
function isTerminalBackgroundAgentStatus(status: string): boolean {
return (
status === 'completed' ||
@@ -286,35 +372,50 @@ export function reconcileBackgroundAgentResolutions(
export function useMessagesFromBlocks(
t: Translator,
blocks: readonly DaemonTranscriptBlock[],
blockChangeSummary?: DaemonTranscriptBlockChangeSummary,
): Message[] {
const workspace = useWorkspace();
const connection = useConnection();
const previousProjectionRef = useRef<
| {
blocks: readonly DaemonTranscriptBlock[];
messages: Message[];
t: Translator;
}
| undefined
>(undefined);
const messages = useMemo(
() =>
reuseUnchangedProjectedPrefix(
previousProjectionRef.current,
blocks,
transcriptBlocksToLocalizedMessages(blocks, t),
t,
),
[blocks, t],
);
useLayoutEffect(() => {
previousProjectionRef.current = { blocks, messages, t };
}, [blocks, messages, t]);
const [resolutionSnapshot, setResolutionSnapshot] = useState<{
sessionId: string;
resolutions: ReadonlyMap<string, BackgroundAgentResolution>;
}>();
const previousProjectionRef = useRef<CommittedMessageProjection | undefined>(
undefined,
);
const projection = useMemo(() => {
const previous = previousProjectionRef.current;
const streamingTailMessages = projectStreamingTailMessages(
previous,
blocks,
t,
blockChangeSummary,
);
return {
previous,
reusedStreamingTail: streamingTailMessages !== undefined,
messages:
streamingTailMessages ??
reuseUnchangedProjectedPrefix(
previous?.resolutions === undefined ? previous : undefined,
blocks,
transcriptBlocksToLocalizedMessages(blocks, t),
t,
),
};
}, [blockChangeSummary, blocks, t]);
const messages = projection.messages;
const reconciledMessages = useMemo(() => {
const previous = projection.previous;
if (
projection.reusedStreamingTail &&
previous !== undefined &&
previous.connectionSessionId === connection.sessionId &&
previous.resolutionSessionId === resolutionSnapshot?.sessionId &&
previous.resolutions === resolutionSnapshot?.resolutions
) {
return messages;
}
if (
!resolutionSnapshot ||
resolutionSnapshot.sessionId !== connection.sessionId
@@ -325,22 +426,74 @@ export function useMessagesFromBlocks(
messages,
resolutionSnapshot.resolutions,
);
}, [connection.sessionId, messages, resolutionSnapshot]);
const pendingBackgroundAgentKey = useMemo(
() => getPendingBackgroundAgentKey(reconciledMessages),
[reconciledMessages],
);
// A stable primitive key for the effect dependency: the Set's identity
// changes on every transcript delta, which would re-run the reconciliation
// effect on each streamed update and defeat the retry backoff.
const pendingPermissionKey = useMemo(
() => [...getPendingPermissionCallIds(blocks)].sort().join('|'),
[blocks],
);
const backgroundAgentNotificationKey = useMemo(
() => getBackgroundAgentNotificationKey(blocks),
[blocks],
);
}, [connection.sessionId, messages, projection, resolutionSnapshot]);
useLayoutEffect(() => {
previousProjectionRef.current = {
blocks,
messages: reconciledMessages,
t,
blockChangeSummary,
connectionSessionId: connection.sessionId,
resolutionSessionId: resolutionSnapshot?.sessionId,
resolutions: resolutionSnapshot?.resolutions,
};
}, [
blockChangeSummary,
blocks,
connection.sessionId,
reconciledMessages,
resolutionSnapshot,
t,
]);
const reconciliationKeysRef = useRef<
| {
source?: object;
barrierRevision?: number;
connectionSessionId?: string;
resolutionSessionId?: string;
resolutions?: ReadonlyMap<string, BackgroundAgentResolution>;
pendingBackgroundAgentKey: string;
pendingPermissionKey: string;
backgroundAgentNotificationKey: string;
}
| undefined
>(undefined);
const cachedReconciliationKeys = reconciliationKeysRef.current;
const canReuseReconciliationKeys =
blockChangeSummary !== undefined &&
cachedReconciliationKeys !== undefined &&
cachedReconciliationKeys.source === blockChangeSummary.source &&
cachedReconciliationKeys.barrierRevision ===
blockChangeSummary.tailAppendBarrierRevision &&
cachedReconciliationKeys.connectionSessionId === connection.sessionId &&
cachedReconciliationKeys.resolutionSessionId ===
resolutionSnapshot?.sessionId &&
cachedReconciliationKeys.resolutions === resolutionSnapshot?.resolutions;
const reconciliationKeys =
canReuseReconciliationKeys && cachedReconciliationKeys
? cachedReconciliationKeys
: {
source: blockChangeSummary?.source,
barrierRevision: blockChangeSummary?.tailAppendBarrierRevision,
connectionSessionId: connection.sessionId,
resolutionSessionId: resolutionSnapshot?.sessionId,
resolutions: resolutionSnapshot?.resolutions,
pendingBackgroundAgentKey:
getPendingBackgroundAgentKey(reconciledMessages),
pendingPermissionKey: [...getPendingPermissionCallIds(blocks)]
.sort()
.join('|'),
backgroundAgentNotificationKey:
getBackgroundAgentNotificationKey(blocks),
};
useLayoutEffect(() => {
reconciliationKeysRef.current = reconciliationKeys;
});
const {
pendingBackgroundAgentKey,
pendingPermissionKey,
backgroundAgentNotificationKey,
} = reconciliationKeys;
const [reconciliationAttempt, setReconciliationAttempt] = useState(0);
const reconciliationRequestRef = useRef<
| {