From 74eeb308e1ac51557ff3249c96a71f0eeb94f258 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 19:17:33 +0000 Subject: [PATCH] story-kit: merge 168_bug_agent_message_queue_limited_to_one_line --- frontend/src/components/Chat.test.tsx | 69 +++++++++++++++++++++++---- frontend/src/components/Chat.tsx | 66 ++++++++++++++----------- 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 174fa22..6ebf89d 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -811,7 +811,7 @@ describe("Chat message queue (Story 155)", () => { expect((input as HTMLTextAreaElement).value).toBe("Edit me back"); }); - it("subsequent submissions replace the queued message (AC5)", async () => { + it("subsequent submissions are appended to the queue (Bug 168)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); @@ -826,9 +826,9 @@ describe("Chat message queue (Story 155)", () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); - // Queue first replacement + // Queue first message await act(async () => { - fireEvent.change(input, { target: { value: "Original queue" } }); + fireEvent.change(input, { target: { value: "Queue 1" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); @@ -836,17 +836,70 @@ describe("Chat message queue (Story 155)", () => { await screen.findByTestId("queued-message-indicator"); - // Queue second replacement — should overwrite the first + // Queue second message — should be appended, not overwrite the first await act(async () => { - fireEvent.change(input, { target: { value: "Replaced queue" } }); + fireEvent.change(input, { target: { value: "Queue 2" } }); }); 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"); + // Both messages should be visible + const indicators = await screen.findAllByTestId("queued-message-indicator"); + expect(indicators).toHaveLength(2); + expect(indicators[0]).toHaveTextContent("Queue 1"); + expect(indicators[1]).toHaveTextContent("Queue 2"); + }); + + it("queued messages are delivered in order (Bug 168)", 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 two messages + await act(async () => { + fireEvent.change(input, { target: { value: "Second" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.change(input, { target: { value: "Third" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Both messages should be visible in order + const indicators = await screen.findAllByTestId("queued-message-indicator"); + expect(indicators).toHaveLength(2); + expect(indicators[0]).toHaveTextContent("Second"); + expect(indicators[1]).toHaveTextContent("Third"); + + // Simulate first response completing — "Second" is sent next + act(() => { + capturedWsHandlers?.onUpdate([ + { role: "user", content: "First" }, + { role: "assistant", content: "Response 1." }, + ]); + }); + + // "Third" should remain queued; "Second" was consumed + await waitFor(() => { + const remaining = screen.queryAllByTestId("queued-message-indicator"); + expect(remaining).toHaveLength(1); + expect(remaining[0]).toHaveTextContent("Third"); + }); }); it("does not auto-send queued message when generation is cancelled (AC6)", async () => { diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index c9ea9b4..a72033f 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -91,9 +91,12 @@ 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); + const [queuedMessages, setQueuedMessages] = useState< + { id: string; text: string }[] + >([]); + // Ref so stale WebSocket callbacks can read the current queued messages + const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]); + const queueIdCounterRef = useRef(0); // Trigger state: set to a message string to fire auto-send after loading ends const [pendingAutoSend, setPendingAutoSend] = useState(null); @@ -210,11 +213,10 @@ 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); + const nextQueued = queuedMessagesRef.current.shift(); + if (nextQueued !== undefined) { + setQueuedMessages([...queuedMessagesRef.current]); + setPendingAutoSend(nextQueued.text); } } }, @@ -225,11 +227,10 @@ 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); + const nextQueued = queuedMessagesRef.current.shift(); + if (nextQueued !== undefined) { + setQueuedMessages([...queuedMessagesRef.current]); + setPendingAutoSend(nextQueued.text); } }, onPipelineState: (state) => { @@ -330,9 +331,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); + // Discard any queued messages — do not auto-send after cancel + queuedMessagesRef.current = []; + setQueuedMessages([]); try { wsRef.current?.cancel(); await api.cancelChat(); @@ -358,8 +359,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { // Agent is busy — queue the message instead of dropping it if (loading) { - queuedMessageRef.current = messageToSend; - setQueuedMessage(messageToSend); + const newItem = { + id: String(queueIdCounterRef.current++), + text: messageToSend, + }; + queuedMessagesRef.current = [...queuedMessagesRef.current, newItem]; + setQueuedMessages([...queuedMessagesRef.current]); if (!messageOverride || messageOverride === input) { setInput(""); } @@ -890,9 +895,10 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { gap: "8px", }} > - {/* Queued message indicator */} - {queuedMessage && ( + {/* Queued message indicators */} + {queuedMessages.map(({ id, text }) => (
- {queuedMessage} + {text}
- )} + ))} {/* Input row */}