diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css
index b90e9efcdd..49751fec2f 100644
--- a/packages/dashboard/app/components/ChatView.css
+++ b/packages/dashboard/app/components/ChatView.css
@@ -37,6 +37,10 @@
FNXC:ViewportChrome 2026-08-23-03:40:
FN-9193 tablet-class touch devices can be 768 CSS pixels wide, so this scoped docked rule
must override the mobile one-pane rule while phone mode remains unchanged.
+
+FNXC:ViewportChrome 2026-08-23-17:07:
+FN-9200 keeps the docked pane width exclusively in its inline width and min-width style because
+no docked-sidebar custom-property producer exists; the tablet cascade only restores its layout bounds.
*/
.chat-view--docked-list .chat-sidebar--docked {
flex: 0 0 auto;
@@ -69,7 +73,6 @@ must override the mobile one-pane rule while phone mode remains unchanged.
}
html[data-viewport-mode="tablet"] .chat-view--docked-list .chat-sidebar--docked {
- width: var(--chat-docked-sidebar-width, auto);
min-width: 0;
max-width: none;
height: auto;
diff --git a/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar-css.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar-css.test.tsx
new file mode 100644
index 0000000000..7ef3866764
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar-css.test.tsx
@@ -0,0 +1,52 @@
+/*
+FNXC:ViewportChrome 2026-08-23-17:07:
+FN-9200 verifies the rendered cascade because a 768px tablet-class device matches the mobile media
+query; without this proof, a mobile full-width rule could silently collapse the docked pane.
+*/
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import { ChatView } from "../ChatView";
+import { loadAllAppCss } from "../../test/cssFixture";
+import { installChatViewEnv, mockTabletClassTouchViewport, mockViewportMode, setupMockChat } from "./ChatView.test-harness";
+
+vi.mock("../../hooks/useChat");
+vi.mock("../../hooks/useChatRooms");
+vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
+vi.mock("../../api", () => ({ fetchSettings: vi.fn().mockResolvedValue({}), fetchModels: vi.fn().mockResolvedValue({ models: [] }), fetchAgents: vi.fn().mockResolvedValue([]), fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), fetchTasks: vi.fn().mockResolvedValue([]), searchFiles: vi.fn().mockResolvedValue({ files: [] }) }));
+
+installChatViewEnv();
+afterEach(() => { document.head.querySelector("#chat-docked-css")?.remove(); });
+const session = { id: "css-session", agentId: "agent-001", status: "active" as const, title: "CSS", createdAt: "2026-08-22T00:00:00.000Z", updatedAt: "2026-08-22T00:00:00.000Z" };
+
+async function renderCss() {
+ const style = document.createElement("style"); style.id = "chat-docked-css"; style.textContent = loadAllAppCss(); document.head.appendChild(style);
+ setupMockChat({ activeSession: session, sessions: [session], filteredSessions: [session] });
+ await act(async () => { render(); });
+}
+
+describe("ChatView docked sidebar tablet cascade", () => {
+ it("preserves inline docked width on a 768px tablet-class device", async () => {
+ const restore = mockTabletClassTouchViewport();
+ try {
+ await renderCss();
+ const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
+ const style = getComputedStyle(sidebar);
+ expect(sidebar.style.width).toBe("300px");
+ expect(style.minWidth).not.toBe("100%");
+ expect(style.maxWidth).not.toBe("100%");
+ expect(style.width).toBe("300px");
+ } finally { restore(); }
+ });
+
+ it("keeps the mobile one-pane sidebar full width", async () => {
+ const restore = mockViewportMode("mobile");
+ try {
+ await renderCss();
+ const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
+ const style = getComputedStyle(sidebar);
+ expect(document.querySelector(".chat-view")).not.toHaveClass("chat-view--docked-list");
+ expect(style.width).toBe("100%");
+ expect(style.minWidth).toBe("100%");
+ } finally { restore(); }
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx
index 9f53998ae3..4e765ad027 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx
@@ -1,15 +1,17 @@
/*
-FNXC:DashboardTests 2026-08-23-03:55:
-FN-9193 requires both tablet shapes to resolve through useViewportMode before docked-list
-assertions run. These tests also keep persistence and excluded hosts on the shared ChatView path.
+FNXC:DashboardTests 2026-08-23-17:07:
+FN-9200 keeps docked-sidebar eligibility behavioral: all tablet variants must expose the pane and
+all embedded or floating hosts must retain the one-pane Back navigation.
*/
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, screen } from "@testing-library/react";
import { ChatView, CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH, CHAT_DOCKED_SIDEBAR_MAX_WIDTH, CHAT_DOCKED_SIDEBAR_MIN_WIDTH, CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY, CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY, clampChatDockedSidebarWidth } from "../ChatView";
import {
+ createRoomFixture,
installChatViewEnv,
mockTabletClassTouchViewport,
mockViewportMode,
+ mockFetchModels,
renderWithAct,
setupMockChat,
setupMockRooms,
@@ -38,36 +40,95 @@ const session = {
createdAt: "2026-08-22T00:00:00.000Z", updatedAt: "2026-08-22T00:00:00.000Z",
};
-async function renderSelected(props: Partial> = {}) {
+async function renderSelected(props: Partial> = {}, selected = session) {
const selectSession = vi.fn();
- setupMockChat({ activeSession: session, sessions: [session], filteredSessions: [session], selectSession });
+ setupMockChat({ activeSession: selected, sessions: [selected], filteredSessions: [selected], selectSession });
const view = await renderWithAct();
- await act(async () => { fireEvent.click(screen.getByTestId(`chat-session-${session.id}`)); });
+ await act(async () => { fireEvent.click(screen.getByTestId(`chat-session-${selected.id}`)); });
return { view, selectSession };
}
+function expectDocked() {
+ expect(document.querySelector(".chat-view")).toHaveClass("chat-view--docked-list");
+ const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
+ expect(sidebar).toHaveClass("chat-sidebar--docked");
+ expect(sidebar.style.width).toBeTruthy();
+ expect(screen.getByTestId("chat-docked-sidebar-toggle")).toBeInTheDocument();
+ return screen.getByTestId("chat-sidebar-resize-handle");
+}
+
+function expectOnePaneHost() {
+ expect(document.querySelector(".chat-view")).not.toHaveClass("chat-view--docked-list");
+ expect(screen.queryByTestId("chat-docked-sidebar-toggle")).toBeNull();
+ expect(screen.queryByTestId("chat-sidebar-resize-handle")).toBeNull();
+ expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
+}
+
describe("ChatView docked conversation sidebar", () => {
- it("proves mobile, desktop, and both tablet viewport shapes", async () => {
+ it.each([
+ ["desktop", () => mockViewportMode("desktop")],
+ ["769-1024 tablet", () => mockViewportMode("tablet")],
+ ["768px touch tablet", () => mockTabletClassTouchViewport()],
+ ])("renders a resizable docked pane on %s", async (_name, installViewport) => {
+ const restore = installViewport();
+ try {
+ const { view } = await renderSelected();
+ const handle = expectDocked();
+ expect(handle).toHaveAttribute("aria-valuenow", "300");
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
+ expect(handle).toHaveAttribute("aria-valuenow", "316");
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
+ expect(handle).toHaveAttribute("aria-valuenow", "300");
+ view.unmount();
+ } finally { restore(); }
+ });
+
+ it.each([
+ ["mobile", { }, () => mockViewportMode("mobile")],
+ ["compact right dock", { compactLayout: true }, () => mockViewportMode("desktop")],
+ ["floating Quick Chat", { floating: true }, () => mockViewportMode("desktop")],
+ ["popped-out chat", { floating: true, persistChatPreferences: false }, () => mockViewportMode("desktop")],
+ ])("keeps %s in one-pane navigation", async (_name, props, installViewport) => {
+ const restore = installViewport();
+ // FN-9200 must prove the floating exclusion itself, not jsdom's zero-width narrow-host fallback.
+ const bounds = props.floating
+ ? vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ width: 1000 } as DOMRect)
+ : null;
+ try {
+ const { view } = await renderSelected(props);
+ expectOnePaneHost();
+ view.unmount();
+ } finally { bounds?.mockRestore(); restore(); }
+ });
+
+ it("hides Back without leaving a header shell while docked, and restores it when closed", async () => {
+ const { view } = await renderSelected();
+ expectDocked();
+ const header = document.querySelector(".chat-thread-header") as HTMLElement;
+ expect(header.querySelector('[aria-label="Back to conversations"]')).toBeNull();
+ expect(header.querySelector("button.btn, button.btn-icon")).toBeNull();
+ expect(screen.getByTestId("chat-thread-header-identity")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("chat-docked-sidebar-toggle"));
+ expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
+ view.unmount();
+ });
+
+ it("keeps the Rooms scope inside the docked sidebar and removes its Back shell", async () => {
+ const room = createRoomFixture("docked");
+ localStorage.setItem("fusion:chat-scope", "rooms");
setupMockChat({ sessions: [], filteredSessions: [] });
- const desktop = mockViewportMode("desktop");
- const desktopView = await renderWithAct();
- expect(document.documentElement.dataset.viewportMode).toBe("desktop");
- desktopView.unmount(); desktop.mockRestore();
-
- const tablet = mockViewportMode("tablet");
- const tabletView = await renderWithAct();
- expect(document.documentElement.dataset.viewportMode).toBe("tablet");
- tabletView.unmount(); tablet.mockRestore();
-
- const restoreTouchTablet = mockTabletClassTouchViewport();
- const touchTabletView = await renderWithAct();
- expect(document.documentElement.dataset.viewportMode).toBe("tablet");
- touchTabletView.unmount(); restoreTouchTablet();
-
- const mobile = mockViewportMode("mobile");
- await renderWithAct();
- expect(document.documentElement.dataset.viewportMode).toBe("mobile");
- mobile.mockRestore();
+ setupMockRooms({ rooms: [room], activeRoom: room });
+ const view = await renderWithAct();
+ expectDocked();
+ const rooms = screen.getByTestId("chat-sidebar-rooms");
+ expect(rooms.closest(".chat-sidebar")).toHaveClass("chat-sidebar--docked");
+ fireEvent.click(screen.getByTestId(`chat-room-item-${room.slug}`));
+ const header = document.querySelector(".chat-room-thread-header") as HTMLElement;
+ expect(header.querySelector('[aria-label="Back to conversations"]')).toBeNull();
+ expect(header.querySelector("button.btn, button.btn-icon")).toBeNull();
+ fireEvent.click(screen.getByTestId("chat-docked-sidebar-toggle"));
+ expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
+ view.unmount();
});
it("clamps widths and restores valid stored preferences at first render", async () => {
@@ -76,23 +137,9 @@ describe("ChatView docked conversation sidebar", () => {
expect(clampChatDockedSidebarWidth(300)).toBe(300);
localStorage.setItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY, "900");
setupMockChat({ sessions: [], filteredSessions: [] });
- await renderWithAct();
+ const view = await renderWithAct();
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe(`${CHAT_DOCKED_SIDEBAR_MAX_WIDTH}px`);
- });
-
- it("keeps selected desktop threads beside the list and supports toggle and keyboard resize", async () => {
- const { selectSession } = await renderSelected();
- expect(selectSession).toHaveBeenCalledWith(session.id);
- expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--docked");
- expect(screen.queryByTestId("chat-back-btn")).toBeNull();
- const handle = screen.getByTestId("chat-sidebar-resize-handle");
- fireEvent.keyDown(handle, { key: "ArrowRight" });
- expect(handle).toHaveAttribute("aria-valuenow", "316");
- expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY)).toBe("316");
- fireEvent.click(screen.getByTestId("chat-docked-sidebar-toggle"));
- expect(screen.queryByTestId("chat-sidebar-resize-handle")).toBeNull();
- expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
- expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY)).toBe("false");
+ view.unmount();
});
it("persists resize and open state across unmount/remount", async () => {
@@ -102,18 +149,87 @@ describe("ChatView docked conversation sidebar", () => {
fireEvent.pointerMove(document, { pointerId: 1, clientX: 180 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 180 });
expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY)).toBe("380");
+ fireEvent.click(screen.getByTestId("chat-docked-sidebar-toggle"));
+ expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY)).toBe("false");
view.unmount();
- setupMockChat({ sessions: [], filteredSessions: [] });
- await renderWithAct();
+ const remount = await renderSelected();
+ expect(screen.queryByTestId("chat-sidebar-resize-handle")).toBeNull();
+ expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("chat-docked-sidebar-toggle"));
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("380px");
+ remount.view.unmount();
});
- it("excludes mobile, compact dock, and floating hosts", async () => {
- const mobile = mockViewportMode("mobile");
- await renderSelected();
- expect(screen.queryByTestId("chat-docked-sidebar-toggle")).toBeNull();
- expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
- mobile.mockRestore();
+ it.each([[null, "300px"], ["340", "340px"], ["abc", "300px"], ["", "300px"], ["-5", "300px"], ["100", "220px"], ["900", "480px"]])("restores valid, corrupt, and bounded width preference %s", async (stored, expected) => {
+ if (stored !== null) localStorage.setItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY, stored);
+ setupMockChat({ sessions: [], filteredSessions: [] });
+ const view = await renderWithAct();
+ expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe(expected);
+ view.unmount();
+ });
+
+ it("round-trips both closed and default-open preferences", async () => {
+ localStorage.setItem(CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY, "false");
+ const closed = await renderSelected();
+ expect(screen.queryByTestId("chat-sidebar-resize-handle")).toBeNull();
+ closed.view.unmount();
+ localStorage.removeItem(CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY);
+ const open = await renderSelected();
+ expectDocked();
+ open.view.unmount();
+ });
+
+ it("cleans up a pointer resize when unmounted mid-drag", async () => {
+ const setItem = vi.spyOn(Storage.prototype, "setItem");
+ const { view } = await renderSelected();
+ const handle = screen.getByTestId("chat-sidebar-resize-handle");
+ fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 });
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 140 });
+ view.unmount();
+ const writes = setItem.mock.calls.filter(([key]) => key === CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY).length;
+ fireEvent.pointerMove(document, { pointerId: 1, clientX: 300 });
+ fireEvent.pointerUp(document, { pointerId: 1, clientX: 300 });
+ expect(setItem.mock.calls.filter(([key]) => key === CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY)).toHaveLength(writes);
+ setItem.mockRestore();
+ });
+
+ it("renders conversation row fallbacks, populated selection, and the docked empty state", async () => {
+ mockFetchModels.mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
+ const untitled = { ...session, id: "session-untitled", title: "", lastMessagePreview: "", modelProvider: undefined, modelId: undefined };
+ const selectSession = vi.fn();
+ setupMockChat({ activeSession: session, sessions: [session, untitled], filteredSessions: [session, untitled], selectSession });
+ const view = await renderWithAct();
+ const populatedRow = screen.getByTestId(`chat-session-${session.id}`);
+ expect(populatedRow.querySelector(".chat-session-title")).toHaveTextContent(session.title);
+ expect(populatedRow.querySelector(".chat-session-preview")).toHaveTextContent(session.lastMessagePreview);
+ expect(populatedRow.querySelector(".chat-session-meta-model [data-provider='anthropic']")).toBeInTheDocument();
+ const untitledRow = screen.getByTestId(`chat-session-${untitled.id}`);
+ expect(untitledRow.querySelector(".chat-session-title")).toHaveTextContent("Untitled");
+ expect(untitledRow.querySelector(".chat-session-preview")).toHaveTextContent("No messages");
+ expect(untitledRow.querySelector(".chat-session-meta-model [data-provider]")).toBeNull();
+ expect(screen.getByTestId(`chat-session-model-tag-${untitled.id}`)).toHaveTextContent("Fusion");
+ fireEvent.click(untitledRow);
+ expect(selectSession).toHaveBeenCalledWith(untitled.id);
+ expectDocked();
+ view.unmount();
+ setupMockChat({ sessions: [], filteredSessions: [] });
+ const empty = await renderWithAct();
+ expectDocked();
+ expect(screen.getByText("No conversations yet")).toBeInTheDocument();
+ empty.unmount();
+ });
+
+ it("keeps the conversation-row contract on mobile", async () => {
+ const restore = mockViewportMode("mobile");
+ try {
+ const { view } = await renderSelected();
+ const row = screen.getByTestId(`chat-session-${session.id}`);
+ expect(row.querySelector(".chat-session-title")).toHaveTextContent(session.title);
+ expect(row.querySelector(".chat-session-preview")).toHaveTextContent(session.lastMessagePreview);
+ expect(row.querySelector(".chat-session-meta-model [data-provider='anthropic']")).toBeInTheDocument();
+ expect(screen.getByTestId(`chat-session-model-tag-${session.id}`)).toHaveTextContent("Claude Sonnet 4.5");
+ view.unmount();
+ } finally { restore(); }
});
it("does not persist preferences for ephemeral hosts", async () => {