From 1fcd5a7880678f1631e296f4b9ac4379dd084ac1 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 30 May 2026 10:08:24 -0400 Subject: [PATCH] feat: queue messages while session is processing, auto-send when idle --- .../chat/hooks/useChatComposerState.ts | 257 +++++++++++------- 1 file changed, 156 insertions(+), 101 deletions(-) diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 883a12f..93b8ed8 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -78,6 +78,11 @@ interface CommandExecutionResult { hasFileIncludes?: boolean; } +interface QueuedMessage { + id: string; + text: string; +} + const createFakeSubmitEvent = () => { return { preventDefault: () => undefined } as unknown as FormEvent; }; @@ -134,8 +139,6 @@ export function useChatComposerState({ }: UseChatComposerStateArgs) { const [input, setInput] = useState(() => { 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 ''; @@ -146,6 +149,10 @@ export function useChatComposerState({ const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [thinkingMode, setThinkingMode] = useState('none'); + // Queue of messages typed while a session is processing + const [messageQueue, setMessageQueue] = useState([]); + const queueIdRef = useRef(0); + const textareaRef = useRef(null); const inputHighlightRef = useRef(null); const handleSubmitRef = useRef< @@ -258,7 +265,6 @@ export function useChatComposerState({ setInput(commandContent); inputValueRef.current = commandContent; - // Defer submit to next tick so the command text is reflected in UI before dispatching. setTimeout(() => { if (handleSubmitRef.current) { handleSubmitRef.current(createFakeSubmitEvent()); @@ -278,8 +284,6 @@ export function useChatComposerState({ const args = 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 = { projectPath: selectedProject.fullPath || selectedProject.path, projectId: selectedProject.projectId, @@ -462,74 +466,16 @@ export function useChatComposerState({ noKeyboard: true, }); - const handleSubmit = useCallback( - async ( - event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, - ) => { - event.preventDefault(); - const currentInput = inputValueRef.current; - if (!currentInput.trim() || isLoading || !selectedProject) { - return; - } + // Core send logic — dispatches a message to the active provider. + // Extracted so both handleSubmit and the queue drainer can call it. + const dispatchMessage = useCallback( + async (rawText: string, uploadedImages: unknown[] = []) => { + if (!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; - } - } - - let messageContent = currentInput; + let messageContent = rawText; const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode); if (selectedThinkingMode && selectedThinkingMode.prefix) { - messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`; - } - - 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; - } + messageContent = `${selectedThinkingMode.prefix}: ${rawText}`; } const effectiveSessionId = @@ -537,30 +483,22 @@ export function useChatComposerState({ const userMessage: ChatMessage = { type: 'user', - content: currentInput, + content: rawText, images: uploadedImages as any, timestamp: new Date(), }; addMessage(userMessage); - setIsLoading(true); // Processing banner starts + setIsLoading(true); setCanAbortSession(true); - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true, - }); - + setClaudeStatus({ text: 'Processing', tokens: 0, can_interrupt: true }); setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 100); if (!effectiveSessionId && !selectedSession?.id) { if (typeof window !== 'undefined') { - // Reset stale pending IDs from previous interrupted runs before creating a new one. 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() }; } if (effectiveSessionId) { @@ -595,7 +533,7 @@ export function useChatComposerState({ const toolsSettings = getToolsSettings(); const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; - const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); + const sessionSummary = getNotificationSessionSummary(selectedSession, rawText); if (provider === 'cursor') { 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 | MouseEvent | TouchEvent | KeyboardEvent, + ) => { + 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(''); inputValueRef.current = ''; @@ -678,34 +728,35 @@ export function useChatComposerState({ safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); }, [ - selectedSession, attachedImages, - claudeModel, - codexModel, - currentSessionId, - cursorModel, + dispatchMessage, executeCommand, - geminiModel, isLoading, - onSessionActive, - onSessionProcessing, - pendingViewSessionRef, - permissionMode, - provider, resetCommandMenuState, - scrollToBottom, selectedProject, - sendMessage, - setCanAbortSession, addMessage, - setClaudeStatus, - setIsLoading, - setIsUserScrolledUp, 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(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); @@ -741,7 +792,6 @@ export function useChatComposerState({ if (!textareaRef.current) { 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 = `${Math.max(22, textareaRef.current.scrollHeight)}px`; const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); @@ -856,6 +906,9 @@ export function useChatComposerState({ return; } + // Clear any queued messages when the user aborts + setMessageQueue([]); + const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; const cursorSessionId = @@ -980,5 +1033,7 @@ export function useChatComposerState({ handleGrantToolPermission, handleInputFocusChange, isInputFocused, + messageQueue, + removeQueuedMessage, }; }