From 354f79a5f01abb89d534d8b39535d8e4872c4425 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 11 Jul 2026 19:40:29 -0400 Subject: [PATCH 1/3] feat(chat): queue messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to compose and queue follow-up messages while the current model turn is running. Display the pending FIFO queue directly above the chat input under the label **“Queued messages:”**, then submit each message as a separate turn after the preceding turn fully completes. --- Changes - Keep `ChatInput` editable during streaming; retain Esc/Ctrl+C as active-turn interruption controls. - In `Chat`, store queued `SubmittedInput` values separately from persisted conversation messages. - When a normal text prompt is submitted while `isLoading`, append it to the queue instead of starting another turn. Do not add it to session history until execution begins. - Drain one queued message when the chat becomes idle, provided no tool approval or plan review is pending. Preserve FIFO execution and start the next item through the existing turn flow. - Render a compact queue preview above `ChatInput`: - Header: `Queued messages:` - Show each message in submission order with a dimmed `↳` prefix. - Truncate only the visual preview; retain the full queued content. - When the input is empty during an active turn, Up Arrow removes the most recently queued message and restores it to the composer. The user can edit and resubmit it or clear the composer to delete it. Normal history navigation resumes when no queued messages remain. - Clear queued messages when switching sessions. Interrupting the active turn leaves the queue intact and allows it to continue afterward. - Do not queue slash commands, `!` shell commands, `/compact`, or image attachments in the initial version; keep their current behavior unavailable while a turn is active. --- Interfaces and State - Add queue state and dequeue/restore handlers at the `Chat` level. - Extend `ChatInput` with: - active-turn status distinct from full input disablement; - an optional callback for restoring the latest queued message; - queue-aware submission behavior and hints. - Add a small colocated queue-preview component if extracting the rendering keeps `Chat` focused; no public package API or persisted session format changes. --- src/components/Chat/Chat.test.tsx | 117 ++++++++++- src/components/Chat/Chat.tsx | 269 +++++++++++++++++-------- src/components/Chat/ChatInput.test.tsx | 82 ++++++++ src/components/Chat/ChatInput.tsx | 45 ++++- 4 files changed, 425 insertions(+), 88 deletions(-) diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index 9d58e8e6..c9617fa8 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -10,11 +10,15 @@ const mockState = vi.hoisted(() => ({ handler: undefined as ((value: { content: string; images?: string[] }) => void) | undefined, history: [] as string[], + isActive: false, + restoreQueuedMessage: undefined as (() => string | undefined) | undefined, testInput: '', shouldReset: false, clear() { this.handler = undefined; this.history = []; + this.isActive = false; + this.restoreQueuedMessage = undefined; this.testInput = ''; this.shouldReset = true; }, @@ -217,6 +221,8 @@ vi.mock('./ChatInput', () => ({ history?: string[]; onSubmit?: (value: { content: string; images?: string[] }) => void; onInterrupt?: () => void; + onRestoreQueuedMessage?: () => string | undefined; + isActive?: boolean; isDisabled?: boolean; }) => { if (props.onSubmit) { @@ -224,6 +230,8 @@ vi.mock('./ChatInput', () => ({ } mockState.history = props.history ?? []; + mockState.isActive = props.isActive ?? false; + mockState.restoreQueuedMessage = props.onRestoreQueuedMessage; if (props.onInterrupt) { interruptState.handler = props.onInterrupt; @@ -404,6 +412,113 @@ describe('Chat', () => { expect(onMessagesChange).toHaveBeenCalled(); }); + it('returns undefined from restoreQueuedMessage when queue is empty', async () => { + const chat = ( + + ); + const { rerender } = renderWithTheme(chat); + rerender(chat); + await time.tick(); + + expect(mockState.restoreQueuedMessage?.()).toBeUndefined(); + }); + + it('shows queued messages and restores the latest message for editing', async () => { + let releaseStream: (() => void) | undefined; + let streamIndex = 0; + vi.mocked(ollama.streamChat).mockImplementation(async function* () { + streamIndex += 1; + if (streamIndex === 1) { + await new Promise((resolve) => { + releaseStream = resolve; + }); + } + yield { type: 'content', content: 'Done' }; + }); + + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + + mockState.handler?.({ content: 'first' }); + rerender(chat); + await time.tick(); + mockState.handler?.({ content: 'second' }); + mockState.handler?.({ content: 'third' }); + rerender(chat); + await time.tick(); + + expect(lastFrame()).toContain('Queued messages:'); + expect(lastFrame()).toContain('second'); + expect(lastFrame()).toContain('third'); + expect(mockState.restoreQueuedMessage?.()).toBe('third'); + rerender(chat); + await time.tick(); + expect(lastFrame()).not.toContain(' ↳ third'); + + releaseStream?.(); + await vi.waitFor(() => { + rerender(chat); + expect(ollama.streamChat).toHaveBeenCalledTimes(2); + expect(lastFrame()).not.toContain('Queued messages:'); + }); + }); + + it('does not queue a message with images submitted while a turn is active', async () => { + let releaseStream: (() => void) | undefined; + let streamIndex = 0; + vi.mocked(ollama.streamChat).mockImplementation(async function* () { + streamIndex += 1; + if (streamIndex === 1) { + await new Promise((resolve) => { + releaseStream = resolve; + }); + } + yield { type: 'content', content: 'Done' }; + }); + + const chat = ( + + ); + const { lastFrame, rerender } = renderWithTheme(chat); + + mockState.handler?.({ content: 'first' }); + rerender(chat); + await time.tick(); + + mockState.handler?.({ content: 'with image', images: ['/tmp/img.png'] }); + rerender(chat); + await time.tick(); + + expect(lastFrame()).not.toContain('Queued messages:'); + + releaseStream?.(); + await vi.waitFor(() => { + rerender(chat); + expect(ollama.streamChat).toHaveBeenCalledTimes(1); + expect(lastFrame()).not.toContain('Thinking'); + }); + }); + it('derives prompt history from user messages and excludes slash commands', async () => { renderWithTheme( { choosePlanMode(MODE.AUTO); await time.tick(); - }, 20_000); + }); it('executes an approved plan immediately in auto mode', async () => { const { streamChat } = ollama; diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 90b5aa6e..438a5224 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -111,8 +111,12 @@ export function Chat({ toolProgress, } = state; const abortControllerRef = useRef(null); + const activeTurnRef = useRef(false); + const isDrainingQueueRef = useRef(false); const persistedSnapshotRef = useRef(''); const [compactError, setCompactError] = useState(null); + const [isDrainingQueue, setIsDrainingQueue] = useState(false); + const [queuedMessages, setQueuedMessages] = useState([]); const compact = useCompact({ abortControllerRef, dispatch, @@ -125,6 +129,10 @@ export function Chat({ }); useEffect(() => { + activeTurnRef.current = false; + isDrainingQueueRef.current = false; + setIsDrainingQueue(false); + setQueuedMessages([]); dispatch({ type: ChatActionType.ResetSession, messages: sessionMessages, @@ -190,6 +198,7 @@ export function Chat({ type: ChatActionType.SetLoading, isLoading: true, }); + activeTurnRef.current = true; // Add instruction to execute the plan const executeInstruction: ollama.Message = { @@ -202,7 +211,11 @@ export function Chat({ const executeMessages = [...planMessages, executeInstruction]; - await runTurn(executeMessages, selectedMode); + try { + await runTurn(executeMessages, selectedMode); + } finally { + activeTurnRef.current = false; + } }, [onModeChange, pendingPlan, runTurn], ); @@ -222,62 +235,95 @@ export function Chat({ type: ChatActionType.SetLoading, isLoading: true, }); + activeTurnRef.current = true; + + try { + switch (decision) { + case DECISION.APPROVE: { + dispatch({ + type: ChatActionType.SetStreamingMessage, + message: { role: ROLE.ASSISTANT, content: '' }, + }); + const result = await tools.executeToolCall(toolCall); + + const toolResultMessage: ollama.Message = { + role: ROLE.SYSTEM, + content: tools.formatToolResultContent( + toolCall.function.name, + result, + toolCall.function.arguments, + ), + toolResult: { + name: toolCall.function.name, + // v8 ignore next + ...(result.diff ? { diff: result.diff } : {}), + // v8 ignore next + ...(result.error ? { error: result.error } : {}), + }, + }; - switch (decision) { - case DECISION.APPROVE: { - dispatch({ - type: ChatActionType.SetStreamingMessage, - message: { role: ROLE.ASSISTANT, content: '' }, - }); - const result = await tools.executeToolCall(toolCall); - - const toolResultMessage: ollama.Message = { - role: ROLE.SYSTEM, - content: tools.formatToolResultContent( - toolCall.function.name, - result, - toolCall.function.arguments, - ), - toolResult: { - name: toolCall.function.name, - // v8 ignore next - ...(result.diff ? { diff: result.diff } : {}), - // v8 ignore next - ...(result.error ? { error: result.error } : {}), - }, - }; + const newMessages = [...approvedMessages, toolResultMessage]; + dispatch({ + type: ChatActionType.CommitMessages, + messages: newMessages, + }); - const newMessages = [...approvedMessages, toolResultMessage]; - dispatch({ - type: ChatActionType.CommitMessages, - messages: newMessages, - }); + await runTurn(newMessages, mode); + break; + } - await runTurn(newMessages, mode); - break; + case DECISION.REJECT: { + const toolResultMessage: ollama.Message = { + role: ROLE.SYSTEM, + content: tools.formatToolResultContent( + toolCall.function.name, + { + content: '', + error: 'Tool call rejected by user', + }, + toolCall.function.arguments, + ), + }; + dispatch({ + type: ChatActionType.ToolRejected, + messages: [...approvedMessages, toolResultMessage], + }); + break; + } } + } finally { + activeTurnRef.current = false; + } + }, + [mode, pendingToolCall, runTurn], + ); - case DECISION.REJECT: { - const toolResultMessage: ollama.Message = { - role: ROLE.SYSTEM, - content: tools.formatToolResultContent( - toolCall.function.name, - { - content: '', - error: 'Tool call rejected by user', - }, - toolCall.function.arguments, - ), - }; - dispatch({ - type: ChatActionType.ToolRejected, - messages: [...approvedMessages, toolResultMessage], - }); - break; + const runUserPrompt = useCallback( + async ({ content, images }: SubmittedInput) => { + const userMessage: ollama.Message = { + role: ROLE.USER, + content, + ...(images?.length ? { images } : {}), + }; + + const updatedMessages = [...messages, userMessage]; + dispatch({ + type: ChatActionType.StartTurn, + message: userMessage, + }); + activeTurnRef.current = true; + + try { + if (mode === MODE.PLAN) { + await runTurnReadOnly(updatedMessages); + } else { + await runTurn(updatedMessages); } + } finally { + activeTurnRef.current = false; } }, - [mode, pendingToolCall, runTurn], + [messages, mode, runTurn, runTurnReadOnly], ); const handleSubmit = useCallback( @@ -289,6 +335,21 @@ export function Chat({ return; } + if (activeTurnRef.current || isLoading) { + if ( + userContent && + !images?.length && + !userContent.startsWith('/') && + !userContent.startsWith('!') + ) { + setQueuedMessages((current) => [ + ...current, + { content: userContent }, + ]); + } + return; + } + if (userContent === '/compact' && !images?.length) { const error = await compact(); if (error) { @@ -338,46 +399,73 @@ export function Chat({ type: ChatActionType.StartTurn, message: { role: ROLE.USER, content: userContent }, }); + activeTurnRef.current = true; - const result = await tools.runShell(command); - const output = result.content.trim(); - const errorLine = result.error ? `\nError: ${result.error}` : ''; - dispatch({ - type: ChatActionType.AppendMessage, - message: { - role: ROLE.SYSTEM, - content: `$ ${command}${output ? `\n${output}` : ''}${errorLine}`, - }, - }); - dispatch({ - type: ChatActionType.SetLoading, - isLoading: false, - }); + try { + const result = await tools.runShell(command); + const output = result.content.trim(); + const errorLine = result.error ? `\nError: ${result.error}` : ''; + dispatch({ + type: ChatActionType.AppendMessage, + message: { + role: ROLE.SYSTEM, + content: `$ ${command}${output ? `\n${output}` : ''}${errorLine}`, + }, + }); + dispatch({ + type: ChatActionType.SetLoading, + isLoading: false, + }); + } finally { + activeTurnRef.current = false; + } return; } - const userMessage: ollama.Message = { - role: ROLE.USER, - content: userContent, - ...(images?.length ? { images } : {}), - }; - - const updatedMessages = [...messages, userMessage]; - dispatch({ - type: ChatActionType.StartTurn, - message: userMessage, - }); - - // Use plan mode stream if in plan mode, otherwise normal stream - if (mode === MODE.PLAN) { - await runTurnReadOnly(updatedMessages); - } else { - await runTurn(updatedMessages); - } + await runUserPrompt({ content: userContent, images }); }, - [compact, messages, mode, onCommand, runTurn, runTurnReadOnly], + [compact, isLoading, onCommand, runUserPrompt], ); + useEffect(() => { + if ( + isLoading || + pendingPlan || + pendingToolCall || + !queuedMessages.length || + isDrainingQueue || + isDrainingQueueRef.current + ) { + return; + } + + const [nextMessage] = queuedMessages; + isDrainingQueueRef.current = true; + setIsDrainingQueue(true); + setQueuedMessages((current) => current.slice(1)); + void runUserPrompt(nextMessage).finally(() => { + isDrainingQueueRef.current = false; + setIsDrainingQueue(false); + }); + }, [ + isDrainingQueue, + isLoading, + pendingPlan, + pendingToolCall, + queuedMessages, + runUserPrompt, + ]); + + const handleRestoreQueuedMessage = useCallback(() => { + const nextMessage = queuedMessages.at(-1); + if (!nextMessage) { + return undefined; + } + + setQueuedMessages((current) => current.slice(0, -1)); + return nextMessage.content; + }, [queuedMessages]); + return ( + + {queuedMessages.length > 0 && ( + + Queued messages: + {queuedMessages.map(({ content }, index) => ( + + {' ↳ '} + {content} + + ))} + + )} + diff --git a/src/components/Chat/ChatInput.test.tsx b/src/components/Chat/ChatInput.test.tsx index 8f603558..8b6751b2 100644 --- a/src/components/Chat/ChatInput.test.tsx +++ b/src/components/Chat/ChatInput.test.tsx @@ -670,6 +670,30 @@ describe('ChatInput', () => { expect(lastFrame()).toContain('[image-1.png]'); }); + it('shows an error when staging a clipboard image via Ctrl+V while a turn is active', async () => { + const { lastFrame, stdin } = renderInput({ isActive: true }); + + stdin.write('\x16'); + await time.tick(); + + expect(clipboard.saveClipboardImage).not.toHaveBeenCalled(); + expect(lastFrame()).toContain("Images can't be queued."); + }); + + it('shows an error when pasting an image path while a turn is active', async () => { + const { lastFrame } = renderInput({ isActive: true }); + const inputProps = mockTextInput.mock.calls.at(-1)?.[0] as + { onChange?: (value: string) => void } | undefined; + + inputProps?.onChange?.( + `"${join(testDirectory, 'screen.png')}" explain this`, + ); + await time.tick(); + + expect(lastFrame()).toContain("Images can't be queued."); + expect(lastFrame()).not.toContain('[screen.png]'); + }); + it('hides the placeholder when an attachment is staged without typed text', async () => { const { lastFrame, stdin } = renderInput(); @@ -969,6 +993,64 @@ describe('ChatInput', () => { expect(onInterrupt).toHaveBeenCalledOnce(); }); + it('accepts and submits normal input while a turn is active', async () => { + const onSubmit = vi.fn(); + const { stdin } = renderInput({ isActive: true, onSubmit }); + stdin.write('follow up'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).toHaveBeenCalledWith({ content: 'follow up' }); + }); + + it('keeps commands in the composer while a turn is active', async () => { + const onSubmit = vi.fn(); + const { lastFrame, stdin } = renderInput({ isActive: true, onSubmit }); + stdin.write('!pwd'); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('!pwd'); + expect(lastFrame()).toContain("Commands and images can't be queued."); + }); + + it('interrupts an active turn on Ctrl+C even with a draft', async () => { + const onInterrupt = vi.fn(); + const { lastFrame, stdin } = renderInput({ isActive: true, onInterrupt }); + stdin.write('draft'); + await time.tick(); + stdin.write(KEY.CTRL_C); + await time.tick(); + expect(onInterrupt).toHaveBeenCalledOnce(); + expect(lastFrame()).toContain('draft'); + }); + + it('restores the latest queued message with Up on blank active input', async () => { + const onRestoreQueuedMessage = vi.fn(() => 'queued follow-up'); + const { lastFrame, stdin } = renderInput({ + isActive: true, + onRestoreQueuedMessage, + }); + stdin.write(KEY.UP); + await time.tick(); + expect(onRestoreQueuedMessage).toHaveBeenCalledOnce(); + expect(lastFrame()).toContain('queued follow-up'); + }); + + it('falls through to history navigation when onRestoreQueuedMessage returns undefined on blank active input', async () => { + const onRestoreQueuedMessage = vi.fn(() => undefined); + const { lastFrame, stdin } = renderInput({ + history: ['previous prompt'], + isActive: true, + onRestoreQueuedMessage, + }); + stdin.write(KEY.UP); + await time.tick(); + expect(onRestoreQueuedMessage).toHaveBeenCalledOnce(); + expect(lastFrame()).toContain('previous prompt'); + }); + it('calls onInterrupt on Esc when disabled', async () => { const onInterrupt = vi.fn(); const { stdin } = renderInput({ isDisabled: true, onInterrupt }); diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 9a861843..e4ca7940 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -21,8 +21,10 @@ import { useHistorySearch } from './hooks'; interface Props { history: string[]; + isActive?: boolean; isDisabled?: boolean; onInterrupt?: () => void; + onRestoreQueuedMessage?: () => string | undefined; onSubmit: (value: SubmittedInput) => void; } @@ -60,8 +62,10 @@ function cleanupAttachments(attachments: Attachment[]) { export function ChatInput({ history: sessionHistory, + isActive = false, isDisabled = false, onInterrupt, + onRestoreQueuedMessage, onSubmit, }: Props) { const theme = useTheme(); @@ -155,6 +159,11 @@ export function ChatInput({ }, []); const attachClipboardImage = useCallback(() => { + if (isActive) { + setError("Images can't be queued."); + return; + } + try { const path = clipboard.saveClipboardImage( `image-${String(nextClipboardImageRef.current)}`, @@ -164,7 +173,7 @@ export function ChatInput({ } catch (error) { setError(error instanceof Error ? error.message : String(error)); } - }, [stageAttachments]); + }, [isActive, stageAttachments]); const handleSelectFileSuggestion = useCallback( (nextInput: FileSuggestionRef) => { @@ -213,6 +222,11 @@ export function ChatInput({ extractImageAttachments(nextInput); if (nextAttachments.length) { + if (isActive) { + setError("Images can't be queued."); + return; + } + stageAttachments(nextAttachments); setInput(remainingInput); setCursorPosition(remainingInput.length); @@ -227,7 +241,7 @@ export function ChatInput({ resetHistorySearch(); setError(null); }, - [input, resetHistorySearch, stageAttachments], + [input, isActive, resetHistorySearch, stageAttachments], ); const submitAndReset = useCallback( @@ -239,6 +253,16 @@ export function ChatInput({ return; } + if ( + isActive && + (imagePaths.length > 0 || + trimmedInput.startsWith('/') || + trimmedInput.startsWith('!')) + ) { + setError("Commands and images can't be queued."); + return; + } + onSubmit({ content: trimmedInput, ...(imagePaths.length ? { images: imagePaths } : {}), @@ -249,7 +273,7 @@ export function ChatInput({ resetInput(trimmedInput.startsWith('/')); fileSuggestionRef.current = null; }, - [attachments, onSubmit, resetInput], + [attachments, isActive, onSubmit, resetInput], ); const isMultilineInput = input.includes('\n'); @@ -362,6 +386,11 @@ export function ChatInput({ return; } + if (isActive && (isEscape || isCtrlC)) { + onInterrupt?.(); + return; + } + if (historySearch.isActive) { if (isEscape || isCtrlC) { cancelHistorySearch(); @@ -419,6 +448,16 @@ export function ChatInput({ } if (key.upArrow) { + if (isActive && !input && !hasAttachments) { + const queuedMessage = onRestoreQueuedMessage?.(); + if (queuedMessage !== undefined) { + setInput(queuedMessage); + setCursorPosition(queuedMessage.length); + setError(null); + return; + } + } + handleHistoryNavigation('up'); return; } From f0b151027e3def72f7d24f9b1357c638fdafcf31 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 11 Jul 2026 19:46:20 -0400 Subject: [PATCH 2/3] refactor(Chat): replace queued-message indentation with marginLeft --- src/components/Chat/Chat.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 438a5224..f61f0e33 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -515,10 +515,11 @@ export function Chat({ Queued messages: {queuedMessages.map(({ content }, index) => ( - - {' ↳ '} - {content} - + + + ↳ {content} + + ))} )} From 759a90c2eff63f4a3430d692ffae547c962e20fc Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 11 Jul 2026 19:53:31 -0400 Subject: [PATCH 3/3] refactor(Chat): extract hook useMessageQueue Added `src/components/Chat/hooks/useMessageQueue.ts` and exported it through the hooks barrel. The hook now owns: - FIFO queue draining - Pause handling - Duplicate-drain protection - LIFO restoration for editing - Session-reset clearing `Chat` retains message classification and rendering. --- src/components/Chat/Chat.tsx | 61 ++++-------------- src/components/Chat/hooks/index.ts | 1 + src/components/Chat/hooks/useMessageQueue.ts | 67 ++++++++++++++++++++ 3 files changed, 80 insertions(+), 49 deletions(-) create mode 100644 src/components/Chat/hooks/useMessageQueue.ts diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index f61f0e33..cd501a5b 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -19,7 +19,7 @@ import { agents, memory, ollama, tools } from '@/utils'; import { ChatInput, type SubmittedInput } from './ChatInput'; import { MEMORY_COMMANDS } from './CommandMenu'; import { ChatActionType, InterruptReason } from './constants'; -import { useCompact, useRunTurn } from './hooks'; +import { useCompact, useMessageQueue, useRunTurn } from './hooks'; import { chatReducer, createInitialChatState } from './reducer'; import { ToolProgress } from './ToolProgress'; @@ -112,11 +112,8 @@ export function Chat({ } = state; const abortControllerRef = useRef(null); const activeTurnRef = useRef(false); - const isDrainingQueueRef = useRef(false); const persistedSnapshotRef = useRef(''); const [compactError, setCompactError] = useState(null); - const [isDrainingQueue, setIsDrainingQueue] = useState(false); - const [queuedMessages, setQueuedMessages] = useState([]); const compact = useCompact({ abortControllerRef, dispatch, @@ -130,9 +127,6 @@ export function Chat({ useEffect(() => { activeTurnRef.current = false; - isDrainingQueueRef.current = false; - setIsDrainingQueue(false); - setQueuedMessages([]); dispatch({ type: ChatActionType.ResetSession, messages: sessionMessages, @@ -326,6 +320,13 @@ export function Chat({ [messages, mode, runTurn, runTurnReadOnly], ); + const { enqueueMessage, queuedMessages, restoreLatestMessage } = + useMessageQueue({ + isPaused: isLoading || !!pendingPlan || !!pendingToolCall, + onRunMessage: runUserPrompt, + resetKey: sessionId, + }); + const handleSubmit = useCallback( async ({ content, images }: SubmittedInput) => { const userContent = content.trim(); @@ -342,10 +343,7 @@ export function Chat({ !userContent.startsWith('/') && !userContent.startsWith('!') ) { - setQueuedMessages((current) => [ - ...current, - { content: userContent }, - ]); + enqueueMessage({ content: userContent }); } return; } @@ -424,47 +422,12 @@ export function Chat({ await runUserPrompt({ content: userContent, images }); }, - [compact, isLoading, onCommand, runUserPrompt], + [compact, enqueueMessage, isLoading, onCommand, runUserPrompt], ); - useEffect(() => { - if ( - isLoading || - pendingPlan || - pendingToolCall || - !queuedMessages.length || - isDrainingQueue || - isDrainingQueueRef.current - ) { - return; - } - - const [nextMessage] = queuedMessages; - isDrainingQueueRef.current = true; - setIsDrainingQueue(true); - setQueuedMessages((current) => current.slice(1)); - void runUserPrompt(nextMessage).finally(() => { - isDrainingQueueRef.current = false; - setIsDrainingQueue(false); - }); - }, [ - isDrainingQueue, - isLoading, - pendingPlan, - pendingToolCall, - queuedMessages, - runUserPrompt, - ]); - const handleRestoreQueuedMessage = useCallback(() => { - const nextMessage = queuedMessages.at(-1); - if (!nextMessage) { - return undefined; - } - - setQueuedMessages((current) => current.slice(0, -1)); - return nextMessage.content; - }, [queuedMessages]); + return restoreLatestMessage()?.content; + }, [restoreLatestMessage]); return ( diff --git a/src/components/Chat/hooks/index.ts b/src/components/Chat/hooks/index.ts index 1c9a844a..29240406 100644 --- a/src/components/Chat/hooks/index.ts +++ b/src/components/Chat/hooks/index.ts @@ -1,3 +1,4 @@ export { useCompact } from './useCompact'; export { useHistorySearch } from './useHistorySearch'; +export { useMessageQueue } from './useMessageQueue'; export { useRunTurn } from './useRunTurn'; diff --git a/src/components/Chat/hooks/useMessageQueue.ts b/src/components/Chat/hooks/useMessageQueue.ts new file mode 100644 index 00000000..bf65e136 --- /dev/null +++ b/src/components/Chat/hooks/useMessageQueue.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { SubmittedInput } from '../ChatInput'; + +interface UseMessageQueueOptions { + isPaused: boolean; + onRunMessage: (message: SubmittedInput) => Promise; + resetKey: string; +} + +interface MessageQueue { + enqueueMessage: (message: SubmittedInput) => void; + queuedMessages: SubmittedInput[]; + restoreLatestMessage: () => SubmittedInput | undefined; +} + +export function useMessageQueue({ + isPaused, + onRunMessage, + resetKey, +}: UseMessageQueueOptions): MessageQueue { + const isDrainingRef = useRef(false); + const [isDraining, setIsDraining] = useState(false); + const [queuedMessages, setQueuedMessages] = useState([]); + + useEffect(() => { + isDrainingRef.current = false; + setIsDraining(false); + setQueuedMessages([]); + }, [resetKey]); + + useEffect(() => { + if ( + isPaused || + !queuedMessages.length || + isDraining || + isDrainingRef.current + ) { + return; + } + + const [nextMessage] = queuedMessages; + isDrainingRef.current = true; + setIsDraining(true); + setQueuedMessages((current) => current.slice(1)); + void onRunMessage(nextMessage).finally(() => { + isDrainingRef.current = false; + setIsDraining(false); + }); + }, [isDraining, isPaused, onRunMessage, queuedMessages]); + + const enqueueMessage = useCallback((message: SubmittedInput) => { + setQueuedMessages((current) => [...current, message]); + }, []); + + const restoreLatestMessage = useCallback(() => { + const latestMessage = queuedMessages.at(-1); + if (!latestMessage) { + return undefined; + } + + setQueuedMessages((current) => current.slice(0, -1)); + return latestMessage; + }, [queuedMessages]); + + return { enqueueMessage, queuedMessages, restoreLatestMessage }; +}