diff --git a/source/app/App.tsx b/source/app/App.tsx index 20efff0a..e1dd91e0 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -38,8 +38,10 @@ import {useSessionAutosave} from '@/hooks/useSessionAutosave'; import {ThemeContext} from '@/hooks/useTheme'; import {TitleShapeContext, updateTitleShape} from '@/hooks/useTitleShape'; import {UIStateProvider} from '@/hooks/useUIState'; +import {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import {useVSCodeServer} from '@/hooks/useVSCodeServer'; import {generateKey} from '@/session/key-generator'; +import type {ImageAttachment} from '@/types/core'; import type {ThemePreset} from '@/types/ui'; import {createPinoLogger} from '@/utils/logging/pino-logger'; import {setGlobalMessageQueue} from '@/utils/message-queue'; @@ -75,6 +77,15 @@ export default function App({ // Use extracted hooks const appState = useAppState(initialDevelopmentMode); + const userMessageQueue = useUserMessageQueue(); + const queuedUserSubmitRef = React.useRef< + | (( + message: string, + displayValue: string, + images?: ImageAttachment[], + ) => Promise) + | null + >(null); const {exit} = useApp(); const {isTrusted, handleConfirmTrust, isTrustLoading, isTrustedError} = useDirectoryTrust(); @@ -159,6 +170,28 @@ export default function App({ } }, []); + const drainQueuedUserMessage = React.useCallback(() => { + queueMicrotask(() => { + void userMessageQueue.drainNextMessage(async message => { + const submitQueuedMessage = queuedUserSubmitRef.current; + if (!submitQueuedMessage || !appState.client || !appState.toolManager) { + return false; + } + + await submitQueuedMessage( + message.message, + message.displayValue, + message.images, + ); + return true; + }); + }); + }, [ + appState.client, + appState.toolManager, + userMessageQueue.drainNextMessage, + ]); + // Setup chat handler const chatHandler = useChatHandler({ client: appState.client, @@ -180,6 +213,7 @@ export default function App({ appState.setCompactToolCounts(null); appState.compactToolCountsRef.current = {}; appState.setLiveTaskList(null); + drainQueuedUserMessage(); }, reasoningExpandedRef: appState.reasoningExpandedRef, compactToolDisplayRef: appState.compactToolDisplayRef, @@ -404,6 +438,10 @@ export default function App({ activeEditor: vscodeServer.activeEditor, }); + React.useEffect(() => { + queuedUserSubmitRef.current = handleUserSubmit; + }, [handleUserSubmit]); + // Setup non-interactive mode const {nonInteractiveLoadingMessage} = useNonInteractiveMode({ nonInteractivePrompt, @@ -605,6 +643,7 @@ export default function App({ handleToolConfirmation={handleToolConfirmation} handleQuestionAnswer={handleQuestionAnswer} handleUserSubmit={handleUserSubmit} + userMessageQueue={userMessageQueue} handleIdeSelect={handleIdeSelect} /> diff --git a/source/app/components/chat-input.spec.tsx b/source/app/components/chat-input.spec.tsx index e276c54a..4cad83de 100644 --- a/source/app/components/chat-input.spec.tsx +++ b/source/app/components/chat-input.spec.tsx @@ -102,6 +102,48 @@ test('ChatInput shows tool execution indicator when executing', t => { unmount(); }); +test('ChatInput keeps UserInput visible while a tool is executing', t => { + const mockToolCall = { + id: 'test-1', + function: {name: 'test_tool', arguments: {}}, + }; + + const props = createDefaultProps({ + isToolExecuting: true, + isBusy: true, + inputDisabled: true, + pendingToolCalls: [mockToolCall], + currentToolIndex: 0, + }); + + const {lastFrame, unmount} = renderWithTheme(); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /hat would you like me to help with\?/); + t.regex(output!, /Press Esc to cancel/); + unmount(); +}); + +test('ChatInput keeps modal decision states ahead of busy input', t => { + const mockToolCall = { + id: 'test-1', + function: {name: 'test_tool', arguments: {}}, + }; + + const props = createDefaultProps({ + isToolExecuting: true, + isBusy: true, + inputDisabled: true, + pendingToolConfirmation: {toolCall: mockToolCall as never}, + }); + + const {lastFrame, unmount} = renderWithTheme(); + const output = lastFrame(); + t.truthy(output); + t.notRegex(output!, /What would you like me to help with\?/); + unmount(); +}); + test('ChatInput shows cancelling indicator when cancelling', t => { const props = createDefaultProps({ isCancelling: true, diff --git a/source/app/components/chat-input.tsx b/source/app/components/chat-input.tsx index 49c74c3c..c0fec546 100644 --- a/source/app/components/chat-input.tsx +++ b/source/app/components/chat-input.tsx @@ -8,6 +8,10 @@ import ToolConfirmation from '@/components/tool-confirmation'; import ToolExecutionIndicator from '@/components/tool-execution-indicator'; import UserInput from '@/components/user-input'; import {useTheme} from '@/hooks/useTheme'; +import type { + QueuedUserMessage, + UserMessageQueueDraft, +} from '@/hooks/useUserMessageQueue'; import type {Task} from '@/tools/tasks/types'; import type { ContextSource, @@ -51,6 +55,9 @@ export interface ChatInputProps { // Input state customCommands: string[]; inputDisabled: boolean; + queuedMessages?: QueuedUserMessage[]; + onQueueMessage?: (message: UserMessageQueueDraft) => void; + onRemoveQueuedMessage?: (id: string) => void; // True when in-flight work makes Escape a cancel; lets UserInput defer to // the section-level global cancel handler instead of clearing the input. isBusy: boolean; @@ -108,6 +115,9 @@ export function ChatInput({ client, customCommands, inputDisabled, + queuedMessages = [], + onQueueMessage, + onRemoveQueuedMessage, isBusy, developmentMode, contextPercentUsed, @@ -126,6 +136,12 @@ export function ChatInput({ onDismissActiveEditor, }: ChatInputProps): React.ReactElement { const {colors} = useTheme(); + const activeToolCall = pendingToolCalls[currentToolIndex]; + const showToolExecutionIndicator = + isToolExecuting && + activeToolCall && + activeToolCall.function.name !== 'execute_bash' && + activeToolCall.function.name !== 'agent'; return ( @@ -141,6 +157,14 @@ export function ChatInput({ {isCancelling && } + {showToolExecutionIndicator && ( + + )} + {/* Subagent Tool Approval — takes priority since subagent is blocked */} {pendingSubagentApproval ? ( onToolConfirmation(false)} /> - ) : /* Tool Execution - skip indicator for streaming tools (they show their own progress) */ - isToolExecuting && - pendingToolCalls[currentToolIndex] && - pendingToolCalls[currentToolIndex].function.name !== 'execute_bash' && - pendingToolCalls[currentToolIndex].function.name !== 'agent' ? ( - ) : /* Question Prompt (ask_question tool) */ isQuestionMode && pendingQuestion ? ( void onSubmit(msg, display, images) } - disabled={inputDisabled} + onQueueMessage={onQueueMessage} + queuedMessages={queuedMessages} + onRemoveQueuedMessage={onRemoveQueuedMessage} + disabled={inputDisabled && !isBusy} isBusy={isBusy} onToggleMode={onToggleMode} onToggleReasoningExpanded={onToggleReasoningExpanded} diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index 7148ee19..cdea5073 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -102,6 +102,16 @@ function makeProps(o: Overrides = {}) { handleToolConfirmation: noop, handleQuestionAnswer: noop, handleUserSubmit: noopAsync, + userMessageQueue: { + queuedMessages: [], + enqueueMessage: () => ({ + id: 'queued-test', + message: '', + displayValue: '', + }), + removeMessage: noop, + drainNextMessage: () => false, + }, handleIdeSelect: noop, } as never; } diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index b2c59076..6331eb1a 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -9,6 +9,7 @@ import type {useChatHandler} from '@/hooks/chat-handler'; import type {AppHandlers} from '@/hooks/useAppHandlers'; import type {useAppState} from '@/hooks/useAppState'; import type {useModeHandlers} from '@/hooks/useModeHandlers'; +import type {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import type {useVSCodeServer} from '@/hooks/useVSCodeServer'; import type {ImageAttachment} from '@/types/core'; import type {PendingToolApproval} from '@/utils/tool-approval-queue'; @@ -33,6 +34,7 @@ interface InteractiveAppProps { displayValue: string, images?: ImageAttachment[], ) => Promise; + userMessageQueue: ReturnType; handleIdeSelect: (ide: string) => void; } @@ -56,6 +58,7 @@ export function InteractiveApp({ handleToolConfirmation, handleQuestionAnswer, handleUserSubmit, + userMessageQueue, handleIdeSelect, }: InteractiveAppProps): React.ReactElement { const handleToggleCompactDisplay = () => { @@ -181,7 +184,10 @@ export function InteractiveApp({ mcpInitialized={appState.mcpInitialized} client={appState.client} customCommands={Array.from(appState.customCommandCache.keys())} - inputDisabled={chatHandler.isGenerating || appState.isToolExecuting} + inputDisabled={false} + queuedMessages={userMessageQueue.queuedMessages} + onQueueMessage={userMessageQueue.enqueueMessage} + onRemoveQueuedMessage={userMessageQueue.removeMessage} isBusy={cancellable} developmentMode={appState.developmentMode} contextPercentUsed={appState.contextPercentUsed} diff --git a/source/components/user-input.spec.tsx b/source/components/user-input.spec.tsx index eafd4a2b..317c02c9 100644 --- a/source/components/user-input.spec.tsx +++ b/source/components/user-input.spec.tsx @@ -31,6 +31,34 @@ const TestWrapper = ({children}: {children: React.ReactNode}) => ( // Helper for async tests that need proper context and more time const wait = async (ms = 200) => new Promise(resolve => setTimeout(resolve, ms)); +const waitForCondition = async ( + condition: () => boolean, + timeoutMs = 1000, +) => { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (condition()) { + return; + } + + await wait(25); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +}; + +const waitForFrame = async ( + lastFrame: () => string | undefined, + pattern: RegExp, + timeoutMs = 1000, +) => { + await waitForCondition( + () => pattern.test(lastFrame() ?? ''), + timeoutMs, + ); +}; + // ============================================================================ // Component Rendering Tests // ============================================================================ @@ -161,6 +189,192 @@ test('UserInput renders while busy (Escape deferred to global handler)', t => { unmount(); }); +test('UserInput queues submitted messages while busy', async t => { + let submittedMessage = ''; + let queuedMessage = ''; + let queuedDisplay = ''; + + const {stdin, lastFrame, unmount} = render( + + { + submittedMessage = message; + }} + onQueueMessage={message => { + queuedMessage = message.message; + queuedDisplay = message.displayValue; + }} + /> + , + ); + + stdin.write('queued while busy'); + await waitForFrame(lastFrame, /queued while busy/); + stdin.write('\r'); + await waitForCondition(() => queuedMessage === 'queued while busy'); + await waitForCondition(() => !/queued while busy/.test(lastFrame() ?? '')); + + t.is(submittedMessage, ''); + t.is(queuedMessage, 'queued while busy'); + t.is(queuedDisplay, 'queued while busy'); + t.notRegex(lastFrame()!, /queued while busy/); + unmount(); +}); + +test('UserInput submits slash commands immediately while busy', async t => { + let submittedMessage = ''; + let queuedMessage = ''; + + const {stdin, lastFrame, unmount} = render( + + { + submittedMessage = message; + }} + onQueueMessage={message => { + queuedMessage = message.message; + }} + /> + , + ); + + stdin.write('/help'); + await waitForFrame(lastFrame, /\/help/); + stdin.write('\r'); + await waitForCondition(() => submittedMessage === '/help'); + + t.is(submittedMessage, '/help'); + t.is(queuedMessage, ''); + unmount(); +}); + +test('UserInput renders queued messages while busy', t => { + const {lastFrame, unmount} = render( + + + , + ); + + const output = lastFrame()!; + t.regex(output, /Queued messages/); + t.regex(output, /first queued message/); + t.regex(output, /second queued message/); + t.regex(output, /1 image/); + unmount(); +}); + +test('UserInput navigates queued messages while busy with empty input', async t => { + const {stdin, lastFrame, unmount} = render( + + + , + ); + + stdin.write('\u001B[B'); + await wait(50); + + const output = lastFrame()!; + t.regex(output, /▸ first queued/); + t.notRegex(output, /▸ second queued/); + unmount(); +}); + +test('UserInput loads selected queued message for editing', async t => { + let removedId = ''; + + const {stdin, lastFrame, unmount} = render( + + { + removedId = id; + }} + /> + , + ); + + stdin.write('\u001B[B'); + await wait(50); + stdin.write('\u001B[B'); + await wait(50); + stdin.write('\r'); + await wait(50); + + t.is(removedId, 'queued-2'); + t.regex(lastFrame()!, /second queued/); + unmount(); +}); + +test('UserInput removes selected queued message with Ctrl+Delete', async t => { + let removedId = ''; + const QueueHarness = () => { + const [messages, setMessages] = React.useState([ + {id: 'queued-1', message: 'first', displayValue: 'first queued'}, + {id: 'queued-2', message: 'second', displayValue: 'second queued'}, + ]); + + return ( + { + removedId = id; + setMessages(current => + current.filter(message => message.id !== id), + ); + }} + /> + ); + }; + + const {stdin, lastFrame, unmount} = render( + + + , + ); + + stdin.write('\u001B[B'); + await wait(50); + stdin.write('\u001B[3;5~'); + await wait(50); + + t.is(removedId, 'queued-1'); + t.notRegex(lastFrame()!, /first queued/); + unmount(); +}); + test('UserInput calls onToggleMode when provided', t => { let toggleCalled = false; const handleToggle = () => { @@ -570,6 +784,3 @@ test('UserInput does not show completions when input is empty', t => { t.notRegex(output, /Available commands:/); unmount(); }); - - - diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index b654f8b3..ea3e965f 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -8,6 +8,10 @@ import {useInputState} from '@/hooks/useInputState'; import {useResponsiveTerminal} from '@/hooks/useTerminalWidth'; import {useTheme} from '@/hooks/useTheme'; import {useUIStateContext} from '@/hooks/useUIState'; +import type { + QueuedUserMessage, + UserMessageQueueDraft, +} from '@/hooks/useUserMessageQueue'; import {promptHistory} from '@/prompt-history'; import type {TuneConfig} from '@/types/config'; import type { @@ -36,6 +40,9 @@ interface ChatProps { displayValue: string, images?: ImageAttachment[], ) => void; + onQueueMessage?: (message: UserMessageQueueDraft) => void; + queuedMessages?: QueuedUserMessage[]; + onRemoveQueuedMessage?: (id: string) => void; placeholder?: string; customCommands?: string[]; // List of custom command names and aliases disabled?: boolean; // Disable input when AI is processing @@ -57,6 +64,9 @@ interface ChatProps { export default function UserInput({ onSubmit, + onQueueMessage, + queuedMessages = [], + onRemoveQueuedMessage, placeholder, customCommands = [], disabled = false, @@ -94,6 +104,7 @@ export default function UserInput({ Array<{path: string; score: number}> >([]); const [selectedFileIndex, setSelectedFileIndex] = useState(0); + const [selectedQueuedIndex, setSelectedQueuedIndex] = useState(-1); // Pending image attachments sent with the next submitted message. const [attachments, setAttachments] = useState([]); @@ -134,6 +145,17 @@ export default function UserInput({ void promptHistory.loadHistory(); }, []); + useEffect(() => { + if (queuedMessages.length === 0) { + setSelectedQueuedIndex(-1); + return; + } + + setSelectedQueuedIndex(index => + index >= queuedMessages.length ? queuedMessages.length - 1 : index, + ); + }, [queuedMessages.length]); + // Consume pending file mentions from explorer and insert into input // Properly attach files by calling handleFileMention for each useEffect(() => { @@ -305,7 +327,7 @@ export default function UserInput({ // Handle form submission const handleSubmit = useCallback(() => { - if (!onSubmit) return; + if (!onSubmit && !onQueueMessage) return; let images = attachments; let assembled = assemblePrompt(currentState); @@ -329,14 +351,46 @@ export default function UserInput({ // Nothing to send: no text and no attachments. if (!assembled.trim() && images.length === 0) return; + const inputStateForHistory: InputState = { + displayValue: currentState.displayValue, + placeholderContent: {...currentState.placeholderContent}, + }; + + if (isBusy && !assembled.trim().startsWith('/') && onQueueMessage) { + promptHistory.addPrompt(inputStateForHistory); + onQueueMessage({ + message: assembled, + displayValue: display, + images: images.length > 0 ? images : undefined, + inputState: inputStateForHistory, + }); + resetInput(); + resetUIState(); + setAttachments([]); + promptHistory.resetIndex(); + setSelectedQueuedIndex(-1); + return; + } + + if (!onSubmit) return; + // Save the InputState to history and send assembled message to AI - promptHistory.addPrompt(currentState); + promptHistory.addPrompt(inputStateForHistory); onSubmit(assembled, display, images.length > 0 ? images : undefined); resetInput(); resetUIState(); setAttachments([]); promptHistory.resetIndex(); - }, [attachments, onSubmit, resetInput, resetUIState, currentState]); + setSelectedQueuedIndex(-1); + }, [ + attachments, + onSubmit, + onQueueMessage, + resetInput, + resetUIState, + currentState, + isBusy, + ]); // Handle escape key logic const handleEscape = useCallback(() => { @@ -427,6 +481,80 @@ export default function UserInput({ ], ); + const handleQueueNavigation = useCallback( + (direction: 'up' | 'down') => { + if (!isBusy || input.length > 0 || queuedMessages.length === 0) { + return false; + } + + setSelectedQueuedIndex(index => { + if (direction === 'down') { + return index < 0 || index >= queuedMessages.length - 1 + ? 0 + : index + 1; + } + + return index <= 0 ? queuedMessages.length - 1 : index - 1; + }); + return true; + }, + [isBusy, input.length, queuedMessages.length], + ); + + const loadSelectedQueuedMessage = useCallback(() => { + if ( + !isBusy || + input.length > 0 || + selectedQueuedIndex < 0 || + selectedQueuedIndex >= queuedMessages.length + ) { + return false; + } + + const queuedMessage = queuedMessages[selectedQueuedIndex]; + setInputState( + queuedMessage.inputState ?? { + displayValue: queuedMessage.displayValue, + placeholderContent: {}, + }, + ); + setAttachments(queuedMessage.images ?? []); + onRemoveQueuedMessage?.(queuedMessage.id); + setSelectedQueuedIndex(-1); + setTextInputKey(prev => prev + 1); + return true; + }, [ + isBusy, + input.length, + selectedQueuedIndex, + queuedMessages, + setInputState, + onRemoveQueuedMessage, + ]); + + const removeSelectedQueuedMessage = useCallback(() => { + if ( + !isBusy || + input.length > 0 || + selectedQueuedIndex < 0 || + selectedQueuedIndex >= queuedMessages.length + ) { + return false; + } + + onRemoveQueuedMessage?.(queuedMessages[selectedQueuedIndex].id); + setSelectedQueuedIndex(index => + index >= queuedMessages.length - 1 ? queuedMessages.length - 2 : index, + ); + return true; + }, [ + isBusy, + input.length, + selectedQueuedIndex, + queuedMessages, + onRemoveQueuedMessage, + ]); + useInput((inputChar, key) => { // Cancelling in-flight work is owned by the single section-level Escape // handler (see InteractiveApp), which fires no matter which component is @@ -454,6 +582,10 @@ export default function UserInput({ return; } + if (key.ctrl && key.delete && removeSelectedQueuedMessage()) { + return; + } + // Block all other input when disabled if (disabled) { return; @@ -566,6 +698,9 @@ export default function UserInput({ // Handle Enter to submit (fallthrough - if completion handler didn't return) if (key.return && !key.shift) { + if (loadSelectedQueuedMessage()) { + return; + } handleSubmit(); return; } @@ -586,6 +721,9 @@ export default function UserInput({ ); return; } + if (handleQueueNavigation('up')) { + return; + } handleHistoryNavigation('up'); return; } @@ -605,12 +743,28 @@ export default function UserInput({ ); return; } + if (handleQueueNavigation('down')) { + return; + } handleHistoryNavigation('down'); return; } }); const textColor = disabled || !input ? colors.secondary : colors.primary; + const formatQueuedMessage = (message: QueuedUserMessage) => { + const imageSuffix = + message.images && message.images.length > 0 + ? ` (${message.images.length} image${message.images.length === 1 ? '' : 's'})` + : ''; + const maxLength = Math.max(20, boxWidth - imageSuffix.length - 6); + const singleLine = message.displayValue.replace(/\s+/g, ' ').trim(); + const text = + singleLine.length > maxLength + ? `${singleLine.slice(0, Math.max(0, maxLength - 1))}…` + : singleLine; + return `${text}${imageSuffix}`; + }; // When disabled, show minimal UI to avoid cluttering the screen if (disabled) { @@ -728,6 +882,38 @@ export default function UserInput({ ))} )} + {queuedMessages.length > 0 && ( + + + Queued messages (↑/↓ select, Enter edit, Ctrl+Delete remove): + + {queuedMessages.map((message, index) => { + const isSelected = index === selectedQueuedIndex; + return ( + + {isSelected ? '▸ ' : ' '} + {formatQueuedMessage(message)} + + ); + })} + + )} + {isBusy && ( + + Press Esc to cancel + {onToggleCompactDisplay && ( + + {' '} + · ctrl-o {compactToolDisplay ? 'expand' : 'compact'}{' '} + {isNarrow ? '' : 'tool results'} + + )} + + )} {attachments.length > 0 && ( diff --git a/source/hooks/useUserMessageQueue.spec.tsx b/source/hooks/useUserMessageQueue.spec.tsx new file mode 100644 index 00000000..e4c45e10 --- /dev/null +++ b/source/hooks/useUserMessageQueue.spec.tsx @@ -0,0 +1,187 @@ +import test from 'ava'; +import {render} from 'ink-testing-library'; +import React from 'react'; +import type {ImageAttachment} from '@/types/core'; +import {PlaceholderType} from '@/types/hooks'; +import {useUserMessageQueue} from './useUserMessageQueue'; + +type HookResult = ReturnType; + +function TestHook({onResult}: {onResult: (result: HookResult) => void}) { + const result = useUserMessageQueue(); + + React.useEffect(() => { + onResult(result); + }, [result, onResult]); + + return <>; +} + +async function renderHook() { + let hook: HookResult | null = null; + const rendered = render( + { + hook = result; + }} + />, + ); + + await new Promise(resolve => setTimeout(resolve, 20)); + + if (!hook) { + throw new Error('hook did not render'); + } + + return { + ...rendered, + get hook() { + if (!hook) { + throw new Error('hook did not render'); + } + return hook; + }, + }; +} + +test('useUserMessageQueue enqueues messages with stable payloads', async t => { + const result = await renderHook(); + const image: ImageAttachment = { + data: 'abc123', + mediaType: 'image/png', + source: 'screenshot.png', + }; + + result.hook.enqueueMessage({ + message: 'full message', + displayValue: 'display message', + images: [image], + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + t.is(result.hook.queuedMessages.length, 1); + t.is(result.hook.queuedMessages[0].message, 'full message'); + t.is(result.hook.queuedMessages[0].displayValue, 'display message'); + t.deepEqual(result.hook.queuedMessages[0].images, [image]); + t.truthy(result.hook.queuedMessages[0].id); + result.unmount(); +}); + +test('useUserMessageQueue preserves placeholder input state metadata', async t => { + const result = await renderHook(); + const inputState = { + displayValue: 'summarize [@file:1]', + placeholderContent: { + 'file:1': { + type: PlaceholderType.FILE, + content: 'file contents', + displayText: '[@file:1]', + filePath: 'source/example.ts', + }, + }, + }; + + result.hook.enqueueMessage({ + message: 'summarize file contents', + displayValue: inputState.displayValue, + inputState, + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + t.deepEqual(result.hook.queuedMessages[0].inputState, inputState); + result.unmount(); +}); + +test('useUserMessageQueue drains messages in FIFO order', async t => { + const result = await renderHook(); + const sent: string[] = []; + + result.hook.enqueueMessage({message: 'first', displayValue: 'First'}); + result.hook.enqueueMessage({message: 'second', displayValue: 'Second'}); + + await new Promise(resolve => setTimeout(resolve, 20)); + + const firstDrained = await result.hook.drainNextMessage(message => { + sent.push(message.message); + return true; + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + const secondDrained = await result.hook.drainNextMessage(message => { + sent.push(message.message); + return true; + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + t.true(firstDrained); + t.true(secondDrained); + t.deepEqual(sent, ['first', 'second']); + t.is(result.hook.queuedMessages.length, 0); + result.unmount(); +}); + +test('useUserMessageQueue keeps message queued when dispatch cannot run', async t => { + const result = await renderHook(); + + result.hook.enqueueMessage({ + message: 'retry later', + displayValue: 'Retry later', + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + const drained = await result.hook.drainNextMessage(() => false); + + await new Promise(resolve => setTimeout(resolve, 20)); + + t.false(drained); + t.is(result.hook.queuedMessages.length, 1); + t.is(result.hook.queuedMessages[0].message, 'retry later'); + result.unmount(); +}); + +test('useUserMessageQueue keeps message queued when async dispatch rejects', async t => { + const result = await renderHook(); + + result.hook.enqueueMessage({ + message: 'retry after rejection', + displayValue: 'Retry after rejection', + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + const drained = await result.hook.drainNextMessage(async () => { + throw new Error('dispatch failed'); + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + t.false(drained); + t.is(result.hook.queuedMessages.length, 1); + t.is(result.hook.queuedMessages[0].message, 'retry after rejection'); + result.unmount(); +}); + +test('useUserMessageQueue removes a queued message by id', async t => { + const result = await renderHook(); + + result.hook.enqueueMessage({message: 'keep', displayValue: 'Keep'}); + result.hook.enqueueMessage({message: 'remove', displayValue: 'Remove'}); + + await new Promise(resolve => setTimeout(resolve, 20)); + + const idToRemove = result.hook.queuedMessages[1].id; + result.hook.removeMessage(idToRemove); + + await new Promise(resolve => setTimeout(resolve, 20)); + + t.deepEqual( + result.hook.queuedMessages.map(message => message.message), + ['keep'], + ); + result.unmount(); +}); diff --git a/source/hooks/useUserMessageQueue.ts b/source/hooks/useUserMessageQueue.ts new file mode 100644 index 00000000..321e21fc --- /dev/null +++ b/source/hooks/useUserMessageQueue.ts @@ -0,0 +1,71 @@ +import React from 'react'; +import type {ImageAttachment} from '@/types/core'; +import type {InputState} from '@/types/hooks'; + +export interface QueuedUserMessage { + id: string; + message: string; + displayValue: string; + images?: ImageAttachment[]; + inputState?: InputState; +} + +export type UserMessageQueueDraft = Omit; + +export function useUserMessageQueue() { + const [queuedMessages, setQueuedMessages] = React.useState< + QueuedUserMessage[] + >([]); + const queuedMessagesRef = React.useRef([]); + const nextIdRef = React.useRef(0); + + const setQueue = React.useCallback((next: QueuedUserMessage[]) => { + queuedMessagesRef.current = next; + setQueuedMessages(next); + }, []); + + const enqueueMessage = React.useCallback( + (message: UserMessageQueueDraft) => { + const queuedMessage: QueuedUserMessage = { + ...message, + id: `queued-user-${nextIdRef.current++}`, + }; + setQueue([...queuedMessagesRef.current, queuedMessage]); + return queuedMessage; + }, + [setQueue], + ); + + const removeMessage = React.useCallback( + (id: string) => { + setQueue(queuedMessagesRef.current.filter(message => message.id !== id)); + }, + [setQueue], + ); + + const drainNextMessage = React.useCallback( + async ( + dispatch: (message: QueuedUserMessage) => boolean | Promise, + ) => { + const [nextMessage, ...remainingMessages] = queuedMessagesRef.current; + if (!nextMessage) return false; + + try { + if (!(await dispatch(nextMessage))) return false; + } catch { + return false; + } + + setQueue(remainingMessages); + return true; + }, + [setQueue], + ); + + return { + queuedMessages, + enqueueMessage, + removeMessage, + drainNextMessage, + }; +}