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:
Brooklyn Nicholson 2026-06-30 03:59:51 -05:00
parent 33d91029b2
commit 94d70dee54
5 changed files with 80 additions and 22 deletions

View file

@ -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

View file

@ -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
)
})
}

View file

@ -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

View 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
View file

@ -0,0 +1 @@
/Users/brooklyn/www/hermes-agent/ui-tui/node_modules