From 43160a7aaef7632486833c5de9b4a46ee4664d4e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 19:52:32 -0700 Subject: [PATCH] FN-8621: migrate complex modals to FloatingWindow Unify complex dashboard modal presentation under persisted FloatingWindow geometry. - Migrate Create Room, Task Detail, Agent Detail, and GitHub Import modal presentations. - Preserve documented embedded and docked exceptions, dismissal behavior, and nested scrolling. - Add presentation-contract coverage and publish dashboard guidance and changesets. Files changed: ...n-8619-resize-persist-modals-floating-window.md | 7 ++ .changeset/fn-8621-create-room-floating-window.md | 7 ++ docs/dashboard-guide.md | 16 +++- docs/dashboard-modal-inventory.md | 12 +-- .../dashboard/app/components/AgentDetailView.css | 16 +--- .../dashboard/app/components/AgentDetailView.tsx | 102 ++++++++++++++++----- .../dashboard/app/components/CreateRoomModal.css | 19 +++- .../dashboard/app/components/CreateRoomModal.tsx | 57 ++++++++---- .../dashboard/app/components/FloatingWindow.css | 13 ++- .../dashboard/app/components/FloatingWindow.tsx | 15 +++ .../dashboard/app/components/GitHubImportModal.css | 11 +-- .../dashboard/app/components/GitHubImportModal.tsx | 40 ++++++-- .../dashboard/app/components/TaskDetailModal.css | 52 +---------- .../dashboard/app/components/TaskDetailModal.tsx | 58 ++++++------ .../__tests__/AgentDetailView.core.test.tsx | 2 +- .../AgentDetailView.mobile-scroll.test.tsx | 6 +- .../components/__tests__/CreateRoomModal.test.tsx | 62 +++++++++++-- .../components/__tests__/FloatingWindow.test.tsx | 1 + .../__tests__/GitHubImportModal.test.tsx | 8 +- ...etailModal.responsive-and-dependencies.test.tsx | 77 +++++++--------- .../__tests__/modal-presentation-contract.test.tsx | 74 +++++++++++++++ .../dashboard/app/hooks/useEmbeddedPresentation.ts | 2 +- .../dashboard/app/hooks/useModalResizePersist.ts | 5 + 23 files changed, 441 insertions(+), 221 deletions(-) Fusion-Task-Id: FN-8621 Fusion-Task-Lineage: 04b6f3fe-d527-4a21-a0cb-489eb20f5e91 Co-authored-by: Fusion (runfusion.ai) --- ...9-resize-persist-modals-floating-window.md | 7 ++ .../fn-8621-create-room-floating-window.md | 7 ++ docs/dashboard-guide.md | 16 ++- docs/dashboard-modal-inventory.md | 12 +-- .../app/components/AgentDetailView.css | 16 +-- .../app/components/AgentDetailView.tsx | 102 +++++++++++++----- .../app/components/CreateRoomModal.css | 19 +++- .../app/components/CreateRoomModal.tsx | 57 +++++++--- .../app/components/FloatingWindow.css | 13 ++- .../app/components/FloatingWindow.tsx | 15 +++ .../app/components/GitHubImportModal.css | 11 +- .../app/components/GitHubImportModal.tsx | 40 +++++-- .../app/components/TaskDetailModal.css | 52 +-------- .../app/components/TaskDetailModal.tsx | 58 +++++----- .../__tests__/AgentDetailView.core.test.tsx | 2 +- .../AgentDetailView.mobile-scroll.test.tsx | 6 +- .../__tests__/CreateRoomModal.test.tsx | 62 +++++++++-- .../__tests__/FloatingWindow.test.tsx | 1 + .../__tests__/GitHubImportModal.test.tsx | 8 +- ...Modal.responsive-and-dependencies.test.tsx | 75 ++++++------- .../modal-presentation-contract.test.tsx | 74 +++++++++++++ .../app/hooks/useEmbeddedPresentation.ts | 2 +- .../app/hooks/useModalResizePersist.ts | 5 + 23 files changed, 440 insertions(+), 220 deletions(-) create mode 100644 .changeset/fn-8619-resize-persist-modals-floating-window.md create mode 100644 .changeset/fn-8621-create-room-floating-window.md create mode 100644 packages/dashboard/app/components/__tests__/modal-presentation-contract.test.tsx diff --git a/.changeset/fn-8619-resize-persist-modals-floating-window.md b/.changeset/fn-8619-resize-persist-modals-floating-window.md new file mode 100644 index 0000000000..448d8c134d --- /dev/null +++ b/.changeset/fn-8619-resize-persist-modals-floating-window.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Agent Detail, Import Tasks, and Task Detail move and resize on tablets; geometry resets once. +category: feature +dev: Replaces size-only modal keys with floating-window identities, preserves Agent Detail mouse-pair dismissal, and retains useModalResizePersist for the Chromium fixture. diff --git a/.changeset/fn-8621-create-room-floating-window.md b/.changeset/fn-8621-create-room-floating-window.md new file mode 100644 index 0000000000..813c06914b --- /dev/null +++ b/.changeset/fn-8621-create-room-floating-window.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Make Create Room movable and resizable with saved window geometry. +category: feature +dev: Uses the floating-window:create-room geometry key and documents supported modal presentation exceptions. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index dff9f89715..95ac09d10e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -102,7 +102,7 @@ Use `FloatingWindow` for a moveable and resizable dashboard surface rather than -Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:"`: `automation` (Scheduled Tasks), `settings`, `git-manager`, `planning-mode`, `changes-diff`, `model-onboarding`, `activity-log`, `scripts`, `add-node`, `connect-node`, `node-detail`, `workflow-add-step`, and `group-task`. The former size-only `fusion:settings-modal-size`, `fusion:git-modal-size`, `fusion:planning-modal-size`, `fusion:changes-diff-modal-size`, and `fusion:model-onboarding-modal-size` keys are superseded by their matching complete geometry records. All of these windows suspend reading and writing geometry on phone and short (`max-height: 480px`) sheet viewports, so a desktop position never leaks into the sheet and a sheet never overwrites the desktop choice. +Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:"`: `automation` (Scheduled Tasks), `settings`, `git-manager`, `planning-mode`, `changes-diff`, `model-onboarding`, `activity-log`, `scripts`, `add-node`, `connect-node`, `node-detail`, `workflow-add-step`, `group-task`, and `create-room`. Create Room uses `floating-window:create-room`; its member picker remains a nested scroll container within the shared window body. The former size-only `fusion:settings-modal-size`, `fusion:git-modal-size`, `fusion:planning-modal-size`, `fusion:changes-diff-modal-size`, and `fusion:model-onboarding-modal-size` keys are superseded by their matching complete geometry records. All of these windows suspend reading and writing geometry on phone and short (`max-height: 480px`) sheet viewports, so a desktop position never leaks into the sheet and a sheet never overwrites the desktop choice. @@ -112,6 +112,20 @@ All non-trivial modals must use `FloatingWindow` with `hideHeader`, a modal-owne Static opt-outs are only brief single-decision alerts without reflowable content or long dwell time: `DuplicateWarningModal`, `AgentErrorDetailsModal`, `ModelSelectionModal`, `ReportModal`, `ResearchTaskActionModal`, `SettingsSyncConflictModal`, and `StashConflictModal`. Their focused acknowledgement or urgent-conflict semantics do not benefit from persistent movable geometry; additions require a documented inventory justification. + +### Supported presentation exceptions + +Every non-trivial dashboard modal is hosted by `FloatingWindow`. It uses the physical-screen-aware `isTabletTouchViewport` contract: phones are **≤767.98px**, tablet-class touch is **≥768px plus touch**, and delegated drag plus all resize hit areas carry `data-resize-hit-target="true"` with an effective target of at least **44px**. This is hit-area-only and never a bare `(pointer: coarse)` rule. `closeOnOutsidePointerDown` defaults **off**; only a surface whose pre-migration backdrop dismissed the dialog may opt in explicitly, preserving its dismissal contract. + +These are supported presentation exceptions, not silently unmigrated dialogs: + +- `TerminalModal` stays docked in docked mode, including its dock-height control; only floating mode is a `FloatingWindow` with project-scoped geometry. +- `AgentDetailView` stays embedded for inline presentation, while its modal presentation uses `FloatingWindow`; the inline branch owns no floating geometry. +- `GitHubImportModal` stays embedded when `useEmbeddedPresentation` resolves embedded presentation. Its `resizePersistEnabled` modal-only gate keeps container-filling imports free of floating chrome; modal presentation uses `FloatingWindow`. +- `RightDockExpandModal` preserves dock-origin content behavior (`surface: "expand"`) while its expanded shell is a `FloatingWindow`. + +A new embedded/docked exception is legitimate only when an owning container must retain its layout, lifecycle, and content origin; it must have an explicit presentation gate and an inventory justification. Brief static opt-outs remain limited to the focused, one-decision dialogs listed above and require the same documented justification. + ## 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/docs/dashboard-modal-inventory.md b/docs/dashboard-modal-inventory.md index 161de933c3..76e10859f4 100644 --- a/docs/dashboard-modal-inventory.md +++ b/docs/dashboard-modal-inventory.md @@ -49,7 +49,7 @@ modal intended to dismiss on outside pointer-down must opt in explicitly. | --- | --- | --- | --- | --- | --- | --- | | `ActivityLogModal.tsx` | A | `ActivityLogModal.tsx:486` `` | already migrated | FN-8619 | Modal uses `floating-window:agent-detail`; paired mouse-only backdrop dismissal remains. | Inline presentation is explicitly gated by `inline` because its owner retains the detail layout and lifecycle. | | `AgentErrorDetailsModal.tsx` | D | `AgentErrorDetailsModal.tsx:65` `.modal-overlay`, `role="dialog"`; direct inspection: no geometry mechanism. | stays static | FN-8607 agent/onboarding/utility | Short blocking error-detail acknowledgement. | Error detail is a brief, fault-recovery acknowledgement; moving/resizing would add state to an urgent recovery path. | | `AgentGenerationModal.tsx` | D | `AgentGenerationModal.tsx:169` `role="dialog"`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Generation progress can be long-running. | — | | `AgentImportModal.tsx` | D | `AgentImportModal.tsx:473` `role="dialog" aria-modal`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Import mapping has nested scroll. | — | @@ -60,12 +60,12 @@ modal intended to dismiss on outside pointer-down must opt in explicitly. | `ArtifactsGallery.tsx#DocViewer` | A | `ArtifactsGallery.tsx:706` `OverlayShell`; `:593` `DocViewerProps` | already migrated | n/a | Sandboxed HTML/markdown preview; headerless delegated handle. | — | | `ChangesDiffModal.tsx` | A | `ChangesDiffModal.tsx:123` `` | already migrated | FN-8621 | `floating-window:create-room`; explicit outside-pointer dismissal; member-picker remains nested scroll owner. | — | | `DockerNodeOnboardingModal.tsx` | D | `DockerNodeOnboardingModal.tsx:214` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Onboarding commands and scroll. | — | | `DuplicateWarningModal.tsx` | D | `DuplicateWarningModal.tsx:41` `.modal-overlay`; direct inspection: no geometry mechanism. | stays static | n/a | Small duplicate-decision confirmation. | This intentionally compact confirmation should stay centered and transient rather than acquire persisted window state. | | `ExperimentalAgentOnboardingModal.tsx` | D | `ExperimentalAgentOnboardingModal.tsx:187` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Experimental onboarding step chrome. | — | | `FileBrowserModal.tsx` | A | `FileBrowserModal.tsx:372` ``; nested import detail remains floating | already migrated | FN-8619 | Modal uses `floating-window:github-import`; nested import detail remains floating. | `useEmbeddedPresentation` / `resizePersistEnabled` explicitly keeps embedded import container-filling. | | `GitManagerModal.tsx` | A | `GitManagerModal.tsx:1385` ``), headerless delegated drag host. | already migrated | FN-8620 | Docked mode is a presentation exception and retains its top-edge resize plus `fusion:terminal-docked-height-`; floating legacy pair superseded by `fusion:terminal-float-geometry-` (one-time reset). | — | +| `TaskDetailModal.tsx` | B | `TaskDetailModal.tsx` `` | already migrated | FN-8619 | Dense tabs and pop-out stacking retained; `task-detail-modal-size` is orphaned for `floating-window:task-detail` (one-time reset). | — | +| `TerminalModal.tsx` | A | Floating mode uses FloatingWindow (`terminal-`), headerless delegated drag host. | already migrated | FN-8620 | Floating legacy pair superseded by `fusion:terminal-float-geometry-` (one-time reset). | Docked mode is explicitly gated and retains dock-height resize / `fusion:terminal-docked-height-` because it owns dock layout. | | `WorkflowAddStepModal.tsx` | A | `WorkflowAddStepModal.tsx:144` ` void | Promise; + /** Distinguishes the task-detail nested modal from the AgentsView window geometry. */ + floatingWindowKey?: string; } type TabId = "dashboard" | "logs" | "mail" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections"; @@ -255,7 +257,7 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string ?? ""; } -export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess }: AgentDetailViewProps) { +export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess, floatingWindowKey = "agent-detail" }: AgentDetailViewProps) { const { t } = useTranslation("app"); const [agent, setAgent] = useState(null); const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1); @@ -276,10 +278,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild const [agentMailbox, setAgentMailbox] = useState(null); const [isLoadingMailbox, setIsLoadingMailbox] = useState(false); const [mailboxError, setMailboxError] = useState(null); - const agentDetailModalRef = useRef(null); const bulkMenuRef = useRef(null); const overlayMouseDownRef = useRef(false); - useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size"); const onCloseRef = useRef(onClose); const addToastRef = useRef(addToast); const agentRef = useRef(null); @@ -902,23 +902,45 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild } return ( -
{ if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }} - onMouseUp={(e) => { - if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose(); - overlayMouseDownRef.current = false; + { if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }, + onMouseUp: (e) => { + if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose(); + overlayMouseDownRef.current = false; + }, }} - role="dialog" - aria-modal="true" > -
+
{t("agents.loading", "Loading agent...")}
-
+
); } @@ -928,19 +950,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild const stateStyle = STATE_COLORS[agent.state]; const health = getHealthStatus(); + /* + FNXC:ModalTouchGeometry 2026-07-26-19:05: + Inline Agent Detail is the supported embedded presentation exception. It fills its owner and + deliberately bypasses FloatingWindow chrome, persistence, and drag/resize affordances. + */ const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal"; const isPauseAllDisabled = isBulkEligibilityLoading || bulkPauseEligibleCount === 0; const isResumeAllDisabled = isBulkEligibilityLoading || bulkResumeEligibleCount === 0; - return ( -
!inline && e.target === e.currentTarget && onClose()} - role={inline ? "region" : "dialog"} - aria-label={inline ? "Agent detail" : undefined} - aria-modal={inline ? undefined : "true"} - > -
+ const detailContent = ( +
{/* Header */}
{/* Identity area: icon + name + badges */} @@ -960,7 +980,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
-

{agent.name}

+

{agent.name}

)}
-
+ ); + + if (inline) { + return
{detailContent}
; + } + + return ( + { if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }, + onMouseUp: (e) => { + if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose(); + overlayMouseDownRef.current = false; + }, + }} + > + {detailContent} + ); } diff --git a/packages/dashboard/app/components/CreateRoomModal.css b/packages/dashboard/app/components/CreateRoomModal.css index 52a01987bb..b5b37e10ec 100644 --- a/packages/dashboard/app/components/CreateRoomModal.css +++ b/packages/dashboard/app/components/CreateRoomModal.css @@ -1,4 +1,19 @@ -.create-room-modal { +/* +FNXC:ModalTouchGeometry 2026-07-26-19:25: +Create Room now lives inside FloatingWindow's body. Fill that host without creating a competing +scroll surface; the member list below remains the only picker scroll owner. +*/ +.floating-window--create-room .floating-window__body { + overflow: hidden; +} + +.floating-window--create-room .create-room-modal { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + border: none; + border-radius: inherit; display: flex; flex-direction: column; gap: var(--space-md); @@ -84,7 +99,7 @@ } @media (max-width: 768px) { - .create-room-modal { + .floating-window--create-room .create-room-modal { width: 100%; } diff --git a/packages/dashboard/app/components/CreateRoomModal.tsx b/packages/dashboard/app/components/CreateRoomModal.tsx index c239d056c7..707328b9a4 100644 --- a/packages/dashboard/app/components/CreateRoomModal.tsx +++ b/packages/dashboard/app/components/CreateRoomModal.tsx @@ -1,11 +1,10 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { createPortal } from "react-dom"; import { fetchAgents } from "../api"; import type { Agent } from "@fusion/core"; import { AgentAvatar } from "./AgentAvatar"; import { LoadingSpinner } from "./LoadingSpinner"; -import { nextFloatingZ } from "./floatingWindowStack"; +import { FloatingWindow } from "./FloatingWindow"; import "./CreateRoomModal.css"; export interface RoomDraft { @@ -52,15 +51,10 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing const nameInputRef = useRef(null); const previousFocusRef = useRef(null); /* - FNXC:ChatRoomModal 2026-07-17-15:56: - Create Room is a blocking dialog launched from Quick Chat's non-blocking FloatingWindow. Because - both surfaces portal to body, claim a fresh shared top-layer z-index on every open so the dialog - stays above its parent on desktop and the mobile full-screen Chat sheet, including after reopen. + FNXC:ModalTouchGeometry 2026-07-26-19:25: + Create Room is a blocking child of Quick Chat. The shared utility layer now claims its fresh + portal z-index on every mount, keeping this dialog above Chat without a bespoke overlay counter. */ - const [overlayZ, setOverlayZ] = useState(undefined); - useLayoutEffect(() => { - if (isOpen) setOverlayZ(nextFloatingZ()); - }, [isOpen]); useEffect(() => { if (!isOpen) return; @@ -89,6 +83,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing return () => window.cancelAnimationFrame(frame); }, [isOpen]); + /* + FNXC:ModalTouchGeometry 2026-07-26-19:25: + FloatingWindow owns the modal focus boundary but not Escape dismissal. Retain this dialog's + existing Escape and explicit prior-focus restoration behavior while moving its presentation. + */ useEffect(() => { if (!isOpen) return; const onKeyDown = (event: KeyboardEvent) => { @@ -150,9 +149,31 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing } }; - return createPortal( -
event.target === event.currentTarget && onClose()} style={overlayZ ? { zIndex: overlayZ } : undefined}> -
event.stopPropagation()}> + /* + FNXC:ModalTouchGeometry 2026-07-26-19:25: + This former portal dialog uses the shared FloatingWindow for clamped, persisted desktop and + tablet-touch geometry. It explicitly opts into outside pointer-down because its former backdrop + closed the dialog; FloatingWindow defaults that behavior off for durable utility windows. + */ + return ( + +

{t("createRoom.title", "Create room")}

@@ -205,6 +226,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
)} + {/* + FNXC:ModalTouchGeometry 2026-07-26-19:25: + The picker, not FloatingWindow's body, remains the nested scroll owner so long member + lists preserve their independent scroll behavior inside the movable dialog. + */}
{loadingAgents ? (
@@ -241,7 +267,6 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
-
, - document.body, + ); } diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 43e937dfe1..daa73cfdaf 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -274,7 +274,9 @@ all floating affordances so persisted desktop geometry cannot imply a draggable .floating-window--model-onboarding .floating-window__body, .floating-window--git-manager .floating-window__body, .floating-window--settings .floating-window__body, -.floating-window--planning-mode .floating-window__body { +.floating-window--planning-mode .floating-window__body, +/* FNXC:ModalTouchGeometry 2026-07-26-19:25: Create Room shares migrated dialogs' host-owned body sizing while its nested member list retains picker scrolling. */ +.floating-window--create-room .floating-window__body { overflow: hidden; } @@ -289,7 +291,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable .floating-window--model-onboarding .model-onboarding-modal, .floating-window--git-manager .gm-modal, .floating-window--settings .settings-modal, -.floating-window--planning-mode .planning-modal { +.floating-window--planning-mode .planning-modal, +.floating-window--create-room .create-room-modal { width: 100%; height: 100%; max-width: none; @@ -326,6 +329,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable .floating-window--setup-wizard, .floating-window--native-shell-onboarding, .floating-window--docker-node-onboarding, + .floating-window--agent-detail, + .floating-window--github-import, .floating-window--mailbox, .floating-window--milestone-slice-interview, .floating-window--subtask-breakdown, @@ -342,6 +347,7 @@ all floating affordances so persisted desktop geometry cannot imply a draggable .floating-window--git-manager, .floating-window--settings, .floating-window--planning-mode, + .floating-window--create-room, .floating-window--chat { inset: 0 !important; width: 100vw !important; @@ -366,6 +372,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable .floating-window--setup-wizard .floating-window__resize-handle, .floating-window--native-shell-onboarding .floating-window__resize-handle, .floating-window--docker-node-onboarding .floating-window__resize-handle, + .floating-window--agent-detail .floating-window__resize-handle, + .floating-window--github-import .floating-window__resize-handle, .floating-window--mailbox .floating-window__resize-handle, .floating-window--milestone-slice-interview .floating-window__resize-handle, .floating-window--subtask-breakdown .floating-window__resize-handle, @@ -382,6 +390,7 @@ all floating affordances so persisted desktop geometry cannot imply a draggable .floating-window--git-manager .floating-window__resize-handle, .floating-window--settings .floating-window__resize-handle, .floating-window--planning-mode .floating-window__resize-handle, + .floating-window--create-room .floating-window__resize-handle, .floating-window--chat .floating-window__resize-handle { display: none; } diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index b449bed36e..2d070b7fe6 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type CSSProperties, + type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; @@ -58,6 +59,12 @@ export interface FloatingWindowProps { * Persistent task/terminal pop-outs must omit this so page clicks do not close them. */ closeOnOutsidePointerDown?: boolean; + /** Mouse-only handlers for hosts whose historical backdrop dismissal cannot use pointer-down semantics. */ + backdropMouseHandlers?: { + onMouseDown?: (event: ReactMouseEvent) => void; + onMouseUp?: (event: ReactMouseEvent) => void; + onClick?: (event: ReactMouseEvent) => void; + }; /** Render as a blocking dialog instead of the default coexisting utility window. */ modal?: boolean; /** Optional legacy hook for callers whose overlay is asserted by existing tests. */ @@ -109,9 +116,14 @@ const FLOATING_WINDOW_OUTSIDE_POINTER_SAFE_SURFACE_SELECTOR = [ ".node-picker-dropdown--portal", ".agent-picker-dropdown--portal", ".priority-picker-dropdown--portal", + ".activity-view-menu", ].join(", "); /* +FNXC:ModalTouchGeometry 2026-08-13-12:00: +FN-8619: Task Detail's body-portaled activity-view menu is a logical child of its modal. +Treating it as safe prevents a preference-enabled outside pointer-down from closing the host. + FNXC:FloatingWindow 2026-07-13-08:01: FN-7943: Quick Chat's outside-pointer dismissal must treat body-portaled dropdowns as logical children of the FloatingWindow. Keep this selector in sync with the sibling FN-7916 ChatThinkingLevelControl and FN-2860 QuickEntryBox portal guards so model, thinking-level, agent, dependency, node, and priority selections do not dismiss the host chat window while bare-page clicks still close it. */ @@ -203,6 +215,7 @@ export function FloatingWindow({ suspendGeometryPersistenceOnMobile = false, suspendGeometryPersistenceOnShortViewport = false, closeOnOutsidePointerDown = false, + backdropMouseHandlers, modal = false, testId, hidden = false, @@ -618,6 +631,8 @@ export function FloatingWindow({ aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} data-testid={testId ?? `floating-window-overlay-${windowKey}`} + {...backdropMouseHandlers} + // FNXC:ModalTouchGeometry 2026-08-13-12:00: FN-8619 keeps Agent Detail's paired mouse-only backdrop contract at the shared modal backdrop; this deliberately does not alter pointer-down dismissal. // 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. style={{ zIndex }} > diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 11e2140581..0737af26ad 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -566,14 +566,11 @@ idle and auto-translate-off views retain their original layout. /* Wider modal for two-pane layout */ .modal.github-import-modal { - width: min(90vw, 1200px); - max-width: 95vw; - min-width: 480px; - height: 80vh; - min-height: 480px; - max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; overflow: hidden; - resize: both; } .github-import-modal__header { diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index df677fb520..94208e8b2f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -37,9 +37,8 @@ import { useGitHubImportAutoTranslate, } from "./GitHubImportTranslateControls"; import type { TFunction } from "i18next"; -import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; -import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; +import { useModalDismissPreference } from "../hooks/useOverlayDismiss"; import { useConfirm } from "../hooks/useConfirm"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence"; @@ -417,7 +416,7 @@ export function buildCheckFixTaskPrompt( } export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, onOpenChatWithPrefill, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) { - const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation); + const { isEmbedded, scrollLockEnabled, escapeEnabled } = useEmbeddedPresentation(presentation); useMobileScrollLock(isOpen && scrollLockEnabled); const { t, i18n } = useTranslation("app"); /* @@ -597,9 +596,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); const remoteLoadRequestIdRef = useRef(0); - const modalRef = useRef(null); - useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size"); - const overlayDismissProps = useOverlayDismiss(onClose); + const dismissOnOutsidePointerDown = useModalDismissPreference(); // Track which owner/repo we've already auto-loaded to prevent duplicate loads const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null); @@ -1578,7 +1575,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o Modal mode is kept byte-identical: same overlay wrapper, header with subtitle + close button, and overlay-dismiss props. */ const inner = ( -
+
{isEmbedded ? ( /* FNXC:RightDockEmbedding 2026-06-22-00:40: @@ -1593,7 +1590,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o ) : (
-

{t("git.importFromGitHub", "Import from GitHub")}

+

{t("git.importFromGitHub", "Import from GitHub")}

{t("git.importSubtitle", "Choose a detected remote, load open issues or pull requests, and import one into the board.")}

@@ -2023,6 +2020,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
+ {/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Import detail is already an independent FloatingWindow and remains unwrapped so it stacks above the migrated root importer. */} {(selectedIssue || selectedPull) && ( ); + /* + FNXC:ModalTouchGeometry 2026-07-26-19:05: + Embedded Import Tasks remains a container-filling presentation exception. resizePersistEnabled + continues to gate modal-only geometry behavior rather than introducing FloatingWindow chrome here. + */ if (isEmbedded) { return
{inner}
; } return ( -
+ {inner} -
+
); } diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 2513f7173b..52f3ac7e5c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -8,25 +8,11 @@ } .modal.task-detail-modal { - width: min(95vw, 800px); - max-width: 95vw; - min-width: 480px; - height: 85vh; - min-height: 480px; - max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; overflow: hidden; - resize: both; -} - -/* -FNXC:TaskModalResize 2026-07-26-11:06: -The tablet-only grip expands outside Task Detail's painted corner. Its default -overflow clipping would make the 44px finger target visible in layout but -unreachable by browser hit testing, so only the explicit touch-resize surface -permits the target outside the panel; true-phone sheets retain clipped content. -*/ -.modal.task-detail-modal.task-modal--touch-resize { - overflow: visible; } /* @@ -1797,36 +1783,6 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a resize: none; } - /* - FNXC:TaskModalResize 2026-08-07-00:00: - Phone CSS also matches a known 768px touch tablet. The shared runtime class - records the physical-screen-aware tablet result, restoring bounded modal - geometry and the existing resize grip without weakening true-phone sheets. - */ - .modal-overlay:has(.task-detail-modal.task-modal--tablet) { - --overlay-padding-top: 6vh; - padding-top: var(--overlay-padding-top); - align-items: flex-start; - justify-content: center; - } - - .modal.task-detail-modal.task-modal--tablet { - width: 98vw; - min-width: 480px; - max-width: 98vw; - height: 92vh; - min-height: 480px; - max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md)); - margin: 0; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - resize: both; - } - - .task-modal--tablet .modal-resize-grip { - display: block; - } - .detail-body--chat { display: flex; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index d04f29ac81..bd8aa67f87 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3,10 +3,10 @@ import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react"; -import { useModalResizePersist } from "../hooks/useModalResizePersist"; -import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode"; +import { useViewportMode } from "../hooks/useViewportMode"; +import { FloatingWindow } from "./FloatingWindow"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; -import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; +import { useModalDismissPreference, useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useColumnLabel } from "../i18n/labels"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; @@ -4074,7 +4074,7 @@ export function TaskDetailContent({ >
- {task.id} + {task.id} {columnLabel(task.column)} @@ -6577,6 +6577,7 @@ export function TaskDetailContent({ projectId={projectId} onClose={() => setSelectedSourceAgentId(null)} addToast={addToast} + floatingWindowKey="agent-detail-task" /> )} @@ -6585,12 +6586,9 @@ export function TaskDetailContent({ } export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) { - const modalRef = useRef(null); const viewportMode = useViewportMode(); - const isTabletTouchResize = isTabletTouchViewport(viewportMode); - useModalResizePersist(modalRef, true, "task-detail-modal-size", { touchTargets: isTabletTouchResize }); useMobileScrollLock(true); - const overlayDismissProps = useOverlayDismiss(onClose); + const dismissOnOutsidePointerDown = useModalDismissPreference(); /* FNXC:TaskDetailSwipeBack 2026-08-07-00:00: Gate predictive-back animation through useViewportMode, the same physical-screen-aware @@ -6599,30 +6597,30 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) { */ const isMobileTransition = viewportMode === "mobile"; - /* - FNXC:TaskModalResize 2026-08-07-00:00: - Known touch tablets at the 768px CSS boundary resolve to `tablet` through - useViewportMode. Carry that single classification into the modal class so CSS - can override phone-sheet rules without a second breakpoint or gesture system. - */ - const isTabletTaskModal = viewportMode === "tablet"; - return ( -
-
- +
+
-
+ ); } diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx index 84f6b48faa..3ca2be70b2 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx @@ -140,7 +140,7 @@ it("keeps modal mode as dialog with close button", async () => { expect(screen.getByRole("dialog")).toBeInTheDocument(); }); - expect(document.querySelector(".agent-detail-overlay")).toBeInTheDocument(); + expect(document.querySelector("[data-testid='floating-window-overlay-agent-detail']")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); }); diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx index 29fcd32c47..f1a68cf35a 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx @@ -232,13 +232,13 @@ describe("AgentDetailView mobile scroll regression (FN-4231)", () => { it("shows mobile task column context without empty task shells (FN-7139)", async () => { mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" })); - const { container } = render(); + const { baseElement } = render(); await waitFor(() => { expect(screen.getAllByText((_, el) => el?.textContent === "FN-MOBILE · In Progress").length).toBeGreaterThanOrEqual(2); }); - expect(container.querySelector(".agent-detail-content")).toBeTruthy(); - expect(container.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress"); + expect(baseElement.querySelector(".agent-detail-content")).toBeTruthy(); + expect(baseElement.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress"); }); it("tabs accept horizontal touch panning and stay non-shrinking on mobile (FN-6450, FN-6865)", async () => { diff --git a/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx b/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx index da6bcb6995..48df738215 100644 --- a/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/CreateRoomModal.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { userEvent } from "@testing-library/user-event"; import { CreateRoomModal, validateRoomName } from "../CreateRoomModal"; import { FloatingWindow } from "../FloatingWindow"; +import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry, expectFloatingWindowStructure } from "./floatingWindowMigration.test-helpers"; import * as apiModule from "../../api"; vi.mock("../../api", () => ({ @@ -38,6 +39,7 @@ describe("validateRoomName", () => { describe("CreateRoomModal", () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); mockFetchAgents.mockResolvedValue([ { id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" }, { id: "agent-2", name: "Beta", role: "reviewer", state: "idle", metadata: {}, createdAt: "", updatedAt: "" }, @@ -84,7 +86,7 @@ describe("CreateRoomModal", () => { ); const chatPanel = screen.getByTestId("floating-window-chat-modal"); - const firstOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement; + const firstOverlay = screen.getByTestId("floating-window-overlay-create-room"); expect(Number(firstOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex)); await screen.findByRole("button", { name: /Alpha/i }); @@ -108,7 +110,7 @@ describe("CreateRoomModal", () => { , ); - const reopenedOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement; + const reopenedOverlay = screen.getByTestId("floating-window-overlay-create-room"); expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex)); expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(firstOverlay.style.zIndex)); }); @@ -124,7 +126,7 @@ describe("CreateRoomModal", () => { , ); - const loadingOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement; + const loadingOverlay = screen.getByTestId("floating-window-overlay-create-room"); expect(screen.getByRole("status")).toHaveTextContent("Loading agents..."); expect(Number(loadingOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-loading").style.zIndex)); loading.unmount(); @@ -139,7 +141,7 @@ describe("CreateRoomModal", () => { , ); - const emptyOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement; + const emptyOverlay = screen.getByTestId("floating-window-overlay-create-room"); expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument(); expect(Number(emptyOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-empty").style.zIndex)); }); @@ -151,10 +153,58 @@ describe("CreateRoomModal", () => { fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); - fireEvent.click(document.querySelector(".modal-overlay.open") as Element); + fireEvent.pointerDown(screen.getByTestId("floating-window-overlay-create-room")); expect(onClose).toHaveBeenCalledTimes(2); }); + it("hosts the dialog in FloatingWindow with persisted touch geometry and sheet recovery", () => { + const { baseElement } = render(); + const panel = expectFloatingWindowStructure("create-room"); + const dialog = within(baseElement).getByRole("dialog", { name: "Create room" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + assertRenderedModalTouchGeometry("create-room", panel.querySelector(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("create-room", () => render()); + }); + + it("focuses the room name, restores prior focus, and keeps the member list as the scroll owner", async () => { + const onClose = vi.fn(); + const { rerender, baseElement } = render( + <> + + + , + ); + const launcher = screen.getByRole("button", { name: "Room launcher" }); + launcher.focus(); + rerender(<>); + const nameInput = await within(baseElement).findByLabelText("Room name"); + await waitFor(() => expect(nameInput).toHaveFocus()); + const memberList = within(baseElement).getByTestId("create-room-member-list"); + expect(memberList).toHaveClass("create-room-modal-member-list"); + expect(getComputedStyle(memberList).overflowY).toBe("auto"); + rerender(<>); + await waitFor(() => expect(screen.getByRole("button", { name: "Room launcher" })).toHaveFocus()); + }); + + it("shows loading, empty, no-match, populated, and selected-member picker states", async () => { + mockFetchAgents.mockImplementationOnce(() => new Promise(() => {})); + const loading = render(); + expect(await screen.findByRole("status")).toHaveTextContent("Loading agents..."); + loading.unmount(); + + mockFetchAgents.mockResolvedValueOnce([]); + const empty = render(); + expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument(); + empty.unmount(); + + render(); + await userEvent.type(await screen.findByLabelText("Members"), "zzz"); + expect(screen.getByText("No agents match your search.")).toBeInTheDocument(); + await userEvent.clear(screen.getByLabelText("Members")); + await userEvent.click(await screen.findByRole("button", { name: /Alpha/i })); + expect(screen.getByTestId("create-room-selected-chips")).toHaveTextContent("Alpha"); + }); + it("shows search-specific empty state copy", async () => { render(); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 462a3d7a13..e62e51ec4b 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -25,6 +25,7 @@ const QUICK_CHAT_PORTALED_MENU_CLASSES = [ "node-picker-dropdown--portal", "agent-picker-dropdown--portal", "priority-picker-dropdown--portal", + "activity-view-menu", ] as const; function cssRuleFor(css: string, selector: string): string { diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index eee1e89645..d03a69af13 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -981,15 +981,15 @@ describe("GitHubImportModal", () => { it("keeps the modal overlay and Escape-to-close in modal mode", async () => { vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]); - const { container } = render( + const { baseElement } = render( , ); await waitFor(() => { expect(screen.getByText("Import from GitHub")).toBeTruthy(); }); - expect(container.querySelector(".modal-overlay")).not.toBeNull(); - expect(container.querySelector(".github-import-modal--embedded")).toBeNull(); + expect(baseElement.querySelector("[data-testid='floating-window-overlay-github-import']")).not.toBeNull(); + expect(baseElement.querySelector(".github-import-modal--embedded")).toBeNull(); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); @@ -2900,7 +2900,7 @@ describe("GitHubImportModal", () => { expect(importSheetRule).toBe(chatSheetRule); expect(importSheetRule).toBe(taskSheetRule); expect(importSheetRule).toContain("inset: 0 !important;"); - expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.floating-window--github-import-detail \.floating-window__resize-handle\s*\{\s*display: none;/); + expect(source).toMatch(/@media \(max-width: 767\.98px\), \(max-height: 480px\)[\s\S]*\.floating-window--github-import-detail \.floating-window__resize-handle\s*\{\s*display: none;/); expect(source).toContain(".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail)"); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index 0d1a8cacac..07cb5b5f14 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -3,6 +3,8 @@ FNXC:TaskDetailTabs 2026-06-17-08:20: FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state. */ import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { @@ -586,18 +588,14 @@ describe("TaskDetailModal", () => { expect(css).not.toMatch(/task-detail-workflow-badge-mobile/); expect(css).not.toMatch(/\.detail-title-row\s+\.detail-workflow-badge\s*\{/); }); - it("keeps desktop and mobile modal sizing guards unchanged", () => { - const css = readDashboardStylesSource(); - const mobileBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".modal-overlay:has(.task-detail-modal)"); - const mobileOverlayBlock = getCssRuleBlock(mobileBlock, ".modal-overlay:has(.task-detail-modal)"); - const mobileModalBlock = getCssRuleBlock(mobileBlock, ".modal.task-detail-modal"); + it("uses FloatingWindow's full-screen sheet guards on phone and short viewports", () => { + const css = readFileSync(resolve(__dirname, "../FloatingWindow.css"), "utf8"); + const sheetBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 767.98px), (max-height: 480px)", ".floating-window--task-detail"); + const sheetRule = getCssRuleBlock(sheetBlock, ".floating-window--task-detail"); - expectBaseRule(css, ".modal.task-detail-modal", "width: min(95vw, 800px);"); - expectBaseRule(css, ".modal.task-detail-modal", "height: 85vh;"); - expect(mobileOverlayBlock).toContain("padding-top: 0;"); - expect(mobileOverlayBlock).toContain("align-items: stretch;"); - expect(mobileModalBlock).toContain("width: 100vw;"); - expect(mobileModalBlock).toContain("height: 100dvh;"); + expect(sheetRule).toContain("inset: 0 !important;"); + expect(sheetRule).toContain("width: 100vw !important;"); + expect(sheetRule).toContain("height: 100dvh !important;"); }); it("reconciles tablet overlay offset with task-detail max-height and widens the modal", () => { @@ -861,7 +859,7 @@ describe("TaskDetailModal", () => { }))); try { - const { container } = render( + const { baseElement: container } = render( { addToast={noop} />, ); - const modal = container.querySelector(".task-detail-modal"); - expect(modal).toHaveClass("task-modal--tablet"); - const grip = modal?.querySelector(".modal-resize-grip") as HTMLElement; - expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner"); - expect(grip).toHaveAttribute("tabindex", "0"); - expect(grip).toHaveAttribute("data-resize-hit-target", "true"); - - // FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must - // remain keyboard discoverable, not merely restore a touch-only grip. - modal!.style.width = "500px"; - modal!.style.height = "400px"; - grip.focus(); - fireEvent.keyDown(grip, { key: "ArrowRight" }); - fireEvent.keyDown(grip, { key: "ArrowDown" }); - - expect(modal!.style.width).toBe("516px"); - expect(modal!.style.height).toBe("416px"); - expect(grip).toHaveAttribute("aria-valuenow", "516"); - expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels"); + const window = container.querySelector("[data-testid='floating-window-task-detail']"); + expect(window).toBeTruthy(); + const handles = window!.querySelectorAll(".floating-window__resize-handle"); + expect(handles).toHaveLength(8); + handles.forEach((handle) => expect(handle).toHaveAttribute("data-resize-hit-target", "true")); + expect(container.querySelector(".modal-resize-grip")).toBeNull(); } finally { if (originalScreen) Object.defineProperty(window, "screen", originalScreen); if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints); @@ -899,7 +884,7 @@ describe("TaskDetailModal", () => { }); it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => { - const { container } = render( + const { baseElement: container } = render( { />, ); expect(container.querySelector(".modal.modal-lg")).toBeTruthy(); - expect(container.querySelector(".modal-overlay.open")).toBeTruthy(); + expect(container.querySelector("[data-testid='floating-window-overlay-task-detail']")).toBeTruthy(); expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy(); expect(container.querySelector(".detail-body")).toBeTruthy(); expect(container.querySelector(".detail-timestamps")).toBeTruthy(); @@ -999,7 +984,7 @@ describe("TaskDetailModal", () => { }); it("keeps dense in-review and standard task controls in their shared footer", () => { - const { container, unmount } = render( + const { baseElement: container, unmount } = render( { addToast={noop} />, ); - const standardFooter = standard.container.querySelector(".modal-actions"); + const standardFooter = standard.baseElement.querySelector(".modal-actions"); expect(standardFooter).toBeTruthy(); expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true); @@ -1051,7 +1036,7 @@ describe("TaskDetailModal", () => { }); it("keeps the triage footer usable when Actions is absent", () => { - const { container } = render( + const { baseElement: container } = render( { />, ); - expect(summary.container.querySelector(".merge-details-card a")).toHaveAttribute("href", "https://github.com/owner/repo/pull/42"); + expect(summary.baseElement.querySelector(".merge-details-card a")).toHaveAttribute("href", "https://github.com/owner/repo/pull/42"); summary.unmount(); render( @@ -1992,7 +1977,7 @@ describe("TaskDetailModal", () => { { id: "FN-002", title: "Add tests", description: "Test coverage", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; - const { container } = render( + const { baseElement: container } = render( { { id: "FN-001", description: "Login is broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; - const { container } = render( + const { baseElement: container } = render( { }); it("renders dependency ID as label when no title or description available", () => { - const { container } = render( + const { baseElement: container } = render( { { id: "FN-001", title: longTitle, description: "Short desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; - const { container } = render( + const { baseElement: container } = render( { { id: "FN-001", title: "Very long title that gets truncated in the UI but should show full text on hover", description: "Desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; - const { container } = render( + const { baseElement: container } = render( { mockFetch.mockResolvedValueOnce(mockDetail); const onOpenDetail = vi.fn(); - const { container } = render( + const { baseElement: container } = render( { const onOpenDetail = vi.fn(); const addToast = vi.fn(); - const { container } = render( + const { baseElement: container } = render( { makeTask({ id: "FN-101", title: "Stale blockedBy dependent", column: "todo" as Column, blockedBy: "FN-099" }), ]; - const { container } = render( + const { baseElement: container } = render( readFileSync(resolve(__dirname, "..", `${name}.tsx`), "utf8"); +const projectDoc = (name: string) => readFileSync(resolve(__dirname, "..", "..", "..", "..", "..", "docs", name), "utf8"); +const inventory = () => projectDoc("dashboard-modal-inventory.md"); +const guide = () => projectDoc("dashboard-guide.md"); + +/* +FNXC:ModalTouchGeometry 2026-07-26-19:25: +FN-8621 publishes one source-level contract for complex presentations. Keep this intentionally +structural: it protects the shared host and explicit presentation gates without mounting slow, +duplicated modal fixtures for every surface. +*/ +describe("complex modal presentation contract", () => { + it("keeps each floating complex modal on FloatingWindow with persisted geometry", () => { + const floatingSurfaces = [ + ["CreateRoomModal", "floating-window:create-room"], + ["AgentDetailView", "floating-window:${floatingWindowKey}"], + ["GitHubImportModal", "floating-window:github-import"], + ["TerminalModal", "fusion:terminal-float-geometry-"], + ["RightDockExpandModal", "fusion:right-dock-expand-modal-geometry"], + ] as const; + + for (const [surface, geometryKey] of floatingSurfaces) { + const source = component(surface); + expect(source, surface).toContain(" { + expect(component("CreateRoomModal")).toContain("closeOnOutsidePointerDown"); + expect(component("GitHubImportModal")).toContain("closeOnOutsidePointerDown={dismissOnOutsidePointerDown}"); + expect(component("AgentDetailView")).toContain("backdropMouseHandlers"); + }); + + it("retains documented embedded, docked, and dock-origin presentation gates", () => { + const agentDetail = component("AgentDetailView"); + const githubImport = component("GitHubImportModal"); + const terminal = component("TerminalModal"); + const rightDock = component("RightDockExpandModal"); + + expect(agentDetail).toContain("if (inline)"); + expect(githubImport).toContain("useEmbeddedPresentation(presentation)"); + expect(githubImport).toContain("if (isEmbedded)"); + expect(githubImport).toContain("resizePersistEnabled"); + expect(terminal).toContain("const terminalPanel = isFloatingMode ? ("); + expect(terminal).toContain("isDockedMode"); + expect(rightDock).toContain("surface: \"expand\""); + }); + + it("keeps the canonical guide and inventory reconciled with complex-modal migration state", () => { + const currentInventory = inventory(); + const currentGuide = guide(); + + for (const surface of ["CreateRoomModal.tsx", "AgentDetailView.tsx", "GitHubImportModal.tsx", "TerminalModal.tsx", "RightDockExpandModal.tsx"]) { + const row = currentInventory.split("\n").find((line) => line.startsWith(`| \`${surface}\``)); + expect(row, surface).toBeDefined(); + expect(row, surface).toContain("already migrated"); + expect(row, surface).not.toContain("migrate →"); + } + + expect(currentGuide).toContain("Supported presentation exceptions"); + expect(currentGuide).toContain("floating-window:create-room"); + expect(currentGuide).toContain("`closeOnOutsidePointerDown` defaults **off**"); + expect(currentGuide).toContain("TerminalModal"); + expect(currentGuide).toContain("AgentDetailView"); + expect(currentGuide).toContain("GitHubImportModal"); + expect(currentGuide).toContain("RightDockExpandModal"); + }); +}); diff --git a/packages/dashboard/app/hooks/useEmbeddedPresentation.ts b/packages/dashboard/app/hooks/useEmbeddedPresentation.ts index 889bfb4a1e..4196a2dcbb 100644 --- a/packages/dashboard/app/hooks/useEmbeddedPresentation.ts +++ b/packages/dashboard/app/hooks/useEmbeddedPresentation.ts @@ -15,7 +15,7 @@ export type ModalPresentation = "modal" | "embedded"; * * - `isEmbedded` / `isModal` — the raw mode test. * - `scrollLockEnabled` — gate for `useMobileScrollLock`; off when embedded (the host page owns scrolling). - * - `resizePersistEnabled` — gate for `useModalResizePersist`; off when embedded (the view fills its container). + * - `resizePersistEnabled` — gate for modal-only FloatingWindow geometry; off when embedded (the view fills its container). * - `escapeEnabled` — gate for Escape-to-close handlers; off when embedded (the dock/router owns lifecycle). * - `overlayDismissEnabled` — gate for backdrop click-to-dismiss; off when embedded (no overlay backdrop exists). */ diff --git a/packages/dashboard/app/hooks/useModalResizePersist.ts b/packages/dashboard/app/hooks/useModalResizePersist.ts index 4680893597..a0ff2855ba 100644 --- a/packages/dashboard/app/hooks/useModalResizePersist.ts +++ b/packages/dashboard/app/hooks/useModalResizePersist.ts @@ -58,6 +58,11 @@ function readPersistableSize(node: HTMLElement): PersistedSize { * @param storageKey localStorage key, must be stable + unique per modal * @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry */ +/* +FNXC:ModalTouchGeometry 2026-07-26-19:30: +FN-8619 migrated every product modal consumer to FloatingWindow. Keep this hook, its grip CSS, +and tests because the Chromium touch-geometry e2e fixture still exercises the legacy resize seam. +*/ export function useModalResizePersist( ref: RefObject, isOpen: boolean,