2026-02-23 15:59:01 +00:00
|
|
|
import {
|
|
|
|
|
act,
|
|
|
|
|
fireEvent,
|
|
|
|
|
render,
|
|
|
|
|
screen,
|
|
|
|
|
waitFor,
|
|
|
|
|
} from "@testing-library/react";
|
2026-02-19 12:54:04 +00:00
|
|
|
|
2026-02-24 17:03:04 +00:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
2026-02-19 12:54:04 +00:00
|
|
|
import { api } from "../api/client";
|
2026-02-20 14:09:59 +00:00
|
|
|
import type { Message } from "../types";
|
2026-02-19 12:54:04 +00:00
|
|
|
import { Chat } from "./Chat";
|
|
|
|
|
|
2026-02-20 14:09:59 +00:00
|
|
|
// 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;
|
|
|
|
|
onSessionId: (sessionId: string) => void;
|
|
|
|
|
onError: (message: string) => void;
|
2026-02-24 13:28:43 +00:00
|
|
|
onActivity: (toolName: string) => void;
|
2026-02-23 22:50:57 +00:00
|
|
|
onReconciliationProgress: (
|
|
|
|
|
storyId: string,
|
|
|
|
|
status: string,
|
|
|
|
|
message: string,
|
|
|
|
|
) => void;
|
2026-02-20 14:09:59 +00:00
|
|
|
};
|
|
|
|
|
let capturedWsHandlers: WsHandlers | null = null;
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
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(),
|
|
|
|
|
};
|
|
|
|
|
class ChatWebSocket {
|
2026-02-20 14:09:59 +00:00
|
|
|
connect(handlers: WsHandlers) {
|
|
|
|
|
capturedWsHandlers = handlers;
|
|
|
|
|
}
|
2026-02-19 12:54:04 +00:00
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-20 19:39:19 +00:00
|
|
|
function setupMocks() {
|
|
|
|
|
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
|
|
|
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
|
|
|
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
|
|
|
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
|
|
|
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
|
|
|
|
mockedApi.cancelChat.mockResolvedValue(true);
|
|
|
|
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
|
|
|
|
}
|
2026-02-20 14:09:59 +00:00
|
|
|
|
|
|
|
|
describe("Chat message rendering — unified tool call UI", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
capturedWsHandlers = null;
|
2026-02-20 19:39:19 +00:00
|
|
|
setupMocks();
|
2026-02-20 14:09:59 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders tool call badge for assistant message with tool_calls (AC3)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
const messages: Message[] = [
|
|
|
|
|
{ role: "user", content: "Read src/main.rs" },
|
|
|
|
|
{
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: "I'll read that file.",
|
|
|
|
|
tool_calls: [
|
|
|
|
|
{
|
|
|
|
|
id: "toolu_abc",
|
|
|
|
|
type: "function",
|
|
|
|
|
function: {
|
|
|
|
|
name: "Read",
|
|
|
|
|
arguments: '{"file_path":"src/main.rs"}',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
|
2026-02-20 15:51:47 +00:00
|
|
|
expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument();
|
2026-02-20 14:09:59 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders collapsible tool output for tool role messages (AC3)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
const messages: Message[] = [
|
|
|
|
|
{ role: "user", content: "Check the file" },
|
|
|
|
|
{
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: "",
|
|
|
|
|
tool_calls: [
|
|
|
|
|
{
|
|
|
|
|
id: "toolu_1",
|
|
|
|
|
type: "function",
|
|
|
|
|
function: { name: "Read", arguments: '{"file_path":"foo.rs"}' },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
role: "tool",
|
|
|
|
|
content: 'fn main() { println!("hello"); }',
|
|
|
|
|
tool_call_id: "toolu_1",
|
|
|
|
|
},
|
|
|
|
|
{ role: "assistant", content: "The file contains a main function." },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText(/Tool Output/)).toBeInTheDocument();
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByText("The file contains a main function."),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders plain assistant message without tool call badges (AC5)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
const messages: Message[] = [
|
|
|
|
|
{ role: "user", content: "Hello" },
|
|
|
|
|
{ role: "assistant", content: "Hi there! How can I help?" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByText("Hi there! How can I help?"),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(/Tool Output/)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders multiple tool calls in a single assistant turn (AC3)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
const messages: Message[] = [
|
|
|
|
|
{ role: "user", content: "Do some work" },
|
|
|
|
|
{
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: "I'll do multiple things.",
|
|
|
|
|
tool_calls: [
|
|
|
|
|
{
|
|
|
|
|
id: "id1",
|
|
|
|
|
type: "function",
|
|
|
|
|
function: { name: "Bash", arguments: '{"command":"cargo test"}' },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "id2",
|
|
|
|
|
type: "function",
|
|
|
|
|
function: { name: "Read", arguments: '{"file_path":"Cargo.toml"}' },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onUpdate(messages);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-20 15:51:47 +00:00
|
|
|
expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
|
|
|
|
|
expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
|
2026-02-20 14:09:59 +00:00
|
|
|
});
|
2026-02-20 19:39:19 +00:00
|
|
|
|
|
|
|
|
it("does not fetch Anthropic models when no API key exists", async () => {
|
|
|
|
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
|
|
|
|
mockedApi.getAnthropicModels.mockClear();
|
|
|
|
|
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2026-02-20 14:09:59 +00:00
|
|
|
});
|
2026-02-20 15:53:24 +00:00
|
|
|
|
|
|
|
|
describe("Chat two-column layout", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
capturedWsHandlers = null;
|
2026-02-20 19:39:19 +00:00
|
|
|
setupMocks();
|
2026-02-20 15:53:24 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders left and right column containers (AC1, AC2)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
const rightColumn = await screen.findByTestId("chat-right-column");
|
2026-02-20 19:39:19 +00:00
|
|
|
const agentsPanel = await screen.findByText("Agents");
|
|
|
|
|
expect(rightColumn).toContainElement(agentsPanel);
|
2026-02-20 15:53:24 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-23 15:59:01 +00:00
|
|
|
|
|
|
|
|
describe("Chat input Shift+Enter behavior", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
capturedWsHandlers = null;
|
|
|
|
|
setupMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders a textarea element for the chat input (AC3)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-23 22:50:57 +00:00
|
|
|
|
|
|
|
|
describe("Chat reconciliation banner", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
capturedWsHandlers = null;
|
|
|
|
|
setupMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows banner when a non-done reconciliation event is received", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onReconciliationProgress(
|
|
|
|
|
"42_story_test",
|
|
|
|
|
"checking",
|
|
|
|
|
"Checking for committed work",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByTestId("reconciliation-banner"),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onReconciliationProgress(
|
|
|
|
|
"",
|
|
|
|
|
"done",
|
|
|
|
|
"Startup reconciliation complete.",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.queryByTestId("reconciliation-banner"),
|
|
|
|
|
).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-24 13:28:43 +00:00
|
|
|
|
2026-02-24 17:03:04 +00:00
|
|
|
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
const history: Message[] = [
|
|
|
|
|
{ role: "user", content: "Hello" },
|
|
|
|
|
{ role: "assistant", content: "Hi there!" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
// 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("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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-24 13:28:43 +00:00
|
|
|
describe("Chat activity status indicator (Bug 140)", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
capturedWsHandlers = null;
|
|
|
|
|
setupMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows activity label when tool activity fires during streaming content", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
2026-02-24 15:26:39 +00:00
|
|
|
|
|
|
|
|
it("shows activity label for Claude Code tool names (Read, Bash, etc.)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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...");
|
|
|
|
|
});
|
2026-02-24 13:28:43 +00:00
|
|
|
});
|
2026-02-24 16:29:05 +00:00
|
|
|
|
|
|
|
|
describe("Chat message queue (Story 155)", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
capturedWsHandlers = null;
|
|
|
|
|
setupMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows queued message indicator when submitting while loading (AC1, AC2)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-24 19:17:33 +00:00
|
|
|
it("subsequent submissions are appended to the queue (Bug 168)", async () => {
|
2026-02-24 16:29:05 +00:00
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-24 19:17:33 +00:00
|
|
|
// Queue first message
|
2026-02-24 16:29:05 +00:00
|
|
|
await act(async () => {
|
2026-02-24 19:17:33 +00:00
|
|
|
fireEvent.change(input, { target: { value: "Queue 1" } });
|
2026-02-24 16:29:05 +00:00
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("queued-message-indicator");
|
|
|
|
|
|
2026-02-24 19:17:33 +00:00
|
|
|
// Queue second message — should be appended, not overwrite the first
|
2026-02-24 16:29:05 +00:00
|
|
|
await act(async () => {
|
2026-02-24 19:17:33 +00:00
|
|
|
fireEvent.change(input, { target: { value: "Queue 2" } });
|
2026-02-24 16:29:05 +00:00
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-24 19:17:33 +00:00
|
|
|
// Both messages should be visible
|
|
|
|
|
const indicators = await screen.findAllByTestId("queued-message-indicator");
|
|
|
|
|
expect(indicators).toHaveLength(2);
|
|
|
|
|
expect(indicators[0]).toHaveTextContent("Queue 1");
|
|
|
|
|
expect(indicators[1]).toHaveTextContent("Queue 2");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("queued messages are delivered in order (Bug 168)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText("Send a message...");
|
|
|
|
|
|
|
|
|
|
// Send first message to start loading
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.change(input, { target: { value: "First" } });
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Queue two messages
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.change(input, { target: { value: "Second" } });
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.change(input, { target: { value: "Third" } });
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Both messages should be visible in order
|
|
|
|
|
const indicators = await screen.findAllByTestId("queued-message-indicator");
|
|
|
|
|
expect(indicators).toHaveLength(2);
|
|
|
|
|
expect(indicators[0]).toHaveTextContent("Second");
|
|
|
|
|
expect(indicators[1]).toHaveTextContent("Third");
|
|
|
|
|
|
|
|
|
|
// Simulate first response completing — "Second" is sent next
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onUpdate([
|
|
|
|
|
{ role: "user", content: "First" },
|
|
|
|
|
{ role: "assistant", content: "Response 1." },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// "Third" should remain queued; "Second" was consumed
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const remaining = screen.queryAllByTestId("queued-message-indicator");
|
|
|
|
|
expect(remaining).toHaveLength(1);
|
|
|
|
|
expect(remaining[0]).toHaveTextContent("Third");
|
|
|
|
|
});
|
2026-02-24 16:29:05 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not auto-send queued message when generation is cancelled (AC6)", async () => {
|
|
|
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-24 17:51:55 +00:00
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedWsHandlers?.onUpdate([
|
|
|
|
|
{ role: "user", content: "I am a user message" },
|
|
|
|
|
{ role: "assistant", content: "I am a response" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-25 18:08:08 +00:00
|
|
|
// 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 <p> 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") ?? "";
|
2026-02-24 17:51:55 +00:00
|
|
|
// 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");
|
|
|
|
|
});
|
|
|
|
|
});
|