Merge pull request #67742 from NousResearch/perf/desktop-streaming-rerenders

perf(desktop): stop per-token sidebar + tool-row re-renders during streaming
This commit is contained in:
brooklyn! 2026-07-19 21:02:48 -04:00 committed by GitHub
commit 04113b5a89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 46 additions and 25 deletions

View file

@ -716,7 +716,7 @@ export function ChatSidebar({
// session settles (its turn finished) or the window refocuses (an external
// terminal may have changed things) — only while a project is entered, and
// only the cheap per-repo `git worktree list`, never the heavy tree scan.
const prevWorkingIdsRef = useRef<string[]>(workingSessionIds)
const prevWorkingIdsRef = useRef<readonly string[]>(workingSessionIds)
useEffect(() => {
const prev = prevWorkingIdsRef.current

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

@ -0,0 +1,7 @@
/** Keep `prev`'s reference when it's element-equal to `next`, so a nanostores
* `computed` (notifies on `!==`) skips the emit when its projected list didn't
* actually change e.g. status-id sets recomputed on every stream delta.
* `next` is frozen: the ref is shared across ticks, so an in-place mutation
* would corrupt the cache fail loud instead. */
export const stableArray = <T>(prev: readonly T[], next: T[]): readonly T[] =>
prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : Object.freeze(next)

View file

@ -1,6 +1,7 @@
import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
import { stableArray } from '@/lib/stable-array'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
@ -44,22 +45,24 @@ 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: recomputes on every $sessionStates change (message deltas, tens/sec),
// but the background-running set rarely moves. `stableArray` keeps the prior
// reference when unchanged so rows reading this don't re-render per token.
let backgroundRunningIds: readonly string[] = []
export const $backgroundRunningSessionIds = computed([$backgroundStatusBySession, $sessionStates], (bg, states) => {
const ids = new Set<string>()
for (const [runtimeId, items] of Object.entries(bg)) {
if (!items.some(i => i.state === 'running')) {
continue
}
if (items.some(i => i.state === 'running')) {
const storedId = states[runtimeId]?.storedSessionId
const storedId = states[runtimeId]?.storedSessionId
if (storedId) {
ids.add(storedId)
if (storedId) {
ids.add(storedId)
}
}
}
return [...ids]
return (backgroundRunningIds = stableArray(backgroundRunningIds, [...ids]))
})
// Rows the user X-ed away. The registry keeps finished processes around for a

View file

@ -27,6 +27,7 @@ import {
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
import { stableArray } from '@/lib/stable-array'
import { readJson, writeJson } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
@ -198,21 +199,29 @@ export function clearAllSessionStates() {
$sessionStates.set({})
}
// Derived per-session status sets. `$sessionStates` already holds `busy` and
// `needsInput` for every runtime session (written by updateSessionState); these
// 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 =>
// Derived per-session status sets — pure projections of `$sessionStates` (which
// holds `busy`/`needsInput` per runtime), keeping the data flow one-directional:
// gateway event → cache → $sessionStates → computed views.
//
// Perf: `$sessionStates` is republished on EVERY message delta (tens/sec during
// a turn), but these sets only change on busy/needsInput edges. `stableArray`
// keeps the prior reference when membership is unchanged so `computed` skips the
// emit — otherwise the whole sidebar + every row re-renders per token.
const storedIds = (states: Record<string, ClientSessionState>, pred: (s: ClientSessionState) => boolean) =>
Object.values(states)
.filter(s => s.busy && s.storedSessionId)
.filter(s => pred(s) && s.storedSessionId)
.map(s => s.storedSessionId!)
let workingIds: readonly string[] = []
export const $workingSessionIds = computed(
$sessionStates,
states => (workingIds = stableArray(workingIds, storedIds(states, s => s.busy)))
)
export const $attentionSessionIds = computed($sessionStates, states =>
Object.values(states)
.filter(s => s.needsInput && s.storedSessionId)
.map(s => s.storedSessionId!)
let attentionIds: readonly string[] = []
export const $attentionSessionIds = computed(
$sessionStates,
states => (attentionIds = stableArray(attentionIds, storedIds(states, s => s.needsInput)))
)
// ---------------------------------------------------------------------------