diff --git a/frontend/src/components/Chat.activity.test.tsx b/frontend/src/components/Chat.activity.test.tsx new file mode 100644 index 00000000..489e0062 --- /dev/null +++ b/frontend/src/components/Chat.activity.test.tsx @@ -0,0 +1,530 @@ +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(); + }); + }); +}); diff --git a/frontend/src/components/Chat.commands.test.tsx b/frontend/src/components/Chat.commands.test.tsx new file mode 100644 index 00000000..ff2b4f02 --- /dev/null +++ b/frontend/src/components/Chat.commands.test.tsx @@ -0,0 +1,514 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { afterEach, 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; +// Captures the last sendChat call's arguments for assertion. +let lastSendChatArgs: { messages: Message[]; config: unknown } | 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(messages: Message[], config: unknown) { + lastSendChatArgs = { messages, config }; + } + 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("Remove bubble styling from streaming messages (Story 163)", () => { + beforeEach(() => { + capturedWsHandlers = null; + setupMocks(); + }); + + it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // Send a message to put chat into loading state + 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 }); + }); + + // Simulate streaming tokens arriving + await act(async () => { + capturedWsHandlers?.onToken("Streaming response text"); + }); + + // Find the streaming message container (the inner div wrapping the Markdown) + const streamingText = await screen.findByText("Streaming response text"); + // The markdown-body wrapper is the parent, and the styled div is its parent + const styledDiv = streamingText.closest(".markdown-body") + ?.parentElement as HTMLElement; + + expect(styledDiv).toBeTruthy(); + const styleAttr = styledDiv.getAttribute("style") ?? ""; + expect(styleAttr).toContain("background: transparent"); + expect(styleAttr).toContain("padding: 0px"); + expect(styleAttr).toContain("border-radius: 0px"); + expect(styleAttr).toContain("max-width: 100%"); + }); + + it("AC1: streaming message wraps Markdown in markdown-body class", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + 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 }); + }); + + await act(async () => { + capturedWsHandlers?.onToken("Some markdown content"); + }); + + const streamingText = await screen.findByText("Some markdown content"); + const markdownBody = streamingText.closest(".markdown-body"); + expect(markdownBody).toBeTruthy(); + }); + + it("AC2: no visual change when streaming ends and message transitions to completed", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // Send a message to start streaming + 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 }); + }); + + // Simulate streaming tokens + await act(async () => { + capturedWsHandlers?.onToken("Final response"); + }); + + // Capture streaming message style attribute + const streamingText = await screen.findByText("Final response"); + const streamingStyledDiv = streamingText.closest(".markdown-body") + ?.parentElement as HTMLElement; + const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? ""; + + // Transition: onUpdate completes the message + await act(async () => { + capturedWsHandlers?.onUpdate([ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Final response" }, + ]); + }); + + // Find the completed message — it should have the same styling + const completedText = await screen.findByText("Final response"); + const completedMarkdownBody = completedText.closest(".markdown-body"); + const completedStyledDiv = + completedMarkdownBody?.parentElement as HTMLElement; + + expect(completedStyledDiv).toBeTruthy(); + const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? ""; + + // Both streaming and completed use transparent bg, 0 padding, 0 border-radius + expect(completedStyleAttr).toContain("background: transparent"); + expect(completedStyleAttr).toContain("padding: 0px"); + expect(completedStyleAttr).toContain("border-radius: 0px"); + expect(streamingStyleAttr).toContain("background: transparent"); + expect(streamingStyleAttr).toContain("padding: 0px"); + expect(streamingStyleAttr).toContain("border-radius: 0px"); + + // Both have the markdown-body class wrapper + expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy(); + }); + + it("AC3: completed assistant messages retain transparent background and no border-radius", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onUpdate([ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello there!" }, + ]); + }); + + const assistantText = await screen.findByText("Hello there!"); + const markdownBody = assistantText.closest(".markdown-body"); + const styledDiv = markdownBody?.parentElement as HTMLElement; + + expect(styledDiv).toBeTruthy(); + const styleAttr = styledDiv.getAttribute("style") ?? ""; + expect(styleAttr).toContain("background: transparent"); + expect(styleAttr).toContain("padding: 0px"); + expect(styleAttr).toContain("border-radius: 0px"); + expect(styleAttr).toContain("max-width: 100%"); + }); + + it("AC3: completed user messages still have their bubble styling", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onUpdate([ + { role: "user", content: "I am a user message" }, + { role: "assistant", content: "I am a response" }, + ]); + }); + + // findByText finds the text element; traverse up to the styled bubble div + const userText = await screen.findByText("I am a user message"); + // User messages are rendered via markdown, so text is inside a

inside .user-markdown-body + // Walk up to find the styled bubble container + const bubbleDiv = userText.closest("[style*='padding: 10px 16px']"); + expect(bubbleDiv).toBeTruthy(); + const styleAttr = bubbleDiv?.getAttribute("style") ?? ""; + // User messages retain bubble: distinct background, padding, rounded corners + expect(styleAttr).toContain("padding: 10px 16px"); + expect(styleAttr).toContain("border-radius: 20px"); + expect(styleAttr).not.toContain("background: transparent"); + }); +}); + +describe("Slash command handling (Story 374)", () => { + beforeEach(() => { + capturedWsHandlers = null; + lastSendChatArgs = null; + setupMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("AC: /status calls botCommand and displays response", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" }); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/status" } }); + }); + // First Enter selects the command from the picker; second Enter submits it + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => { + expect(mockedApi.botCommand).toHaveBeenCalledWith( + "status", + "", + undefined, + ); + }); + expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument(); + // Should NOT go to LLM + expect(lastSendChatArgs).toBeNull(); + }); + + it("AC: /status passes args to botCommand", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" }); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/status 42" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => { + expect(mockedApi.botCommand).toHaveBeenCalledWith( + "status", + "42", + undefined, + ); + }); + }); + + it("AC: /start calls botCommand", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "Started agent" }); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/start 42 opus" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => { + expect(mockedApi.botCommand).toHaveBeenCalledWith( + "start", + "42 opus", + undefined, + ); + }); + expect(await screen.findByText("Started agent")).toBeInTheDocument(); + }); + + it("AC: /git calls botCommand", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "On branch main" }); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/git" } }); + }); + // First Enter selects the command from the picker; second Enter submits it + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => { + expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined); + }); + }); + + it("AC: /cost calls botCommand", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" }); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/cost" } }); + }); + // First Enter selects the command from the picker; second Enter submits it + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => { + expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined); + }); + }); + + it("AC: /reset clears messages and session without LLM", async () => { + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // First add a message so there is history to clear + await act(async () => { + capturedWsHandlers?.onUpdate([ + { role: "user", content: "hello" }, + { role: "assistant", content: "world" }, + ]); + }); + expect(await screen.findByText("world")).toBeInTheDocument(); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/reset" } }); + }); + // First Enter selects the command from the picker; second Enter submits it + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // LLM must NOT be invoked + expect(lastSendChatArgs).toBeNull(); + // botCommand must NOT be invoked (reset is frontend-only) + expect(mockedApi.botCommand).not.toHaveBeenCalled(); + // Confirmation message should appear + expect(await screen.findByText(/Session reset/)).toBeInTheDocument(); + }); + + it("AC: unrecognised slash command shows error message", async () => { + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/foobar" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + expect(await screen.findByText(/Unknown command/)).toBeInTheDocument(); + // Should NOT go to LLM + expect(lastSendChatArgs).toBeNull(); + // Should NOT call botCommand + expect(mockedApi.botCommand).not.toHaveBeenCalled(); + }); + + it("AC: /help calls botCommand and displays response", async () => { + mockedApi.botCommand.mockResolvedValue({ + response: "Available commands: status, help, ...", + }); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/help" } }); + }); + // First Enter selects the command from the picker; second Enter submits it + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => { + expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined); + }); + expect(lastSendChatArgs).toBeNull(); + }); + + it("AC: botCommand API error shows error message in chat", async () => { + mockedApi.botCommand.mockRejectedValue(new Error("Server error")); + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "/git" } }); + }); + // First Enter selects the command from the picker; second Enter submits it + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + expect( + await screen.findByText(/Error running command/), + ).toBeInTheDocument(); + }); +}); + +describe("Bug 450: WebSocket error messages displayed in chat", () => { + beforeEach(() => { + capturedWsHandlers = null; + setupMocks(); + }); + + it("AC1: WebSocket error message is shown in chat as an assistant message", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onError("Something went wrong on the server."); + }); + + expect( + await screen.findByText("Something went wrong on the server."), + ).toBeInTheDocument(); + }); + + it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onError( + "OAuth login required. Please visit: https://example.com/oauth/login", + ); + }); + + const link = await screen.findByRole("link", { + name: /https:\/\/example\.com\/oauth\/login/, + }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://example.com/oauth/login"); + }); +}); diff --git a/frontend/src/components/Chat.layout.test.tsx b/frontend/src/components/Chat.layout.test.tsx new file mode 100644 index 00000000..d94252d6 --- /dev/null +++ b/frontend/src/components/Chat.layout.test.tsx @@ -0,0 +1,264 @@ +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 two-column layout", () => { + beforeEach(() => { + capturedWsHandlers = null; + setupMocks(); + }); + + it("renders left and right column containers (AC1, AC2)", async () => { + render(); + + expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument(); + expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument(); + expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument(); + }); + + it("renders chat input inside the left column (AC2, AC5)", async () => { + render(); + + const leftColumn = await screen.findByTestId("chat-left-column"); + const input = screen.getByPlaceholderText("Send a message..."); + expect(leftColumn).toContainElement(input); + }); + + it("renders panels inside the right column (AC2)", async () => { + render(); + + const rightColumn = await screen.findByTestId("chat-right-column"); + const agentsPanel = await screen.findByText("Agents"); + expect(rightColumn).toContainElement(agentsPanel); + }); + + it("uses row flex-direction on wide screens (AC3)", async () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1200, + }); + window.dispatchEvent(new Event("resize")); + + render(); + + const contentArea = await screen.findByTestId("chat-content-area"); + expect(contentArea).toHaveStyle({ flexDirection: "row" }); + }); + + it("uses column flex-direction on narrow screens (AC4)", async () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 600, + }); + window.dispatchEvent(new Event("resize")); + + render(); + + const contentArea = await screen.findByTestId("chat-content-area"); + expect(contentArea).toHaveStyle({ flexDirection: "column" }); + + // Restore wide width for subsequent tests + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + }); +}); + +describe("Chat input Shift+Enter behavior", () => { + beforeEach(() => { + capturedWsHandlers = null; + setupMocks(); + }); + + it("renders a textarea element for the chat input (AC3)", async () => { + render(); + const input = screen.getByPlaceholderText("Send a message..."); + expect(input.tagName.toLowerCase()).toBe("textarea"); + }); + + it("sends message on Enter key press without Shift (AC2)", async () => { + render(); + 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 }); + }); + + await waitFor(() => { + expect((input as HTMLTextAreaElement).value).toBe(""); + }); + }); + + it("does not send message on Shift+Enter (AC1)", async () => { + render(); + 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: true }); + }); + + expect((input as HTMLTextAreaElement).value).toBe("Hello"); + }); +}); + +describe("Chat reconciliation banner", () => { + beforeEach(() => { + capturedWsHandlers = null; + setupMocks(); + }); + + it("shows banner when a non-done reconciliation event is received", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onReconciliationProgress( + "42_story_test", + "checking", + "Checking for committed work in 2_current/", + ); + }); + + expect( + await screen.findByTestId("reconciliation-banner"), + ).toBeInTheDocument(); + expect( + await screen.findByText("Reconciling startup state..."), + ).toBeInTheDocument(); + }); + + it("shows event message in the banner", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onReconciliationProgress( + "42_story_test", + "gates_running", + "Running acceptance gates…", + ); + }); + + expect( + await screen.findByText(/Running acceptance gates/), + ).toBeInTheDocument(); + }); + + it("dismisses banner when done event is received", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onReconciliationProgress( + "42_story_test", + "checking", + "Checking for committed work", + ); + }); + + expect( + await screen.findByTestId("reconciliation-banner"), + ).toBeInTheDocument(); + + await act(async () => { + capturedWsHandlers?.onReconciliationProgress( + "", + "done", + "Startup reconciliation complete.", + ); + }); + + await waitFor(() => { + expect( + screen.queryByTestId("reconciliation-banner"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Chat.persistence.test.tsx b/frontend/src/components/Chat.persistence.test.tsx new file mode 100644 index 00000000..c2904105 --- /dev/null +++ b/frontend/src/components/Chat.persistence.test.tsx @@ -0,0 +1,461 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { afterEach, 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; +// Captures the last sendChat call's arguments for assertion. +let lastSendChatArgs: { messages: Message[]; config: unknown } | 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(messages: Message[], config: unknown) { + lastSendChatArgs = { messages, config }; + } + 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 localStorage persistence (Story 145)", () => { + const PROJECT_PATH = "/tmp/project"; + const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; + + beforeEach(() => { + capturedWsHandlers = null; + localStorage.clear(); + setupMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("AC1: restores persisted messages on mount", async () => { + const saved: Message[] = [ + { role: "user", content: "Previously saved question" }, + { role: "assistant", content: "Previously saved answer" }, + ]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + + render(); + + expect( + await screen.findByText("Previously saved question"), + ).toBeInTheDocument(); + expect( + await screen.findByText("Previously saved answer"), + ).toBeInTheDocument(); + }); + + it("AC2: persists messages when WebSocket onUpdate fires", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const history: Message[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ]; + + await act(async () => { + capturedWsHandlers?.onUpdate(history); + }); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(stored).toEqual(history); + }); + + it("AC3: clears localStorage when New Session is clicked", async () => { + const saved: Message[] = [ + { role: "user", content: "Old message" }, + { role: "assistant", content: "Old reply" }, + ]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + + // Stub window.confirm to auto-approve the clear dialog + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + + // Wait for the persisted message to appear + expect(await screen.findByText("Old message")).toBeInTheDocument(); + + // Click "New Session" button + const newSessionBtn = screen.getByText(/New Session/); + await act(async () => { + fireEvent.click(newSessionBtn); + }); + + // localStorage should be cleared + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + + // Messages should be gone from the UI + expect(screen.queryByText("Old message")).not.toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + + it("Bug 245: messages survive unmount/remount cycle (page refresh)", async () => { + // Step 1: Render Chat and populate messages via WebSocket onUpdate + const { unmount } = render( + , + ); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const history: Message[] = [ + { role: "user", content: "Persist me across refresh" }, + { role: "assistant", content: "I should survive a reload" }, + ]; + + await act(async () => { + capturedWsHandlers?.onUpdate(history); + }); + + // Verify messages are persisted to localStorage + expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); + const storedBefore = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(storedBefore).toEqual(history); + + // Step 2: Unmount the Chat component (simulates page unload) + unmount(); + + // Verify localStorage was NOT cleared by unmount + expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); + const storedAfterUnmount = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "[]", + ); + expect(storedAfterUnmount).toEqual(history); + + // Step 3: Remount the Chat component (simulates page reload) + capturedWsHandlers = null; + render(); + + // Verify messages are restored from localStorage + expect( + await screen.findByText("Persist me across refresh"), + ).toBeInTheDocument(); + expect( + await screen.findByText("I should survive a reload"), + ).toBeInTheDocument(); + + // Verify localStorage still has the messages + const storedAfterRemount = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "[]", + ); + expect(storedAfterRemount).toEqual(history); + }); + + it("Bug 245: after refresh, sendChat includes full prior history", async () => { + // Step 1: Render, populate messages via onUpdate, then unmount (simulate refresh) + const { unmount } = render( + , + ); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const priorHistory: Message[] = [ + { role: "user", content: "What is Rust?" }, + { role: "assistant", content: "Rust is a systems programming language." }, + ]; + await act(async () => { + capturedWsHandlers?.onUpdate(priorHistory); + }); + + // Verify localStorage has the prior history + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(stored).toEqual(priorHistory); + + unmount(); + + // Step 2: Remount (simulates page reload) — messages load from localStorage + capturedWsHandlers = null; + lastSendChatArgs = null; + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // Verify prior messages are displayed + expect(await screen.findByText("What is Rust?")).toBeInTheDocument(); + + // Step 3: Send a new message — sendChat should include the full prior history + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "Tell me more" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + // Verify sendChat was called with ALL prior messages + the new one + expect(lastSendChatArgs).not.toBeNull(); + const args = lastSendChatArgs as unknown as { + messages: Message[]; + config: unknown; + }; + expect(args.messages).toHaveLength(3); + expect(args.messages[0]).toEqual({ + role: "user", + content: "What is Rust?", + }); + expect(args.messages[1]).toEqual({ + role: "assistant", + content: "Rust is a systems programming language.", + }); + expect(args.messages[2]).toEqual({ + role: "user", + content: "Tell me more", + }); + }); + + it("AC5: uses project-scoped storage key", async () => { + const otherKey = "storykit-chat-history:/other/project"; + localStorage.setItem( + otherKey, + JSON.stringify([{ role: "user", content: "Other project msg" }]), + ); + + render(); + + // Should NOT show the other project's messages + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + expect(screen.queryByText("Other project msg")).not.toBeInTheDocument(); + + // Other project's data should still be in storage + expect(localStorage.getItem(otherKey)).not.toBeNull(); + }); +}); + +describe("Bug 264: Claude Code session ID persisted across browser refresh", () => { + const PROJECT_PATH = "/tmp/project"; + const SESSION_KEY = `storykit-claude-session-id:${PROJECT_PATH}`; + const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; + + beforeEach(() => { + capturedWsHandlers = null; + lastSendChatArgs = null; + localStorage.clear(); + setupMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("AC1: session_id is persisted to localStorage when onSessionId fires", async () => { + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onSessionId("test-session-abc"); + }); + + await waitFor(() => { + expect(localStorage.getItem(SESSION_KEY)).toBe("test-session-abc"); + }); + }); + + it("AC2: after remount, next sendChat includes session_id from localStorage", async () => { + // Step 1: Render, receive a session ID, then unmount (simulate refresh) + localStorage.setItem(SESSION_KEY, "persisted-session-xyz"); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify([ + { role: "user", content: "Prior message" }, + { role: "assistant", content: "Prior reply" }, + ]), + ); + + const { unmount } = render( + , + ); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + unmount(); + + // Step 2: Remount (simulates page reload) + capturedWsHandlers = null; + lastSendChatArgs = null; + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + // Prior messages should be visible + expect(await screen.findByText("Prior message")).toBeInTheDocument(); + + // Step 3: Send a new message — config should include session_id + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "Continue" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + expect(lastSendChatArgs).not.toBeNull(); + expect( + ( + ( + lastSendChatArgs as unknown as { + messages: Message[]; + config: unknown; + } + )?.config as Record + ).session_id, + ).toBe("persisted-session-xyz"); + }); + + it("AC3: clearing the session also clears the persisted session_id", async () => { + localStorage.setItem(SESSION_KEY, "session-to-clear"); + + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const newSessionBtn = screen.getByText(/New Session/); + await act(async () => { + fireEvent.click(newSessionBtn); + }); + + expect(localStorage.getItem(SESSION_KEY)).toBeNull(); + + confirmSpy.mockRestore(); + }); + + it("AC1: storage key is scoped to project path", async () => { + const otherPath = "/other/project"; + const otherKey = `storykit-claude-session-id:${otherPath}`; + localStorage.setItem(otherKey, "other-session"); + + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + await act(async () => { + capturedWsHandlers?.onSessionId("my-session"); + }); + + await waitFor(() => { + expect(localStorage.getItem(SESSION_KEY)).toBe("my-session"); + }); + + // Other project's session should be untouched + expect(localStorage.getItem(otherKey)).toBe("other-session"); + }); +}); + +describe("File reference expansion (Story 269 AC4)", () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedWsHandlers = null; + lastSendChatArgs = null; + setupMocks(); + }); + + it("includes file contents as context when message contains @file reference", async () => { + mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }'); + + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "explain @src/main.rs" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); + const sentMessages = ( + lastSendChatArgs as NonNullable + ).messages; + const userMsg = sentMessages[sentMessages.length - 1]; + expect(userMsg.content).toContain("explain @src/main.rs"); + expect(userMsg.content).toContain("[File: src/main.rs]"); + expect(userMsg.content).toContain("fn main()"); + }); + + it("sends message without modification when no @file references are present", async () => { + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "hello world" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); + const sentMessages = ( + lastSendChatArgs as NonNullable + ).messages; + const userMsg = sentMessages[sentMessages.length - 1]; + expect(userMsg.content).toBe("hello world"); + expect(mockedApi.readFile).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 579c0c85..3d3a1844 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -1,18 +1,15 @@ import { act, - fireEvent, render, screen, waitFor, } from "@testing-library/react"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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(). -// Tests in the "message rendering" suite use this to simulate incoming messages. type WsHandlers = { onToken: (content: string) => void; onUpdate: (history: Message[]) => void; @@ -26,8 +23,6 @@ type WsHandlers = { ) => void; }; let capturedWsHandlers: WsHandlers | null = null; -// Captures the last sendChat call's arguments for assertion. -let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null; vi.mock("../api/client", () => { const api = { @@ -47,9 +42,7 @@ vi.mock("../api/client", () => { capturedWsHandlers = handlers; } close() {} - sendChat(messages: Message[], config: unknown) { - lastSendChatArgs = { messages, config }; - } + sendChat() {} cancel() {} } return { api, ChatWebSocket }; @@ -275,1456 +268,3 @@ describe("Chat message rendering — unified tool call UI", () => { expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled(); }); }); - -describe("Chat two-column layout", () => { - beforeEach(() => { - capturedWsHandlers = null; - setupMocks(); - }); - - it("renders left and right column containers (AC1, AC2)", async () => { - render(); - - expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument(); - expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument(); - expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument(); - }); - - it("renders chat input inside the left column (AC2, AC5)", async () => { - render(); - - const leftColumn = await screen.findByTestId("chat-left-column"); - const input = screen.getByPlaceholderText("Send a message..."); - expect(leftColumn).toContainElement(input); - }); - - it("renders panels inside the right column (AC2)", async () => { - render(); - - const rightColumn = await screen.findByTestId("chat-right-column"); - const agentsPanel = await screen.findByText("Agents"); - expect(rightColumn).toContainElement(agentsPanel); - }); - - it("uses row flex-direction on wide screens (AC3)", async () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1200, - }); - window.dispatchEvent(new Event("resize")); - - render(); - - const contentArea = await screen.findByTestId("chat-content-area"); - expect(contentArea).toHaveStyle({ flexDirection: "row" }); - }); - - it("uses column flex-direction on narrow screens (AC4)", async () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 600, - }); - window.dispatchEvent(new Event("resize")); - - render(); - - const contentArea = await screen.findByTestId("chat-content-area"); - expect(contentArea).toHaveStyle({ flexDirection: "column" }); - - // Restore wide width for subsequent tests - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1024, - }); - }); -}); - -describe("Chat input Shift+Enter behavior", () => { - beforeEach(() => { - capturedWsHandlers = null; - setupMocks(); - }); - - it("renders a textarea element for the chat input (AC3)", async () => { - render(); - const input = screen.getByPlaceholderText("Send a message..."); - expect(input.tagName.toLowerCase()).toBe("textarea"); - }); - - it("sends message on Enter key press without Shift (AC2)", async () => { - render(); - 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 }); - }); - - await waitFor(() => { - expect((input as HTMLTextAreaElement).value).toBe(""); - }); - }); - - it("does not send message on Shift+Enter (AC1)", async () => { - render(); - 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: true }); - }); - - expect((input as HTMLTextAreaElement).value).toBe("Hello"); - }); -}); - -describe("Chat reconciliation banner", () => { - beforeEach(() => { - capturedWsHandlers = null; - setupMocks(); - }); - - it("shows banner when a non-done reconciliation event is received", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onReconciliationProgress( - "42_story_test", - "checking", - "Checking for committed work in 2_current/", - ); - }); - - expect( - await screen.findByTestId("reconciliation-banner"), - ).toBeInTheDocument(); - expect( - await screen.findByText("Reconciling startup state..."), - ).toBeInTheDocument(); - }); - - it("shows event message in the banner", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onReconciliationProgress( - "42_story_test", - "gates_running", - "Running acceptance gates…", - ); - }); - - expect( - await screen.findByText(/Running acceptance gates/), - ).toBeInTheDocument(); - }); - - it("dismisses banner when done event is received", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onReconciliationProgress( - "42_story_test", - "checking", - "Checking for committed work", - ); - }); - - expect( - await screen.findByTestId("reconciliation-banner"), - ).toBeInTheDocument(); - - await act(async () => { - capturedWsHandlers?.onReconciliationProgress( - "", - "done", - "Startup reconciliation complete.", - ); - }); - - await waitFor(() => { - expect( - screen.queryByTestId("reconciliation-banner"), - ).not.toBeInTheDocument(); - }); - }); -}); - -describe("Chat localStorage persistence (Story 145)", () => { - const PROJECT_PATH = "/tmp/project"; - const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; - - beforeEach(() => { - capturedWsHandlers = null; - localStorage.clear(); - setupMocks(); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it("AC1: restores persisted messages on mount", async () => { - const saved: Message[] = [ - { role: "user", content: "Previously saved question" }, - { role: "assistant", content: "Previously saved answer" }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); - - render(); - - expect( - await screen.findByText("Previously saved question"), - ).toBeInTheDocument(); - expect( - await screen.findByText("Previously saved answer"), - ).toBeInTheDocument(); - }); - - it("AC2: persists messages when WebSocket onUpdate fires", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const history: Message[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ]; - - await act(async () => { - capturedWsHandlers?.onUpdate(history); - }); - - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); - expect(stored).toEqual(history); - }); - - it("AC3: clears localStorage when New Session is clicked", async () => { - const saved: Message[] = [ - { role: "user", content: "Old message" }, - { role: "assistant", content: "Old reply" }, - ]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); - - // Stub window.confirm to auto-approve the clear dialog - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); - - render(); - - // Wait for the persisted message to appear - expect(await screen.findByText("Old message")).toBeInTheDocument(); - - // Click "New Session" button - const newSessionBtn = screen.getByText(/New Session/); - await act(async () => { - fireEvent.click(newSessionBtn); - }); - - // localStorage should be cleared - expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); - - // Messages should be gone from the UI - expect(screen.queryByText("Old message")).not.toBeInTheDocument(); - - confirmSpy.mockRestore(); - }); - - it("Bug 245: messages survive unmount/remount cycle (page refresh)", async () => { - // Step 1: Render Chat and populate messages via WebSocket onUpdate - const { unmount } = render( - , - ); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const history: Message[] = [ - { role: "user", content: "Persist me across refresh" }, - { role: "assistant", content: "I should survive a reload" }, - ]; - - await act(async () => { - capturedWsHandlers?.onUpdate(history); - }); - - // Verify messages are persisted to localStorage - expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); - const storedBefore = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); - expect(storedBefore).toEqual(history); - - // Step 2: Unmount the Chat component (simulates page unload) - unmount(); - - // Verify localStorage was NOT cleared by unmount - expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); - const storedAfterUnmount = JSON.parse( - localStorage.getItem(STORAGE_KEY) ?? "[]", - ); - expect(storedAfterUnmount).toEqual(history); - - // Step 3: Remount the Chat component (simulates page reload) - capturedWsHandlers = null; - render(); - - // Verify messages are restored from localStorage - expect( - await screen.findByText("Persist me across refresh"), - ).toBeInTheDocument(); - expect( - await screen.findByText("I should survive a reload"), - ).toBeInTheDocument(); - - // Verify localStorage still has the messages - const storedAfterRemount = JSON.parse( - localStorage.getItem(STORAGE_KEY) ?? "[]", - ); - expect(storedAfterRemount).toEqual(history); - }); - - it("Bug 245: after refresh, sendChat includes full prior history", async () => { - // Step 1: Render, populate messages via onUpdate, then unmount (simulate refresh) - const { unmount } = render( - , - ); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const priorHistory: Message[] = [ - { role: "user", content: "What is Rust?" }, - { role: "assistant", content: "Rust is a systems programming language." }, - ]; - await act(async () => { - capturedWsHandlers?.onUpdate(priorHistory); - }); - - // Verify localStorage has the prior history - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); - expect(stored).toEqual(priorHistory); - - unmount(); - - // Step 2: Remount (simulates page reload) — messages load from localStorage - capturedWsHandlers = null; - lastSendChatArgs = null; - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - // Verify prior messages are displayed - expect(await screen.findByText("What is Rust?")).toBeInTheDocument(); - - // Step 3: Send a new message — sendChat should include the full prior history - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "Tell me more" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - // Verify sendChat was called with ALL prior messages + the new one - expect(lastSendChatArgs).not.toBeNull(); - const args = lastSendChatArgs as unknown as { - messages: Message[]; - config: unknown; - }; - expect(args.messages).toHaveLength(3); - expect(args.messages[0]).toEqual({ - role: "user", - content: "What is Rust?", - }); - expect(args.messages[1]).toEqual({ - role: "assistant", - content: "Rust is a systems programming language.", - }); - expect(args.messages[2]).toEqual({ - role: "user", - content: "Tell me more", - }); - }); - - it("AC5: uses project-scoped storage key", async () => { - const otherKey = "storykit-chat-history:/other/project"; - localStorage.setItem( - otherKey, - JSON.stringify([{ role: "user", content: "Other project msg" }]), - ); - - render(); - - // Should NOT show the other project's messages - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - expect(screen.queryByText("Other project msg")).not.toBeInTheDocument(); - - // Other project's data should still be in storage - expect(localStorage.getItem(otherKey)).not.toBeNull(); - }); -}); - -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(); - }); - }); -}); - -describe("Remove bubble styling from streaming messages (Story 163)", () => { - beforeEach(() => { - capturedWsHandlers = null; - setupMocks(); - }); - - it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - // Send a message to put chat into loading state - 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 }); - }); - - // Simulate streaming tokens arriving - await act(async () => { - capturedWsHandlers?.onToken("Streaming response text"); - }); - - // Find the streaming message container (the inner div wrapping the Markdown) - const streamingText = await screen.findByText("Streaming response text"); - // The markdown-body wrapper is the parent, and the styled div is its parent - const styledDiv = streamingText.closest(".markdown-body") - ?.parentElement as HTMLElement; - - expect(styledDiv).toBeTruthy(); - const styleAttr = styledDiv.getAttribute("style") ?? ""; - expect(styleAttr).toContain("background: transparent"); - expect(styleAttr).toContain("padding: 0px"); - expect(styleAttr).toContain("border-radius: 0px"); - expect(styleAttr).toContain("max-width: 100%"); - }); - - it("AC1: streaming message wraps Markdown in markdown-body class", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - 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 }); - }); - - await act(async () => { - capturedWsHandlers?.onToken("Some markdown content"); - }); - - const streamingText = await screen.findByText("Some markdown content"); - const markdownBody = streamingText.closest(".markdown-body"); - expect(markdownBody).toBeTruthy(); - }); - - it("AC2: no visual change when streaming ends and message transitions to completed", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - // Send a message to start streaming - 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 }); - }); - - // Simulate streaming tokens - await act(async () => { - capturedWsHandlers?.onToken("Final response"); - }); - - // Capture streaming message style attribute - const streamingText = await screen.findByText("Final response"); - const streamingStyledDiv = streamingText.closest(".markdown-body") - ?.parentElement as HTMLElement; - const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? ""; - - // Transition: onUpdate completes the message - await act(async () => { - capturedWsHandlers?.onUpdate([ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Final response" }, - ]); - }); - - // Find the completed message — it should have the same styling - const completedText = await screen.findByText("Final response"); - const completedMarkdownBody = completedText.closest(".markdown-body"); - const completedStyledDiv = - completedMarkdownBody?.parentElement as HTMLElement; - - expect(completedStyledDiv).toBeTruthy(); - const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? ""; - - // Both streaming and completed use transparent bg, 0 padding, 0 border-radius - expect(completedStyleAttr).toContain("background: transparent"); - expect(completedStyleAttr).toContain("padding: 0px"); - expect(completedStyleAttr).toContain("border-radius: 0px"); - expect(streamingStyleAttr).toContain("background: transparent"); - expect(streamingStyleAttr).toContain("padding: 0px"); - expect(streamingStyleAttr).toContain("border-radius: 0px"); - - // Both have the markdown-body class wrapper - expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy(); - }); - - it("AC3: completed assistant messages retain transparent background and no border-radius", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onUpdate([ - { role: "user", content: "Hi" }, - { role: "assistant", content: "Hello there!" }, - ]); - }); - - const assistantText = await screen.findByText("Hello there!"); - const markdownBody = assistantText.closest(".markdown-body"); - const styledDiv = markdownBody?.parentElement as HTMLElement; - - expect(styledDiv).toBeTruthy(); - const styleAttr = styledDiv.getAttribute("style") ?? ""; - expect(styleAttr).toContain("background: transparent"); - expect(styleAttr).toContain("padding: 0px"); - expect(styleAttr).toContain("border-radius: 0px"); - expect(styleAttr).toContain("max-width: 100%"); - }); - - it("AC3: completed user messages still have their bubble styling", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onUpdate([ - { role: "user", content: "I am a user message" }, - { role: "assistant", content: "I am a response" }, - ]); - }); - - // findByText finds the text element; traverse up to the styled bubble div - const userText = await screen.findByText("I am a user message"); - // User messages are rendered via markdown, so text is inside a

inside .user-markdown-body - // Walk up to find the styled bubble container - const bubbleDiv = userText.closest("[style*='padding: 10px 16px']"); - expect(bubbleDiv).toBeTruthy(); - const styleAttr = bubbleDiv?.getAttribute("style") ?? ""; - // User messages retain bubble: distinct background, padding, rounded corners - expect(styleAttr).toContain("padding: 10px 16px"); - expect(styleAttr).toContain("border-radius: 20px"); - expect(styleAttr).not.toContain("background: transparent"); - }); -}); - -describe("Bug 264: Claude Code session ID persisted across browser refresh", () => { - const PROJECT_PATH = "/tmp/project"; - const SESSION_KEY = `storykit-claude-session-id:${PROJECT_PATH}`; - const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; - - beforeEach(() => { - capturedWsHandlers = null; - lastSendChatArgs = null; - localStorage.clear(); - setupMocks(); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it("AC1: session_id is persisted to localStorage when onSessionId fires", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onSessionId("test-session-abc"); - }); - - await waitFor(() => { - expect(localStorage.getItem(SESSION_KEY)).toBe("test-session-abc"); - }); - }); - - it("AC2: after remount, next sendChat includes session_id from localStorage", async () => { - // Step 1: Render, receive a session ID, then unmount (simulate refresh) - localStorage.setItem(SESSION_KEY, "persisted-session-xyz"); - localStorage.setItem( - STORAGE_KEY, - JSON.stringify([ - { role: "user", content: "Prior message" }, - { role: "assistant", content: "Prior reply" }, - ]), - ); - - const { unmount } = render( - , - ); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - unmount(); - - // Step 2: Remount (simulates page reload) - capturedWsHandlers = null; - lastSendChatArgs = null; - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - // Prior messages should be visible - expect(await screen.findByText("Prior message")).toBeInTheDocument(); - - // Step 3: Send a new message — config should include session_id - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "Continue" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - expect(lastSendChatArgs).not.toBeNull(); - expect( - ( - ( - lastSendChatArgs as unknown as { - messages: Message[]; - config: unknown; - } - )?.config as Record - ).session_id, - ).toBe("persisted-session-xyz"); - }); - - it("AC3: clearing the session also clears the persisted session_id", async () => { - localStorage.setItem(SESSION_KEY, "session-to-clear"); - - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); - - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const newSessionBtn = screen.getByText(/New Session/); - await act(async () => { - fireEvent.click(newSessionBtn); - }); - - expect(localStorage.getItem(SESSION_KEY)).toBeNull(); - - confirmSpy.mockRestore(); - }); - - it("AC1: storage key is scoped to project path", async () => { - const otherPath = "/other/project"; - const otherKey = `storykit-claude-session-id:${otherPath}`; - localStorage.setItem(otherKey, "other-session"); - - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onSessionId("my-session"); - }); - - await waitFor(() => { - expect(localStorage.getItem(SESSION_KEY)).toBe("my-session"); - }); - - // Other project's session should be untouched - expect(localStorage.getItem(otherKey)).toBe("other-session"); - }); -}); - -describe("File reference expansion (Story 269 AC4)", () => { - beforeEach(() => { - vi.clearAllMocks(); - capturedWsHandlers = null; - lastSendChatArgs = null; - setupMocks(); - }); - - it("includes file contents as context when message contains @file reference", async () => { - mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }'); - - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "explain @src/main.rs" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); - const sentMessages = ( - lastSendChatArgs as NonNullable - ).messages; - const userMsg = sentMessages[sentMessages.length - 1]; - expect(userMsg.content).toContain("explain @src/main.rs"); - expect(userMsg.content).toContain("[File: src/main.rs]"); - expect(userMsg.content).toContain("fn main()"); - }); - - it("sends message without modification when no @file references are present", async () => { - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "hello world" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); - const sentMessages = ( - lastSendChatArgs as NonNullable - ).messages; - const userMsg = sentMessages[sentMessages.length - 1]; - expect(userMsg.content).toBe("hello world"); - expect(mockedApi.readFile).not.toHaveBeenCalled(); - }); -}); - -describe("Slash command handling (Story 374)", () => { - beforeEach(() => { - capturedWsHandlers = null; - lastSendChatArgs = null; - setupMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("AC: /status calls botCommand and displays response", async () => { - mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" }); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/status" } }); - }); - // First Enter selects the command from the picker; second Enter submits it - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith( - "status", - "", - undefined, - ); - }); - expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument(); - // Should NOT go to LLM - expect(lastSendChatArgs).toBeNull(); - }); - - it("AC: /status passes args to botCommand", async () => { - mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" }); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/status 42" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith( - "status", - "42", - undefined, - ); - }); - }); - - it("AC: /start calls botCommand", async () => { - mockedApi.botCommand.mockResolvedValue({ response: "Started agent" }); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/start 42 opus" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith( - "start", - "42 opus", - undefined, - ); - }); - expect(await screen.findByText("Started agent")).toBeInTheDocument(); - }); - - it("AC: /git calls botCommand", async () => { - mockedApi.botCommand.mockResolvedValue({ response: "On branch main" }); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/git" } }); - }); - // First Enter selects the command from the picker; second Enter submits it - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined); - }); - }); - - it("AC: /cost calls botCommand", async () => { - mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" }); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/cost" } }); - }); - // First Enter selects the command from the picker; second Enter submits it - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined); - }); - }); - - it("AC: /reset clears messages and session without LLM", async () => { - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - // First add a message so there is history to clear - await act(async () => { - capturedWsHandlers?.onUpdate([ - { role: "user", content: "hello" }, - { role: "assistant", content: "world" }, - ]); - }); - expect(await screen.findByText("world")).toBeInTheDocument(); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/reset" } }); - }); - // First Enter selects the command from the picker; second Enter submits it - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - // LLM must NOT be invoked - expect(lastSendChatArgs).toBeNull(); - // botCommand must NOT be invoked (reset is frontend-only) - expect(mockedApi.botCommand).not.toHaveBeenCalled(); - // Confirmation message should appear - expect(await screen.findByText(/Session reset/)).toBeInTheDocument(); - }); - - it("AC: unrecognised slash command shows error message", async () => { - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/foobar" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - expect(await screen.findByText(/Unknown command/)).toBeInTheDocument(); - // Should NOT go to LLM - expect(lastSendChatArgs).toBeNull(); - // Should NOT call botCommand - expect(mockedApi.botCommand).not.toHaveBeenCalled(); - }); - - it("AC: /help calls botCommand and displays response", async () => { - mockedApi.botCommand.mockResolvedValue({ - response: "Available commands: status, help, ...", - }); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/help" } }); - }); - // First Enter selects the command from the picker; second Enter submits it - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined); - }); - expect(lastSendChatArgs).toBeNull(); - }); - - it("AC: botCommand API error shows error message in chat", async () => { - mockedApi.botCommand.mockRejectedValue(new Error("Server error")); - render(); - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - const input = screen.getByPlaceholderText("Send a message..."); - await act(async () => { - fireEvent.change(input, { target: { value: "/git" } }); - }); - // First Enter selects the command from the picker; second Enter submits it - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); - }); - - expect( - await screen.findByText(/Error running command/), - ).toBeInTheDocument(); - }); -}); - -describe("Bug 450: WebSocket error messages displayed in chat", () => { - beforeEach(() => { - capturedWsHandlers = null; - setupMocks(); - }); - - it("AC1: WebSocket error message is shown in chat as an assistant message", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onError("Something went wrong on the server."); - }); - - expect( - await screen.findByText("Something went wrong on the server."), - ).toBeInTheDocument(); - }); - - it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => { - render(); - - await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); - - await act(async () => { - capturedWsHandlers?.onError( - "OAuth login required. Please visit: https://example.com/oauth/login", - ); - }); - - const link = await screen.findByRole("link", { - name: /https:\/\/example\.com\/oauth\/login/, - }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute("href", "https://example.com/oauth/login"); - }); -});