From e0a78336c1e3d3ea2216510154430f6142f4464a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 02:58:04 -0500 Subject: [PATCH 01/12] refactor(desktop): extract composer sizing into useComposerMetrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of decomposing the ChatBar god component (composer/index.tsx). Pull the self-contained *sizing* engine — stacked/inline layout + the measured-height CSS vars the thread reads for clearance — into composer/hooks/use-composer-metrics.ts. The hook owns: the media-query `narrow`, `expanded`/`tight`, the 8px height bucketing (so per-keystroke growth never invalidates the tree's computed style), the ResizeObserver, the popout re-sync, and the CSS-var cleanup. ChatBar now just calls `useComposerMetrics(...)` and consumes `stacked`. Behaviour-preserving (no keystroke/IME/contentEditable path touched): code moved verbatim. Deliberately a low-risk first slice on the app's most fragile file; the draft/state-engine spine is the next, dogfood-heavy step (see desktop-composer-plan.md). --- .../composer/hooks/use-composer-metrics.ts | 158 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 131 +-------------- 2 files changed, 160 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts new file mode 100644 index 00000000000..c1df2f84553 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -0,0 +1,158 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { useMediaQuery } from '@/hooks/use-media-query' +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { $composerPoppedOut } from '@/store/composer-popout' +import { isSecondaryWindow } from '@/store/windows' + +import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' + +interface UseComposerMetricsArgs { + composerRef: RefObject + composerSurfaceRef: RefObject + editorRef: RefObject + draft: string + poppedOut: boolean +} + +/** + * Owns the composer's *sizing* engine: the stacked-vs-inline layout decision + * and the measured-height CSS vars the thread reads for bottom clearance. All + * work is edge-gated — the ResizeObserver only fires on real size changes, the + * height vars are 8px-bucketed so per-keystroke growth never invalidates the + * 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 } { + const [expanded, setExpanded] = useState(false) + const [tight, setTight] = useState(false) + const narrow = useMediaQuery('(max-width: 30rem)') + + // 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 + // second line, so the layout flips exactly at the wrap point rather than at + // a guessed character count. We only handle the two cases the observer + // can't: an explicit newline (expand before layout settles) and an emptied + // draft (collapse back). We never read scrollHeight per keystroke. + useEffect(() => { + if (!draft) { + setExpanded(false) + + return + } + + if (expanded) { + return + } + + // 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')) { + setExpanded(true) + } + }, [draft, expanded]) + + // Bucket measured heights so we only invalidate the global CSS var when + // the size crosses a meaningful threshold. Without bucketing, the editor + // grows ~1px per character → setProperty fires every keystroke → entire + // tree's computed style is invalidated → next paint forces a full + // recalculate-style pass. With an 8px bucket, the invalidation rate drops + // ~8× and small char-by-char typing produces no style invalidation at all + // until a wrap or row change actually happens. + const lastBucketedHeightRef = useRef(0) + const lastBucketedSurfaceHeightRef = useRef(0) + const lastTightRef = useRef(null) + + const syncComposerMetrics = useCallback(() => { + const composer = composerRef.current + + if (!composer) { + return + } + + // Floating composer is out of the thread's flow — it must not reserve any + // bottom clearance. Zero the measured vars so the thread reclaims the space. + // (Read globals here so the callback stays stable; mirror the popoutAllowed + // gate since secondary windows are forced docked.) + if ($composerPoppedOut.get() && !isSecondaryWindow()) { + const root = document.documentElement + lastBucketedHeightRef.current = 0 + lastBucketedSurfaceHeightRef.current = 0 + root.style.setProperty('--composer-measured-height', '0px') + root.style.setProperty('--composer-surface-measured-height', '0px') + + return + } + + const { height, width } = composer.getBoundingClientRect() + const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height + const root = document.documentElement + + if (width > 0) { + const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX + + if (nextTight !== lastTightRef.current) { + lastTightRef.current = nextTight + setTight(nextTight) + } + } + + // Expand once the input has actually wrapped past a single line. The + // observer only fires on real size changes, so this reads scrollHeight at + // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem + // min-height + padding); a second line clears ~36px. We only ever expand + // here — collapse is handled by the emptied-draft effect to avoid + // oscillating across the wrap boundary as the input switches widths. + const editor = editorRef.current + + if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { + setExpanded(true) + } + + if (height > 0) { + const bucket = Math.round(height / 8) * 8 + + if (bucket !== lastBucketedHeightRef.current) { + lastBucketedHeightRef.current = bucket + root.style.setProperty('--composer-measured-height', `${bucket}px`) + } + } + + if (surfaceHeight && surfaceHeight > 0) { + const bucket = Math.round(surfaceHeight / 8) * 8 + + if (bucket !== lastBucketedSurfaceHeightRef.current) { + lastBucketedSurfaceHeightRef.current = bucket + root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + } + } + }, [composerRef, composerSurfaceRef, editorRef]) + + useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef) + + // Toggling pop-out changes whether the composer reserves thread clearance. + // The ResizeObserver may not fire (the box can keep the same box size), so + // re-sync explicitly: docked republishes the measured height, floating zeroes + // it so the thread reclaims the bottom space. + useEffect(() => { + syncComposerMetrics() + }, [poppedOut, syncComposerMetrics]) + + useEffect(() => { + return () => { + const root = document.documentElement + root.style.removeProperty('--composer-measured-height') + root.style.removeProperty('--composer-surface-measured-height') + } + }, []) + + return { stacked: expanded || narrow || tight } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index f9a47260a33..fac03f1f828 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -16,8 +16,6 @@ import { import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' -import { useMediaQuery } from '@/hooks/use-media-query' -import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' @@ -78,8 +76,6 @@ import { cloneAttachments, COMPLETION_ACTIONS, COMPOSER_FADE_BACKGROUND, - COMPOSER_SINGLE_LINE_MAX_PX, - COMPOSER_STACK_BREAKPOINT_PX, DRAFT_PERSIST_DEBOUNCE_MS, pickPlaceholder, type QueueEditState, @@ -103,6 +99,7 @@ import { import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' import { useAutoSpeakReplies } from './hooks/use-auto-speak-replies' +import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' @@ -270,9 +267,7 @@ export function ChatBar({ const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [expanded, setExpanded] = useState(false) const [voiceConversationActive, setVoiceConversationActive] = useState(false) - const [tight, setTight] = useState(false) const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) const [focusRequestId, setFocusRequestId] = useState(0) @@ -282,13 +277,11 @@ export function ChatBar({ const composingRef = useRef(false) // true during IME composition (CJK input) const lastSpokenIdRef = useRef(null) - const narrow = useMediaQuery('(max-width: 30rem)') - const { availableThemes, themeName } = useTheme() const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes }) - const stacked = expanded || narrow || tight + const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, draft, editorRef, poppedOut }) const trimmedDraft = draft.trim() const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0 const canSubmit = busy || hasComposerPayload @@ -438,118 +431,6 @@ export function ChatBar({ } }, [urlOpen]) - // 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 - // second line, so the layout flips exactly at the wrap point rather than at - // a guessed character count. We only handle the two cases the observer - // can't: an explicit newline (expand before layout settles) and an emptied - // draft (collapse back). We never read scrollHeight per keystroke. - useEffect(() => { - if (!draft) { - setExpanded(false) - - return - } - - if (expanded) { - return - } - - // 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')) { - setExpanded(true) - } - }, [draft, expanded]) - - // Bucket measured heights so we only invalidate the global CSS var when - // the size crosses a meaningful threshold. Without bucketing, the editor - // grows ~1px per character → setProperty fires every keystroke → entire - // tree's computed style is invalidated → next paint forces a full - // recalculate-style pass. With an 8px bucket, the invalidation rate drops - // ~8× and small char-by-char typing produces no style invalidation at all - // until a wrap or row change actually happens. - const lastBucketedHeightRef = useRef(0) - const lastBucketedSurfaceHeightRef = useRef(0) - const lastTightRef = useRef(null) - - const syncComposerMetrics = useCallback(() => { - const composer = composerRef.current - - if (!composer) { - return - } - - // Floating composer is out of the thread's flow — it must not reserve any - // bottom clearance. Zero the measured vars so the thread reclaims the space. - // (Read globals here so the callback stays stable; mirror the popoutAllowed - // gate since secondary windows are forced docked.) - if ($composerPoppedOut.get() && !isSecondaryWindow()) { - const root = document.documentElement - lastBucketedHeightRef.current = 0 - lastBucketedSurfaceHeightRef.current = 0 - root.style.setProperty('--composer-measured-height', '0px') - root.style.setProperty('--composer-surface-measured-height', '0px') - - return - } - - const { height, width } = composer.getBoundingClientRect() - const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height - const root = document.documentElement - - if (width > 0) { - const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX - - if (nextTight !== lastTightRef.current) { - lastTightRef.current = nextTight - setTight(nextTight) - } - } - - // Expand once the input has actually wrapped past a single line. The - // observer only fires on real size changes, so this reads scrollHeight at - // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem - // min-height + padding); a second line clears ~36px. We only ever expand - // here — collapse is handled by the emptied-draft effect to avoid - // oscillating across the wrap boundary as the input switches widths. - const editor = editorRef.current - - if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { - setExpanded(true) - } - - if (height > 0) { - const bucket = Math.round(height / 8) * 8 - - if (bucket !== lastBucketedHeightRef.current) { - lastBucketedHeightRef.current = bucket - root.style.setProperty('--composer-measured-height', `${bucket}px`) - } - } - - if (surfaceHeight && surfaceHeight > 0) { - const bucket = Math.round(surfaceHeight / 8) * 8 - - if (bucket !== lastBucketedSurfaceHeightRef.current) { - lastBucketedSurfaceHeightRef.current = bucket - root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) - } - } - }, []) - - useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef) - - // Toggling pop-out changes whether the composer reserves thread clearance. - // The ResizeObserver may not fire (the box can keep the same box size), so - // re-sync explicitly: docked republishes the measured height, floating zeroes - // it so the thread reclaims the bottom space. - useEffect(() => { - syncComposerMetrics() - }, [poppedOut, syncComposerMetrics]) - // Keep the floating box on-screen: re-clamp (with the real measured size + // thread bounds) when it pops out and on every window resize — so a position // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar @@ -578,14 +459,6 @@ export function ChatBar({ } }, [poppedOut]) - useEffect(() => { - return () => { - const root = document.documentElement - root.style.removeProperty('--composer-measured-height') - root.style.removeProperty('--composer-surface-measured-height') - } - }, []) - const insertText = (text: string) => { const currentDraft = draftRef.current const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : '' From 00694b935fb360bf5a93b1d3034a115ec09f08cd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:07:27 -0500 Subject: [PATCH 02/12] perf(desktop): composer typing no longer re-renders ChatBar (imperative draft sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../composer/hooks/use-composer-metrics.ts | 24 ++-- apps/desktop/src/app/chat/composer/index.tsx | 107 +++++++++++------- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index c1df2f84553..da66ddd843a 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -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 composerSurfaceRef: RefObject editorRef: RefObject - 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 diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index fac03f1f828..2c9e5436b49 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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(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 From cf05b38683ebe2035d41f9eb7c39c735092e8f68 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:26:04 -0500 Subject: [PATCH 03/12] refactor(desktop): extract composer voice engine into useComposerVoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the dictation + voice-conversation + auto-speak subsystem out of ChatBar into composer/hooks/use-composer-voice.ts. It owns voiceConversationActive, lastSpokenIdRef, the pending-reply readers, submitVoiceTurn, the voice hooks (recorder/conversation/auto-speak), the Ctrl+B toggle event, and handleToggleAutoSpeak; it exposes dictate/voiceStatus/voiceActivityState/ conversation/start+endConversation/handleToggleAutoSpeak for the controls. Self-contained: consumes the draft/submit primitives (insertText, clearDraft, focusInput, onSubmit) passed in, nothing depends back on it — so unlike the queue subsystem (which is circularly coupled to the draft helpers) it lifts cleanly. Behaviour-preserving; verbatim move. --- .../chat/composer/hooks/use-composer-voice.ts | 160 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 121 +++---------- 2 files changed, 182 insertions(+), 99 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts new file mode 100644 index 00000000000..2cff7a4084c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { chatMessageText } from '@/lib/chat-messages' +import { triggerHaptic } from '@/lib/haptics' +import { resetBrowseState } from '@/store/composer-input-history' +import { notifyError } from '@/store/notifications' +import { $messages } from '@/store/session' +import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' + +import { onComposerVoiceToggleRequest } from '../focus' +import type { ChatBarProps } from '../types' + +import { useAutoSpeakReplies } from './use-auto-speak-replies' +import { useVoiceConversation } from './use-voice-conversation' +import { useVoiceRecorder } from './use-voice-recorder' + +interface UseComposerVoiceArgs { + busy: boolean + clearDraft: () => void + disabled: boolean + focusInput: () => void + insertText: (text: string) => void + maxRecordingSeconds: number + onSubmit: ChatBarProps['onSubmit'] + onTranscribeAudio: ChatBarProps['onTranscribeAudio'] + sessionId: string | null | undefined +} + +/** + * The composer's voice engine: push-to-talk dictation (transcript → draft), the + * full voice-conversation loop, and auto-speak of replies. Self-contained — it + * consumes the draft/submit primitives passed in but nothing depends back on it, + * so it lifts cleanly out of ChatBar. + */ +export function useComposerVoice({ + busy, + clearDraft, + disabled, + focusInput, + insertText, + maxRecordingSeconds, + onSubmit, + onTranscribeAudio, + sessionId +}: UseComposerVoiceArgs) { + const { t } = useI18n() + const [voiceConversationActive, setVoiceConversationActive] = useState(false) + const lastSpokenIdRef = useRef(null) + + const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ + focusInput, + maxRecordingSeconds, + onTranscript: insertText, + onTranscribeAudio + }) + + const pendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (!last || last.id === lastSpokenIdRef.current) { + return null + } + + const text = chatMessageText(last).trim() + + if (!text) { + return null + } + + return { + id: last.id, + pending: Boolean(last.pending), + text + } + } + + const consumePendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (last) { + lastSpokenIdRef.current = last.id + } + } + + const submitVoiceTurn = async (text: string) => { + if (busy) { + return + } + + triggerHaptic('submit') + resetBrowseState(sessionId) + clearDraft() + await onSubmit(text) + } + + const conversation = useVoiceConversation({ + busy, + consumePendingResponse, + enabled: voiceConversationActive, + onFatalError: () => setVoiceConversationActive(false), + onSubmit: submitVoiceTurn, + onTranscribeAudio, + pendingResponse + }) + + // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting + // with STT unconfigured lets the conversation surface its own "configure + // speech-to-text" notice rather than silently no-opping. + const toggleVoiceConversation = useCallback(() => { + if (disabled) { + return + } + + if (voiceConversationActive) { + setVoiceConversationActive(false) + void conversation.end() + } else { + setVoiceConversationActive(true) + } + }, [conversation, disabled, voiceConversationActive]) + + useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + + // Explicit start/end for the on-screen conversation controls (the hotkey uses + // the gated toggle above). + const startConversation = useCallback(() => setVoiceConversationActive(true), []) + + const endConversation = useCallback(() => { + setVoiceConversationActive(false) + void conversation.end() + }, [conversation]) + + const handleToggleAutoSpeak = useCallback(() => { + void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error => + notifyError(error, t.settings.config.autosaveFailed) + ) + }, [t]) + + useAutoSpeakReplies({ + conversationActive: voiceConversationActive, + failureLabel: t.assistant.thread.readAloudFailed, + markSpoken: consumePendingResponse, + pendingReply: pendingResponse, + sessionId + }) + + return { + conversation, + dictate, + endConversation, + handleToggleAutoSpeak, + startConversation, + voiceActivityState, + voiceConversationActive, + voiceStatus + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 2c9e5436b49..478aabd7e55 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -58,14 +58,14 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { notify, notifyError } from '@/store/notifications' +import { notify } from '@/store/notifications' import { $previewStatusBySession } from '@/store/preview-status' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' -import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' +import { $autoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' @@ -93,17 +93,14 @@ import { onComposerFocusRequest, onComposerInsertRefsRequest, onComposerInsertRequest, - onComposerSubmitRequest, - onComposerVoiceToggleRequest + onComposerSubmitRequest } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' -import { useAutoSpeakReplies } from './hooks/use-auto-speak-replies' import { useComposerMetrics } from './hooks/use-composer-metrics' +import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' -import { useVoiceConversation } from './hooks/use-voice-conversation' -import { useVoiceRecorder } from './hooks/use-voice-recorder' import { dragHasAttachments, droppedFileInlineRefs, @@ -284,7 +281,6 @@ export function ChatBar({ const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [voiceConversationActive, setVoiceConversationActive] = useState(false) const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) const [focusRequestId, setFocusRequestId] = useState(0) @@ -292,7 +288,6 @@ export function ChatBar({ queueEditRef.current = queueEdit const dragDepthRef = useRef(0) const composingRef = useRef(false) // true during IME composition (CJK input) - const lastSpokenIdRef = useRef(null) const { availableThemes, themeName } = useTheme() const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) @@ -1802,93 +1797,24 @@ export function ChatBar({ setUrlOpen(false) } - const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ - focusInput, - maxRecordingSeconds, - onTranscript: insertText, - onTranscribeAudio - }) - - const pendingResponse = () => { - const messages = $messages.get() - const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) - - if (!last || last.id === lastSpokenIdRef.current) { - return null - } - - const text = chatMessageText(last).trim() - - if (!text) { - return null - } - - return { - id: last.id, - pending: Boolean(last.pending), - text - } - } - - const consumePendingResponse = () => { - const messages = $messages.get() - const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) - - if (last) { - lastSpokenIdRef.current = last.id - } - } - - const submitVoiceTurn = async (text: string) => { - if (busy) { - return - } - - triggerHaptic('submit') - resetBrowseState(sessionId) - clearDraft() - await onSubmit(text) - } - - const conversation = useVoiceConversation({ + const { + conversation, + dictate, + endConversation, + handleToggleAutoSpeak, + startConversation, + voiceActivityState, + voiceConversationActive, + voiceStatus + } = useComposerVoice({ busy, - consumePendingResponse, - enabled: voiceConversationActive, - onFatalError: () => setVoiceConversationActive(false), - onSubmit: submitVoiceTurn, + clearDraft, + disabled, + focusInput, + insertText, + maxRecordingSeconds, + onSubmit, onTranscribeAudio, - pendingResponse - }) - - // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting - // with STT unconfigured lets the conversation surface its own "configure - // speech-to-text" notice rather than silently no-opping. - const toggleVoiceConversation = useCallback(() => { - if (disabled) { - return - } - - if (voiceConversationActive) { - setVoiceConversationActive(false) - void conversation.end() - } else { - setVoiceConversationActive(true) - } - }, [conversation, disabled, voiceConversationActive]) - - useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) - - const handleToggleAutoSpeak = useCallback(() => { - void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error => - notifyError(error, t.settings.config.autosaveFailed) - ) - }, [t]) - - useAutoSpeakReplies({ - conversationActive: voiceConversationActive, - failureLabel: t.assistant.thread.readAloudFailed, - markSpoken: consumePendingResponse, - pendingReply: pendingResponse, sessionId }) @@ -1919,11 +1845,8 @@ export function ChatBar({ active: voiceConversationActive, level: conversation.level, muted: conversation.muted, - onEnd: () => { - setVoiceConversationActive(false) - void conversation.end() - }, - onStart: () => setVoiceConversationActive(true), + onEnd: endConversation, + onStart: startConversation, onStopTurn: conversation.stopTurn, onToggleMute: conversation.toggleMute, status: conversation.status From bd53230739da3bacbf58211f79a56cebcbfb4e06 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:28:28 -0500 Subject: [PATCH 04/12] refactor(desktop): extract composer drag-and-drop into useComposerDrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the attachment drop engine (dragActive + the 7 drag/drop handlers + the in-app-ref vs OS-upload split) out of ChatBar into composer/hooks/use-composer-drop.ts. Self-contained, off the keystroke path — consumes insertInlineRefs + onAttachDroppedItems + requestMainFocus. Verbatim move, behaviour-preserving. --- .../chat/composer/hooks/use-composer-drop.ts | 164 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 139 ++------------- 2 files changed, 174 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts new file mode 100644 index 00000000000..2c56061c80d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts @@ -0,0 +1,164 @@ +import { type DragEvent as ReactDragEvent, useRef, useState } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../../hooks/use-composer-actions' +import { dragHasAttachments, droppedFileInlineRefs, type InlineRefInput } from '../inline-refs' +import type { ChatBarProps } from '../types' + +interface UseComposerDropArgs { + cwd: ChatBarProps['cwd'] + insertInlineRefs: (refs: InlineRefInput[]) => boolean + onAttachDroppedItems: ChatBarProps['onAttachDroppedItems'] + requestMainFocus: () => void +} + +/** + * Drag-and-drop attachment engine. Splits drops by origin: in-app drags + * (project tree / gutter) stay inline `@file:`/`@line:` refs the gateway + * resolves directly; OS/Finder drops (absolute local paths a remote gateway + * can't read, image bytes vision needs) route through the upload pipeline. + * Off the keystroke path; consumes `insertInlineRefs` + the attach handler. + */ +export function useComposerDrop({ + cwd, + insertInlineRefs, + onAttachDroppedItems, + requestMainFocus +}: UseComposerDropArgs) { + const [dragActive, setDragActive] = useState(false) + const dragDepthRef = useRef(0) + + const resetDragState = () => { + dragDepthRef.current = 0 + setDragActive(false) + } + + const handleDragEnter = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + resetDragState() + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (candidates.length === 0) { + return + } + + // In-app drags (project tree / gutter) are workspace-relative paths the + // gateway resolves directly, so they stay inline @file:/@line: refs. OS + // drops are absolute local paths a remote gateway can't read (and images + // need byte upload for vision), so route them through the upload pipeline. + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(inAppRefs, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (osDrops.length) { + void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + const handleInputDragOver = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + } + + const handleInputDrop = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (!candidates.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + // Dropping straight onto the text box used to inline-ref *every* file — + // including OS/Finder drops, whose absolute local path a remote gateway + // can't read and whose image bytes never reached vision. Split by origin: + // in-app drags stay inline refs; OS drops go through the upload pipeline. + // (When no upload handler is wired, fall back to inline refs for all.) + const attach = onAttachDroppedItems + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (attach && osDrops.length) { + void Promise.resolve(attach(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + return { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 478aabd7e55..71c32292800 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -5,7 +5,6 @@ import { type ClipboardEvent, type FormEvent, type KeyboardEvent, - type DragEvent as ReactDragEvent, useCallback, useEffect, useMemo, @@ -69,8 +68,6 @@ import { $autoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' -import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' - import { AttachmentList } from './attachments' import { cloneAttachments, @@ -97,13 +94,12 @@ import { } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { - dragHasAttachments, - droppedFileInlineRefs, type InlineRefInput, insertInlineRefsIntoEditor } from './inline-refs' @@ -281,12 +277,10 @@ export function ChatBar({ const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) const [focusRequestId, setFocusRequestId] = useState(0) const queueEditRef = useRef(queueEdit) queueEditRef.current = queueEdit - const dragDepthRef = useRef(0) const composingRef = useRef(false) // true during IME composition (CJK input) const { availableThemes, themeName } = useTheme() @@ -1114,128 +1108,15 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } - const resetDragState = () => { - dragDepthRef.current = 0 - setDragActive(false) - } - - const handleDragEnter = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - dragDepthRef.current += 1 - - if (!dragActive) { - setDragActive(true) - } - } - - const handleDragOver = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - - const handleDragLeave = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - - if (dragDepthRef.current === 0) { - setDragActive(false) - } - } - - const handleDrop = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - resetDragState() - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (candidates.length === 0) { - return - } - - // In-app drags (project tree / gutter) are workspace-relative paths the - // gateway resolves directly, so they stay inline @file:/@line: refs. OS - // drops are absolute local paths a remote gateway can't read (and images - // need byte upload for vision), so route them through the upload pipeline. - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(inAppRefs, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (osDrops.length) { - void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } - - const handleInputDragOver = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'copy' - } - - const handleInputDrop = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (!candidates.length) { - return - } - - event.preventDefault() - event.stopPropagation() - resetDragState() - - // Dropping straight onto the text box used to inline-ref *every* file — - // including OS/Finder drops, whose absolute local path a remote gateway - // can't read and whose image bytes never reached vision. Split by origin: - // in-app drags stay inline refs; OS drops go through the upload pipeline. - // (When no upload handler is wired, fall back to inline refs for all.) - const attach = onAttachDroppedItems - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (attach && osDrops.length) { - void Promise.resolve(attach(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } + const { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) const clearDraft = useCallback(() => { setComposerText('') From 9ee7333e5b36633993c3898a01a26f0b189b81bc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:38:15 -0500 Subject: [PATCH 05/12] refactor(desktop): extract composer draft engine into useComposerDraft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-entangle the draft spine: lift the source-of-truth engine (the imperative composer-runtime subscription, edit primitives, focus, edge selectors, and per-session load/clear/stash/restore) out of ChatBar into composer/hooks/use-composer-draft.ts. The draft↔queue cycle is broken by making `queueEditRef` a coordinator-owned ref ChatBar threads into the hook (explicit dep, not an implicit shared global). The contentEditable *event* handlers stay in ChatBar (they bridge into the trigger engine) and drive the primitives the hook exposes. Behaviour-preserving (verbatim move); typing perf preserved. Verified: typecheck clean, composer DOM repro tests (enter-submit, IME, slash-nav) + text-guard pass. --- .../chat/composer/hooks/use-composer-draft.ts | 319 +++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 332 ++---------------- 2 files changed, 350 insertions(+), 301 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts new file mode 100644 index 00000000000..4943838e790 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -0,0 +1,319 @@ +import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { isBrowsingHistory } from '@/store/composer-input-history' + +import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRefsRequest, + onComposerInsertRequest +} from '../focus' +import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs' +import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor' +import type { ChatBarProps } from '../types' + +interface UseComposerDraftArgs { + activeQueueSessionKey: string | null + focusKey: ChatBarProps['focusKey'] + inputDisabled: boolean + queueEditRef: RefObject + sessionId: string | null | undefined +} + +/** + * The composer's draft engine — the detached source-of-truth spine. The live + * text lives in the contentEditable DOM + `draftRef`; React only sees coarse + * edge selectors, so typing never re-renders the chrome. Owns the imperative + * composer-runtime subscription (draftRef mirror + external repaint + debounced + * per-session stash), the edit primitives (append/insert/inline-refs), focus, + * and per-session load/clear/stash/restore. The contentEditable *event* + * handlers stay in ChatBar (they bridge into the trigger engine) and drive the + * primitives exposed here. + */ +export function useComposerDraft({ + activeQueueSessionKey, + focusKey, + inputDisabled, + queueEditRef, + sessionId +}: UseComposerDraftArgs) { + const aui = useAui() + const composerRuntime = useComposerRuntime() + + // Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil, + // steerable-vs-slash), so typing within a line costs no render. + 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 throw when the core isn't bound yet (a + // startup/thread-swap window); the DOM + draftRef hold the text and the + // subscription reconciles once it binds, so swallow the premature write. + const setComposerText = useCallback( + (value: string) => { + try { + aui.composer().setText(value) + } catch { + // Composer core not bound yet — DOM/draftRef carry the text. + } + }, + [aui] + ) + + const editorRef = useRef(null) + const draftRef = useRef('') + const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) + const draftPersistTimerRef = useRef(undefined) + const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) + activeQueueSessionKeyRef.current = activeQueueSessionKey + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + + const [focusRequestId, setFocusRequestId] = useState(0) + + const focusInput = useCallback(() => { + focusComposerInput(editorRef.current) + markActiveComposer('main') + }, []) + + const requestMainFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + // The single write path for programmatic draft mutations: mirror → AUI state → + // repaint the editor (caret to end). Repaints even while focused — inserts / + // restores run mid-focus, and the runtime sync only repaints an unfocused + // editor — so the visible text never lags the store. + const paintDraft = useCallback( + (next: string, focus = true) => { + draftRef.current = next + setComposerText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + if (focus) { + requestMainFocus() + } + }, + [requestMainFocus, setComposerText] + ) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + + paintDraft(`${base}${sep}${value}`) + }, + [paintDraft] + ) + + useEffect(() => { + if (!inputDisabled) { + focusInput() + } + }, [focusInput, focusKey, focusRequestId, inputDisabled]) + + useEffect(() => { + if (inputDisabled) { + return undefined + } + + const offFocus = onComposerFocusRequest(target => { + if (target === 'main') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'main') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText, inputDisabled]) + + const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => + stashSessionDraft(scope, text, attachments) + + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + $composerAttachments.set(cloneAttachments(attachments)) + paintDraft(text, false) + } + + const clearDraft = useCallback(() => { + setComposerText('') + draftRef.current = '' + + if (editorRef.current) { + editorRef.current.replaceChildren() + } + }, [setComposerText]) + + // Imperative draft sync — the spine of the "work only when work is to be + // performed" model. Subscribing to the composer runtime directly (not + // `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of + // React, so typing never re-renders the chrome. On each change we (1) mirror + // text into draftRef, (2) repaint the editor only when the change came from + // OUTSIDE it (programmatic clear/restore/insert; the focused editor is the + // source otherwise), 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(() => { + const sync = () => { + const text = composerRuntime.getState().text + draftRef.current = text + + const editor = editorRef.current + + 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) + } + + const unsubscribe = composerRuntime.subscribe(sync) + + return () => { + unsubscribe() + window.clearTimeout(draftPersistTimerRef.current) + } + }, [composerRuntime, queueEditRef]) + + const insertText = (text: string) => { + const base = draftRef.current + const sep = base && !base.endsWith('\n') ? '\n' : '' + + paintDraft(`${base}${sep}${text}`) + } + + // insertInlineRefs mutates the editor in place (chips), so it can't go through + // paintDraft's re-render — it mirrors the resulting plain text and refocuses. + const insertInlineRefs = (refs: InlineRefInput[]) => { + const editor = editorRef.current + + if (!editor) { + return false + } + + const nextDraft = insertInlineRefsIntoEditor(editor, refs) + + if (nextDraft === null) { + return false + } + + draftRef.current = nextDraft + setComposerText(nextDraft) + requestMainFocus() + + return true + } + + // Latest-closure ref so the once-only subscription always calls the current + // insertInlineRefs without re-subscribing every render. + const insertInlineRefsRef = useRef(insertInlineRefs) + insertInlineRefsRef.current = insertInlineRefs + + useEffect(() => { + return onComposerInsertRefsRequest(({ refs, target }) => { + if (target === 'main') { + insertInlineRefsRef.current(refs) + } + }) + }, []) + + // Per-thread draft swap — the composer's only session coupling. Lifecycle + // never clears composer state; this effect alone stashes on leave, restores + // on enter. Keyed writes are idempotent, so no skip-sentinel. + useEffect(() => { + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) + loadIntoComposer(text, attachments) + + return () => { + const editing = queueEditRef.current + + if (editing?.sessionKey === activeQueueSessionKey) { + stashAt(activeQueueSessionKey, editing.draft, editing.attachments) + } else if (!isBrowsingHistory(sessionId)) { + stashAt(activeQueueSessionKey) + } + } + }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps + + // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R + // inside the debounce window would drop trailing keystrokes without this. + useEffect(() => { + const flushPendingDraftPersist = () => { + const pending = pendingDraftPersistRef.current + + if (!pending) { + return + } + + pendingDraftPersistRef.current = null + stashAt(pending.scope, pending.text) + } + + window.addEventListener('pagehide', flushPendingDraftPersist) + + return () => { + window.removeEventListener('pagehide', flushPendingDraftPersist) + flushPendingDraftPersist() + } + }, []) + + return { + activeQueueSessionKeyRef, + clearDraft, + draftRef, + editorRef, + focusInput, + hasText, + insertInlineRefs, + insertText, + isHelpHint, + isSteerableText, + loadIntoComposer, + requestMainFocus, + sessionIdRef, + setComposerText, + stashAt + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 71c32292800..942980f0c8e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,5 +1,5 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' -import { ComposerPrimitive, useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' +import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ClipboardEvent, @@ -26,9 +26,7 @@ import { $composerAttachments, clearComposerAttachments, clearSessionDraft, - type ComposerAttachment, - stashSessionDraft, - takeSessionDraft + type ComposerAttachment } from '@/store/composer' import { browseBackward, @@ -73,7 +71,6 @@ import { cloneAttachments, COMPLETION_ACTIONS, COMPOSER_FADE_BACKGROUND, - DRAFT_PERSIST_DEBOUNCE_MS, pickPlaceholder, type QueueEditState, slashArgStage, @@ -84,25 +81,17 @@ import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' import { - type ComposerInsertMode, - focusComposerInput, markActiveComposer, - onComposerFocusRequest, - onComposerInsertRefsRequest, - onComposerInsertRequest, onComposerSubmitRequest } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' -import { - type InlineRefInput, - insertInlineRefsIntoEditor -} from './inline-refs' import { QueuePanel } from './queue-panel' import { composerPlainText, @@ -147,48 +136,6 @@ export function ChatBar({ onSubmit, onTranscribeAudio }: ChatBarProps) { - const aui = useAui() - 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 - // read path (`s.composer.text`, which is null-safe), there's no graceful - // fallback. There's a startup/thread-swap window where this ChatBar's mount - // effects (draft restore, clearDraft, external inserts) run before the core - // binds; the popout refactor (#49488) widened it by moving the composer out - // of the contain wrapper into a sibling of the thread, so the throw began - // surfacing as an uncaught error that wedged the desktop input (#49903). - // - // Guard every mutation: if the core isn't ready, no-op the assistant-ui write. - // The contentEditable DOM + draftRef already hold the text, and the - // draft⇄editor sync reconciles composer state once the core attaches, so the - // draft is never lost — only the (premature) state push is skipped. - const setComposerText = useCallback( - (value: string) => { - try { - aui.composer().setText(value) - } catch { - // Composer core not bound yet — DOM/draftRef carry the text; the sync - // effect re-applies it after bind. Swallow so the input stays usable. - } - }, - [aui] - ) - const attachments = useStore($composerAttachments) const queuedPromptsBySession = useStore($queuedPromptsBySession) const statusItemsBySession = useStore($statusItemsBySession) @@ -231,7 +178,6 @@ export function ChatBar({ const composerRef = useRef(null) const composerSurfaceRef = useRef(null) - const editorRef = useRef(null) const handleComposerPopOut = useCallback(() => { triggerHaptic('open') @@ -261,13 +207,6 @@ export function ChatBar({ position: popoutPosition }) - const draftRef = useRef('') - const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) - const draftPersistTimerRef = useRef(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 @@ -278,7 +217,6 @@ export function ChatBar({ const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') const [queueEdit, setQueueEdit] = useState(null) - const [focusRequestId, setFocusRequestId] = useState(0) const queueEditRef = useRef(queueEdit) queueEditRef.current = queueEdit const composingRef = useRef(false) // true during IME composition (CJK input) @@ -287,6 +225,34 @@ 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 { t } = useI18n() + const gatewayState = useStore($gatewayState) + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders + const reconnecting = gatewayState === 'closed' || gatewayState === 'error' + const inputDisabled = disabled && !reconnecting + + // The draft engine — detached source of truth (DOM + draftRef + edge + // selectors); typing never re-renders the chrome. ChatBar owns `queueEditRef` + // and threads it in so the draft↔queue coupling is an explicit dep, not a tangle. + const { + activeQueueSessionKeyRef, + clearDraft, + draftRef, + editorRef, + focusInput, + hasText, + insertInlineRefs, + insertText, + isHelpHint, + isSteerableText, + loadIntoComposer, + requestMainFocus, + sessionIdRef, + setComposerText, + stashAt + } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) const hasComposerPayload = hasText || attachments.length > 0 const canSubmit = busy || hasComposerPayload @@ -299,13 +265,6 @@ export function ChatBar({ const showHelpHint = isHelpHint - const { t } = useI18n() - const gatewayState = useStore($gatewayState) - const newSessionPlaceholders = t.composer.newSessionPlaceholders - const followUpPlaceholders = t.composer.followUpPlaceholders - const reconnecting = gatewayState === 'closed' || gatewayState === 'error' - const inputDisabled = disabled && !reconnecting - // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a // *different* conversation. Critically, the first id assignment of a freshly @@ -346,112 +305,6 @@ export function ChatBar({ : t.composer.placeholderStarting : restingPlaceholder - const focusInput = useCallback(() => { - focusComposerInput(editorRef.current) - markActiveComposer('main') - }, []) - - const requestMainFocus = useCallback(() => { - setFocusRequestId(id => id + 1) - }, []) - - const appendExternalText = useCallback( - (text: string, mode: ComposerInsertMode) => { - const value = text.trim() - - if (!value) { - return - } - - const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current - const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' - const next = `${base}${sep}${value}` - - draftRef.current = next - setComposerText(next) - - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, next) - placeCaretEnd(editor) - } - - setFocusRequestId(id => id + 1) - }, - [setComposerText] - ) - - useEffect(() => { - if (!inputDisabled) { - focusInput() - } - }, [focusInput, focusKey, focusRequestId, inputDisabled]) - - useEffect(() => { - if (inputDisabled) { - return undefined - } - - const offFocus = onComposerFocusRequest(target => { - if (target === 'main') { - setFocusRequestId(id => id + 1) - } - }) - - const offInsert = onComposerInsertRequest(({ mode, target, text }) => { - if (target === 'main') { - appendExternalText(text, mode) - } - }) - - return () => { - offFocus() - offInsert() - } - }, [appendExternalText, inputDisabled]) - - // 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(() => { - const sync = () => { - const text = composerRuntime.getState().text - draftRef.current = text - - const editor = editorRef.current - - 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) - } - - const unsubscribe = composerRuntime.subscribe(sync) - - return () => { - unsubscribe() - window.clearTimeout(draftPersistTimerRef.current) - } - }, [composerRuntime]) - useEffect(() => { if (urlOpen) { window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) @@ -486,64 +339,6 @@ export function ChatBar({ } }, [poppedOut]) - const insertText = (text: string) => { - const currentDraft = draftRef.current - const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : '' - const nextDraft = `${currentDraft}${sep}${text}` - - draftRef.current = nextDraft - setComposerText(nextDraft) - - // Push the new text into the contentEditable editor directly. Setting the - // assistant-ui composer state alone is not enough: the draft→editor sync - // effect only re-renders the editor when it is NOT focused - // (document.activeElement !== editor), and the dictation/insert paths - // typically run while the editor has (or immediately regains) focus — so - // the store would hold the text but the visible editor would stay empty - // and there'd be nothing to send. Mirror appendExternalText here. - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, nextDraft) - placeCaretEnd(editor) - } - - requestMainFocus() - } - - const insertInlineRefs = (refs: InlineRefInput[]) => { - const editor = editorRef.current - - if (!editor) { - return false - } - - const nextDraft = insertInlineRefsIntoEditor(editor, refs) - - if (nextDraft === null) { - return false - } - - draftRef.current = nextDraft - setComposerText(nextDraft) - requestMainFocus() - - return true - } - - // Latest-closure ref so the (once-only) subscription always calls the current - // insertInlineRefs without re-subscribing every render. - const insertInlineRefsRef = useRef(insertInlineRefs) - insertInlineRefsRef.current = insertInlineRefs - - useEffect(() => { - return onComposerInsertRefsRequest(({ refs, target }) => { - if (target === 'main') { - insertInlineRefsRef.current(refs) - } - }) - }, []) - const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) @@ -1118,15 +913,6 @@ export function ChatBar({ handleInputDrop } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) - const clearDraft = useCallback(() => { - setComposerText('') - draftRef.current = '' - - if (editorRef.current) { - editorRef.current.replaceChildren() - } - }, [setComposerText]) - // Hand a worktree off to the controller: open a fresh session anchored there, // carrying the composer draft as its first turn. Clearing here means the draft // travels to the new session instead of getting stashed under this one. @@ -1201,62 +987,6 @@ export function ChatBar({ [cwd] ) - const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { - draftRef.current = text - setComposerText(text) - $composerAttachments.set(cloneAttachments(attachments)) - - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, text) - placeCaretEnd(editor) - } - } - - const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => - stashSessionDraft(scope, text, attachments) - - // Per-thread draft swap — the composer's only session coupling. Lifecycle - // never clears composer state; this effect alone stashes on leave, restores - // on enter. Keyed writes are idempotent, so no skip-sentinel. - useEffect(() => { - const { attachments, text } = takeSessionDraft(activeQueueSessionKey) - loadIntoComposer(text, attachments) - - return () => { - const editing = queueEditRef.current - - if (editing?.sessionKey === activeQueueSessionKey) { - stashAt(activeQueueSessionKey, editing.draft, editing.attachments) - } else if (!isBrowsingHistory(sessionId)) { - stashAt(activeQueueSessionKey) - } - } - }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps - - // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R - // inside the debounce window would drop trailing keystrokes without this. - useEffect(() => { - const flushPendingDraftPersist = () => { - const pending = pendingDraftPersistRef.current - - if (!pending) { - return - } - - pendingDraftPersistRef.current = null - stashAt(pending.scope, pending.text) - } - - window.addEventListener('pagehide', flushPendingDraftPersist) - - return () => { - window.removeEventListener('pagehide', flushPendingDraftPersist) - flushPendingDraftPersist() - } - }, []) - const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { return From 4c4b790f110be2c34fdfdb72575efe2d1f8ba632 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:41:34 -0500 Subject: [PATCH 06/12] refactor(desktop): extract composer queue engine into useComposerQueue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the queue subsystem out of ChatBar into composer/hooks/use-composer-queue.ts: the per-session queue-store binding + queuedPrompts, in-place queued-prompt editing (begin/step/exit), the shared drain lock + send-then-remove sequence, manual send-now, bounded auto-drain, and the three queue effects (re-key migrate, idle auto-drain, queue-edit cleanup). It consumes the draft API (draftRef/clearDraft/loadIntoComposer/focusInput) and writes the coordinator-owned `queueEditRef` the draft engine reads — so the draft↔queue coupling is two explicit deps, not an inline tangle. `steerDraft` and the chat-focus Esc-cancel stay in ChatBar (not queue-internal). Behaviour-identical (verbatim move). Verified: typecheck clean, composer DOM repro tests + queue/edit paths pass. --- .../chat/composer/hooks/use-composer-queue.ts | 341 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 332 +++-------------- 2 files changed, 385 insertions(+), 288 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts 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 new file mode 100644 index 00000000000..dc52cee5bea --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -0,0 +1,341 @@ +import { useStore } from '@nanostores/react' +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { resetBrowseState } from '@/store/composer-input-history' +import { + $queuedPromptsBySession, + enqueueQueuedPrompt, + MAX_AUTO_DRAIN_ATTEMPTS, + migrateQueuedPrompts, + promoteQueuedPrompt, + type QueuedPromptEntry, + removeQueuedPrompt, + shouldAutoDrain, + updateQueuedPrompt +} from '@/store/composer-queue' +import { notify } from '@/store/notifications' + +import { cloneAttachments, type QueueEditState } from '../composer-utils' +import type { ChatBarProps } from '../types' + +interface UseComposerQueueArgs { + activeQueueSessionKey: string | null + attachments: ComposerAttachment[] + busy: boolean + clearDraft: () => void + draftRef: RefObject + focusInput: () => void + loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void + onCancel: ChatBarProps['onCancel'] + onSubmit: ChatBarProps['onSubmit'] + queueEditRef: RefObject + queueSessionKey: ChatBarProps['queueSessionKey'] + sessionId: string | null | undefined +} + +/** + * The composer's queue engine — everything about queued turns: the per-session + * queue store binding, in-place queued-prompt editing (begin/step/exit), the + * shared drain lock + send-then-remove sequence, manual send-now, and the + * edge-independent auto-drain with bounded retries. It consumes the draft API + * (draftRef/clearDraft/loadIntoComposer/focusInput) and writes the + * coordinator-owned `queueEditRef` so the draft engine can read the edit state + * without a back-reference. Behaviour-identical to the inline original. + */ +export function useComposerQueue({ + activeQueueSessionKey, + attachments, + busy, + clearDraft, + draftRef, + focusInput, + loadIntoComposer, + onCancel, + onSubmit, + queueEditRef, + queueSessionKey, + sessionId +}: UseComposerQueueArgs) { + const { t } = useI18n() + + const queuedPromptsBySession = useStore($queuedPromptsBySession) + + const queuedPrompts = useMemo( + () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), + [activeQueueSessionKey, queuedPromptsBySession] + ) + + const [queueEdit, setQueueEdit] = useState(null) + queueEditRef.current = queueEdit + + const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null + + const prevQueueKeyRef = useRef(activeQueueSessionKey) + const drainingQueueRef = useRef(false) + const drainFailuresRef = useRef(new Map()) + + const beginQueuedEdit = (entry: QueuedPromptEntry) => { + if (!activeQueueSessionKey || queueEdit) { + return + } + + setQueueEdit({ + attachments: cloneAttachments(attachments), + draft: draftRef.current, + entryId: entry.id, + sessionKey: activeQueueSessionKey + }) + loadIntoComposer(entry.text, entry.attachments) + triggerHaptic('selection') + focusInput() + } + + // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer), + // saving the in-progress edit on each step. Stepping newer past the last + // entry exits edit mode and restores the pre-edit draft. + const stepQueuedEdit = (direction: -1 | 1) => { + if (!queueEdit) { + return false + } + + const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId) + const target = index + direction + + if (index < 0 || target < 0) { + return index >= 0 // at the oldest: swallow; missing entry: let it fall through + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { + attachments: cloneAttachments(attachments), + text: draftRef.current + }) + + const next = queuedPrompts[target] + + if (next) { + setQueueEdit({ ...queueEdit, entryId: next.id }) + loadIntoComposer(next.text, next.attachments) + } else { + setQueueEdit(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + } + + triggerHaptic(saved ? 'success' : 'selection') + focusInput() + + return true + } + + const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { + if (!queueEdit) { + return false + } + + if (action === 'save') { + const text = draftRef.current + const next = cloneAttachments(attachments) + + if (!text.trim() && next.length === 0) { + return false + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) + triggerHaptic(saved ? 'success' : 'selection') + } else { + triggerHaptic('cancel') + } + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + setQueueEdit(null) + focusInput() + + return true + } + + const queueCurrentDraft = useCallback(() => { + const text = draftRef.current + + if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) { + return false + } + + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) { + return false + } + + clearDraft() + clearComposerAttachments() + triggerHaptic('selection') + + return true + }, [activeQueueSessionKey, attachments, clearDraft, draftRef]) + + // All queue drain paths share one lock + send-then-remove sequence. + // `pickEntry` lets each caller choose head, by-id, or skip-edited. + const runDrain = useCallback( + async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { + if (drainingQueueRef.current || !activeQueueSessionKey) { + return false + } + + const entry = pickEntry(queuedPrompts) + + if (!entry) { + return false + } + + drainingQueueRef.current = true + + try { + const accepted = await Promise.resolve( + onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) + ) + + if (accepted === false) { + return false + } + + drainFailuresRef.current.delete(entry.id) + removeQueuedPrompt(activeQueueSessionKey, entry.id) + resetBrowseState(sessionId) + + return true + } finally { + drainingQueueRef.current = false + } + }, + [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] + ) + + const pickDrainHead = useCallback( + (entries: QueuedPromptEntry[]) => { + const skip = queueEditRef.current?.entryId + + return skip ? entries.find(e => e.id !== skip) : entries[0] + }, + [queueEditRef] // reads the edit id off a ref so the lock-holder always sees the latest + ) + + const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain]) + + const sendQueuedNow = useCallback( + (id: string) => { + if (!activeQueueSessionKey || id === queueEdit?.entryId) { + return false + } + + if (busy) { + // Promote to the head, then interrupt. The gateway always emits a + // settle (message.complete + session.info running:false) when the + // turn unwinds, and the busy→false auto-drain below sends this entry. + promoteQueuedPrompt(activeQueueSessionKey, id) + triggerHaptic('selection') + void Promise.resolve(onCancel()) + + return true + } + + // A manual send clears the auto-drain backoff so a stuck entry the user + // taps gets a fresh attempt (and re-enables auto-retry on success). + drainFailuresRef.current.delete(id) + + return runDrain(entries => entries.find(e => e.id === id)) + }, + [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain] + ) + + // Edge-independent auto-drain: send the head whenever the session is idle and + // the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g. + // a stale-session 404) can't strand the entry permanently nor spin-loop. The + // drain lock serializes sends; a remount/reconnect resets the failure counts. + const autoDrainNext = useCallback(() => { + if (busy || drainingQueueRef.current || !activeQueueSessionKey) { + return + } + + const entry = pickDrainHead(queuedPrompts) + + if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { + return + } + + const onFail = () => { + const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1 + drainFailuresRef.current.set(entry.id, fails) + + if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) { + notify({ + id: 'composer-queue-stuck', + kind: 'error', + title: t.composer.queueStuckTitle, + message: t.composer.queueStuckBody + }) + } + } + + void runDrain(() => entry) + .then(sent => { + if (!sent) { + onFail() + } + }) + .catch(onFail) + }, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t]) + + // Re-key on a runtime session-id change. A stable stored id (queueSessionKey) + // never churns, so a change there is a real session switch and must NOT + // migrate; only the runtime-derived key (queueSessionKey falsy → key is + // sessionId) churns on a backend bounce/resume of the same conversation. + useEffect(() => { + const prev = prevQueueKeyRef.current + prevQueueKeyRef.current = activeQueueSessionKey + + if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) { + return + } + + migrateQueuedPrompts(prev, activeQueueSessionKey) + }, [activeQueueSessionKey, queueSessionKey]) + + // Queued turns flow whenever the session is idle — on the busy→false settle + // edge, on mount/reconnect, and after a re-key — so a swallowed edge can't + // strand them. To cancel queued turns, the user deletes them from the panel. + useEffect(() => { + if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) { + autoDrainNext() + } + }, [autoDrainNext, busy, queuedPrompts.length]) + + // Queue-edit cleanup: on session swap the scope effect already stashed the + // edit snapshot; only restore into the composer when still on the same scope. + useEffect(() => { + if (!queueEdit) { + return + } + + if (queueEdit.sessionKey === activeQueueSessionKey) { + if (editingQueuedPrompt) { + return + } + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + } + + setQueueEdit(null) + }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps + + return { + beginQueuedEdit, + drainNextQueued, + editingQueuedPrompt, + exitQueuedEdit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sendQueuedNow, + stepQueuedEdit + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 942980f0c8e..2e49b5ddd60 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -44,18 +44,10 @@ import { setComposerPoppedOut } from '@/store/composer-popout' import { - $queuedPromptsBySession, enqueueQueuedPrompt, - MAX_AUTO_DRAIN_ATTEMPTS, - migrateQueuedPrompts, - promoteQueuedPrompt, - type QueuedPromptEntry, - removeQueuedPrompt, - shouldAutoDrain, - updateQueuedPrompt + removeQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { notify } from '@/store/notifications' import { $previewStatusBySession } from '@/store/preview-status' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' @@ -80,15 +72,13 @@ import { import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' -import { - markActiveComposer, - onComposerSubmitRequest -} from './focus' +import { markActiveComposer, onComposerSubmitRequest } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerMetrics } from './hooks/use-composer-metrics' +import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' @@ -137,7 +127,6 @@ export function ChatBar({ onTranscribeAudio }: ChatBarProps) { const attachments = useStore($composerAttachments) - const queuedPromptsBySession = useStore($queuedPromptsBySession) const statusItemsBySession = useStore($statusItemsBySession) const previewStatusBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) @@ -156,26 +145,11 @@ export function ChatBar({ const popoutPosition = useStore($composerPopoutPosition) const activeQueueSessionKey = queueSessionKey || sessionId || null - const queuedPrompts = useMemo( - () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), - [activeQueueSessionKey, queuedPromptsBySession] - ) - // Status items (subagents, background processes) are keyed by the RUNTIME // session id — gateway events and process.list both speak that id. Only the // queue uses the stored-session fallback key (prompts can queue pre-resume). const statusSessionId = sessionId ?? null - const statusStackVisible = useMemo( - () => - queuedPrompts.length > 0 || - (statusSessionId - ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 || - (previewStatusBySession[statusSessionId]?.length ?? 0) > 0 - : false), - [previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId] - ) - const composerRef = useRef(null) const composerSurfaceRef = useRef(null) @@ -207,18 +181,14 @@ export function ChatBar({ position: popoutPosition }) - const prevQueueKeyRef = useRef(activeQueueSessionKey) - const drainingQueueRef = useRef(false) - // Per-entry auto-drain failure counts; bounds retries so a persistent 404 - // can't spin-loop. Cleared on success; reset naturally on remount/reconnect. - const drainFailuresRef = useRef(new Map()) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [queueEdit, setQueueEdit] = useState(null) - const queueEditRef = useRef(queueEdit) - queueEditRef.current = queueEdit + // Coordinator-owned: the draft engine reads the live queue-edit snapshot off + // this ref (to suppress its stash while editing a queued prompt) and the queue + // engine writes it — an explicit shared handle, not a back-reference. + const queueEditRef = useRef(null) const composingRef = useRef(false) // true during IME composition (CJK input) const { availableThemes, themeName } = useTheme() @@ -253,10 +223,46 @@ export function ChatBar({ stashAt } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + // The queue engine — queued turns, in-place editing, the shared drain lock, + // and bounded auto-drain. Consumes the draft API and writes `queueEditRef`. + const { + beginQueuedEdit, + drainNextQueued, + editingQueuedPrompt, + exitQueuedEdit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sendQueuedNow, + stepQueuedEdit + } = useComposerQueue({ + activeQueueSessionKey, + attachments, + busy, + clearDraft, + draftRef, + focusInput, + loadIntoComposer, + onCancel, + onSubmit, + queueEditRef, + queueSessionKey, + 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 { 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 @@ -987,102 +993,6 @@ export function ChatBar({ [cwd] ) - const beginQueuedEdit = (entry: QueuedPromptEntry) => { - if (!activeQueueSessionKey || queueEdit) { - return - } - - setQueueEdit({ - attachments: cloneAttachments($composerAttachments.get()), - draft: draftRef.current, - entryId: entry.id, - sessionKey: activeQueueSessionKey - }) - loadIntoComposer(entry.text, entry.attachments) - triggerHaptic('selection') - focusInput() - } - - // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer), - // saving the in-progress edit on each step. Stepping newer past the last - // entry exits edit mode and restores the pre-edit draft. - const stepQueuedEdit = (direction: -1 | 1) => { - if (!queueEdit) { - return false - } - - const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId) - const target = index + direction - - if (index < 0 || target < 0) { - return index >= 0 // at the oldest: swallow; missing entry: let it fall through - } - - const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { - attachments: cloneAttachments($composerAttachments.get()), - text: draftRef.current - }) - - const next = queuedPrompts[target] - - if (next) { - setQueueEdit({ ...queueEdit, entryId: next.id }) - loadIntoComposer(next.text, next.attachments) - } else { - setQueueEdit(null) - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - } - - triggerHaptic(saved ? 'success' : 'selection') - focusInput() - - return true - } - - const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { - if (!queueEdit) { - return false - } - - if (action === 'save') { - const text = draftRef.current - const next = cloneAttachments($composerAttachments.get()) - - if (!text.trim() && next.length === 0) { - return false - } - - const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) - triggerHaptic(saved ? 'success' : 'selection') - } else { - triggerHaptic('cancel') - } - - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - setQueueEdit(null) - focusInput() - - return true - } - - const queueCurrentDraft = useCallback(() => { - const text = draftRef.current - - if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) { - return false - } - - if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) { - return false - } - - clearDraft() - clearComposerAttachments() - triggerHaptic('selection') - - return true - }, [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 // are re-queued so nothing is lost — same safety net as a plain queue. @@ -1103,142 +1013,6 @@ export function ChatBar({ }) }, [activeQueueSessionKey, canSteer, clearDraft, onSteer]) - // All queue drain paths share one lock + send-then-remove sequence. - // `pickEntry` lets each caller choose head, by-id, or skip-edited. - const runDrain = useCallback( - async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { - if (drainingQueueRef.current || !activeQueueSessionKey) { - return false - } - - const entry = pickEntry(queuedPrompts) - - if (!entry) { - return false - } - - drainingQueueRef.current = true - - try { - const accepted = await Promise.resolve( - onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) - ) - - if (accepted === false) { - return false - } - - drainFailuresRef.current.delete(entry.id) - removeQueuedPrompt(activeQueueSessionKey, entry.id) - resetBrowseState(sessionId) - - return true - } finally { - drainingQueueRef.current = false - } - }, - [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] - ) - - const pickDrainHead = useCallback( - (entries: QueuedPromptEntry[]) => { - const skip = queueEditRef.current?.entryId - - return skip ? entries.find(e => e.id !== skip) : entries[0] - }, - [] // reads the edit id off a ref so the lock-holder always sees the latest - ) - - const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain]) - - const sendQueuedNow = useCallback( - (id: string) => { - if (!activeQueueSessionKey || id === queueEdit?.entryId) { - return false - } - - if (busy) { - // Promote to the head, then interrupt. The gateway always emits a - // settle (message.complete + session.info running:false) when the - // turn unwinds, and the busy→false auto-drain below sends this entry. - promoteQueuedPrompt(activeQueueSessionKey, id) - triggerHaptic('selection') - void Promise.resolve(onCancel()) - - return true - } - - // A manual send clears the auto-drain backoff so a stuck entry the user - // taps gets a fresh attempt (and re-enables auto-retry on success). - drainFailuresRef.current.delete(id) - - return runDrain(entries => entries.find(e => e.id === id)) - }, - [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain] - ) - - // Edge-independent auto-drain: send the head whenever the session is idle and - // the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g. - // a stale-session 404) can't strand the entry permanently nor spin-loop. The - // drain lock serializes sends; a remount/reconnect resets the failure counts. - const autoDrainNext = useCallback(() => { - if (busy || drainingQueueRef.current || !activeQueueSessionKey) { - return - } - - const entry = pickDrainHead(queuedPrompts) - - if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { - return - } - - const onFail = () => { - const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1 - drainFailuresRef.current.set(entry.id, fails) - - if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) { - notify({ - id: 'composer-queue-stuck', - kind: 'error', - title: t.composer.queueStuckTitle, - message: t.composer.queueStuckBody - }) - } - } - - void runDrain(() => entry) - .then(sent => { - if (!sent) { - onFail() - } - }) - .catch(onFail) - }, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t]) - - // Re-key on a runtime session-id change. A stable stored id (queueSessionKey) - // never churns, so a change there is a real session switch and must NOT - // migrate; only the runtime-derived key (queueSessionKey falsy → key is - // sessionId) churns on a backend bounce/resume of the same conversation. - useEffect(() => { - const prev = prevQueueKeyRef.current - prevQueueKeyRef.current = activeQueueSessionKey - - if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) { - return - } - - migrateQueuedPrompts(prev, activeQueueSessionKey) - }, [activeQueueSessionKey, queueSessionKey]) - - // Queued turns flow whenever the session is idle — on the busy→false settle - // edge, on mount/reconnect, and after a re-key — so a swallowed edge can't - // strand them. To cancel queued turns, the user deletes them from the panel. - useEffect(() => { - if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) { - autoDrainNext() - } - }, [autoDrainNext, busy, queuedPrompts.length]) - // Esc cancels the in-flight turn when the CHAT has focus — not just the // composer input (which has its own handler above). Clicking into the // transcript and hitting Esc now stops the run, matching the Stop button. @@ -1279,24 +1053,6 @@ export function ChatBar({ return () => window.removeEventListener('keydown', onKeyDown) }, []) - // Queue-edit cleanup: on session swap the scope effect already stashed the - // edit snapshot; only restore into the composer when still on the same scope. - useEffect(() => { - if (!queueEdit) { - return - } - - if (queueEdit.sessionKey === activeQueueSessionKey) { - if (editingQueuedPrompt) { - return - } - - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - } - - setQueueEdit(null) - }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps - const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { const submittedScope = activeQueueSessionKeyRef.current const submittedAttachments = attachments ?? [] From 773a3703bfc1f8ff2f3aef40d7a565e7f4fe1404 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:43:08 -0500 Subject: [PATCH 07/12] refactor(desktop): extract composer submit engine into useComposerSubmit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the submit orchestration out of ChatBar into composer/hooks/use-composer-submit.ts: `submitDraft` (the one decision tree — queue-edit save · slash-now-while-busy · queue · drain · send · stop), `dispatchSubmit` (the shared send-with-restore primitive + the external-submit listener), and `steerDraft`. This is the seam where the draft and queue engines meet; it now reads both clean APIs as explicit inputs instead of closing over inline state. ChatBar is left as a thin coordinator that owns the shared `queueEditRef` and wires the four engines (draft · queue · submit · metrics/voice/drop) into render. Behaviour-identical (verbatim move). Verified: typecheck clean, composer DOM repro tests (enter-submit, IME, slash-now, steer, drain) pass. --- .../composer/hooks/use-composer-submit.ts | 190 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 155 +++----------- 2 files changed, 222 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts new file mode 100644 index 00000000000..eab822d7cd8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -0,0 +1,190 @@ +import { type RefObject, useEffect, useRef } from 'react' + +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer' +import { resetBrowseState } from '@/store/composer-input-history' +import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' + +import { cloneAttachments, type QueueEditState } from '../composer-utils' +import { onComposerSubmitRequest } from '../focus' +import { composerPlainText } from '../rich-editor' +import type { ChatBarProps } from '../types' + +interface UseComposerSubmitArgs { + activeQueueSessionKey: string | null + activeQueueSessionKeyRef: RefObject + attachments: ComposerAttachment[] + busy: boolean + canSteer: boolean + clearDraft: () => void + disabled: boolean + draftRef: RefObject + drainNextQueued: () => Promise + editorRef: RefObject + exitQueuedEdit: (action: 'cancel' | 'save') => boolean + focusInput: () => void + inputDisabled: boolean + loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void + onCancel: ChatBarProps['onCancel'] + onSteer: ChatBarProps['onSteer'] + onSubmit: ChatBarProps['onSubmit'] + queueCurrentDraft: () => boolean + queueEdit: QueueEditState | null + queuedPrompts: QueuedPromptEntry[] + sessionId: string | null | undefined + setComposerText: (value: string) => void + stashAt: (scope: string | null, text?: string, attachments?: ComposerAttachment[]) => void +} + +/** + * The composer's submit engine — the orchestration seam where the draft and + * queue meet. `submitDraft` is the one decision tree (queue-edit save · slash- + * now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared + * send-with-restore primitive (re-loads + re-stashes the draft if the gateway + * rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads + * the draft + queue APIs; owns no state of its own beyond the stable + * external-submit listener ref. + */ +export function useComposerSubmit({ + activeQueueSessionKey, + activeQueueSessionKeyRef, + attachments, + busy, + canSteer, + clearDraft, + disabled, + draftRef, + drainNextQueued, + editorRef, + exitQueuedEdit, + focusInput, + inputDisabled, + loadIntoComposer, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sessionId, + setComposerText, + stashAt +}: UseComposerSubmitArgs) { + // Shared send primitive: fire onSubmit, and if the gateway rejects (accepted + // === false) or throws, re-load + re-stash the draft so the words survive. + const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { + const submittedScope = activeQueueSessionKeyRef.current + const submittedAttachments = attachments ?? [] + + const restore = () => { + loadIntoComposer(text, submittedAttachments) + stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + } + + void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) + .catch(restore) + } + + // External "submit this prompt" requests (e.g. the review pane's agent-ship + // button) route through the same send path. A ref keeps the listener stable + // while always calling the latest dispatchSubmit closure. + const dispatchSubmitRef = useRef(dispatchSubmit) + dispatchSubmitRef.current = dispatchSubmit + + useEffect( + () => + onComposerSubmitRequest(({ target, text }) => { + if (target === 'main' && !inputDisabled) { + dispatchSubmitRef.current(text) + } + }), + [inputDisabled] + ) + + const submitDraft = () => { + if (disabled) { + return + } + + // Source the text from the DOM editor, not React state. The AUI composer + // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a + // render, so on fast typing or IME composition the final keystroke(s) may + // not have synced yet — reading state here drops the message (Enter looks + // like it does nothing; typing a trailing space only "fixes" it because the + // extra input event forces a state sync). draftRef is updated on every + // input event; refresh it from the editor once more to also cover an + // in-flight keystroke that hasn't fired its input event yet. + const editor = editorRef.current + + if (editor) { + const domText = composerPlainText(editor) + + if (domText !== draftRef.current) { + draftRef.current = domText + setComposerText(domText) + } + } + + const text = draftRef.current + const payloadPresent = text.trim().length > 0 || attachments.length > 0 + + if (queueEdit) { + exitQueuedEdit('save') + } else if (busy) { + // Slash commands should execute immediately even while the agent is + // busy — they're client-side operations (/yolo, /skin, /new, /help, + // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit + // routes them to executeSlashCommand, which has its own per-command + // busy guard for commands that genuinely need an idle session (skill + // /send directives). Queuing them would make every slash command wait + // for the current turn to finish, which is how the TUI never behaves. + if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { + triggerHaptic('submit') + clearDraft() + dispatchSubmit(text) + } else if (payloadPresent) { + queueCurrentDraft() + } else { + // Stop button (the only way to reach here while busy with an empty + // composer — empty Enter is short-circuited in the keydown handler). + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + } else if (!payloadPresent && queuedPrompts.length > 0) { + void drainNextQueued() + } else if (payloadPresent) { + const submittedAttachments = cloneAttachments(attachments) + triggerHaptic('submit') + resetBrowseState(sessionId) + clearDraft() + clearComposerAttachments() + dispatchSubmit(text, submittedAttachments) + } + + focusInput() + } + + // 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 + // are re-queued so nothing is lost — same safety net as a plain queue. + const steerDraft = () => { + if (!onSteer || !canSteer) { + return + } + + const text = draftRef.current.trim() + + triggerHaptic('submit') + clearDraft() + + void Promise.resolve(onSteer(text)).then(accepted => { + if (!accepted && activeQueueSessionKey) { + enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] }) + } + }) + } + + return { dispatchSubmit, steerDraft, submitDraft } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 2e49b5ddd60..c6464e890ed 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -17,16 +17,13 @@ import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-d import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' -import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $composerAttachments, - clearComposerAttachments, - clearSessionDraft, - type ComposerAttachment + clearComposerAttachments } from '@/store/composer' import { browseBackward, @@ -43,10 +40,7 @@ import { setComposerPopoutPosition, setComposerPoppedOut } from '@/store/composer-popout' -import { - enqueueQueuedPrompt, - removeQueuedPrompt -} from '@/store/composer-queue' +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' @@ -60,7 +54,6 @@ import { useTheme } from '@/themes' import { AttachmentList } from './attachments' import { - cloneAttachments, COMPLETION_ACTIONS, COMPOSER_FADE_BACKGROUND, pickPlaceholder, @@ -72,13 +65,14 @@ import { import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' -import { markActiveComposer, onComposerSubmitRequest } from './focus' +import { markActiveComposer } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerQueue } from './hooks/use-composer-queue' +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' @@ -271,6 +265,34 @@ export function ChatBar({ const showHelpHint = isHelpHint + // The submit engine — the orchestration seam where draft + queue meet. Owns + // the submit decision tree, the send-with-restore primitive, and steer. + const { steerDraft, submitDraft } = useComposerSubmit({ + activeQueueSessionKey, + activeQueueSessionKeyRef, + attachments, + busy, + canSteer, + clearDraft, + disabled, + draftRef, + drainNextQueued, + editorRef, + exitQueuedEdit, + focusInput, + inputDisabled, + loadIntoComposer, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sessionId, + setComposerText, + stashAt + }) + // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a // *different* conversation. Critically, the first id assignment of a freshly @@ -993,26 +1015,6 @@ export function ChatBar({ [cwd] ) - // 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 - // are re-queued so nothing is lost — same safety net as a plain queue. - const steerDraft = useCallback(() => { - if (!onSteer || !canSteer) { - return - } - - const text = draftRef.current.trim() - - triggerHaptic('submit') - clearDraft() - - void Promise.resolve(onSteer(text)).then(accepted => { - if (!accepted && activeQueueSessionKey) { - enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] }) - } - }) - }, [activeQueueSessionKey, canSteer, clearDraft, onSteer]) - // Esc cancels the in-flight turn when the CHAT has focus — not just the // composer input (which has its own handler above). Clicking into the // transcript and hitting Esc now stops the run, matching the Stop button. @@ -1053,99 +1055,6 @@ export function ChatBar({ return () => window.removeEventListener('keydown', onKeyDown) }, []) - const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { - const submittedScope = activeQueueSessionKeyRef.current - const submittedAttachments = attachments ?? [] - - const restore = () => { - loadIntoComposer(text, submittedAttachments) - stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) - } - - void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) - .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) - .catch(restore) - } - - // External "submit this prompt" requests (e.g. the review pane's agent-ship - // button) route through the same send path. A ref keeps the listener stable - // while always calling the latest dispatchSubmit closure. - const dispatchSubmitRef = useRef(dispatchSubmit) - dispatchSubmitRef.current = dispatchSubmit - - useEffect( - () => - onComposerSubmitRequest(({ target, text }) => { - if (target === 'main' && !inputDisabled) { - dispatchSubmitRef.current(text) - } - }), - [inputDisabled] - ) - - const submitDraft = () => { - if (disabled) { - return - } - - // Source the text from the DOM editor, not React state. The AUI composer - // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a - // render, so on fast typing or IME composition the final keystroke(s) may - // not have synced yet — reading state here drops the message (Enter looks - // like it does nothing; typing a trailing space only "fixes" it because the - // extra input event forces a state sync). draftRef is updated on every - // input event; refresh it from the editor once more to also cover an - // in-flight keystroke that hasn't fired its input event yet. - const editor = editorRef.current - - if (editor) { - const domText = composerPlainText(editor) - - if (domText !== draftRef.current) { - draftRef.current = domText - setComposerText(domText) - } - } - - const text = draftRef.current - const payloadPresent = text.trim().length > 0 || attachments.length > 0 - - if (queueEdit) { - exitQueuedEdit('save') - } else if (busy) { - // Slash commands should execute immediately even while the agent is - // busy — they're client-side operations (/yolo, /skin, /new, /help, - // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit - // routes them to executeSlashCommand, which has its own per-command - // busy guard for commands that genuinely need an idle session (skill - // /send directives). Queuing them would make every slash command wait - // for the current turn to finish, which is how the TUI never behaves. - if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { - triggerHaptic('submit') - clearDraft() - dispatchSubmit(text) - } else if (payloadPresent) { - queueCurrentDraft() - } else { - // Stop button (the only way to reach here while busy with an empty - // composer — empty Enter is short-circuited in the keydown handler). - triggerHaptic('cancel') - void Promise.resolve(onCancel()) - } - } else if (!payloadPresent && queuedPrompts.length > 0) { - void drainNextQueued() - } else if (payloadPresent) { - const submittedAttachments = cloneAttachments(attachments) - triggerHaptic('submit') - resetBrowseState(sessionId) - clearDraft() - clearComposerAttachments() - dispatchSubmit(text, submittedAttachments) - } - - focusInput() - } - const submitUrl = () => { const url = urlValue.trim() From 33d91029b26680a466cf057844df2fcb5fb87b15 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:55:37 -0500 Subject: [PATCH 08/12] perf+fix(desktop): coalesce composer paste/input flush; scope dock glow to thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two composer fixes: - **Paste/input lag** — `flushEditorToDraft` serializes the whole editor (`composerPlainText` is O(n)); running it on every event during a burst (holding a key, or holding Cmd+V into a growing editor) was O(n²). Coalesce the input/paste path to one flush per animation frame. Lossless: the contentEditable DOM is the source of truth and submit + the compositionend / keydown paths re-read it synchronously (those stay immediate). - **Detached-composer dock glow** — was `fixed inset-x-0` (full viewport, spilled under the sessions sidebar). Switched to `absolute inset-x-0`, so it anchors to the chat-column root the docked composer centers in — the glow now spans only the thread area, matching the actual dock target. Verified: typecheck clean, 0 lint errors, composer DOM repro tests pass. --- apps/desktop/src/app/chat/composer/index.tsx | 52 +++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c6464e890ed..7dfada7ec61 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -21,10 +21,7 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { - $composerAttachments, - clearComposerAttachments -} from '@/store/composer' +import { $composerAttachments, clearComposerAttachments } from '@/store/composer' import { browseBackward, browseForward, @@ -427,7 +424,20 @@ export function ChatBar({ // Pull the live contentEditable text into draftRef + the AUI composer state // (which drives `hasComposerPayload` → the send button). Shared by the input // and compositionend paths so committed IME text reaches state through either. + // A pending coalesced flush (rAF id). `composerPlainText` serializes the whole + // editor (O(n)), so running it on every event during a burst — holding a key, + // or holding Cmd+V into a growing editor — is O(n²) across the burst. The + // contentEditable DOM is the source of truth (submit + the compositionend / + // keydown paths re-read it synchronously), so collapsing the input/paste + // flushes to one per paint is lossless. + const flushRafRef = useRef(undefined) + const flushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + flushRafRef.current = undefined + } + normalizeComposerEditorDom(editor) const nextDraft = composerPlainText(editor) @@ -440,6 +450,29 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } + // Coalesce the high-frequency input/paste flushes to one per frame. Immediate + // paths (compositionend, Enter/keydown, submit) keep calling + // flushEditorToDraft directly, which cancels any pending coalesced run first. + const scheduleFlushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + return + } + + flushRafRef.current = window.requestAnimationFrame(() => { + flushRafRef.current = undefined + flushEditorToDraft(editor) + }) + } + + useEffect( + () => () => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + } + }, + [] + ) + const handleEditorInput = (event: FormEvent) => { // During IME composition the DOM contains uncommitted preedit text // mixed with real content. Skip state writes — compositionend flushes @@ -448,7 +481,7 @@ export function ChatBar({ return } - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const handlePaste = (event: ClipboardEvent) => { @@ -498,7 +531,7 @@ export function ChatBar({ event.preventDefault() insertPlainTextAtCaret(event.currentTarget, pastedText) - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const triggerAdapter: Unstable_TriggerAdapter | null = @@ -1217,7 +1250,12 @@ export function ChatBar({ {dragging && poppedOut && (
Date: Tue, 30 Jun 2026 03:59:51 -0500 Subject: [PATCH 09/12] perf(desktop): stop ChatBar re-rendering on cross-session status/queue churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../chat/composer/hooks/use-composer-queue.ts | 14 ++++---- .../composer/hooks/use-status-presence.ts | 36 +++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 20 ++++------- apps/desktop/src/lib/use-session-slice.ts | 31 ++++++++++++++++ ui-tui/node_modules | 1 + 5 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts create mode 100644 apps/desktop/src/lib/use-session-slice.ts create mode 120000 ui-tui/node_modules 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 From 2f46fde3f51db2db1cb0854ce1bd2b5c07c16a0d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 04:27:22 -0500 Subject: [PATCH 10/12] fix(desktop): keep queued composer edit ref in sync Update the shared queued-edit ref synchronously with React state so draft persistence sees the correct edit mode while loading and restoring queued prompts. Also drop the accidental node_modules symlink from the PR. --- .../chat/composer/hooks/use-composer-queue.ts | 23 ++++++++++++++----- ui-tui/node_modules | 1 - 2 files changed, 17 insertions(+), 7 deletions(-) delete mode 120000 ui-tui/node_modules 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 7b02cd955ac..c40d56a4826 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 @@ -69,6 +69,14 @@ export function useComposerQueue({ const [queueEdit, setQueueEdit] = useState(null) queueEditRef.current = queueEdit + const setQueueEditSnapshot = useCallback( + (next: QueueEditState | null) => { + queueEditRef.current = next + setQueueEdit(next) + }, + [queueEditRef] + ) + const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null const prevQueueKeyRef = useRef(activeQueueSessionKey) @@ -80,7 +88,7 @@ export function useComposerQueue({ return } - setQueueEdit({ + setQueueEditSnapshot({ attachments: cloneAttachments(attachments), draft: draftRef.current, entryId: entry.id, @@ -114,10 +122,10 @@ export function useComposerQueue({ const next = queuedPrompts[target] if (next) { - setQueueEdit({ ...queueEdit, entryId: next.id }) + setQueueEditSnapshot({ ...queueEdit, entryId: next.id }) loadIntoComposer(next.text, next.attachments) } else { - setQueueEdit(null) + setQueueEditSnapshot(null) loadIntoComposer(queueEdit.draft, queueEdit.attachments) } @@ -146,8 +154,8 @@ export function useComposerQueue({ triggerHaptic('cancel') } + setQueueEditSnapshot(null) loadIntoComposer(queueEdit.draft, queueEdit.attachments) - setQueueEdit(null) focusInput() return true @@ -319,11 +327,14 @@ export function useComposerQueue({ return } + setQueueEditSnapshot(null) loadIntoComposer(queueEdit.draft, queueEdit.attachments) + + return } - setQueueEdit(null) - }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps + setQueueEditSnapshot(null) + }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit, setQueueEditSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps return { beginQueuedEdit, diff --git a/ui-tui/node_modules b/ui-tui/node_modules deleted file mode 120000 index 001f2a89107..00000000000 --- a/ui-tui/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/brooklyn/www/hermes-agent/ui-tui/node_modules \ No newline at end of file From 9998ff4cbebc9812aec66a7d0839665fa7fbfa36 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 04:32:39 -0500 Subject: [PATCH 11/12] fix(desktop): persist live composer draft before swap/reload Sync the contentEditable text before stash-on-scope-change and pagehide so pending rAF draft flushes cannot drop the newest keystrokes. --- .../chat/composer/hooks/use-composer-draft.ts | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 4943838e790..058155add2d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -176,6 +176,26 @@ export function useComposerDraft({ } }, [setComposerText]) + // Read the editor's current plain text into draftRef + composer state. This + // closes the "queued rAF flush hasn't run yet" window so scope-swap/pagehide + // persistence captures the latest keystrokes. + const syncDraftFromEditor = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return draftRef.current + } + + const text = composerPlainText(editor) + + if (text !== draftRef.current) { + draftRef.current = text + setComposerText(text) + } + + return text + }, [setComposerText]) + // Imperative draft sync — the spine of the "work only when work is to be // performed" model. Subscribing to the composer runtime directly (not // `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of @@ -267,28 +287,31 @@ export function useComposerDraft({ loadIntoComposer(text, attachments) return () => { + const latestText = syncDraftFromEditor() const editing = queueEditRef.current if (editing?.sessionKey === activeQueueSessionKey) { stashAt(activeQueueSessionKey, editing.draft, editing.attachments) } else if (!isBrowsingHistory(sessionId)) { - stashAt(activeQueueSessionKey) + stashAt(activeQueueSessionKey, latestText) } } }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R - // inside the debounce window would drop trailing keystrokes without this. + // inside the debounce/rAF window would drop trailing keystrokes without this. useEffect(() => { const flushPendingDraftPersist = () => { - const pending = pendingDraftPersistRef.current + const scope = activeQueueSessionKeyRef.current + const editing = queueEditRef.current - if (!pending) { + if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { return } + const latestText = syncDraftFromEditor() pendingDraftPersistRef.current = null - stashAt(pending.scope, pending.text) + stashAt(scope, latestText) } window.addEventListener('pagehide', flushPendingDraftPersist) @@ -297,7 +320,7 @@ export function useComposerDraft({ window.removeEventListener('pagehide', flushPendingDraftPersist) flushPendingDraftPersist() } - }, []) + }, [queueEditRef, syncDraftFromEditor]) return { activeQueueSessionKeyRef, From aa07400e1a8f37e835714d8a68c1b72e634e3793 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 04:33:08 -0500 Subject: [PATCH 12/12] chore(desktop): keep draft persist effect deps clean Replace direct queueEditRef reads in cleanup/pagehide with a mirrored local ref so hook deps stay stable and eslint-clean. --- .../src/app/chat/composer/hooks/use-composer-draft.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 058155add2d..5f8bcf8e233 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -79,6 +79,8 @@ export function useComposerDraft({ activeQueueSessionKeyRef.current = activeQueueSessionKey const sessionIdRef = useRef(sessionId) sessionIdRef.current = sessionId + const queueEditStateRef = useRef(queueEditRef.current) + queueEditStateRef.current = queueEditRef.current const [focusRequestId, setFocusRequestId] = useState(0) @@ -288,7 +290,7 @@ export function useComposerDraft({ return () => { const latestText = syncDraftFromEditor() - const editing = queueEditRef.current + const editing = queueEditStateRef.current if (editing?.sessionKey === activeQueueSessionKey) { stashAt(activeQueueSessionKey, editing.draft, editing.attachments) @@ -303,7 +305,7 @@ export function useComposerDraft({ useEffect(() => { const flushPendingDraftPersist = () => { const scope = activeQueueSessionKeyRef.current - const editing = queueEditRef.current + const editing = queueEditStateRef.current if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { return @@ -320,7 +322,7 @@ export function useComposerDraft({ window.removeEventListener('pagehide', flushPendingDraftPersist) flushPendingDraftPersist() } - }, [queueEditRef, syncDraftFromEditor]) + }, [syncDraftFromEditor]) return { activeQueueSessionKeyRef,