perf(desktop): stop per-token sidebar + tool-row re-renders during streaming

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-19 18:29:22 -05:00
parent 8142331616
commit 5f154e881c
3 changed files with 59 additions and 16 deletions

View file

@ -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' &&

View file

@ -44,6 +44,12 @@ export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem
// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events
// and process.list both speak that); the sidebar row knows only the STORED id.
// $sessionStates bridges the two: runtime id → state.storedSessionId.
// Perf: this recomputes whenever $sessionStates changes (every message delta,
// tens/sec during a turn), but the background-running set changes rarely.
// Returning a fresh array each time re-renders every sidebar row that reads it;
// hand back the previous reference when the set is unchanged so nanostores skips
// the notify.
let backgroundRunningCache: string[] = []
export const $backgroundRunningSessionIds = computed([$backgroundStatusBySession, $sessionStates], (bg, states) => {
const ids = new Set<string>()
@ -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

View file

@ -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.