mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
commit
04113b5a89
5 changed files with 46 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
7
apps/desktop/src/lib/stable-array.ts
Normal file
7
apps/desktop/src/lib/stable-array.ts
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue