From eaa1d47cf00d5b755f54caab38e5d42e84adb7f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 22 Aug 2026 21:13:02 -0700 Subject: [PATCH] FN-9193: add docked chat conversation sidebar Add a persistent, resizable conversation sidebar to non-floating chat on tablet and desktop layouts. - Keep conversation lists visible beside selected direct and room threads on eligible viewports. - Persist sidebar visibility and width while preserving mobile, compact, and floating one-pane behavior. - Add responsive styling, operator documentation, a release changeset, and focused regression coverage. Files changed: .changeset/fn-9193-chat-docked-sidebar.md | 7 ++ docs/dashboard-guide.md | 12 +- packages/dashboard/app/components/ChatView.css | 58 +++++++++- packages/dashboard/app/components/ChatView.tsx | 120 ++++++++++++++++--- .../__tests__/ChatView.docked-sidebar.test.tsx | 128 +++++++++++++++++++++ .../__tests__/ChatView.sessions-rooms.test.tsx | 29 ++--- .../components/__tests__/ChatView.test-harness.tsx | 41 ++++++- 7 files changed, 360 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-9193 Fusion-Task-Lineage: 58266e56-9d09-4472-98f4-f9d1d80635a7 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9193-chat-docked-sidebar.md | 7 + docs/dashboard-guide.md | 12 +- .../dashboard/app/components/ChatView.css | 58 +++++++- .../dashboard/app/components/ChatView.tsx | 120 ++++++++++++++-- .../ChatView.docked-sidebar.test.tsx | 128 ++++++++++++++++++ .../ChatView.sessions-rooms.test.tsx | 29 ++-- .../__tests__/ChatView.test-harness.tsx | 41 +++++- 7 files changed, 360 insertions(+), 35 deletions(-) create mode 100644 .changeset/fn-9193-chat-docked-sidebar.md create mode 100644 packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx diff --git a/.changeset/fn-9193-chat-docked-sidebar.md b/.changeset/fn-9193-chat-docked-sidebar.md new file mode 100644 index 0000000000..c34929de95 --- /dev/null +++ b/.changeset/fn-9193-chat-docked-sidebar.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a resizable docked conversation sidebar to full Chat. +category: feature +dev: Persists fusion:chat-docked-sidebar-width and fusion:chat-docked-sidebar-open. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 9d2204958e..0e77625611 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -754,9 +754,13 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne ## Chat View -Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread as the full pane, then use the thread-row **< BACK** action to return to the loaded list without interrupting the thread. +Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread. -The shared Chat header owns **New Chat** in both the list and selected detail across embedded Chat, Quick Chat, mobile, and dock hosts. Search and tag filters, archived/restore, and each row's rename, pin, archive, and delete actions remain list-only. Detail intentionally contains the saved conversation title, secondary model metadata when available, and its one **< BACK** action: it does not include a conversation selector or duplicate management controls. The default list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action. +### Docked conversation sidebar + +In the full, non-floating Chat view on tablet and desktop, the Chat header can show or hide a docked conversation sidebar. When shown, it keeps the conversation list next to the open thread; drag its separator or use its arrow keys to resize it between 220px and 480px. Its width and open state are remembered in the browser across reloads. Conversation rows show the saved name, provider icon, model, and last-message snippet. Mobile, compact right-dock Chat, Quick Chat, and popped-out chat windows retain one-pane list/detail navigation with **< BACK**. + +The shared Chat header owns **New Chat** in both the list and selected detail across embedded Chat, Quick Chat, mobile, and dock hosts. Search and tag filters, archived/restore, and each row's rename, pin, archive, and delete actions remain list-only. Detail intentionally contains the saved conversation title and secondary model metadata when available. The default list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action. For an active Direct conversation, open the row actions with desktop right-click or the **⋯** control (including touch, keyboard, compact, and dock hosts), then choose **Open in new window**. Fusion opens an independent in-app Quick Chat window for that conversation; it is not a browser or OS window. You can keep several different conversations open, move and close each one independently, and reopening the same conversation refreshes its existing window instead of duplicating it. Rooms and archived conversations do not offer this action. Escape closes one secondary Quick Chat at a time after popped-out task windows and before the primary Quick Chat; switching projects or choosing all projects closes every secondary window. @@ -2604,3 +2608,7 @@ Settings separates automatic installation from automatic restart. Following an o ### Review finding resolutions The Review tab renders the `dispute-upheld` badge for an adjudicated finding. It is a terminal resolution and cannot be selected for a further revision; it is distinct from the existing Superseded badge. + +### Docked conversation sidebar + +In the full Chat view on tablet and desktop, use the conversation-list button in the Chat header to show or hide a docked conversation sidebar. Drag its separator or use the arrow keys while focused to resize it from 220 to 480; its width and open state are remembered in the browser. Conversation rows show their name, provider, model, and latest-message snippet. Mobile, compact right dock, Quick Chat, and popped-out chat windows retain one-pane conversation navigation. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index da9edb824b..7c60c18012 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -33,6 +33,48 @@ display: none; } +/* +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. +*/ +.chat-view--docked-list .chat-sidebar--docked { + flex: 0 0 auto; + position: relative; + border-right: 1px solid var(--border); + min-width: 0; +} + +.chat-view--docked-list .chat-sidebar--docked .chat-sidebar-list { + min-width: 0; + flex: 1 1 auto; + overflow-y: auto; +} + +.chat-sidebar__resize-handle { + position: absolute; + inset-block: 0; + inset-inline-end: calc(var(--space-xs) / -2); + width: var(--space-sm); + cursor: col-resize; + z-index: 1; + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) var(--transition-default); +} + +.chat-sidebar__resize-handle:hover, +.chat-sidebar__resize-handle:focus-visible { + background: color-mix(in srgb, var(--border), transparent 35%); + outline: none; +} + +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; +} + .chat-sidebar-scope-toggle { display: flex; gap: var(--space-xs); @@ -613,8 +655,8 @@ desktop geometry, and every other ViewHeader consumer remain unchanged. } /* -FNXC:ChatNavigation 2026-08-19-20:40: -A narrow floating host uses the same full-pane list/detail flow as every other Chat host. Keep this layout class for surrounding list sizing only; selected detail hides the list through .chat-sidebar--hidden rather than restoring a split pane. +FNXC:ChatNavigation 2026-08-23-03:55: +A narrow floating host keeps the one-pane list/detail flow. FN-9193 adds a docked list only for non-floating tablet-or-wider hosts, so this narrow layout must still hide selected detail through .chat-sidebar--hidden. */ .chat-view--narrow .chat-view__body { flex-direction: column; @@ -2141,6 +2183,18 @@ Thinking-section text uses the defined muted text token across all themes. The m thread takes the full viewport. The thread already renders a back button (ChevronLeft) on mobile to flip back to the session list. */ @media (max-width: 768px) { + html[data-viewport-mode="tablet"] .chat-view--docked-list .chat-view__body { + flex-direction: row; + } + + html[data-viewport-mode="tablet"] .chat-view--docked-list .chat-sidebar--docked { + min-width: 0; + max-width: none; + height: auto; + border-right: 1px solid var(--border); + border-bottom: none; + } + .chat-view__body { flex-direction: column; } diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 15711686cd..6fa3e6eaa4 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -23,6 +23,8 @@ import { ExternalLink, Tag, FileText, + PanelLeft, + PanelLeftClose, } from "lucide-react"; import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo, type ChatSessionInfo } from "../hooks/useChat"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; @@ -162,6 +164,35 @@ export function resolveChatContextMenuPosition( } /** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */ const ROOM_SKIP_SENTINEL = "__SKIP__"; + +export const CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY = "fusion:chat-docked-sidebar-width"; +export const CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY = "fusion:chat-docked-sidebar-open"; +export const CHAT_DOCKED_SIDEBAR_MIN_WIDTH = 220; +export const CHAT_DOCKED_SIDEBAR_MAX_WIDTH = 480; +export const CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH = 300; + +export function clampChatDockedSidebarWidth(width: number): number { + return Number.isFinite(width) ? Math.max(CHAT_DOCKED_SIDEBAR_MIN_WIDTH, Math.min(CHAT_DOCKED_SIDEBAR_MAX_WIDTH, width)) : CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH; +} + +function readChatDockedSidebarWidth(persist: boolean): number { + if (!persist || typeof window === "undefined") return CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH; + try { + const raw = window.localStorage.getItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY); + const value = raw?.trim() ? Number(raw) : NaN; + return Number.isFinite(value) && value > 0 ? clampChatDockedSidebarWidth(value) : CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH; + } catch { return CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH; } +} + +function readChatDockedSidebarOpen(persist: boolean): boolean { + if (!persist || typeof window === "undefined") return true; + try { return window.localStorage.getItem(CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY) !== "false"; } catch { return true; } +} + +function persistChatDockedSidebarPreference(key: string, value: string, persist: boolean) { + if (!persist || typeof window === "undefined") return; + try { window.localStorage.setItem(key, value); } catch { /* Ignore unavailable storage. */ } +} let chatViewWasPreviouslyInactive = false; let activeChatFindOwner: HTMLElement | null = null; @@ -906,6 +937,16 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return () => observer.disconnect(); }, [floating]); const isChatMobile = isMobile || floatingNarrow || compactLayout; + /* + FNXC:ChatNavigation 2026-08-23-03:40: + FN-9193 restores an optional conversation list only for non-floating tablet-or-wider hosts. + Mobile, compact dock, and floating hosts retain their one-pane list/detail contract. + */ + const [dockedSidebarWidth, setDockedSidebarWidth] = useState(() => readChatDockedSidebarWidth(persistChatPreferences)); + const [dockedSidebarOpen, setDockedSidebarOpen] = useState(() => readChatDockedSidebarOpen(persistChatPreferences)); + const resizeTeardownRef = useRef<(() => void) | null>(null); + const dockedSidebarEligible = !floating && !isChatMobile; + const dockedSidebarVisible = dockedSidebarEligible && dockedSidebarOpen; useEffect(() => { if (!activeSession?.id) { @@ -2600,6 +2641,47 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout [deleteSession, addToast], ); + useEffect(() => () => resizeTeardownRef.current?.(), []); + + const persistDockedWidth = useCallback((nextWidth: number) => { + persistChatDockedSidebarPreference(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY, String(nextWidth), persistChatPreferences); + }, [persistChatPreferences]); + + const handleDockedResizeStart = useCallback((event: React.PointerEvent) => { + event.preventDefault(); + const handle = event.currentTarget; + handle.setPointerCapture?.(event.pointerId); + const startX = event.clientX; + const startWidth = dockedSidebarWidth; + let latestWidth = startWidth; + const priorUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + const onMove = (move: PointerEvent) => { latestWidth = clampChatDockedSidebarWidth(startWidth + move.clientX - startX); setDockedSidebarWidth(latestWidth); }; + const teardown = (up?: PointerEvent) => { + if (up) handle.releasePointerCapture?.(up.pointerId); + document.body.style.userSelect = priorUserSelect; + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + document.removeEventListener("pointercancel", onUp); + resizeTeardownRef.current = null; + persistDockedWidth(latestWidth); + }; + const onUp = (up: PointerEvent) => teardown(up); + resizeTeardownRef.current?.(); + resizeTeardownRef.current = () => teardown(); + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onUp); + }, [dockedSidebarWidth, persistDockedWidth]); + + const handleDockedResizeKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const nextWidth = clampChatDockedSidebarWidth(dockedSidebarWidth + (event.key === "ArrowRight" ? 16 : -16)); + setDockedSidebarWidth(nextWidth); + persistDockedWidth(nextWidth); + }, [dockedSidebarWidth, persistDockedWidth]); + // Handle session click const handleSessionClick = useCallback( (id: string) => { @@ -2780,9 +2862,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout useEffect(() => { const previousDetailOpen = previousDetailOpenRef.current; previousDetailOpenRef.current = hasDetailSelection; - if (previousDetailOpen || !hasDetailSelection) return; + if (previousDetailOpen || !hasDetailSelection || dockedSidebarVisible) return; pushNav({ type: "view", revert: chatScope === "rooms" ? handleRoomBack : handleBack }); - }, [chatScope, handleBack, handleRoomBack, hasDetailSelection, pushNav]); + }, [chatScope, dockedSidebarVisible, handleBack, handleRoomBack, hasDetailSelection, pushNav]); /* FNXC:ChatNavigation 2026-08-20-05:25: @@ -3300,13 +3382,22 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout FNXC:ChatNavigation 2026-08-20-23:57: FN-096 keeps the canonical New Chat action in this shared header for both list and selected-detail states. Embedded, floating, and dock hosts must reuse this one creation entry point while the thread row retains the sole Back action. */ -
+
- {!hasDetailSelection ? scopeToggle : null} + {!hasDetailSelection || dockedSidebarVisible ? scopeToggle : null} + {dockedSidebarEligible ? ( + + ) : null} + : null} #{rooms.activeRoom.name}
{rooms.activeRoomMembers.map((member) => ( @@ -4030,7 +4124,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout always renders Markdown (forcePlain is hardcoded to false). */} {hasThreadInView && (
- + : null}
{activeModelProvider ? : } {/* diff --git a/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx new file mode 100644 index 0000000000..9f53998ae3 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.docked-sidebar.test.tsx @@ -0,0 +1,128 @@ +/* +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. +*/ +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 { + installChatViewEnv, + mockTabletClassTouchViewport, + mockViewportMode, + renderWithAct, + setupMockChat, + setupMockRooms, +} from "./ChatView.test-harness"; + +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), +})); +vi.mock("../../api", () => ({ + fetchSettings: vi.fn().mockResolvedValue({}), + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }), + fetchAgents: vi.fn().mockResolvedValue([]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), +})); + +installChatViewEnv(); + +const session = { + id: "session-001", agentId: "agent-001", status: "active" as const, title: "Architecture discussion", + lastMessagePreview: "The latest conversation message", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", + createdAt: "2026-08-22T00:00:00.000Z", updatedAt: "2026-08-22T00:00:00.000Z", +}; + +async function renderSelected(props: Partial> = {}) { + const selectSession = vi.fn(); + setupMockChat({ activeSession: session, sessions: [session], filteredSessions: [session], selectSession }); + const view = await renderWithAct(); + await act(async () => { fireEvent.click(screen.getByTestId(`chat-session-${session.id}`)); }); + return { view, selectSession }; +} + +describe("ChatView docked conversation sidebar", () => { + it("proves mobile, desktop, and both tablet viewport shapes", async () => { + 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(); + }); + + it("clamps widths and restores valid stored preferences at first render", async () => { + expect(clampChatDockedSidebarWidth(100)).toBe(CHAT_DOCKED_SIDEBAR_MIN_WIDTH); + expect(clampChatDockedSidebarWidth(900)).toBe(CHAT_DOCKED_SIDEBAR_MAX_WIDTH); + expect(clampChatDockedSidebarWidth(300)).toBe(300); + localStorage.setItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY, "900"); + setupMockChat({ sessions: [], filteredSessions: [] }); + 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"); + }); + + it("persists resize and open state across unmount/remount", async () => { + 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: 180 }); + fireEvent.pointerUp(document, { pointerId: 1, clientX: 180 }); + expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY)).toBe("380"); + view.unmount(); + setupMockChat({ sessions: [], filteredSessions: [] }); + await renderWithAct(); + expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("380px"); + }); + + 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("does not persist preferences for ephemeral hosts", async () => { + const { view } = await renderSelected({ persistChatPreferences: false }); + fireEvent.keyDown(screen.getByTestId("chat-sidebar-resize-handle"), { key: "ArrowRight" }); + fireEvent.click(screen.getByTestId("chat-docked-sidebar-toggle")); + expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_WIDTH_STORAGE_KEY)).toBeNull(); + expect(localStorage.getItem(CHAT_DOCKED_SIDEBAR_OPEN_STORAGE_KEY)).toBeNull(); + view.unmount(); + expect(CHAT_DOCKED_SIDEBAR_DEFAULT_WIDTH).toBe(300); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx index c2c97f7908..8d58d36beb 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx @@ -197,12 +197,17 @@ async function selectFirstConversation() { const item = document.querySelector("[data-testid^=\"chat-session-session-\"], [data-testid^=\"chat-room-item-\"]"); if (!item) throw new Error("Expected a conversation list item"); await userEvent.click(item); - await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument()); + await waitFor(() => expect(document.querySelector(".chat-thread")).toBeInTheDocument()); } async function returnToConversationList() { - await userEvent.click(screen.getByTestId("chat-back-btn")); - await waitFor(() => expect(screen.queryByTestId("chat-back-btn")).toBeNull()); + const back = screen.queryByTestId("chat-back-btn"); + if (back) await userEvent.click(back); + else { + await userEvent.click(screen.getByTestId("chat-docked-sidebar-toggle")); + await userEvent.click(screen.getByTestId("chat-back-btn")); + } + await waitFor(() => expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden")); } describe("ChatView sidebar structure", () => { @@ -290,11 +295,11 @@ describe("room creation", () => { viewportSpy.mockRestore(); }); - it("opens the newly created room in full-pane detail on desktop", async () => { + it("keeps the newly created room in the desktop docked list", async () => { const { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "desktop" }); expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] }); - expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--hidden"); + expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--docked"); expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull(); expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument(); @@ -813,17 +818,15 @@ describe("FN-5720 room re-entry anchoring", () => { }); }); -describe("full-pane conversation list", () => { - it("does not render a split-pane resize separator or persisted width shell", async () => { +describe("docked conversation list", () => { + it("renders the persisted split-pane shell on desktop", async () => { const viewportSpy = mockViewportMode("desktop"); - localStorage.setItem("fusion:chat-sidebar-width", "350"); setupMockChat({ sessions: [], filteredSessions: [] }); await renderWithAct(); - expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull(); - expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe(""); - expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("350"); + expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument(); + expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("300px"); viewportSpy.mockRestore(); }); }); @@ -870,7 +873,7 @@ describe("Chat header New Chat button", () => { await selectFirstConversation(); expect(document.querySelectorAll(".view-header [data-testid='chat-new-btn']")).toHaveLength(1); - expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-back-btn")).toBeNull(); expect(document.querySelector(".chat-thread-new-chat-btn")).toBeNull(); viewportSpy.mockRestore(); }); @@ -954,7 +957,7 @@ describe("Chat pop-out header actions", () => { expect(css).toMatch(/\.chat-view--narrow \.chat-view__body\s*\{[^}]*flex-direction:\s*column;/); expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar\s*\{[^}]*min-width:\s*100%;[^}]*border-right:\s*none;/); - expect(css).not.toContain("chat-sidebar-resize-handle"); + expect(css).toContain("chat-sidebar__resize-handle"); expect(css).toMatch(/\.chat-sidebar--hidden\s*\{[^}]*display:\s*none;/); expect(css).toMatch(/\.chat-view--narrow \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/); expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-view \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/); diff --git a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx index 77957fea95..69ad21e4eb 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx @@ -215,14 +215,19 @@ export function ensureMatchMedia() { } } -export function mockViewportMode(mode: "mobile" | "desktop") { +/* +FNXC:DashboardTests 2026-08-23-03:40: +FN-9193 needs real tablet mode rather than desktop-shaped assertions; retain existing mobile and + desktop matching semantics while providing the 769–1024px media-query shape. +*/ +export function mockViewportMode(mode: "mobile" | "tablet" | "desktop") { ensureMatchMedia(); - const isMobile = mode === "mobile"; - Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true }); + const width = { mobile: 375, tablet: 900, desktop: 1280 }[mode]; + Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: - isMobile && - (query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)"), + (mode === "mobile" && (query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)")) || + (mode === "tablet" && query.includes("min-width: 769px") && query.includes("max-width: 1024px")), media: query, onchange: null, addListener: vi.fn(), @@ -233,6 +238,32 @@ export function mockViewportMode(mode: "mobile" | "desktop") { })); } +/* +FNXC:DashboardTests 2026-08-23-03:40: +FN-9193 also covers tablet-class touch hardware at 768 CSS pixels. Touch and physical screen +signals are required by useViewportMode; without them this setup silently becomes mobile. +*/ +export function mockTabletClassTouchViewport() { + ensureMatchMedia(); + const previousWidth = window.innerWidth; + const previousScreen = window.screen; + const maxTouchDescriptor = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); + Object.defineProperty(window, "innerWidth", { value: 768, configurable: true }); + Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true }); + Object.defineProperty(window, "screen", { value: { width: 768, height: 1024 }, configurable: true }); + const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query.includes("max-width: 768px") && !query.includes("max-width: 600px") && !query.includes("max-height: 480px"), + media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + })); + return () => { + spy.mockRestore(); + Object.defineProperty(window, "innerWidth", { value: previousWidth, configurable: true }); + Object.defineProperty(window, "screen", { value: previousScreen, configurable: true }); + if (maxTouchDescriptor) Object.defineProperty(navigator, "maxTouchPoints", maxTouchDescriptor); + else delete (navigator as { maxTouchPoints?: number }).maxTouchPoints; + }; +} + /** * FNXC:DashboardTests 2026-06-25-16:30: * The former file-level beforeEach/afterEach from ChatView.test.tsx. Each split file calls