mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
8142331616
commit
5f154e881c
3 changed files with 59 additions and 16 deletions
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue