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 && (
setOpen(false)} className="floating-window--chat" suspendGeometryPersistenceOnMobile>
-
+
)}
>
);
}
-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 (