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().
// 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;
	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("Default provider selection (Story 206)", () => {
	beforeEach(() => {
		capturedWsHandlers = null;
	});

	it("AC1: defaults to claude-code-pty when no saved model preference exists", async () => {
		mockedApi.getOllamaModels.mockResolvedValue([]);
		mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
		mockedApi.getAnthropicModels.mockResolvedValue([]);
		mockedApi.getModelPreference.mockResolvedValue(null);
		mockedApi.setModelPreference.mockResolvedValue(true);
		mockedApi.cancelChat.mockResolvedValue(true);

		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		// With no models available, the header renders a text input with the model value
		const input = screen.getByPlaceholderText("Model");
		expect(input).toHaveValue("claude-code-pty");
	});

	it("AC2: claude-code-pty remains default even when ollama models are available", async () => {
		mockedApi.getOllamaModels.mockResolvedValue(["llama3.1", "deepseek-coder"]);
		mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
		mockedApi.getAnthropicModels.mockResolvedValue([]);
		mockedApi.getModelPreference.mockResolvedValue(null);
		mockedApi.setModelPreference.mockResolvedValue(true);
		mockedApi.cancelChat.mockResolvedValue(true);

		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		// Wait for Ollama models to load and the select dropdown to appear
		const select = await screen.findByRole("combobox");
		expect(select).toHaveValue("claude-code-pty");
	});

	it("AC3: respects saved model preference for existing projects", async () => {
		mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
		mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
		mockedApi.getAnthropicModels.mockResolvedValue([]);
		mockedApi.getModelPreference.mockResolvedValue("llama3.1");
		mockedApi.setModelPreference.mockResolvedValue(true);
		mockedApi.cancelChat.mockResolvedValue(true);

		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		// Wait for models to load and preference to be applied
		const select = await screen.findByRole("combobox");
		await waitFor(() => {
			expect(select).toHaveValue("llama3.1");
		});
	});
});

describe("Chat message rendering — unified tool call UI", () => {
	beforeEach(() => {
		capturedWsHandlers = null;
		setupMocks();
	});

	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"}',
						},
					},
				],
			},
		];

		await act(async () => {
			capturedWsHandlers?.onUpdate(messages);
		});

		expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
		expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument();
	});

	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." },
		];

		await act(async () => {
			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?" },
		];

		await act(async () => {
			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"}' },
					},
				],
			},
		];

		await act(async () => {
			capturedWsHandlers?.onUpdate(messages);
		});

		expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
		expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
	});

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

describe("Chat two-column layout", () => {
	beforeEach(() => {
		capturedWsHandlers = null;
		setupMocks();
	});

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

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

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());

		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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		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(<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!" },
		];

		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(<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("Bug 245: messages survive unmount/remount cycle (page refresh)", async () => {
		// Step 1: Render Chat and populate messages via WebSocket onUpdate
		const { unmount } = render(
			<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
		);

		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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);

		// 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(
			<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
		);
		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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
		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(<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();
	});
});

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

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

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

	it("subsequent submissions are appended to the queue (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 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(<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 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(<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();
		});
	});
});

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

		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(<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
		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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		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 <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") ?? "";
		// 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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);

		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(
			<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
		);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
		unmount();

		// Step 2: Remount (simulates page reload)
		capturedWsHandlers = null;
		lastSendChatArgs = null;
		render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
		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<string, unknown>
			).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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);

		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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
		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(<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: "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<typeof lastSendChatArgs>
		).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(<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 world" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
		const sentMessages = (
			lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
		).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(<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: "/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 <number> passes args to botCommand", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
		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: "/status 42" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith(
				"status",
				"42",
				undefined,
			);
		});
	});

	it("AC: /start <number> calls botCommand", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
		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: "/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(<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: "/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(<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: "/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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		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(<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: "/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(<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: "/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(<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: "/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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

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