FN-9200: preserve docked chat sidebar width

Preserve docked chat behavior across tablet layouts and excluded one-pane hosts.

- Let the inline docked width remain authoritative in the tablet cascade.
- Expand behavioral coverage for viewport eligibility, host exclusions, persistence, resizing, rooms, and header controls.
- Add computed-style coverage for tablet and mobile sidebar widths.

Files changed:
 packages/dashboard/app/components/ChatView.css     |   5 +-
 .../__tests__/ChatView.docked-sidebar-css.test.tsx |  52 +++++
 .../__tests__/ChatView.docked-sidebar.test.tsx     | 212 ++++++++++++++++-----
 3 files changed, 220 insertions(+), 49 deletions(-)

Fusion-Task-Id: FN-9200

Fusion-Task-Lineage: 5e8100ea-1a60-42b5-a64a-5e33b7d4b2c7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 10:35:53 -07:00
parent 4f40c91b38
commit eade82f55d
3 changed files with 222 additions and 51 deletions

View File

@@ -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;

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />); });
}
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(); }
});
});

View File

@@ -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<React.ComponentProps<typeof ChatView>> = {}) {
async function renderSelected(props: Partial<React.ComponentProps<typeof ChatView>> = {}, 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(<ChatView projectId="proj-123" addToast={vi.fn()} {...props} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.documentElement.dataset.viewportMode).toBe("desktop");
desktopView.unmount(); desktop.mockRestore();
const tablet = mockViewportMode("tablet");
const tabletView = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.documentElement.dataset.viewportMode).toBe("tablet");
tabletView.unmount(); tablet.mockRestore();
const restoreTouchTablet = mockTabletClassTouchViewport();
const touchTabletView = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.documentElement.dataset.viewportMode).toBe("tablet");
touchTabletView.unmount(); restoreTouchTablet();
const mobile = mockViewportMode("mobile");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.documentElement.dataset.viewportMode).toBe("mobile");
mobile.mockRestore();
setupMockRooms({ rooms: [room], activeRoom: room });
const view = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const view = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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 () => {