From 773a3703bfc1f8ff2f3aef40d7a565e7f4fe1404 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:43:08 -0500 Subject: [PATCH] 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()