FN-8049: preserve desktop geometry across sheet views

Prevent full-screen sheet opens from replacing saved desktop FloatingWindow geometry.

- Suspend geometry restoration and writes for sheet modal surfaces without adding storage keys.
- Preserve Artifact Gallery geometry at both narrow-width and short-height sheet breakpoints.
- Cover sheet persistence behavior and document desktop geometry restoration.

Files changed:
 docs/dashboard-guide.md                            |   3 +
 packages/dashboard/app/App.tsx                     |   4 +
 .../dashboard/app/components/ArtifactsGallery.tsx  |   3 +
 .../dashboard/app/components/FileBrowserModal.tsx  |   2 +
 .../dashboard/app/components/FloatingWindow.tsx    |  25 ++++-
 .../dashboard/app/components/GitHubImportModal.tsx |   4 +
 .../app/components/MissionInterviewModal.tsx       |   2 +
 .../dashboard/app/components/PrCreateModal.tsx     |   2 +
 .../app/components/ScheduledTasksModal.tsx         |   2 +
 .../app/components/WorkflowNodeEditor.tsx          |   2 +
 .../components/__tests__/FloatingWindow.test.tsx   | 118 ++++++++++++++++++++-
 .../app/hooks/__tests__/useViewportMode.test.ts    |  13 ++-
 packages/dashboard/app/hooks/useViewportMode.ts    |  15 +++
 13 files changed, 190 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8049

Fusion-Task-Lineage: 4c659046-694a-42fa-aca1-e702c1d3842a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 01:19:43 -07:00
parent 4b7f0d2d0e
commit 3f5d7c2147
13 changed files with 190 additions and 5 deletions

View File

@@ -60,6 +60,9 @@ Shortcut handling is intentionally guarded. Fusion ignores global shortcuts whil
Press `Escape` to close the current/topmost dashboard popup. Popped-out task windows and floating Quick Chat close before fixed app modals such as Terminal, Settings, Files, or Task Detail, and only one surface closes per key press. Nested editors and menus that already handle Escape keep first ownership by preventing the global handler.
<!-- FNXC:ModalGeometryPersistenceDocs 2026-07-16-00:40: Full-screen mobile FloatingWindow sheets must preserve, rather than overwrite, the movable desktop geometry record so a later desktop reopen restores the user's chosen location and size. -->
Movable dashboard pop-outs remember their last desktop location and size, while centered resizable dialogs remember their size. When a pop-out becomes a full-screen sheet at mobile widths (or, for Artifact Gallery, its short-height sheet breakpoint), it leaves that desktop record untouched; reopening it on desktop restores the prior floating geometry.
## Mobile/PWA app icons
The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.

View File

@@ -1812,6 +1812,8 @@ function AppInner() {
hideHeader
dragHandleSelector=".chat-view--floating .view-header"
className="floating-window--chat"
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Chat is a full-screen sheet at ≤768px, so preserve its desktop location and size instead of restoring or overwriting them there. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="kb-dashboard-chat-floating-window"
defaultSize={{ width: 980, height: 680 }}
/*
@@ -1865,6 +1867,8 @@ function AppInner() {
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
className="floating-window--task-detail"
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Task pop-outs are full-screen sheets at ≤768px; preserve the shared desktop geometry record for their next movable reopen. */
suspendGeometryPersistenceOnMobile
persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY}
layer="task-detail"
>

View File

@@ -464,6 +464,9 @@ function OverlayShell({ label, onClose, children, wide, closeRef, windowKey, per
dragHandleSelector=".artifacts-gallery-viewer-header"
className="artifacts-gallery-window"
ariaLabel={label}
/* FNXC:ModalGeometryPersistence 2026-07-16-00:40: Artifact viewers are full-screen sheets at ≤768px or ≤480px tall, so neither mobile shape may overwrite desktop geometry. */
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
persistGeometryKey={persistKey}
defaultSize={wide ? { width: 1024, height: 720 } : { width: 720, height: 640 }}
minSize={{ width: 320, height: 280 }}

View File

@@ -378,6 +378,8 @@ export function FileBrowserModal({
className="floating-window--file-browser"
defaultSize={{ width: 1120, height: 720 }}
minSize={{ width: 360, height: 420 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: File Browser is a ≤768px full-screen sheet; preserve its desktop position and size for movable reopen. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="fusion:files-modal-window"
>
{/*

View File

@@ -10,6 +10,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { isFullScreenSheetViewport, isShortViewport } from "../hooks/useViewportMode";
import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDetailFloatingZ } from "./floatingWindowStack";
import "./FloatingWindow.css";
@@ -48,6 +49,10 @@ export interface FloatingWindowProps {
className?: string;
/** Optional localStorage key used to restore the last clamped position and size. */
persistGeometryKey?: string;
/** Skip desktop geometry restoration/writes while this caller renders as a full-screen mobile sheet. */
suspendGeometryPersistenceOnMobile?: boolean;
/** Include the CSS short-viewport sheet breakpoint when suspending geometry persistence. */
suspendGeometryPersistenceOnShortViewport?: boolean;
/**
* Opt-in outside-pointer dismissal for transient windows like Quick Chat.
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
@@ -175,16 +180,30 @@ export function FloatingWindow({
dragHandleSelector,
className,
persistGeometryKey,
suspendGeometryPersistenceOnMobile = false,
suspendGeometryPersistenceOnShortViewport = false,
closeOnOutsidePointerDown = false,
layer = "utility",
ariaLabel,
}: FloatingWindowProps) {
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null);
/*
FNXC:ModalGeometryPersistence 2026-07-16-00:40:
Opt-in sheet callers leave desktop geometry untouched at `max-width: 768px`. Most wide, short
landscape phones remain movable FloatingWindows and must restore geometry; Artifact Gallery opts
into its separate `max-height: 480px` full-screen-sheet CSS breakpoint as well.
*/
const geometryPersistenceSuspended = suspendGeometryPersistenceOnMobile && (
isFullScreenSheetViewport() || (suspendGeometryPersistenceOnShortViewport && isShortViewport())
);
if (!initialGeometry.current) {
const fallbackSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize);
const fallbackPosition = defaultPosition ? clampPosition(defaultPosition, fallbackSize) : defaultPositionFor(windowKey, fallbackSize);
initialGeometry.current = readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize);
initialGeometry.current = geometryPersistenceSuspended
? { size: fallbackSize, position: fallbackPosition }
: readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize);
}
const [size, setSize] = useState<FloatingWindowSize>(() =>
@@ -406,13 +425,13 @@ export function FloatingWindow({
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") return;
if (!persistGeometryKey || typeof window === "undefined" || geometryPersistenceSuspended) return;
try {
localStorage.setItem(persistGeometryKey, JSON.stringify({ size, position }));
} catch {
// Ignore storage failures; geometry persistence is a convenience only.
}
}, [persistGeometryKey, position, size]);
}, [geometryPersistenceSuspended, persistGeometryKey, position, size]);
const panelStyle = {
left: `${position.x}px`,

View File

@@ -1641,6 +1641,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
onClose={() => { setSelectedIssueNumber(null); setSelectedPullNumber(null); }}
defaultSize={{ width: 760, height: 680 }}
minSize={{ width: 420, height: 360 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Import detail is a ≤768px sheet, so preserve its desktop floating geometry instead of touching it on mobile. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="floating-window:github-import-detail"
className="floating-window--github-import-detail"
>
@@ -1938,6 +1940,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
onClose={() => setSelectedGitlabKey(null)}
defaultSize={{ width: 760, height: 680 }}
minSize={{ width: 420, height: 360 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: GitLab detail shares the ≤768px import sheet behavior and must preserve the shared desktop geometry record. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="floating-window:github-import-detail"
className="floating-window--github-import-detail"
>

View File

@@ -780,6 +780,8 @@ export function MissionInterviewModal({
className="floating-window--mission-interview"
defaultSize={{ width: 760, height: 680 }}
minSize={{ width: 560, height: 420 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: This interview is a ≤768px full-screen sheet, so do not replace its stored desktop window geometry while mobile. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="floating-window:mission-interview"
>
{/*

View File

@@ -555,6 +555,8 @@ export function PrCreateModal({
className="floating-window--pr-create"
defaultSize={{ width: 720, height: 680 }}
minSize={{ width: 480, height: 420 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Create PR becomes a ≤768px sheet, so its desktop floating geometry remains intact across mobile opens. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="floating-window:pr-create"
>
{/**

View File

@@ -566,6 +566,8 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
className="floating-window--automation"
defaultSize={{ width: 720, height: 640 }}
minSize={{ width: 420, height: 360 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Automations is a full-screen sheet at ≤768px; its movable desktop geometry must survive mobile opens. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="floating-window:automation"
>
{/**

View File

@@ -5643,6 +5643,8 @@ function InnerEditor({
dragHandleSelector=".wf-editor-header"
defaultSize={{ width: 1200, height: 820 }}
minSize={{ width: 640, height: 480 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: The workflow editor becomes a ≤768px full-screen sheet, so retain desktop geometry without replaying or writing it on the sheet. */
suspendGeometryPersistenceOnMobile
persistGeometryKey="fusion:workflow-node-editor-floating-geometry"
>
{modalElement}

View File

@@ -1,6 +1,6 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
import { FloatingWindow } from "../FloatingWindow";
@@ -38,6 +38,16 @@ function cssRulesForClass(css: string, className: string): string[] {
return [...css.matchAll(new RegExp(`\\.${escaped}[^{}]*\\{[^}]*\\}`, "g"))].map((match) => match[0]);
}
function setSheetViewport(isSheetWidth: boolean): void {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(max-width: 768px)" ? isSheetWidth : query === "(max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Contract tests for the reusable non-blocking floating window:
@@ -52,6 +62,10 @@ describe("FloatingWindow", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders a non-blocking, click-through transparent overlay with a pointer-events:auto panel", () => {
render(
<FloatingWindow windowKey="alpha" title="Alpha" onClose={() => {}}>
@@ -628,6 +642,108 @@ describe("FloatingWindow", () => {
expect(panel.style.top).toBe("90px");
});
it("preserves desktop geometry during opt-in sheet opens and restores it on desktop", () => {
const key = "floating-window:sheet-preserve";
const desktopGeometry = { size: { width: 640, height: 460 }, position: { x: 120, y: 96 } };
localStorage.setItem(key, JSON.stringify(desktopGeometry));
setSheetViewport(true);
const { unmount } = render(
<FloatingWindow
windowKey="sheet-preserve-mobile"
title="Sheet"
onClose={() => {}}
persistGeometryKey={key}
suspendGeometryPersistenceOnMobile
defaultSize={{ width: 500, height: 400 }}
defaultPosition={{ x: 32, y: 48 }}
>
<div>sheet body</div>
</FloatingWindow>,
);
const sheetPanel = screen.getByTestId("floating-window-sheet-preserve-mobile");
expect(sheetPanel.style.width).toBe("500px");
expect(sheetPanel.style.left).toBe("32px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(desktopGeometry);
unmount();
setSheetViewport(false);
render(
<FloatingWindow windowKey="sheet-preserve-desktop" title="Desktop" onClose={() => {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile>
<div>desktop body</div>
</FloatingWindow>,
);
const desktopPanel = screen.getByTestId("floating-window-sheet-preserve-desktop");
expect(desktopPanel.style.width).toBe("640px");
expect(desktopPanel.style.height).toBe("460px");
expect(desktopPanel.style.left).toBe("120px");
expect(desktopPanel.style.top).toBe("96px");
});
it("preserves opt-in geometry during a short-viewport full-screen sheet", () => {
const key = "floating-window:short-sheet";
const desktopGeometry = { size: { width: 640, height: 460 }, position: { x: 120, y: 96 } };
localStorage.setItem(key, JSON.stringify(desktopGeometry));
setSheetViewport(false);
render(
<FloatingWindow
windowKey="short-sheet"
title="Short sheet"
onClose={() => {}}
persistGeometryKey={key}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
defaultSize={{ width: 500, height: 400 }}
defaultPosition={{ x: 32, y: 48 }}
>
<div>short sheet body</div>
</FloatingWindow>,
);
const sheetPanel = screen.getByTestId("floating-window-short-sheet");
expect(sheetPanel.style.width).toBe("500px");
expect(sheetPanel.style.left).toBe("32px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(desktopGeometry);
});
it("keeps opt-in geometry persistence on wide short landscape phones that remain movable", () => {
const key = "floating-window:landscape-phone";
localStorage.setItem(key, JSON.stringify({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } }));
// `isMobileViewport()` would be true for this max-height match, but sheets use only max-width.
setSheetViewport(false);
render(
<FloatingWindow windowKey="landscape-phone" title="Landscape" onClose={() => {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile>
<div>landscape body</div>
</FloatingWindow>,
);
const panel = screen.getByTestId("floating-window-landscape-phone");
expect(panel.style.width).toBe("620px");
expect(panel.style.left).toBe("100px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } });
});
it("continues persistence at sheet width when suspension is not opted in", () => {
const key = "floating-window:sheet-default";
const geometry = { size: { width: 610, height: 440 }, position: { x: 90, y: 72 } };
localStorage.setItem(key, JSON.stringify(geometry));
setSheetViewport(true);
render(
<FloatingWindow windowKey="sheet-default" title="Default" onClose={() => {}} persistGeometryKey={key}>
<div>default body</div>
</FloatingWindow>,
);
const panel = screen.getByTestId("floating-window-sheet-default");
expect(panel.style.width).toBe("610px");
expect(panel.style.left).toBe("90px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(geometry);
});
it("shares geometry only between windows that opt into the same persistence key", () => {
localStorage.setItem(
"floating-window:shared-task-detail",

View File

@@ -1,6 +1,6 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getViewportMode, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
@@ -131,6 +131,17 @@ describe("useViewportMode", () => {
expect(renderHook(() => useViewportMode()).result.current).toBe("mobile");
});
it("matches full-screen sheets by width only, not the landscape-phone mobile clause", () => {
stubScreen(844, 390);
installViewportMedia({ width: false, height: true, tablet: false });
expect(isMobileViewport()).toBe(true);
expect(isFullScreenSheetViewport()).toBe(false);
installViewportMedia({ width: true, height: false, tablet: false });
expect(isFullScreenSheetViewport()).toBe(true);
});
it("keeps tablet mode when only the short-height clause matches on a tablet-class screen", () => {
stubScreen(1024, 768);
installViewportMedia({ width: false, height: true, tablet: true });

View File

@@ -13,6 +13,21 @@ export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
/*
FNXC:ModalGeometryPersistence 2026-07-15-19:30:
Full-screen FloatingWindow sheets use only the CSS width breakpoint. This deliberately diverges from
`isMobileViewport()`: its short landscape-phone clause still renders movable windows, whose desktop
geometry must continue to restore and persist.
*/
export function isFullScreenSheetViewport(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches;
}
/** Returns whether the CSS short-viewport breakpoint is active. */
export function isShortViewport(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches;
}
function hasTouchScreen(): boolean {
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
return "ontouchstart" in window || navigator.maxTouchPoints > 0;