Repository navigation
perf(web-shell): optimize streaming transcript rendering #9672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -197,9 +197,18 @@ 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); | ||
| const result = trimTranscriptState(next, shareSideIndexes); | ||
| // 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. | ||
|
|
@@ -1540,6 +1549,7 @@ function createTextBlock( | |
| function cloneTranscriptState( | ||
| state: DaemonTranscriptState, | ||
| opts: DaemonTranscriptReducerOptions, | ||
| shareSideIndexes = false, | ||
| ): DaemonTranscriptState { | ||
| const next: DaemonTranscriptState = { | ||
| ...state, | ||
|
|
@@ -1560,26 +1570,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 +1628,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 +1709,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); | ||
|
ytahdn marked this conversation as resolved.
Resolved
|
||
| state.activeAssistantBlockByParent = createIndex( | ||
| state.activeAssistantBlockByParent, | ||
| ); | ||
| state.activeThoughtBlockByParent = createIndex( | ||
| state.activeThoughtBlockByParent, | ||
| ); | ||
| state.trimmedToolNotificationByCallId = createIndex( | ||
| state.trimmedToolNotificationByCallId, | ||
| ); | ||
| state.permissionBlockByRequestId = createIndex( | ||
| state.permissionBlockByRequestId, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
for (const toolCallId of Object.keys(state.toolProgress)) {
if (!liveToolCallIds.has(toolCallId)) {
delete state.toolProgress[toolCallId];
}
}In a production build ( Suggested fix: add if (sideIndexesShared) {
state.toolBlockByCallId = createIndex(state.toolBlockByCallId);
// … existing five …
state.toolProgress = createIndex(
Object.fromEntries(Object.entries(state.toolProgress).map(([k, v]) => [k, { ...v }])),
);
}
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 感谢指出。这里核对后确认不需要修改:评论中引用的
|
||
| ); | ||
| } | ||
| for (const [toolCallId, blockId] of Object.entries(state.toolBlockByCallId)) { | ||
| if (!keptIds.has(blockId)) { | ||
| state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.