From 4ac921f2c0812a719f6628dc195b9075078c0c9e Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 8 Dec 2025 14:45:28 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20perf:=20debounce=20stream=20delt?= =?UTF-8?q?a=20events=20to=20reduce=20lag=20during=20markdown=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-frequency streaming events (stream-delta, tool-call-delta, reasoning-delta) were triggering immediate state bumps and React re-renders on every token. This caused noticeable lag during fast streaming. Changes: - Add debounce mechanism (~16ms / 60fps cap) for delta event UI notifications - Data is still updated immediately in aggregator - only UI notification is debounced - Stream-end and stream-abort flush pending debounces to ensure final state visibility - No behavior change for other event types (tool-call-start/end, etc.) _Generated with mux_ --- src/browser/stores/WorkspaceStore.ts | 56 ++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 7ad9246548..0a2eb6c041 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -125,6 +125,11 @@ export class WorkspaceStore { private workspaceMetadata = new Map(); // Store metadata for name lookup private queuedMessages = new Map(); // Cached queued messages + // Debounce timers for high-frequency delta events to reduce re-renders during streaming + // Data is always updated immediately in the aggregator; only UI notification is debounced + private deltaDebounceTimers = new Map>(); + private static readonly DELTA_DEBOUNCE_MS = 16; // ~60fps cap for smooth streaming + /** * Map of event types to their handlers. This is the single source of truth for: * 1. Which events should be buffered during replay (the keys) @@ -154,7 +159,7 @@ export class WorkspaceStore { }, "stream-delta": (workspaceId, aggregator, data) => { aggregator.handleStreamDelta(data as never); - this.states.bump(workspaceId); + this.debouncedStateBump(workspaceId); }, "stream-end": (workspaceId, aggregator, data) => { const streamEndData = data as StreamEndEvent; @@ -167,6 +172,8 @@ export class WorkspaceStore { // Reset retry state on successful stream completion updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); + // Flush any pending debounced bump before final bump to avoid double-bump + this.flushPendingDebouncedBump(workspaceId); this.states.bump(workspaceId); this.checkAndBumpRecencyIfChanged(); this.finalizeUsageStats(workspaceId, streamEndData.metadata); @@ -191,6 +198,8 @@ export class WorkspaceStore { ); } + // Flush any pending debounced bump before final bump to avoid double-bump + this.flushPendingDebouncedBump(workspaceId); this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); this.finalizeUsageStats(workspaceId, streamAbortData.metadata); @@ -201,7 +210,7 @@ export class WorkspaceStore { }, "tool-call-delta": (workspaceId, aggregator, data) => { aggregator.handleToolCallDelta(data as never); - this.states.bump(workspaceId); + this.debouncedStateBump(workspaceId); }, "tool-call-end": (workspaceId, aggregator, data) => { aggregator.handleToolCallEnd(data as never); @@ -210,7 +219,7 @@ export class WorkspaceStore { }, "reasoning-delta": (workspaceId, aggregator, data) => { aggregator.handleReasoningDelta(data as never); - this.states.bump(workspaceId); + this.debouncedStateBump(workspaceId); }, "reasoning-end": (workspaceId, aggregator, data) => { aggregator.handleReasoningEnd(data as never); @@ -304,6 +313,40 @@ export class WorkspaceStore { window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { workspaceId })); } + /** + * Debounced state bump for high-frequency delta events. + * Coalesces rapid updates (stream-delta, tool-call-delta, reasoning-delta) + * into a single bump per frame (~60fps), reducing React re-renders during streaming. + * + * Data is always updated immediately in the aggregator - only UI notification is debounced. + */ + private debouncedStateBump(workspaceId: string): void { + // Skip if already scheduled + if (this.deltaDebounceTimers.has(workspaceId)) { + return; + } + + const timer = setTimeout(() => { + this.deltaDebounceTimers.delete(workspaceId); + this.states.bump(workspaceId); + }, WorkspaceStore.DELTA_DEBOUNCE_MS); + + this.deltaDebounceTimers.set(workspaceId, timer); + } + + /** + * Flush any pending debounced state bump for a workspace (without double-bumping). + * Used when immediate state visibility is needed (e.g., stream-end). + * Just clears the timer - the caller will bump() immediately after. + */ + private flushPendingDebouncedBump(workspaceId: string): void { + const timer = this.deltaDebounceTimers.get(workspaceId); + if (timer) { + clearTimeout(timer); + this.deltaDebounceTimers.delete(workspaceId); + } + } + /** * Track stream completion telemetry */ @@ -744,6 +787,13 @@ export class WorkspaceStore { // Clean up consumer manager state this.consumerManager.removeWorkspace(workspaceId); + // Clean up debounce timer to prevent stale callbacks + const timer = this.deltaDebounceTimers.get(workspaceId); + if (timer) { + clearTimeout(timer); + this.deltaDebounceTimers.delete(workspaceId); + } + // Unsubscribe from IPC const unsubscribe = this.ipcUnsubscribers.get(workspaceId); if (unsubscribe) {