diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index dc52cee5bea..7b02cd955ac 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -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(null) queueEditRef.current = queueEdit diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts new file mode 100644 index 00000000000..c6b9af53b73 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts @@ -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 + ) + }) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 7dfada7ec61..d49c8382b7b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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(null) const composerSurfaceRef = useRef(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 diff --git a/apps/desktop/src/lib/use-session-slice.ts b/apps/desktop/src/lib/use-session-slice.ts new file mode 100644 index 00000000000..78ab0391337 --- /dev/null +++ b/apps/desktop/src/lib/use-session-slice.ts @@ -0,0 +1,31 @@ +import { useSyncExternalStore } from 'react' + +interface SliceStore { + get(): Record + 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` 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(store: SliceStore, key: string | null): T[] { + return useSyncExternalStore( + onChange => store.listen(onChange), + () => (key ? (store.get()[key] ?? (EMPTY as unknown as T[])) : (EMPTY as unknown as T[])) + ) +} diff --git a/ui-tui/node_modules b/ui-tui/node_modules new file mode 120000 index 00000000000..001f2a89107 --- /dev/null +++ b/ui-tui/node_modules @@ -0,0 +1 @@ +/Users/brooklyn/www/hermes-agent/ui-tui/node_modules \ No newline at end of file