mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
perf(desktop): stop the statusbar re-rendering per streaming token
The statusbar subscribed to `$focusedSessionState` — a projection of `$sessionStates`, which is republished on every message delta — but reads only three fields off it. Every token therefore re-ran useStatusbarItems and rebuilt all ~9 item objects, and since StatusbarItemView was not memoized, one changed item (the running timer) re-rendered the whole bar. Adds `useStoreSelector` beside the existing `useSessionSlice`, the same narrowing idea for a scalar instead of a keyed array: subscribe to the store, but bail out unless the selected value changes. Applies it to the three fields the statusbar actually reads, and memoizes StatusbarItemView. Measured over five concurrent streaming tabs (`render-churn`): total renders 78,385 -> 21,701 (-72%) wasted renders 10,432 -> 6,112 (-41%) TooltipContent 2,304 -> 143 (-94%) StatusbarItemView 2,174 -> 0
This commit is contained in:
parent
41f2196c53
commit
41dd895e12
3 changed files with 64 additions and 8 deletions
|
|
@ -12,6 +12,7 @@ import { useI18n } from '@/i18n'
|
|||
import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||
import { revealFileInTree } from '@/store/layout'
|
||||
|
|
@ -117,19 +118,30 @@ export function useStatusbarItems({
|
|||
// tile makes the statusbar describe THAT session.
|
||||
const focusedStoredSessionId = useStore($focusedStoredSessionId)
|
||||
const focusedRuntimeId = useStore($focusedRuntimeId)
|
||||
const focusedState = useStore($focusedSessionState)
|
||||
// `$focusedSessionState` is a projection of `$sessionStates`, which is
|
||||
// republished on EVERY message delta — tens of times a second during a turn.
|
||||
// Only three fields are read off it here, so subscribing to the whole object
|
||||
// re-ran this hook (and re-created all ~9 statusbar items) per token. Select
|
||||
// each field individually so an unchanged readout bails out instead.
|
||||
const focusedBusy = useStoreSelector($focusedSessionState, state => Boolean(state?.busy))
|
||||
const focusedTurnStartedAt = useStoreSelector($focusedSessionState, state => state?.turnStartedAt ?? null)
|
||||
// `usage` is an object, so it can't be compared as a scalar. It IS however
|
||||
// replaced wholesale rather than mutated, and only changes when the backend
|
||||
// reports new usage — far rarer than a delta — so its reference is a valid
|
||||
// bail-out key on its own.
|
||||
const focusedUsage = useStoreSelector($focusedSessionState, state => state?.usage ?? null)
|
||||
const sessions = useStore($sessions)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
|
||||
|
||||
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
|
||||
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
|
||||
const busy = primaryFocused ? primaryBusy : focusedBusy
|
||||
|
||||
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
|
||||
// a fresh `{...}` each render would bust the usage-label memos below.
|
||||
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
|
||||
const currentUsage = primaryFocused ? primaryUsage : (focusedUsage ?? EMPTY_USAGE)
|
||||
|
||||
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
|
||||
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : focusedTurnStartedAt
|
||||
|
||||
// A tile's session-start comes from its stored row (the cache only knows
|
||||
// runtime state); seconds → ms.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ComponentProps, type ReactNode, useState } from 'react'
|
||||
import { type ComponentProps, memo, type ReactNode, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
|
@ -95,7 +95,19 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
)
|
||||
}
|
||||
|
||||
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
||||
/** Memoized: `useStatusbarItems` rebuilds the item array whenever ANY of its
|
||||
* inputs change, but each individual item object is usually identical across
|
||||
* those rebuilds. Without this, one changed item (the running timer, say)
|
||||
* re-rendered every other item in the bar — measured at 1,446 wasted renders
|
||||
* of 2,174 during a five-tab streaming run. `navigate` is stable for the
|
||||
* router's lifetime, so item identity is the only real input. */
|
||||
const StatusbarItemView = memo(function StatusbarItemView({
|
||||
item,
|
||||
navigate
|
||||
}: {
|
||||
item: StatusbarItem
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
|
||||
|
|
@ -230,4 +242,4 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useSyncExternalStore } from 'react'
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
interface SliceStore<T> {
|
||||
get(): Record<string, T[] | undefined>
|
||||
|
|
@ -29,3 +29,35 @@ export function useSessionSlice<T>(store: SliceStore<T>, key: string | null): T[
|
|||
() => (key ? (store.get()[key] ?? (EMPTY as unknown as T[])) : (EMPTY as unknown as T[]))
|
||||
)
|
||||
}
|
||||
|
||||
interface ReadableStore<T> {
|
||||
get(): T
|
||||
listen(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a SCALAR derived from a hot store, re-rendering only when that
|
||||
* scalar changes by `Object.is` — not on every write to the store it came from.
|
||||
*
|
||||
* `useStore($someHotStore)` bails out on reference equality alone, so a store
|
||||
* republished per streaming token re-renders every consumer even when the two
|
||||
* or three fields they actually read are identical. `$sessionStates` is the
|
||||
* canonical case: it is republished on every message delta, so a component
|
||||
* reading only `busy` or `turnStartedAt` off it pays for the whole transcript's
|
||||
* churn.
|
||||
*
|
||||
* `select` must return a PRIMITIVE (or a referentially stable value). Returning
|
||||
* a fresh object or array defeats the bail-out and reintroduces the churn this
|
||||
* exists to remove — derive one scalar per call instead.
|
||||
*/
|
||||
export function useStoreSelector<T, S>(store: ReadableStore<T>, select: (value: T) => S): S {
|
||||
// `select` is read through a ref so an inline arrow at the call site doesn't
|
||||
// resubscribe on every render; useSyncExternalStore re-reads the snapshot on
|
||||
// each render anyway, so the latest selector is always applied.
|
||||
const selectRef = useRef(select)
|
||||
selectRef.current = select
|
||||
|
||||
const subscribe = useCallback((onChange: () => void) => store.listen(onChange), [store])
|
||||
|
||||
return useSyncExternalStore(subscribe, () => selectRef.current(store.get()))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue