From eade82f55d188d2f96101ef7ddbbe1b985174164 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 10:35:53 -0700 Subject: [PATCH] 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) --- .../dashboard/app/components/ChatView.css | 5 +- .../ChatView.docked-sidebar-css.test.tsx | 52 +++++ .../ChatView.docked-sidebar.test.tsx | 216 ++++++++++++++---- 3 files changed, 222 insertions(+), 51 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/ChatView.docked-sidebar-css.test.tsx 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 () => {