/** * 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(); return { ...actual, MessageSquare: ({ "data-testid": testId, ...props }: any) => ( ), Send: ({ "data-testid": testId, ...props }: any) => , Plus: ({ "data-testid": testId, ...props }: any) => , Search: ({ "data-testid": testId, ...props }: any) => , Trash2: ({ "data-testid": testId, ...props }: any) => , Archive: ({ "data-testid": testId, ...props }: any) => , ChevronLeft: ({ "data-testid": testId, ...props }: any) => , Bot: ({ "data-testid": testId, ...props }: any) => , Square: ({ "data-testid": testId, ...props }: any) => , Eye: ({ "data-testid": testId, ...props }: any) => , EyeOff: ({ "data-testid": testId, ...props }: any) => , Paperclip: ({ "data-testid": testId, ...props }: any) => , File: ({ "data-testid": testId, ...props }: any) => , }; }); // 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; }) => ( ), })); // 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 { 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 = {}) { 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(); 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(); 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(); 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(); 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(); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.queryByTestId("chat-render-mode-markdown")).not.toBeInTheDocument(); expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument(); }); it("thread-header toggle flips every assistant bubble between rendered Markdown and plain text", 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(); const firstBubble = screen.getByTestId("chat-message-msg-001"); const secondBubble = screen.getByTestId("chat-message-msg-002"); const headerToggle = screen.getByTestId("chat-thread-render-toggle"); // Per-message toggles were intentionally removed; only the single // thread-level toggle should exist. expect(screen.queryAllByTestId("chat-message-render-toggle")).toHaveLength(0); expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument(); expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument(); await userEvent.click(headerToggle); expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument(); expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull(); expect(within(secondBubble).getByText(/\*\*Second\*\* item/)).toBeInTheDocument(); expect(within(secondBubble).queryByText("Second", { selector: "strong" })).toBeNull(); await userEvent.click(headerToggle); expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument(); expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument(); }); it("thread-header toggle also drives the streaming bubble", 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(); const persistedBubble = screen.getByTestId("chat-message-msg-001"); const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement; const headerToggle = screen.getByTestId("chat-thread-render-toggle"); expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument(); expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument(); await userEvent.click(headerToggle); expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument(); expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument(); await userEvent.click(headerToggle); expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument(); expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).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(); 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(); 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(); const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement; expect(group).toBeInTheDocument(); expect(group.open).toBe(false); const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement; expect(summary).toBeInTheDocument(); expect(summary.querySelector(".chat-tool-calls-count")).toHaveTextContent("2 tool calls"); expect(summary.querySelector(".chat-tool-calls-names")).toHaveTextContent("read, grep"); }); 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(); 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(); 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(); 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(); 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(); 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); expect(details?.querySelector(".chat-tool-call-name")).toHaveTextContent("read"); expect(details?.querySelector(".chat-tool-call-status-text")).toHaveTextContent("completed"); }); 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(); 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(); 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(); 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(); 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("hides per-message assistant identity for fn agent (model-only) sessions", () => { // Model-only chats use the active model as their identity, which is // already shown in the thread header. We deliberately suppress the // per-message avatar to avoid repeating it on every reply. 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(); const messageBubble = screen.getByTestId("chat-message-msg-001"); expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); }); it("hides per-message assistant identity for fn agent (model-only) sessions even when a model is configured", 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(); const messageBubble = screen.getByTestId("chat-message-msg-001"); expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); // The model name still appears once in the thread header. await waitFor(() => { expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument(); }); }); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); // 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(); 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 mobile thread visible when active session metadata refreshes during streaming", () => { const mediaQuerySpy = mockViewportMode("mobile"); const streamingState: UseChatReturn = { ...defaultChatState, sessions: [{ ...activeSessionFixture }], filteredSessions: [{ ...activeSessionFixture }], activeSession: { ...activeSessionFixture }, messages: [], isStreaming: true, streamingText: "", streamingThinking: "", }; const refreshedStreamingState: UseChatReturn = { ...streamingState, sessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], filteredSessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }], activeSession: null, }; mockUseChat .mockReturnValueOnce(streamingState) .mockReturnValue(refreshedStreamingState); const { rerender } = render(); expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Connecting"); rerender(); expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Connecting"); expect(screen.queryByText("Start a new conversation")).not.toBeInTheDocument(); expect(screen.queryByText("No messages yet. Start the conversation!")).not.toBeInTheDocument(); expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); void mediaQuerySpy; }); 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(); 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(); // 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(); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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("keeps provider identity text grouped in header while render toggle stays on the same row", () => { 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: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" }, ], }); render(); const header = document.querySelector(".chat-thread-header") as HTMLElement | null; const identity = screen.getByTestId("chat-thread-header-identity"); const toggle = screen.getByTestId("chat-thread-render-toggle"); const providerIcon = identity.querySelector(".provider-icon"); const modelTag = identity.querySelector(".chat-model-tag"); expect(header).toBeInTheDocument(); expect(providerIcon).toBeInTheDocument(); expect(within(identity).getByText("Agent Chat")).toBeInTheDocument(); expect(modelTag).toBeInTheDocument(); expect(modelTag).toHaveTextContent("Claude Sonnet 4.5"); expect(toggle).toBeInTheDocument(); expect(header?.children[header.children.length - 1]).toBe(toggle); expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1); }); 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(); const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null; expect(modelTag).not.toBeInTheDocument(); }); it("does not repeat the model tag in per-message avatars for non-fn sessions", () => { // Per-message model tags were intentionally removed — the model is shown // once in the thread header. The avatar should still render with the // agent name (no agent identity collapse for real agents) but no model // tag inside it. 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(); const messageBubble = screen.getByTestId("chat-message-msg-001"); const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null; expect(avatar).toBeInTheDocument(); expect(avatar?.querySelector(".chat-model-tag")).toBeNull(); }); it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", () => { 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(); const messageBubble = screen.getByTestId("chat-message-msg-001"); expect(messageBubble.querySelector(".chat-message-avatar")).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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); await waitFor(() => { expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-456"); }); }); it("passes projectId to NewChatDialog for agent selection", async () => { setupMockChat({ sessions: [], filteredSessions: [] }); render(); // 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(); await waitFor(() => { expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001"); }); const callsBeforeRerender = vi.mocked(apiModule.fetchAgents).mock.calls.length; // Rerender with proj-002 rerender(); 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(); // 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(); // 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(); 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(); expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument(); }); it("renders mobile footer New Chat button", () => { setupMockChat({ sessions: [], filteredSessions: [] }); render(); expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument(); }); it("opens new chat dialog when clicking mobile footer New Chat button", async () => { setupMockChat({ sessions: [], filteredSessions: [] }); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull(); viewportSpy.mockRestore(); }); }); describe("thread header New Chat button", () => { const activeSession = { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; it("renders New Chat button in thread header on desktop when session is active", () => { const viewportSpy = mockViewportMode("desktop"); setupMockChat({ activeSession }); render(); const btn = screen.getByTestId("chat-thread-new-chat-btn"); expect(btn).toBeInTheDocument(); expect(btn).toHaveTextContent("New Chat"); expect(btn).toHaveClass("btn", "btn-sm", "btn-primary"); viewportSpy.mockRestore(); }); it("clicking thread header New Chat button opens the NewChatDialog", () => { const viewportSpy = mockViewportMode("desktop"); setupMockChat({ activeSession }); render(); const btn = screen.getByTestId("chat-thread-new-chat-btn"); fireEvent.click(btn); expect(screen.getByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument(); viewportSpy.mockRestore(); }); it("does not render New Chat button in thread header on mobile", () => { const viewportSpy = mockViewportMode("mobile"); setupMockChat({ activeSession }); render(); expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull(); viewportSpy.mockRestore(); }); }); describe("ChatView mobile behavior", () => { let savedVisualViewport: typeof window.visualViewport; let savedInnerHeight: number; let savedOntouchstart: typeof window.ontouchstart; beforeEach(() => { _resetInitialViewportHeight(); savedVisualViewport = window.visualViewport; savedInnerHeight = window.innerHeight; savedOntouchstart = window.ontouchstart; }); afterEach(() => { _resetInitialViewportHeight(); 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 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(); // 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(); // 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(); 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(); 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(); const thread = document.querySelector(".chat-thread") as HTMLDivElement; expect(thread).toBeInTheDocument(); expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe(""); // Focus the chat textarea so the hook treats the active element as a // keyboard-focusable target. const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { textarea.focus(); }); act(() => { document.dispatchEvent(new Event("focusin")); }); 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"); }); // Blur to signal keyboard dismissal textarea.blur(); 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 keyboard-active class for iOS fallback when viewport offset is present", 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(); const thread = document.querySelector(".chat-thread") as HTMLDivElement; expect(thread).toBeInTheDocument(); expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { textarea.focus(); }); act(() => { document.dispatchEvent(new Event("focusin")); }); 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(); }); await waitFor(() => { expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px"); expect(thread.style.getPropertyValue("--vv-height")).toBe("784px"); expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); }); } finally { restoreMatchMedia.mockRestore(); } }); it("mobile mode: removes keyboard-active class immediately on blur even before visualViewport settles", 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(); const thread = document.querySelector(".chat-thread") as HTMLDivElement; expect(thread).toBeInTheDocument(); const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { textarea.focus(); }); act(() => { document.dispatchEvent(new Event("focusin")); }); Object.defineProperty(mockVV, "height", { value: 560, writable: true, configurable: true }); Object.defineProperty(window, "innerHeight", { value: 560, writable: true, configurable: true }); act(() => { for (const cb of listeners.resize) cb(); }); await waitFor(() => { expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); }); textarea.blur(); act(() => { document.dispatchEvent(new Event("focusout")); }); await waitFor(() => { expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); }); } finally { restoreMatchMedia.mockRestore(); } }); it("mobile mode: scrolls messages container to bottom when keyboard opens", async () => { _resetInitialViewportHeight(); 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(); const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement; expect(messagesContainer).toBeInTheDocument(); Object.defineProperty(messagesContainer, "scrollHeight", { value: 900, configurable: true, }); // In jsdom, scrollTop on a non-scrollable div may not reflect writes. // Intercept the setter so the assertion can read back the value the effect wrote. let capturedScrollTop = 0; Object.defineProperty(messagesContainer, "scrollTop", { get() { return capturedScrollTop; }, set(v: number) { capturedScrollTop = v; }, configurable: true, }); // Focus the chat textarea so the hook treats the active element as a // keyboard-focusable target. const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { textarea.focus(); }); act(() => { document.dispatchEvent(new Event("focusin")); }); 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(); 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(); 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(); // 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(); // 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(); } }); it("shows jump-to-latest only after scrolling away from bottom and jumps back on click", async () => { const restoreMatchMedia = mockDesktopViewport(); try { setupMockChat({ activeSession: activeSessionFixture, messages: [ { id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }, ], }); render(); const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement; let scrollTopValue = 0; Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1000 }); Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 }); Object.defineProperty(messagesContainer, "scrollTop", { configurable: true, get: () => scrollTopValue, set: (value: number) => { scrollTopValue = value; }, }); scrollTopValue = 600; fireEvent.scroll(messagesContainer); expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument(); await userEvent.click(screen.getByTestId("chat-jump-to-latest")); expect(scrollTopValue).toBe(1000); expect(screen.queryByTestId("chat-jump-to-latest")).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", "justify-content: center")).toBe(true); expect(mobileRuleContains(".chat-view-dialog-backdrop", "overflow-y: auto")).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", "display: flex")).toBe(true); expect(mobileRuleContains(".chat-view-dialog", "flex-direction: column")).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(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s*\{[^}]*--vv-height/); }); it("mobile widens chat bubbles for readability", () => { expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[\s\S]*?max-width:\s*82%/); }); it("mobile keeps thread-header identity and render toggle inline", () => { expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header\s*\{[^}]*flex-wrap:\s*nowrap/); expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*flex:\s*1\s+1\s+auto[^}]*white-space:\s*nowrap/); expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-render-toggle\s*\{[^}]*flex-shrink:\s*0/); }); });