Files
fusion/packages/dashboard/app/components/__tests__/ChatView.test.tsx
Fusion 07a2a42808 feat(FN-3242): add documentation for compact mobile chat dialogs
Adds documentation for the compact mobile chat dialogs feature (FN-3242) to the dashboard guide.

Fusion-Task-Id: FN-3242
2026-05-03 06:53:42 -07:00

2901 lines
113 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("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/);
});
});