Files
fusion/packages/dashboard/app/components/__tests__/ChatView.test.tsx
Fusion 0347bd4afe feat(FN-3331): add New Chat button to thread header on desktop
Adds a New Chat button to the ChatView thread header on desktop with accompanying CSS styling and tests. The feature is scoped to the dashboard's chat interface with 62 lines of additions across the component, its stylesheet, and test file.

Fusion-Task-Id: FN-3331
2026-05-03 15:04:47 -07:00

2944 lines
114 KiB
TypeScript

/**
* Tests for ChatView component: sidebar, session list, message thread,
* new chat dialog, and input handling.
*/
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import type { DiscoveredSkill } from "@fusion/dashboard";
import { loadAllAppCss } from "../../test/cssFixture";
// Mock scrollIntoView for JSDOM
Element.prototype.scrollIntoView = vi.fn();
import * as useChatModule from "../../hooks/useChat";
import type { UseChatReturn, ChatSessionInfo, ChatMessageInfo, ToolCallInfo } from "../../hooks/useChat";
import * as apiModule from "../../api";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
// Mock the hooks
vi.mock("../../hooks/useChat");
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
// Mock lucide-react icons - spread actual module and override specific icons
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
MessageSquare: ({ "data-testid": testId, ...props }: any) => (
<svg data-testid={testId || "icon-message-square"} {...props} />
),
Send: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-send"} {...props} />,
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
Search: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-search"} {...props} />,
Trash2: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-trash"} {...props} />,
Archive: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-archive"} {...props} />,
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
Eye: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye"} {...props} />,
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
Paperclip: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-paperclip"} {...props} />,
File: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-file"} {...props} />,
};
});
// Mock CustomModelDropdown - no longer used but kept for other tests
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
onChange,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => (
<select
data-testid="mock-model-dropdown"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Use default</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
// Mock fetchAgents for new chat dialog
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: [],
favoriteModels: [],
}),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
const defaultChatState: UseChatReturn = {
sessions: [],
activeSession: null,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__", status: "active", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" } satisfies ChatSessionInfo),
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
const activeSessionFixture: ChatSessionInfo = {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test Chat",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
function createMockSkill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: "skill-id",
name: "skill/name",
path: "/tmp/skills/skill.md",
relativePath: "skills/skill.md",
enabled: true,
metadata: {
source: "*",
scope: "project",
origin: "top-level",
},
...overrides,
};
}
function setupMockChat(overrides: Partial<UseChatReturn> = {}) {
const state: UseChatReturn = { ...defaultChatState, ...overrides };
mockUseChat.mockReturnValue(state);
}
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockViewportMode(mode: "mobile" | "desktop") {
ensureMatchMedia();
const isMobile = mode === "mobile";
Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: isMobile && query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
beforeEach(() => {
vi.clearAllMocks();
mockFetchDiscoveredSkills.mockResolvedValue([]);
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: mockRevokeObjectURL, writable: true });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("ChatView", () => {
it("renders empty state when no session is selected", () => {
setupMockChat({ sessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
});
it("renders session list in sidebar", () => {
setupMockChat({
sessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
],
filteredSessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("Test Chat")).toBeInTheDocument();
expect(screen.getByText("Another Chat")).toBeInTheDocument();
});
it("calls selectSession when clicking a session", async () => {
const selectSession = vi.fn();
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
selectSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByText("Test Chat"));
expect(selectSession).toHaveBeenCalledWith("session-001");
});
it("highlights active session", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
expect(sessionItem).toHaveClass("chat-session-item--active");
});
it("opens new chat dialog when clicking New Chat button", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Click the sidebar New Chat button
await userEvent.click(screen.getByTestId("chat-new-btn"));
// Dialog should be open - check for dialog content
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
expect(dialog).toBeInTheDocument();
// Should show mode toggle with Agent and Model buttons
expect(within(dialog!).getByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument();
expect(within(dialog!).getByTestId("chat-new-dialog-mode-agent")).toBeInTheDocument();
expect(within(dialog!).getByTestId("chat-new-dialog-mode-model")).toBeInTheDocument();
});
it("creates session without model selection (uses default)", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Create button should be disabled initially (no agent selected)
const createBtn = within(dialog!).getByText("Create") as HTMLButtonElement;
expect(createBtn).toBeDisabled();
// Click on an agent to select it
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
// Create button should now be enabled
expect(createBtn).not.toBeDisabled();
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "agent-001",
});
});
});
it("creates session with agent selection", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-002" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Click on a different agent
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-002"));
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "agent-002",
});
});
});
it("creates session with model selection (model mode uses KB agent ID)", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Switch to model mode
await userEvent.click(within(dialog!).getByTestId("chat-new-dialog-mode-model"));
// Select a model from the dropdown (now visible in model mode)
const modelDropdown = within(dialog!).getByTestId("mock-model-dropdown");
await userEvent.selectOptions(modelDropdown, "anthropic/claude-sonnet-4-5");
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
});
it("creates session without model selection omits model fields (agent mode)", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Agent mode is default — just select an agent and create
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "agent-001",
});
});
});
it("agent mode shows agent list without model dropdown", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Agent mode is active by default — agent list visible, model section hidden
await waitFor(() => {
expect(within(dialog!).getByTestId("agent-option-agent-001")).toBeInTheDocument();
});
expect(within(dialog!).queryByTestId("chat-new-dialog-model-section")).toBeNull();
});
it("model mode shows model dropdown without agent list", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Switch to model mode
await userEvent.click(within(dialog!).getByTestId("chat-new-dialog-mode-model"));
// Model section visible, no agent list
await waitFor(() => {
expect(within(dialog!).getByTestId("chat-new-dialog-model-section")).toBeInTheDocument();
});
expect(within(dialog!).queryByTestId("agent-option-agent-001")).toBeNull();
});
it("toggle between modes clears opposite selection", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
// Select an agent in agent mode
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
expect(within(dialog!).getByTestId("agent-option-agent-001").classList.contains("chat-new-dialog-agent-item--selected")).toBe(true);
// Switch to model mode — agent selection should be cleared
await userEvent.click(within(dialog!).getByTestId("chat-new-dialog-mode-model"));
// Switch back to agent mode — Create should be disabled (no agent selected)
await userEvent.click(within(dialog!).getByTestId("chat-new-dialog-mode-agent"));
await waitFor(() => {
expect(within(dialog!).getByText("Create")).toBeDisabled();
});
});
it("renders messages for active session", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi there!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("Hello")).toBeInTheDocument();
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
it("does not render markdown/plain toggle controls in the thread header", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-render-mode-markdown")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument();
});
it("renders per-message eye toggles for assistant bubbles on desktop and isolates toggles by message", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "**Second** item", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const firstBubble = screen.getByTestId("chat-message-msg-001");
const secondBubble = screen.getByTestId("chat-message-msg-002");
const [firstToggle, secondToggle] = screen.getAllByTestId("chat-message-render-toggle");
expect(firstToggle).toBeInTheDocument();
expect(secondToggle).toBeInTheDocument();
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(firstToggle);
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(firstToggle);
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
});
it("uses a dedicated streaming toggle sentinel without affecting persisted assistant messages", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
isStreaming: true,
streamingText: "**Live** stream",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const persistedBubble = screen.getByTestId("chat-message-msg-001");
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
const [persistedToggle, streamingToggle] = screen.getAllByTestId("chat-message-render-toggle");
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(streamingToggle);
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(persistedToggle);
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
});
it("renders tool calls from persisted messages", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "I used a tool",
toolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
result: "contents",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("read")).toBeInTheDocument();
const preview = document.querySelector(".chat-tool-call-preview") as HTMLElement | null;
expect(preview).toHaveTextContent("result: contents");
});
it("renders streaming tool calls", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "user", content: "Use tools", createdAt: "2026-04-08T00:00:00.000Z" }],
isStreaming: true,
streamingText: "Working...",
streamingToolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
status: "running",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement | null;
expect(streamingBubble).toBeInTheDocument();
expect(within(streamingBubble as HTMLElement).getByText("read")).toBeInTheDocument();
const preview = (streamingBubble as HTMLElement).querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("path=foo.ts");
});
it("collapses multiple tool calls into single summary line", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Done",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
{
toolName: "grep",
isError: false,
result: "matches",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group).toBeInTheDocument();
expect(group.open).toBe(false);
expect(screen.getByText("2 tool calls")).toBeInTheDocument();
expect(screen.getByText("read, grep")).toBeInTheDocument();
});
it("auto-opens grouped tool calls when any tool call is running", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Running",
toolCalls: [
{
toolName: "read",
isError: false,
status: "running",
},
{
toolName: "grep",
isError: false,
result: "done",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group).toBeInTheDocument();
expect(group.open).toBe(true);
});
it("shows status counts in group summary", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Mixed",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
{
toolName: "grep",
isError: false,
status: "running",
},
{
toolName: "write",
isError: true,
result: "failed",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("(1 running)")).toBeInTheDocument();
});
it("shows error count when there are errors and no running calls", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Mixed",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
{
toolName: "write",
isError: true,
result: "failed",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("(1 error)")).toBeInTheDocument();
});
it("expands grouped tool calls to reveal individual tool items", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Done",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
{
toolName: "grep",
isError: false,
result: "matches",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group.open).toBe(false);
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
await userEvent.click(summary);
expect(group.open).toBe(true);
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("grep")).toBeInTheDocument();
});
it("single tool call renders without group wrapper", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Done",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-tool-calls-group")).not.toBeInTheDocument();
const details = document.querySelector(".chat-tool-call") as HTMLDetailsElement | null;
expect(details).toBeInTheDocument();
expect(details?.open).toBe(false);
});
it("truncates tool names when more than 5 unique", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Done",
toolCalls: [
{ toolName: "read", isError: false, status: "completed" },
{ toolName: "edit", isError: false, status: "completed" },
{ toolName: "bash", isError: false, status: "completed" },
{ toolName: "grep", isError: false, status: "completed" },
{ toolName: "write", isError: false, status: "completed" },
{ toolName: "list", isError: false, status: "completed" },
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("read, edit, bash, grep, write, +1 more")).toBeInTheDocument();
});
it("running tool calls show running indicator", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Running",
toolCalls: [
{
toolName: "read",
isError: false,
status: "running",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-tool-call--running")).toBeInTheDocument();
});
it("error tool calls show error styling", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Error",
toolCalls: [
{
toolName: "read",
isError: true,
result: "failed",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-tool-call--error")).toBeInTheDocument();
});
it("shows resolved agent name in assistant message avatar", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Agent Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello from Alpha", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
await waitFor(() => {
expect(within(avatar!).getByText("Alpha")).toBeInTheDocument();
});
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
});
it("shows Fusion in assistant message avatar for fn agent sessions", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Built-in assistant response", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
expect(within(avatar!).getByText("Fusion")).toBeInTheDocument();
});
it("shows formatted model name in assistant message avatar for fn agent sessions", async () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Fusion Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Built-in assistant response", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
await waitFor(() => {
expect(within(avatar!).getByText("Claude Sonnet 4.5")).toBeInTheDocument();
});
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
});
it("shows resolved agent name in streaming assistant avatar", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Agent Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Think", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "Thinking...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message--streaming .chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
await waitFor(() => {
expect(within(avatar!).getByText("Alpha")).toBeInTheDocument();
});
});
it("intercepts exact /clear and starts a fresh session instead of sending message", async () => {
const sendMessage = vi.fn();
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
const stopStreaming = vi.fn();
const clearPendingMessage = vi.fn();
setupMockChat({
activeSession: activeSessionFixture,
messages: [],
sendMessage,
createSession,
stopStreaming,
clearPendingMessage,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, " /clear {enter}");
expect(sendMessage).not.toHaveBeenCalled();
expect(createSession).toHaveBeenCalledWith({ agentId: "agent-001" });
expect(stopStreaming).toHaveBeenCalledTimes(1);
expect(clearPendingMessage).toHaveBeenCalledTimes(1);
});
it("does not intercept non-exact /clear text", async () => {
const sendMessage = vi.fn();
const createSession = vi.fn();
setupMockChat({
activeSession: activeSessionFixture,
messages: [],
sendMessage,
createSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/clear now{enter}");
expect(sendMessage).toHaveBeenCalledWith("/clear now", []);
expect(createSession).not.toHaveBeenCalled();
});
it("sends message on Enter key", async () => {
const sendMessage = vi.fn();
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
sendMessage,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Hello world{enter}");
expect(sendMessage).toHaveBeenCalledWith("Hello world", []);
});
it("does not send on Shift+Enter", async () => {
const sendMessage = vi.fn();
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
sendMessage,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Hello world{Shift>}{Enter}{/Shift}");
expect(sendMessage).not.toHaveBeenCalled();
});
describe("attachments", () => {
it("clicking paperclip triggers hidden file input", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, "click");
await userEvent.click(screen.getByTestId("chat-attach-btn"));
expect(clickSpy).toHaveBeenCalled();
});
it("allows attaching an image and sends with attachments only", async () => {
const sendMessage = vi.fn();
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const attachButton = screen.getByTestId("chat-attach-btn");
expect(attachButton).toBeInTheDocument();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const imageFile = new File(["image"], "shot.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [imageFile] } });
expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument();
const sendButton = screen.getByTestId("chat-send-btn");
expect(sendButton).not.toBeDisabled();
await userEvent.click(sendButton);
expect(sendMessage).toHaveBeenCalledWith("", [imageFile]);
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
});
it("accepts non-image files and renders filename preview", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const textFile = new File(["hello"], "note.txt", { type: "text/plain" });
fireEvent.change(fileInput, { target: { files: [textFile] } });
expect(await screen.findByText("note.txt")).toBeInTheDocument();
expect(mockCreateObjectURL).not.toHaveBeenCalled();
});
it("adds image attachments from paste events", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
const imageFile = new File(["image"], "paste.png", { type: "image/png" });
fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } });
expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument();
});
it("adds attachments from drag-and-drop", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement;
const textFile = new File(["log"], "drop.log", { type: "text/x-log" });
fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } });
expect(await screen.findByText("drop.log")).toBeInTheDocument();
});
it("removes pending attachments and revokes preview urls", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const imageFile = new File(["image"], "shot.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [imageFile] } });
const removeButton = await screen.findByTestId("chat-attachment-remove-0");
await userEvent.click(removeButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:shot.png");
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
});
it("renders message attachments inline as actionable links", () => {
setupMockChat({
activeSession: activeSessionFixture,
messages: [
{
id: "msg-attach",
sessionId: "session-001",
role: "assistant",
content: "Attached files",
createdAt: "2026-04-08T00:00:00.000Z",
attachments: [
{
id: "att-1",
filename: "img-1.png",
originalName: "capture.png",
mimeType: "image/png",
size: 10,
createdAt: "2026-04-08T00:00:00.000Z",
},
{
id: "att-2",
filename: "note.txt",
originalName: "note.txt",
mimeType: "text/plain",
size: 20,
createdAt: "2026-04-08T00:00:00.000Z",
},
],
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const links = screen.getAllByTestId("chat-message-attachment");
expect(links).toHaveLength(2);
expect(links[0]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/img-1.png");
expect(links[0]).toHaveAttribute("target", "_blank");
expect(links[1]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/note.txt");
expect(screen.getByText("note.txt")).toBeInTheDocument();
});
});
describe("agent mentions", () => {
it("shows mention popup when @ is typed", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "@");
expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument();
});
it("filters mention popup by text after @", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "@be");
expect(await screen.findByTestId("agent-mention-item-agent-002")).toBeInTheDocument();
expect(screen.queryByTestId("agent-mention-item-agent-001")).not.toBeInTheDocument();
});
it("hides mention popup on Escape", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "@");
expect(await screen.findByTestId("agent-mention-popup")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument();
});
it("inserts mention text when selecting an agent", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await userEvent.type(textarea, "@al");
const mentionItem = await screen.findByTestId("agent-mention-item-agent-001");
await userEvent.click(mentionItem);
expect(textarea.value).toBe("@Alpha ");
expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument();
});
it("renders assistant mentions as plain text in markdown mode", async () => {
setupMockChat({
activeSession: activeSessionFixture,
messages: [
{
id: "msg-001",
sessionId: "session-001",
role: "assistant",
content: "Talk to @Alpha and @Unknown next.",
createdAt: "2026-04-08T00:00:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument();
});
expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull();
expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull();
});
});
describe("slash skill autocomplete", () => {
it("shows the skill menu when typing slash in the chat input", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
expect(screen.getByText("refactor/code")).toBeInTheDocument();
});
it("filters discovered skills from slash input", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/re");
expect(await screen.findByText("review/pr")).toBeInTheDocument();
expect(screen.queryByText("deploy/app")).not.toBeInTheDocument();
});
it("inserts /skill command when clicking a menu item", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/re");
await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i }));
expect(textarea).toHaveValue("/skill:review/pr ");
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
});
it("supports arrow navigation with wrapping and Enter selection", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),
createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }),
createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
await screen.findByRole("option", { name: /alpha/i });
// Wrap to bottom from the first item.
await userEvent.keyboard("{ArrowUp}");
expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass(
"chat-skill-menu-item--highlighted",
);
await userEvent.keyboard("{Enter}");
expect(textarea).toHaveValue("/skill:gamma ");
});
it("supports selecting highlighted skill with Tab", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),
createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
await screen.findByRole("option", { name: /alpha/i });
await userEvent.keyboard("{ArrowDown}");
expect(screen.getByRole("option", { name: /beta/i })).toHaveClass(
"chat-skill-menu-item--highlighted",
);
await userEvent.keyboard("{Tab}");
expect(textarea).toHaveValue("/skill:beta ");
});
it("closes the menu when pressing Escape", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
});
it("closes the menu when slash trigger pattern no longer matches", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/re");
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
await userEvent.type(textarea, " ");
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
});
it("shows loading indicator while discovered skills are still loading", async () => {
let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined;
mockFetchDiscoveredSkills.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSkills = resolve;
}),
);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByText("Loading skills…")).toBeInTheDocument();
resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]);
await waitFor(() => {
expect(screen.getByText("review/pr")).toBeInTheDocument();
});
});
it("does not crash when discovered skills fail to load", async () => {
mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable"));
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByText("No skills available")).toBeInTheDocument();
});
});
it("disables send button when input is empty", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sendButton = screen.getByTestId("chat-send-btn");
expect(sendButton).toBeDisabled();
});
it("renders stop button when streaming", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
isStreaming: true,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-stop-btn")).toBeInTheDocument();
expect(screen.queryByTestId("chat-send-btn")).not.toBeInTheDocument();
});
it("clicking stop button calls stopStreaming", async () => {
const stopStreaming = vi.fn();
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
isStreaming: true,
stopStreaming,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-stop-btn"));
expect(stopStreaming).toHaveBeenCalledTimes(1);
});
it("renders send button when not streaming", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
isStreaming: false,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
});
it("renders pending message indicator and dismisses it", async () => {
const clearPendingMessage = vi.fn();
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
pendingMessage: "Queued while streaming",
clearPendingMessage,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming");
await userEvent.click(screen.getByTestId("chat-pending-dismiss"));
expect(clearPendingMessage).toHaveBeenCalledTimes(1);
});
it("textarea is enabled during streaming", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "Thinking...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
expect(textarea).not.toBeDisabled();
});
it("user can type while streaming", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "Thinking...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
// User should be able to type in the textarea while streaming
fireEvent.change(textarea, { target: { value: "Second message" } });
expect((textarea as HTMLTextAreaElement).value).toBe("Second message");
});
it("shows streaming indicator when isStreaming is true", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "Typing...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Streaming message should show
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
expect(streamingMessage).toBeInTheDocument();
expect(streamingMessage?.textContent).toContain("Typing");
});
it("shows thinking blocks collapsed by default", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const message = screen.getByTestId("chat-message-msg-001");
const details = message.querySelector("details");
expect(details).toBeInTheDocument();
expect(details).toHaveProperty("open", false);
});
describe("streaming states", () => {
it("keeps the streaming indicator visible while message history is still loading", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [],
messagesLoading: true,
isStreaming: true,
streamingText: "",
streamingThinking: "",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
expect(streamingMessage).toBeInTheDocument();
expect(streamingMessage?.textContent).toContain("Connecting");
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
});
it("shows waiting indicator when streaming starts before text arrives", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "",
streamingThinking: "",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Streaming message should show with "Connecting..." text
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
expect(streamingMessage).toBeInTheDocument();
expect(streamingMessage?.textContent).toContain("Connecting");
// Waiting class should be present
const waitingContent = streamingMessage?.querySelector(".chat-message-content--waiting");
expect(waitingContent).toBeInTheDocument();
// Typing indicator dots should be rendered
const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator");
expect(typingIndicator).toBeInTheDocument();
expect(typingIndicator?.querySelectorAll("span").length).toBe(3);
});
it("shows thinking indicator when streaming thinking arrives before text", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "",
streamingThinking: "analyzing the request...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Streaming message should show with "Thinking..." text
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
expect(streamingMessage).toBeInTheDocument();
expect(streamingMessage?.textContent).toContain("Thinking");
// Thinking details should be rendered
const thinkingDetails = streamingMessage?.querySelector("details.chat-message-thinking");
expect(thinkingDetails).toBeInTheDocument();
expect(thinkingDetails?.querySelector(".chat-message-thinking-content")?.textContent).toContain("analyzing the request");
// Typing indicator dots should be rendered
const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator");
expect(typingIndicator).toBeInTheDocument();
});
});
it("filters sessions by search query", async () => {
setupMockChat({
sessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
],
filteredSessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
],
searchQuery: "frontend",
setSearchQuery: vi.fn(),
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("Frontend work")).toBeInTheDocument();
expect(screen.queryByText("Backend API")).not.toBeInTheDocument();
});
it("shows empty state with Start Chat button (no inline agent selector)", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
// Find the New Chat button in the empty state section
const emptyState = document.querySelector(".chat-empty-state") as HTMLElement | null;
expect(within(emptyState!).getByRole("button", { name: /new chat/i })).toBeInTheDocument();
// Should NOT have an agent selector in empty state
expect(emptyState?.querySelector("select")).toBeNull();
});
it("shows context menu on right-click", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" });
expect(screen.getByTestId("chat-context-archive")).toBeInTheDocument();
expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument();
});
it("calls archiveSession when clicking Archive in context menu", async () => {
const archiveSession = vi.fn();
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
archiveSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" });
await userEvent.click(screen.getByTestId("chat-context-archive"));
expect(archiveSession).toHaveBeenCalledWith("session-001");
});
it("shows delete confirmation dialog", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" });
await userEvent.click(screen.getByTestId("chat-context-delete"));
// Dialog should be open
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
expect(dialog).toBeInTheDocument();
expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument();
});
it("shows formatted model label for fn agent sessions in sidebar", () => {
setupMockChat({
sessions: [{
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "My Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
updatedAt: "2026-04-08T00:00:00.000Z",
createdAt: "2026-04-08T00:00:00.000Z",
}],
filteredSessions: [{
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "My Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
updatedAt: "2026-04-08T00:00:00.000Z",
createdAt: "2026-04-08T00:00:00.000Z",
}],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
expect(within(sessionItem).getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(within(sessionItem).queryByText("Fusion")).not.toBeInTheDocument();
});
it("shows Fusion fallback for fn agent sessions in sidebar without model info", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "__fn_agent__", status: "active", title: "My Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument();
});
it("shows agent ID for non-fn agent sessions in sidebar", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
// Should show the agent ID (truncated to 30 chars)
expect(within(sessionItem).getByText("my-custom-agent")).toBeInTheDocument();
});
it("shows formatted model name in thread header title for fn agent sessions", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Test Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null;
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent("Claude Sonnet 4.5");
expect(title).not.toHaveTextContent("Fusion");
});
it("shows model tag in thread header when non-fn session has model", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Agent Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null;
expect(headerModelTag).toBeInTheDocument();
expect(headerModelTag?.textContent).toContain("Claude");
});
it("does not show duplicate model tag in thread header for fn agent sessions", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Test Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const title = document.querySelector(".chat-thread-header-title") as HTMLElement | null;
expect(title).toHaveTextContent("Claude Sonnet 4.5");
const headerModelTag = document.querySelector(".chat-thread-header .chat-model-tag") as HTMLElement | null;
expect(headerModelTag).toBeNull();
});
it("does not show model tag when session has no model", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Test Chat",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag).not.toBeInTheDocument();
});
it("shows model tag in message avatar when non-fn session has model", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Agent Chat",
modelProvider: "openai",
modelId: "gpt-4o",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")?.textContent).toContain("GPT");
});
it("does not show duplicate model tag in message avatar for fn agent sessions", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Test Chat",
modelProvider: "openai",
modelId: "gpt-4o",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
expect(within(avatar!).getByText("GPT-4o")).toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
});
});
describe("formatModelTag helper function", () => {
// Import the function for testing - we'll test it via the UI behavior instead
// The function is not exported, so we test it indirectly through the component
it("formats claude-sonnet-4-5 model ID correctly", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag?.textContent).toContain("Claude Sonnet");
});
it("formats gpt-4o model ID correctly", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test",
modelProvider: "openai",
modelId: "gpt-4o",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag?.textContent).toContain("GPT-4o");
});
it("formats gemini-2.5-pro model ID correctly", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test",
modelProvider: "google",
modelId: "gemini-2.5-pro",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag?.textContent).toContain("Gemini");
});
it("returns null when modelId is missing", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Test",
modelProvider: "anthropic",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag).not.toBeInTheDocument();
});
it("returns null when provider is missing", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Test",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag).not.toBeInTheDocument();
});
});
describe("Chat Session Delete Button", () => {
it("renders delete button on each session item", () => {
setupMockChat({
sessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
],
filteredSessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat 1", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Test Chat 2", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const deleteButtons = screen.getAllByTestId("chat-session-delete-btn");
expect(deleteButtons.length).toBe(2);
});
it("clicking delete button shows confirmation dialog", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const deleteButton = screen.getByTestId("chat-session-delete-btn");
await userEvent.click(deleteButton);
// Dialog should be open
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
expect(dialog).toBeInTheDocument();
expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument();
});
it("clicking delete button does not select the session", async () => {
const selectSession = vi.fn();
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
selectSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const deleteButton = screen.getByTestId("chat-session-delete-btn");
await userEvent.click(deleteButton);
expect(selectSession).not.toHaveBeenCalled();
});
it("confirming delete calls deleteSession", async () => {
const deleteSession = vi.fn();
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
deleteSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const deleteButton = screen.getByTestId("chat-session-delete-btn");
await userEvent.click(deleteButton);
// Click confirm in dialog
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
await userEvent.click(within(dialog!).getByText("Delete"));
expect(deleteSession).toHaveBeenCalledWith("session-001");
});
});
describe("Chat Session Delete Button CSS", () => {
const css = loadAllAppCss();
it(".chat-session-delete-btn exists with opacity: 0", () => {
const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/);
expect(match).toBeTruthy();
expect(match![1]).toContain("opacity: 0");
});
it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", () => {
const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/);
expect(match).toBeTruthy();
expect(match![1]).toContain("opacity: 1");
});
it("mobile override makes delete button always visible", () => {
// Find all mobile media query blocks and check if any has chat-session-delete-btn with opacity: 1
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
let match;
let foundMobileDeleteBtn = false;
while ((match = mobileRegex.exec(css)) !== null) {
const mediaContent = match[1];
if (mediaContent.includes(".chat-session-delete-btn")) {
const deleteBtnMatch = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/);
if (deleteBtnMatch && deleteBtnMatch[1].includes("opacity: 1")) {
foundMobileDeleteBtn = true;
break;
}
}
}
expect(foundMobileDeleteBtn).toBe(true);
});
});
describe("ChatView CSS — nested flexbox scrolling fix", () => {
const css = loadAllAppCss();
it(".chat-session-list has min-height: 0 for proper vertical scrolling", () => {
const match = css.match(/\.chat-session-list\s*\{([^}]*)\}/);
expect(match).toBeTruthy();
expect(match![1]).toContain("min-height: 0");
});
it(".chat-thread has min-height: 0 for proper vertical scrolling", () => {
const match = css.match(/\.chat-thread\s*\{([^}]*)\}/);
expect(match).toBeTruthy();
expect(match![1]).toContain("min-height: 0");
});
it(".chat-messages has min-height: 0 for proper vertical scrolling", () => {
const match = css.match(/\.chat-messages\s*\{([^}]*)\}/);
expect(match).toBeTruthy();
expect(match![1]).toContain("min-height: 0");
});
});
describe("ChatView project-scoped agent fetching", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchDiscoveredSkills.mockResolvedValue([]);
});
it("passes projectId to fetchAgents in agent name resolution effect", async () => {
// Mock useChat to return empty agentsMap so ChatView fetches its own
setupMockChat({ agentsMap: new Map() });
render(<ChatView projectId="proj-456" addToast={vi.fn()} />);
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-456");
});
});
it("passes projectId to NewChatDialog for agent selection", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-789" addToast={vi.fn()} />);
// Open the new chat dialog
await userEvent.click(screen.getByTestId("chat-new-btn"));
// The dialog should have been rendered with projectId
// We verify the mock fetchAgents was called with the correct projectId
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-789");
});
});
it("refetches agents when projectId changes in ChatView", async () => {
// First render with proj-001
setupMockChat({ agentsMap: new Map() });
const { rerender } = render(<ChatView projectId="proj-001" addToast={vi.fn()} />);
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001");
});
const callsBeforeRerender = vi.mocked(apiModule.fetchAgents).mock.calls.length;
// Rerender with proj-002
rerender(<ChatView projectId="proj-002" addToast={vi.fn()} />);
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002");
});
// Should have made an additional fetch call
expect(vi.mocked(apiModule.fetchAgents).mock.calls.length).toBeGreaterThan(callsBeforeRerender);
});
it("refetches agents when projectId changes in NewChatDialog", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const { rerender } = render(<ChatView projectId="proj-001" addToast={vi.fn()} />);
// Open dialog and check initial projectId
await userEvent.click(screen.getByTestId("chat-new-btn"));
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001");
});
// Close dialog, change projectId, reopen
// Note: we need to trigger a new dialog render with the new projectId
rerender(<ChatView projectId="proj-002" addToast={vi.fn()} />);
// Close and reopen dialog
const closeBtn = document.querySelector(".chat-new-dialog-backdrop") as HTMLElement | null;
if (closeBtn) {
await userEvent.click(closeBtn);
}
await userEvent.click(screen.getByTestId("chat-new-btn"));
await waitFor(() => {
expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002");
});
});
});
describe("ChatView sidebar structure", () => {
it("renders sidebar sections without an empty header spacer", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-sidebar")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-search")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-list")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-footer")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-header")).not.toBeInTheDocument();
});
it("renders desktop header New Chat button", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
});
it("renders mobile footer New Chat button", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
});
it("opens new chat dialog when clicking mobile footer New Chat button", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
expect(dialog).toBeInTheDocument();
});
it("session list has both chat-session-list and chat-sidebar-list classes", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionList = document.querySelector(".chat-session-list") as HTMLElement | null;
expect(sessionList).toBeInTheDocument();
expect(sessionList).toHaveClass("chat-sidebar-list");
});
});
describe("resizable sidebar", () => {
beforeEach(() => {
localStorage.clear();
});
it("renders desktop resize handle with separator ARIA attributes", () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
expect(handle).toHaveAttribute("aria-orientation", "vertical");
expect(handle).toHaveAttribute("aria-valuemin", "180");
expect(handle).toHaveAttribute("aria-valuemax", "500");
expect(handle).toHaveAttribute("aria-valuenow", "280");
expect(handle).toHaveAttribute("tabindex", "0");
viewportSpy.mockRestore();
});
it("updates sidebar width while dragging", () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
expect(sidebar.style.width).toBe("360px");
expect(handle).toHaveAttribute("aria-valuenow", "360");
viewportSpy.mockRestore();
});
it("clamps width between min and max", () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 });
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("180px");
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 });
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("500px");
viewportSpy.mockRestore();
});
it("persists width to localStorage on pointer up", () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
act(() => {
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 360 });
});
expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("360");
viewportSpy.mockRestore();
});
it("restores persisted width on mount", () => {
const viewportSpy = mockViewportMode("desktop");
localStorage.setItem("fusion:chat-sidebar-width", "350");
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px");
viewportSpy.mockRestore();
});
it("does not render resize handle on mobile", () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
viewportSpy.mockRestore();
});
});
describe("thread header New Chat button", () => {
const activeSession = { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
it("renders New Chat button in thread header on desktop when session is active", () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ activeSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const btn = screen.getByTestId("chat-thread-new-chat-btn");
expect(btn).toBeInTheDocument();
expect(btn).toHaveTextContent("New Chat");
expect(btn).toHaveClass("btn", "btn-sm", "btn-primary");
viewportSpy.mockRestore();
});
it("clicking thread header New Chat button opens the NewChatDialog", () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ activeSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const btn = screen.getByTestId("chat-thread-new-chat-btn");
fireEvent.click(btn);
expect(screen.getByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("does not render New Chat button in thread header on mobile", () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ activeSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull();
viewportSpy.mockRestore();
});
});
describe("ChatView mobile behavior", () => {
let savedVisualViewport: typeof window.visualViewport;
let savedInnerHeight: number;
let savedOntouchstart: typeof window.ontouchstart;
beforeEach(() => {
savedVisualViewport = window.visualViewport;
savedInnerHeight = window.innerHeight;
savedOntouchstart = window.ontouchstart;
});
afterEach(() => {
Object.defineProperty(window, "visualViewport", {
value: savedVisualViewport,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: savedInnerHeight,
writable: true,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: savedOntouchstart,
writable: true,
configurable: true,
});
});
function mockMobileVisualViewport({
innerHeight,
vvHeight,
}: {
innerHeight: number;
vvHeight: number;
}) {
(window as any).ontouchstart = null;
Object.defineProperty(window, "innerHeight", {
value: innerHeight,
writable: true,
configurable: true,
});
const listeners: Record<string, Array<() => void>> = {
resize: [],
scroll: [],
};
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: 0,
offsetLeft: 0,
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event]?.push(cb);
}),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, "visualViewport", {
value: mockVV,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockMobileViewport() {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function mockDesktopViewport() {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
it("mobile mode: does not render thread header when no active session (list view)", () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
activeSession: null,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Thread header should not be rendered when there's no active session
expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument();
// Back button should not be visible
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: renders thread header with back button when session is active", () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Thread header should be rendered when there's an active session
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should be visible in mobile thread view
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: tapping back button calls selectSession with empty string to return to list", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
selectSession,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const backBtn = screen.getByTestId("chat-back-btn");
await userEvent.click(backBtn);
// Back button should trigger selectSession("") to return to list view
expect(selectSession).toHaveBeenCalledWith("");
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: send button sends on first touch and keeps composer focused", async () => {
const restoreMatchMedia = mockMobileViewport();
const sendMessage = vi.fn();
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [],
sendMessage,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Hello mobile" } });
input.focus();
const sendButton = screen.getByTestId("chat-send-btn");
fireEvent.touchStart(sendButton);
fireEvent.click(sendButton);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith("Hello mobile", []);
expect(document.activeElement).toBe(input);
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: sets and clears keyboard overlap CSS vars on chat thread", async () => {
const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
Object.defineProperty(window, "innerHeight", {
value: 560,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 560,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
});
Object.defineProperty(mockVV, "height", {
value: 844,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: applies --vv-height when keyboard opens with zero overlap (iOS last-resort signal)", async () => {
const restoreMatchMedia = mockMobileViewport();
_resetInitialViewportHeight();
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 800,
});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
// Focus the chat textarea so the hook treats the active element as a
// keyboard-focusable target — this is what unlocks the iOS last-resort
// signal where viewport shrinks but offsetTop+height closes the gap.
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
textarea.focus();
// iOS scenario: vv.height shrinks by 16px, vv.offsetTop also = 16.
// Both chromeOverlap (innerHeight - offsetTop - height) and the gap
// measurement are 0, but baselineHeight - vv.height = 16 trips the
// "viewport shrank" branch with overlap=0, keyboardOpen=true.
Object.defineProperty(mockVV, "height", { value: 784, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 16, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
// The thread style is gated on keyboardOpen (not keyboardOverlap > 0),
// so --vv-height must still be applied even when the computed overlap
// collapses to zero. Regression guard for the gating change in
// ChatView.tsx:766.
await waitFor(() => {
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
expect(thread.style.getPropertyValue("--vv-height")).toBe("784px");
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: scrolls messages container to bottom when keyboard opens", async () => {
const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 800,
});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
expect(messagesContainer).toBeInTheDocument();
Object.defineProperty(messagesContainer, "scrollHeight", {
value: 900,
configurable: true,
});
messagesContainer.scrollTop = 0;
Object.defineProperty(window, "innerHeight", {
value: 560,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 560,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(messagesContainer.scrollTop).toBe(900);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: does not force window scroll when keyboard opens", async () => {
const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 800,
});
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
Object.defineProperty(window, "innerHeight", {
value: 560,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 560,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(scrollToSpy).not.toHaveBeenCalled();
});
} finally {
scrollToSpy.mockRestore();
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: does not subscribe to keyboard tracking without active session", async () => {
const restoreMatchMedia = mockMobileViewport();
const { mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 600,
});
try {
setupMockChat({
activeSession: null,
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(mockVV.addEventListener).not.toHaveBeenCalled();
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("desktop mode: renders thread header even without active session (shows empty state)", () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
activeSession: null,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Desktop mode: thread header should always be visible (even in empty state)
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should not be visible in desktop mode
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
// Should show empty state
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("desktop mode: thread header is visible with active session", () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Desktop mode: thread header should always be visible
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should not be visible in desktop mode
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
});
describe("ChatView mobile CSS contract", () => {
const css = loadAllAppCss();
// Helper to find a selector rule within any mobile media query block
function findMobileRule(selector: string): string | null {
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
let match;
while ((match = mobileRegex.exec(css)) !== null) {
const mediaContent = match[1];
if (mediaContent.includes(selector)) {
const ruleMatch = mediaContent.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
if (ruleMatch) return ruleMatch[1];
}
}
return null;
}
// Helper to check if any mobile media query contains a selector with a specific property
function mobileRuleContains(selector: string, property: string): boolean {
const ruleCSS = findMobileRule(selector);
return ruleCSS !== null && ruleCSS.includes(property);
}
// Helper to check if a selector does NOT contain a property in any mobile media query
function mobileRuleNotContains(selector: string, property: string): boolean {
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
let match;
while ((match = mobileRegex.exec(css)) !== null) {
const mediaContent = match[1];
if (mediaContent.includes(selector)) {
const ruleMatch = mediaContent.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
if (ruleMatch && ruleMatch[1].includes(property)) {
return false;
}
}
}
return true;
}
it("mobile .chat-sidebar uses height: 100% instead of max-height: 40vh", () => {
expect(mobileRuleContains(".chat-sidebar", "height: 100%")).toBe(true);
expect(mobileRuleNotContains(".chat-sidebar", "max-height: 40vh")).toBe(true);
});
it("mobile .chat-sidebar-header is hidden", () => {
expect(mobileRuleContains(".chat-sidebar-header", "display: none")).toBe(true);
});
it("mobile .chat-sidebar-search is hidden", () => {
expect(mobileRuleContains(".chat-sidebar-search", "display: none")).toBe(true);
});
it("mobile .chat-sidebar-list has flex: 1 and overflow-y: auto for scrolling", () => {
expect(mobileRuleContains(".chat-sidebar-list", "flex: 1")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-list", "overflow-y: auto")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-list", "min-height: 0")).toBe(true);
});
it("mobile .chat-sidebar-footer exists with display: flex and border-top", () => {
expect(mobileRuleContains(".chat-sidebar-footer", "display: flex")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-footer", "border-top")).toBe(true);
});
it("mobile .chat-sidebar-footer-btn has flex: 1 for full-width button", () => {
expect(mobileRuleContains(".chat-sidebar-footer-btn", "flex: 1")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-footer-btn", "justify-content: center")).toBe(true);
});
it("mobile does not override assistant render toggle visibility", () => {
expect(mobileRuleNotContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true);
});
it("mobile keeps ChatView dialog backdrop centered with safe-area padding", () => {
expect(mobileRuleContains(".chat-view-dialog-backdrop", "align-items: center")).toBe(true);
expect(mobileRuleContains(".chat-view-dialog-backdrop", "padding-top: max(var(--space-md), env(safe-area-inset-top, 0px))")).toBe(true);
expect(mobileRuleContains(".chat-view-dialog-backdrop", "padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px))")).toBe(true);
});
it("mobile constrains ChatView dialog height and allows internal scrolling", () => {
expect(mobileRuleContains(".chat-view-dialog", "max-height: calc(100dvh - (var(--space-md) * 2) - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))")).toBe(true);
expect(mobileRuleContains(".chat-view-dialog", "overflow-y: auto")).toBe(true);
});
it("mobile ChatView dialog rules do not set full-screen heights", () => {
expect(mobileRuleNotContains(".chat-view-dialog", "height: 100vh")).toBe(true);
expect(mobileRuleNotContains(".chat-view-dialog", "height: 100dvh")).toBe(true);
});
it("mobile includes keyboard-aware chat-thread height rule", () => {
expect(css).toMatch(/\.chat-thread\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]*--vv-height/);
});
});