4761 lines
187 KiB
TypeScript
4761 lines
187 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 { readFileSync } from "node:fs";
|
|
import { useState } from "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";
|
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
|
|
|
// 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";
|
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
|
|
|
// Mock the hooks
|
|
vi.mock("../../hooks/useChat");
|
|
vi.mock("../../hooks/useChatRooms");
|
|
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
|
return {
|
|
...actual,
|
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
|
};
|
|
});
|
|
|
|
const mockUseChat = vi.mocked(useChatModule.useChat);
|
|
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
|
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
|
const mockCreateObjectURL = vi.fn();
|
|
const mockRevokeObjectURL = vi.fn();
|
|
const mockClipboardWriteText = 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} />,
|
|
Copy: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-copy"} {...props} />,
|
|
Check: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-check"} {...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 defaultRoomsState: UseChatRoomsResult = {
|
|
rooms: [],
|
|
roomsLoading: false,
|
|
roomsError: null,
|
|
activeRoom: null,
|
|
activeRoomMembers: [],
|
|
messages: [],
|
|
messagesLoading: false,
|
|
selectRoom: vi.fn(),
|
|
createRoom: vi.fn(),
|
|
deleteRoom: vi.fn(),
|
|
sendRoomMessage: vi.fn(),
|
|
refreshRooms: vi.fn(),
|
|
};
|
|
|
|
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 setupMockRooms(overrides: Partial<UseChatRoomsResult> = {}) {
|
|
const state: UseChatRoomsResult = { ...defaultRoomsState, ...overrides };
|
|
mockUseChatRooms.mockReturnValue(state);
|
|
}
|
|
|
|
function createRoomFixture(name: string) {
|
|
return {
|
|
id: `room-${name}`,
|
|
projectId: "proj-123",
|
|
slug: name,
|
|
name,
|
|
createdAt: "2026-05-12T00:00:00.000Z",
|
|
updatedAt: "2026-05-12T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function setupStatefulCreateRoomMock(options?: { createRejects?: boolean }) {
|
|
const createRoom = vi.fn();
|
|
|
|
mockUseChatRooms.mockImplementation(() => {
|
|
const [roomsState, setRoomsState] = useState<UseChatRoomsResult["rooms"]>([]);
|
|
const [activeRoom, setActiveRoom] = useState<UseChatRoomsResult["activeRoom"]>(null);
|
|
|
|
return {
|
|
...defaultRoomsState,
|
|
rooms: roomsState,
|
|
activeRoom,
|
|
activeRoomMembers: activeRoom
|
|
? [{ roomId: activeRoom.id, agentId: "agent-001", role: "member", addedAt: "2026-05-12T00:00:00.000Z" }]
|
|
: [],
|
|
createRoom: async ({ name, memberAgentIds }) => {
|
|
createRoom({ name, memberAgentIds });
|
|
if (options?.createRejects) {
|
|
throw new Error("Failed to create room.");
|
|
}
|
|
const nextRoom = createRoomFixture(name);
|
|
setRoomsState((previous) => [...previous, nextRoom]);
|
|
setActiveRoom(nextRoom);
|
|
return nextRoom;
|
|
},
|
|
selectRoom: (roomId) => {
|
|
setActiveRoom(roomsState.find((room) => room.id === roomId) ?? null);
|
|
},
|
|
} satisfies UseChatRoomsResult;
|
|
});
|
|
|
|
return { createRoom };
|
|
}
|
|
|
|
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();
|
|
localStorage.clear();
|
|
_resetInitialViewportHeight();
|
|
setupMockRooms();
|
|
mockViewportMode("desktop");
|
|
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 });
|
|
mockClipboardWriteText.mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
value: { writeText: mockClipboardWriteText },
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
_resetInitialViewportHeight();
|
|
});
|
|
|
|
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("renders file paths in assistant inline code as clickable links while preserving the code wrapper", async () => {
|
|
const openFile = vi.fn();
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "see `packages/foo/bar.ts:42` for details", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserProvider openFile={openFile}>
|
|
<ChatView projectId="proj-123" addToast={vi.fn()} />
|
|
</FileBrowserProvider>,
|
|
);
|
|
|
|
const fileLink = screen.getByRole("button", { name: "packages/foo/bar.ts:42" });
|
|
const code = fileLink.closest("code");
|
|
expect(code).toBeTruthy();
|
|
expect(code?.querySelector("button.file-path-link")).toBe(fileLink);
|
|
|
|
await userEvent.click(fileLink);
|
|
expect(openFile).toHaveBeenCalledWith("packages/foo/bar.ts", { line: 42, col: undefined });
|
|
});
|
|
|
|
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("thread-header toggle flips every assistant bubble between rendered Markdown and plain text", async () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "**Second** item", createdAt: "2026-04-08T00:01:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const firstBubble = screen.getByTestId("chat-message-msg-001");
|
|
const secondBubble = screen.getByTestId("chat-message-msg-002");
|
|
const headerToggle = screen.getByTestId("chat-thread-render-toggle");
|
|
|
|
// Per-message toggles were intentionally removed; only the single
|
|
// thread-level toggle should exist.
|
|
expect(screen.queryAllByTestId("chat-message-render-toggle")).toHaveLength(0);
|
|
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
|
|
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
|
|
|
|
await userEvent.click(headerToggle);
|
|
|
|
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
|
|
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
|
|
expect(within(secondBubble).getByText(/\*\*Second\*\* item/)).toBeInTheDocument();
|
|
expect(within(secondBubble).queryByText("Second", { selector: "strong" })).toBeNull();
|
|
|
|
await userEvent.click(headerToggle);
|
|
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
|
|
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("thread-header toggle also drives the streaming bubble", async () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
isStreaming: true,
|
|
streamingText: "**Live** stream",
|
|
});
|
|
|
|
render(<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 headerToggle = screen.getByTestId("chat-thread-render-toggle");
|
|
|
|
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
|
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
|
|
|
|
await userEvent.click(headerToggle);
|
|
|
|
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
|
|
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
|
|
|
|
await userEvent.click(headerToggle);
|
|
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
|
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders tool calls from persisted messages", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{
|
|
id: "msg-002",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "I used a tool",
|
|
toolCalls: [
|
|
{
|
|
toolName: "read",
|
|
args: { path: "foo.ts" },
|
|
isError: false,
|
|
result: "contents",
|
|
status: "completed",
|
|
},
|
|
],
|
|
createdAt: "2026-04-08T00:01:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<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);
|
|
|
|
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
|
|
expect(summary).toBeInTheDocument();
|
|
expect(summary.querySelector(".chat-tool-calls-count")).toHaveTextContent("2 tool calls");
|
|
expect(summary.querySelector(".chat-tool-calls-names")).toHaveTextContent("read, grep");
|
|
});
|
|
|
|
it("auto-opens grouped tool calls when any tool call is running", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{
|
|
id: "msg-002",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Running",
|
|
toolCalls: [
|
|
{
|
|
toolName: "read",
|
|
isError: false,
|
|
status: "running",
|
|
},
|
|
{
|
|
toolName: "grep",
|
|
isError: false,
|
|
result: "done",
|
|
status: "completed",
|
|
},
|
|
],
|
|
createdAt: "2026-04-08T00:01:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<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);
|
|
expect(details?.querySelector(".chat-tool-call-name")).toHaveTextContent("read");
|
|
expect(details?.querySelector(".chat-tool-call-status-text")).toHaveTextContent("completed");
|
|
});
|
|
|
|
it("truncates tool names when more than 5 unique", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{
|
|
id: "msg-002",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Done",
|
|
toolCalls: [
|
|
{ toolName: "read", isError: false, status: "completed" },
|
|
{ toolName: "edit", isError: false, status: "completed" },
|
|
{ toolName: "bash", isError: false, status: "completed" },
|
|
{ toolName: "grep", isError: false, status: "completed" },
|
|
{ toolName: "write", isError: false, status: "completed" },
|
|
{ toolName: "list", isError: false, status: "completed" },
|
|
],
|
|
createdAt: "2026-04-08T00:01:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<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("hides per-message assistant identity for fn agent (model-only) sessions", () => {
|
|
// Model-only chats use the active model as their identity, which is
|
|
// already shown in the thread header. We deliberately suppress the
|
|
// per-message avatar to avoid repeating it on every reply.
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Built-in assistant response", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
|
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
|
|
});
|
|
|
|
it("hides per-message assistant identity for fn agent (model-only) sessions even when a model is configured", async () => {
|
|
setupMockChat({
|
|
activeSession: {
|
|
id: "session-001",
|
|
agentId: "__fn_agent__",
|
|
status: "active",
|
|
title: "Fusion Chat",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Built-in assistant response", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
|
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
|
|
// The model name still appears once in the thread header.
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows copy actions only for assistant responses in provider/model chats", () => {
|
|
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-user", sessionId: "session-001", role: "user", content: "Question", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
{ id: "msg-assistant", sessionId: "session-001", role: "assistant", content: "Answer", createdAt: "2026-04-08T00:00:01.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("chat-copy-response-msg-assistant")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("chat-copy-response-msg-user")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("copies raw provider response content and shows feedback for success/failure", 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-assistant", sessionId: "session-001", role: "assistant", content: "**Raw** output", createdAt: "2026-04-08T00:00:01.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const copyButton = screen.getByTestId("chat-copy-response-msg-assistant");
|
|
expect(copyButton).not.toHaveTextContent("Copy");
|
|
await userEvent.click(copyButton);
|
|
|
|
expect(mockClipboardWriteText).toHaveBeenCalledWith("**Raw** output");
|
|
expect(screen.getByLabelText("Response copied")).toBeInTheDocument();
|
|
|
|
mockClipboardWriteText.mockRejectedValueOnce(new Error("denied"));
|
|
await userEvent.click(screen.getByTestId("chat-copy-response-msg-assistant"));
|
|
expect(screen.getByLabelText("Copy failed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders assistant failure bubbles inline with detail affordances", 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-failure",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Model request failed",
|
|
failureInfo: {
|
|
summary: "Model request failed",
|
|
errorClass: "ProviderError",
|
|
code: "E_MODEL",
|
|
detail: "ProviderError: Model request failed",
|
|
reference: { kind: "mailbox", id: "msg-42", label: "Mailbox message msg-42" },
|
|
},
|
|
createdAt: "2026-04-08T00:00:01.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messageBubble = screen.getByTestId("chat-message-msg-failure");
|
|
expect(messageBubble).toHaveClass("chat-message--failure");
|
|
expect(within(messageBubble).getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("Response failed")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("ProviderError")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("E_MODEL")).toBeInTheDocument();
|
|
expect(within(messageBubble).queryByTestId("chat-copy-response-msg-failure")).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(within(messageBubble).getByText("Failure details"));
|
|
|
|
expect(within(messageBubble).getByText("ProviderError: Model request failed")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("Mailbox message msg-42")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByRole("link", { name: "Open mailbox message" })).toHaveAttribute(
|
|
"href",
|
|
"/?view=mailbox&mailbox-message=msg-42#message-msg-42",
|
|
);
|
|
expect(messageBubble.querySelector(".status-dot.status-dot--error")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a generic failure reference details affordance for non-mailbox references", 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-run-failure",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Run failed",
|
|
failureInfo: {
|
|
summary: "Run failed",
|
|
reference: { kind: "agent-run", id: "run-42", label: "Agent run 42" },
|
|
},
|
|
createdAt: "2026-04-08T00:00:02.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messageBubble = screen.getByTestId("chat-message-msg-run-failure");
|
|
await userEvent.click(within(messageBubble).getByText("Failure details"));
|
|
await userEvent.click(within(messageBubble).getByText("View failure details"));
|
|
|
|
expect(within(messageBubble).getAllByText("Agent run 42")).toHaveLength(2);
|
|
expect(within(messageBubble).getByText("Kind")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("agent-run")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("ID")).toBeInTheDocument();
|
|
expect(within(messageBubble).getByText("run-42")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows streaming copy action for provider chats", () => {
|
|
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: [],
|
|
isStreaming: true,
|
|
streamingText: "Live answer",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("chat-copy-response-streaming")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show copy actions for non-provider sessions", () => {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [
|
|
{ id: "msg-assistant", sessionId: "session-001", role: "assistant", content: "Answer", createdAt: "2026-04-08T00:00:01.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "Live answer",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("chat-copy-response-msg-assistant")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("chat-copy-response-streaming")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows resolved agent name in streaming assistant avatar", async () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Agent Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Think", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "Thinking...",
|
|
});
|
|
|
|
render(<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("intercepts exact /new 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, " /new {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 /new 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, "/new now{enter}");
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith("/new now", []);
|
|
expect(createSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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("clears room composer on Enter after successful room send", async () => {
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
setupMockRooms({
|
|
activeRoom: {
|
|
id: "room-001",
|
|
projectId: "proj-123",
|
|
name: "backend",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
sendRoomMessage,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await userEvent.type(textarea, "Room hello{enter}");
|
|
|
|
await waitFor(() => {
|
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room hello", { files: [] });
|
|
});
|
|
expect(textarea.value).toBe("");
|
|
localStorage.removeItem("fusion:chat-scope");
|
|
});
|
|
|
|
it("clears room composer on send button click after successful room send", async () => {
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
|
const sendMessage = vi.fn();
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
|
setupMockRooms({
|
|
activeRoom: {
|
|
id: "room-001",
|
|
projectId: "proj-123",
|
|
name: "backend",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
sendRoomMessage,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await userEvent.type(textarea, "Room click hello");
|
|
await userEvent.click(screen.getByTestId("chat-send-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(sendRoomMessage).toHaveBeenCalledWith("Room click hello", { files: [] });
|
|
});
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
expect(textarea.value).toBe("");
|
|
localStorage.removeItem("fusion:chat-scope");
|
|
});
|
|
|
|
it("keeps direct chat send behavior unchanged when chat rooms are enabled", async () => {
|
|
localStorage.setItem("fusion:chat-scope", "direct");
|
|
const sendMessage = vi.fn();
|
|
const sendRoomMessage = vi.fn();
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [],
|
|
sendMessage,
|
|
});
|
|
setupMockRooms({ sendRoomMessage });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await userEvent.type(textarea, "Direct hello{enter}");
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith("Direct hello", []);
|
|
expect(sendRoomMessage).not.toHaveBeenCalled();
|
|
expect(textarea.value).toBe("");
|
|
localStorage.removeItem("fusion:chat-scope");
|
|
});
|
|
|
|
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("uses room member ordering in popup and marks non-member mention chips in room messages", async () => {
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
setupMockRooms({
|
|
activeRoom: {
|
|
id: "room-001",
|
|
slug: "engineering",
|
|
name: "engineering",
|
|
createdBy: "agent-001",
|
|
status: "active",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
activeRoomMembers: [
|
|
{ roomId: "room-001", agentId: "agent-001", role: "member", addedAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
messages: [
|
|
{
|
|
id: "room-msg-1",
|
|
roomId: "room-001",
|
|
role: "user",
|
|
content: "Ping @Alpha and @Beta",
|
|
senderAgentId: "agent-001",
|
|
metadata: null,
|
|
attachments: [],
|
|
mentions: ["agent-001", "agent-002"],
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
const allCss = await loadAllAppCss();
|
|
const style = document.createElement("style");
|
|
style.textContent = allCss;
|
|
document.head.appendChild(style);
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "@");
|
|
|
|
expect(await screen.findByTestId("agent-mention-members-header")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument();
|
|
|
|
const bubble = screen.getByText("Ping", { exact: false }).closest(".chat-message--user");
|
|
expect(bubble).toBeTruthy();
|
|
|
|
const memberChip = screen.getByText("@Alpha", { selector: ".chat-mention-chip" });
|
|
const nonMemberChip = screen.getByText("@Beta", { selector: ".chat-mention-chip--non-member" });
|
|
expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering");
|
|
|
|
// FN-4520: member mention chip text must not visually collapse into sent-bubble background.
|
|
expect(getComputedStyle(memberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor);
|
|
// FN-4520: non-member mention chip text must remain legible inside sent bubbles.
|
|
expect(getComputedStyle(nonMemberChip).color).not.toBe(getComputedStyle(bubble as Element).backgroundColor);
|
|
});
|
|
|
|
it("renders assistant mentions as plain text in markdown mode", async () => {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [
|
|
{
|
|
id: "msg-001",
|
|
sessionId: "session-001",
|
|
role: "assistant",
|
|
content: "Talk to @Alpha and @Unknown next.",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull();
|
|
expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("slash skill autocomplete", () => {
|
|
it("shows the skill menu when typing slash in the chat input", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/");
|
|
|
|
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
|
|
expect(screen.getByText("refactor/code")).toBeInTheDocument();
|
|
});
|
|
|
|
it("filters discovered skills from slash input", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
|
createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/re");
|
|
|
|
expect(await screen.findByText("review/pr")).toBeInTheDocument();
|
|
expect(screen.queryByText("deploy/app")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("inserts /skill command when clicking a menu item", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/re");
|
|
|
|
await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i }));
|
|
|
|
expect(textarea).toHaveValue("/skill:review/pr ");
|
|
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("supports arrow navigation with wrapping and Enter selection", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),
|
|
createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }),
|
|
createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/");
|
|
await screen.findByRole("option", { name: /alpha/i });
|
|
|
|
// Wrap to bottom from the first item.
|
|
await userEvent.keyboard("{ArrowUp}");
|
|
expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass(
|
|
"chat-skill-menu-item--highlighted",
|
|
);
|
|
|
|
await userEvent.keyboard("{Enter}");
|
|
expect(textarea).toHaveValue("/skill:gamma ");
|
|
});
|
|
|
|
it("supports selecting highlighted skill with Tab", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),
|
|
createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/");
|
|
await screen.findByRole("option", { name: /alpha/i });
|
|
|
|
await userEvent.keyboard("{ArrowDown}");
|
|
expect(screen.getByRole("option", { name: /beta/i })).toHaveClass(
|
|
"chat-skill-menu-item--highlighted",
|
|
);
|
|
|
|
await userEvent.keyboard("{Tab}");
|
|
expect(textarea).toHaveValue("/skill:beta ");
|
|
});
|
|
|
|
it("closes the menu when pressing Escape", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/");
|
|
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
|
|
|
|
await userEvent.keyboard("{Escape}");
|
|
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("closes the menu when slash trigger pattern no longer matches", async () => {
|
|
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
|
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
|
]);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/re");
|
|
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
|
|
|
|
await userEvent.type(textarea, " ");
|
|
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows loading indicator while discovered skills are still loading", async () => {
|
|
let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined;
|
|
mockFetchDiscoveredSkills.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSkills = resolve;
|
|
}),
|
|
);
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/");
|
|
|
|
expect(await screen.findByText("Loading skills…")).toBeInTheDocument();
|
|
|
|
resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("review/pr")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("does not crash when discovered skills fail to load", async () => {
|
|
mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable"));
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
await userEvent.type(textarea, "/");
|
|
|
|
expect(await screen.findByText("No skills available")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("disables send button when input is empty", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const sendButton = screen.getByTestId("chat-send-btn");
|
|
expect(sendButton).toBeDisabled();
|
|
});
|
|
|
|
it("renders stop button when streaming", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [],
|
|
isStreaming: true,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("chat-stop-btn")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("chat-send-btn")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking stop button calls stopStreaming", async () => {
|
|
const stopStreaming = vi.fn();
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [],
|
|
isStreaming: true,
|
|
stopStreaming,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-stop-btn"));
|
|
expect(stopStreaming).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders send button when not streaming", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [],
|
|
isStreaming: false,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders pending message indicator and dismisses it", async () => {
|
|
const clearPendingMessage = vi.fn();
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [],
|
|
pendingMessage: "Queued while streaming",
|
|
clearPendingMessage,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming");
|
|
|
|
await userEvent.click(screen.getByTestId("chat-pending-dismiss"));
|
|
expect(clearPendingMessage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("textarea is enabled during streaming", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "Thinking...",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
expect(textarea).not.toBeDisabled();
|
|
});
|
|
|
|
it("user can type while streaming", async () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "Thinking...",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const textarea = screen.getByTestId("chat-input");
|
|
|
|
// User should be able to type in the textarea while streaming
|
|
fireEvent.change(textarea, { target: { value: "Second message" } });
|
|
expect((textarea as HTMLTextAreaElement).value).toBe("Second message");
|
|
});
|
|
|
|
it("shows streaming indicator when isStreaming is true", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "Typing...",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
// Streaming message should show
|
|
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
|
|
expect(streamingMessage).toBeInTheDocument();
|
|
expect(streamingMessage?.textContent).toContain("Typing");
|
|
});
|
|
|
|
it("shows thinking blocks collapsed by default", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const message = screen.getByTestId("chat-message-msg-001");
|
|
const details = message.querySelector("details");
|
|
expect(details).toBeInTheDocument();
|
|
expect(details).toHaveProperty("open", false);
|
|
});
|
|
|
|
describe("streaming states", () => {
|
|
it("keeps mobile thread visible when active session metadata refreshes during streaming", () => {
|
|
const mediaQuerySpy = mockViewportMode("mobile");
|
|
const streamingState: UseChatReturn = {
|
|
...defaultChatState,
|
|
sessions: [{ ...activeSessionFixture }],
|
|
filteredSessions: [{ ...activeSessionFixture }],
|
|
activeSession: { ...activeSessionFixture },
|
|
messages: [],
|
|
isStreaming: true,
|
|
streamingText: "",
|
|
streamingThinking: "",
|
|
};
|
|
const refreshedStreamingState: UseChatReturn = {
|
|
...streamingState,
|
|
sessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }],
|
|
filteredSessions: [{ ...activeSessionFixture, updatedAt: "2026-04-08T00:05:00.000Z" }],
|
|
activeSession: null,
|
|
};
|
|
|
|
mockUseChat
|
|
.mockReturnValueOnce(streamingState)
|
|
.mockReturnValue(refreshedStreamingState);
|
|
|
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Connecting");
|
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(document.querySelector(".chat-message--streaming")?.textContent).toContain("Connecting");
|
|
expect(screen.queryByText("Start a new conversation")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("No messages yet. Start the conversation!")).not.toBeInTheDocument();
|
|
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
|
|
|
|
void mediaQuerySpy;
|
|
});
|
|
|
|
it("keeps the streaming indicator visible while message history is still loading", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [],
|
|
messagesLoading: true,
|
|
isStreaming: true,
|
|
streamingText: "",
|
|
streamingThinking: "",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
|
|
expect(streamingMessage).toBeInTheDocument();
|
|
expect(streamingMessage?.textContent).toContain("Connecting");
|
|
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows waiting indicator when streaming starts before text arrives", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "",
|
|
streamingThinking: "",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
// Streaming message should show with "Connecting..." text
|
|
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
|
|
expect(streamingMessage).toBeInTheDocument();
|
|
expect(streamingMessage?.textContent).toContain("Connecting");
|
|
|
|
// Waiting class should be present
|
|
const waitingContent = streamingMessage?.querySelector(".chat-message-content--waiting");
|
|
expect(waitingContent).toBeInTheDocument();
|
|
|
|
// Typing indicator dots should be rendered
|
|
const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator");
|
|
expect(typingIndicator).toBeInTheDocument();
|
|
expect(typingIndicator?.querySelectorAll("span").length).toBe(3);
|
|
});
|
|
|
|
it("shows thinking indicator when streaming thinking arrives before text", () => {
|
|
setupMockChat({
|
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
isStreaming: true,
|
|
streamingText: "",
|
|
streamingThinking: "analyzing the request...",
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
// Streaming message should show with "Thinking..." text
|
|
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
|
|
expect(streamingMessage).toBeInTheDocument();
|
|
expect(streamingMessage?.textContent).toContain("Thinking");
|
|
|
|
// Thinking details should be rendered
|
|
const thinkingDetails = streamingMessage?.querySelector("details.chat-message-thinking");
|
|
expect(thinkingDetails).toBeInTheDocument();
|
|
expect(thinkingDetails?.querySelector(".chat-message-thinking-content")?.textContent).toContain("analyzing the request");
|
|
|
|
// Typing indicator dots should be rendered
|
|
const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator");
|
|
expect(typingIndicator).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("filters sessions by search query", async () => {
|
|
setupMockChat({
|
|
sessions: [
|
|
{ id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
{ id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
|
|
],
|
|
filteredSessions: [
|
|
{ id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
searchQuery: "frontend",
|
|
setSearchQuery: vi.fn(),
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Frontend work")).toBeInTheDocument();
|
|
expect(screen.queryByText("Backend API")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows empty state with Start Chat button (no inline agent selector)", () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
|
|
// Find the New Chat button in the empty state section
|
|
const emptyStateText = screen.getByText("Start a new conversation");
|
|
const emptyState = emptyStateText.closest(".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("keeps provider identity text grouped in header while render toggle stays on the same row", () => {
|
|
setupMockChat({
|
|
activeSession: {
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
status: "active",
|
|
title: "Agent Chat",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const header = document.querySelector(".chat-thread-header") as HTMLElement | null;
|
|
const identity = screen.getByTestId("chat-thread-header-identity");
|
|
const toggle = screen.getByTestId("chat-thread-render-toggle");
|
|
const providerIcon = identity.querySelector(".provider-icon");
|
|
const modelTag = identity.querySelector(".chat-model-tag");
|
|
const newChatButton = screen.getByTestId("chat-thread-new-chat-btn");
|
|
|
|
expect(header).toBeInTheDocument();
|
|
expect(providerIcon).toBeInTheDocument();
|
|
expect(within(identity).getByText("Agent Chat")).toBeInTheDocument();
|
|
expect(modelTag).toBeInTheDocument();
|
|
expect(modelTag).toHaveTextContent("Claude Sonnet 4.5");
|
|
expect(toggle).toBeInTheDocument();
|
|
expect(header?.children[header.children.length - 2]).toBe(toggle);
|
|
expect(header?.children[header.children.length - 1]).toBe(newChatButton);
|
|
expect(document.querySelectorAll(".chat-thread-header .chat-model-tag")).toHaveLength(1);
|
|
});
|
|
|
|
it("does not show model tag when session has no model", () => {
|
|
setupMockChat({
|
|
activeSession: {
|
|
id: "session-001",
|
|
agentId: "__fn_agent__",
|
|
status: "active",
|
|
title: "Test Chat",
|
|
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
|
|
expect(modelTag).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not repeat the model tag in per-message avatars for non-fn sessions", () => {
|
|
// Per-message model tags were intentionally removed — the model is shown
|
|
// once in the thread header. The avatar should still render with the
|
|
// agent name (no agent identity collapse for real agents) but no model
|
|
// tag inside it.
|
|
setupMockChat({
|
|
activeSession: {
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
status: "active",
|
|
title: "Agent Chat",
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
|
const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null;
|
|
expect(avatar).toBeInTheDocument();
|
|
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
|
|
});
|
|
|
|
it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", () => {
|
|
setupMockChat({
|
|
activeSession: {
|
|
id: "session-001",
|
|
agentId: "__fn_agent__",
|
|
status: "active",
|
|
title: "Test Chat",
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
|
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("formatModelTag helper function", () => {
|
|
// Import the function for testing - we'll test it via the UI behavior instead
|
|
// The function is not exported, so we test it indirectly through the component
|
|
|
|
it("formats claude-sonnet-4-5 model ID correctly", () => {
|
|
setupMockChat({
|
|
activeSession: {
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
status: "active",
|
|
title: "Test",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<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("ChatView CSS — failure bubble contracts", () => {
|
|
const css = loadAllAppCss();
|
|
|
|
it("uses shared error surface tokens for failure bubbles and detail affordances", () => {
|
|
const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/);
|
|
const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/);
|
|
const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/);
|
|
const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/);
|
|
|
|
expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)");
|
|
expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)");
|
|
expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
|
|
expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
|
|
expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
|
|
});
|
|
});
|
|
|
|
describe("FN-3911 chat session list layout", () => {
|
|
const css = loadAllAppCss();
|
|
|
|
it("reserves right padding on title and preview rows so text clears the delete button", () => {
|
|
const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/);
|
|
const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/);
|
|
expect(titleMatch).toBeTruthy();
|
|
expect(previewMatch).toBeTruthy();
|
|
expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/);
|
|
expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/);
|
|
});
|
|
|
|
it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", () => {
|
|
expect(css).toMatch(
|
|
/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/,
|
|
);
|
|
});
|
|
});
|
|
|
|
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("FN-4352: mobile delete button stays visible without min-size inflation", () => {
|
|
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
|
|
let match;
|
|
let deleteRule = "";
|
|
while ((match = mobileRegex.exec(css)) !== null) {
|
|
const mediaContent = match[1];
|
|
if (mediaContent.includes(".chat-session-delete-btn")) {
|
|
deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? "";
|
|
if (deleteRule) break;
|
|
}
|
|
}
|
|
|
|
expect(deleteRule).toContain("opacity: 1");
|
|
expect(deleteRule).not.toContain("min-width:");
|
|
expect(deleteRule).not.toContain("min-height:");
|
|
});
|
|
});
|
|
|
|
describe("ChatView CSS — mobile thread switcher", () => {
|
|
const css = loadAllAppCss();
|
|
|
|
it("includes mobile session switcher trigger and dropdown tokenized contracts", () => {
|
|
const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/);
|
|
const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/);
|
|
const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/);
|
|
const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/);
|
|
const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/);
|
|
expect(triggerMatch).toBeTruthy();
|
|
expect(triggerIconMatch).toBeTruthy();
|
|
expect(dropdownMatch).toBeTruthy();
|
|
expect(optionMatch).toBeTruthy();
|
|
expect(optionTitleMatch).toBeTruthy();
|
|
expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))");
|
|
expect(triggerMatch?.[1]).toContain("min-width: 0");
|
|
expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)");
|
|
expect(triggerMatch?.[1]).toContain("font: inherit");
|
|
expect(triggerMatch?.[1]).toContain("line-height: normal");
|
|
expect(triggerMatch?.[1]).toContain("text-align: left");
|
|
expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)");
|
|
expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)");
|
|
expect(dropdownMatch?.[1]).toContain("background: var(--surface)");
|
|
expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)");
|
|
expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)");
|
|
expect(optionMatch?.[1]).toContain("align-items: flex-start");
|
|
expect(optionMatch?.[1]).toContain("line-height: normal");
|
|
expect(optionTitleMatch?.[1]).toContain("display: block");
|
|
expect(optionTitleMatch?.[1]).toContain("line-height: normal");
|
|
expect(optionTitleMatch?.[1]).toContain("white-space: normal");
|
|
expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere");
|
|
});
|
|
|
|
it("keeps mobile override for header identity overflow visible so dropdown can render", () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*overflow:\s*visible;/);
|
|
});
|
|
});
|
|
|
|
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 in Direct scope", () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("hides mobile footer New Chat button in Rooms scope", async () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
expect(screen.queryByTestId("chat-new-btn")).not.toBeInTheDocument();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("opens new chat dialog when clicking mobile footer New Chat button in Direct scope", async () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
|
|
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();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
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("room creation", () => {
|
|
it("opens the newly created room and collapses the mobile sidebar on success", async () => {
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
const { createRoom } = setupStatefulCreateRoomMock();
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
|
|
const dialog = await screen.findByRole("dialog", { name: "Create room" });
|
|
await userEvent.type(within(dialog).getByLabelText("Room name"), "newroom");
|
|
await userEvent.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
|
|
|
|
await waitFor(() => {
|
|
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
|
|
});
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--hidden");
|
|
});
|
|
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
|
|
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("opens the newly created room on desktop without hiding the sidebar", async () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
const { createRoom } = setupStatefulCreateRoomMock();
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
|
|
const dialog = await screen.findByRole("dialog", { name: "Create room" });
|
|
await userEvent.type(within(dialog).getByLabelText("Room name"), "newroom");
|
|
await userEvent.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
|
|
|
|
await waitFor(() => {
|
|
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
|
|
});
|
|
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
|
|
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
|
|
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("keeps the modal open and sidebar visible when room creation fails", async () => {
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
const { createRoom } = setupStatefulCreateRoomMock({ createRejects: true });
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
|
|
const dialog = await screen.findByRole("dialog", { name: "Create room" });
|
|
await userEvent.type(within(dialog).getByLabelText("Room name"), "newroom");
|
|
await userEvent.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha"));
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
|
|
|
|
await waitFor(() => {
|
|
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
|
|
});
|
|
expect(screen.getByRole("dialog", { name: "Create room" })).toBeInTheDocument();
|
|
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
|
|
expect(screen.queryByText("#newroom")).toBeNull();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Direct/Rooms scope toggle", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("hides rooms UI when chatRooms experimental flag is off", () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
|
|
|
|
expect(screen.queryByTestId("chat-sidebar-scope-rooms")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("chat-sidebar-rooms")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("defaults to Direct with sidebar list visible", () => {
|
|
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()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
expect(screen.getByTestId("chat-sidebar-scope-direct")).toHaveAttribute("aria-selected", "true");
|
|
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "false");
|
|
expect(document.querySelector(".chat-session-list")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("chat-sidebar-rooms-empty")).toBeNull();
|
|
});
|
|
|
|
it("shows rooms UI when chatRooms experimental flag is on", async () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows rooms placeholder and hides direct search/list in Rooms scope", 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()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
|
|
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
|
|
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
|
|
expect(document.querySelector(".chat-session-list")).toBeNull();
|
|
expect(screen.queryByTestId("chat-search-input")).toBeNull();
|
|
});
|
|
|
|
it("switching back to Direct restores search/list and keeps active session highlight", 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" },
|
|
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()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
|
|
|
|
expect(screen.getByTestId("chat-search-input")).toBeInTheDocument();
|
|
expect(document.querySelector(".chat-session-list")).toBeInTheDocument();
|
|
expect(screen.getByTestId("chat-session-session-001")).toHaveClass("chat-session-item--active");
|
|
});
|
|
|
|
it("FN-4327: switching scope from Rooms to Direct re-anchors direct thread", 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: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
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()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1200 });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
let scrollTopValue = 500;
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
fireEvent.scroll(messagesContainer);
|
|
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1200);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("forces direct scope when localStorage persisted rooms but chatRooms is off", () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
|
|
|
|
expect(screen.queryByTestId("chat-sidebar-scope-rooms")).not.toBeInTheDocument();
|
|
expect(screen.getByTestId("chat-search-input")).toBeInTheDocument();
|
|
});
|
|
|
|
it("persists scope in localStorage and restores Rooms on next mount", async () => {
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
const { unmount } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
expect(localStorage.getItem("fusion:chat-scope")).toBe("rooms");
|
|
|
|
unmount();
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
|
|
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("resizable sidebar", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("renders desktop resize handle with separator ARIA attributes", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
|
expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
|
expect(handle).toHaveAttribute("aria-valuemin", "180");
|
|
expect(handle).toHaveAttribute("aria-valuemax", "500");
|
|
expect(handle).toHaveAttribute("aria-valuenow", "280");
|
|
expect(handle).toHaveAttribute("tabindex", "0");
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("updates sidebar width while dragging", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
|
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
|
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
|
|
|
|
const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
|
|
expect(sidebar.style.width).toBe("360px");
|
|
expect(handle).toHaveAttribute("aria-valuenow", "360");
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("clamps width between min and max", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
|
|
|
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
|
fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 });
|
|
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("180px");
|
|
|
|
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
|
fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 });
|
|
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("500px");
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("persists width to localStorage on pointer up", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
|
|
act(() => {
|
|
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
|
|
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
|
|
fireEvent.pointerUp(document, { pointerId: 1, clientX: 360 });
|
|
});
|
|
|
|
expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("360");
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("restores persisted width on mount", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
localStorage.setItem("fusion:chat-sidebar-width", "350");
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px");
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("does not render resize handle on mobile", () => {
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("thread header New Chat button", () => {
|
|
const activeSession = { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
|
|
|
|
it("renders New Chat button in thread header on desktop when session is active", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
setupMockChat({ activeSession });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const btn = screen.getByTestId("chat-thread-new-chat-btn");
|
|
expect(btn).toBeInTheDocument();
|
|
expect(btn).toHaveTextContent("New Chat");
|
|
expect(btn).toHaveClass("btn", "btn-sm", "btn-primary");
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("clicking thread header New Chat button opens the NewChatDialog", () => {
|
|
const viewportSpy = mockViewportMode("desktop");
|
|
setupMockChat({ activeSession });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const btn = screen.getByTestId("chat-thread-new-chat-btn");
|
|
fireEvent.click(btn);
|
|
|
|
expect(screen.getByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
|
|
it("does not render New Chat button in thread header on mobile", () => {
|
|
const viewportSpy = mockViewportMode("mobile");
|
|
setupMockChat({ activeSession });
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull();
|
|
|
|
viewportSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("ChatView mobile behavior", () => {
|
|
let savedVisualViewport: typeof window.visualViewport;
|
|
let savedInnerHeight: number;
|
|
let savedOntouchstart: typeof window.ontouchstart;
|
|
|
|
beforeEach(() => {
|
|
_resetInitialViewportHeight();
|
|
savedVisualViewport = window.visualViewport;
|
|
savedInnerHeight = window.innerHeight;
|
|
savedOntouchstart = window.ontouchstart;
|
|
});
|
|
|
|
afterEach(() => {
|
|
_resetInitialViewportHeight();
|
|
Object.defineProperty(window, "visualViewport", {
|
|
value: savedVisualViewport,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
value: savedInnerHeight,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(window, "ontouchstart", {
|
|
value: savedOntouchstart,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
function mockMobileVisualViewport({
|
|
innerHeight,
|
|
vvHeight,
|
|
}: {
|
|
innerHeight: number;
|
|
vvHeight: number;
|
|
}) {
|
|
(window as any).ontouchstart = null;
|
|
Object.defineProperty(window, "innerHeight", {
|
|
value: innerHeight,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
const listeners: Record<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: thread header title opens quick session switcher and closes after selection", async () => {
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
const selectSession = vi.fn();
|
|
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" },
|
|
{ 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" },
|
|
],
|
|
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" },
|
|
selectSession,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const trigger = screen.getByTestId("chat-mobile-session-trigger");
|
|
expect(trigger).toHaveClass("btn", "chat-mobile-session-trigger");
|
|
expect(trigger).not.toHaveClass("btn-icon");
|
|
expect(trigger).toHaveTextContent("Test Chat");
|
|
|
|
await userEvent.click(trigger);
|
|
expect(screen.getByTestId("chat-mobile-session-dropdown")).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByTestId("chat-mobile-session-option-session-002"));
|
|
expect(selectSession).toHaveBeenCalledWith("session-002");
|
|
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("mobile mode: quick session switcher closes on outside click and is not shown for rooms", async () => {
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
try {
|
|
setupMockChat({ activeSession: activeSessionFixture });
|
|
const initialRender = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
|
|
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
|
|
expect(screen.getByTestId("chat-mobile-session-dropdown")).toBeInTheDocument();
|
|
|
|
fireEvent.mouseDown(document.body);
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
|
|
});
|
|
|
|
initialRender.unmount();
|
|
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
setupMockRooms({
|
|
activeRoom: {
|
|
id: "room-001",
|
|
projectId: "proj-123",
|
|
name: "backend",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument();
|
|
expect(screen.getByText("#backend")).toBeInTheDocument();
|
|
} finally {
|
|
localStorage.setItem("fusion:chat-scope", "direct");
|
|
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: room send button sends on first touch and keeps composer focused", async () => {
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
|
const sendMessage = vi.fn();
|
|
|
|
try {
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [],
|
|
sendMessage,
|
|
});
|
|
setupMockRooms({
|
|
activeRoom: {
|
|
id: "room-001",
|
|
projectId: "proj-123",
|
|
name: "backend",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
sendRoomMessage,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
fireEvent.change(input, { target: { value: "Hello mobile room" } });
|
|
input.focus();
|
|
|
|
const sendButton = screen.getByTestId("chat-send-btn");
|
|
fireEvent.touchStart(sendButton);
|
|
fireEvent.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(sendRoomMessage).toHaveBeenCalledTimes(1);
|
|
expect(sendRoomMessage).toHaveBeenCalledWith("Hello mobile room", { files: [] });
|
|
});
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
expect(document.activeElement).toBe(input);
|
|
} finally {
|
|
localStorage.removeItem("fusion:chat-scope");
|
|
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("");
|
|
|
|
// Focus the chat textarea so the hook treats the active element as a
|
|
// keyboard-focusable target.
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
textarea.focus();
|
|
});
|
|
act(() => {
|
|
document.dispatchEvent(new Event("focusin"));
|
|
});
|
|
|
|
Object.defineProperty(window, "innerHeight", {
|
|
value: 560,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(mockVV, "height", {
|
|
value: 560,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
act(() => {
|
|
for (const cb of listeners.resize) cb();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
|
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
|
});
|
|
|
|
// Blur to signal keyboard dismissal
|
|
textarea.blur();
|
|
|
|
Object.defineProperty(mockVV, "height", {
|
|
value: 844,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
act(() => {
|
|
for (const cb of listeners.resize) cb();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
|
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("mobile mode: applies keyboard-active class for iOS fallback when viewport offset is present", async () => {
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
const { listeners, mockVV } = mockMobileVisualViewport({
|
|
innerHeight: 800,
|
|
vvHeight: 800,
|
|
});
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
|
expect(thread).toBeInTheDocument();
|
|
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
|
|
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
textarea.focus();
|
|
});
|
|
act(() => {
|
|
document.dispatchEvent(new Event("focusin"));
|
|
});
|
|
|
|
Object.defineProperty(mockVV, "height", { value: 784, writable: true, configurable: true });
|
|
Object.defineProperty(mockVV, "offsetTop", { value: 16, writable: true, configurable: true });
|
|
|
|
act(() => {
|
|
for (const cb of listeners.resize) cb();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
|
|
expect(thread.style.getPropertyValue("--vv-height")).toBe("784px");
|
|
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("mobile mode: removes keyboard-active class immediately on blur even before visualViewport settles", async () => {
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
const { listeners, mockVV } = mockMobileVisualViewport({
|
|
innerHeight: 800,
|
|
vvHeight: 800,
|
|
});
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
|
expect(thread).toBeInTheDocument();
|
|
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
textarea.focus();
|
|
});
|
|
act(() => {
|
|
document.dispatchEvent(new Event("focusin"));
|
|
});
|
|
|
|
Object.defineProperty(mockVV, "height", { value: 560, writable: true, configurable: true });
|
|
Object.defineProperty(window, "innerHeight", { value: 560, writable: true, configurable: true });
|
|
|
|
act(() => {
|
|
for (const cb of listeners.resize) cb();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
|
});
|
|
|
|
textarea.blur();
|
|
act(() => {
|
|
document.dispatchEvent(new Event("focusout"));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("mobile mode: scrolls messages container to bottom when keyboard opens", async () => {
|
|
_resetInitialViewportHeight();
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
const { listeners, mockVV } = mockMobileVisualViewport({
|
|
innerHeight: 800,
|
|
vvHeight: 800,
|
|
});
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<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,
|
|
});
|
|
// In jsdom, scrollTop on a non-scrollable div may not reflect writes.
|
|
// Intercept the setter so the assertion can read back the value the effect wrote.
|
|
let capturedScrollTop = 0;
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
get() { return capturedScrollTop; },
|
|
set(v: number) { capturedScrollTop = v; },
|
|
configurable: true,
|
|
});
|
|
|
|
// Focus the chat textarea so the hook treats the active element as a
|
|
// keyboard-focusable target.
|
|
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
textarea.focus();
|
|
});
|
|
act(() => {
|
|
document.dispatchEvent(new Event("focusin"));
|
|
});
|
|
|
|
Object.defineProperty(window, "innerHeight", {
|
|
value: 560,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(mockVV, "height", {
|
|
value: 560,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
act(() => {
|
|
for (const cb of listeners.resize) cb();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(messagesContainer.scrollTop).toBe(900);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("mobile mode: does not force window scroll when keyboard opens", async () => {
|
|
const restoreMatchMedia = mockMobileViewport();
|
|
const { listeners, mockVV } = mockMobileVisualViewport({
|
|
innerHeight: 800,
|
|
vvHeight: 800,
|
|
});
|
|
|
|
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<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();
|
|
}
|
|
});
|
|
|
|
it("shows jump-to-latest only after scrolling away from bottom and jumps back on click", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1000 });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
scrollTopValue = 600;
|
|
fireEvent.scroll(messagesContainer);
|
|
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByTestId("chat-jump-to-latest"));
|
|
expect(scrollTopValue).toBe(1000);
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("shows jump-to-latest in rooms after scrolling away from bottom and jumps back on click", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
try {
|
|
setupMockChat({ activeSession: null, messages: [] });
|
|
setupMockRooms({
|
|
activeRoom: createRoomFixture("general"),
|
|
rooms: [createRoomFixture("general")],
|
|
messages: [
|
|
{
|
|
id: "room-msg-001",
|
|
roomId: "room-general",
|
|
role: "assistant",
|
|
content: "One",
|
|
thinkingOutput: null,
|
|
metadata: null,
|
|
senderAgentId: null,
|
|
mentions: [],
|
|
createdAt: "2026-05-12T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1000 });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
|
|
|
scrollTopValue = 600;
|
|
fireEvent.scroll(messagesContainer);
|
|
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByTestId("chat-jump-to-latest"));
|
|
expect(scrollTopValue).toBe(1000);
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
|
});
|
|
} finally {
|
|
localStorage.removeItem("fusion:chat-scope");
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-3884: snaps to bottom when opening a session with loaded messages", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 950 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(950);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-3884: re-anchors when messagesLoading transitions to loaded with messages", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({ activeSession: activeSessionFixture, messages: [], messagesLoading: true });
|
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 980 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messagesLoading: false,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Loaded", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(980);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-4040: mobile thread entry anchors to latest message", async () => {
|
|
const restoreMatchMedia = mockViewportMode("mobile");
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1040 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1040);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-4040: mobile visibility restore re-anchors chat thread to latest", async () => {
|
|
const restoreMatchMedia = mockViewportMode("mobile");
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 250;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1180 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
|
fireEvent(document, new Event("visibilitychange"));
|
|
scrollTopValue = 300;
|
|
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
fireEvent(document, new Event("visibilitychange"));
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1180);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
}
|
|
});
|
|
|
|
it("FN-4336: direct chat re-anchors on ResizeObserver growth in mobile", async () => {
|
|
const restoreMatchMedia = mockViewportMode("mobile");
|
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
let resizeCallback: ResizeObserverCallback | null = null;
|
|
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
resizeCallback = callback;
|
|
}
|
|
observe = vi.fn();
|
|
disconnect = vi.fn();
|
|
unobserve = vi.fn();
|
|
},
|
|
);
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
let scrollHeightValue = 1000;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1000);
|
|
});
|
|
|
|
scrollHeightValue = 1300;
|
|
await act(async () => {
|
|
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1300);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
if (originalResizeObserver) {
|
|
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("FN-4336: direct chat ResizeObserver growth keeps thread pinned", async () => {
|
|
const restoreMatchMedia = mockViewportMode("mobile");
|
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
let resizeCallback: ResizeObserverCallback | null = null;
|
|
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
resizeCallback = callback;
|
|
}
|
|
observe = vi.fn();
|
|
disconnect = vi.fn();
|
|
unobserve = vi.fn();
|
|
},
|
|
);
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
let scrollHeightValue = 2000;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
fireEvent.scroll(messagesContainer);
|
|
scrollHeightValue = 2400;
|
|
|
|
await act(async () => {
|
|
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(2400);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
if (originalResizeObserver) {
|
|
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("FN-4336: visibility restore performs deferred direct chat settle pass", async () => {
|
|
const restoreMatchMedia = mockViewportMode("mobile");
|
|
vi.useFakeTimers();
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
let scrollHeightValue = 1000;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
fireEvent(document, new Event("visibilitychange"));
|
|
await act(async () => {
|
|
vi.runOnlyPendingTimers();
|
|
});
|
|
|
|
scrollHeightValue = 1500;
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(260);
|
|
vi.runOnlyPendingTimers();
|
|
});
|
|
|
|
expect(scrollTopValue).toBe(1500);
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
vi.useRealTimers();
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
}
|
|
});
|
|
|
|
it("FN-4336: rooms scope does not attach direct ResizeObserver follower", async () => {
|
|
const restoreMatchMedia = mockViewportMode("mobile");
|
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
const resizeObserverCtor = vi.fn();
|
|
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
resizeObserverCtor(callback);
|
|
}
|
|
observe = vi.fn();
|
|
disconnect = vi.fn();
|
|
unobserve = vi.fn();
|
|
},
|
|
);
|
|
|
|
try {
|
|
setupMockChat({ activeSession: null, messages: [] });
|
|
setupMockRooms({
|
|
activeRoom: createRoomFixture("general"),
|
|
rooms: [createRoomFixture("general")],
|
|
messages: [
|
|
{
|
|
id: "room-msg-001",
|
|
roomId: "room-general",
|
|
role: "assistant",
|
|
content: "Room message",
|
|
thinkingOutput: null,
|
|
metadata: null,
|
|
senderAgentId: null,
|
|
mentions: [],
|
|
createdAt: "2026-05-12T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
|
|
});
|
|
expect(resizeObserverCtor).not.toHaveBeenCalled();
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
if (originalResizeObserver) {
|
|
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("FN-4336: desktop direct chat still re-anchors on ResizeObserver growth", async () => {
|
|
const restoreMatchMedia = mockViewportMode("desktop");
|
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
let resizeCallback: ResizeObserverCallback | null = null;
|
|
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
resizeCallback = callback;
|
|
}
|
|
observe = vi.fn();
|
|
disconnect = vi.fn();
|
|
unobserve = vi.fn();
|
|
},
|
|
);
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
let scrollHeightValue = 1200;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 300 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1200);
|
|
});
|
|
|
|
scrollHeightValue = 1700;
|
|
await act(async () => {
|
|
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1700);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
if (originalResizeObserver) {
|
|
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("FN-4327: desktop visibility restore re-anchors direct chat and resets jump-to-latest state", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 600;
|
|
let scrollHeightValue = 1200;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
fireEvent.scroll(messagesContainer);
|
|
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
|
|
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
fireEvent(document, new Event("visibilitychange"));
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1200);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
|
});
|
|
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z" },
|
|
],
|
|
});
|
|
scrollTopValue = 700;
|
|
scrollHeightValue = 1300;
|
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1300);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
}
|
|
});
|
|
|
|
it("FN-4327: desktop pageshow re-anchors direct chat thread", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 420;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1280 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
fireEvent(window, new Event("pageshow"));
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1280);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-3884: retries bottom anchor while container height keeps growing", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
const originalRaf = window.requestAnimationFrame;
|
|
const rafQueue: FrameRequestCallback[] = [];
|
|
window.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
|
|
rafQueue.push(cb);
|
|
return rafQueue.length;
|
|
});
|
|
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
let scrollHeightValue = 600;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
scrollHeightValue = 900;
|
|
while (rafQueue.length > 0) {
|
|
const cb = rafQueue.shift();
|
|
cb?.(performance.now());
|
|
}
|
|
|
|
expect(scrollTopValue).toBe(900);
|
|
} finally {
|
|
window.requestAnimationFrame = originalRaf;
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-3884: snaps to bottom when switching active session id", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 0;
|
|
let scrollHeightValue = 900;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
setupMockChat({
|
|
activeSession: { ...activeSessionFixture, id: "session-002" },
|
|
messages: [{ id: "msg-101", sessionId: "session-002", role: "assistant", content: "Two", createdAt: "2026-04-08T00:01:00.000Z" }],
|
|
});
|
|
scrollHeightValue = 1300;
|
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(1300);
|
|
});
|
|
} finally {
|
|
restoreMatchMedia.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("FN-3884: does not yank when user scrolled up on same-session updates", async () => {
|
|
const restoreMatchMedia = mockDesktopViewport();
|
|
try {
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
|
});
|
|
|
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
let scrollTopValue = 700;
|
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1200 });
|
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
|
configurable: true,
|
|
get: () => scrollTopValue,
|
|
set: (value: number) => {
|
|
scrollTopValue = value;
|
|
},
|
|
});
|
|
|
|
fireEvent.scroll(messagesContainer);
|
|
|
|
setupMockChat({
|
|
activeSession: activeSessionFixture,
|
|
messages: [
|
|
{ id: "msg-000", sessionId: "session-001", role: "assistant", content: "Older", createdAt: "2026-04-07T23:59:00.000Z" },
|
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(scrollTopValue).toBe(700);
|
|
});
|
|
} 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 remains visible (FN-4120)", () => {
|
|
expect(mobileRuleNotContains(".chat-sidebar-search", "display: none")).toBe(true);
|
|
});
|
|
|
|
it("mobile .chat-sidebar-search keeps a token-based touch target (FN-4120)", () => {
|
|
expect(mobileRuleContains(".chat-sidebar-search", "min-height: calc(var(--space-2xl) + var(--space-xs))")).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 block and border-top", () => {
|
|
expect(mobileRuleContains(".chat-sidebar-footer", "display: block")).toBe(true);
|
|
expect(mobileRuleContains(".chat-sidebar-footer", "border-top")).toBe(true);
|
|
});
|
|
|
|
it("mobile .chat-sidebar-footer-btn stays full-width and centered", () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-sidebar-footer\s+\.chat-sidebar-footer-btn\s*\{[^}]*width:\s*100%[^}]*justify-content:\s*center/);
|
|
});
|
|
|
|
it("mobile does not override assistant render toggle visibility", () => {
|
|
expect(mobileRuleNotContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true);
|
|
});
|
|
|
|
it("mobile keeps ChatView dialog backdrop centered with safe-area padding", () => {
|
|
expect(mobileRuleContains(".chat-view-dialog-backdrop", "align-items: center")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog-backdrop", "justify-content: center")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog-backdrop", "overflow-y: auto")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog-backdrop", "padding-top: max(var(--space-md), env(safe-area-inset-top, 0px))")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog-backdrop", "padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px))")).toBe(true);
|
|
});
|
|
|
|
it("mobile constrains ChatView dialog height and allows internal scrolling", () => {
|
|
expect(mobileRuleContains(".chat-view-dialog", "max-height: calc(100dvh - (var(--space-md) * 2) - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog", "display: flex")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog", "flex-direction: column")).toBe(true);
|
|
expect(mobileRuleContains(".chat-view-dialog", "overflow-y: auto")).toBe(true);
|
|
});
|
|
|
|
it("mobile ChatView dialog rules do not set full-screen heights", () => {
|
|
expect(mobileRuleNotContains(".chat-view-dialog", "height: 100vh")).toBe(true);
|
|
expect(mobileRuleNotContains(".chat-view-dialog", "height: 100dvh")).toBe(true);
|
|
});
|
|
|
|
it("mobile includes keyboard-aware chat-thread height rule", () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s*\{[^}]*--vv-height/);
|
|
});
|
|
|
|
it("mobile widens chat bubbles for readability", () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/);
|
|
});
|
|
|
|
it("mobile keeps thread-header identity and render toggle inline", () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header\s*\{[^}]*flex-wrap:\s*nowrap/);
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*flex:\s*1\s+1\s+auto[^}]*white-space:\s*nowrap/);
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-render-toggle\s*\{[^}]*flex-shrink:\s*0/);
|
|
});
|
|
|
|
it("FN-4352: response copy action stays compact on mobile", () => {
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*opacity:\s*1/);
|
|
expect(css).not.toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*min-width:\s*calc\(var\(--space-lg\)\s*\*\s*2\.25\)/);
|
|
expect(css).not.toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*min-height:\s*calc\(var\(--space-lg\)\s*\*\s*2\.25\)/);
|
|
});
|
|
});
|
|
|
|
describe("ChatView empty-state token guards", () => {
|
|
it("renders loading and empty states with chat-empty-state class and no inline text-secondary style", () => {
|
|
setupMockChat({
|
|
sessions: [],
|
|
filteredSessions: [],
|
|
sessionsLoading: true,
|
|
activeSession: activeSessionFixture,
|
|
messages: [],
|
|
messagesLoading: true,
|
|
});
|
|
|
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const loadingNodes = screen.getAllByText("Loading messages...");
|
|
const sidebarLoadingNode = screen.getByText("Loading...");
|
|
|
|
const legacyToken = `--text-${"secondary"}`;
|
|
expect(sidebarLoadingNode.className).toContain("chat-empty-state");
|
|
expect(sidebarLoadingNode.getAttribute("style") ?? "").not.toContain(legacyToken);
|
|
|
|
for (const node of loadingNodes) {
|
|
expect(node.className).toContain("chat-empty-state");
|
|
expect(node.getAttribute("style") ?? "").not.toContain(legacyToken);
|
|
}
|
|
});
|
|
|
|
it("keeps ChatView source files free of deprecated secondary token", () => {
|
|
const chatViewTsx = readFileSync("app/components/ChatView.tsx", "utf8");
|
|
const chatViewCss = readFileSync("app/components/ChatView.css", "utf8");
|
|
const legacyToken = `--text-${"secondary"}`;
|
|
|
|
expect(chatViewTsx.includes(legacyToken)).toBe(false);
|
|
expect(chatViewCss.includes(legacyToken)).toBe(false);
|
|
});
|
|
});
|