import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../api/client"; import type { Message } from "../types"; import { Chat } from "./Chat"; // Module-level store for the WebSocket handlers captured during connect(). type WsHandlers = { onToken: (content: string) => void; onUpdate: (history: Message[]) => void; onSessionId: (sessionId: string) => void; onError: (message: string) => void; onActivity: (toolName: string) => void; onReconciliationProgress: ( storyId: string, status: string, message: string, ) => void; }; let capturedWsHandlers: WsHandlers | null = null; vi.mock("../api/client", () => { const api = { getOllamaModels: vi.fn(), getAnthropicApiKeyExists: vi.fn(), getAnthropicModels: vi.fn(), getModelPreference: vi.fn(), setModelPreference: vi.fn(), cancelChat: vi.fn(), setAnthropicApiKey: vi.fn(), readFile: vi.fn(), listProjectFiles: vi.fn(), botCommand: vi.fn(), }; class ChatWebSocket { connect(handlers: WsHandlers) { capturedWsHandlers = handlers; } close() {} sendChat() {} cancel() {} } return { api, ChatWebSocket }; }); const mockedApi = { getOllamaModels: vi.mocked(api.getOllamaModels), getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists), getAnthropicModels: vi.mocked(api.getAnthropicModels), getModelPreference: vi.mocked(api.getModelPreference), setModelPreference: vi.mocked(api.setModelPreference), cancelChat: vi.mocked(api.cancelChat), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), readFile: vi.mocked(api.readFile), listProjectFiles: vi.mocked(api.listProjectFiles), botCommand: vi.mocked(api.botCommand), }; function setupMocks() { mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.readFile.mockResolvedValue(""); mockedApi.listProjectFiles.mockResolvedValue([]); mockedApi.cancelChat.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true); mockedApi.botCommand.mockResolvedValue({ response: "Bot response" }); } describe("Chat activity status indicator (Bug 140)", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("shows activity label when tool activity fires during streaming content", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Read my file" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Simulate tokens arriving (streamingContent becomes non-empty) await act(async () => { capturedWsHandlers?.onToken("I'll read that file for you."); }); // Now simulate a tool activity event while streamingContent is non-empty await act(async () => { capturedWsHandlers?.onActivity("read_file"); }); // The activity indicator should be visible with the tool activity label const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Reading file..."); }); it("shows Thinking... fallback when loading with no streaming and no activity", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // No tokens, no activity — should show "Thinking..." const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Thinking..."); }); it("hides Thinking... when streaming content is present but no tool activity", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Tokens arrive — streamingContent is non-empty, no activity await act(async () => { capturedWsHandlers?.onToken("Here is my response..."); }); // The activity indicator should NOT be visible (just streaming bubble) expect(screen.queryByTestId("activity-indicator")).not.toBeInTheDocument(); }); it("shows activity label for Claude Code tool names (Read, Bash, etc.)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Read my file" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Simulate tokens arriving await act(async () => { capturedWsHandlers?.onToken("Let me read that."); }); // Claude Code sends tool name "Read" (not "read_file") await act(async () => { capturedWsHandlers?.onActivity("Read"); }); const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Reading file..."); }); it("shows activity label for Claude Code Bash tool", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Run the tests" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { capturedWsHandlers?.onToken("Running tests now."); }); await act(async () => { capturedWsHandlers?.onActivity("Bash"); }); const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Executing command..."); }); it("shows generic label for unknown tool names", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Do something" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { capturedWsHandlers?.onToken("Working on it."); }); await act(async () => { capturedWsHandlers?.onActivity("SomeCustomTool"); }); const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); 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) await act(async () => { 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 are appended to the queue (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 first message await act(async () => { fireEvent.change(input, { target: { value: "Queue 1" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await screen.findByTestId("queued-message-indicator"); // Queue second message — should be appended, not overwrite the first await act(async () => { fireEvent.change(input, { target: { value: "Queue 2" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // 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("all queued messages are drained at once when agent responds (Story 199)", 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 while loading 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 — both "Second" and "Third" are drained at once await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "First" }, { role: "assistant", content: "Response 1." }, ]); }); // Both queued indicators should be gone — entire queue drained in one shot await waitFor(() => { const remaining = screen.queryAllByTestId("queued-message-indicator"); expect(remaining).toHaveLength(0); }); }); 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(); }); }); });