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");
- });
-});