From 7337248a4c182d65b6c101acac1e21be5101c4cc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:15:10 -0500 Subject: [PATCH] refactor(desktop): extract submit pipeline into use-prompt-actions/submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the slash dispatcher, the next-largest body unit was submitPromptText — a ~280-line submit pipeline. Lift it into a colocated useSubmitPrompt sub-hook (use-prompt-actions/submit.ts) with a typed SubmitPromptDeps object; body moves verbatim. SubmitTextOptions moves to utils.ts (shared by submit + submitText). Pure restructuring, no behaviour change (full use-prompt-actions suite green). index.ts: 1,212 -> 937. --- .../session/hooks/use-prompt-actions/index.ts | 303 +--------------- .../hooks/use-prompt-actions/submit.ts | 342 ++++++++++++++++++ .../session/hooks/use-prompt-actions/utils.ts | 6 + 3 files changed, 362 insertions(+), 289 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index d3dec5847c5..e6f6b4bcaec 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -6,21 +6,18 @@ import { transcribeAudio } from '@/hermes' import { useI18n } from '@/i18n' import { stripAnsi } from '@/lib/ansi' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' -import { optimisticAttachmentRef, pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { clearClarifyRequest } from '@/store/clarify' import { $composerAttachments, - clearComposerAttachments, type ComposerAttachment, setComposerAttachmentUploadState, - terminalContextBlocksFromDraft, updateComposerAttachment } from '@/store/composer' import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' -import { requestDesktopOnboarding } from '@/store/onboarding' import { clearPreviewArtifacts } from '@/store/preview-status' import { clearAllPrompts } from '@/store/prompts' import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' @@ -38,19 +35,19 @@ import type { } from '../../../types' import { useSlashCommand } from './slash' +import { useSubmitPrompt } from './submit' import { - _submitInFlight, appendText, blobToDataUrl, delay, friendlyRemoteAttachError, type GatewayRequest, inlineErrorMessage, - isProviderSetupError, isSessionBusyError, isSessionNotFoundError, readFileDataUrlForAttach, readImageForRemoteAttach, + type SubmitTextOptions, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry @@ -174,11 +171,6 @@ interface PromptActionsOptions { ) => ClientSessionState } -interface SubmitTextOptions { - attachments?: ComposerAttachment[] - fromQueue?: boolean -} - /** Everything a slash handler needs about the invocation it's serving. */ interface RestoreMessageTarget { @@ -353,284 +345,17 @@ export function usePromptActions({ } }, [activeSessionId, composerAttachments, eagerlyUploadAttachment]) - const submitPromptText = useCallback( - async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() - const usingComposerAttachments = !options?.attachments - - // Drop undefined/null holes a session switch or draft restore can leave in - // the attachments array (same bug class as AttachmentList #49624). Without - // this, the sibling iterations below (a.kind / a.label / a.refText, and the - // sync step) throw "Cannot read properties of undefined (reading 'refText')" - // and break the chat surface. - const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment => - Boolean(a) - ) - - const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') - const hasImage = attachments.some(a => a.kind === 'image') - - // Refs are recomputed after sync (file.attach rewrites @file: refs to - // workspace-relative paths the remote gateway can resolve). Seed the - // optimistic message with the pre-sync refs, then rewrite once synced. - // Images use their base64 preview so the thumbnail renders inline without - // a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef. - let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) - - const buildContextText = (atts: ComposerAttachment[]): string => { - // atts may be the post-sync array, which can reintroduce holes; filter - // before touching a.refText / a.kind. - const present = atts.filter((a): a is ComposerAttachment => Boolean(a)) - - const contextRefs = present - .map(a => a.refText) - .filter(Boolean) - .join('\n') - - return ( - [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') || - (present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '') - ) - } - - // Queue drains fire on the busy→false settle edge, where busyRef (synced - // from $busy by a separate effect) may still read true — honoring it would - // bounce the drained send. The drain lock serializes them; the user path - // keeps the guard so a stray Enter mid-turn can't double-submit. - const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage) - - if (!hasSendable || (!options?.fromQueue && busyRef.current)) { - return false - } - - // One submit in flight per session — drop any concurrent re-fire so a - // stalled turn can't stack the same prompt into multiple real turns. - const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' - - if (_submitInFlight.has(submitLockKey)) { - return false - } - - _submitInFlight.add(submitLockKey) - let submitLockReleased = false - - const releaseSubmitLock = () => { - if (!submitLockReleased) { - submitLockReleased = true - _submitInFlight.delete(submitLockKey) - } - } - - const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - - const buildUserMessage = (): ChatMessage => ({ - id: optimisticId, - role: 'user', - parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], - attachmentRefs - }) - - const releaseBusy = () => { - releaseSubmitLock() - setMutableRef(busyRef, false) - setBusy(false) - setAwaitingResponse(false) - } - - // Idempotent optimistic insert — re-running with the resolved sessionId - // after createBackendSessionForSend just overwrites with the same id. - const seedOptimistic = (sid: string) => - updateSessionState( - sid, - state => ({ - ...state, - messages: state.messages.some(m => m.id === optimisticId) - ? state.messages - : [...state.messages, buildUserMessage()], - busy: true, - awaitingResponse: true, - pendingBranchGroup: null, - sawAssistantPayload: false, - // Fresh submit = new turn — clear any leftover interrupt flag, else - // mutateStream/completeAssistantMessage drop every delta of this turn - // (what made drained-after-interrupt sends go silent). - interrupted: false - }), - selectedStoredSessionIdRef.current - ) - - // After sync rewrites refs, refresh the optimistic message in place so the - // transcript shows the resolved @file: ref rather than the local path. - const rewriteOptimistic = (sid: string) => - updateSessionState( - sid, - state => ({ - ...state, - messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) - }), - selectedStoredSessionIdRef.current - ) - - const dropOptimistic = (sid: null | string) => { - if (!sid) { - setMessages(current => current.filter(m => m.id !== optimisticId)) - - return - } - - updateSessionState( - sid, - state => ({ - ...state, - messages: state.messages.filter(m => m.id !== optimisticId), - busy: false, - awaitingResponse: false, - pendingBranchGroup: null - }), - selectedStoredSessionIdRef.current - ) - } - - setMutableRef(busyRef, true) - setBusy(true) - setAwaitingResponse(true) - clearNotifications() - - let sessionId: null | string = activeSessionId - - if (sessionId) { - seedOptimistic(sessionId) - } else { - setMessages(current => [...current, buildUserMessage()]) - } - - if (!sessionId) { - try { - sessionId = await createBackendSessionForSend(visibleText) - } catch (err) { - dropOptimistic(null) - releaseBusy() - notifyError(err, copy.sessionUnavailable) - - return false - } - - if (!sessionId) { - dropOptimistic(null) - releaseBusy() - notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) - - return false - } - - seedOptimistic(sessionId) - } - - try { - const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { - updateComposerAttachments: usingComposerAttachments - }) - - // Rewrite the optimistic message + prompt text with the synced refs so - // the gateway receives @file: paths that resolve in its workspace. - // (Images keep their inline base64 preview — see optimisticAttachmentRef.) - attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) - rewriteOptimistic(sessionId) - const text = buildContextText(syncedAttachments) - - // On sleep/wake the gateway's in-memory session may have been cleared - // while the desktop app still holds the old session ID. Detect this, - // resume the stored session to re-register it, and retry once. - let submitErr: unknown = null - - try { - await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text })) - } catch (firstErr) { - if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { - // Re-register the session in the gateway and get a fresh live ID. - const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current - }) - - const recoveredId = resumed?.session_id - - if (recoveredId) { - activeSessionIdRef.current = recoveredId - await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) - } else { - submitErr = firstErr - } - } else { - submitErr = firstErr - } - } - - if (submitErr !== null) { - throw submitErr - } - - if (usingComposerAttachments) { - clearComposerAttachments() - } - - // Submit landed — the turn now runs (busy stays true), but the submit - // window is closed, so release the lock for the next (sequential) send. - releaseSubmitLock() - - return true - } catch (err) { - releaseBusy() - - // A queued drain that raced a not-yet-settled turn gets a transient - // "session busy" (4009). Don't surface an error bubble/toast — the entry - // stays queued and the composer's bounded auto-drain retries when idle. - if (options?.fromQueue && isSessionBusyError(err)) { - return false - } - - const message = inlineErrorMessage(err, copy.promptFailed) - - updateSessionState(sessionId, state => ({ - ...state, - messages: [ - ...state.messages, - { - id: `assistant-error-${Date.now()}`, - role: 'assistant', - parts: [], - error: message || copy.promptFailed, - branchGroupId: state.pendingBranchGroup ?? undefined - } - ], - busy: false, - awaitingResponse: false, - pendingBranchGroup: null, - sawAssistantPayload: true - })) - - if (isProviderSetupError(err)) { - requestDesktopOnboarding(copy.providerCredentialRequired) - - return false - } - - notifyError(err, copy.promptFailed) - - return false - } - }, - [ - activeSessionId, - activeSessionIdRef, - busyRef, - copy, - createBackendSessionForSend, - requestGateway, - selectedStoredSessionIdRef, - syncAttachmentsForSubmit, - updateSessionState - ] - ) + const submitPromptText = useSubmitPrompt({ + activeSessionId, + activeSessionIdRef, + busyRef, + copy, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncAttachmentsForSubmit, + updateSessionState + }) // Queue a handoff of this session to a messaging platform and watch it to // a terminal state. We only write the request through the gateway; the diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts new file mode 100644 index 00000000000..1975bf189b1 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -0,0 +1,342 @@ +import { type MutableRefObject, useCallback } from 'react' + +import type { Translations } from '@/i18n' +import { type ChatMessage, textPart } from '@/lib/chat-messages' +import { optimisticAttachmentRef } from '@/lib/chat-runtime' +import { setMutableRef } from '@/lib/mutable-ref' +import { + $composerAttachments, + clearComposerAttachments, + type ComposerAttachment, + terminalContextBlocksFromDraft +} from '@/store/composer' +import { clearNotifications, notify, notifyError } from '@/store/notifications' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { setAwaitingResponse, setBusy, setMessages } from '@/store/session' + +import type { ClientSessionState } from '../../../types' + +import { + _submitInFlight, + type GatewayRequest, + inlineErrorMessage, + isProviderSetupError, + isSessionBusyError, + isSessionNotFoundError, + type SubmitTextOptions, + withSessionBusyRetry +} from './utils' + +interface SubmitPromptDeps { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject + busyRef: MutableRefObject + copy: Translations['desktop'] + createBackendSessionForSend: (preview?: string | null) => Promise + requestGateway: GatewayRequest + selectedStoredSessionIdRef: MutableRefObject + syncAttachmentsForSubmit: ( + sessionId: string, + attachments: ComposerAttachment[], + options?: { updateComposerAttachments?: boolean } + ) => Promise + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +/** The prompt submit pipeline, extracted from usePromptActions. */ +export function useSubmitPrompt(deps: SubmitPromptDeps) { + const { + activeSessionId, + activeSessionIdRef, + busyRef, + copy, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncAttachmentsForSubmit, + updateSessionState + } = deps + + return useCallback( + async (rawText: string, options?: SubmitTextOptions) => { + const visibleText = rawText.trim() + const usingComposerAttachments = !options?.attachments + + // Drop undefined/null holes a session switch or draft restore can leave in + // the attachments array (same bug class as AttachmentList #49624). Without + // this, the sibling iterations below (a.kind / a.label / a.refText, and the + // sync step) throw "Cannot read properties of undefined (reading 'refText')" + // and break the chat surface. + const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment => + Boolean(a) + ) + + const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') + const hasImage = attachments.some(a => a.kind === 'image') + + // Refs are recomputed after sync (file.attach rewrites @file: refs to + // workspace-relative paths the remote gateway can resolve). Seed the + // optimistic message with the pre-sync refs, then rewrite once synced. + // Images use their base64 preview so the thumbnail renders inline without + // a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef. + let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + + const buildContextText = (atts: ComposerAttachment[]): string => { + // atts may be the post-sync array, which can reintroduce holes; filter + // before touching a.refText / a.kind. + const present = atts.filter((a): a is ComposerAttachment => Boolean(a)) + + const contextRefs = present + .map(a => a.refText) + .filter(Boolean) + .join('\n') + + return ( + [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') || + (present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '') + ) + } + + // Queue drains fire on the busy→false settle edge, where busyRef (synced + // from $busy by a separate effect) may still read true — honoring it would + // bounce the drained send. The drain lock serializes them; the user path + // keeps the guard so a stray Enter mid-turn can't double-submit. + const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage) + + if (!hasSendable || (!options?.fromQueue && busyRef.current)) { + return false + } + + // One submit in flight per session — drop any concurrent re-fire so a + // stalled turn can't stack the same prompt into multiple real turns. + const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' + + if (_submitInFlight.has(submitLockKey)) { + return false + } + + _submitInFlight.add(submitLockKey) + let submitLockReleased = false + + const releaseSubmitLock = () => { + if (!submitLockReleased) { + submitLockReleased = true + _submitInFlight.delete(submitLockKey) + } + } + + const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + const buildUserMessage = (): ChatMessage => ({ + id: optimisticId, + role: 'user', + parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], + attachmentRefs + }) + + const releaseBusy = () => { + releaseSubmitLock() + setMutableRef(busyRef, false) + setBusy(false) + setAwaitingResponse(false) + } + + // Idempotent optimistic insert — re-running with the resolved sessionId + // after createBackendSessionForSend just overwrites with the same id. + const seedOptimistic = (sid: string) => + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.some(m => m.id === optimisticId) + ? state.messages + : [...state.messages, buildUserMessage()], + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + // Fresh submit = new turn — clear any leftover interrupt flag, else + // mutateStream/completeAssistantMessage drop every delta of this turn + // (what made drained-after-interrupt sends go silent). + interrupted: false + }), + selectedStoredSessionIdRef.current + ) + + // After sync rewrites refs, refresh the optimistic message in place so the + // transcript shows the resolved @file: ref rather than the local path. + const rewriteOptimistic = (sid: string) => + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) + }), + selectedStoredSessionIdRef.current + ) + + const dropOptimistic = (sid: null | string) => { + if (!sid) { + setMessages(current => current.filter(m => m.id !== optimisticId)) + + return + } + + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.filter(m => m.id !== optimisticId), + busy: false, + awaitingResponse: false, + pendingBranchGroup: null + }), + selectedStoredSessionIdRef.current + ) + } + + setMutableRef(busyRef, true) + setBusy(true) + setAwaitingResponse(true) + clearNotifications() + + let sessionId: null | string = activeSessionId + + if (sessionId) { + seedOptimistic(sessionId) + } else { + setMessages(current => [...current, buildUserMessage()]) + } + + if (!sessionId) { + try { + sessionId = await createBackendSessionForSend(visibleText) + } catch (err) { + dropOptimistic(null) + releaseBusy() + notifyError(err, copy.sessionUnavailable) + + return false + } + + if (!sessionId) { + dropOptimistic(null) + releaseBusy() + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return false + } + + seedOptimistic(sessionId) + } + + try { + const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { + updateComposerAttachments: usingComposerAttachments + }) + + // Rewrite the optimistic message + prompt text with the synced refs so + // the gateway receives @file: paths that resolve in its workspace. + // (Images keep their inline base64 preview — see optimisticAttachmentRef.) + attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + rewriteOptimistic(sessionId) + const text = buildContextText(syncedAttachments) + + // On sleep/wake the gateway's in-memory session may have been cleared + // while the desktop app still holds the old session ID. Detect this, + // resume the stored session to re-register it, and retry once. + let submitErr: unknown = null + + try { + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text })) + } catch (firstErr) { + if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { + // Re-register the session in the gateway and get a fresh live ID. + const resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: selectedStoredSessionIdRef.current + }) + + const recoveredId = resumed?.session_id + + if (recoveredId) { + activeSessionIdRef.current = recoveredId + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) + } else { + submitErr = firstErr + } + } else { + submitErr = firstErr + } + } + + if (submitErr !== null) { + throw submitErr + } + + if (usingComposerAttachments) { + clearComposerAttachments() + } + + // Submit landed — the turn now runs (busy stays true), but the submit + // window is closed, so release the lock for the next (sequential) send. + releaseSubmitLock() + + return true + } catch (err) { + releaseBusy() + + // A queued drain that raced a not-yet-settled turn gets a transient + // "session busy" (4009). Don't surface an error bubble/toast — the entry + // stays queued and the composer's bounded auto-drain retries when idle. + if (options?.fromQueue && isSessionBusyError(err)) { + return false + } + + const message = inlineErrorMessage(err, copy.promptFailed) + + updateSessionState(sessionId, state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + parts: [], + error: message || copy.promptFailed, + branchGroupId: state.pendingBranchGroup ?? undefined + } + ], + busy: false, + awaitingResponse: false, + pendingBranchGroup: null, + sawAssistantPayload: true + })) + + if (isProviderSetupError(err)) { + requestDesktopOnboarding(copy.providerCredentialRequired) + + return false + } + + notifyError(err, copy.promptFailed) + + return false + } + }, + [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncAttachmentsForSubmit, + updateSessionState + ] + ) +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 497ad8fdd6c..d3533f4d688 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -4,6 +4,7 @@ import { translateNow, type Translations } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/desktop-slash-commands' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import type { ComposerAttachment } from '@/store/composer' export type GatewayRequest = (method: string, params?: Record) => Promise @@ -209,3 +210,8 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ return -1 } + +export interface SubmitTextOptions { + attachments?: ComposerAttachment[] + fromQueue?: boolean +}