diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index ea9291c7..7571e517 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -852,6 +852,8 @@ export function ThreadComposer({ const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); const queuedPromptCounterRef = useRef(0); + // Only the prompt queued by the immediately preceding Enter can use the second-Enter shortcut. + const secondEnterPromptIdRef = useRef(null); const draggedQueuedPromptIdRef = useRef(null); const previousPendingQueueKeyRef = useRef(pendingQueueKey); const wasStreamingRef = useRef(isStreaming); @@ -871,6 +873,7 @@ export function ThreadComposer({ && workspaceControls?.can_change_project !== false; useEffect(() => { + secondEnterPromptIdRef.current = null; skipQueuedPromptPersistRef.current = true; setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []); }, [queuedPromptStorageKey]); @@ -902,6 +905,7 @@ export function ThreadComposer({ const addFiles = useCallback( (files: File[]) => { if (files.length === 0) return; + secondEnterPromptIdRef.current = null; const { rejected } = enqueue(files); if (rejected.length > 0) { setInlineError(formatRejection(rejected[0].reason)); @@ -1251,6 +1255,7 @@ export function ThreadComposer({ useLayoutEffect(() => { if (previousPendingQueueKeyRef.current === pendingQueueKey) return; previousPendingQueueKeyRef.current = pendingQueueKey; + secondEnterPromptIdRef.current = null; setValue(""); setInlineError(null); setSlashMenuDismissed(false); @@ -1268,6 +1273,7 @@ export function ThreadComposer({ const appendTranscription = useCallback((text: string) => { const transcript = text.trim(); if (!transcript) return; + secondEnterPromptIdRef.current = null; setValue((current) => { if (!current.trim()) return transcript; const separator = /[\s\n]$/.test(current) ? "" : " "; @@ -1405,10 +1411,12 @@ export function ThreadComposer({ if (!canQueueGuidance || (!text && readyImages.length === 0)) return; const queuedImages = readyImagesToQueuedImages(readyImages); queuedPromptCounterRef.current += 1; + const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`; + secondEnterPromptIdRef.current = id; setQueuedPrompts((items) => [ ...items, { - id: `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`, + id, text, ...(queuedImages.length > 0 ? { images: queuedImages } : {}), }, @@ -1418,11 +1426,13 @@ export function ThreadComposer({ }, [canQueueGuidance, clear, clearComposerText, readyImages, value]); const removeQueuedPrompt = useCallback((id: string) => { + secondEnterPromptIdRef.current = null; setQueuedPrompts((items) => items.filter((item) => item.id !== id)); requestAnimationFrame(() => textareaRef.current?.focus()); }, []); const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => { + secondEnterPromptIdRef.current = null; setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); setValue(prompt.text); setInlineError(null); @@ -1445,6 +1455,7 @@ export function ThreadComposer({ const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => { if (dragId === targetId) return; + secondEnterPromptIdRef.current = null; setQueuedPrompts((items) => { const from = items.findIndex((item) => item.id === dragId); const to = items.findIndex((item) => item.id === targetId); @@ -1458,6 +1469,7 @@ export function ThreadComposer({ const sendQueuedPrompt = useCallback( (prompt: QueuedPrompt) => { + secondEnterPromptIdRef.current = null; const text = prompt.text.trim(); const queuedImages = queuedImagesToSendImages(prompt.images); setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); @@ -1487,6 +1499,7 @@ export function ThreadComposer({ useEffect(() => { const wasStreaming = wasStreamingRef.current; wasStreamingRef.current = isStreaming; + if (!isStreaming) secondEnterPromptIdRef.current = null; if (!wasStreaming || isStreaming || queuedPrompts.length === 0) return; if (skipNextQueuedFlushRef.current) { skipNextQueuedFlushRef.current = false; @@ -1496,6 +1509,7 @@ export function ThreadComposer({ }, [sendNextQueuedPrompt, isStreaming, queuedPrompts.length]); const handleStop = useCallback(() => { + secondEnterPromptIdRef.current = null; if (queuedPrompts.length > 0) { skipNextQueuedFlushRef.current = true; } @@ -1639,9 +1653,25 @@ export function ThreadComposer({ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (canQueueGuidance) { - queueGuidancePrompt(); + if (!e.repeat) queueGuidancePrompt(); return; } + const secondEnterPrompt = queuedPrompts.find( + (prompt) => prompt.id === secondEnterPromptIdRef.current, + ); + if ( + isStreaming + && value.length === 0 + && images.length === 0 + && !e.altKey + && !e.ctrlKey + && !e.metaKey + && secondEnterPrompt + ) { + if (!e.repeat) sendQueuedPrompt(secondEnterPrompt); + return; + } + secondEnterPromptIdRef.current = null; submit(); } }; @@ -1779,6 +1809,7 @@ export function ThreadComposer({ onDelete={removeQueuedPrompt} onEdit={editQueuedPrompt} onDragStart={(id) => { + secondEnterPromptIdRef.current = null; draggedQueuedPromptIdRef.current = id; }} onDragEnd={() => { @@ -1831,11 +1862,15 @@ export function ThreadComposer({ ref={textareaRef} value={value} onChange={(e) => { + secondEnterPromptIdRef.current = null; setValue(e.target.value); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setCursorPosition(e.target.selectionStart ?? e.target.value.length); }} + onBlur={() => { + secondEnterPromptIdRef.current = null; + }} onInput={onInput} onKeyDown={onKeyDown} onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)} diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 748c8956..239ccdbb 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1554,6 +1554,125 @@ describe("ThreadComposer", () => { expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument(); }); + it("guides queued guidance when Enter is pressed again", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "send this guidance now" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSend).not.toHaveBeenCalled(); + expect(input).toHaveValue(""); + expect(screen.getByText("send this guidance now")).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: "Enter", repeat: true }); + + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByText("send this guidance now")).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSend).toHaveBeenCalledWith("send this guidance now"); + expect(screen.queryByText("send this guidance now")).not.toBeInTheDocument(); + }); + + it("disarms the second Enter shortcut after stopping the active response", () => { + const onSend = vi.fn(); + const onStop = vi.fn(); + const { rerender } = render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "keep this queued" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: "Stop response" })); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onStop).toHaveBeenCalledTimes(1); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByText("keep this queued")).toBeInTheDocument(); + + rerender( + , + ); + rerender( + , + ); + fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" }); + + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByText("keep this queued")).toBeInTheDocument(); + }); + + it("disarms the second Enter shortcut when the composer loses focus", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "leave this queued" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.blur(input); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByText("leave this queued")).toBeInTheDocument(); + }); + + it("guides the newly queued prompt when older guidance is still waiting", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "older guidance" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.change(input, { target: { value: "guide this one now" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSend).toHaveBeenCalledWith("guide this one now"); + expect(screen.getByText("older guidance")).toBeInTheDocument(); + expect(screen.queryByText("guide this one now")).not.toBeInTheDocument(); + }); + it("keeps queued guidance attached to the composer and sends it one item at a time", async () => { const onSend = vi.fn(); const { rerender } = render( @@ -1914,6 +2033,8 @@ describe("ThreadComposer", () => { ); expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument(); + fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" }); + expect(onSend).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Guide" })); expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");