FN-8257: retain quick chat state when closed
Keep Quick Chat mounted after its first opening so closing and reopening preserves its session and scroll state. - Add an opt-in hidden state for FloatingWindow that suspends invisible-window effects. - Retain Quick Chat per project while resetting its React identity on project changes. - Cover persistent close/reopen behavior and hidden floating-window semantics. Files changed: packages/dashboard/app/App.tsx | 25 ++++- .../dashboard/app/components/FloatingWindow.css | 10 ++ .../dashboard/app/components/FloatingWindow.tsx | 38 +++++-- .../components/__tests__/FloatingWindow.test.tsx | 90 +++++++++++++++- .../__tests__/QuickChat.persist.test.tsx | 120 +++++++++++++++++++++ 5 files changed, 274 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-8257 Fusion-Task-Lineage: 6b0cd41c-32e8-4cd4-8c0c-82d0254aa1e4 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -568,6 +568,27 @@ function AppInner() {
|
||||
}, [initialLoadComplete]);
|
||||
|
||||
const [quickChatOpen, setQuickChatOpen] = useState(false);
|
||||
const [quickChatEverOpenedProjectId, setQuickChatEverOpenedProjectId] = useState<string | null>(null);
|
||||
const quickChatProjectIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
/*
|
||||
FNXC:ChatModal 2026-07-18-00:00:
|
||||
FN-8257 requires Quick Chat to mount only after its first open, then remain mounted and hidden
|
||||
across close/reopen so ChatView retains its selected session, transcript, and scroll position.
|
||||
Reset the latch when the project changes so a hidden ChatView cannot leak one project's state
|
||||
into another project; the FloatingWindow key supplies the matching React identity boundary.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const projectId = currentProject?.id;
|
||||
if (quickChatProjectIdRef.current !== projectId) {
|
||||
quickChatProjectIdRef.current = projectId;
|
||||
setQuickChatEverOpenedProjectId(quickChatOpen && projectId ? projectId : null);
|
||||
return;
|
||||
}
|
||||
if (quickChatOpen && projectId) {
|
||||
setQuickChatEverOpenedProjectId(projectId);
|
||||
}
|
||||
}, [currentProject?.id, quickChatOpen]);
|
||||
|
||||
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
// Keyboard visibility controls both MobileNavBar rendering and whether
|
||||
@@ -1829,9 +1850,11 @@ function AppInner() {
|
||||
onOpenChange={setQuickChatOpen}
|
||||
/>
|
||||
)}
|
||||
{quickChatOpen && currentProject && (
|
||||
{currentProject && quickChatEverOpenedProjectId === currentProject.id && (
|
||||
<FloatingWindow
|
||||
key={currentProject.id}
|
||||
windowKey="chat-modal"
|
||||
hidden={!quickChatOpen}
|
||||
title="Chat"
|
||||
onClose={() => setQuickChatOpen(false)}
|
||||
closeOnOutsidePointerDown={quickChatCloseOnOutsideClick}
|
||||
|
||||
@@ -13,6 +13,16 @@ Click-through overlays cannot implement backdrop clicks in CSS/DOM structure. Ou
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
.floating-window-overlay--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-22-20:45:
|
||||
Floating panel positioned by state-driven inline `left/top/width/height` and stacked by inline `z-index`. min/max keep the panel usable and on-screen. `resize: none` because resizing is handled by the corner/edge handles. `pointer-events: auto` re-enables interaction on the panel only.
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface FloatingWindowProps {
|
||||
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
|
||||
*/
|
||||
closeOnOutsidePointerDown?: boolean;
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-07-18-00:00:
|
||||
Quick Chat must hide without unmounting so its active session, messages, and scroll position
|
||||
reopen instantly. This opt-in flag keeps children mounted but removes the window from paint and
|
||||
interaction; defaulting to false preserves every existing FloatingWindow caller unchanged.
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* Layer band for z-index claiming. Task-detail peers (including Quick Chat) interleave by
|
||||
* interaction; unrelated utilities use the global floating stack.
|
||||
@@ -186,6 +193,7 @@ export function FloatingWindow({
|
||||
suspendGeometryPersistenceOnMobile = false,
|
||||
suspendGeometryPersistenceOnShortViewport = false,
|
||||
closeOnOutsidePointerDown = false,
|
||||
hidden = false,
|
||||
layer = "utility",
|
||||
ariaLabel,
|
||||
}: FloatingWindowProps) {
|
||||
@@ -239,6 +247,16 @@ export function FloatingWindow({
|
||||
});
|
||||
}, [claimFrontZ, readCurrentZ]);
|
||||
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-07-18-00:00:
|
||||
A hidden Quick Chat must reclaim a fresh z-index when reopened because another task-detail
|
||||
popup may have been focused while chat was invisible. Hidden windows do not otherwise affect
|
||||
the shared interaction stack.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hidden) bringToFront();
|
||||
}, [bringToFront, hidden]);
|
||||
|
||||
const handleDragPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
@@ -384,16 +402,16 @@ export function FloatingWindow({
|
||||
Root-portaled Activity menus cannot inherit movement from a dragged/resized task popup. Emit a bounded geometry-change signal after FloatingWindow commits new geometry so owning task-detail content can recompute fixed menu coordinates from the live Activity trigger rect.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (hidden || typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, { detail: { windowKey, layer } }));
|
||||
}, [layer, position, size, windowKey]);
|
||||
}, [hidden, layer, position, size, windowKey]);
|
||||
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-27-00:00:
|
||||
Outside-click dismissal is opt-in because the overlay is intentionally click-through for coexisting floating windows. A capture-phase document pointerdown listener is the only reliable outside signal, and it must ignore in-flight drag/resize gestures plus nested modal/floating surfaces so Quick Chat can dismiss from bare-page clicks without making persistent task pop-outs fragile.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!closeOnOutsidePointerDown || typeof document === "undefined") return;
|
||||
if (hidden || !closeOnOutsidePointerDown || typeof document === "undefined") return;
|
||||
|
||||
let lastTouchAt = 0;
|
||||
const markTouch = () => {
|
||||
@@ -423,20 +441,20 @@ export function FloatingWindow({
|
||||
document.removeEventListener("touchend", markTouch);
|
||||
document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
|
||||
};
|
||||
}, [closeOnOutsidePointerDown, onClose]);
|
||||
}, [closeOnOutsidePointerDown, hidden, onClose]);
|
||||
|
||||
/*
|
||||
FNXC:ChatModal 2026-06-22-14:57:
|
||||
Quick Chat reopens should restore the last desktop floating-window size and position while still clamping onto the current viewport. Keep persistence generic and opt-in with persistGeometryKey so each caller controls whether geometry is shared or isolated.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!persistGeometryKey || typeof window === "undefined" || geometryPersistenceSuspended) return;
|
||||
if (hidden || !persistGeometryKey || typeof window === "undefined" || geometryPersistenceSuspended) return;
|
||||
try {
|
||||
localStorage.setItem(persistGeometryKey, JSON.stringify({ size, position }));
|
||||
} catch {
|
||||
// Ignore storage failures; geometry persistence is a convenience only.
|
||||
}
|
||||
}, [geometryPersistenceSuspended, persistGeometryKey, position, size]);
|
||||
}, [geometryPersistenceSuspended, hidden, persistGeometryKey, position, size]);
|
||||
|
||||
const panelStyle = {
|
||||
left: `${position.x}px`,
|
||||
@@ -449,12 +467,18 @@ 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.
|
||||
*/
|
||||
return createPortal(
|
||||
<div
|
||||
className="floating-window-overlay"
|
||||
className={`floating-window-overlay${hidden ? " floating-window-overlay--hidden" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`floating-window-overlay-${windowKey}`}
|
||||
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
|
||||
import { FloatingWindow } from "../FloatingWindow";
|
||||
import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, FloatingWindow } from "../FloatingWindow";
|
||||
|
||||
const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8");
|
||||
const allAppCss = loadAllAppCss();
|
||||
@@ -851,6 +851,94 @@ describe("FloatingWindow", () => {
|
||||
expect(chatPanel.style.top).toBe("140px");
|
||||
});
|
||||
|
||||
it("keeps hidden children mounted while suspending invisible-window effects and reclaiming the task-detail stack", () => {
|
||||
const onClose = vi.fn();
|
||||
const geometryEvents = vi.fn();
|
||||
const storageKey = "floating-window:hidden";
|
||||
window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
|
||||
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<FloatingWindow
|
||||
windowKey="hidden-chat"
|
||||
title="Chat"
|
||||
onClose={onClose}
|
||||
hidden
|
||||
closeOnOutsidePointerDown
|
||||
persistGeometryKey={storageKey}
|
||||
layer="task-detail"
|
||||
>
|
||||
<div data-testid="retained-hidden-child">retained chat</div>
|
||||
</FloatingWindow>
|
||||
<FloatingWindow windowKey="active-task" title="Task" onClose={() => {}} layer="task-detail">
|
||||
<div>active task</div>
|
||||
</FloatingWindow>
|
||||
</>,
|
||||
);
|
||||
|
||||
const hiddenOverlay = screen.getByTestId("floating-window-overlay-hidden-chat");
|
||||
const retainedChild = screen.getByTestId("retained-hidden-child");
|
||||
const activeTask = screen.getByTestId("floating-window-active-task");
|
||||
expect(hiddenOverlay).toHaveClass("floating-window-overlay--hidden");
|
||||
expect(hiddenOverlay).toHaveAttribute("aria-hidden", "true");
|
||||
expect(geometryEvents).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(storageKey)).toBeNull();
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<FloatingWindow
|
||||
windowKey="hidden-chat"
|
||||
title="Chat"
|
||||
onClose={onClose}
|
||||
closeOnOutsidePointerDown
|
||||
persistGeometryKey={storageKey}
|
||||
layer="task-detail"
|
||||
>
|
||||
<div data-testid="retained-hidden-child">retained chat</div>
|
||||
</FloatingWindow>
|
||||
<FloatingWindow windowKey="active-task" title="Task" onClose={() => {}} layer="task-detail">
|
||||
<div>active task</div>
|
||||
</FloatingWindow>
|
||||
</>,
|
||||
);
|
||||
|
||||
const visibleOverlay = screen.getByTestId("floating-window-overlay-hidden-chat");
|
||||
const shownChat = screen.getByTestId("floating-window-hidden-chat");
|
||||
expect(visibleOverlay).not.toHaveClass("floating-window-overlay--hidden");
|
||||
expect(visibleOverlay).not.toHaveAttribute("aria-hidden");
|
||||
expect(screen.getByTestId("retained-hidden-child")).toBe(retainedChild);
|
||||
expect(geometryEvents).toHaveBeenCalledTimes(2);
|
||||
expect(localStorage.getItem(storageKey)).not.toBeNull();
|
||||
expect(Number(shownChat.style.zIndex)).toBeGreaterThan(Number(activeTask.style.zIndex));
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
|
||||
});
|
||||
|
||||
it("keeps the hidden prop opt-in so existing callers retain visible, interactive behavior", () => {
|
||||
const onClose = vi.fn();
|
||||
const geometryEvents = vi.fn();
|
||||
const storageKey = "floating-window:default-hidden-off";
|
||||
window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
|
||||
|
||||
render(
|
||||
<FloatingWindow windowKey="default-hidden-off" title="Visible by default" onClose={onClose} closeOnOutsidePointerDown persistGeometryKey={storageKey}>
|
||||
<div>existing caller body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const overlay = screen.getByTestId("floating-window-overlay-default-hidden-off");
|
||||
expect(overlay).not.toHaveClass("floating-window-overlay--hidden");
|
||||
expect(overlay).not.toHaveAttribute("aria-hidden");
|
||||
expect(geometryEvents).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(storageKey)).not.toBeNull();
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
|
||||
});
|
||||
|
||||
it("makes only the mobile chat floating window full-screen", () => {
|
||||
const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FloatingWindow } from "../FloatingWindow";
|
||||
|
||||
interface QuickChatHarnessProps {
|
||||
projectId?: string;
|
||||
onChatMount: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatModal 2026-07-18-00:00:
|
||||
This focused App-shaped harness retains the production lifecycle boundary: Quick Chat lazily mounts
|
||||
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) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [everOpenedProjectId, setEverOpenedProjectId] = useState<string | null>(null);
|
||||
const trackedProjectId = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedProjectId.current !== projectId) {
|
||||
trackedProjectId.current = projectId;
|
||||
setEverOpenedProjectId(open && projectId ? projectId : null);
|
||||
return;
|
||||
}
|
||||
if (open && projectId) setEverOpenedProjectId(projectId);
|
||||
}, [open, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>Open Quick Chat</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>Close Quick Chat</button>
|
||||
{projectId && everOpenedProjectId === projectId && (
|
||||
<FloatingWindow key={projectId} windowKey="quick-chat-persist" title="Chat" hidden={!open} onClose={() => setOpen(false)} className="floating-window--chat" suspendGeometryPersistenceOnMobile>
|
||||
<RetainedChatProbe projectId={projectId} onMount={onChatMount} />
|
||||
</FloatingWindow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RetainedChatProbe({ projectId, onMount }: { projectId: string; onMount: () => void }) {
|
||||
const [session, setSession] = useState("Session one");
|
||||
useEffect(() => onMount(), [onMount]);
|
||||
return (
|
||||
<div data-testid="quick-chat-body" data-project-id={projectId}>
|
||||
<span data-testid="quick-chat-session">{session}</span>
|
||||
<button type="button" onClick={() => setSession("Session two")}>Change session</button>
|
||||
<div data-testid="quick-chat-scroll" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Quick Chat persistent close/reopen lifecycle", () => {
|
||||
it("does not mount ChatView before Quick Chat has ever opened", () => {
|
||||
render(<QuickChatHarness projectId="project-a" onChatMount={() => {}} />);
|
||||
|
||||
expect(screen.queryByTestId("quick-chat-body")).toBeNull();
|
||||
});
|
||||
|
||||
it("retains the same chat instance, session, and scroll position across close and reopen", async () => {
|
||||
const onChatMount = vi.fn();
|
||||
render(<QuickChatHarness projectId="project-a" onChatMount={onChatMount} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Quick Chat" }));
|
||||
const chatBody = await screen.findByTestId("quick-chat-body");
|
||||
const scroll = screen.getByTestId("quick-chat-scroll");
|
||||
Object.defineProperty(scroll, "scrollTop", { configurable: true, value: 143, writable: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change session" }));
|
||||
expect(screen.getByTestId("quick-chat-session")).toHaveTextContent("Session two");
|
||||
expect(onChatMount).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Quick Chat" }));
|
||||
expect(screen.getByTestId("quick-chat-body")).toBe(chatBody);
|
||||
expect(screen.getByTestId("floating-window-overlay-quick-chat-persist")).toHaveClass("floating-window-overlay--hidden");
|
||||
|
||||
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-session")).toHaveTextContent("Session two");
|
||||
expect(onChatMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the retained desktop window and the mobile full-screen sheet", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})));
|
||||
render(<QuickChatHarness projectId="project-a" onChatMount={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Quick Chat" }));
|
||||
await screen.findByTestId("quick-chat-body");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close Quick Chat" }));
|
||||
|
||||
const overlay = screen.getByTestId("floating-window-overlay-quick-chat-persist");
|
||||
expect(overlay).toHaveClass("floating-window-overlay--hidden");
|
||||
expect(screen.getByTestId("quick-chat-body")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("unmounts the old project's retained chat instead of leaking it into the next project", async () => {
|
||||
const onChatMount = vi.fn();
|
||||
const { rerender } = render(<QuickChatHarness projectId="project-a" onChatMount={onChatMount} />);
|
||||
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(<QuickChatHarness projectId="project-b" onChatMount={onChatMount} />);
|
||||
await waitFor(() => expect(screen.getByTestId("quick-chat-body")).toHaveAttribute("data-project-id", "project-b"));
|
||||
expect(onChatMount).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user