From 5f154e881c21164d6411f7c1fde8cebe31880412 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Jul 2026 18:29:22 -0500 Subject: [PATCH] perf(desktop): stop per-token sidebar + tool-row re-renders during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real render-cost wins found by inspection (no behavior change): 1. Sidebar re-rendered on every stream token. $sessionStates is republished on every message delta (tens/sec during a turn), and the derived ID computeds ($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds) allocated a fresh array each time. nanostores notifies on !==, so the whole ChatSidebar + every mounted row re-rendered per token even when the working/ attention/background set was unchanged. Return the previous array reference when the contents match → nanostores skips the notify unless the set actually changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar. 2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant` (lowercase + whitespace-collapse over the entire read_file/terminal payload) ran twice in the ToolEntry render body, so every completed tool re-normalized its whole output on every stream tick of the running message. Memoize on the view fields so it recomputes only when the tool's content changes. Both are correctness-preserving (stable refs + memoization). The CI stream scenario drives $messages directly, not the publishSessionState path, so it won't reflect #1 — verified by inspection. --- .../components/assistant-ui/tool/fallback.tsx | 12 +++-- apps/desktop/src/store/composer-status.ts | 16 ++++++- apps/desktop/src/store/session-states.ts | 47 +++++++++++++++---- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 96db51f49550..5b14de5fead5 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -348,15 +348,17 @@ function ToolEntry({ part }: ToolEntryProps) { return { body: rest.join('\n\n').trim(), summary } }, [view.detail, view.status, view.subtitle]) - const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail) + // `looksRedundant` normalizes the FULL (uncapped) detail payload — a + // read_file / terminal result can be huge. Memoize on the view fields so it + // recomputes only when the tool's content changes, not on every parent + // re-render (tool rows re-render on every stream tick of the running message). + const detailMatchesSubtitle = useMemo(() => looksRedundant(view.subtitle, view.detail), [view.subtitle, view.detail]) + const detailMatchesTitle = useMemo(() => looksRedundant(view.title, view.detail), [view.title, view.detail]) const showDetail = !view.inlineDiff && ((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || - (view.status !== 'error' && - Boolean(view.detail) && - !looksRedundant(view.title, view.detail) && - !detailMatchesSubtitle)) + (view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle)) const renderDetailAsCode = view.status !== 'error' && diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index b87cf389a1bb..dafc934d5fee 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -44,6 +44,12 @@ export const $backgroundStatusBySession = atom { const ids = new Set() @@ -59,7 +65,15 @@ export const $backgroundRunningSessionIds = computed([$backgroundStatusBySession } } - return [...ids] + const next = [...ids] + + if (next.length === backgroundRunningCache.length && next.every((id, i) => id === backgroundRunningCache[i])) { + return backgroundRunningCache + } + + backgroundRunningCache = next + + return next }) // Rows the user X-ed away. The registry keeps finished processes around for a diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 869433436bbd..e8bb58ff5ca7 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -203,17 +203,44 @@ export function clearAllSessionStates() { // are pure projections of it, not independently maintained atoms. This keeps the // data flow one-directional: gateway event → cache → $sessionStates → computed // views, eliminating the "projection atom out of sync with cache" bug class. -export const $workingSessionIds = computed($sessionStates, states => - Object.values(states) - .filter(s => s.busy && s.storedSessionId) - .map(s => s.storedSessionId!) -) +// +// CRITICAL for streaming perf: `$sessionStates` is republished on EVERY message +// delta (tens of times/sec during a turn), but the *membership* of these ID sets +// only changes on busy/needsInput edges. `computed` notifies on `!==`, so +// returning a fresh array each time would re-render the whole sidebar (and every +// row) per token. Return the PREVIOUS array reference when the contents match so +// nanostores skips the notify unless the set actually changed. +function stableIds(previous: string[], next: string[]): string[] { + if (previous.length === next.length && previous.every((id, i) => id === next[i])) { + return previous + } -export const $attentionSessionIds = computed($sessionStates, states => - Object.values(states) - .filter(s => s.needsInput && s.storedSessionId) - .map(s => s.storedSessionId!) -) + return next +} + +let workingIdsCache: string[] = [] +export const $workingSessionIds = computed($sessionStates, states => { + workingIdsCache = stableIds( + workingIdsCache, + Object.values(states) + .filter(s => s.busy && s.storedSessionId) + .map(s => s.storedSessionId!) + ) + + return workingIdsCache +}) + +let attentionIdsCache: string[] = [] +export const $attentionSessionIds = computed($sessionStates, states => { + attentionIdsCache = stableIds( + attentionIdsCache, + Object.values(states) + .filter(s => s.needsInput && s.storedSessionId) + .map(s => s.storedSessionId!) + ) + + return attentionIdsCache +}) // --------------------------------------------------------------------------- // Session tiles.