diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b82b694fd4..4bd9727b33 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 415263f3b9..f88e129bbc 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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" > diff --git a/packages/dashboard/app/components/ArtifactsGallery.tsx b/packages/dashboard/app/components/ArtifactsGallery.tsx index 3e310f9cef..275ba15db6 100644 --- a/packages/dashboard/app/components/ArtifactsGallery.tsx +++ b/packages/dashboard/app/components/ArtifactsGallery.tsx @@ -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 }} diff --git a/packages/dashboard/app/components/FileBrowserModal.tsx b/packages/dashboard/app/components/FileBrowserModal.tsx index 6a76ffc65d..84e0d39af7 100644 --- a/packages/dashboard/app/components/FileBrowserModal.tsx +++ b/packages/dashboard/app/components/FileBrowserModal.tsx @@ -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" > {/* diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index c717fa4acf..e7dc706b54 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -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(() => @@ -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`, diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 94687bba0e..a0aed79bfb 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -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" > diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx index c1f686c941..6be52cd9a0 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.tsx +++ b/packages/dashboard/app/components/MissionInterviewModal.tsx @@ -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" > {/* diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index 8102557fc4..f1fd6df230 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -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" > {/** diff --git a/packages/dashboard/app/components/ScheduledTasksModal.tsx b/packages/dashboard/app/components/ScheduledTasksModal.tsx index f1423a9610..e844e88187 100644 --- a/packages/dashboard/app/components/ScheduledTasksModal.tsx +++ b/packages/dashboard/app/components/ScheduledTasksModal.tsx @@ -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" > {/** diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 5cbb0bea51..b1ac74275e 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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} diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 0c9f8336c8..3c859685f3 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -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( {}}> @@ -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( + {}} + persistGeometryKey={key} + suspendGeometryPersistenceOnMobile + defaultSize={{ width: 500, height: 400 }} + defaultPosition={{ x: 32, y: 48 }} + > +
sheet body
+
, + ); + + 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( + {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile> +
desktop body
+
, + ); + 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( + {}} + persistGeometryKey={key} + suspendGeometryPersistenceOnMobile + suspendGeometryPersistenceOnShortViewport + defaultSize={{ width: 500, height: 400 }} + defaultPosition={{ x: 32, y: 48 }} + > +
short sheet body
+
, + ); + + 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( + {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile> +
landscape body
+
, + ); + + 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( + {}} persistGeometryKey={key}> +
default body
+
, + ); + + 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", diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index fef90143a1..b95778d8d4 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -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 }); diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index 62b196d3cc..219f0b4418 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -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;