;
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)
act(() => {
capturedWsHandlers?.onToken("I'll read that file for you.");
});
// Now simulate a tool activity event while streamingContent is non-empty
act(() => {
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
act(() => {
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
act(() => {
capturedWsHandlers?.onToken("Let me read that.");
});
// Claude Code sends tool name "Read" (not "read_file")
act(() => {
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 });
});
act(() => {
capturedWsHandlers?.onToken("Running tests now.");
});
act(() => {
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 });
});
act(() => {
capturedWsHandlers?.onToken("Working on it.");
});
act(() => {
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)
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "First" },
{ role: "assistant", content: "Done." },
]);
});
// The queued indicator should disappear (message was sent)
await waitFor(() => {
expect(
screen.queryByTestId("queued-message-indicator"),
).not.toBeInTheDocument();
});
});
it("cancel button discards the queued message (AC3, AC6)", async () => {
render();
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
// Send first message to start loading
await act(async () => {
fireEvent.change(input, { target: { value: "First" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// Queue a second message
await act(async () => {
fireEvent.change(input, { target: { value: "Discard me" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
const indicator = await screen.findByTestId("queued-message-indicator");
expect(indicator).toBeInTheDocument();
// Click the ✕ cancel button
const cancelBtn = screen.getByTitle("Cancel queued message");
await act(async () => {
fireEvent.click(cancelBtn);
});
// Indicator should be gone
expect(
screen.queryByTestId("queued-message-indicator"),
).not.toBeInTheDocument();
});
it("edit button puts queued message back into input (AC3)", async () => {
render();
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
// Send first message to start loading
await act(async () => {
fireEvent.change(input, { target: { value: "First" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// Queue a second message
await act(async () => {
fireEvent.change(input, { target: { value: "Edit me back" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await screen.findByTestId("queued-message-indicator");
// Click the Edit button
const editBtn = screen.getByTitle("Edit queued message");
await act(async () => {
fireEvent.click(editBtn);
});
// Indicator should be gone and message back in input
expect(
screen.queryByTestId("queued-message-indicator"),
).not.toBeInTheDocument();
expect((input as HTMLTextAreaElement).value).toBe("Edit me back");
});
it("subsequent submissions 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
act(() => {
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
act(() => {
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 });
});
act(() => {
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
act(() => {
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
act(() => {
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());
act(() => {
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());
act(() => {
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());
act(() => {
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?.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());
act(() => {
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();
});
});