mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
perf(desktop): stop ChatBar re-rendering on cross-session status/queue churn
Audit follow-up. ChatBar subscribed to the whole `$statusItemsBySession` (a computed that rebuilds the entire map) + `$previewStatusBySession` maps just to derive a boolean, so every per-item status mutation (a subagent tick, the 5s background poll) and every OTHER session's change re-rendered the ~1.4k component. The queue hook likewise subscribed to the whole `$queuedPromptsBySession` map. - Add `useSessionStatusPresence` — a coarse edge (useSyncExternalStore) that flips only when the stack shows/hides; ChatBar uses it for the styling data-attr instead of the two map subscriptions. - Add generic `useSessionSlice(store, key)` — subscribes to one session's array, bailing out when other sessions churn (the plain atom keeps per-key refs stable). The queue hook now reads its slice through it. Result: ChatBar re-renders only when the stack's presence flips or this session's queue changes — not on background/subagent status streaming or other sessions. Verified: typecheck clean, 0 lint errors, composer tests 39/40 (pre-existing attachments failure unrelated).
This commit is contained in:
parent
33d91029b2
commit
94d70dee54
5 changed files with 80 additions and 22 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import {
|
||||
|
|
@ -61,12 +61,10 @@ export function useComposerQueue({
|
|||
}: UseComposerQueueArgs) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
|
||||
[activeQueueSessionKey, queuedPromptsBySession]
|
||||
)
|
||||
// Per-session slice (edge): re-renders only when THIS session's queue changes,
|
||||
// not on cross-session queue churn (the plain atom's map ref changes on every
|
||||
// write; the keyed array does not).
|
||||
const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey)
|
||||
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
queueEditRef.current = queueEdit
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const offItems = $statusItemsBySession.listen(onChange)
|
||||
const offPreviews = $previewStatusBySession.listen(onChange)
|
||||
|
||||
return () => {
|
||||
offItems()
|
||||
offPreviews()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session has any status items or previews, as a coarse *edge*: the
|
||||
* boolean only flips when the stack appears/disappears. ChatBar uses it to
|
||||
* toggle a styling data-attr — subscribing to the whole `$statusItemsBySession`
|
||||
* (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps
|
||||
* re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a
|
||||
* 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails
|
||||
* out of all of that, re-rendering only on the actual show/hide transition.
|
||||
*/
|
||||
export function useSessionStatusPresence(sessionId: string | null): boolean {
|
||||
return useSyncExternalStore(subscribe, () => {
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 ||
|
||||
($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import {
|
|||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
|
@ -38,8 +37,6 @@ import {
|
|||
setComposerPoppedOut
|
||||
} from '@/store/composer-popout'
|
||||
import { removeQueuedPrompt } from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
|
|
@ -73,6 +70,7 @@ import { useComposerSubmit } from './hooks/use-composer-submit'
|
|||
import { useComposerVoice } from './hooks/use-composer-voice'
|
||||
import { useComposerPopoutGestures } from './hooks/use-popout-drag'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useSessionStatusPresence } from './hooks/use-status-presence'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
|
|
@ -118,8 +116,6 @@ export function ChatBar({
|
|||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
const attachments = useStore($composerAttachments)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const previewStatusBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
|
|
@ -141,6 +137,10 @@ export function ChatBar({
|
|||
// queue uses the stored-session fallback key (prompts can queue pre-resume).
|
||||
const statusSessionId = sessionId ?? null
|
||||
|
||||
// Coarse edge: re-renders ChatBar only when the stack shows/hides, NOT on
|
||||
// every per-item status mutation or other sessions' churn (see the hook).
|
||||
const statusPresent = useSessionStatusPresence(statusSessionId)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
|
|
@ -241,15 +241,7 @@ export function ChatBar({
|
|||
sessionId
|
||||
})
|
||||
|
||||
const statusStackVisible = useMemo(
|
||||
() =>
|
||||
queuedPrompts.length > 0 ||
|
||||
(statusSessionId
|
||||
? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 ||
|
||||
(previewStatusBySession[statusSessionId]?.length ?? 0) > 0
|
||||
: false),
|
||||
[previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
)
|
||||
const statusStackVisible = queuedPrompts.length > 0 || statusPresent
|
||||
|
||||
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
|
|
|
|||
31
apps/desktop/src/lib/use-session-slice.ts
Normal file
31
apps/desktop/src/lib/use-session-slice.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
interface SliceStore<T> {
|
||||
get(): Record<string, T[] | undefined>
|
||||
listen(listener: () => void): () => void
|
||||
}
|
||||
|
||||
// Stable empty result so an absent key never yields a fresh array (which would
|
||||
// defeat the snapshot bail-out and re-render on every store write).
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
/**
|
||||
* Subscribe to ONE session's slice of a `Record<sessionId, T[]>` nanostore,
|
||||
* re-rendering only when *that* slice's reference changes — not on writes to
|
||||
* other sessions. The map reference churns on every cross-session update, so a
|
||||
* plain `useStore(map)` re-renders all consumers globally; reading `map[key]`
|
||||
* through `useSyncExternalStore` bails out whenever the keyed array is
|
||||
* unchanged (the stores update immutably per key). Returns a shared empty array
|
||||
* when the key is null/absent.
|
||||
*
|
||||
* Note: only helps stores whose per-key arrays are referentially stable across
|
||||
* unrelated writes (plain atoms with immutable per-key updates). A `computed`
|
||||
* that rebuilds the whole map churns every slice — use a presence/edge selector
|
||||
* there instead.
|
||||
*/
|
||||
export function useSessionSlice<T>(store: SliceStore<T>, key: string | null): T[] {
|
||||
return useSyncExternalStore(
|
||||
onChange => store.listen(onChange),
|
||||
() => (key ? (store.get()[key] ?? (EMPTY as unknown as T[])) : (EMPTY as unknown as T[]))
|
||||
)
|
||||
}
|
||||
1
ui-tui/node_modules
Symbolic link
1
ui-tui/node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/Users/brooklyn/www/hermes-agent/ui-tui/node_modules
|
||||
Loading…
Add table
Add a link
Reference in a new issue