Files
fusion/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx
Fusion 056b1a9d6a feat(FN-4179): optimistically clear room composer on send
Implements optimistic room composer clear in the ChatView, providing instant UI feedback when switching rooms without waiting for server confirmation, with accompanying tests and documentation updates.

Fusion-Task-Id: FN-4179

Fusion-Task-Lineage: 0c3588fa-81ca-464b-b001-506c1756d0ea
2026-05-12 17:04:33 -07:00

634 lines
24 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
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() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
};
});
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
const activeSession: 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",
};
const defaultChatState: UseChatReturn = {
sessions: [activeSession],
activeSession,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [activeSession],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
const roomA = {
id: "room-a",
name: "Room A",
slug: "room-a",
description: null,
projectId: "proj-123",
createdBy: "agent-1",
status: "active" as const,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
const defaultRoomsState: UseChatRoomsResult = {
rooms: [roomA],
roomsLoading: false,
roomsError: null,
activeRoom: roomA,
activeRoomMembers: [],
messages: [{ id: "rmsg-1", roomId: "room-a", role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] }],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
refreshRooms: vi.fn(),
};
function setup(chatOverrides: Partial<UseChatReturn> = {}, roomsOverrides: Partial<UseChatRoomsResult> = {}) {
mockUseChat.mockReturnValue({ ...defaultChatState, ...chatOverrides });
mockUseChatRooms.mockReturnValue({ ...defaultRoomsState, ...roomsOverrides });
}
function mockMobileViewport() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
}
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function mockMobileVisualViewport({ innerHeight, vvHeight }: { innerHeight: number; vvHeight: number }) {
const resizeListeners = new Set<() => void>();
const scrollListeners = new Set<() => void>();
const mockVV = {
height: vvHeight,
offsetTop: 0,
addEventListener: vi.fn((event: string, cb: () => void) => {
if (event === "resize") resizeListeners.add(cb);
if (event === "scroll") scrollListeners.add(cb);
}),
removeEventListener: vi.fn((event: string, cb: () => void) => {
if (event === "resize") resizeListeners.delete(cb);
if (event === "scroll") scrollListeners.delete(cb);
}),
};
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true, writable: true });
Object.defineProperty(window, "visualViewport", { value: mockVV, configurable: true, writable: true });
return { mockVV, listeners: { resize: resizeListeners, scroll: scrollListeners } };
}
function mockDesktopViewport() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
}
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(),
}));
}
function mockMessagesContainerMetrics({
scrollHeight,
clientHeight = 200,
initialScrollTop = 0,
}: {
scrollHeight: number;
clientHeight?: number;
initialScrollTop?: number;
}) {
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollHeight");
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "clientHeight");
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollTop");
let scrollTopValue = initialScrollTop;
Object.defineProperty(HTMLDivElement.prototype, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLDivElement.prototype, "clientHeight", {
configurable: true,
get: () => clientHeight,
});
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
return {
getScrollTop: () => scrollTopValue,
setScrollTop: (value: number) => {
scrollTopValue = value;
},
restore: () => {
if (scrollHeightDescriptor) {
Object.defineProperty(HTMLDivElement.prototype, "scrollHeight", scrollHeightDescriptor);
} else {
delete (HTMLDivElement.prototype as Partial<HTMLDivElement>).scrollHeight;
}
if (clientHeightDescriptor) {
Object.defineProperty(HTMLDivElement.prototype, "clientHeight", clientHeightDescriptor);
} else {
delete (HTMLDivElement.prototype as Partial<HTMLDivElement>).clientHeight;
}
if (scrollTopDescriptor) {
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", scrollTopDescriptor);
} else {
delete (HTMLDivElement.prototype as Partial<HTMLDivElement>).scrollTop;
}
},
};
}
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
beforeEach(() => {
_resetInitialViewportHeight();
vi.clearAllMocks();
localStorage.clear();
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
}
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(),
}));
localStorage.setItem("fusion:chat-scope", "rooms");
setup();
});
it("renders Direct/Rooms toggle and allows room selection without message leakage", async () => {
const selectRoom = vi.fn();
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { rooms: [roomA, roomB], selectRoom });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-direct")).toBeInTheDocument();
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
expect(screen.getByText("Room hello")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-room-item-room-b"));
expect(selectRoom).toHaveBeenCalledWith("room-b");
});
it("creates room via modal and sends room message on Enter", async () => {
const createRoom = vi.fn().mockResolvedValue({ ...roomA, id: "room-new", name: "Room New", slug: "room-new" });
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { createRoom, sendRoomMessage });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
await userEvent.type(screen.getByLabelText("Room name"), "room-new");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
const modal = screen.getByRole("dialog", { name: "Create room" });
await userEvent.click(within(modal).getByRole("button", { name: "Create room" }));
await waitFor(() => {
expect(createRoom).toHaveBeenCalledWith({ name: "room-new", memberAgentIds: ["agent-1"] });
});
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Hello room{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Hello room");
});
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
});
it("keeps room composer text and toasts once when room send fails", async () => {
const addToast = vi.fn();
let rejectSend: (error?: unknown) => void;
const sendPromise = new Promise<undefined>((_, reject) => {
rejectSend = reject;
});
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
setup({}, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await userEvent.type(textarea, "Will retry{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Will retry");
});
expect(textarea.value).toBe("");
rejectSend!(new Error("Room backend failed"));
await waitFor(() => {
expect(textarea.value).toBe("Will retry");
});
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Room backend failed", "error");
});
it("clears room composer optimistically before send resolves", async () => {
let resolveSend: () => void;
const sendPromise = new Promise<void>((resolve) => {
resolveSend = resolve;
});
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
setup({}, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await userEvent.type(textarea, "Optimistic clear{enter}");
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("Optimistic clear");
});
expect(textarea.value).toBe("");
resolveSend!();
await waitFor(() => {
expect(textarea.value).toBe("");
});
});
it("supports delete-room confirm/cancel and rerenders messages from hook state", async () => {
const deleteRoom = vi.fn().mockResolvedValue(undefined);
const rerenderedRooms = {
...defaultRoomsState,
messages: [{ id: "rmsg-2", roomId: "room-a", role: "assistant", content: "Updated room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-2", mentions: [] }],
deleteRoom,
};
mockUseChat.mockReturnValue(defaultChatState);
mockUseChatRooms
.mockReturnValueOnce({ ...defaultRoomsState, deleteRoom })
.mockReturnValue(rerenderedRooms);
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-delete-room-a"));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(deleteRoom).not.toHaveBeenCalled();
await userEvent.click(screen.getByTestId("chat-room-delete-room-a"));
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(deleteRoom).toHaveBeenCalledWith("room-a");
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByText("Updated room reply")).toBeInTheDocument();
});
it("shows mobile back button in room thread view", () => {
const mediaSpy = mockMobileViewport();
setup();
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
mediaSpy.mockRestore();
});
it("keeps room composer touch-focus behavior in parity with direct chat on mobile", async () => {
const mediaSpy = mockMobileViewport();
setup(
{
activeSession,
messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }],
},
{
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
},
);
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const roomFocusSpy = vi.spyOn(roomInput, "focus");
await act(async () => {
fireEvent.touchStart(roomInput);
});
expect(roomFocusSpy).toHaveBeenCalledWith({ preventScroll: true });
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const directFocusSpy = vi.spyOn(directInput, "focus");
await act(async () => {
fireEvent.touchStart(directInput);
});
expect(directFocusSpy).toHaveBeenCalledWith({ preventScroll: true });
mediaSpy.mockRestore();
});
it("applies keyboard-active thread layout in room mode on mobile and preserves direct-chat parity", async () => {
const mediaSpy = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({ innerHeight: 800, vvHeight: 800 });
const originalVisualViewport = window.visualViewport;
const originalInnerHeight = window.innerHeight;
try {
setup(
{
activeSession: activeSession,
messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }],
},
{
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
},
);
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
Object.defineProperty(window, "innerHeight", { value: 560, configurable: true, writable: true });
Object.defineProperty(mockVV, "height", { value: 560, configurable: true, writable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
const roomThread = document.querySelector(".chat-thread") as HTMLDivElement;
await waitFor(() => {
expect(roomThread.classList.contains("chat-thread--keyboard-active")).toBe(true);
expect(roomThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
});
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
directInput.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
const directThread = document.querySelector(".chat-thread") as HTMLDivElement;
await waitFor(() => {
expect(directThread.classList.contains("chat-thread--keyboard-active")).toBe(true);
expect(directThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
});
} finally {
Object.defineProperty(window, "visualViewport", { value: originalVisualViewport, configurable: true, writable: true });
Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, configurable: true, writable: true });
mediaSpy.mockRestore();
}
});
it("FN-4118: anchors an already-loaded active room to the live tail on mount and remount", async () => {
const restoreMatchMedia = mockDesktopViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 960, clientHeight: 240 });
try {
setup({}, {
activeRoom: roomA,
messagesLoading: false,
messages: [
{ id: "rmsg-1", roomId: roomA.id, role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] },
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Latest room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
],
});
const { unmount } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(960);
});
metrics.setScrollTop(0);
unmount();
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(960);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
}
});
it("FN-4118: anchors to the live tail when a new room message arrives", async () => {
const restoreMatchMedia = mockDesktopViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 980, clientHeight: 240 });
try {
setup({}, {
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
metrics.setScrollTop(980);
fireEvent.scroll(messagesContainer);
setup({}, {
activeRoom: roomA,
messages: [
{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
],
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(980);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
}
});
it("FN-4118: does not yank room scrollback readers when new messages arrive", async () => {
const restoreMatchMedia = mockDesktopViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 720 });
try {
setup({}, {
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
metrics.setScrollTop(720);
fireEvent.scroll(messagesContainer);
setup({}, {
activeRoom: roomA,
messages: [
{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
],
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(720);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
}
});
it("FN-4118: mobile visibility restore re-anchors an active room thread", async () => {
const restoreMatchMedia = mockMobileViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1180, clientHeight: 240, initialScrollTop: 250 });
try {
setup({}, {
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
fireEvent(document, new Event("visibilitychange"));
metrics.setScrollTop(300);
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(1180);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
}
});
it("FN-4118: mobile pageshow restore re-anchors an active room thread", async () => {
const restoreMatchMedia = mockMobileViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1180, clientHeight: 240, initialScrollTop: 250 });
try {
setup({}, {
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
metrics.setScrollTop(300);
fireEvent(window, new Event("pageshow"));
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(1180);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
}
});
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
localStorage.setItem("fusion:chat-scope", "direct");
const addToast = vi.fn();
const sendMessage = vi.fn();
const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed"));
setup({ sendMessage }, { sendRoomMessage, activeRoom: roomA });
render(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "Direct hello{enter}");
expect(sendMessage).toHaveBeenCalledWith("Direct hello", []);
expect(sendRoomMessage).not.toHaveBeenCalled();
expect(addToast).not.toHaveBeenCalled();
});
});