fix(cli): dispatch queued slash commands through the slash path (#3523)

* fix(cli): dispatch queued slash commands through the slash path

When the agent was responding and the user queued a message, the drain
path joined all queued messages with `\n\n` and submitted them as one
prompt. Any slash command in that blob (e.g. `/model`) no longer started
with `/`, so it was sent to the model as plain text instead of opening
the command's dialog.

The mid-turn tool-result drain had the same problem: it drained the
entire queue into the tool-result payload, so a slash command queued
during tool execution was injected as context for the model rather than
executed as a command.

Queue draining now splits into segments — consecutive plain-text
messages are still batched into one submission, while slash commands
are submitted alone so their `/` prefix survives. The mid-turn drain
only takes leading plain-text messages and leaves slash commands
queued for the normal idle drain. The idle drain is gated on open
dialogs so a queued `/model` does not cause the following queued
prompt to be sent to the model while the picker is still open, and a
re-entry lock plus a nonce close the race between state commits and
the async dialog-open.

* fix(cli): defer queued slash commands until idle

* fix(cli): drop queued messages on cancel instead of auto-submitting

Cancel's contract is now "abort and redirect" in both cancel paths:
restore the most recent queued segment into the buffer for editing and
drop the rest, so forgotten follow-ups cannot auto-submit once the turn
settles. Previously the non-tool path left queued plain-text segments
in place for the idle drain to fire, and the tool-executing path
cleared only the buffer — both surprised users with belated message
dispatches after they had already cancelled.

* refactor(cli): batch plain prompts in idle drain

Idle drain now runs in two phases: drain all plain-text prompts into one
turn (drainQueue), then pop slash commands one-by-one (popNextSegment).
Mirrors the mid-turn behavior so queue handling is consistent across
mid-turn and idle contexts.

popAllMessages now drains the entire queue joined with \n\n for Ctrl+C
cancel and ESC/Up edit-restore. Drop the unused options parameter from
useMessageQueue and the extractFirstSegment helper.

---------

Co-authored-by: 愚远 <zhenxing.tzx@alibaba-inc.com>
This commit is contained in:
tanzhenxin 2026-04-24 17:11:00 +08:00 committed by GitHub
parent 2aad7c0617
commit c87d2798bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 367 additions and 269 deletions

View file

@ -28,6 +28,7 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { ToolCallStatus } from './types.js';
import { useContext } from 'react';
// Mock useStdout to capture terminal title writes
@ -245,6 +246,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
mockedUseAutoAcceptIndicator.mockReturnValue(false);
mockedUseGitBranchName.mockReturnValue('main');
@ -459,6 +461,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@ -476,6 +479,44 @@ describe('AppContainer State Management', () => {
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it('submits slash commands immediately instead of queueing while idle', () => {
const mockSubmitQuery = vi.fn();
const mockQueueMessage = vi.fn();
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
capturedUIActions.handleFinalSubmit('/model');
expect(mockSubmitQuery).toHaveBeenCalledWith('/model');
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])(
'routes bare "%s" to /quit instead of sending as a message',
(command) => {
@ -497,6 +538,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@ -577,6 +619,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue(null),
});
render(
@ -605,6 +648,7 @@ describe('AppContainer State Management', () => {
it('moves queued follow-up messages into an empty buffer on cancel', async () => {
const mockSetText = vi.fn();
const mockPopAllMessages = vi.fn().mockReturnValue('queued follow-up');
const mockClearQueue = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: mockSetText,
@ -626,10 +670,11 @@ describe('AppContainer State Management', () => {
mockedUseMessageQueue.mockReturnValue({
messageQueue: ['queued follow-up'],
addMessage: vi.fn(),
clearQueue: vi.fn(),
clearQueue: mockClearQueue,
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
popAllMessages: mockPopAllMessages,
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
});
render(
@ -653,6 +698,75 @@ describe('AppContainer State Management', () => {
expect.stringContaining('the previous prompt'),
);
expect(mockPopAllMessages).toHaveBeenCalled();
// popAllForEdit drains the queue internally, so the cancel handler
// does not need to call clearQueue separately on this path.
expect(mockClearQueue).not.toHaveBeenCalled();
});
it('drops the queue when cancelling during tool execution', async () => {
// Simulates: user asks for a shell tool (e.g. sleep 30), queues
// `/model` and `hi` while the tool is running, then hits Ctrl+C.
// The cancel must clear BOTH the buffer and the queue so that
// `hi` does not auto-fire once the tool settles and the app
// returns to idle.
const mockSetText = vi.fn();
const mockClearQueue = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: mockSetText,
});
installCancelCapture({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'run_shell_command',
description: 'sleep 30',
status: ToolCallStatus.Executing,
resultDisplay: undefined,
confirmationDetails: undefined,
renderOutputAsMarkdown: false,
},
],
},
],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: ['/model', 'hi'],
addMessage: vi.fn(),
clearQueue: mockClearQueue,
getQueuedMessagesText: vi.fn().mockReturnValue('/model\n\nhi'),
popAllMessages: vi.fn().mockReturnValue('/model'),
drainQueue: vi.fn().mockReturnValue([]),
popNextSegment: vi.fn().mockReturnValue('/model'),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await Promise.resolve();
await Promise.resolve();
triggerCancel();
// Buffer cleared and queue dropped — same "abort and redirect"
// contract as the non-tool cancel path.
expect(mockSetText).toHaveBeenCalledWith('');
expect(mockClearQueue).toHaveBeenCalled();
});
it('preserves an in-progress draft when restoring queued messages on cancel', async () => {
@ -680,6 +794,7 @@ describe('AppContainer State Management', () => {
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
popAllMessages: vi.fn().mockReturnValue('queued follow-up'),
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
});
render(

View file

@ -88,7 +88,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useVim } from './hooks/vim.js';
import { isBtwCommand } from './utils/commandUtils.js';
import { isBtwCommand, isSlashCommand } from './utils/commandUtils.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
@ -882,16 +882,18 @@ export const AppContainer = (props: AppContainerProps) => {
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, popAllMessages, drainQueue } =
useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
});
const {
messageQueue,
addMessage,
clearQueue,
popAllMessages,
drainQueue,
popNextSegment,
} = useMessageQueue();
// Bridge message queue to mid-turn drain via ref.
// drainQueue reads the synchronous queueRef inside the hook, so it
// stays consistent with popAllMessages even before React re-renders.
// stays consistent with popNextSegment even before React re-renders.
midTurnDrainRef.current = drainQueue;
// Connect remote input watcher to submitQuery for bidirectional sync.
@ -1167,6 +1169,14 @@ export const AppContainer = (props: AppContainerProps) => {
speculationRef.current = IDLE_SPECULATION;
}
if (
streamingState === StreamingState.Idle &&
isSlashCommand(submittedValue)
) {
void submitQuery(submittedValue);
return;
}
addMessage(submittedValue);
},
[
@ -1213,28 +1223,22 @@ export const AppContainer = (props: AppContainerProps) => {
...pendingGeminiHistoryItems,
];
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
// Tool-cancel: drop both buffer and queue so nothing auto-fires later.
buffer.setText('');
clearQueue();
return;
}
// Move any queued follow-up messages back into the buffer so the user
// can edit or resubmit them. Otherwise leave the buffer alone — in
// particular, do NOT repopulate it with the previous prompt; the user
// can still recall it via history navigation (Up/Ctrl+P).
//
// popAllMessages is atomic via the queue's synchronous ref, matching
// the drain behavior used during tool completion.
// Restore queued input joined into the buffer for editing.
const popped = popAllMessages();
if (popped) {
const currentText = buffer.text;
// Preserve any in-progress draft the user typed since submitting (this
// is reachable via Ctrl+C cancel, which fires regardless of buffer
// content). Mirrors the popQueueIntoInput convention in InputPrompt.
buffer.setText(currentText ? `${popped}\n${currentText}` : popped);
}
}, [
buffer,
popAllMessages,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
]);
@ -2038,6 +2042,39 @@ export const AppContainer = (props: AppContainerProps) => {
isExtensionsManagerDialogOpen;
dialogsVisibleRef.current = dialogsVisible;
// Drain queued messages when idle. `queueDrainNonce` re-fires the effect
// after each submission settles so multi-step queues drain end-to-end.
const queueDrainingRef = useRef(false);
const [queueDrainNonce, setQueueDrainNonce] = useState(0);
useEffect(() => {
if (queueDrainingRef.current) return;
if (!isConfigInitialized) return;
if (streamingState !== StreamingState.Idle) return;
if (dialogsVisible) return;
if (messageQueue.length === 0) return;
// Two-phase: batch plain prompts as one turn, else pop next slash command.
const plainPrompts = drainQueue();
const submission =
plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
if (submission === null) return;
queueDrainingRef.current = true;
Promise.resolve(submitQuery(submission)).finally(() => {
queueDrainingRef.current = false;
setQueueDrainNonce((n) => n + 1);
});
}, [
isConfigInitialized,
streamingState,
dialogsVisible,
messageQueue,
drainQueue,
popNextSegment,
submitQuery,
queueDrainNonce,
]);
const {
isFeedbackDialogOpen,
openFeedbackDialog,

View file

@ -7,13 +7,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessageQueue } from './useMessageQueue.js';
import { StreamingState } from '../types.js';
describe('useMessageQueue', () => {
let mockSubmitQuery: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockSubmitQuery = vi.fn();
vi.useFakeTimers();
});
@ -23,26 +19,14 @@ describe('useMessageQueue', () => {
});
it('should initialize with empty queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
}),
);
const { result } = renderHook(() => useMessageQueue());
expect(result.current.messageQueue).toEqual([]);
expect(result.current.getQueuedMessagesText()).toBe('');
});
it('should add messages to queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Test message 1');
@ -56,13 +40,7 @@ describe('useMessageQueue', () => {
});
it('should filter out empty messages', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Valid message');
@ -78,13 +56,7 @@ describe('useMessageQueue', () => {
});
it('should clear queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Test message');
@ -100,13 +72,7 @@ describe('useMessageQueue', () => {
});
it('should return queued messages as text with double newlines', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Message 1');
@ -119,181 +85,193 @@ describe('useMessageQueue', () => {
);
});
it('should auto-submit queued messages when transitioning to Idle', () => {
const { result, rerender } = renderHook(
({ streamingState }) =>
useMessageQueue({
isConfigInitialized: true,
streamingState,
submitQuery: mockSubmitQuery,
}),
{
initialProps: { streamingState: StreamingState.Responding },
},
);
describe('popAllMessages (cancel and ESC/Up restore)', () => {
it('returns null when the queue is empty', () => {
const { result } = renderHook(() => useMessageQueue());
// Add some messages
act(() => {
result.current.addMessage('Message 1');
result.current.addMessage('Message 2');
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBeNull();
expect(result.current.messageQueue).toEqual([]);
});
expect(result.current.messageQueue).toEqual(['Message 1', 'Message 2']);
it('joins all queued messages with double newlines and clears the queue', () => {
const { result } = renderHook(() => useMessageQueue());
// Transition to Idle
rerender({ streamingState: StreamingState.Idle });
act(() => {
result.current.addMessage('Message 1');
result.current.addMessage('Message 2');
result.current.addMessage('Message 3');
});
expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2');
expect(result.current.messageQueue).toEqual([]);
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBe('Message 1\n\nMessage 2\n\nMessage 3');
expect(result.current.messageQueue).toEqual([]);
});
it('returns a single message without separator', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('Only message');
});
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBe('Only message');
expect(result.current.messageQueue).toEqual([]);
});
it('joins mixed slash commands and prompts in original order', () => {
// Edit-restore intentionally collapses segment boundaries: the user is
// recovering input into the buffer to edit before resubmitting, so
// typing order matters more than slash-vs-prompt routing boundaries.
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('/model');
result.current.addMessage('hello');
result.current.addMessage('world');
});
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBe('/model\n\nhello\n\nworld');
expect(result.current.messageQueue).toEqual([]);
});
});
it('should not auto-submit when queue is empty', () => {
const { rerender } = renderHook(
({ streamingState }) =>
useMessageQueue({
isConfigInitialized: true,
streamingState,
submitQuery: mockSubmitQuery,
}),
{
initialProps: { streamingState: StreamingState.Responding },
},
);
describe('drainQueue (mid-turn drain for tool-result injection)', () => {
it('returns an empty array when the queue is empty', () => {
const { result } = renderHook(() => useMessageQueue());
// Transition to Idle with empty queue
rerender({ streamingState: StreamingState.Idle });
let drained: string[] = [];
act(() => {
drained = result.current.drainQueue();
});
expect(drained).toEqual([]);
});
expect(mockSubmitQuery).not.toHaveBeenCalled();
it('drains all plain-text messages and leaves slash commands queued', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('one');
result.current.addMessage('two');
result.current.addMessage('/model');
result.current.addMessage('three');
});
let drained: string[] = [];
act(() => {
drained = result.current.drainQueue();
});
expect(drained).toEqual(['one', 'two', 'three']);
expect(result.current.messageQueue).toEqual(['/model']);
});
it('returns an empty array when the queue contains only slash commands', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('/model');
result.current.addMessage('/help');
});
let drained: string[] = [];
act(() => {
drained = result.current.drainQueue();
});
expect(drained).toEqual([]);
expect(result.current.messageQueue).toEqual(['/model', '/help']);
});
it('drains the whole queue when it contains no slash commands', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('a');
result.current.addMessage('b');
result.current.addMessage('c');
});
let drained: string[] = [];
act(() => {
drained = result.current.drainQueue();
});
expect(drained).toEqual(['a', 'b', 'c']);
expect(result.current.messageQueue).toEqual([]);
});
});
it('should not auto-submit when not transitioning to Idle', () => {
const { result, rerender } = renderHook(
({ streamingState }) =>
useMessageQueue({
isConfigInitialized: true,
streamingState,
submitQuery: mockSubmitQuery,
}),
{
initialProps: { streamingState: StreamingState.Responding },
},
);
describe('popNextSegment', () => {
it('returns null when the queue is empty', () => {
const { result } = renderHook(() => useMessageQueue());
// Add messages
act(() => {
result.current.addMessage('Message 1');
let segment: string | null = null;
act(() => {
segment = result.current.popNextSegment();
});
expect(segment).toBeNull();
});
// Transition to WaitingForConfirmation (not Idle)
rerender({ streamingState: StreamingState.WaitingForConfirmation });
it('pops the first item and leaves the rest queued', () => {
const { result } = renderHook(() => useMessageQueue());
expect(mockSubmitQuery).not.toHaveBeenCalled();
expect(result.current.messageQueue).toEqual(['Message 1']);
});
act(() => {
result.current.addMessage('/model');
result.current.addMessage('/help');
});
it('should handle multiple state transitions correctly', () => {
const { result, rerender } = renderHook(
({ streamingState }) =>
useMessageQueue({
isConfigInitialized: true,
streamingState,
submitQuery: mockSubmitQuery,
}),
{
initialProps: { streamingState: StreamingState.Idle },
},
);
// Start responding
rerender({ streamingState: StreamingState.Responding });
// Add messages while responding
act(() => {
result.current.addMessage('First batch');
let segment: string | null = null;
act(() => {
segment = result.current.popNextSegment();
});
expect(segment).toBe('/model');
expect(result.current.messageQueue).toEqual(['/help']);
});
// Go back to idle - should submit
rerender({ streamingState: StreamingState.Idle });
it('drains the queue one item at a time across repeated calls', () => {
const { result } = renderHook(() => useMessageQueue());
expect(mockSubmitQuery).toHaveBeenCalledWith('First batch');
expect(result.current.messageQueue).toEqual([]);
act(() => {
result.current.addMessage('/model');
result.current.addMessage('/theme');
result.current.addMessage('/help');
});
// Start responding again
rerender({ streamingState: StreamingState.Responding });
const segments: Array<string | null> = [];
act(() => {
segments.push(result.current.popNextSegment());
});
act(() => {
segments.push(result.current.popNextSegment());
});
act(() => {
segments.push(result.current.popNextSegment());
});
act(() => {
segments.push(result.current.popNextSegment());
});
// Add more messages
act(() => {
result.current.addMessage('Second batch');
expect(segments).toEqual(['/model', '/theme', '/help', null]);
expect(result.current.messageQueue).toEqual([]);
});
// Go back to idle - should submit again
rerender({ streamingState: StreamingState.Idle });
expect(mockSubmitQuery).toHaveBeenCalledWith('Second batch');
expect(mockSubmitQuery).toHaveBeenCalledTimes(2);
});
it('should pop all messages from queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
act(() => {
result.current.addMessage('Message 1');
result.current.addMessage('Message 2');
result.current.addMessage('Message 3');
});
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBe('Message 1\n\nMessage 2\n\nMessage 3');
expect(result.current.messageQueue).toEqual([]);
});
it('should pop single message without separator', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
act(() => {
result.current.addMessage('Only message');
});
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBe('Only message');
expect(result.current.messageQueue).toEqual([]);
});
it('should return null when popping from empty queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
let popped: string | null = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toBeNull();
expect(result.current.messageQueue).toEqual([]);
});
});

View file

@ -4,45 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { StreamingState } from '../types.js';
export interface UseMessageQueueOptions {
isConfigInitialized: boolean;
streamingState: StreamingState;
submitQuery: (query: string) => void;
}
import { useCallback, useRef, useState } from 'react';
import { isSlashCommand } from '../utils/commandUtils.js';
export interface UseMessageQueueReturn {
messageQueue: string[];
addMessage: (message: string) => void;
clearQueue: () => void;
getQueuedMessagesText: () => string;
/** Drain the entire queue joined with `\n\n`. For Ctrl+C / ESC / Up edit-restore. */
popAllMessages: () => string | null;
/**
* Atomically drain all queued messages. Returns the drained messages
* and clears both the synchronous ref and React state. Safe to call
* from non-React contexts (e.g., tool completion callbacks).
*/
/** Drain plain-text prompts; leave slash commands queued. Safe from non-React callbacks. */
drainQueue: () => string[];
/** Pop the first item from the queue. */
popNextSegment: () => string | null;
}
/**
* Hook for managing message queuing during streaming responses.
* Allows users to queue messages while the AI is responding and automatically
* sends them when streaming completes.
*/
export function useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
}: UseMessageQueueOptions): UseMessageQueueReturn {
export function useMessageQueue(): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// Synchronous ref mirrors React state so non-React callbacks (e.g.,
// mid-turn drain in handleCompletedTools) always see the latest queue.
// Synchronous mirror so non-React callbacks see the latest queue.
const queueRef = useRef<string[]>([]);
// Add a message to the queue
const addMessage = useCallback((message: string) => {
const trimmedMessage = message.trim();
if (trimmedMessage.length > 0) {
@ -51,58 +33,43 @@ export function useMessageQueue({
}
}, []);
// Clear the entire queue
const clearQueue = useCallback(() => {
queueRef.current = [];
setMessageQueue([]);
}, []);
// Get all queued messages as a single text string
const getQueuedMessagesText = useCallback(() => {
if (messageQueue.length === 0) return '';
return messageQueue.join('\n\n');
}, [messageQueue]);
// Pop all messages from the queue for editing (atomic via ref to prevent
// duplicate pops from key auto-repeat before React re-renders)
const popAllMessages = useCallback((): string | null => {
const current = queueRef.current;
if (current.length === 0) return null;
const allText = current.join('\n\n');
queueRef.current = [];
setMessageQueue([]);
return allText;
return current.join('\n\n');
}, []);
// Atomically drain all queued messages (synchronous, safe from callbacks).
const drainQueue = useCallback((): string[] => {
const drained = queueRef.current;
const current = queueRef.current;
if (current.length === 0) return [];
const drained = current.filter((message) => !isSlashCommand(message));
if (drained.length === 0) return [];
queueRef.current = [];
setMessageQueue([]);
const rest = current.filter((message) => isSlashCommand(message));
queueRef.current = rest;
setMessageQueue(rest);
return drained;
}, []);
// Process queued messages when streaming becomes idle
useEffect(() => {
if (
isConfigInitialized &&
streamingState === StreamingState.Idle &&
messageQueue.length > 0
) {
// Combine all messages with double newlines for clarity
const combinedMessage = messageQueue.join('\n\n');
// Clear the queue and submit
clearQueue();
submitQuery(combinedMessage);
}
}, [
isConfigInitialized,
streamingState,
messageQueue,
submitQuery,
clearQueue,
]);
const popNextSegment = useCallback((): string | null => {
const current = queueRef.current;
if (current.length === 0) return null;
const [head, ...rest] = current;
queueRef.current = rest;
setMessageQueue(rest);
return head;
}, []);
return {
messageQueue,
@ -111,5 +78,6 @@ export function useMessageQueue({
getQueuedMessagesText,
popAllMessages,
drainQueue,
popNextSegment,
};
}