From 482dbe77a4a1e2224d6f3211904c85883f04e5f6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 19:11:56 -0700 Subject: [PATCH] FN-8340: preserve Quick Chat layout on restore Keep minimized Quick Chat mounted and layout-stable for instant restores. - Hide retained floating windows with visibility and disable pointer interaction - Cover conversation-load, session, and scroll retention across desktop and mobile restores - Document the retained Quick Chat behavior Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/FloatingWindow.css | 12 +++-- packages/dashboard/app/components/FloatingWindow.tsx | 9 ++-- packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx | 4 ++ packages/dashboard/app/components/__tests__/QuickChat.persist.test.tsx | 58 +++++++++++++++++----- 5 files changed, 63 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-8340 Fusion-Task-Lineage: 25027098-b89a-481c-bf00-de64cf302628 Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 2 +- .../app/components/FloatingWindow.css | 12 ++-- .../app/components/FloatingWindow.tsx | 9 +-- .../__tests__/FloatingWindow.test.tsx | 4 ++ .../__tests__/QuickChat.persist.test.tsx | 58 +++++++++++++++---- 5 files changed, 63 insertions(+), 22 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1c05307eca..e76d62f38c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -681,7 +681,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio - The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent` - Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list - On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists. -- Closing and reopening Quick Chat keeps the active conversation warm in memory, so messages stay visible without a conversation reload or "Loading conversation…" flash. +- Closing or minimizing Quick Chat keeps the active conversation, panel geometry, and message-list scroll position warm in memory. Reopening is an instant visibility restore with no conversation reload, layout reflow, or "Loading conversation…" flash. - Clicking outside the desktop Quick Chat floating window closes it by default; disable **Settings → General → Close Quick Chat on outside click** to keep it open until you explicitly close/minimize/maximize it. Model, thinking-level, agent, dependency, node, and priority dropdowns that open from Quick Chat are treated as part of the panel even when they render in a page-level portal, so selecting from them does not close Quick Chat. Task pop-out floating windows remain persistent on page clicks. - Quick Chat and task popups use an interaction-driven peer stack: clicking or focusing either overlapping surface raises it above the other. Blocking dialogs opened from Quick Chat, including **Create Room**, claim a fresh top layer on every open so they remain above both the desktop floating panel and the mobile full-screen sheet. - Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued stack and flushes the messages one at a time in FIFO order as active responses complete. diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 6b36adcac2..6902dff866 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -14,13 +14,15 @@ Click-through overlays cannot implement backdrop clicks in CSS/DOM structure. Ou } /* -FNXC:FloatingWindow 2026-07-18-00:00: -Quick Chat closes by hiding its already-mounted portal instead of unmounting ChatView. `display: -none` removes the desktop window and the ≤768px full-screen sheet from paint and interaction while -preserving the child DOM and in-memory scroll/session state for an instant reopen. +FNXC:FloatingWindow 2026-07-18-14:05: +FN-8340 resolves #2114: Quick Chat minimize must retain its desktop window and ≤768px full-screen +sheet in the layout tree. `visibility: hidden` removes paint while preserving child geometry and +message-list scroll state; `pointer-events: none` keeps the already click-through overlay inert. +Never replace this with `display: none`, which collapses layout and makes reopening visibly reflow. */ .floating-window-overlay--hidden { - display: none; + visibility: hidden; + pointer-events: none; } /* diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index f14201d034..0b2f9c15ea 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -476,10 +476,11 @@ export function FloatingWindow({ FNXC:FloatingWindow 2026-06-22-21:10: Rendered via a portal to document.body so the window escapes every ancestor stacking context (board card badges, the List view's sticky sort header + column divider, transformed columns, etc.). Without the portal the panel's z-index battles inside whatever subtree mounted it, letting card dependency/overlap tags and the list divider/sort header paint over the modal. At document.body the 4000+ z-index wins over all page content. - FNXC:FloatingWindow 2026-07-18-00:00: - Hidden windows remain portaled so their child component identity survives a close/reopen cycle. - The hidden overlay is display:none and aria-hidden, which removes it from paint, focus, and - pointer interaction while the effects above suspend invisible-window side effects. + FNXC:FloatingWindow 2026-07-18-14:05: + FN-8340 resolves #2114: hidden Quick Chat windows remain portaled and layout-participating so + child identity, geometry, and message-list scroll survive minimize/restore. The CSS hidden branch + uses visibility (never display:none), while aria-hidden and suspended invisible-window effects + keep the retained surface out of focus and interaction. */ return createPortal(
{ const hiddenOverlay = screen.getByTestId("floating-window-overlay-hidden-chat"); const retainedChild = screen.getByTestId("retained-hidden-child"); const activeTask = screen.getByTestId("floating-window-active-task"); + const hiddenRule = cssRuleFor(floatingWindowCss, ".floating-window-overlay--hidden"); expect(hiddenOverlay).toHaveClass("floating-window-overlay--hidden"); expect(hiddenOverlay).toHaveAttribute("aria-hidden", "true"); + expect(hiddenRule).toContain("visibility: hidden;"); + expect(hiddenRule).toContain("pointer-events: none;"); + expect(hiddenRule).not.toMatch(/display\s*:\s*none/); expect(geometryEvents).toHaveBeenCalledTimes(1); expect(localStorage.getItem(storageKey)).toBeNull(); fireEvent.pointerDown(document.body); diff --git a/packages/dashboard/app/components/__tests__/QuickChat.persist.test.tsx b/packages/dashboard/app/components/__tests__/QuickChat.persist.test.tsx index 49096782bd..dfef9404d2 100644 --- a/packages/dashboard/app/components/__tests__/QuickChat.persist.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChat.persist.test.tsx @@ -1,11 +1,21 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; import { useEffect, useRef, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { FloatingWindow } from "../FloatingWindow"; +const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8"); + +function cssRuleFor(selector: string): string { + const start = floatingWindowCss.indexOf(`${selector} {`); + const end = floatingWindowCss.indexOf("}", start); + return start === -1 || end === -1 ? "" : floatingWindowCss.slice(start, end); +} + interface QuickChatHarnessProps { projectId?: string; onChatMount: () => void; + onConversationLoad: () => void; } /* @@ -14,7 +24,7 @@ This focused App-shaped harness retains the production lifecycle boundary: Quick only after the first open, then passes `hidden={!open}` to FloatingWindow. Keeping the test at this seam proves the user-visible close/reopen symptom without loading App's unrelated dashboard data. */ -function QuickChatHarness({ projectId, onChatMount }: QuickChatHarnessProps) { +function QuickChatHarness({ projectId, onChatMount, onConversationLoad }: QuickChatHarnessProps) { const [open, setOpen] = useState(false); const [everOpenedProjectId, setEverOpenedProjectId] = useState(null); const trackedProjectId = useRef(undefined); @@ -34,16 +44,25 @@ function QuickChatHarness({ projectId, onChatMount }: QuickChatHarnessProps) { {projectId && everOpenedProjectId === projectId && ( )} ); } -function RetainedChatProbe({ projectId, onMount }: { projectId: string; onMount: () => void }) { +/* +FNXC:ChatModal 2026-07-18-14:05: +FN-8340's regression seam models ChatView's mount-time conversation load. A minimize/restore is +only a FloatingWindow visibility change, so the retained probe must not mount or invoke this load +again when Quick Chat reopens. +*/ +function RetainedChatProbe({ projectId, onMount, onConversationLoad }: { projectId: string; onMount: () => void; onConversationLoad: () => void }) { const [session, setSession] = useState("Session one"); - useEffect(() => onMount(), [onMount]); + useEffect(() => { + onMount(); + onConversationLoad(); + }, [onConversationLoad, onMount]); return (
{session} @@ -59,14 +78,15 @@ afterEach(() => { describe("Quick Chat persistent close/reopen lifecycle", () => { it("does not mount ChatView before Quick Chat has ever opened", () => { - render( {}} />); + render( {}} onConversationLoad={() => {}} />); expect(screen.queryByTestId("quick-chat-body")).toBeNull(); }); - it("retains the same chat instance, session, and scroll position across close and reopen", async () => { + it("retains the same chat instance, session, scroll position, and conversation load across close and reopen", async () => { const onChatMount = vi.fn(); - render(); + const onConversationLoad = vi.fn(); + render(); fireEvent.click(screen.getByRole("button", { name: "Open Quick Chat" })); const chatBody = await screen.findByTestId("quick-chat-body"); @@ -75,19 +95,27 @@ describe("Quick Chat persistent close/reopen lifecycle", () => { fireEvent.click(screen.getByRole("button", { name: "Change session" })); expect(screen.getByTestId("quick-chat-session")).toHaveTextContent("Session two"); expect(onChatMount).toHaveBeenCalledTimes(1); + expect(onConversationLoad).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "Close Quick Chat" })); expect(screen.getByTestId("quick-chat-body")).toBe(chatBody); + expect(screen.getByTestId("quick-chat-scroll")).toBe(scroll); expect(screen.getByTestId("floating-window-overlay-quick-chat-persist")).toHaveClass("floating-window-overlay--hidden"); + const hiddenRule = cssRuleFor(".floating-window-overlay--hidden"); + expect(hiddenRule).toContain("visibility: hidden;"); + expect(hiddenRule).toContain("pointer-events: none;"); + expect(hiddenRule).not.toMatch(/display\s*:\s*none/); fireEvent.click(screen.getByRole("button", { name: "Open Quick Chat" })); expect(screen.getByTestId("quick-chat-body")).toBe(chatBody); - expect(screen.getByTestId("quick-chat-scroll")).toHaveProperty("scrollTop", 143); + expect(screen.getByTestId("quick-chat-scroll")).toBe(scroll); + expect(scroll).toHaveProperty("scrollTop", 143); expect(screen.getByTestId("quick-chat-session")).toHaveTextContent("Session two"); expect(onChatMount).toHaveBeenCalledTimes(1); + expect(onConversationLoad).toHaveBeenCalledTimes(1); }); - it("hides the retained desktop window and the mobile full-screen sheet", async () => { + it("hides the retained desktop window and the mobile full-screen sheet without display:none", async () => { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ matches: query === "(max-width: 768px)", media: query, @@ -95,7 +123,7 @@ describe("Quick Chat persistent close/reopen lifecycle", () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), }))); - render( {}} />); + render( {}} onConversationLoad={() => {}} />); fireEvent.click(screen.getByRole("button", { name: "Open Quick Chat" })); await screen.findByTestId("quick-chat-body"); @@ -104,17 +132,23 @@ describe("Quick Chat persistent close/reopen lifecycle", () => { const overlay = screen.getByTestId("floating-window-overlay-quick-chat-persist"); expect(overlay).toHaveClass("floating-window-overlay--hidden"); expect(screen.getByTestId("quick-chat-body")).toBeTruthy(); + const hiddenRule = cssRuleFor(".floating-window-overlay--hidden"); + expect(hiddenRule).toContain("visibility: hidden;"); + expect(hiddenRule).toContain("pointer-events: none;"); + expect(hiddenRule).not.toMatch(/display\s*:\s*none/); }); it("unmounts the old project's retained chat instead of leaking it into the next project", async () => { const onChatMount = vi.fn(); - const { rerender } = render(); + const onConversationLoad = vi.fn(); + const { rerender } = render(); fireEvent.click(screen.getByRole("button", { name: "Open Quick Chat" })); await screen.findByTestId("quick-chat-body"); expect(screen.getByTestId("quick-chat-body")).toHaveAttribute("data-project-id", "project-a"); - rerender(); + rerender(); await waitFor(() => expect(screen.getByTestId("quick-chat-body")).toHaveAttribute("data-project-id", "project-b")); expect(onChatMount).toHaveBeenCalledTimes(2); + expect(onConversationLoad).toHaveBeenCalledTimes(2); }); });