import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' import { type MutableRefObject, useCallback } from 'react' import { getProfiles, transcribeAudio } from '@/hermes' import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' import { attachmentDisplayText, INTERRUPTED_MARKER, parseCommandDispatch, parseSlashCommand, pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { type CommandsCatalogLike, desktopSlashUnavailableMessage, filterDesktopCommandsCatalog, isDesktopSlashCommand } from '@/lib/desktop-slash-commands' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { setSessionYolo } from '@/lib/yolo-session' import { $composerAttachments, addComposerAttachment, clearComposerAttachments, type ComposerAttachment, terminalContextBlocksFromDraft } from '@/store/composer' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $busy, $messages, $yoloActive, setAwaitingResponse, setBusy, setMessages, setSessions, setYoloActive } from '@/store/session' import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types' function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener('load', () => { if (typeof reader.result === 'string') { resolve(reader.result) } else { reject(new Error('Could not read recorded audio')) } }) reader.addEventListener('error', () => reject(reader.error || new Error('Could not read recorded audio'))) reader.readAsDataURL(blob) }) } function isProviderSetupError(error: unknown) { const message = error instanceof Error ? error.message : String(error) return isProviderSetupErrorMessage(message) } function inlineErrorMessage(error: unknown, fallback: string): string { const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() } interface PromptActionsOptions { activeSessionId: string | null activeSessionIdRef: MutableRefObject busyRef: MutableRefObject branchCurrentSession: () => Promise createBackendSessionForSend: (preview?: string | null) => Promise handleSkinCommand: (arg: string) => string refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void sttEnabled: boolean updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, storedSessionId?: string | null ) => ClientSessionState } interface SubmitTextOptions { attachments?: ComposerAttachment[] fromQueue?: boolean } function renderCommandsCatalog(catalog: CommandsCatalogLike): string { const desktopCatalog = filterDesktopCommandsCatalog(catalog) const sections = desktopCatalog.categories?.length ? desktopCatalog.categories : [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }] const body = sections .filter(section => section.pairs.length > 0) .map(section => { const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`) return [`${section.name}:`, ...rows].join('\n') }) .join('\n\n') const tail = [ desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '', desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : '' ] .filter(Boolean) .join('\n') return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n') } function slashStatusText(command: string, output: string): string { return [`slash:${command}`, output.trim()].filter(Boolean).join('\n') } function appendText(message: AppendMessage): string { return message.content .map(part => ('text' in part ? part.text : '')) .join('') .trim() } function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number { return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length } export function usePromptActions({ activeSessionId, activeSessionIdRef, busyRef, branchCurrentSession, createBackendSessionForSend, handleSkinCommand, refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, sttEnabled, updateSessionState }: PromptActionsOptions) { const appendSessionTextMessage = useCallback( (sessionId: string, role: ChatMessage['role'], text: string) => { const body = text.trim() if (!body) { return } updateSessionState( sessionId, state => ({ ...state, messages: [ ...state.messages, { id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role, parts: [textPart(body)] } ] }), selectedStoredSessionIdRef.current ) }, [selectedStoredSessionIdRef, updateSessionState] ) const syncImageAttachmentsForSubmit = useCallback( async ( sessionId: string, attachments: ComposerAttachment[], options: { updateComposerAttachments?: boolean } = {} ) => { const updateComposerAttachments = options.updateComposerAttachments ?? true const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path) for (const attachment of images) { if (attachment.attachedSessionId === sessionId) { continue } const result = await requestGateway('image.attach', { session_id: sessionId, path: attachment.path }) if (!result.attached) { const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image') throw new Error(result.message || `Could not attach ${label}`) } const attachedPath = result.path || attachment.path if (updateComposerAttachments) { addComposerAttachment({ ...attachment, id: attachment.id, label: attachedPath ? pathLabel(attachedPath) : attachment.label, path: attachedPath, attachedSessionId: sessionId }) } } }, [requestGateway] ) const submitPromptText = useCallback( async (rawText: string, options?: SubmitTextOptions) => { const visibleText = rawText.trim() const usingComposerAttachments = !options?.attachments const attachments = options?.attachments ?? $composerAttachments.get() const contextRefs = attachments .map(a => a.refText) .filter(Boolean) .join('\n') const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') const hasImage = attachments.some(a => a.kind === 'image') const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r)) const text = [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '') if (!text || busyRef.current) { return false } const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const userMessage: ChatMessage = { id: optimisticId, role: 'user', parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], attachmentRefs } const releaseBusy = () => { 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, userMessage], busy: true, awaitingResponse: true, pendingBranchGroup: null, sawAssistantPayload: false, interrupted: state.interrupted }), 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, userMessage]) } if (!sessionId) { try { sessionId = await createBackendSessionForSend(visibleText) } catch (err) { dropOptimistic(null) releaseBusy() notifyError(err, 'Session unavailable') return false } if (!sessionId) { dropOptimistic(null) releaseBusy() notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) return false } seedOptimistic(sessionId) } try { await syncImageAttachmentsForSubmit(sessionId, attachments, { updateComposerAttachments: usingComposerAttachments }) await requestGateway('prompt.submit', { session_id: sessionId, text }) if (usingComposerAttachments) { clearComposerAttachments() } return true } catch (err) { const message = inlineErrorMessage(err, 'Prompt failed') releaseBusy() updateSessionState(sessionId, state => ({ ...state, messages: [ ...state.messages, { id: `assistant-error-${Date.now()}`, role: 'assistant', parts: [], error: message || 'Prompt failed', branchGroupId: state.pendingBranchGroup ?? undefined } ], busy: false, awaitingResponse: false, pendingBranchGroup: null, sawAssistantPayload: true })) if (isProviderSetupError(err)) { requestDesktopOnboarding('Add a provider credential before sending your first message.') return false } notifyError(err, 'Prompt failed') return false } }, [ activeSessionId, busyRef, createBackendSessionForSend, requestGateway, selectedStoredSessionIdRef, syncImageAttachmentsForSubmit, updateSessionState ] ) const executeSlashCommand = useCallback( async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => { const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise => { const command = commandText.trim() const { name, arg } = parseSlashCommand(command) const normalizedName = name.toLowerCase() if (!name) { const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) if (sessionId) { appendSessionTextMessage(sessionId, 'system', 'empty slash command') } return } if (normalizedName === 'new' || normalizedName === 'reset') { startFreshSessionDraft() return } if (normalizedName === 'branch' || normalizedName === 'fork') { await branchCurrentSession() return } // /yolo maps to the status-bar YOLO control — a per-session approval // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm // it locally; the session-create path applies it on the first message. if (normalizedName === 'yolo') { const sid = sessionHint || activeSessionIdRef.current const next = !$yoloActive.get() if (!sid) { setYoloActive(next) notify({ kind: 'success', message: next ? 'YOLO armed for this chat' : 'YOLO off' }) return } try { const active = await setSessionYolo(requestGateway, sid, next) appendSessionTextMessage(sid, 'system', `YOLO ${active ? 'on' : 'off'} for this session`) } catch { notify({ kind: 'error', title: 'YOLO', message: 'Could not toggle YOLO' }) } return } if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) { notify({ kind: 'success', message: handleSkinCommand(arg) }) return } // /profile selects which profile new chats open in — no app relaunch. // A profile is per-session now, so an existing thread can't change its // profile mid-stream; `/profile ` instead points the next new chat // (and the current empty draft) at that profile's backend. if (normalizedName === 'profile') { const target = arg.trim() const current = normalizeProfileKey($activeGatewayProfile.get()) if (!target) { notify({ kind: 'success', message: `Profile: ${current}. Use /profile or the "New session" picker to start a chat in another profile.` }) return } try { const { profiles } = await getProfiles() const match = profiles.find(profile => profile.name === target) if (!match) { notify({ kind: 'error', title: 'Unknown profile', message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}` }) return } const key = normalizeProfileKey(match.name) $newChatProfile.set(key) // Swap the live gateway now so an empty draft sends into this // profile immediately; an existing thread keeps its own profile. await ensureGatewayProfile(key) notify({ kind: 'success', message: `New chats will use profile ${match.name}.` }) } catch (err) { notifyError(err, 'Failed to set profile') } return } const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) if (!sessionId) { notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) return } const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) // /title renames the session. Route through the gateway's // `session.title` RPC — the same path the TUI uses — NOT the REST // renameSession endpoint and NOT the slash worker. // // Why not the slash worker: it's a separate HermesCLI subprocess whose // SQLite write to the shared state.db can silently fail (notably on // Windows), and it never refreshes the sidebar. // // Why not REST renameSession: `sessionId` here is the *runtime* session // id returned by session.create — it is NOT the stored DB `sessions.id`, // and session.create deliberately does not persist a DB row until the // first turn. The REST PATCH endpoint resolves against the sessions // table, so a runtime id (or a brand-new, not-yet-persisted session) // 404s with "Session not found" on every platform. See #38508 / #38576. // // session.title maps the runtime id to the in-memory session, writes // through the gateway's own DB connection, and QUEUES the title // (`pending: true`) when the row isn't persisted yet — so it works for a // fresh chat too. refreshSessions() then pulls the authoritative title // back into the sidebar. A bare `/title` (no arg) still falls through to // the worker to display the current title. if (normalizedName === 'title' && arg) { try { const result = await requestGateway('session.title', { session_id: sessionId, title: arg }) const finalTitle = (result?.title || arg).trim() const queued = result?.pending === true setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) await refreshSessions().catch(() => undefined) renderSlashOutput( finalTitle ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` : 'Session title cleared.' ) } catch (err) { renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } return } if (normalizedName === 'skin') { renderSlashOutput(handleSkinCommand(arg)) return } if (name === 'help' || name === 'commands') { try { const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) renderSlashOutput(renderCommandsCatalog(catalog)) } catch (err) { renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } return } if (!isDesktopSlashCommand(name)) { renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) return } try { const result = await requestGateway('slash.exec', { session_id: sessionId, command: command.replace(/^\/+/, '') }) const body = result?.output || `/${name}: no output` renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body) return } catch { // Fall back to command.dispatch for skill/send/alias directives. } try { const dispatch = parseCommandDispatch( await requestGateway('command.dispatch', { session_id: sessionId, name, arg }) ) if (!dispatch) { renderSlashOutput('error: invalid response: command.dispatch') return } if (dispatch.type === 'exec' || dispatch.type === 'plugin') { renderSlashOutput(dispatch.output ?? '(no output)') return } if (dispatch.type === 'alias') { await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false) return } const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? '' if (!message) { renderSlashOutput( `/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}` ) return } if (dispatch.type === 'skill') { renderSlashOutput(`⚡ loading skill: ${dispatch.name}`) } if (busyRef.current) { renderSlashOutput('session busy — /interrupt the current turn before sending this command') return } await submitPromptText(message) } catch (err) { renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } } await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true) }, [ activeSessionIdRef, appendSessionTextMessage, branchCurrentSession, busyRef, createBackendSessionForSend, handleSkinCommand, refreshSessions, requestGateway, startFreshSessionDraft, submitPromptText ] ) const submitText = useCallback( async (rawText: string, options?: SubmitTextOptions) => { const visibleText = rawText.trim() const attachments = options?.attachments ?? $composerAttachments.get() if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { triggerHaptic('selection') await executeSlashCommand(visibleText) return true } return await submitPromptText(rawText, options) }, [executeSlashCommand, submitPromptText] ) const transcribeVoiceAudio = useCallback( async (audio: Blob) => { if (!sttEnabled) { throw new Error('Speech-to-text is disabled in settings.') } const dataUrl = await blobToDataUrl(audio) const result = await transcribeAudio(dataUrl, audio.type) return result.transcript }, [sttEnabled] ) const cancelRun = useCallback(async () => { const sessionId = activeSessionId || activeSessionIdRef.current setMutableRef(busyRef, false) setBusy(false) setAwaitingResponse(false) const finalizeMessages = (messages: ChatMessage[]) => messages.map(message => message.pending ? { ...message, parts: chatMessageText(message).trim() ? appendTextPart(message.parts, INTERRUPTED_MARKER) : [...message.parts, textPart(INTERRUPTED_MARKER.trim())], pending: false } : message ) if (!sessionId) { setMessages(finalizeMessages($messages.get())) return } updateSessionState(sessionId, state => { const streamId = state.streamId const messages = streamId ? state.messages.map(message => message.id === streamId ? { ...message, parts: chatMessageText(message).trim() ? appendTextPart(message.parts, INTERRUPTED_MARKER) : [...message.parts, textPart(INTERRUPTED_MARKER.trim())], pending: false } : message ) : finalizeMessages(state.messages) return { ...state, messages, busy: false, awaitingResponse: false, streamId: null, pendingBranchGroup: null, interrupted: true } }) try { await requestGateway('session.interrupt', { session_id: sessionId }) } catch (err) { notifyError(err, 'Stop failed') } }, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState]) const reloadFromMessage = useCallback( async (parentId: string | null) => { if (!activeSessionId || $busy.get()) { return } const messages = $messages.get() const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1 const userIndex = parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user') : -1 if (userIndex < 0) { return } const absoluteUserIndex = parentIndex - userIndex const userMessage = messages[absoluteUserIndex] const userText = userMessage ? chatMessageText(userMessage).trim() : '' if (!userText) { return } const targetAssistant = parentId && messages[parentIndex]?.role === 'assistant' ? messages[parentIndex] : messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant') const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage) const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex) clearNotifications() updateSessionState(activeSessionId, state => { const nextUserIndex = state.messages.findIndex( (message, index) => index > absoluteUserIndex && message.role === 'user' ) const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex return { ...state, busy: true, awaitingResponse: true, pendingBranchGroup: branchGroupId, sawAssistantPayload: false, interrupted: false, messages: [ ...state.messages.slice(0, absoluteUserIndex + 1), ...state.messages .slice(absoluteUserIndex + 1, end) .map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message)) ] } }) try { await requestGateway('prompt.submit', { session_id: activeSessionId, text: userText, truncate_before_user_ordinal: truncateBeforeUserOrdinal }) } catch (err) { updateSessionState(activeSessionId, state => ({ ...state, busy: false, awaitingResponse: false })) notifyError(err, 'Regenerate failed') } }, [activeSessionId, requestGateway, updateSessionState] ) const editMessage = useCallback( async (edited: AppendMessage) => { const sessionId = activeSessionId || activeSessionIdRef.current const sourceId = edited.sourceId || edited.parentId const text = appendText(edited) if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) { return } const messages = $messages.get() const sourceIndex = messages.findIndex(m => m.id === sourceId) const source = messages[sourceIndex] if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) { return } // Failed turn: optimistic user msg never reached the gateway, so truncating // by ordinal would 422. Submit as a plain resend instead. const nextMessage = messages[sourceIndex + 1] const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] } clearNotifications() setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) updateSessionState(sessionId, state => ({ ...state, busy: true, awaitingResponse: true, pendingBranchGroup: null, sawAssistantPayload: false, interrupted: false, messages: [...state.messages.slice(0, sourceIndex), editedMessage] })) const submit = (truncateOrdinal?: number) => requestGateway('prompt.submit', { session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) }) const isStaleTargetError = (err: unknown) => /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) try { await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)) } catch (err) { let surfaced = err if (!isFailedTurn && isStaleTargetError(err)) { try { await submit() return } catch (retryErr) { surfaced = retryErr } } setMutableRef(busyRef, false) setBusy(false) setAwaitingResponse(false) updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false })) notifyError(surfaced, 'Edit failed') } }, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState] ) const handleThreadMessagesChange = useCallback( (nextMessages: readonly ThreadMessage[]) => { const visibleIds = new Set(nextMessages.map(m => m.id)) const sessionId = activeSessionIdRef.current if (!sessionId) { return } updateSessionState(sessionId, state => { let changed = false const messages = state.messages.map(message => { if (message.role !== 'assistant' || !message.branchGroupId) { return message } const hidden = !visibleIds.has(message.id) if (message.hidden === hidden) { return message } changed = true return { ...message, hidden } }) return changed ? { ...state, messages } : state }) }, [activeSessionIdRef, updateSessionState] ) return { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } }