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