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.