diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 1858d20..8385932 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -555,3 +555,243 @@ describe("Chat activity status indicator (Bug 140)", () => { expect(indicator).toHaveTextContent("Using SomeCustomTool..."); }); }); + +describe("Chat message queue (Story 155)", () => { + beforeEach(() => { + capturedWsHandlers = null; + setupMocks(); + }); + + it("shows queued message indicator when submitting while loading (AC1, AC2)", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // Send first message to put the chat in loading state + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "First message" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Now type and submit a second message while loading is true + await act(async () => { + fireEvent.change(input, { target: { value: "Queued message" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // The queued message indicator should appear + const indicator = await screen.findByTestId("queued-message-indicator"); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveTextContent("Queued"); + expect(indicator).toHaveTextContent("Queued message"); + + // Input should be cleared after queuing + expect((input as HTMLTextAreaElement).value).toBe(""); + }); + + it("auto-sends queued message when agent response completes (AC4)", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + + // Send first message + await act(async () => { + fireEvent.change(input, { target: { value: "First" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Queue a second message while loading + await act(async () => { + fireEvent.change(input, { target: { value: "Auto-send this" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Verify it's queued + expect( + await screen.findByTestId("queued-message-indicator"), + ).toBeInTheDocument(); + + // Simulate agent response completing (loading → false) + act(() => { + capturedWsHandlers?.onUpdate([ + { role: "user", content: "First" }, + { role: "assistant", content: "Done." }, + ]); + }); + + // The queued indicator should disappear (message was sent) + await waitFor(() => { + expect( + screen.queryByTestId("queued-message-indicator"), + ).not.toBeInTheDocument(); + }); + }); + + it("cancel button discards the queued message (AC3, AC6)", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + + // Send first message to start loading + await act(async () => { + fireEvent.change(input, { target: { value: "First" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Queue a second message + await act(async () => { + fireEvent.change(input, { target: { value: "Discard me" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + const indicator = await screen.findByTestId("queued-message-indicator"); + expect(indicator).toBeInTheDocument(); + + // Click the ✕ cancel button + const cancelBtn = screen.getByTitle("Cancel queued message"); + await act(async () => { + fireEvent.click(cancelBtn); + }); + + // Indicator should be gone + expect( + screen.queryByTestId("queued-message-indicator"), + ).not.toBeInTheDocument(); + }); + + it("edit button puts queued message back into input (AC3)", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + + // Send first message to start loading + await act(async () => { + fireEvent.change(input, { target: { value: "First" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Queue a second message + await act(async () => { + fireEvent.change(input, { target: { value: "Edit me back" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await screen.findByTestId("queued-message-indicator"); + + // Click the Edit button + const editBtn = screen.getByTitle("Edit queued message"); + await act(async () => { + fireEvent.click(editBtn); + }); + + // Indicator should be gone and message back in input + expect( + screen.queryByTestId("queued-message-indicator"), + ).not.toBeInTheDocument(); + expect((input as HTMLTextAreaElement).value).toBe("Edit me back"); + }); + + it("subsequent submissions replace the queued message (AC5)", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + + // Send first message to start loading + await act(async () => { + fireEvent.change(input, { target: { value: "First" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Queue first replacement + await act(async () => { + fireEvent.change(input, { target: { value: "Original queue" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await screen.findByTestId("queued-message-indicator"); + + // Queue second replacement — should overwrite the first + await act(async () => { + fireEvent.change(input, { target: { value: "Replaced queue" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + const indicator = await screen.findByTestId("queued-message-indicator"); + expect(indicator).toHaveTextContent("Replaced queue"); + expect(indicator).not.toHaveTextContent("Original queue"); + }); + + it("does not auto-send queued message when generation is cancelled (AC6)", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + + // Send first message to start loading + await act(async () => { + fireEvent.change(input, { target: { value: "First" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Queue a second message + await act(async () => { + fireEvent.change(input, { target: { value: "Should not send" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await screen.findByTestId("queued-message-indicator"); + + // Click the stop button (■) — but input is empty so button is stop + // Actually simulate cancel by clicking the stop button (which requires empty input) + // We need to use the send button when input is empty (stop mode) + // Simulate cancel via the cancelGeneration path: the button when loading && !input + // At this point input is empty (was cleared after queuing) + const stopButton = screen.getByRole("button", { name: "■" }); + await act(async () => { + fireEvent.click(stopButton); + }); + + // Queued indicator should be gone (cancelled) + await waitFor(() => { + expect( + screen.queryByTestId("queued-message-indicator"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index fb51bd7..4849293 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -90,6 +90,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [agentConfigVersion, setAgentConfigVersion] = useState(0); const [needsOnboarding, setNeedsOnboarding] = useState(false); const onboardingTriggeredRef = useRef(false); + const [queuedMessage, setQueuedMessage] = useState(null); + // Ref so stale WebSocket callbacks can read the current queued message + const queuedMessageRef = useRef(null); + // Trigger state: set to a message string to fire auto-send after loading ends + const [pendingAutoSend, setPendingAutoSend] = useState(null); const wsRef = useRef(null); const messagesEndRef = useRef(null); @@ -204,6 +209,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { if (last?.role === "assistant" && !last.tool_calls) { setLoading(false); setActivityStatus(null); + if (queuedMessageRef.current) { + const msg = queuedMessageRef.current; + queuedMessageRef.current = null; + setQueuedMessage(null); + setPendingAutoSend(msg); + } } }, onSessionId: (sessionId) => { @@ -213,6 +224,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { console.error("WebSocket error:", message); setLoading(false); setActivityStatus(null); + if (queuedMessageRef.current) { + const msg = queuedMessageRef.current; + queuedMessageRef.current = null; + setQueuedMessage(null); + setPendingAutoSend(msg); + } }, onPipelineState: (state) => { setPipeline(state); @@ -295,6 +312,15 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { inputRef.current?.focus(); }, []); + // Auto-send queued message when loading ends + useEffect(() => { + if (pendingAutoSend) { + const msg = pendingAutoSend; + setPendingAutoSend(null); + sendMessage(msg); + } + }, [pendingAutoSend]); + useEffect(() => { const handleResize = () => setIsNarrowScreen(window.innerWidth < NARROW_BREAKPOINT); @@ -303,6 +329,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }, []); const cancelGeneration = async () => { + // Discard any queued message — do not auto-send after cancel + queuedMessageRef.current = null; + setQueuedMessage(null); try { wsRef.current?.cancel(); await api.cancelChat(); @@ -324,7 +353,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const sendMessage = async (messageOverride?: string) => { const messageToSend = messageOverride ?? input; - if (!messageToSend.trim() || loading) return; + if (!messageToSend.trim()) return; + + // Agent is busy — queue the message instead of dropping it + if (loading) { + queuedMessageRef.current = messageToSend; + setQueuedMessage(messageToSend); + if (!messageOverride || messageOverride === input) { + setInput(""); + } + return; + } const isClaudeCode = model === "claude-code-pty"; if (!isClaudeCode && model.startsWith("claude-")) { @@ -842,59 +881,154 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { maxWidth: "768px", width: "100%", display: "flex", + flexDirection: "column", gap: "8px", - alignItems: "center", }} > -