mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
perf(desktop): composer typing no longer re-renders ChatBar (imperative draft sync)
The real composer state-engine fix. ChatBar subscribed to the full draft string (`useAuiState(s => s.composer.text)`), so every keystroke re-rendered the whole ~2k-line component even though the contentEditable DOM already owns the text. Replace that with: - an imperative composer-runtime subscription (useComposerRuntime().subscribe) that mirrors text into draftRef, repaints the editor ONLY on external changes (clear/restore/insert; the focused editor is the source otherwise), and drives the debounced per-session stash — all without a React render. This folds the old `[draft]` sync effect and the `[draft]` debounced-stash effect into one place keyed off the runtime, surviving core rebinds via the effect dep. - coarse edge selectors (hasText / isHelpHint / isSteerableText, plus isEmpty / hasHardNewline in useComposerMetrics) for the chrome, which only re-render when an edge actually flips. Net: typing within a line does zero ChatBar re-renders / style invalidations; work happens only on real edges. Behaviour-preserving — draftRef + editor are already kept current by every mutation path; verified by the composer DOM repro tests (enter-submit, IME composition, slash-nav) + text-guard.
This commit is contained in:
parent
e0a78336c1
commit
00694b935f
2 changed files with 78 additions and 53 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
|
|
@ -11,7 +12,6 @@ interface UseComposerMetricsArgs {
|
|||
composerRef: RefObject<HTMLFormElement | null>
|
||||
composerSurfaceRef: RefObject<HTMLDivElement | null>
|
||||
editorRef: RefObject<HTMLDivElement | null>
|
||||
draft: string
|
||||
poppedOut: boolean
|
||||
}
|
||||
|
||||
|
|
@ -23,17 +23,19 @@ interface UseComposerMetricsArgs {
|
|||
* tree's computed style, and `tight` only flips when it crosses the breakpoint.
|
||||
* Returns `stacked` (the only value the render needs).
|
||||
*/
|
||||
export function useComposerMetrics({
|
||||
composerRef,
|
||||
composerSurfaceRef,
|
||||
editorRef,
|
||||
draft,
|
||||
poppedOut
|
||||
}: UseComposerMetricsArgs): { stacked: boolean } {
|
||||
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
|
||||
stacked: boolean
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [tight, setTight] = useState(false)
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
// Edge signals, not the live text: these only re-render when emptiness / the
|
||||
// presence of a non-trailing newline actually flips, so typing within a line
|
||||
// costs nothing here.
|
||||
const isEmpty = useAuiState(s => s.composer.text.length === 0)
|
||||
const hasHardNewline = useAuiState(s => s.composer.text.trimEnd().includes('\n'))
|
||||
|
||||
// Expansion (input on its own full-width row, controls below) is driven by
|
||||
// the editor's *actual* rendered height via the ResizeObserver in
|
||||
// syncComposerMetrics — it only fires when the text genuinely wraps to a
|
||||
|
|
@ -42,7 +44,7 @@ export function useComposerMetrics({
|
|||
// can't: an explicit newline (expand before layout settles) and an emptied
|
||||
// draft (collapse back). We never read scrollHeight per keystroke.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
if (isEmpty) {
|
||||
setExpanded(false)
|
||||
|
||||
return
|
||||
|
|
@ -55,10 +57,10 @@ export function useComposerMetrics({
|
|||
// Only a non-trailing newline forces an immediate expand. A trailing newline
|
||||
// (or phantom \n from contenteditable junk) is left to the ResizeObserver,
|
||||
// which expands only when the editor's real height actually grows.
|
||||
if (draft.trimEnd().includes('\n')) {
|
||||
if (hasHardNewline) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
}, [expanded, hasHardNewline, isEmpty])
|
||||
|
||||
// Bucket measured heights so we only invalidate the global CSS var when
|
||||
// the size crosses a meaningful threshold. Without bucketing, the editor
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { ComposerPrimitive, useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
|
|
@ -155,7 +155,21 @@ export function ChatBar({
|
|||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const composerRuntime = useComposerRuntime()
|
||||
|
||||
// Per-keystroke text lives in the contentEditable DOM + draftRef (kept current
|
||||
// imperatively by every mutation path + the composer subscription below), NOT
|
||||
// in a React subscription — so typing never re-renders this ~2k-line component.
|
||||
// Only the coarse *edges* the chrome reacts to are subscribed, and they flip
|
||||
// rarely (empty↔non-empty, the `?` help sigil, steerable-vs-slash).
|
||||
const hasText = useAuiState(s => s.composer.text.trim().length > 0)
|
||||
const isHelpHint = useAuiState(s => s.composer.text === '?')
|
||||
|
||||
const isSteerableText = useAuiState(s => {
|
||||
const trimmed = s.composer.text.trim()
|
||||
|
||||
return trimmed.length > 0 && !SLASH_COMMAND_RE.test(trimmed)
|
||||
})
|
||||
|
||||
// assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not
|
||||
// available" when the thread's composer core isn't bound yet — and unlike the
|
||||
|
|
@ -254,10 +268,13 @@ export function ChatBar({
|
|||
position: popoutPosition
|
||||
})
|
||||
|
||||
const draftRef = useRef(draft)
|
||||
const draftRef = useRef('')
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const draftPersistTimerRef = useRef<number | undefined>(undefined)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const sessionIdRef = useRef(sessionId)
|
||||
sessionIdRef.current = sessionId
|
||||
const prevQueueKeyRef = useRef(activeQueueSessionKey)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Per-entry auto-drain failure counts; bounds retries so a persistent 404
|
||||
|
|
@ -281,19 +298,17 @@ export function ChatBar({
|
|||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||
|
||||
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, draft, editorRef, poppedOut })
|
||||
const trimmedDraft = draft.trim()
|
||||
const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0
|
||||
const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer =
|
||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
const showHelpHint = draft === '?'
|
||||
const showHelpHint = isHelpHint
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
|
|
@ -407,23 +422,46 @@ export function ChatBar({
|
|||
}
|
||||
}, [appendExternalText, inputDisabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
// to `$composerDraft` per keystroke any more — nobody outside the composer
|
||||
// subscribes to it (verified by grep), and the round-trip
|
||||
// `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per-
|
||||
// keystroke critical path. `reconcileComposerTerminalSelections` only
|
||||
// matters when the draft is submitted; we now call it from the submit
|
||||
// path instead.
|
||||
// Imperative draft sync — the spine of the composer's "work only when work is
|
||||
// to be performed" model. Subscribing to the composer runtime directly (rather
|
||||
// than `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out
|
||||
// of React entirely, so typing never re-renders this component. On each change
|
||||
// we (1) mirror the text into draftRef for the out-of-render callers, (2)
|
||||
// repaint the editor only when the change came from OUTSIDE it — a programmatic
|
||||
// clear/restore/insert; while the editor is focused it IS the source of truth —
|
||||
// and (3) schedule the debounced per-session stash. Browsing history / editing
|
||||
// a queued prompt suppress the stash so recalled text never clobbers the draft.
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
const sync = () => {
|
||||
const text = composerRuntime.getState().text
|
||||
draftRef.current = text
|
||||
|
||||
const editor = editorRef.current
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
|
||||
renderComposerContents(editor, draft)
|
||||
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
|
||||
renderComposerContents(editor, text)
|
||||
}
|
||||
|
||||
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const scope = activeQueueSessionKeyRef.current
|
||||
pendingDraftPersistRef.current = { scope, text }
|
||||
window.clearTimeout(draftPersistTimerRef.current)
|
||||
draftPersistTimerRef.current = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(scope, text)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const unsubscribe = composerRuntime.subscribe(sync)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
window.clearTimeout(draftPersistTimerRef.current)
|
||||
}
|
||||
}, [composerRuntime])
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
|
|
@ -1321,23 +1359,6 @@ export function ChatBar({
|
|||
}
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced stash into the active scope. Skipped while browsing history or
|
||||
// editing a queued prompt — recalled text must not clobber the real draft.
|
||||
useEffect(() => {
|
||||
if (isBrowsingHistory(sessionId) || queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(activeQueueSessionKey, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||
|
||||
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||
// inside the debounce window would drop trailing keystrokes without this.
|
||||
useEffect(() => {
|
||||
|
|
@ -1439,11 +1460,13 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
const queueCurrentDraft = useCallback(() => {
|
||||
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) {
|
||||
const text = draftRef.current
|
||||
|
||||
if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) {
|
||||
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -1452,7 +1475,7 @@ export function ChatBar({
|
|||
triggerHaptic('selection')
|
||||
|
||||
return true
|
||||
}, [activeQueueSessionKey, attachments, clearDraft, draft])
|
||||
}, [activeQueueSessionKey, attachments, clearDraft])
|
||||
|
||||
// Steer the live turn (nudge without interrupting). Clears the draft up front
|
||||
// for snappy feedback; if the gateway rejects (no live tool window) the words
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue