diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx similarity index 56% rename from packages/dashboard/app/components/__tests__/ChatView.test.tsx rename to packages/dashboard/app/components/__tests__/ChatView.core.test.tsx index cd57de4880..5821ef9c48 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx @@ -1,28 +1,37 @@ -/** - * Tests for ChatView component: sidebar, session list, message thread, - * new chat dialog, and input handling. - */ +/* +FNXC:DashboardTests 2026-06-25-16:30: +ChatView suite split 1/3 (core) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, +helpers, vi.mocked handles, and installChatViewEnv(). vi.mock factories stay inline & self +-contained here (see harness header for why delegating them triggers a TDZ ReferenceError). +*/ -import { act, fireEvent, render as rtlRender, 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; import { ChatView } from "../ChatView"; import type { DiscoveredSkill } from "@fusion/dashboard"; +import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat"; 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 { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; -import * as useChatRoomsModule from "../../hooks/useChatRooms"; -import type { UseChatRoomsResult } from "../../hooks/useChatRooms"; -import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createMockSkill, + defaultChatState, + defaultModelsResponse, + mockUseChat, + mockFetchModels, + mockFetchDiscoveredSkills, + mockCreateObjectURL, + mockRevokeObjectURL, + mockClipboardWriteText, + installChatViewEnv, +} from "./ChatView.test-harness"; // Mock the hooks vi.mock("../../hooks/useChat"); @@ -35,14 +44,6 @@ vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { }; }); -const mockUseChat = vi.mocked(useChatModule.useChat); -const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms); -const mockFetchModels = vi.mocked(apiModule.fetchModels); -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(); @@ -93,17 +94,6 @@ vi.mock("../CustomModelDropdown", () => ({ ), })); -const defaultModelsResponse = { - 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: [], - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", -}; - // Mock fetchAgents for new chat dialog vi.mock("../../api", () => ({ fetchModels: vi.fn().mockResolvedValue({ @@ -125,204 +115,7 @@ vi.mock("../../api", () => ({ 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(), - renameSession: 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(), -}; - -async function renderWithAct(ui: Parameters[0]) { - let result: ReturnType | undefined; - await act(async () => { - result = rtlRender(ui); - }); - return result!; -} - -const activeSessionFixture: ChatSessionInfo = { - id: "session-001", - agentId: "agent-001", - status: "active", - title: "Test Chat", - createdAt: "2026-04-08T00:00:00.000Z", - updatedAt: "2026-04-08T00:00:00.000Z", -}; - -function createMockSkill(overrides: Partial): DiscoveredSkill { - return { - id: "skill-id", - name: "skill/name", - path: "/tmp/skills/skill.md", - relativePath: "skills/skill.md", - enabled: true, - metadata: { - source: "*", - scope: "project", - origin: "top-level", - }, - ...overrides, - }; -} - -function setupMockChat(overrides: Partial = {}) { - const state: UseChatReturn = { ...defaultChatState, ...overrides }; - mockUseChat.mockReturnValue(state); -} - -function setupMockRooms(overrides: Partial = {}) { - 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([]); - const [activeRoom, setActiveRoom] = useState(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 }; -} - -async function renderRoomCreation(options?: { viewport?: "mobile" | "desktop"; createRejects?: boolean }) { - const viewportSpy = mockViewportMode(options?.viewport ?? "mobile"); - const { createRoom } = setupStatefulCreateRoomMock({ createRejects: options?.createRejects }); - setupMockChat({ sessions: [], filteredSessions: [] }); - localStorage.setItem("fusion:chat-scope", "rooms"); - - const user = userEvent.setup({ delay: null }); - await renderWithAct(); - - await user.click(screen.getByTestId("chat-create-room-btn")); - const dialog = await screen.findByRole("dialog", { name: "Create room" }); - fireEvent.change(within(dialog).getByLabelText("Room name"), { target: { value: "newroom" } }); - await user.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha")); - await user.click(within(dialog).getByRole("button", { name: "Create room" })); - - return { createRoom, viewportSpy }; -} - -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)" || query === "(max-width: 768px), (max-height: 480px)"), - 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"); - mockFetchModels.mockResolvedValue({ ...defaultModelsResponse }); - 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(); -}); +installChatViewEnv(); describe("ChatView", () => { @@ -3426,2397 +3219,3 @@ describe("ChatView CSS — nested flexbox scrolling fix", () => { }); }); -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() }); - - await renderWithAct(); - - await waitFor(() => { - expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-456"); - }); - }); - - it("passes projectId to NewChatDialog for agent selection", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - // 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 } = await renderWithAct(); - - await waitFor(() => { - expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001"); - }); - - const callsBeforeRerender = vi.mocked(apiModule.fetchAgents).mock.calls.length; - - // Rerender with proj-002 - rerender(); - - await waitFor(() => { - expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002"); - }); - - // Should have made an additional fetch call - expect(vi.mocked(apiModule.fetchAgents).mock.calls.length).toBeGreaterThan(callsBeforeRerender); - }); - - it("refetches agents when projectId changes in NewChatDialog", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - const { rerender } = await renderWithAct(); - - // Open dialog and check initial projectId - await userEvent.click(screen.getByTestId("chat-new-btn")); - await waitFor(() => { - expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001"); - }); - - // Close dialog, change projectId, reopen - // Note: we need to trigger a new dialog render with the new projectId - rerender(); - - // Close and reopen dialog - const closeBtn = document.querySelector(".chat-new-dialog-backdrop") as HTMLElement | null; - if (closeBtn) { - await userEvent.click(closeBtn); - } - - await userEvent.click(screen.getByTestId("chat-new-btn")); - - await waitFor(() => { - expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002"); - }); - }); -}); - -describe("ChatView sidebar structure", () => { - it("renders sidebar sections without an empty header spacer", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - 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")).not.toBeInTheDocument(); - expect(screen.getByTestId("chat-new-btn").closest(".view-header")).toBeInTheDocument(); - expect(document.querySelector(".chat-sidebar-header")).not.toBeInTheDocument(); - }); - - it("renders desktop header New Chat button", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument(); - }); - - it("renders mobile footer New Chat button in Direct scope", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - const viewportSpy = mockViewportMode("mobile"); - - await renderWithAct(); - - 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"); - - await renderWithAct(); - - 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"); - - await renderWithAct(); - - 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", 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" }], - }); - - await renderWithAct(); - - 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 { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "mobile" }); - - expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] }); - 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 { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "desktop" }); - - 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 { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "mobile", createRejects: true }); - - 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("shows rooms UI when chatRooms experimental flag is missing", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument(); - expect(screen.queryByTestId("chat-sidebar-rooms")).not.toBeInTheDocument(); - }); - - it("defaults to Direct with sidebar list visible", 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" }], - }); - - await renderWithAct(); - - 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: [] }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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("restores persisted rooms scope when chatRooms experimental flag is missing", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - localStorage.setItem("fusion:chat-scope", "rooms"); - - await renderWithAct(); - - expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true"); - expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument(); - }); - - it("persists scope in localStorage and restores Rooms on next mount", async () => { - setupMockChat({ sessions: [], filteredSessions: [] }); - - const { unmount } = await renderWithAct(); - - await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); - expect(localStorage.getItem("fusion:chat-scope")).toBe("rooms"); - - unmount(); - - await renderWithAct(); - - expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true"); - expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument(); - }); -}); - -describe("FN-5380 scroll preservation", () => { - beforeEach(() => { - mockFetchModels.mockImplementation(() => new Promise(() => {})); - }); - - const makeMessages = (count: number, sessionId = "session-001") => - Array.from({ length: count }, (_, index) => ({ - id: `msg-${index + 1}`, - sessionId, - role: index % 2 === 0 ? "assistant" : "user", - content: `Message ${index + 1}`, - createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`, - } satisfies ChatMessageInfo)); - - const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => { - let scrollTopValue = initialTop; - Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height }); - Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 }); - Object.defineProperty(container, "scrollTop", { - configurable: true, - get: () => scrollTopValue, - set: (value: number) => { - scrollTopValue = value; - }, - }); - return () => scrollTopValue; - }; - - it("preserves scroll across silent reconnect-style refetch for direct chats", async () => { - const baseMessages = makeMessages(30); - setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); - - const view = rtlRender(); - const container = document.querySelector(".chat-messages") as HTMLDivElement; - const readScrollTop = attachScrollGeometry(container, 760); - - fireEvent.scroll(container); - - setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages] }); - view.rerender(); - - await waitFor(() => { - expect(readScrollTop()).toBe(760); - }); - }); - - it("auto-scrolls on new message only when previously pinned", async () => { - const baseMessages = makeMessages(4); - setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); - - const view = rtlRender(); - const container = document.querySelector(".chat-messages") as HTMLDivElement; - const readScrollTop = attachScrollGeometry(container, 1700); - - fireEvent.scroll(container); - setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-5" }))] }); - view.rerender(); - - await waitFor(() => { - expect(readScrollTop()).toBe(2000); - }); - - container.scrollTop = 500; - fireEvent.scroll(container); - - setupMockChat({ activeSession: activeSessionFixture, messages: makeMessages(6) }); - view.rerender(); - - await waitFor(() => { - expect(readScrollTop()).toBe(500); - }); - }); - - it("preserves scroll through visibility reconnect path", async () => { - const baseMessages = makeMessages(20); - setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); - - const view = rtlRender(); - const container = document.querySelector(".chat-messages") as HTMLDivElement; - const readScrollTop = attachScrollGeometry(container, 640); - - fireEvent.scroll(container); - - Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); - fireEvent(document, new Event("visibilitychange")); - - setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-21" }))] }); - view.rerender(); - - await waitFor(() => { - expect(readScrollTop()).toBe(640); - }); - }); - - it("preserves room transcript scroll on message refresh", async () => { - const room = createRoomFixture("ops"); - const roomMessages = makeMessages(12, room.id).map((message) => ({ - id: message.id, - roomId: room.id, - role: message.role, - content: message.content, - createdAt: message.createdAt, - senderAgentId: null, - thinkingOutput: null, - metadata: null, - mentions: [], - })); - - setupMockChat({ sessions: [], filteredSessions: [] }); - setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); - - const view = rtlRender(); - - const container = document.querySelector(".chat-messages") as HTMLDivElement; - const readScrollTop = attachScrollGeometry(container, 420); - fireEvent.scroll(container); - - setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false }); - view.rerender(); - - await waitFor(() => { - expect(readScrollTop()).toBe(420); - }); - }); -}); - -describe("FN-5720 room re-entry anchoring", () => { - beforeEach(() => { - mockFetchModels.mockImplementation(() => new Promise(() => {})); - }); - - const makeMessages = (count: number, sessionId = "session-001") => - Array.from({ length: count }, (_, index) => ({ - id: `msg-${index + 1}`, - sessionId, - role: index % 2 === 0 ? "assistant" : "user", - content: `Message ${index + 1}`, - createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`, - } satisfies ChatMessageInfo)); - - const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => { - let scrollTopValue = initialTop; - Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height }); - Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 }); - Object.defineProperty(container, "scrollTop", { - configurable: true, - get: () => scrollTopValue, - set: (value: number) => { - scrollTopValue = value; - }, - }); - return () => scrollTopValue; - }; - - const makeRoomMessages = (roomId: string, count: number) => - makeMessages(count, roomId).map((message) => ({ - id: message.id, - roomId, - role: message.role, - content: message.content, - createdAt: message.createdAt, - senderAgentId: null, - thinkingOutput: null, - metadata: null, - mentions: [], - })); - - it("anchors to bottom when re-entering Rooms scope", async () => { - const room = createRoomFixture("ops"); - const roomMessages = makeRoomMessages(room.id, 12); - - setupMockChat({ - activeSession: activeSessionFixture, - sessions: [activeSessionFixture], - filteredSessions: [activeSessionFixture], - messages: [{ id: "dm-1", sessionId: activeSessionFixture.id, role: "assistant", content: "Direct", createdAt: "2026-04-08T00:00:00.000Z" }], - }); - setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); - localStorage.setItem("fusion:chat-scope", "rooms"); - - rtlRender(); - - const container = document.querySelector(".chat-messages") as HTMLDivElement; - const readScrollTop = attachScrollGeometry(container, 420); - - container.scrollTop = 420; - fireEvent.scroll(container); - - await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); - await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); - - await waitFor(() => { - expect(readScrollTop()).toBe(2000); - }); - }); - - it("preserves scrolled-up room position on message refetch", async () => { - const room = createRoomFixture("ops"); - const roomMessages = makeRoomMessages(room.id, 10); - - setupMockChat({ sessions: [], filteredSessions: [] }); - setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); - - const view = rtlRender(); - - const container = document.querySelector(".chat-messages") as HTMLDivElement; - const readScrollTop = attachScrollGeometry(container, 380); - fireEvent.scroll(container); - - setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false }); - view.rerender(); - - await waitFor(() => { - expect(readScrollTop()).toBe(380); - }); - }); -}); - -describe("resizable sidebar", () => { - beforeEach(() => { - localStorage.clear(); - }); - - it("renders desktop resize handle with separator ARIA attributes", async () => { - const viewportSpy = mockViewportMode("desktop"); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - 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", async () => { - const viewportSpy = mockViewportMode("desktop"); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - 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", async () => { - const viewportSpy = mockViewportMode("desktop"); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - 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", async () => { - const viewportSpy = mockViewportMode("desktop"); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - 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", async () => { - const viewportSpy = mockViewportMode("desktop"); - localStorage.setItem("fusion:chat-sidebar-width", "350"); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px"); - - viewportSpy.mockRestore(); - }); - - it("does not render resize handle on mobile", async () => { - const viewportSpy = mockViewportMode("mobile"); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull(); - - viewportSpy.mockRestore(); - }); -}); - -describe("Chat 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 the shared header on desktop when session is active", async () => { - const viewportSpy = mockViewportMode("desktop"); - setupMockChat({ activeSession }); - - await renderWithAct(); - - const btn = screen.getByTestId("chat-new-btn"); - expect(btn).toBeInTheDocument(); - expect(btn.closest(".view-header")).toBeInTheDocument(); - expect(btn).toHaveTextContent("New Chat"); - expect(btn).toHaveClass("btn", "btn-sm", "btn-primary"); - - viewportSpy.mockRestore(); - }); - - it("clicking shared header New Chat button opens the NewChatDialog", async () => { - const viewportSpy = mockViewportMode("desktop"); - setupMockChat({ activeSession }); - - await renderWithAct(); - - const btn = screen.getByTestId("chat-new-btn"); - await act(async () => { - fireEvent.click(btn); - }); - - expect(await screen.findByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument(); - - viewportSpy.mockRestore(); - }); - - it("does not render New Chat button in the shared header on mobile", async () => { - const viewportSpy = mockViewportMode("mobile"); - setupMockChat({ activeSession }); - - await renderWithAct(); - - expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull(); - expect(document.querySelector(".view-header [data-testid='chat-new-btn']")).toBeNull(); - - viewportSpy.mockRestore(); - }); -}); - -describe("Chat pop-out header actions", () => { - it("renders a pop-out action in the main Chat header", async () => { - const onPopOut = vi.fn(); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct(); - - const button = screen.getByTestId("chat-pop-out"); - expect(button.closest(".view-header")).toBeInTheDocument(); - fireEvent.click(button); - expect(onPopOut).toHaveBeenCalledTimes(1); - }); - - it("renders maximize, minimize, and close actions in floating Chat", async () => { - const onMaximize = vi.fn(); - const onMinimize = vi.fn(); - const onClose = vi.fn(); - setupMockChat({ sessions: [], filteredSessions: [] }); - - await renderWithAct( - , - ); - - fireEvent.click(screen.getByTestId("chat-modal-maximize")); - fireEvent.click(screen.getByTestId("chat-modal-minimize")); - fireEvent.click(screen.getByTestId("chat-modal-close")); - expect(onMaximize).toHaveBeenCalledTimes(1); - expect(onMinimize).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("defines a modal-width narrow layout that mirrors mobile one-pane behavior", async () => { - const css = loadAllAppCss(); - - expect(css).toMatch(/\.chat-view--narrow \.chat-view__body\s*\{[^}]*flex-direction:\s*column;/); - expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar\s*\{[^}]*min-width:\s*100%;[^}]*border-right:\s*none;/); - expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar:not\(\.chat-sidebar--hidden\) \+ \.chat-thread\s*\{[^}]*display:\s*none;/); - expect(css).toMatch(/\.chat-view--narrow \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/); - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-view \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/); - }); - - it("collapses Direct/Rooms labels from ChatView container width so the header title remains visible", async () => { - const css = loadAllAppCss(); - - expect(css).toMatch(/\.chat-view\s*\{[^}]*container:\s*chat-view \/ inline-size;/); - expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px;/); - expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip-path:\s*inset\(50%\);/); - }); -}); - -describe("ChatView mobile behavior", () => { - let savedVisualViewport: typeof window.visualViewport; - let savedInnerHeight: number; - let savedOntouchstart: typeof window.ontouchstart; - - beforeEach(() => { - _resetInitialViewportHeight(); - savedVisualViewport = window.visualViewport; - savedInnerHeight = window.innerHeight; - savedOntouchstart = window.ontouchstart; - }); - - afterEach(() => { - _resetInitialViewportHeight(); - Object.defineProperty(window, "visualViewport", { - value: savedVisualViewport, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "innerHeight", { - value: savedInnerHeight, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "ontouchstart", { - value: savedOntouchstart, - writable: true, - configurable: true, - }); - }); - - function mockMobileVisualViewport({ - innerHeight, - vvHeight, - }: { - innerHeight: number; - vvHeight: number; - }) { - (window as any).ontouchstart = null; - Object.defineProperty(window, "innerHeight", { - value: innerHeight, - writable: true, - configurable: true, - }); - - const listeners: Record void>> = { - resize: [], - scroll: [], - }; - - const mockVV = { - width: 375, - height: vvHeight, - offsetTop: 0, - offsetLeft: 0, - addEventListener: vi.fn((event: string, cb: () => void) => { - listeners[event]?.push(cb); - }), - removeEventListener: vi.fn(), - }; - - Object.defineProperty(window, "visualViewport", { - value: mockVV, - writable: true, - configurable: true, - }); - - return { listeners, mockVV }; - } - function ensureMatchMedia() { - if (!window.matchMedia) { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: vi.fn(), - }); - } - } - - function mockMobileViewport() { - ensureMatchMedia(); - Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); - return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ - matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)", - 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)", async () => { - 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, - }); - - await renderWithAct(); - - // 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", async () => { - 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" }], - }); - - await renderWithAct(); - - // 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, - }); - - await renderWithAct(); - - 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, - }); - - await renderWithAct(); - - 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 = await renderWithAct(); - - 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", - }, - }); - - await renderWithAct(); - 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: iOS first tap focuses direct composer without blocking native focus, then sends", async () => { - const restoreMatchMedia = mockMobileViewport(); - const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); - const sendMessage = vi.fn(); - - try { - setupMockChat({ - activeSession: activeSessionFixture, - messages: [], - sendMessage, - }); - - await renderWithAct(); - - const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; - input.blur(); - expect(document.activeElement).not.toBe(input); - - const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); - const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); - fireEvent(input, touchEvent); - // jsdom has no soft keyboard/native touch-focus default action; mirror - // the browser focus that iOS only performs when touchstart is not canceled. - if (!touchEvent.defaultPrevented) { - input.focus(); - } - - expect(preventDefaultSpy).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(input); - - fireEvent.change(input, { target: { value: "Hello mobile" } }); - 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 { - isIOSSpy.mockRestore(); - 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, - }); - - await renderWithAct(); - - 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, - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - const thread = document.querySelector(".chat-thread") as HTMLDivElement; - expect(thread).toBeInTheDocument(); - expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px"); - - // 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(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 - await act(async () => { - 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("284px"); - expect(thread.style.getPropertyValue("--vv-height")).toBe("560px"); - }); - } 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" }], - }); - - await renderWithAct(); - - 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("FN-5365: mobile keyboard viewport vars follow settled sample and suppress blur-dismiss shrink", 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" }], - }); - - await renderWithAct(); - - const thread = document.querySelector(".chat-thread") as HTMLDivElement; - expect(thread).toBeInTheDocument(); - expect(thread.style.getPropertyValue("--vv-height")).toBe("844px"); - expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("0px"); - expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px"); - - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; - await act(async () => { - textarea.focus(); - }); - act(() => { - document.dispatchEvent(new Event("focusin")); - }); - - Object.defineProperty(mockVV, "offsetTop", { value: 180, writable: true, configurable: true }); - Object.defineProperty(mockVV, "height", { value: 820, writable: true, configurable: true }); - act(() => { - for (const cb of listeners.resize) cb(); - }); - expect(thread.style.getPropertyValue("--vv-height")).toBe("820px"); - - Object.defineProperty(mockVV, "offsetTop", { value: 0, 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("--vv-height")).toBe("560px"); - expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("0px"); - expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px"); - expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); - }); - - const styleAfterVvEvents = thread.getAttribute("style") ?? ""; - expect(styleAfterVvEvents).toContain("--vv-height: 560px"); - expect(styleAfterVvEvents).toContain("--vv-offset-top: 0px"); - expect(styleAfterVvEvents).toContain("--keyboard-overlap: 284px"); - - await act(async () => { - textarea.blur(); - document.dispatchEvent(new Event("focusout")); - }); - await waitFor(() => { - expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); - }); - - Object.defineProperty(mockVV, "height", { value: 700, writable: true, configurable: true }); - act(() => { - for (const cb of listeners.resize) cb(); - }); - expect(thread.style.getPropertyValue("--vv-height")).toBe("560px"); - - await act(async () => { - textarea.focus(); - }); - act(() => { - document.dispatchEvent(new Event("focusin")); - }); - - Object.defineProperty(mockVV, "height", { value: 640, writable: true, configurable: true }); - act(() => { - for (const cb of listeners.resize) cb(); - }); - - await waitFor(() => { - expect(thread.style.getPropertyValue("--vv-height")).toBe("640px"); - }); - } 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" }], - }); - - await renderWithAct(); - - 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 }); - - act(() => { - for (const cb of listeners.resize) cb(); - }); - - await waitFor(() => { - expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); - }); - - await act(async () => { - textarea.blur(); - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - await waitFor(() => { - expect(mockVV.addEventListener).toHaveBeenCalledTimes(1); - expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function)); - expect(mockVV.addEventListener).not.toHaveBeenCalledWith("scroll", expect.any(Function)); - }); - } finally { - restoreMatchMedia.mockRestore(); - } - }); - - it("desktop mode: renders thread header even without active session (shows empty state)", async () => { - 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, - }); - - await renderWithAct(); - - // 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", async () => { - 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" }], - }); - - await renderWithAct(); - - // 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" }, - ], - }); - - await renderWithAct(); - - 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", - }, - ], - }); - - await renderWithAct(); - - 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 } = await renderWithAct(); - - 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(); - - 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 } = await renderWithAct(); - - 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(); - - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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")); - - // Regression guard: visibility restore must explicitly re-anchor when pinned. - // Without that, this only passed when leftover anchorToBottom rAF callbacks - // happened to run after the visibility event. - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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 () => { - await vi.runOnlyPendingTimersAsync(); - }); - - scrollHeightValue = 1500; - await act(async () => { - await vi.advanceTimersByTimeAsync(260); - await vi.runOnlyPendingTimersAsync(); - }); - - 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", - }, - ], - }); - - await renderWithAct(); - - 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" }], - }); - - await renderWithAct(); - - 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-5380: desktop visibility restore preserves manual direct-thread scroll", 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 } = await renderWithAct(); - - 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(600); - }); - expect(screen.getByTestId("chat-jump-to-latest")).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(); - - await waitFor(() => { - expect(scrollTopValue).toBe(1300); - }); - } finally { - restoreMatchMedia.mockRestore(); - Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); - } - }); - - it("FN-5380: desktop pageshow preserves direct chat scroll position", 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" }], - }); - - await renderWithAct(); - - 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(420); - }); - } 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" }], - }); - - await renderWithAct(); - - 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; - await act(async () => { - 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 } = await renderWithAct(); - - 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(); - - 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 } = await renderWithAct(); - 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(); - - 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[^{]*\(max-width:\s*768px\)[^{]*\{([\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[^{]*\(max-width:\s*768px\)[^{]*\{([\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", async () => { - expect(mobileRuleContains(".chat-sidebar", "height: 100%")).toBe(true); - expect(mobileRuleNotContains(".chat-sidebar", "max-height: 40vh")).toBe(true); - }); - - it("keeps the shared header outside the bounded chat body row", async () => { - const viewRule = css.match(/\.chat-view\s*\{([^}]*)\}/)?.[1] ?? ""; - const bodyRule = css.match(/\.chat-view__body\s*\{([^}]*)\}/)?.[1] ?? ""; - - expect(viewRule).toContain("flex-direction: column;"); - expect(viewRule).toContain("min-height: 0;"); - expect(bodyRule).toContain("display: flex;"); - expect(bodyRule).toContain("flex: 1 1 auto;"); - expect(bodyRule).toContain("min-height: 0;"); - expect(bodyRule).toContain("overflow: hidden;"); - }); - - it("mobile .chat-sidebar-header is hidden", async () => { - expect(mobileRuleContains(".chat-sidebar-header", "display: none")).toBe(true); - }); - - it("mobile .chat-sidebar-search remains visible (FN-4120)", async () => { - expect(mobileRuleNotContains(".chat-sidebar-search", "display: none")).toBe(true); - }); - - it("mobile .chat-sidebar-search keeps a token-based touch target (FN-4120)", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - expect(mobileRuleNotContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true); - }); - - it("mobile keeps ChatView dialog backdrop centered with safe-area padding", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - 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", async () => { - setupMockChat({ - sessions: [], - filteredSessions: [], - sessionsLoading: true, - activeSession: activeSessionFixture, - messages: [], - messagesLoading: true, - }); - - await renderWithAct(); - - 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", async () => { - 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); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx new file mode 100644 index 0000000000..e320fc349d --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -0,0 +1,1759 @@ +/* +FNXC:DashboardTests 2026-06-25-16:30: +ChatView suite split 3/3 (mobile) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, +helpers, vi.mocked handles, and installChatViewEnv(). vi.mock factories stay inline & self +-contained here (see harness header for why delegating them triggers a TDZ ReferenceError). +*/ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { readFileSync } from "node:fs"; +import { ChatView } from "../ChatView"; +import { loadAllAppCss } from "../../test/cssFixture"; +import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; +import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createRoomFixture, + ensureMatchMedia, + installChatViewEnv, +} from "./ChatView.test-harness"; + +// Mock the hooks +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +// Mock lucide-react icons - spread actual module and override specific icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MessageSquare: ({ "data-testid": testId, ...props }: any) => ( + + ), + Send: ({ "data-testid": testId, ...props }: any) => , + Plus: ({ "data-testid": testId, ...props }: any) => , + Search: ({ "data-testid": testId, ...props }: any) => , + Trash2: ({ "data-testid": testId, ...props }: any) => , + Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , + ChevronLeft: ({ "data-testid": testId, ...props }: any) => , + Bot: ({ "data-testid": testId, ...props }: any) => , + Square: ({ "data-testid": testId, ...props }: any) => , + Eye: ({ "data-testid": testId, ...props }: any) => , + EyeOff: ({ "data-testid": testId, ...props }: any) => , + Paperclip: ({ "data-testid": testId, ...props }: any) => , + File: ({ "data-testid": testId, ...props }: any) => , + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +// Mock CustomModelDropdown - no longer used but kept for other tests +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ + value, + onChange, + label, + }: { + value: string; + onChange: (value: string) => void; + label: string; + }) => ( + + ), +})); + +// Mock fetchAgents for new chat dialog +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + 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([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + +describe("ChatView mobile behavior", () => { + let savedVisualViewport: typeof window.visualViewport; + let savedInnerHeight: number; + let savedOntouchstart: typeof window.ontouchstart; + + beforeEach(() => { + _resetInitialViewportHeight(); + savedVisualViewport = window.visualViewport; + savedInnerHeight = window.innerHeight; + savedOntouchstart = window.ontouchstart; + }); + + afterEach(() => { + _resetInitialViewportHeight(); + Object.defineProperty(window, "visualViewport", { + value: savedVisualViewport, + writable: true, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: savedInnerHeight, + writable: true, + configurable: true, + }); + Object.defineProperty(window, "ontouchstart", { + value: savedOntouchstart, + writable: true, + configurable: true, + }); + }); + + function mockMobileVisualViewport({ + innerHeight, + vvHeight, + }: { + innerHeight: number; + vvHeight: number; + }) { + (window as any).ontouchstart = null; + Object.defineProperty(window, "innerHeight", { + value: innerHeight, + writable: true, + configurable: true, + }); + + const listeners: Record void>> = { + resize: [], + scroll: [], + }; + + const mockVV = { + width: 375, + height: vvHeight, + offsetTop: 0, + offsetLeft: 0, + addEventListener: vi.fn((event: string, cb: () => void) => { + listeners[event]?.push(cb); + }), + removeEventListener: vi.fn(), + }; + + Object.defineProperty(window, "visualViewport", { + value: mockVV, + writable: true, + configurable: true, + }); + + return { listeners, mockVV }; + } + function ensureMatchMedia() { + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn(), + }); + } + } + + function mockMobileViewport() { + ensureMatchMedia(); + Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); + return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)", + 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)", async () => { + 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, + }); + + await renderWithAct(); + + // 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", async () => { + 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" }], + }); + + await renderWithAct(); + + // 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, + }); + + await renderWithAct(); + + 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, + }); + + await renderWithAct(); + + 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 = await renderWithAct(); + + 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", + }, + }); + + await renderWithAct(); + 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: iOS first tap focuses direct composer without blocking native focus, then sends", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + const sendMessage = vi.fn(); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [], + sendMessage, + }); + + await renderWithAct(); + + const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; + input.blur(); + expect(document.activeElement).not.toBe(input); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + // jsdom has no soft keyboard/native touch-focus default action; mirror + // the browser focus that iOS only performs when touchstart is not canceled. + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + + fireEvent.change(input, { target: { value: "Hello mobile" } }); + 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 { + isIOSSpy.mockRestore(); + 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, + }); + + await renderWithAct(); + + 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, + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + expect(thread).toBeInTheDocument(); + expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px"); + + // 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(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 + await act(async () => { + 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("284px"); + expect(thread.style.getPropertyValue("--vv-height")).toBe("560px"); + }); + } 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" }], + }); + + await renderWithAct(); + + 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("FN-5365: mobile keyboard viewport vars follow settled sample and suppress blur-dismiss shrink", 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" }], + }); + + await renderWithAct(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + expect(thread).toBeInTheDocument(); + expect(thread.style.getPropertyValue("--vv-height")).toBe("844px"); + expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px"); + + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await act(async () => { + textarea.focus(); + }); + act(() => { + document.dispatchEvent(new Event("focusin")); + }); + + Object.defineProperty(mockVV, "offsetTop", { value: 180, writable: true, configurable: true }); + Object.defineProperty(mockVV, "height", { value: 820, writable: true, configurable: true }); + act(() => { + for (const cb of listeners.resize) cb(); + }); + expect(thread.style.getPropertyValue("--vv-height")).toBe("820px"); + + Object.defineProperty(mockVV, "offsetTop", { value: 0, 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("--vv-height")).toBe("560px"); + expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px"); + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + }); + + const styleAfterVvEvents = thread.getAttribute("style") ?? ""; + expect(styleAfterVvEvents).toContain("--vv-height: 560px"); + expect(styleAfterVvEvents).toContain("--vv-offset-top: 0px"); + expect(styleAfterVvEvents).toContain("--keyboard-overlap: 284px"); + + await act(async () => { + textarea.blur(); + document.dispatchEvent(new Event("focusout")); + }); + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); + }); + + Object.defineProperty(mockVV, "height", { value: 700, writable: true, configurable: true }); + act(() => { + for (const cb of listeners.resize) cb(); + }); + expect(thread.style.getPropertyValue("--vv-height")).toBe("560px"); + + await act(async () => { + textarea.focus(); + }); + act(() => { + document.dispatchEvent(new Event("focusin")); + }); + + Object.defineProperty(mockVV, "height", { value: 640, writable: true, configurable: true }); + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(thread.style.getPropertyValue("--vv-height")).toBe("640px"); + }); + } 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" }], + }); + + await renderWithAct(); + + 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 }); + + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + }); + + await act(async () => { + textarea.blur(); + 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" }], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + await waitFor(() => { + expect(mockVV.addEventListener).toHaveBeenCalledTimes(1); + expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function)); + expect(mockVV.addEventListener).not.toHaveBeenCalledWith("scroll", expect.any(Function)); + }); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("desktop mode: renders thread header even without active session (shows empty state)", async () => { + 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, + }); + + await renderWithAct(); + + // 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", async () => { + 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" }], + }); + + await renderWithAct(); + + // 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" }, + ], + }); + + await renderWithAct(); + + 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", + }, + ], + }); + + await renderWithAct(); + + 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 } = await renderWithAct(); + + 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(); + + 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 } = await renderWithAct(); + + 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(); + + 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" }], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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")); + + // Regression guard: visibility restore must explicitly re-anchor when pinned. + // Without that, this only passed when leftover anchorToBottom rAF callbacks + // happened to run after the visibility event. + 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" }], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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 () => { + await vi.runOnlyPendingTimersAsync(); + }); + + scrollHeightValue = 1500; + await act(async () => { + await vi.advanceTimersByTimeAsync(260); + await vi.runOnlyPendingTimersAsync(); + }); + + 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", + }, + ], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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-5380: desktop visibility restore preserves manual direct-thread scroll", 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 } = await renderWithAct(); + + 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(600); + }); + expect(screen.getByTestId("chat-jump-to-latest")).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(); + + await waitFor(() => { + expect(scrollTopValue).toBe(1300); + }); + } finally { + restoreMatchMedia.mockRestore(); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + } + }); + + it("FN-5380: desktop pageshow preserves direct chat scroll position", 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" }], + }); + + await renderWithAct(); + + 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(420); + }); + } 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" }], + }); + + await renderWithAct(); + + 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; + await act(async () => { + 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 } = await renderWithAct(); + + 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(); + + 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 } = await renderWithAct(); + 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(); + + 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[^{]*\(max-width:\s*768px\)[^{]*\{([\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[^{]*\(max-width:\s*768px\)[^{]*\{([\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", async () => { + expect(mobileRuleContains(".chat-sidebar", "height: 100%")).toBe(true); + expect(mobileRuleNotContains(".chat-sidebar", "max-height: 40vh")).toBe(true); + }); + + it("keeps the shared header outside the bounded chat body row", async () => { + const viewRule = css.match(/\.chat-view\s*\{([^}]*)\}/)?.[1] ?? ""; + const bodyRule = css.match(/\.chat-view__body\s*\{([^}]*)\}/)?.[1] ?? ""; + + expect(viewRule).toContain("flex-direction: column;"); + expect(viewRule).toContain("min-height: 0;"); + expect(bodyRule).toContain("display: flex;"); + expect(bodyRule).toContain("flex: 1 1 auto;"); + expect(bodyRule).toContain("min-height: 0;"); + expect(bodyRule).toContain("overflow: hidden;"); + }); + + it("mobile .chat-sidebar-header is hidden", async () => { + expect(mobileRuleContains(".chat-sidebar-header", "display: none")).toBe(true); + }); + + it("mobile .chat-sidebar-search remains visible (FN-4120)", async () => { + expect(mobileRuleNotContains(".chat-sidebar-search", "display: none")).toBe(true); + }); + + it("mobile .chat-sidebar-search keeps a token-based touch target (FN-4120)", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + expect(mobileRuleNotContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true); + }); + + it("mobile keeps ChatView dialog backdrop centered with safe-area padding", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + 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", async () => { + setupMockChat({ + sessions: [], + filteredSessions: [], + sessionsLoading: true, + activeSession: activeSessionFixture, + messages: [], + messagesLoading: true, + }); + + await renderWithAct(); + + 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", async () => { + 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); + }); +}); + diff --git a/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx new file mode 100644 index 0000000000..18a50ae3df --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx @@ -0,0 +1,898 @@ +/* +FNXC:DashboardTests 2026-06-25-16:30: +ChatView suite split 2/3 (sessions + rooms + scope) (was ChatView.test.tsx). Shares ChatView.test-harness for fixtures, +helpers, vi.mocked handles, and installChatViewEnv(). vi.mock factories stay inline & self +-contained here (see harness header for why delegating them triggers a TDZ ReferenceError). +*/ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { ChatView } from "../ChatView"; +import type { ChatMessageInfo } from "../../hooks/useChat"; +import { loadAllAppCss } from "../../test/cssFixture"; +import * as apiModule from "../../api"; +import { + renderWithAct, + setupMockChat, + setupMockRooms, + mockViewportMode, + activeSessionFixture, + createRoomFixture, + renderRoomCreation, + mockFetchModels, + mockFetchDiscoveredSkills, + installChatViewEnv, +} from "./ChatView.test-harness"; + +// Mock the hooks +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +// Mock lucide-react icons - spread actual module and override specific icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MessageSquare: ({ "data-testid": testId, ...props }: any) => ( + + ), + Send: ({ "data-testid": testId, ...props }: any) => , + Plus: ({ "data-testid": testId, ...props }: any) => , + Search: ({ "data-testid": testId, ...props }: any) => , + Trash2: ({ "data-testid": testId, ...props }: any) => , + Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , + ChevronLeft: ({ "data-testid": testId, ...props }: any) => , + Bot: ({ "data-testid": testId, ...props }: any) => , + Square: ({ "data-testid": testId, ...props }: any) => , + Eye: ({ "data-testid": testId, ...props }: any) => , + EyeOff: ({ "data-testid": testId, ...props }: any) => , + Paperclip: ({ "data-testid": testId, ...props }: any) => , + File: ({ "data-testid": testId, ...props }: any) => , + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +// Mock CustomModelDropdown - no longer used but kept for other tests +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ + value, + onChange, + label, + }: { + value: string; + onChange: (value: string) => void; + label: string; + }) => ( + + ), +})); + +// Mock fetchAgents for new chat dialog +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + 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([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + +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() }); + + await renderWithAct(); + + await waitFor(() => { + expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-456"); + }); + }); + + it("passes projectId to NewChatDialog for agent selection", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + // 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 } = await renderWithAct(); + + await waitFor(() => { + expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001"); + }); + + const callsBeforeRerender = vi.mocked(apiModule.fetchAgents).mock.calls.length; + + // Rerender with proj-002 + rerender(); + + await waitFor(() => { + expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002"); + }); + + // Should have made an additional fetch call + expect(vi.mocked(apiModule.fetchAgents).mock.calls.length).toBeGreaterThan(callsBeforeRerender); + }); + + it("refetches agents when projectId changes in NewChatDialog", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + const { rerender } = await renderWithAct(); + + // Open dialog and check initial projectId + await userEvent.click(screen.getByTestId("chat-new-btn")); + await waitFor(() => { + expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-001"); + }); + + // Close dialog, change projectId, reopen + // Note: we need to trigger a new dialog render with the new projectId + rerender(); + + // Close and reopen dialog + const closeBtn = document.querySelector(".chat-new-dialog-backdrop") as HTMLElement | null; + if (closeBtn) { + await userEvent.click(closeBtn); + } + + await userEvent.click(screen.getByTestId("chat-new-btn")); + + await waitFor(() => { + expect(apiModule.fetchAgents).toHaveBeenCalledWith(undefined, "proj-002"); + }); + }); +}); + +describe("ChatView sidebar structure", () => { + it("renders sidebar sections without an empty header spacer", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + 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")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-new-btn").closest(".view-header")).toBeInTheDocument(); + expect(document.querySelector(".chat-sidebar-header")).not.toBeInTheDocument(); + }); + + it("renders desktop header New Chat button", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument(); + }); + + it("renders mobile footer New Chat button in Direct scope", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + const viewportSpy = mockViewportMode("mobile"); + + await renderWithAct(); + + 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"); + + await renderWithAct(); + + 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"); + + await renderWithAct(); + + 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", 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" }], + }); + + await renderWithAct(); + + 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 { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "mobile" }); + + expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] }); + 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 { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "desktop" }); + + 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 { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "mobile", createRejects: true }); + + 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("shows rooms UI when chatRooms experimental flag is missing", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-sidebar-rooms")).not.toBeInTheDocument(); + }); + + it("defaults to Direct with sidebar list visible", 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" }], + }); + + await renderWithAct(); + + 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: [] }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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" }], + }); + + await renderWithAct(); + + 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 () => { + /* + FNXC:DashboardTests 2026-06-25-16:30: + The Direct↔Rooms toggle swaps subtrees in ChatView's render (chatScope ternary), so the + `.chat-messages` container UNMOUNTS on entering Rooms and a FRESH node mounts on return to + Direct. The re-anchor effect (ChatView.tsx anchorToBottom on scope change) correctly targets + that remounted node — whose jsdom scrollHeight is 0 unless we provide geometry at creation. + The pre-split file masked this by mocking geometry on the pre-toggle node and only passing via + a timing race against the remount. Install scroll geometry at the prototype level (restored in + finally) with per-node scrollTop backing so whichever `.chat-messages` node is live — including + the remounted one — reports scrollHeight 1200, making the re-anchor deterministically observable + without weakening the "re-anchors to bottom" assertion. + */ + const proto = HTMLElement.prototype; + const originalScrollHeight = Object.getOwnPropertyDescriptor(proto, "scrollHeight"); + const originalClientHeight = Object.getOwnPropertyDescriptor(proto, "clientHeight"); + const originalScrollTop = Object.getOwnPropertyDescriptor(proto, "scrollTop"); + const scrollTopByNode = new WeakMap(); + const restoreGeometry = () => { + if (originalScrollHeight) Object.defineProperty(proto, "scrollHeight", originalScrollHeight); + if (originalClientHeight) Object.defineProperty(proto, "clientHeight", originalClientHeight); + if (originalScrollTop) Object.defineProperty(proto, "scrollTop", originalScrollTop); + }; + + try { + Object.defineProperty(proto, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("chat-messages") ? 1200 : (originalScrollHeight?.get?.call(this) ?? 0); + }, + }); + Object.defineProperty(proto, "clientHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("chat-messages") ? 200 : (originalClientHeight?.get?.call(this) ?? 0); + }, + }); + Object.defineProperty(proto, "scrollTop", { + configurable: true, + get(this: HTMLElement) { + return scrollTopByNode.get(this) ?? (originalScrollTop?.get?.call(this) ?? 0); + }, + set(this: HTMLElement, value: number) { + scrollTopByNode.set(this, value); + }, + }); + + 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" }], + }); + + await renderWithAct(); + + const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement; + // Scroll up from the bottom so the jump-to-latest affordance appears. + messagesContainer.scrollTop = 500; + 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(() => { + const live = document.querySelector(".chat-messages") as HTMLDivElement; + expect(live.scrollTop).toBe(1200); + }); + await waitFor(() => { + expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument(); + }); + } finally { + restoreGeometry(); + } + }); + + it("restores persisted rooms scope when chatRooms experimental flag is missing", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + localStorage.setItem("fusion:chat-scope", "rooms"); + + await renderWithAct(); + + expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument(); + }); + + it("persists scope in localStorage and restores Rooms on next mount", async () => { + setupMockChat({ sessions: [], filteredSessions: [] }); + + const { unmount } = await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); + expect(localStorage.getItem("fusion:chat-scope")).toBe("rooms"); + + unmount(); + + await renderWithAct(); + + expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument(); + }); +}); + +describe("FN-5380 scroll preservation", () => { + beforeEach(() => { + mockFetchModels.mockImplementation(() => new Promise(() => {})); + }); + + const makeMessages = (count: number, sessionId = "session-001") => + Array.from({ length: count }, (_, index) => ({ + id: `msg-${index + 1}`, + sessionId, + role: index % 2 === 0 ? "assistant" : "user", + content: `Message ${index + 1}`, + createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`, + } satisfies ChatMessageInfo)); + + const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => { + let scrollTopValue = initialTop; + Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height }); + Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 }); + Object.defineProperty(container, "scrollTop", { + configurable: true, + get: () => scrollTopValue, + set: (value: number) => { + scrollTopValue = value; + }, + }); + return () => scrollTopValue; + }; + + it("preserves scroll across silent reconnect-style refetch for direct chats", async () => { + const baseMessages = makeMessages(30); + setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); + + const view = rtlRender(); + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const readScrollTop = attachScrollGeometry(container, 760); + + fireEvent.scroll(container); + + setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages] }); + view.rerender(); + + await waitFor(() => { + expect(readScrollTop()).toBe(760); + }); + }); + + it("auto-scrolls on new message only when previously pinned", async () => { + const baseMessages = makeMessages(4); + setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); + + const view = rtlRender(); + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const readScrollTop = attachScrollGeometry(container, 1700); + + fireEvent.scroll(container); + setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-5" }))] }); + view.rerender(); + + await waitFor(() => { + expect(readScrollTop()).toBe(2000); + }); + + container.scrollTop = 500; + fireEvent.scroll(container); + + setupMockChat({ activeSession: activeSessionFixture, messages: makeMessages(6) }); + view.rerender(); + + await waitFor(() => { + expect(readScrollTop()).toBe(500); + }); + }); + + it("preserves scroll through visibility reconnect path", async () => { + const baseMessages = makeMessages(20); + setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); + + const view = rtlRender(); + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const readScrollTop = attachScrollGeometry(container, 640); + + fireEvent.scroll(container); + + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + fireEvent(document, new Event("visibilitychange")); + + setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-21" }))] }); + view.rerender(); + + await waitFor(() => { + expect(readScrollTop()).toBe(640); + }); + }); + + it("preserves room transcript scroll on message refresh", async () => { + const room = createRoomFixture("ops"); + const roomMessages = makeMessages(12, room.id).map((message) => ({ + id: message.id, + roomId: room.id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + senderAgentId: null, + thinkingOutput: null, + metadata: null, + mentions: [], + })); + + setupMockChat({ sessions: [], filteredSessions: [] }); + setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); + + const view = rtlRender(); + + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const readScrollTop = attachScrollGeometry(container, 420); + fireEvent.scroll(container); + + setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false }); + view.rerender(); + + await waitFor(() => { + expect(readScrollTop()).toBe(420); + }); + }); +}); + +describe("FN-5720 room re-entry anchoring", () => { + beforeEach(() => { + mockFetchModels.mockImplementation(() => new Promise(() => {})); + }); + + const makeMessages = (count: number, sessionId = "session-001") => + Array.from({ length: count }, (_, index) => ({ + id: `msg-${index + 1}`, + sessionId, + role: index % 2 === 0 ? "assistant" : "user", + content: `Message ${index + 1}`, + createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`, + } satisfies ChatMessageInfo)); + + const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => { + let scrollTopValue = initialTop; + Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height }); + Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 }); + Object.defineProperty(container, "scrollTop", { + configurable: true, + get: () => scrollTopValue, + set: (value: number) => { + scrollTopValue = value; + }, + }); + return () => scrollTopValue; + }; + + const makeRoomMessages = (roomId: string, count: number) => + makeMessages(count, roomId).map((message) => ({ + id: message.id, + roomId, + role: message.role, + content: message.content, + createdAt: message.createdAt, + senderAgentId: null, + thinkingOutput: null, + metadata: null, + mentions: [], + })); + + it("anchors to bottom when re-entering Rooms scope", async () => { + const room = createRoomFixture("ops"); + const roomMessages = makeRoomMessages(room.id, 12); + + setupMockChat({ + activeSession: activeSessionFixture, + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + messages: [{ id: "dm-1", sessionId: activeSessionFixture.id, role: "assistant", content: "Direct", createdAt: "2026-04-08T00:00:00.000Z" }], + }); + setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); + localStorage.setItem("fusion:chat-scope", "rooms"); + + rtlRender(); + + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const readScrollTop = attachScrollGeometry(container, 420); + + container.scrollTop = 420; + fireEvent.scroll(container); + + await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); + await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); + + await waitFor(() => { + expect(readScrollTop()).toBe(2000); + }); + }); + + it("preserves scrolled-up room position on message refetch", async () => { + const room = createRoomFixture("ops"); + const roomMessages = makeRoomMessages(room.id, 10); + + setupMockChat({ sessions: [], filteredSessions: [] }); + setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); + + const view = rtlRender(); + + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const readScrollTop = attachScrollGeometry(container, 380); + fireEvent.scroll(container); + + setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false }); + view.rerender(); + + await waitFor(() => { + expect(readScrollTop()).toBe(380); + }); + }); +}); + +describe("resizable sidebar", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("renders desktop resize handle with separator ARIA attributes", async () => { + const viewportSpy = mockViewportMode("desktop"); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + 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", async () => { + const viewportSpy = mockViewportMode("desktop"); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + 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", async () => { + const viewportSpy = mockViewportMode("desktop"); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + 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", async () => { + const viewportSpy = mockViewportMode("desktop"); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + 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", async () => { + const viewportSpy = mockViewportMode("desktop"); + localStorage.setItem("fusion:chat-sidebar-width", "350"); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px"); + + viewportSpy.mockRestore(); + }); + + it("does not render resize handle on mobile", async () => { + const viewportSpy = mockViewportMode("mobile"); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull(); + + viewportSpy.mockRestore(); + }); +}); + +describe("Chat 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 the shared header on desktop when session is active", async () => { + const viewportSpy = mockViewportMode("desktop"); + setupMockChat({ activeSession }); + + await renderWithAct(); + + const btn = screen.getByTestId("chat-new-btn"); + expect(btn).toBeInTheDocument(); + expect(btn.closest(".view-header")).toBeInTheDocument(); + expect(btn).toHaveTextContent("New Chat"); + expect(btn).toHaveClass("btn", "btn-sm", "btn-primary"); + + viewportSpy.mockRestore(); + }); + + it("clicking shared header New Chat button opens the NewChatDialog", async () => { + const viewportSpy = mockViewportMode("desktop"); + setupMockChat({ activeSession }); + + await renderWithAct(); + + const btn = screen.getByTestId("chat-new-btn"); + await act(async () => { + fireEvent.click(btn); + }); + + expect(await screen.findByTestId("chat-new-dialog-mode-toggle")).toBeInTheDocument(); + + viewportSpy.mockRestore(); + }); + + it("does not render New Chat button in the shared header on mobile", async () => { + const viewportSpy = mockViewportMode("mobile"); + setupMockChat({ activeSession }); + + await renderWithAct(); + + expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull(); + expect(document.querySelector(".view-header [data-testid='chat-new-btn']")).toBeNull(); + + viewportSpy.mockRestore(); + }); +}); + +describe("Chat pop-out header actions", () => { + it("renders a pop-out action in the main Chat header", async () => { + const onPopOut = vi.fn(); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct(); + + const button = screen.getByTestId("chat-pop-out"); + expect(button.closest(".view-header")).toBeInTheDocument(); + fireEvent.click(button); + expect(onPopOut).toHaveBeenCalledTimes(1); + }); + + it("renders maximize, minimize, and close actions in floating Chat", async () => { + const onMaximize = vi.fn(); + const onMinimize = vi.fn(); + const onClose = vi.fn(); + setupMockChat({ sessions: [], filteredSessions: [] }); + + await renderWithAct( + , + ); + + fireEvent.click(screen.getByTestId("chat-modal-maximize")); + fireEvent.click(screen.getByTestId("chat-modal-minimize")); + fireEvent.click(screen.getByTestId("chat-modal-close")); + expect(onMaximize).toHaveBeenCalledTimes(1); + expect(onMinimize).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("defines a modal-width narrow layout that mirrors mobile one-pane behavior", async () => { + const css = loadAllAppCss(); + + expect(css).toMatch(/\.chat-view--narrow \.chat-view__body\s*\{[^}]*flex-direction:\s*column;/); + expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar\s*\{[^}]*min-width:\s*100%;[^}]*border-right:\s*none;/); + expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar:not\(\.chat-sidebar--hidden\) \+ \.chat-thread\s*\{[^}]*display:\s*none;/); + expect(css).toMatch(/\.chat-view--narrow \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/); + expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-view \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/); + }); + + it("collapses Direct/Rooms labels from ChatView container width so the header title remains visible", async () => { + const css = loadAllAppCss(); + + expect(css).toMatch(/\.chat-view\s*\{[^}]*container:\s*chat-view \/ inline-size;/); + expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle\s*\{[^}]*width:\s*72px;/); + expect(css).toMatch(/@container\s+chat-view\s+\(max-width:\s*560px\)[\s\S]*?\.chat-view-header-scope-toggle \.chat-sidebar-scope-btn span\s*\{[^}]*clip-path:\s*inset\(50%\);/); + }); +}); + diff --git a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx new file mode 100644 index 0000000000..f345a73abb --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx @@ -0,0 +1,258 @@ +import { vi, beforeEach, afterEach } from "vitest"; +import { act, fireEvent, render as rtlRender, screen, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useState } from "react"; +import { ChatView } from "../ChatView"; +import type { DiscoveredSkill } from "@fusion/dashboard"; +import * as useChatModule from "../../hooks/useChat"; +import type { UseChatReturn, ChatSessionInfo } 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"; + +/* +FNXC:DashboardTests 2026-06-25-16:30: +Shared harness for the ChatView suite. The 231-test ChatView.test.tsx was split into 3 +sibling files (core / sessions-rooms / mobile) so the dashboard chat project parallelizes +them across workers instead of running one ~24s sequential file (FN-5048 feedback-loop +velocity). + +CRITICAL: the vi.mock(...) factories stay INLINE in each split test file, NOT here. This +harness imports ChatView/../../api, so a vi.mock factory that referenced a harness export +would evaluate during harness init while that export is still in the TDZ — producing +`ReferenceError: Cannot access '__vi_import_N__' before initialization`. The factories must +stay self-contained (lucide-react, CustomModelDropdown, useNavigationHistory, ../../api, +useChat, useChatRooms). This harness only exports fixtures, helpers, the `vi.mocked` +handles (resolved against the per-file hoisted mocks), and installChatViewEnv() — the +former file-level beforeEach/afterEach. +*/ + +// Mock scrollIntoView for JSDOM +Element.prototype.scrollIntoView = vi.fn(); + +export const mockUseChat = vi.mocked(useChatModule.useChat); +export const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms); +export const mockFetchModels = vi.mocked(apiModule.fetchModels); +export const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills); +export const mockCreateObjectURL = vi.fn(); +export const mockRevokeObjectURL = vi.fn(); +export const mockClipboardWriteText = vi.fn(); + +export const defaultModelsResponse = { + 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: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", +}; + +export 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(), + renameSession: 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(), +}; + +export 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(), +}; + +export async function renderWithAct(ui: Parameters[0]) { + let result: ReturnType | undefined; + await act(async () => { + result = rtlRender(ui); + }); + return result!; +} + +export 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", +}; + +export function createMockSkill(overrides: Partial): DiscoveredSkill { + return { + id: "skill-id", + name: "skill/name", + path: "/tmp/skills/skill.md", + relativePath: "skills/skill.md", + enabled: true, + metadata: { + source: "*", + scope: "project", + origin: "top-level", + }, + ...overrides, + }; +} + +export function setupMockChat(overrides: Partial = {}) { + const state: UseChatReturn = { ...defaultChatState, ...overrides }; + mockUseChat.mockReturnValue(state); +} + +export function setupMockRooms(overrides: Partial = {}) { + const state: UseChatRoomsResult = { ...defaultRoomsState, ...overrides }; + mockUseChatRooms.mockReturnValue(state); +} + +export 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", + }; +} + +export function setupStatefulCreateRoomMock(options?: { createRejects?: boolean }) { + const createRoom = vi.fn(); + + mockUseChatRooms.mockImplementation(() => { + const [roomsState, setRoomsState] = useState([]); + const [activeRoom, setActiveRoom] = useState(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 }; +} + +export async function renderRoomCreation(options?: { viewport?: "mobile" | "desktop"; createRejects?: boolean }) { + const viewportSpy = mockViewportMode(options?.viewport ?? "mobile"); + const { createRoom } = setupStatefulCreateRoomMock({ createRejects: options?.createRejects }); + setupMockChat({ sessions: [], filteredSessions: [] }); + localStorage.setItem("fusion:chat-scope", "rooms"); + + const user = userEvent.setup({ delay: null }); + await renderWithAct(); + + await user.click(screen.getByTestId("chat-create-room-btn")); + const dialog = await screen.findByRole("dialog", { name: "Create room" }); + fireEvent.change(within(dialog).getByLabelText("Room name"), { target: { value: "newroom" } }); + await user.click(within(screen.getByTestId("create-room-member-list")).getByText("Alpha")); + await user.click(within(dialog).getByRole("button", { name: "Create room" })); + + return { createRoom, viewportSpy }; +} + +export function ensureMatchMedia() { + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn(), + }); + } +} + +export 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)" || query === "(max-width: 768px), (max-height: 480px)"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +/** + * FNXC:DashboardTests 2026-06-25-16:30: + * The former file-level beforeEach/afterEach from ChatView.test.tsx. Each split file calls + * this once at top level so the shared jsdom/global setup (object-URL, clipboard, matchMedia + * desktop default, viewport-height reset) is identical across the parallel chat files. + */ +export function installChatViewEnv() { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + _resetInitialViewportHeight(); + setupMockRooms(); + mockViewportMode("desktop"); + mockFetchModels.mockResolvedValue({ ...defaultModelsResponse }); + 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(); + }); +} diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index ab677a1f4b..33ba1a6e31 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -121,7 +121,6 @@ const qualityAppComponentTests = [ "board-mobile", "board-mobile-view-switch", "BranchGroupCard", - "ChatView", "ChatView.autosize", "ChatView.chat-input-autosize", "ChatView.default-model-icon", @@ -212,7 +211,7 @@ const qualityAppComponentTests = [ // project, but the 231-test suite is now 4 sibling files sharing // SettingsModal.test-harness so the settings project parallelizes them across // workers instead of one ~61s sequential file (FN-5048 feedback-loop velocity). -const isolatedQualityAppComponentTests = ["App", "ChatView"] as const; +const isolatedQualityAppComponentTests = ["App"] as const; const batchedQualityAppComponentTests = qualityAppComponentTests.filter( (testName) => !isolatedQualityAppComponentTests.includes(testName), ); @@ -238,7 +237,20 @@ const qualityAppComponentBatchATests = buildComponentQualityInclude(batchedQuali const qualityAppComponentBatchBTests = buildComponentQualityInclude(batchedQualityAppComponentTestsB); const qualityAppAppOnlyTests = ["app/components/__tests__/App.test.tsx"]; -const qualityAppChatOnlyTests = ["app/components/__tests__/ChatView.test.tsx"]; +/* +FNXC:DashboardTests 2026-06-25-16:30: +ChatView keeps its own isolated `dashboard-app-quality-chat` project, but the 231-test +ChatView.test.tsx is now 3 sibling files sharing ChatView.test-harness so the chat project +parallelizes them across workers instead of one ~24s sequential file (FN-5048 feedback-loop +velocity). Mirror the SettingsModal split: these 3 files live ONLY here (not in +qualityAppComponentTests), so they must be spread into backfillAppExclude too — otherwise +the broad `app/**` backfill glob re-collects them and they run in BOTH projects. +*/ +const qualityAppChatOnlyTests = [ + "app/components/__tests__/ChatView.core.test.tsx", + "app/components/__tests__/ChatView.sessions-rooms.test.tsx", + "app/components/__tests__/ChatView.mobile.test.tsx", +]; const qualityAppSettingsOnlyTests = [ "app/components/__tests__/SettingsModal.general.test.tsx", "app/components/__tests__/SettingsModal.models-auth.test.tsx", @@ -342,6 +354,10 @@ const backfillAppExclude = [ settings project and backfill, doubling their wall-time instead of halving it. */ ...qualityAppSettingsOnlyTests, + // FNXC:DashboardTests 2026-06-25-16:30: same rationale as the settings split above — + // the 3 ChatView split files live only in qualityAppChatOnlyTests, so exclude them from + // the broad app backfill to avoid double-collection. + ...qualityAppChatOnlyTests, ...skipListDashboardGlobs.filter((file) => file.startsWith("app/")), "app/__tests__/build-output.test.ts", ];