feat: queue messages while session is processing, auto-send when idle

This commit is contained in:
Zac Gaetano 2026-05-30 10:08:24 -04:00
parent f7be8ff31d
commit 1fcd5a7880

View file

@ -78,6 +78,11 @@ interface CommandExecutionResult {
hasFileIncludes?: boolean; hasFileIncludes?: boolean;
} }
interface QueuedMessage {
id: string;
text: string;
}
const createFakeSubmitEvent = () => { const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>; return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
}; };
@ -134,8 +139,6 @@ export function useChatComposerState({
}: UseChatComposerStateArgs) { }: UseChatComposerStateArgs) {
const [input, setInput] = useState(() => { const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) { if (typeof window !== 'undefined' && selectedProject) {
// Draft inputs are keyed by the DB projectId so per-project drafts
// survive display-name changes.
return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || ''; return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
} }
return ''; return '';
@ -146,6 +149,10 @@ export function useChatComposerState({
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none'); const [thinkingMode, setThinkingMode] = useState('none');
// Queue of messages typed while a session is processing
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([]);
const queueIdRef = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null); const inputHighlightRef = useRef<HTMLDivElement>(null);
const handleSubmitRef = useRef< const handleSubmitRef = useRef<
@ -258,7 +265,6 @@ export function useChatComposerState({
setInput(commandContent); setInput(commandContent);
inputValueRef.current = commandContent; inputValueRef.current = commandContent;
// Defer submit to next tick so the command text is reflected in UI before dispatching.
setTimeout(() => { setTimeout(() => {
if (handleSubmitRef.current) { if (handleSubmitRef.current) {
handleSubmitRef.current(createFakeSubmitEvent()); handleSubmitRef.current(createFakeSubmitEvent());
@ -278,8 +284,6 @@ export function useChatComposerState({
const args = const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
// The `/api/commands/execute` context sends `projectId` now instead of
// a folder-derived project name; the path is still included verbatim.
const context = { const context = {
projectPath: selectedProject.fullPath || selectedProject.path, projectPath: selectedProject.fullPath || selectedProject.path,
projectId: selectedProject.projectId, projectId: selectedProject.projectId,
@ -462,74 +466,16 @@ export function useChatComposerState({
noKeyboard: true, noKeyboard: true,
}); });
const handleSubmit = useCallback( // Core send logic — dispatches a message to the active provider.
async ( // Extracted so both handleSubmit and the queue drainer can call it.
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>, const dispatchMessage = useCallback(
) => { async (rawText: string, uploadedImages: unknown[] = []) => {
event.preventDefault(); if (!selectedProject) return;
const currentInput = inputValueRef.current;
if (!currentInput.trim() || isLoading || !selectedProject) {
return;
}
// Intercept slash commands only when "/" is the first input character. let messageContent = rawText;
const commandInput = currentInput.trimEnd();
if (commandInput.startsWith('/')) {
const firstSpace = commandInput.indexOf(' ');
const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
if (matchedCommand && matchedCommand.type !== 'skill') {
executeCommand(matchedCommand, commandInput);
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
return;
}
}
let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode); const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) { if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`; messageContent = `${selectedThinkingMode.prefix}: ${rawText}`;
}
let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) {
const formData = new FormData();
attachedImages.forEach((file) => {
formData.append('images', file);
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
method: 'POST',
headers: {},
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload images');
}
const result = await response.json();
uploadedImages = result.images;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Image upload failed:', error);
addMessage({
type: 'error',
content: `Failed to upload images: ${message}`,
timestamp: new Date(),
});
return;
}
} }
const effectiveSessionId = const effectiveSessionId =
@ -537,30 +483,22 @@ export function useChatComposerState({
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
type: 'user', type: 'user',
content: currentInput, content: rawText,
images: uploadedImages as any, images: uploadedImages as any,
timestamp: new Date(), timestamp: new Date(),
}; };
addMessage(userMessage); addMessage(userMessage);
setIsLoading(true); // Processing banner starts setIsLoading(true);
setCanAbortSession(true); setCanAbortSession(true);
setClaudeStatus({ setClaudeStatus({ text: 'Processing', tokens: 0, can_interrupt: true });
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100); setTimeout(() => scrollToBottom(), 100);
if (!effectiveSessionId && !selectedSession?.id) { if (!effectiveSessionId && !selectedSession?.id) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Reset stale pending IDs from previous interrupted runs before creating a new one.
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
} }
// For new sessions we intentionally keep this as `null` until the backend
// emits `session_created` with the canonical provider session id.
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
} }
if (effectiveSessionId) { if (effectiveSessionId) {
@ -595,7 +533,7 @@ export function useChatComposerState({
const toolsSettings = getToolsSettings(); const toolsSettings = getToolsSettings();
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); const sessionSummary = getNotificationSessionSummary(selectedSession, rawText);
if (provider === 'cursor') { if (provider === 'cursor') {
sendMessage({ sendMessage({
@ -661,6 +599,118 @@ export function useChatComposerState({
}, },
}); });
} }
},
[
addMessage,
claudeModel,
codexModel,
currentSessionId,
cursorModel,
geminiModel,
onSessionActive,
onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
scrollToBottom,
selectedProject,
selectedSession,
sendMessage,
setCanAbortSession,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
thinkingMode,
],
);
const handleSubmit = useCallback(
async (
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
) => {
event.preventDefault();
const currentInput = inputValueRef.current;
if (!currentInput.trim() || !selectedProject) {
return;
}
// Intercept slash commands only when "/" is the first input character.
const commandInput = currentInput.trimEnd();
if (commandInput.startsWith('/')) {
const firstSpace = commandInput.indexOf(' ');
const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
if (matchedCommand && matchedCommand.type !== 'skill') {
executeCommand(matchedCommand, commandInput);
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
return;
}
}
// If a session is currently processing, queue the message instead of blocking.
// Slash commands are NOT queued (handled above). Images are not queued (must send now).
if (isLoading && attachedImages.length === 0) {
queueIdRef.current += 1;
const id = `q-${queueIdRef.current}`;
setMessageQueue((prev) => [...prev, { id, text: currentInput }]);
setInput('');
inputValueRef.current = '';
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
return;
}
// Block only if loading AND there are images (can't queue image uploads safely)
if (isLoading) {
return;
}
let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) {
const formData = new FormData();
attachedImages.forEach((file) => {
formData.append('images', file);
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
method: 'POST',
headers: {},
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload images');
}
const result = await response.json();
uploadedImages = result.images;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Image upload failed:', error);
addMessage({
type: 'error',
content: `Failed to upload images: ${message}`,
timestamp: new Date(),
});
return;
}
}
await dispatchMessage(currentInput, uploadedImages);
setInput(''); setInput('');
inputValueRef.current = ''; inputValueRef.current = '';
@ -678,34 +728,35 @@ export function useChatComposerState({
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
}, },
[ [
selectedSession,
attachedImages, attachedImages,
claudeModel, dispatchMessage,
codexModel,
currentSessionId,
cursorModel,
executeCommand, executeCommand,
geminiModel,
isLoading, isLoading,
onSessionActive,
onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
resetCommandMenuState, resetCommandMenuState,
scrollToBottom,
selectedProject, selectedProject,
sendMessage,
setCanAbortSession,
addMessage, addMessage,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
slashCommands, slashCommands,
thinkingMode,
], ],
); );
// Drain the queue: when the session goes idle and there are queued messages,
// send the next one automatically.
useEffect(() => {
if (isLoading || messageQueue.length === 0) {
return;
}
const [next, ...rest] = messageQueue;
setMessageQueue(rest);
// Fire after current render so isLoading flips correctly
setTimeout(() => {
dispatchMessage(next.text, []);
}, 50);
}, [isLoading, messageQueue, dispatchMessage]);
const removeQueuedMessage = useCallback((id: string) => {
setMessageQueue((prev) => prev.filter((m) => m.id !== id));
}, []);
useEffect(() => { useEffect(() => {
handleSubmitRef.current = handleSubmit; handleSubmitRef.current = handleSubmit;
}, [handleSubmit]); }, [handleSubmit]);
@ -741,7 +792,6 @@ export function useChatComposerState({
if (!textareaRef.current) { if (!textareaRef.current) {
return; return;
} }
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`; textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
@ -856,6 +906,9 @@ export function useChatComposerState({
return; return;
} }
// Clear any queued messages when the user aborts
setMessageQueue([]);
const pendingSessionId = const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const cursorSessionId = const cursorSessionId =
@ -980,5 +1033,7 @@ export function useChatComposerState({
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused, isInputFocused,
messageQueue,
removeQueuedMessage,
}; };
} }