/** * Tests for ChatView component: sidebar, session list, message thread, * new chat dialog, and input handling. */ import fs from "node:fs"; import path from "node:path"; import { 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"; const stylesPath = path.resolve(__dirname, "../../styles.css"); // Mock scrollIntoView for JSDOM Element.prototype.scrollIntoView = vi.fn(); import * as useChatModule from "../../hooks/useChat"; // Mock the hooks vi.mock("../../hooks/useChat"); const mockUseChat = vi.mocked(useChatModule.useChat); // 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) => , }; }); // 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: {} }, ]), })); const defaultChatState = { sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false, isStreaming: false, streamingText: "", streamingThinking: "", selectSession: vi.fn(), createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__kb_agent__" }), archiveSession: vi.fn(), deleteSession: vi.fn(), sendMessage: vi.fn(), loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), filteredSessions: [], refreshSessions: vi.fn(), }; function setupMockChat(overrides: Partial = {}) { const state = { ...defaultChatState, ...overrides }; mockUseChat.mockReturnValue(state as any); } describe("ChatView", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.clearAllMocks(); }); 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", updatedAt: "2026-04-08T00:00:00.000Z" }, { id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", updatedAt: "2026-04-07T00:00:00.000Z" }, ], filteredSessions: [ { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }, { id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", 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", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }], activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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"); expect(dialog).toBeInTheDocument(); // Should show Agent label expect(within(dialog!).getByText("Agent")).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"); // 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"); // 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("renders messages for active session", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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("sends message on Enter key", async () => { const sendMessage = vi.fn(); setupMockChat({ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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", 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(); }); it("disables send button when input is empty", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }, messages: [], }); render(); const sendButton = screen.getByTestId("chat-send-btn"); expect(sendButton).toBeDisabled(); }); it("disables send button when streaming", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }, messages: [], isStreaming: true, }); render(); const sendButton = screen.getByTestId("chat-send-btn"); expect(sendButton).toBeDisabled(); }); it("shows streaming indicator when isStreaming is true", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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"); 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", 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 details = screen.getByText("Here's my response").parentElement?.querySelector("details"); expect(details).toBeInTheDocument(); expect(details).toHaveProperty("open", false); }); it("filters sessions by search query", async () => { setupMockChat({ sessions: [ { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", updatedAt: "2026-04-08T00:00:00.000Z" }, { id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", updatedAt: "2026-04-07T00:00:00.000Z" }, ], filteredSessions: [ { id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", 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"); 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", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", 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"); expect(dialog).toBeInTheDocument(); expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); }); it("shows Fusion label for kb agent sessions in sidebar", () => { setupMockChat({ sessions: [{ id: "session-001", agentId: "__kb_agent__", status: "active", title: "My Chat", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "__kb_agent__", status: "active", title: "My Chat", updatedAt: "2026-04-08T00:00:00.000Z" }], }); render(); const sessionItem = screen.getByTestId("chat-session-session-001"); // Should show "Fusion" instead of "__kb_agent__" expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument(); }); it("shows agent ID for non-kb agent sessions in sidebar", () => { setupMockChat({ sessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", updatedAt: "2026-04-08T00:00:00.000Z" }], filteredSessions: [{ id: "session-001", agentId: "my-custom-agent", status: "active", title: "Custom Chat", 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 model tag in thread header when session has model", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test Chat", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", 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"); expect(modelTag).toBeInTheDocument(); expect(modelTag?.textContent).toContain("Claude"); }); it("does not show model tag when session has no model", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test Chat", 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"); expect(modelTag).not.toBeInTheDocument(); }); it("shows model tag in message avatar when session has model", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test Chat", modelProvider: "openai", modelId: "gpt-4o", 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(); // Find the avatar with "Fusion" text const avatars = document.querySelectorAll(".chat-message-avatar"); expect(avatars.length).toBeGreaterThan(0); // Check that one avatar has the model tag const avatarWithModelTag = Array.from(avatars).find((avatar) => avatar.querySelector(".chat-model-tag"), ); expect(avatarWithModelTag).toBeTruthy(); expect(avatarWithModelTag?.querySelector(".chat-model-tag")?.textContent).toContain("GPT"); }); }); 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: "__kb_agent__", status: "active", title: "Test", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", 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"); expect(modelTag?.textContent).toContain("Claude Sonnet"); }); it("formats gpt-4o model ID correctly", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test", modelProvider: "openai", modelId: "gpt-4o", 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"); expect(modelTag?.textContent).toContain("GPT-4o"); }); it("formats gemini-2.5-pro model ID correctly", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test", modelProvider: "google", modelId: "gemini-2.5-pro", 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"); expect(modelTag?.textContent).toContain("Gemini"); }); it("returns null when modelId is missing", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test", modelProvider: "anthropic", 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"); expect(modelTag).not.toBeInTheDocument(); }); it("returns null when provider is missing", () => { setupMockChat({ activeSession: { id: "session-001", agentId: "__kb_agent__", status: "active", title: "Test", modelId: "claude-sonnet-4-5", 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"); expect(modelTag).not.toBeInTheDocument(); }); }); describe("ChatView CSS — nested flexbox scrolling fix", () => { const css = fs.readFileSync(stylesPath, "utf-8"); 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"); }); });