mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
+111
@@ -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),
|
||||
|
||||
@@ -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<
|
||||
| {
|
||||
|
||||
Reference in new issue
Block a user