Files
fusion/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx
gsxdsm 92436d031b FN-7120: add mobile New Chat switcher action
Adds a mobile direct-chat shortcut for starting a new chat from the session switcher.

- Add a New Chat menuitem to the mobile direct-chat session dropdown using the existing NewChatDialog flow.
- Style the new mobile switcher action with touch-friendly spacing and focus handling.
- Extend mobile ChatView tests and docs, and add a changeset for the published CLI package.

Files changed:
 .changeset/fn-7120-mobile-new-chat.md              |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/ChatView.css     |  31 ++++++
 packages/dashboard/app/components/ChatView.tsx     |  17 +++
 .../components/__tests__/ChatView.mobile.test.tsx  | 115 +++++++++++++++++++--
 5 files changed, 162 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7120

Fusion-Task-Lineage: 5d066667-20c1-4015-9b84-a9e558eecd48

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 10:45:31 -07:00

1857 lines
70 KiB
TypeScript

/*
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<typeof import("../../hooks/useNavigationHistory")>();
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<typeof import("lucide-react")>();
return {
...actual,
MessageSquare: ({ "data-testid": testId, ...props }: any) => (
<svg data-testid={testId || "icon-message-square"} {...props} />
),
Send: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-send"} {...props} />,
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
Search: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-search"} {...props} />,
Trash2: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-trash"} {...props} />,
Archive: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-archive"} {...props} />,
Pencil: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-pencil"} {...props} />,
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
Eye: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye"} {...props} />,
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
Paperclip: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-paperclip"} {...props} />,
File: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-file"} {...props} />,
Copy: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-copy"} {...props} />,
Check: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-check"} {...props} />,
};
});
// Mock CustomModelDropdown - no longer used but kept for other tests
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
onChange,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => (
<select
data-testid="mock-model-dropdown"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Use default</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
// Mock fetchAgents for new chat dialog
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: [],
favoriteModels: [],
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<string, Array<() => void>> = {
resize: [],
scroll: [],
};
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: 0,
offsetLeft: 0,
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event]?.push(cb);
}),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, "visualViewport", {
value: mockVV,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
function 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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Thread header should not be rendered when there's no active session
expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument();
// Back button should not be visible
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: renders thread header with back button when session is active", 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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Thread header should be rendered when there's an active session
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should be visible in mobile thread view
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: tapping back button calls selectSession with empty string to return to list", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
try {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
selectSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const backBtn = screen.getByTestId("chat-back-btn");
await userEvent.click(backBtn);
// Back button should trigger selectSession("") to return to list view
expect(selectSession).toHaveBeenCalledWith("");
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: thread header title opens quick session switcher and closes after selection", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
try {
setupMockChat({
sessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
],
filteredSessions: [
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
],
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
selectSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const trigger = screen.getByTestId("chat-mobile-session-trigger");
expect(trigger).toHaveClass("btn", "chat-mobile-session-trigger");
expect(trigger).not.toHaveClass("btn-icon");
expect(trigger).toHaveTextContent("Test Chat");
await userEvent.click(trigger);
expect(screen.getByTestId("chat-mobile-session-dropdown")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-mobile-session-option-session-002"));
expect(selectSession).toHaveBeenCalledWith("session-002");
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: quick session switcher closes on outside click and is not shown for rooms", async () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({ activeSession: activeSessionFixture });
const initialRender = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-dropdown")).toBeInTheDocument();
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
});
initialRender.unmount();
localStorage.setItem("fusion:chat-scope", "rooms");
setupMockRooms({
activeRoom: {
id: "room-001",
projectId: "proj-123",
name: "backend",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-mobile-session-new")).not.toBeInTheDocument();
expect(screen.getByText("#backend")).toBeInTheDocument();
} finally {
localStorage.setItem("fusion:chat-scope", "direct");
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: quick session switcher New Chat opens the dialog and closes the menu", async () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
activeSession: activeSessionFixture,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
const dropdown = screen.getByTestId("chat-mobile-session-dropdown");
const newChatItem = within(dropdown).getByRole("menuitem", { name: /new chat/i });
expect(newChatItem).toBe(screen.getByTestId("chat-mobile-session-new"));
await userEvent.click(newChatItem);
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: quick session switcher New Chat renders for single and multiple session lists", async () => {
const restoreMatchMedia = mockMobileViewport();
try {
setupMockChat({
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
activeSession: activeSessionFixture,
});
const singleRender = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /new chat/i })).toBeInTheDocument();
singleRender.unmount();
const anotherSession = {
id: "session-002",
agentId: "agent-002",
status: "active" as const,
title: "Another Chat",
createdAt: "2026-04-07T00:00:00.000Z",
updatedAt: "2026-04-07T00:00:00.000Z",
};
setupMockChat({
sessions: [activeSessionFixture, anotherSession],
filteredSessions: [activeSessionFixture, anotherSession],
activeSession: activeSessionFixture,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();
expect(screen.getAllByRole("menuitem")).toHaveLength(3);
} finally {
restoreMatchMedia.mockRestore();
}
});
it("floating narrow mode: quick session switcher includes New Chat", async () => {
const restoreMatchMedia = mockDesktopViewport();
const originalResizeObserver = globalThis.ResizeObserver;
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 560,
height: 640,
top: 0,
right: 560,
bottom: 640,
left: 0,
toJSON: () => ({}),
});
class MockResizeObserver implements ResizeObserver {
readonly observe = vi.fn();
readonly unobserve = vi.fn();
readonly disconnect = vi.fn();
constructor(_callback: ResizeObserverCallback) {}
}
globalThis.ResizeObserver = MockResizeObserver;
try {
setupMockChat({
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
activeSession: activeSessionFixture,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();
} finally {
globalThis.ResizeObserver = originalResizeObserver;
rectSpy.mockRestore();
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Hello mobile" } });
input.focus();
const sendButton = screen.getByTestId("chat-send-btn");
fireEvent.touchStart(sendButton);
fireEvent.click(sendButton);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith("Hello mobile", []);
expect(document.activeElement).toBe(input);
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: room send button sends on first touch and keeps composer focused", async () => {
const restoreMatchMedia = mockMobileViewport();
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
const sendMessage = vi.fn();
try {
localStorage.setItem("fusion:chat-scope", "rooms");
setupMockChat({
activeSession: activeSessionFixture,
messages: [],
sendMessage,
});
setupMockRooms({
activeRoom: {
id: "room-001",
projectId: "proj-123",
name: "backend",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
sendRoomMessage,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Hello mobile room" } });
input.focus();
const sendButton = screen.getByTestId("chat-send-btn");
fireEvent.touchStart(sendButton);
fireEvent.click(sendButton);
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledTimes(1);
expect(sendRoomMessage).toHaveBeenCalledWith("Hello mobile room", { files: [] });
});
expect(sendMessage).not.toHaveBeenCalled();
expect(document.activeElement).toBe(input);
} finally {
localStorage.removeItem("fusion:chat-scope");
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: sets and clears keyboard overlap CSS vars on chat thread", async () => {
const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
textarea.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
Object.defineProperty(mockVV, "height", { value: 784, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 16, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
expect(thread.style.getPropertyValue("--vv-height")).toBe("784px");
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
expect(thread.style.getPropertyValue("--vv-height")).toBe("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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
expect(thread).toBeInTheDocument();
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
textarea.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
Object.defineProperty(mockVV, "height", { value: 560, writable: true, configurable: true });
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
expect(messagesContainer).toBeInTheDocument();
Object.defineProperty(messagesContainer, "scrollHeight", {
value: 900,
configurable: true,
});
// In jsdom, scrollTop on a non-scrollable div may not reflect writes.
// Intercept the setter so the assertion can read back the value the effect wrote.
let capturedScrollTop = 0;
Object.defineProperty(messagesContainer, "scrollTop", {
get() { return capturedScrollTop; },
set(v: number) { capturedScrollTop = v; },
configurable: true,
});
// Focus the chat textarea so the hook treats the active element as a
// keyboard-focusable target.
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
textarea.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
Object.defineProperty(window, "innerHeight", {
value: 560,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 560,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(messagesContainer.scrollTop).toBe(900);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: does not force window scroll when keyboard opens", async () => {
const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 800,
});
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
Object.defineProperty(window, "innerHeight", {
value: 560,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 560,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(scrollToSpy).not.toHaveBeenCalled();
});
} finally {
scrollToSpy.mockRestore();
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: does not subscribe to keyboard tracking without active session", async () => {
const restoreMatchMedia = mockMobileViewport();
const { mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 600,
});
try {
setupMockChat({
activeSession: null,
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Desktop mode: thread header should always be visible (even in empty state)
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should not be visible in desktop mode
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
// Should show empty state
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("desktop mode: thread header is visible with active session", 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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Desktop mode: thread header should always be visible
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
// Back button should not be visible in desktop mode
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("shows jump-to-latest only after scrolling away from bottom and jumps back on click", async () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1000 });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
scrollTopValue = 600;
fireEvent.scroll(messagesContainer);
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-jump-to-latest"));
expect(scrollTopValue).toBe(1000);
await waitFor(() => {
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("shows jump-to-latest in rooms after scrolling away from bottom and jumps back on click", async () => {
const restoreMatchMedia = mockDesktopViewport();
localStorage.setItem("fusion:chat-scope", "rooms");
try {
setupMockChat({ activeSession: null, messages: [] });
setupMockRooms({
activeRoom: createRoomFixture("general"),
rooms: [createRoomFixture("general")],
messages: [
{
id: "room-msg-001",
roomId: "room-general",
role: "assistant",
content: "One",
thinkingOutput: null,
metadata: null,
senderAgentId: null,
mentions: [],
createdAt: "2026-05-12T00:00:00.000Z",
},
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1000 });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
scrollTopValue = 600;
fireEvent.scroll(messagesContainer);
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-jump-to-latest"));
expect(scrollTopValue).toBe(1000);
await waitFor(() => {
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
});
} finally {
localStorage.removeItem("fusion:chat-scope");
restoreMatchMedia.mockRestore();
}
});
it("FN-3884: snaps to bottom when opening a session with loaded messages", async () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 950 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(scrollTopValue).toBe(950);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("FN-3884: re-anchors when messagesLoading transitions to loaded with messages", async () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({ activeSession: activeSessionFixture, messages: [], messagesLoading: true });
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 980 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
setupMockChat({
activeSession: activeSessionFixture,
messagesLoading: false,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Loaded", createdAt: "2026-04-08T00:00:00.000Z" }],
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(scrollTopValue).toBe(980);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("FN-4040: mobile thread entry anchors to latest message", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1040 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
await waitFor(() => {
expect(scrollTopValue).toBe(1040);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("FN-4040: mobile visibility restore re-anchors chat thread to latest", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 250;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1180 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
fireEvent(document, new Event("visibilitychange"));
scrollTopValue = 300;
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
// 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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
let scrollHeightValue = 1000;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
await waitFor(() => {
expect(scrollTopValue).toBe(1000);
});
scrollHeightValue = 1300;
await act(async () => {
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
});
await waitFor(() => {
expect(scrollTopValue).toBe(1300);
});
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4336: direct chat ResizeObserver growth keeps thread pinned", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallback: ResizeObserverCallback | null = null;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
let scrollHeightValue = 2000;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
fireEvent.scroll(messagesContainer);
scrollHeightValue = 2400;
await act(async () => {
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
});
await waitFor(() => {
expect(scrollTopValue).toBe(2400);
});
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4336: visibility restore performs deferred direct chat settle pass", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
vi.useFakeTimers();
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
let scrollHeightValue = 1000;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
await act(async () => {
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
});
expect(resizeObserverCtor).not.toHaveBeenCalled();
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4336: desktop direct chat still re-anchors on ResizeObserver growth", async () => {
const restoreMatchMedia = mockViewportMode("desktop");
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallback: ResizeObserverCallback | null = null;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
let scrollHeightValue = 1200;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 300 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
await waitFor(() => {
expect(scrollTopValue).toBe(1200);
});
scrollHeightValue = 1700;
await act(async () => {
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
});
await waitFor(() => {
expect(scrollTopValue).toBe(1700);
});
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 600;
let scrollHeightValue = 1200;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
fireEvent.scroll(messagesContainer);
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
await waitFor(() => {
expect(scrollTopValue).toBe(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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(scrollTopValue).toBe(1300);
});
} finally {
restoreMatchMedia.mockRestore();
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
}
});
it("FN-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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 420;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1280 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
fireEvent(window, new Event("pageshow"));
await waitFor(() => {
expect(scrollTopValue).toBe(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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
let scrollHeightValue = 600;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
scrollHeightValue = 900;
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 0;
let scrollHeightValue = 900;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
setupMockChat({
activeSession: { ...activeSessionFixture, id: "session-002" },
messages: [{ id: "msg-101", sessionId: "session-002", role: "assistant", content: "Two", createdAt: "2026-04-08T00:01:00.000Z" }],
});
scrollHeightValue = 1300;
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(scrollTopValue).toBe(1300);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("FN-3884: does not yank when user scrolled up on same-session updates", async () => {
const restoreMatchMedia = mockDesktopViewport();
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
});
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
let scrollTopValue = 700;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1200 });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
fireEvent.scroll(messagesContainer);
setupMockChat({
activeSession: activeSessionFixture,
messages: [
{ id: "msg-000", sessionId: "session-001", role: "assistant", content: "Older", createdAt: "2026-04-07T23:59:00.000Z" },
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(scrollTopValue).toBe(700);
});
} finally {
restoreMatchMedia.mockRestore();
}
});
});
describe("ChatView mobile CSS contract", () => {
const css = loadAllAppCss();
// Helper to find a selector rule within any mobile media query block
function findMobileRule(selector: string): string | null {
const mobileRegex = /@media[^{]*\(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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const loadingNodes = screen.getAllByText("Loading messages...");
const sidebarLoadingNode = screen.getByText("Loading...");
const legacyToken = `--text-${"secondary"}`;
expect(sidebarLoadingNode.className).toContain("chat-empty-state");
expect(sidebarLoadingNode.getAttribute("style") ?? "").not.toContain(legacyToken);
for (const node of loadingNodes) {
expect(node.className).toContain("chat-empty-state");
expect(node.getAttribute("style") ?? "").not.toContain(legacyToken);
}
});
it("keeps ChatView source files free of deprecated secondary token", 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);
});
});