FN-8620: migrate dashboard modals to FloatingWindow

Standardize dashboard modal geometry and interaction behavior through the shared FloatingWindow contract.

- Migrate New Task, right-dock expansion, terminal, onboarding, and utility modals to FloatingWindow.
- Expand shared window resizing, positioning, accessibility, and contract coverage.
- Document the modal migration inventory and publish changesets.

Files changed:
 .changeset/fn-8607-floating-modal-contract.md      |   7 +
 .changeset/fn-8620-bespoke-geometry-modals.md      |   7 +
 docs/dashboard-guide.md                            |  47 ++
 docs/dashboard-modal-inventory.md                  |   6 +-
 .../app/components/AgentGenerationModal.tsx        |  14 +-
 .../dashboard/app/components/AgentImportModal.tsx  |   7 +-
 .../dashboard/app/components/AgentListModal.tsx    |   8 +-
 .../app/components/AgentOnboardingModal.tsx        |   6 +-
 .../app/components/DockerNodeOnboardingModal.tsx   |  14 +-
 .../ExperimentalAgentOnboardingModal.tsx           |   6 +-
 .../dashboard/app/components/FloatingWindow.css    |  21 +
 .../dashboard/app/components/FloatingWindow.tsx    |  62 ++-
 packages/dashboard/app/components/MailboxModal.tsx |  14 +-
 .../components/MilestoneSliceInterviewModal.tsx    |  12 +-
 .../app/components/NativeShellOnboardingModal.tsx  |   6 +-
 packages/dashboard/app/components/NewTaskModal.css | 196 +------
 packages/dashboard/app/components/NewTaskModal.tsx | 593 +++++++--------------
 packages/dashboard/app/components/RightDock.css    |  84 +--
 .../app/components/RightDockExpandModal.tsx        | 333 ++----------
 .../dashboard/app/components/SetupWizardModal.tsx  |  20 +-
 .../app/components/SubtaskBreakdownModal.tsx       |   6 +-
 .../dashboard/app/components/TerminalModal.css     | 120 +----
 .../dashboard/app/components/TerminalModal.tsx     | 321 ++---------
 .../__tests__/AgentModals.floatingWindow.test.tsx  |  26 +
 .../components/__tests__/FloatingWindow.test.tsx   |  27 +-
 .../app/components/__tests__/NewTaskModal.test.tsx |  66 +--
 .../OnboardingModals.floatingWindow.test.tsx       |  20 +
 .../app/components/__tests__/RightDock.test.tsx    |  18 +-
 .../components/__tests__/TerminalModal.test.tsx    |  61 +--
 .../UtilityModals.floatingWindow.test.tsx          |  18 +
 .../components/__tests__/migratedModalFixtures.tsx |  25 +
 .../__tests__/modalFloatingWindowContract.test.tsx |  30 ++
 32 files changed, 726 insertions(+), 1475 deletions(-)

Fusion-Task-Id: FN-8620
Fusion-Task-Lineage: 2a912260-3292-4b13-bcf0-9de5a3df8ccd
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-26 18:35:01 -07:00
parent 256c64a7bd
commit ba24a530ef
32 changed files with 725 additions and 1474 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Make long-running dashboard agent, onboarding, and utility dialogs movable and resizable.
category: feature
dev: Uses FloatingWindow geometry keys and the modalFloatingWindowContract ratchet.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: New Task, right-dock pop-out, and floating terminal windows are moveable and resizable on tablets.
category: feature
dev: Replaced three bespoke drag/resize implementations with FloatingWindow. Legacy geometry key pairs reset once; Terminal docked mode remains unchanged.

View File

@@ -104,6 +104,14 @@ Use `FloatingWindow` for a moveable and resizable dashboard surface rather than
Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:<windowKey>"`: `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.
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-16:35: FN-8607 requires every non-trivial modal to share the FloatingWindow contract so tablet touch users receive one consistent move/resize implementation. -->
All non-trivial modals must use `FloatingWindow` with `hideHeader`, a modal-owned `dragHandleSelector`, a class name, sensible `defaultSize`/`minSize`, a stable `persistGeometryKey`, `suspendGeometryPersistenceOnMobile`, and `suspendGeometryPersistenceOnShortViewport`. Desktop drag and eight-direction resize remain active; only known tablet touch viewports (768px–1024px) enlarge active targets to at least 44px. Exactly 768px is tablet-class and persists geometry; 767px and below are full-screen sheets. A viewport at `max-height: 480px` is also a full-screen sheet regardless of width and never reads or writes geometry.
`closeOnOutsidePointerDown` defaults to **off**. A modal that previously dismissed on its backdrop must pass it explicitly, while first-run/blocking flows must omit it. Preserve the existing focus, Escape, ARIA, close guard, and scroll-container behavior when hosting content. `AgentListModal`, `AgentImportModal`, `AgentGenerationModal`, `AgentOnboardingModal`, `ExperimentalAgentOnboardingModal`, `SetupWizardModal`, `NativeShellOnboardingModal`, `DockerNodeOnboardingModal`, `MailboxModal`, `MilestoneSliceInterviewModal`, and `SubtaskBreakdownModal` use this contract. `modalFloatingWindowContract.test.tsx` and `migratedModalFixtures.tsx` ratchet their host, geometry, and dismissal configuration.
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.
## 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.
@@ -2310,3 +2318,42 @@ Direct conversations can be organized with reusable tags. Open a conversation's
### Tablet touch modal resize
Task Detail and New Task retain their desktop resize chrome, but tablet-class touch viewports expose a 44px `data-resize-hit-target` around resize controls. The target is enabled only by the shared tablet-touch viewport classifier; true-phone sheets and desktop coarse-pointer devices do not expose it.
## Floating modal contract
All non-trivial, reflowable dashboard dialogs use `FloatingWindow`; do not create a second drag,
resize, or viewport-classification implementation. A migration uses the dialog's existing header as
its drag handle and supplies `hideHeader`, `dragHandleSelector`, `className`, `defaultSize`,
`minSize`, `persistGeometryKey`, `suspendGeometryPersistenceOnMobile`, and
`suspendGeometryPersistenceOnShortViewport`. Former blocking dialogs also pass `modal`: it enables
the shared backdrop, `aria-modal` dialog semantics, and keyboard focus boundary while preserving
shared touch geometry. `closeOnOutsidePointerDown` defaults to **off**; a dialog that previously
closed from its backdrop must opt in explicitly, and blocking first-run flows must omit it.
| Viewport | Drag and resize | Persistence |
| --- | --- | --- |
| Desktop (including coarse-pointer desktop), ≥1025px | Active desktop mouse drag and eight-direction resize; targets are unchanged | Persisted |
| Touch tablet, 768–1024px | Active touch drag and resize with ≥44px `data-resize-hit-target` controls | Persisted |
| True phone, ≤767px | Full-screen sheet; no active geometry affordance | Suspended |
| Any viewport ≤480px tall | Full-screen sheet; this overrides the width row | Suspended |
Exactly **768px** is touch-tablet when the shared `isTabletTouchViewport` classifier identifies a
touch tablet; **767px** starts the true-phone sheet range. Do not use bare `@media (pointer:
coarse)`: desktop coarse-pointer hardware retains normal desktop controls, while only the shared
classifier activates enlarged tablet targets. Preserve focus, Escape, ARIA labels, existing scroll
containers, and each dialog's dismissal/confirmation behavior when moving its content to the host.
`AgentListModal`, `AgentImportModal`, `AgentGenerationModal`, `AgentOnboardingModal`,
`ExperimentalAgentOnboardingModal`, `SetupWizardModal`, `NativeShellOnboardingModal`,
`DockerNodeOnboardingModal`, `MailboxModal`, `MilestoneSliceInterviewModal`, and
`SubtaskBreakdownModal` use this contract. Outside dismissal is explicit for Agent List/Import/
Generation, Docker onboarding (guarded while submitting), Mailbox, Milestone/Slice Interview, and
Subtask Breakdown; the four onboarding flows remain blocking.
Only transient, single-decision confirm/alert dialogs with no reflowable content or long dwell time
may remain static. The inventory opt-outs are `AgentErrorDetailsModal` (brief acknowledgement),
`ModelSelectionModal` (compact focused choice), `ReportModal` (brief reporting action),
`ResearchTaskActionModal` (bounded confirmation), `SettingsSyncConflictModal` (urgent conflict
choice), and `StashConflictModal` (urgent bounded recovery). The executable inventory is guarded by
`modalFloatingWindowContract.test.tsx`; `migratedModalFixtures.tsx` keeps every hosted surface in
per-modal geometry coverage.

View File

@@ -74,13 +74,13 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
| `ModelOnboardingModal.tsx` | A | `ModelOnboardingModal.tsx:2438` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Long provider onboarding flow. | — |
| `ModelSelectionModal.tsx` | D | `ModelSelectionModal.tsx:195` `.modal-overlay role="dialog"`; direct inspection: no geometry mechanism. | stays static | FN-8607 agent/onboarding/utility | Compact blocking model choice. | Model selection is a deliberately short, focused choice dialog; persistent movable geometry is unnecessary and risks obscuring the required selection. |
| `NativeShellOnboardingModal.tsx` | D | `NativeShellOnboardingModal.tsx:48` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Native-shell onboarding instructions. | — |
| `NewTaskModal.tsx` | C | `NewTaskModal.tsx:453-527` pointer-captured header drag/resize; `:1221-1245` handlers. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Bespoke geometry, phone sheet, focus trap. | — |
| `NewTaskModal.tsx` | A | FloatingWindow (`new-task`), headerless delegated drag host. | already migrated | FN-8620 | Phone sheet and focus trap remain; `fusion:new-task-modal-size` / `-position` superseded by `fusion:new-task-modal-geometry` (one-time reset). | — |
| `NodeDetailModal.tsx` | A | `NodeDetailModal.tsx:442` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
| `PlanningModeModal.tsx` | A | `PlanningModeModal.tsx:3548` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Planner has internal sidebar and list drag controls; shell geometry is shared. | — |
| `PrCreateModal.tsx` | A | `PrCreateModal.tsx:549` `<FloatingWindow` | already migrated | n/a | Create-PR form; delegated header. | — |
| `ReportModal.tsx` | D | `ReportModal.tsx:79` `role="dialog" aria-modal`; direct inspection: no geometry mechanism. | stays static | n/a | Short reporting-action dialog. | Reporting is a brief confirmation/input flow; a fixed centered dialog preserves its blocking, one-shot interaction. |
| `ResearchTaskActionModal.tsx` | D | `ResearchTaskActionModal.tsx:57` `.modal-overlay`; direct inspection: no geometry mechanism. | stays static | n/a | Small action confirmation. | The modal is a bounded task-action confirmation, so persistent drag/resize state would be needless interaction cost. |
| `RightDockExpandModal.tsx` | C | `RightDockExpandModal.tsx:164-242` captured-pointer drag/resize; `:319-335` floating handlers. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Dock-origin content and bespoke geometry persistence. | — |
| `RightDockExpandModal.tsx` | A | FloatingWindow (`right-dock-expand`), headerless delegated drag host. | already migrated | FN-8620 | Dock-origin content remains; legacy size/position pair superseded by `fusion:right-dock-expand-modal-geometry` (one-time reset). | — |
| `ScheduledTasksModal.tsx` | A | `ScheduledTasksModal.tsx:560` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded automation presentation. | — |
| `ScriptsModal.tsx` | A | `ScriptsModal.tsx:181` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Script output nested scroll. | — |
| `SettingsModal.tsx` | A | `SettingsModal.tsx:4632` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded destination and internal nav-width splitter. | — |
@@ -89,7 +89,7 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
| `StashConflictModal.tsx` | D | `StashConflictModal.tsx:236` `.modal-overlay role="dialog"`; direct inspection: no geometry mechanism. | stays static | n/a | Blocking git-conflict recovery. | The conflict resolver is an urgent bounded recovery decision; centered static presentation keeps the destructive choices visible. |
| `SubtaskBreakdownModal.tsx` | D | `SubtaskBreakdownModal.tsx:607` `.modal-overlay role="dialog"`; direct inspection: list `draggable` controls reorder subtasks only, not window geometry. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Nested subtask drag/reorder; panel itself is static. | — |
| `TaskDetailModal.tsx` | B | `TaskDetailModal.tsx:6591` `useModalResizePersist`; `:3` `createPortal` | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Dense tabs, pop-out compatibility, tablet resize grip. | — |
| `TerminalModal.tsx` | C | `TerminalModal.tsx:756-889` pointer-captured dock/floating resize and drag; `:2738-2765` handlers. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Docked and floating modes; terminal keyboard/focus management. | — |
| `TerminalModal.tsx` | A | Floating mode uses FloatingWindow (`terminal-<project>`), 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-<project>`; floating legacy pair superseded by `fusion:terminal-float-geometry-<project>` (one-time reset). | — |
| `WorkflowAddStepModal.tsx` | A | `WorkflowAddStepModal.tsx:144` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
| `WorkflowNodeEditor.tsx` | A | `WorkflowNodeEditor.tsx:5646` `<FloatingWindow`; `:19` `createPortal` | already migrated | n/a | Full-screen workflow editor, delegated header. | — |

View File

@@ -8,6 +8,7 @@ import {
cancelAgentGeneration,
} from "../api";
import { FloatingWindow } from "./FloatingWindow";
interface AgentGenerationModalProps {
isOpen: boolean;
onClose: () => void;
@@ -158,14 +159,9 @@ export function AgentGenerationModal({
roleDescription.trim().length <= MAX_ROLE_LENGTH;
return (
<div
className="agent-dialog-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className="agent-dialog"
<FloatingWindow windowKey="agent-generation" title={t("agents.generation.title", "Generate Agent")} ariaLabel={t("agents.generateWithAiLabel", "Generate agent with AI")} onClose={handleCancel} hideHeader dragHandleSelector=".agent-generation-modal .agent-dialog-header" className="floating-window--agent-generation" defaultSize={{ width: 640, height: 560 }} minSize={{ width: 400, height: 300 }} persistGeometryKey="floating-window:agent-generation" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Generation can dwell while AI responds; persist shared geometry and preserve overlay dismissal. */}
<div className="agent-dialog agent-generation-modal"
role="dialog"
aria-modal="true"
aria-label={t("agents.generateWithAiLabel", "Generate agent with AI")}
@@ -329,6 +325,6 @@ export function AgentGenerationModal({
)}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -5,6 +5,8 @@ import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, G
import { fetchCompanies, type CompanyEntry } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { FloatingWindow } from "./FloatingWindow";
export interface AgentImportModalProps {
isOpen: boolean;
onClose: () => void;
@@ -469,7 +471,8 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
if (!isOpen) return null;
return (
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<FloatingWindow windowKey="agent-import" title={t("agents.importAgents", "Import Agents")} ariaLabel={t("agents.importAgents", "Import agents")} onClose={handleClose} hideHeader dragHandleSelector=".agent-import-dialog .agent-dialog-header" className="floating-window--agent-import" defaultSize={{ width: 720, height: 640 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:agent-import" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Import mapping is reflowable, so FloatingWindow owns tablet touch geometry and retains prior outside dismissal. */}
<div className="agent-dialog agent-import-dialog" role="dialog" aria-modal="true" aria-label={t("agents.importAgents", "Import agents")}>
{/* Header */}
<div className="agent-dialog-header">
@@ -981,6 +984,6 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
)}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -17,6 +17,7 @@ import { AgentAvatar } from "./AgentAvatar";
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
import { AgentTaskBadge } from "./AgentTaskBadge";
import { FloatingWindow } from "./FloatingWindow";
interface AgentListModalProps {
isOpen: boolean;
onClose: () => void;
@@ -286,8 +287,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true">
<div className="modal modal--wide agent-list-modal">
<FloatingWindow windowKey="agent-list" title={t("agents.modalTitle", "Agents")} ariaLabel={t("agents.modalTitle", "Agents")} onClose={onClose} hideHeader dragHandleSelector=".agent-list-modal .modal-header" className="floating-window--agent-list" defaultSize={{ width: 900, height: 680 }} minSize={{ width: 480, height: 360 }} persistGeometryKey="floating-window:agent-list" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Agents is a long-lived workspace; shared geometry persists on desktop/tablet while explicit outside dismissal preserves its former overlay behavior. */}
<div className="modal modal--wide agent-list-modal" role="dialog" aria-modal="true">
<div className="modal-header">
<h2 className="modal-title">
<Bot size={20} />
@@ -718,6 +720,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</div>
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -23,6 +23,7 @@ import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
type ViewState = "initial" | "loading" | "question" | "summary" | "creating" | "error";
import { FloatingWindow } from "./FloatingWindow";
interface AgentOnboardingModalProps {
isOpen: boolean;
onClose: () => void;
@@ -191,7 +192,8 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
};
return (
<div className="modal-overlay open" role="presentation">
<FloatingWindow windowKey="agent-onboarding" title={t("agents.onboarding.title", "Agent Onboarding")} ariaLabel={t("agents.onboarding.title", "Agent Onboarding")} onClose={() => void handleClose()} hideHeader dragHandleSelector=".agent-onboarding-modal .modal-header" className="floating-window--agent-onboarding" defaultSize={{ width: 720, height: 620 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:agent-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: First-run onboarding remains blocking: omit outside dismissal while sharing tablet geometry and suspending every sheet viewport. */}
<div className="modal modal-lg agent-onboarding-modal" role="dialog" aria-modal="true" aria-label={t("agents.onboarding.title", "Agent Onboarding")}>
<div className="modal-header">
<h3>{t("agents.onboarding.title", "Agent Onboarding")}</h3>
@@ -277,6 +279,6 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
</div>
)}
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -5,6 +5,7 @@ import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { DockerTargetSelector } from "./DockerTargetSelector";
import "./DockerNodeOnboardingModal.css";
import { FloatingWindow } from "./FloatingWindow";
interface DockerNodeOnboardingModalProps {
isOpen: boolean;
@@ -211,14 +212,9 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={closeModal}>
<div
className="modal docker-onboarding"
role="dialog"
aria-modal="true"
aria-label={t("docker.ariaLabels.modal", "Docker node onboarding")}
onClick={(event) => event.stopPropagation()}
>
<FloatingWindow windowKey="docker-node-onboarding" title={t("docker.titles.provisionNode", "Provision Docker Node")} ariaLabel={t("docker.ariaLabels.modal", "Docker node onboarding")} onClose={() => { if (!submitting) closeModal(); }} hideHeader dragHandleSelector=".docker-onboarding .modal-header" className="floating-window--docker-node-onboarding" defaultSize={{ width: 720, height: 640 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:docker-node-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Provisioning retains outside dismissal only before submit; the guarded host close protects an in-flight node creation. */}
<div className="modal docker-onboarding" role="dialog" aria-modal="true" aria-label={t("docker.ariaLabels.modal", "Docker node onboarding")}>
<div className="modal-header">
<h3>{t("docker.titles.provisionNode", "Provision Docker Node")}</h3>
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label={t("docker.ariaLabels.closeModal", "Close onboarding modal")}>
@@ -519,6 +515,6 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
</button>
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -13,6 +13,7 @@ import "./ExperimentalAgentOnboardingModal.css";
type ViewState = "initial" | "loading" | "question" | "summary" | "error";
import { FloatingWindow } from "./FloatingWindow";
interface ExperimentalAgentOnboardingModalProps {
isOpen: boolean;
onClose: () => void;
@@ -184,7 +185,8 @@ export function ExperimentalAgentOnboardingModal({
};
return (
<div className="modal-overlay open" role="presentation">
<FloatingWindow windowKey="experimental-agent-onboarding" title={t("agents.onboarding.title", "AI Interview")} ariaLabel={t("agents.onboarding.dialogLabel", "AI Interview")} onClose={() => void handleClose()} hideHeader dragHandleSelector=".experimental-agent-onboarding-modal .modal-header" className="floating-window--experimental-agent-onboarding" defaultSize={{ width: 720, height: 620 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:experimental-agent-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Experimental onboarding remains a blocking flow; no outside-dismiss opt-in accompanies shared geometry. */}
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label={t("agents.onboarding.dialogLabel", "AI Interview")}>
<div className="modal-header">
<h3>{t("agents.onboarding.title", "AI Interview")}</h3>
@@ -283,6 +285,6 @@ export function ExperimentalAgentOnboardingModal({
</div>
)}
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -20,6 +20,15 @@ sheet in the layout tree. `visibility: hidden` removes paint while preserving ch
message-list scroll state; `pointer-events: none` keeps the already click-through overlay inert.
Never replace this with `display: none`, which collapses layout and makes reopening visibly reflow.
*/
/*
FNXC:ModalTouchGeometry 2026-07-26-18:42:
FN-8607's migrated dialogs remain blocking despite sharing FloatingWindow with non-blocking
utilities. This opt-in backdrop prevents pointer events reaching the application beneath a modal.
*/
.floating-window-overlay--modal {
pointer-events: auto;
}
.floating-window-overlay--hidden {
visibility: hidden;
pointer-events: none;
@@ -298,6 +307,15 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
}
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Automations shares every FN-8606 sheet breakpoint: a short viewport must not expose desktop geometry even when it remains wider than a phone. */
/*
FNXC:ModalTouchGeometry 2026-07-27-20:00:
FN-8620's New Task, right-dock, and terminal hosts use the same short/phone sheet contract.
Their content owns custom chrome, but the shared host must fill the viewport when geometry is
suspended so hiding handles does not leave a stranded desktop-sized panel.
*/
.floating-window.new-task-modal,
.floating-window.right-dock-expand-modal--floating,
.floating-window.terminal-modal--floating,
.floating-window--automation,
.floating-window--activity-log,
.floating-window--scripts,
@@ -324,6 +342,9 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
box-shadow: none;
}
.floating-window.new-task-modal .floating-window__resize-handle,
.floating-window.right-dock-expand-modal--floating .floating-window__resize-handle,
.floating-window.terminal-modal--floating .floating-window__resize-handle,
.floating-window--automation .floating-window__resize-handle,
.floating-window--activity-log .floating-window__resize-handle,
.floating-window--scripts .floating-window__resize-handle,

View File

@@ -58,6 +58,10 @@ export interface FloatingWindowProps {
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
*/
closeOnOutsidePointerDown?: boolean;
/** 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. */
testId?: string;
/*
FNXC:FloatingWindow 2026-07-18-00:00:
Quick Chat must hide without unmounting so its active session, messages, and scroll position
@@ -199,6 +203,8 @@ export function FloatingWindow({
suspendGeometryPersistenceOnMobile = false,
suspendGeometryPersistenceOnShortViewport = false,
closeOnOutsidePointerDown = false,
modal = false,
testId,
hidden = false,
layer = "utility",
ariaLabel,
@@ -236,6 +242,29 @@ export function FloatingWindow({
initialGeometry.current!.size
);
const [position, setPosition] = useState<FloatingWindowPosition>(() => initialGeometry.current!.position);
const geometryIdentityRef = useRef({ windowKey, persistGeometryKey });
/*
FNXC:ModalTouchGeometry 2026-07-27-20:00:
A project-scoped floating host can stay mounted while its window/storage identity changes.
Reload that identity's geometry before passive persistence runs so Terminal never copies one
project's geometry into another project's key.
*/
useLayoutEffect(() => {
const previousIdentity = geometryIdentityRef.current;
if (previousIdentity.windowKey === windowKey && previousIdentity.persistGeometryKey === persistGeometryKey) return;
const fallbackSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize);
const fallbackPosition = defaultPosition ? clampPosition(defaultPosition, fallbackSize) : defaultPositionFor(windowKey, fallbackSize);
const nextGeometry = geometryPersistenceSuspended
? { size: fallbackSize, position: fallbackPosition }
: readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize);
geometryIdentityRef.current = { windowKey, persistGeometryKey };
initialGeometry.current = nextGeometry;
setSize(nextGeometry.size);
setPosition(nextGeometry.position);
}, [defaultPosition, defaultSize, geometryPersistenceSuspended, persistGeometryKey, resolvedMinSize, windowKey]);
const claimFrontZ = useCallback(() => (layer === "task-detail" ? nextTaskDetailFloatingZ() : nextFloatingZ()), [layer]);
const readCurrentZ = useCallback(() => (layer === "task-detail" ? currentTaskDetailFloatingZ() : currentFloatingZ()), [layer]);
/*
@@ -524,6 +553,32 @@ export function FloatingWindow({
}
}, [geometryPersistenceSuspended, hidden, persistGeometryKey, position, size]);
/*
FNXC:ModalTouchGeometry 2026-07-26-18:42:
FN-8607 migrates former blocking dialogs into the shared geometry host. Modal callers opt into
a real backdrop and keyboard focus boundary; utility windows retain the historical click-through
behavior by default so this does not change existing multi-window surfaces.
*/
useEffect(() => {
if (!modal || hidden || typeof document === "undefined") return;
const panel = panelRef.current;
const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
panel?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab" || !panel) return;
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)).filter((element) => !element.hasAttribute("hidden"));
if (focusable.length === 0) { event.preventDefault(); panel.focus(); return; }
const current = document.activeElement;
const index = focusable.indexOf(current as HTMLElement);
if (event.shiftKey && (index <= 0 || !panel.contains(current))) { event.preventDefault(); focusable.at(-1)?.focus(); }
else if (!event.shiftKey && index === focusable.length - 1) { event.preventDefault(); focusable[0]?.focus(); }
};
document.addEventListener("keydown", onKeyDown);
return () => { document.removeEventListener("keydown", onKeyDown); priorFocus?.focus(); };
}, [hidden, modal]);
const panelStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
@@ -544,13 +599,13 @@ export function FloatingWindow({
*/
return createPortal(
<div
className={`floating-window-overlay${hidden ? " floating-window-overlay--hidden" : ""}`}
className={`floating-window-overlay${modal ? " floating-window-overlay--modal" : ""}${hidden ? " floating-window-overlay--hidden" : ""}`}
role="dialog"
aria-modal="false"
aria-modal={modal ? "true" : "false"}
aria-hidden={hidden || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
data-testid={`floating-window-overlay-${windowKey}`}
data-testid={testId ?? `floating-window-overlay-${windowKey}`}
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
style={{ zIndex }}
>
@@ -562,6 +617,7 @@ export function FloatingWindow({
onPointerDownCapture={bringToFront}
onPointerDown={handlePanelPointerDown}
onFocusCapture={bringToFront}
tabIndex={modal ? -1 : undefined}
>
{/*
FNXC:ModalTouchGeometry 2026-07-26-16:54:

View File

@@ -1,4 +1,5 @@
import "./MailboxModal.css";
import { FloatingWindow } from "./FloatingWindow";
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -767,15 +768,8 @@ export function MailboxModal({
// ── Render ────────────────────────────────────────────────────────────
return (
<div
className="modal-overlay open"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
data-testid="mailbox-modal-overlay"
>
<FloatingWindow windowKey="mailbox" title={t("mailbox.title", "Mailbox")} ariaLabel={t("mailbox.title", "Mailbox")} onClose={onClose} hideHeader dragHandleSelector=".mailbox-modal .modal-header" className="floating-window--mailbox" defaultSize={{ width: 860, height: 680 }} minSize={{ width: 480, height: 360 }} persistGeometryKey="floating-window:mailbox" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal testId="mailbox-modal-overlay">
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Mailbox is a long-lived workspace; preserve outside dismissal and keep keyboard positioning inside the hosted panel. */}
<div className="modal modal-lg mailbox-modal" style={containerKeyboardStyle} data-testid="mailbox-modal">
{/* Header */}
<div className="modal-header mailbox-header">
@@ -1265,7 +1259,7 @@ export function MailboxModal({
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -33,6 +33,7 @@ import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import { FloatingWindow } from "./FloatingWindow";
const WARNING_ICON = "⚠️";
const MILESTONE_SLICE_OTHER_RESPONSE_KEY = "_other";
@@ -406,13 +407,8 @@ export function MilestoneSliceInterviewModal({
if (!isOpen) return null;
return (
<div
className="modal-overlay open"
onClick={(e) => e.target === e.currentTarget && handleCancel()}
role="dialog"
aria-modal="true"
data-testid="milestone-slice-interview-modal"
>
<FloatingWindow windowKey="milestone-slice-interview" title={t("missions.planTargetTitle", "Plan {{targetLabel}}: {{targetTitle}}", { targetLabel, targetTitle })} ariaLabel={t("missions.planTargetTitle", "Plan interview")} onClose={handleCancel} hideHeader dragHandleSelector=".planning-modal .modal-header" className="floating-window--milestone-slice-interview" defaultSize={{ width: 760, height: 640 }} minSize={{ width: 440, height: 320 }} persistGeometryKey="floating-window:milestone-slice-interview" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal testId="milestone-slice-interview-modal">
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Interview dismissal still routes through handleCancel so confirmation semantics survive shared tablet geometry. */}
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
<div className="modal-header">
<div className="detail-title-row">
@@ -572,7 +568,7 @@ export function MilestoneSliceInterviewModal({
)}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
import "./NativeShellOnboardingModal.css";
import { FloatingWindow } from "./FloatingWindow";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
@@ -45,7 +46,8 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
}
return (
<div className="modal-overlay open">
<FloatingWindow windowKey="native-shell-onboarding" title={t("onboarding.welcome", "Welcome to Fusion")} ariaLabel={t("onboarding.welcome", "Welcome to Fusion")} onClose={() => {}} hideHeader dragHandleSelector=".native-shell-onboarding-modal .modal-header" className="floating-window--native-shell-onboarding" defaultSize={{ width: 640, height: 560 }} minSize={{ width: 400, height: 320 }} persistGeometryKey="floating-window:native-shell-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Connection onboarding is blocking, so its shared geometry deliberately has no outside or Escape dismissal. */}
<div className="modal native-shell-onboarding-modal">
<div className="modal-header">
<h2>{t("onboarding.welcome", "Welcome to Fusion")}</h2>
@@ -133,6 +135,6 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
</button>
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -1,7 +1,8 @@
/* === New Task Modal === */
/*
FNXC:NewTaskMobileAffordances 2026-06-25-12:22:
The transparent New Task overlay intentionally passes clicks through for desktop floating mode, so the panel itself must always opt back into hit testing. Mobile uses the non-floating full-screen sheet and must keep every desktop affordance (GitHub picker, quick buttons, dropdowns, and action row) tappable and scroll-reachable even when the keyboard constrains the visual viewport.
FNXC:ModalTouchGeometry 2026-07-27-18:00:
FN-8620 removes New Task's local floating geometry CSS. FloatingWindow owns its shared drag,
resize, sheet, and tablet touch-target presentation; this component retains only form layout.
*/
.new-task-modal {
min-height: min(520px, 80vh);
@@ -9,196 +10,12 @@ The transparent New Task overlay intentionally passes clicks through for desktop
}
/*
FNXC:NewTask 2026-06-22-20:30:
The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window (mirrors the right-dock pop-out). The overlay MUST out-specify the base `.modal-overlay` (which dims + blurs the page). Both base and override are single-class, so a two-class selector (`.modal-overlay.new-task-modal-overlay`) guarantees the transparent, non-blurring, click-through backdrop regardless of stylesheet order. `pointer-events: none` lets behind-clicks pass through to the app; the floating panel re-enables `pointer-events: auto`. No overlay click-to-dismiss — the header X / Cancel / Escape are the only dismissals.
*/
.modal-overlay.new-task-modal-overlay {
align-items: stretch;
justify-content: flex-start;
padding: 0;
background: transparent;
backdrop-filter: none;
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Only the desktop FLOATING New Task dialog joins the shared cross-type floating stack. When the overlay hosts the floating panel, reset the base `.modal-overlay` z-index:100 to auto so it does NOT establish a stacking context; the panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the terminal, the right-dock pop-out, and FloatingWindow. The mobile full-screen sheet (no `--floating` panel) keeps the base overlay z-index:100 so it still paints above page content.
*/
.modal-overlay.new-task-modal-overlay:has(.new-task-modal--floating) {
z-index: auto;
}
/*
FNXC:NewTask 2026-06-22-20:30:
Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet.
*/
/*
FNXC:TaskModalResize 2026-08-07-00:00:
The floating class is also applied to known tablets at the 768px CSS boundary.
Keep this selector more specific than the global phone-sheet rule; the runtime
viewport contract, not a duplicate CSS breakpoint, decides whether it applies.
*/
.modal.new-task-modal.new-task-modal--floating {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
display: flex;
flex-direction: column;
min-width: calc(var(--space-2xl) * 8.75);
min-height: calc(var(--space-2xl) * 7.5);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
/*
FNXC:FloatingWindow 2026-06-23-23:32:
Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across New Task, Terminal, Right Dock, and shared FloatingWindow panels.
*/
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
.new-task-modal--floating .modal-body {
max-height: none;
}
/*
FNXC:NewTask 2026-06-22-20:30:
Header is the drag handle. `touch-action: none` (matching the resize handles) hands the whole gesture to our pointer handlers so a finger drag stays smooth and never scrolls the page behind it. `cursor: grab/grabbing` is desktop-only signal.
FNXC:ModalTouchGeometry 2026-07-27-20:05:
The custom New Task header remains FloatingWindow's delegated drag surface. Keep its gesture
contract explicit so tablet touch reaches the shared captured-pointer handler rather than page pan.
*/
.new-task-modal__header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
min-height: 48px;
}
.new-task-modal__header--draggable:active {
cursor: grabbing;
}
/*
FNXC:NewTask 2026-06-22-20:30:
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
*/
/*
FNXC:TaskModalResize 2026-07-24-19:00:
Resize grips are focusable separators on floating desktop and tablet task windows. Preserve a
visible tokenized focus ring so keyboard users can discover the same controls touch users drag.
*/
.new-task-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.new-task-resize-handle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.new-task-resize-handle--n,
.new-task-resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.new-task-resize-handle--n { top: 0; }
.new-task-resize-handle--s { bottom: 0; }
.new-task-resize-handle--e,
.new-task-resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.new-task-resize-handle--e { right: 0; }
.new-task-resize-handle--w { left: 0; }
.new-task-resize-handle--ne,
.new-task-resize-handle--nw,
.new-task-resize-handle--se,
.new-task-resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.new-task-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.new-task-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.new-task-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.new-task-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
/*
FNXC:TaskModalResize 2026-07-26-10:40:
Finger drags need a 44px hit area, unlike the mouse-sized painted edge/corner controls.
These targets expand outward from the floating panel so they cannot cover its footer or
scrollable body. The runtime tablet-touch class is required: true-phone sheets and desktop
coarse-pointer devices must never expose an invisible resize target.
*/
.task-modal--touch-resize .new-task-resize-handle--touch-target {
z-index: 3;
touch-action: none;
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n,
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s {
left: var(--modal-resize-touch-target);
right: var(--modal-resize-touch-target);
height: var(--modal-resize-touch-target);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n {
top: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s {
bottom: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e,
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w {
top: var(--modal-resize-touch-target);
bottom: var(--modal-resize-touch-target);
width: var(--modal-resize-touch-target);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e {
right: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w {
left: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne,
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw,
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se,
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw {
width: var(--modal-resize-touch-target);
height: var(--modal-resize-touch-target);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne {
top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw {
top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se {
bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw {
bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.new-task-modal .modal-body {
@@ -208,7 +25,6 @@ coarse-pointer devices must never expose an invisible resize target.
flex: 1;
min-height: 0;
}
.new-task-modal .form-group {
margin-top: 0;
margin-bottom: var(--space-xl);

View File

@@ -1,5 +1,5 @@
import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core";
@@ -28,9 +28,9 @@ import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import { FloatingWindow } from "./FloatingWindow";
type NewTaskCreateInput = Omit<CreateTaskInput, "branchSelection"> & {
branchSelection?: {
@@ -54,100 +54,15 @@ interface NewTaskModalProps {
}
/*
FNXC:NewTask 2026-06-22-20:30:
The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window matching the right-dock pop-out (RightDockExpandModal). The overlay is transparent and `pointer-events: none` so the app behind stays usable and behind-clicks pass through — there is therefore NO overlay click-to-dismiss; the header close (X) and Cancel button are the only dismissals (plus Escape). The panel is `position: fixed; pointer-events: auto`, dragged by its header and resized from corner/edge handles, with rAF-batched position/size state and a single teardown ref invoked on pointerup/pointercancel AND on unmount so no document/element listeners or pending rAF leak. Size/position persist to localStorage. On mobile we keep the full-screen sheet behavior (no floating) so the keyboard-aware layout still works.
FNXC:ModalTouchGeometry 2026-07-27-18:00:
FN-8620 replaces New Task's bespoke pointer geometry with FloatingWindow. The single shared
geometry key intentionally supersedes the former size/position pair; old values reset once.
*/
const NEW_TASK_MODAL_SIZE_STORAGE_KEY = "fusion:new-task-modal-size";
const NEW_TASK_MODAL_POSITION_STORAGE_KEY = "fusion:new-task-modal-position";
const NEW_TASK_DEFAULT_WIDTH = 720;
const NEW_TASK_DEFAULT_HEIGHT = 640;
const NEW_TASK_MIN_WIDTH = 420;
const NEW_TASK_MIN_HEIGHT = 360;
const NEW_TASK_VIEWPORT_PADDING = 16;
/*
FNXC:TaskModalResize 2026-07-24-19:00:
Keyboard resizing follows the existing viewport padding quantum, so focusable edge controls produce predictable, touch-safe geometry changes without a second sizing scale.
*/
const NEW_TASK_KEYBOARD_RESIZE_STEP = NEW_TASK_VIEWPORT_PADDING;
interface FloatSize {
width: number;
height: number;
}
interface FloatPosition {
x: number;
y: number;
}
function clampFloatSize(size: FloatSize): FloatSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, NEW_TASK_MIN_WIDTH), Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, NEW_TASK_MIN_HEIGHT), Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2)),
};
}
function clampFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerWidth - size.width - NEW_TASK_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerHeight - size.height - NEW_TASK_VIEWPORT_PADDING)),
};
}
function readFloatSize(): FloatSize {
if (typeof window === "undefined") return { width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<FloatSize>;
if (typeof parsed.width === "number" && typeof parsed.height === "number") {
return clampFloatSize({ width: parsed.width, height: parsed.height });
}
}
} catch {
// ignore corrupted persisted size
}
return clampFloatSize({ width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT });
}
function writeFloatSize(size: FloatSize): FloatSize {
const clamped = clampFloatSize(size);
if (typeof window !== "undefined") {
window.localStorage.setItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
function readFloatPosition(size: FloatSize): FloatPosition {
if (typeof window === "undefined") return { x: NEW_TASK_VIEWPORT_PADDING, y: NEW_TASK_VIEWPORT_PADDING };
try {
const raw = window.localStorage.getItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<FloatPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampFloatPosition({ x: parsed.x, y: parsed.y }, size);
}
}
} catch {
// ignore corrupted persisted position
}
// Default: roughly centered.
return clampFloatPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size);
}
function writeFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition {
const clamped = clampFloatPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
type FloatResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const NEW_TASK_RESIZE_DIRECTIONS: FloatResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
/*
FNXC:GitHubImport 2026-07-16-15:20:
Reference picker must surface all of a normal repo's open issues/PRs, not just the first 30.
@@ -412,7 +327,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
const isTabletTouchResize = isTabletTouchViewport(viewportMode);
useMobileScrollLock(isOpen);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
@@ -427,186 +341,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [description, setDescription] = useState("");
const githubGeneratedDescriptionRef = useRef("");
const wasOpenRef = useRef(false);
const floatingFormRef = useRef<HTMLDivElement>(null);
/*
FNXC:NewTask 2026-06-22-20:30:
Floating window position/size state (desktop only). Mobile keeps the full-screen sheet, so we only apply the floating panel style and drag/resize handlers when not mobile. A single active-drag teardown (drag OR resize) lives in dragTeardownRef; pointerup/pointercancel AND the unmount effect run it so an interrupted drag never leaks element pointer listeners or a pending rAF.
FNXC:ModalTouchGeometry 2026-07-27-18:00:
Phones and short viewports retain the keyboard-aware full-screen sheet. Desktop and tablet
presentations delegate drag, resize, clamping, persistence, and stacking to FloatingWindow.
*/
const isFloating = viewportMode !== "mobile";
const [size, setSizeState] = useState<FloatSize>(() => readFloatSize());
const [position, setPositionState] = useState<FloatPosition>(() => readFloatPosition(readFloatSize()));
const dragTeardownRef = useRef<(() => void) | null>(null);
// FNXC:FloatingWindow 2026-06-22-21:30: Floating (desktop) New Task dialog shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. Mobile keeps the full-screen sheet so this z-index is harmless there.
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
const bringToFront = useCallback(() => {
setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, []);
const persistSize = useCallback((next: FloatSize) => {
setSizeState(writeFloatSize(next));
}, []);
const persistPosition = useCallback((next: FloatPosition, withSize: FloatSize) => {
setPositionState(writeFloatPosition(next, withSize));
}, []);
// FNXC:NewTask 2026-06-22-20:30: Header drag. setPointerCapture redirects the pointer stream to the captured header element, so element-scoped pointermove/up listeners receive the full drag even off the header; moves are rAF-batched; the panel is clamped on-screen. Close button clicks are excluded so dragging never swallows close.
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = position;
const currentSize = size;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latest = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setPositionState(clampFloatPosition(latest, currentSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, position, size]);
// FNXC:NewTask 2026-06-22-20:30: Corner/edge resize, rAF-batched. West/north handles also shift the panel origin so the opposite edge stays pinned. Same teardown discipline as the drag.
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: FloatResizeDirection) => {
event.preventDefault();
event.stopPropagation();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = size;
const startPosition = position;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latestSize = startSize;
let latestPosition = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampFloatSize({
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
});
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
latestSize = nextSize;
latestPosition = nextPosition;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setSizeState(latestSize);
setPositionState(clampFloatPosition(latestPosition, latestSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
/*
FNXC:TaskModalResize 2026-07-26-10:51:
A tablet finger resize owns exactly the pointer that started it. Ignore another
finger's terminal event so it cannot release capture or persist partial geometry;
this preserves the established floating-window gesture isolation.
*/
function handlePointerUp(upEvent: PointerEvent) {
if (upEvent.pointerId !== pointerId) return;
upEvent.preventDefault();
if (frame) cancelAnimationFrame(frame);
persistSize(latestSize);
persistPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, persistSize, position, size]);
/*
FNXC:TaskModalResize 2026-07-24-19:00:
Tablet resize handles must be keyboard-operable as well as touch-operable. Each focused edge
adjusts the dimensions it owns, clamps and persists exactly like a completed pointer resize,
and exposes the resulting geometry through its ARIA separator value.
*/
const handleFloatingResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>, direction: FloatResizeDirection) => {
let widthDelta = 0;
let heightDelta = 0;
const step = NEW_TASK_KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowRight") widthDelta = direction.includes("e") ? step : direction.includes("w") ? -step : 0;
if (event.key === "ArrowLeft") widthDelta = direction.includes("w") ? step : direction.includes("e") ? -step : 0;
if (event.key === "ArrowDown") heightDelta = direction.includes("s") ? step : direction.includes("n") ? -step : 0;
if (event.key === "ArrowUp") heightDelta = direction.includes("n") ? step : direction.includes("s") ? -step : 0;
if (widthDelta === 0 && heightDelta === 0) return;
event.preventDefault();
event.stopPropagation();
const nextSize = clampFloatSize({ width: size.width + widthDelta, height: size.height + heightDelta });
const nextPosition = clampFloatPosition({
x: position.x + (direction.includes("w") ? size.width - nextSize.width : 0),
y: position.y + (direction.includes("n") ? size.height - nextSize.height : 0),
}, nextSize);
persistSize(nextSize);
persistPosition(nextPosition, nextSize);
}, [persistPosition, persistSize, position, size]);
// FNXC:NewTask 2026-06-22-20:30: Run any active drag/resize teardown on unmount so element pointer listeners + a pending rAF never outlive the modal.
useEffect(() => () => dragTeardownRef.current?.(), []);
const [dependencies, setDependencies] = useState<string[]>([]);
const [branchMode, setBranchMode] = useState<BranchSelectionMode>("project-default");
@@ -1050,6 +792,58 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
}
}, [handleClose]);
/*
FNXC:ModalTouchGeometry 2026-07-27-18:20:
FloatingWindow owns the desktop host, so New Task must route document Escape through its
existing discard-aware close path rather than bypassing the FN-8563 abandon-changes confirmation.
*/
useEffect(() => {
if (!isOpen || !isFloating) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
handleClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [handleClose, isFloating, isOpen]);
/*
FNXC:ModalTouchGeometry 2026-07-26-18:01:
New Task keeps its keyboard focus boundary without opting into FloatingWindow's blocking
backdrop. Desktop clicks must pass through the transparent overlay, while Tab remains inside
the composer and every close route continues through the discard-aware handler. A blocking
duplicate-warning child temporarily owns Tab so its Cancel/Create controls remain reachable.
*/
useEffect(() => {
if (!isOpen || !isFloating) return;
const form = floatingFormRef.current;
if (!form) return;
const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
// A duplicate warning is a blocking child dialog and owns keyboard traversal while open.
if (document.querySelector(".duplicate-warning-modal")) return;
const focusable = Array.from(form.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)).filter((element) => !element.hasAttribute("hidden"));
if (focusable.length === 0) return;
const index = focusable.indexOf(document.activeElement as HTMLElement);
if (event.shiftKey && (index <= 0 || !form.contains(document.activeElement))) {
event.preventDefault();
focusable.at(-1)?.focus();
} else if (!event.shiftKey && (index === focusable.length - 1 || !form.contains(document.activeElement))) {
event.preventDefault();
focusable[0]?.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
priorFocus?.focus();
};
}, [isFloating, isOpen]);
// Compute selected agent label for display
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
@@ -1197,152 +991,151 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
if (!isOpen) return null;
// FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals.
const panelStyle: CSSProperties = isFloating
? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex }
: keyboardStyle;
/*
FNXC:ModalTouchGeometry 2026-07-27-18:00:
Close routes remain handleClose so Escape, the header X, and Cancel all preserve the
abandon-changes confirmation. FloatingWindow intentionally keeps outside dismissal disabled.
*/
const duplicateWarning = duplicateMatches ? (
<DuplicateWarningModal
matches={duplicateMatches}
onOpen={handleDuplicateOpen}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
/>
) : null;
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected.
return createPortal(
<>
const taskFormContents = (
<div ref={floatingFormRef}>
<div
className="modal-overlay open new-task-modal-overlay"
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="false"
aria-label={t("newTaskModal.title", "New Task")}
data-testid="new-task-modal-overlay"
/* FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped and loses to page stacking contexts like the right dock. Mobile keeps its CSS z. */
style={isFloating ? { zIndex } : undefined}
className="modal-header new-task-modal__header--draggable"
data-testid="new-task-drag-handle"
>
<div
className={`modal modal-lg new-task-modal${viewportMode === "tablet" ? " task-modal--tablet" : ""}${isTabletTouchResize ? " task-modal--touch-resize" : ""}${isFloating ? " new-task-modal--floating" : ""}`}
style={panelStyle}
onPointerDownCapture={isFloating ? bringToFront : undefined}
onFocusCapture={isFloating ? bringToFront : undefined}
>
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`new-task-resize-handle new-task-resize-handle--${direction}${isTabletTouchResize ? " new-task-resize-handle--touch-target" : ""}`}
data-testid={`new-task-resize-${direction}`}
{...(isTabletTouchResize ? { "data-resize-hit-target": "true" } : {})}
role="separator"
aria-orientation={direction === "n" || direction === "s" ? "horizontal" : "vertical"}
aria-valuemin={direction === "n" || direction === "s" ? NEW_TASK_MIN_HEIGHT : NEW_TASK_MIN_WIDTH}
aria-valuemax={direction === "n" || direction === "s" ? Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2) : Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)}
aria-valuenow={direction === "n" || direction === "s" ? size.height : size.width}
aria-valuetext={`${t("newTaskModal.resize", "Resize new task window")}: ${size.width} by ${size.height}`}
aria-label={t("newTaskModal.resize", "Resize new task window")}
tabIndex={0}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
onKeyDown={(event) => handleFloatingResizeKeyDown(event, direction)}
/>
))}
<div
className={`modal-header${isFloating ? " new-task-modal__header--draggable" : ""}`}
data-testid="new-task-drag-handle"
onPointerDown={isFloating ? handleFloatingDragPointerDown : undefined}
>
<h3>{t("newTaskModal.title", "New Task")}</h3>
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
&times;
</button>
<h3>{t("newTaskModal.title", "New Task")}</h3>
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
&times;
</button>
</div>
<div className="modal-body">
{!setupReadinessLoading && visibleSetupHasWarnings && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
showGithubWarning={false}
/>
)}
{!setupReadinessLoading && visibleSetupHasWarnings && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}
hasGithub={hasGithub}
showGithubWarning={false}
/>
)}
<TaskForm
mode="create"
description={description}
onDescriptionChange={setDescription}
dependencies={dependencies}
onDependenciesChange={setDependencies}
executorModel={executorModel}
onExecutorModelChange={handleExecutorModelChange}
validatorModel={validatorModel}
onValidatorModelChange={handleValidatorModelChange}
presetMode={presetMode}
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
onSelectedPresetIdChange={setSelectedPresetId}
selectedWorkflowId={selectedWorkflowId}
onWorkflowIdChange={setSelectedWorkflowId}
enabledWorkflowSteps={enabledWorkflowSteps}
onEnabledWorkflowStepsChange={handleEnabledWorkflowStepsChange}
pendingImages={pendingImages}
onImagesChange={setPendingImages}
tasks={tasks}
projectId={projectId}
disabled={isSubmitting}
addToast={addToast}
isActive={isOpen}
onClose={handleAiAssistClose}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
planningModel={planningModel}
onPlanningModelChange={setPlanningModel}
thinkingLevel={thinkingLevel}
plannerOversightLevel={plannerOversightLevel}
onPlannerOversightLevelChange={setPlannerOversightLevel}
onThinkingLevelChange={setThinkingLevel}
reviewLevel={reviewLevel}
onReviewLevelChange={setReviewLevel}
autoMerge={autoMerge}
onAutoMergeChange={setAutoMerge}
priority={priority}
onPriorityChange={setPriority}
branch={branch}
onBranchChange={setBranch}
branchMode={branchMode}
onBranchModeChange={setBranchMode}
baseBranch={baseBranch}
onBaseBranchChange={setBaseBranch}
nodeId={nodeId}
onNodeIdChange={setNodeId}
nodeOptions={nodes}
executionMode={executionMode}
onExecutionModeChange={setExecutionMode}
githubTrackingEnabled={githubTrackingEnabled}
onGithubTrackingEnabledChange={handleGithubTrackingEnabledChange}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
onCreateSubmit={handleSubmit}
createSubmitLabel={isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
createSubmitDisabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
/>
<TaskForm
mode="create"
description={description}
onDescriptionChange={setDescription}
dependencies={dependencies}
onDependenciesChange={setDependencies}
executorModel={executorModel}
onExecutorModelChange={handleExecutorModelChange}
validatorModel={validatorModel}
onValidatorModelChange={handleValidatorModelChange}
presetMode={presetMode}
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
onSelectedPresetIdChange={setSelectedPresetId}
selectedWorkflowId={selectedWorkflowId}
onWorkflowIdChange={setSelectedWorkflowId}
enabledWorkflowSteps={enabledWorkflowSteps}
onEnabledWorkflowStepsChange={handleEnabledWorkflowStepsChange}
pendingImages={pendingImages}
onImagesChange={setPendingImages}
tasks={tasks}
projectId={projectId}
disabled={isSubmitting}
addToast={addToast}
isActive={isOpen}
onClose={handleAiAssistClose}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
planningModel={planningModel}
onPlanningModelChange={setPlanningModel}
thinkingLevel={thinkingLevel}
plannerOversightLevel={plannerOversightLevel}
onPlannerOversightLevelChange={setPlannerOversightLevel}
onThinkingLevelChange={setThinkingLevel}
reviewLevel={reviewLevel}
onReviewLevelChange={setReviewLevel}
autoMerge={autoMerge}
onAutoMergeChange={setAutoMerge}
priority={priority}
onPriorityChange={setPriority}
branch={branch}
onBranchChange={setBranch}
branchMode={branchMode}
onBranchModeChange={setBranchMode}
baseBranch={baseBranch}
onBaseBranchChange={setBaseBranch}
nodeId={nodeId}
onNodeIdChange={setNodeId}
nodeOptions={nodes}
executionMode={executionMode}
onExecutionModeChange={setExecutionMode}
githubTrackingEnabled={githubTrackingEnabled}
onGithubTrackingEnabledChange={handleGithubTrackingEnabledChange}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
onCreateSubmit={handleSubmit}
createSubmitLabel={isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
createSubmitDisabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
/>
</div>
{hasInvalidBranchSelection && (
<div className="form-error new-task-branch-error">{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}</div>
<div className="form-error new-task-branch-error">{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}</div>
)}
<div className="modal-actions">
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
{t("actions.cancel", "Cancel")}
</button>
</div>
<div className="modal-actions">
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
{t("actions.cancel", "Cancel")}
</button>
</div>
</div>
);
if (isFloating) {
return (
<>
<FloatingWindow
title={t("newTaskModal.title", "New Task")}
onClose={handleClose}
windowKey="new-task"
defaultSize={{ width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT }}
minSize={{ width: NEW_TASK_MIN_WIDTH, height: NEW_TASK_MIN_HEIGHT }}
hideHeader
dragHandleSelector=".new-task-modal__header--draggable"
persistGeometryKey="fusion:new-task-modal-geometry"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
ariaLabel={t("newTaskModal.title", "New Task")}
className={`modal modal-lg new-task-modal new-task-modal--floating${viewportMode === "tablet" ? " task-modal--tablet" : ""}`}
testId="new-task-modal-overlay"
>
{taskFormContents}
</FloatingWindow>
{duplicateWarning}
</>
);
}
return createPortal(
<>
<div className="modal-overlay open new-task-modal-overlay" onKeyDown={handleKeyDown} role="dialog" aria-modal="true" aria-label={t("newTaskModal.title", "New Task")} data-testid="new-task-modal-overlay" style={keyboardStyle}>
<div className="modal modal-lg new-task-modal" style={keyboardStyle}>
{taskFormContents}
</div>
</div>
{duplicateMatches && (
<DuplicateWarningModal
matches={duplicateMatches}
onOpen={handleDuplicateOpen}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
/>
)}
{duplicateWarning}
</>,
document.body,
);

View File

@@ -179,24 +179,6 @@ The hosted view is a flex child of the dock body; without min-height:0 it cannot
min-block-size: 0;
}
/*
FNXC:RightDock 2026-06-22-17:40:
The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it. This overlay MUST out-specify the base `.modal-overlay` (which dims the page with a backdrop + blur). Both base and override are single-class selectors, so if styles.css loads after this file the dim/blur would win and the page would fade; qualify with `.modal-overlay` (two classes) so the pop-out reliably keeps a transparent, non-blurring, click-through backdrop regardless of stylesheet order. `pointer-events: none` lets behind-clicks pass through to the app; the floating panel re-enables `pointer-events: auto`.
*/
.modal-overlay.right-dock-expand-modal-overlay {
align-items: stretch;
justify-content: flex-start;
padding: 0;
background: transparent;
backdrop-filter: none;
pointer-events: none;
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context. The floating panel carries an inline z-index from the SHARED floatingWindowStack (4000+); without this reset that inline z would be clamped inside the overlay's own stacking context and could never interleave with the other floating modal types (terminal, New Task, FloatingWindow) that all draw from the same stack.
*/
z-index: auto;
}
.right-dock-expand-modal {
display: flex;
flex-direction: column;
@@ -204,22 +186,19 @@ The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window.
}
/*
FNXC:RightDock 2026-06-22-17:40:
Floating panel positioned by state-driven inline `left/top/width/height`. min/max keep content usable and the panel on-screen. `resize: none` because resizing is handled by the corner/edge handles (the native grip conflicts with the drag/resize pointer handlers). `pointer-events: auto` re-enables interaction on the panel only.
FNXC:ModalTouchGeometry 2026-07-27-19:00:
The expanded dock is now content within FloatingWindow. Retain its visual identity while letting
its dimensions and position belong exclusively to the shared host rather than a nested fixed panel.
*/
.right-dock-expand-modal--floating {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
min-width: calc(var(--space-2xl) * 7.5);
min-height: calc(var(--space-2xl) * 5.83);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-width: none;
max-height: none;
resize: none;
pointer-events: auto;
/*
FNXC:FloatingWindow 2026-06-23-23:32:
Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Right Dock, New Task, Terminal, and shared FloatingWindow panels.
*/
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
@@ -242,51 +221,6 @@ Touch dragging was janky because the browser claimed the header's touch stream f
cursor: grabbing;
}
/*
FNXC:RightDock 2026-06-22-17:40:
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
*/
.right-dock-expand-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.right-dock-expand-resize-handle--n,
.right-dock-expand-resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.right-dock-expand-resize-handle--n { top: 0; }
.right-dock-expand-resize-handle--s { bottom: 0; }
.right-dock-expand-resize-handle--e,
.right-dock-expand-resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.right-dock-expand-resize-handle--e { right: 0; }
.right-dock-expand-resize-handle--w { left: 0; }
.right-dock-expand-resize-handle--ne,
.right-dock-expand-resize-handle--nw,
.right-dock-expand-resize-handle--se,
.right-dock-expand-resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.right-dock-expand-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.right-dock-expand-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.right-dock-expand-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.right-dock-expand-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
.right-dock-expand-modal__header,
.right-dock-expand-modal__title {
display: flex;

View File

@@ -1,102 +1,14 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, type RefObject } from "react";
import { Maximize2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FloatingWindow } from "./FloatingWindow";
import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import "./RightDock.css";
const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size";
const RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY = "fusion:right-dock-expand-modal-position";
/*
FNXC:RightDock 2026-06-22-17:40:
The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it: NO background dimming/blur, and the overlay is `pointer-events: none` so behind-clicks pass through (only the panel re-enables `pointer-events: auto`). Because behind-clicks never reach the overlay there is no overlay click-to-dismiss; the explicit header close button is the only dismissal. This mirrors TerminalModal's floating mode (drag the header, resize from the corners, rAF-batched updates, a single dragTeardownRef invoked on pointerup/pointercancel AND on unmount so no document listeners leak).
*/
const EXPAND_DEFAULT_WIDTH = 960;
const EXPAND_DEFAULT_HEIGHT = 600;
const EXPAND_MIN_WIDTH = 360;
const EXPAND_MIN_HEIGHT = 280;
const EXPAND_VIEWPORT_PADDING = 16;
interface ExpandSize {
width: number;
height: number;
}
interface ExpandPosition {
x: number;
y: number;
}
function clampExpandSize(size: ExpandSize): ExpandSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, EXPAND_MIN_WIDTH), Math.max(EXPAND_MIN_WIDTH, window.innerWidth - EXPAND_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, EXPAND_MIN_HEIGHT), Math.max(EXPAND_MIN_HEIGHT, window.innerHeight - EXPAND_VIEWPORT_PADDING * 2)),
};
}
function clampExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerWidth - size.width - EXPAND_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerHeight - size.height - EXPAND_VIEWPORT_PADDING)),
};
}
function readExpandSize(): ExpandSize {
if (typeof window === "undefined") return { width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<ExpandSize>;
if (typeof parsed.width === "number" && typeof parsed.height === "number") {
return clampExpandSize({ width: parsed.width, height: parsed.height });
}
}
} catch {
// ignore corrupted persisted size
}
return clampExpandSize({ width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT });
}
function writeExpandSize(size: ExpandSize): ExpandSize {
const clamped = clampExpandSize(size);
if (typeof window !== "undefined") {
window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
function readExpandPosition(size: ExpandSize): ExpandPosition {
if (typeof window === "undefined") return { x: EXPAND_VIEWPORT_PADDING, y: EXPAND_VIEWPORT_PADDING };
try {
const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<ExpandPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampExpandPosition({ x: parsed.x, y: parsed.y }, size);
}
}
} catch {
// ignore corrupted persisted position
}
// Default: roughly centered.
return clampExpandPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size);
}
function writeExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition {
const clamped = clampExpandPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
type ExpandResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const EXPAND_RESIZE_DIRECTIONS: ExpandResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
type RenderableOverflowViewEntry = OverflowViewEntry & Required<Pick<OverflowViewEntry, "render">>;
@@ -129,165 +41,11 @@ export function RightDockExpandModal({
const resolvedEntry = viewKey ? findOverflowViewEntry(viewKey, visibilityOptions) : undefined;
const entry: RenderableOverflowViewEntry | undefined = resolvedEntry?.render ? { ...resolvedEntry, render: resolvedEntry.render } : undefined;
const [size, setSizeState] = useState<ExpandSize>(() => readExpandSize());
const [position, setPositionState] = useState<ExpandPosition>(() => readExpandPosition(readExpandSize()));
// FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type.
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
const bringToFront = useCallback(() => {
setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, []);
/*
FNXC:RightDock 2026-06-22-17:40:
A single active-drag teardown lives here (drag OR resize). pointerup/pointercancel run it, and the unmount effect runs it too, so a drag interrupted by close/unmount never leaks document pointer listeners or a pending rAF — this was a P1 in review of the terminal floating window.
*/
const dragTeardownRef = useRef<(() => void) | null>(null);
const persistSize = useCallback((next: ExpandSize) => {
setSizeState(writeExpandSize(next));
}, []);
const persistPosition = useCallback((next: ExpandPosition, withSize: ExpandSize) => {
setPositionState(writeExpandPosition(next, withSize));
}, []);
const closeAndRestoreFocus = useCallback(() => {
onClose();
window.setTimeout(() => returnFocusRef?.current?.focus(), 0);
}, [onClose, returnFocusRef]);
/*
FNXC:RightDock 2026-06-22-17:40:
Header drag: pointerdown on the title bar moves the panel via state-driven `position: fixed; left/top`. Pointer capture keeps the drag alive past the header bounds, updates are rAF-batched so the move stays smooth, and the panel is clamped on-screen. Clicks on the close button are excluded so dragging never swallows the close.
FNXC:RightDock 2026-06-22-18:50:
Touch smoothness fix: listen for pointermove/up on the CAPTURED element (`captureTarget` = event.currentTarget) rather than `document`. `setPointerCapture` redirects every move for this pointerId to that element, so element-scoped listeners receive the full stream even when the finger drifts off the header — and they pair cleanly with `touch-action: none` (CSS) without a separate non-passive document listener. clientX/clientY are read from the captured pointer's move events. Raw moves are coalesced into a single rAF (`frame`) so we set left/top at most once per frame and never thrash layout on a flood of touch-move events.
*/
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = position;
const currentSize = size;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latest = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setPositionState(clampExpandPosition(latest, currentSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-drag teardown cancels the rAF and drops the listeners without persisting a partial move.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, position, size]);
/*
FNXC:RightDock 2026-06-22-17:40:
Corner/edge resize: pointer events resize the panel, rAF-batched for smoothness. West/north handles also shift the panel origin so the opposite edge stays pinned. Same teardown discipline as the drag.
*/
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: ExpandResizeDirection) => {
event.preventDefault();
event.stopPropagation();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = size;
const startPosition = position;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latestSize = startSize;
let latestPosition = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampExpandSize({
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
});
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
latestSize = nextSize;
latestPosition = nextPosition;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setSizeState(latestSize);
setPositionState(clampExpandPosition(latestPosition, latestSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistSize(latestSize);
persistPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-resize teardown.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, persistSize, position, size]);
// FNXC:RightDock 2026-06-22-17:40: Run any active drag/resize teardown on unmount so document pointer listeners + a pending rAF never outlive the modal.
useEffect(() => () => dragTeardownRef.current?.(), []);
useEffect(() => {
if (entry) return undefined;
return () => {
@@ -295,63 +53,50 @@ export function RightDockExpandModal({
};
}, [entry, returnFocusRef]);
if (!entry) {
return null;
}
if (!entry) return null;
const Icon = entry.icon;
const expandedViewLabel = t("rightDock.viewExpanded", "{{label}} expanded", { label: entry.label });
const panelStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex,
} as CSSProperties;
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root.
return createPortal(
<div className="modal-overlay open right-dock-expand-modal-overlay" role="dialog" aria-modal="false" aria-label={expandedViewLabel} data-testid="right-dock-expand-modal" style={{ zIndex }}>
/*
FNXC:ModalTouchGeometry 2026-07-27-17:00:
FN-8620 replaces this pop-out's duplicate captured-pointer geometry engine with FloatingWindow.
Its custom header stays the delegated drag surface, while the shared host owns clamping, stack
claims, tablet touch targets, and teardown. The old size/position key pair intentionally resets
once to this single geometry record.
*/
return (
<FloatingWindow
title={expandedViewLabel}
onClose={closeAndRestoreFocus}
windowKey="right-dock-expand"
defaultSize={{ width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT }}
minSize={{ width: EXPAND_MIN_WIDTH, height: EXPAND_MIN_HEIGHT }}
hideHeader
dragHandleSelector=".right-dock-expand-modal__header"
persistGeometryKey="fusion:right-dock-expand-modal-geometry"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
ariaLabel={expandedViewLabel}
className="modal right-dock-expand-modal right-dock-expand-modal--floating"
testId="right-dock-expand-modal"
>
<div
className="modal right-dock-expand-modal right-dock-expand-modal--floating"
style={panelStyle}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
className="modal-header right-dock-expand-modal__header right-dock-expand-modal__header--draggable"
data-testid="right-dock-expand-drag-handle"
>
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`right-dock-expand-resize-handle right-dock-expand-resize-handle--${direction}`}
data-testid={`right-dock-expand-resize-${direction}`}
role="separator"
aria-label={t("rightDock.resizeExpandedView", "Resize expanded right dock window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div
className="modal-header right-dock-expand-modal__header right-dock-expand-modal__header--draggable"
data-testid="right-dock-expand-drag-handle"
onPointerDown={handleFloatingDragPointerDown}
>
<div className="right-dock-expand-modal__title">
<Maximize2 size={16} />
<Icon size={16} />
<span>{entry.label}</span>
</div>
<button className="modal-close" onClick={closeAndRestoreFocus} aria-label={t("rightDock.closeExpandedView", "Close expanded right dock view")} data-testid="right-dock-expand-close">
<X size={20} />
</button>
</div>
{/*
FNXC:RightDockFiles 2026-06-22-15:00:
Tag the render props with `surface="expand"` so registry entries (notably Files) deterministically choose their pop-out layout instead of guessing from a measured container width. DockFilesView reads this to force its LEFT|RIGHT two-pane layout.
*/}
<div className="right-dock-expand-modal__body" data-testid="right-dock-expand-body">
{entry.render({ ...renderProps, surface: "expand" })}
<div className="right-dock-expand-modal__title">
<Maximize2 size={16} />
<Icon size={16} />
<span>{entry.label}</span>
</div>
<button className="modal-close" onClick={closeAndRestoreFocus} aria-label={t("rightDock.closeExpandedView", "Close expanded right dock view")} data-testid="right-dock-expand-close">
<X size={20} />
</button>
</div>
</div>,
document.body,
<div className="right-dock-expand-modal__body" data-testid="right-dock-expand-body">
{entry.render({ ...renderProps, surface: "expand" })}
</div>
</FloatingWindow>
);
}

View File

@@ -8,6 +8,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { openExternalUrl } from "../utils/open-external";
import { DirectoryPicker } from "./DirectoryPicker";
import { suggestProjectName } from "../utils/projectDetection";
import { FloatingWindow } from "./FloatingWindow";
/*
FNXC:TaskPrefix 2026-06-24-19:00:
@@ -449,7 +450,22 @@ export function SetupWizardModal({
const modalClassName = `modal setup-wizard-modal${state.step === "agent" ? " setup-wizard-modal--agent" : ""}`;
return (
<div className="modal-overlay open setup-wizard-overlay" role="dialog" aria-modal="true" aria-labelledby="wizard-title">
<FloatingWindow
windowKey="setup-wizard"
title={t("setup.welcomeToFusion", "Welcome to Fusion")}
ariaLabelledBy="wizard-title"
onClose={() => {}}
hideHeader
dragHandleSelector=".setup-wizard-modal .setup-wizard-header"
className="floating-window--setup-wizard"
defaultSize={{ width: 760, height: 680 }}
minSize={{ width: 480, height: 360 }}
persistGeometryKey="floating-window:setup-wizard"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
modal
>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: The first-run wizard remains blocking, while its reflowable steps use shared tablet geometry and suspend it for sheet viewports. */}
<div className={modalClassName}>
{/* Header */}
<div className="setup-wizard-header">
@@ -1000,6 +1016,6 @@ export function SetupWizardModal({
</Suspense>
</ErrorBoundary>
)}
</div>
</FloatingWindow>
);
}

View File

@@ -25,6 +25,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import { FloatingWindow } from "./FloatingWindow";
const WARNING_ICON = "⚠️";
@@ -604,7 +605,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true">
<FloatingWindow windowKey="subtask-breakdown" title={t("subtasks.modalTitle", "Subtask Breakdown")} ariaLabel={t("subtasks.modalTitle", "Subtask Breakdown")} onClose={() => void handleClose()} hideHeader dragHandleSelector=".planning-modal .modal-header" className="floating-window--subtask-breakdown" defaultSize={{ width: 760, height: 640 }} minSize={{ width: 440, height: 320 }} persistGeometryKey="floating-window:subtask-breakdown" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Subtask close stays on handleClose for its confirmation path while FloatingWindow owns all tablet geometry. */}
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
<div className="modal-header">
<div className="detail-title-row">
@@ -923,7 +925,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -52,8 +52,7 @@ FN-7621 recurrence #3 root cause: styles.css's mobile `@media (max-width: 768px)
touch-action: pan-x pan-y;
}
.modal-overlay.terminal-modal-overlay--docked.terminal-modal-overlay--docked,
.modal-overlay.terminal-modal-overlay--floating.terminal-modal-overlay--floating {
.modal-overlay.terminal-modal-overlay--docked.terminal-modal-overlay--docked {
align-items: stretch;
justify-content: flex-end;
padding: 0;
@@ -63,14 +62,6 @@ FN-7621 recurrence #3 root cause: styles.css's mobile `@media (max-width: 768px)
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Only the FLOATING terminal joins the shared cross-type floating stack. Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context; the floating panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the right-dock pop-out, the floating New Task dialog, and FloatingWindow. Docked mode keeps the base overlay stacking (full-width bottom panel) and is intentionally excluded.
*/
.modal-overlay.terminal-modal-overlay--floating {
z-index: auto;
}
.modal.terminal-modal {
/* Initial dimensions are applied only when no persisted size has been
restored — see :not([style*=...]) selectors below. */
@@ -92,6 +83,22 @@ Only the FLOATING terminal joins the shared cross-type floating stack. Reset the
height: min(85vh, calc(100dvh - 40px));
}
/*
FNXC:ModalTouchGeometry 2026-07-27-19:00:
The floating terminal is now content inside FloatingWindow, so its old standalone dimensions and
native resize grip must not escape the shared geometry host. The docked and below presentations
retain their own sizing rules below.
*/
.floating-window .modal.terminal-modal.terminal-modal--floating {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-width: none;
max-height: none;
resize: none;
}
.modal.terminal-modal.terminal-modal--docked {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
@@ -200,88 +207,6 @@ FN-7897: reserve space for the fixed ExecutorStatusBar footer when it is rendere
background: var(--border);
}
.modal.terminal-modal.terminal-modal--floating {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
left: var(--terminal-float-x);
top: var(--terminal-float-y);
width: var(--terminal-float-width);
height: var(--terminal-float-height);
min-width: calc(var(--space-xl) * 20);
min-height: calc(var(--space-xl) * 13.333);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
/*
FNXC:FloatingWindow 2026-06-23-23:32:
Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Terminal, New Task, Right Dock, and shared FloatingWindow panels.
*/
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
/*
FNXC:Terminal 2026-06-22-19:50:
The floating-mode header is the move grip. `touch-action: none` is required so a touch-drag on it is delivered as a continuous pointermove stream (paired with setPointerCapture on the captured element) instead of being hijacked by the browser into page scroll/pan. Without it the floating drag stutters on touch — same fix the right-dock pop-out drag handle uses. cursor: grab/grabbing signals the move affordance on desktop.
*/
.terminal-header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
min-height: 48px;
}
.terminal-header--draggable:active {
cursor: grabbing;
}
.terminal-floating-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
/*
FNXC:TerminalModalControls 2026-07-24-12:35:
Tablet operators resize the global floating terminal with the same edge and corner targets as other
floating windows. Use tokenized, larger hit regions so touch gestures are discoverable without
adding a second header or exposing controls in the fixed phone and embedded presentations.
*/
.terminal-floating-resize-handle--n,
.terminal-floating-resize-handle--s {
left: var(--space-md);
right: var(--space-md);
height: var(--space-md);
cursor: ns-resize;
}
.terminal-floating-resize-handle--n { top: 0; }
.terminal-floating-resize-handle--s { bottom: 0; }
.terminal-floating-resize-handle--e,
.terminal-floating-resize-handle--w {
top: var(--space-md);
bottom: var(--space-md);
width: var(--space-md);
cursor: ew-resize;
}
.terminal-floating-resize-handle--e { right: 0; }
.terminal-floating-resize-handle--w { left: 0; }
.terminal-floating-resize-handle--ne,
.terminal-floating-resize-handle--nw,
.terminal-floating-resize-handle--se,
.terminal-floating-resize-handle--sw {
width: var(--space-xl);
height: var(--space-xl);
}
.terminal-floating-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.terminal-floating-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.terminal-floating-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.terminal-floating-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
.terminal-header {
display: flex;
align-items: center;
@@ -1900,17 +1825,6 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
This keeps the shared header move grip and all eight resize targets reachable at
exactly 768px without exposing any geometry controls on true-phone sheets.
*/
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating {
min-width: calc(var(--space-xl) * 20) !important;
min-height: calc(var(--space-xl) * 13.333) !important;
width: var(--terminal-float-width) !important;
height: var(--terminal-float-height) !important;
max-width: calc(100vw - (var(--space-lg) * 2)) !important;
max-height: calc(100dvh - (var(--space-lg) * 2)) !important;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--docked {
min-width: 0 !important;
min-height: calc(var(--space-xl) * 10) !important;

View File

@@ -35,7 +35,8 @@ import { useTerminal } from "../hooks/useTerminal";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import { useWorkspaces } from "../hooks/useWorkspaces";
import { getViewportMode, isMobileViewport } from "../hooks/useViewportMode";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import { FloatingWindow, FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow";
import { currentFloatingZ } from "./floatingWindowStack";
import { getPathBasename } from "../utils/pathDisplay";
import {
DEFAULT_TERMINAL_PREFERENCES,
@@ -97,20 +98,6 @@ const TERMINAL_FLOAT_DEFAULT_WIDTH = 960;
const TERMINAL_FLOAT_DEFAULT_HEIGHT = 560;
const TERMINAL_FLOAT_MIN_WIDTH = 480;
const TERMINAL_FLOAT_MIN_HEIGHT = 320;
const TERMINAL_FLOAT_VIEWPORT_PADDING = 16;
type TerminalResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const TERMINAL_RESIZE_DIRECTIONS: TerminalResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
interface TerminalFloatSize {
width: number;
height: number;
}
interface TerminalFloatPosition {
x: number;
y: number;
}
interface TerminalWorkspaceMenuPosition {
top: number;
@@ -168,66 +155,6 @@ function writeTerminalDockedHeight(height: number, projectId?: string, mode: "do
return clamped;
}
function clampTerminalFloatSize(size: TerminalFloatSize): TerminalFloatSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, TERMINAL_FLOAT_MIN_WIDTH), Math.max(TERMINAL_FLOAT_MIN_WIDTH, window.innerWidth - TERMINAL_FLOAT_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, TERMINAL_FLOAT_MIN_HEIGHT), Math.max(TERMINAL_FLOAT_MIN_HEIGHT, window.innerHeight - TERMINAL_FLOAT_VIEWPORT_PADDING * 2)),
};
}
function clampTerminalFloatPosition(position: TerminalFloatPosition, size: TerminalFloatSize): TerminalFloatPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, TERMINAL_FLOAT_VIEWPORT_PADDING), Math.max(TERMINAL_FLOAT_VIEWPORT_PADDING, window.innerWidth - size.width - TERMINAL_FLOAT_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, TERMINAL_FLOAT_VIEWPORT_PADDING), Math.max(TERMINAL_FLOAT_VIEWPORT_PADDING, window.innerHeight - size.height - TERMINAL_FLOAT_VIEWPORT_PADDING)),
};
}
function readTerminalFloatSize(projectId?: string): TerminalFloatSize {
if (typeof window === "undefined") return { width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(`fusion:terminal-modal-size-${projectId ?? "default"}`) ?? window.localStorage.getItem("fusion:terminal-modal-size");
if (raw) {
const parsed = JSON.parse(raw) as Partial<TerminalFloatSize>;
if (typeof parsed.width === "number" && typeof parsed.height === "number") return clampTerminalFloatSize({ width: parsed.width, height: parsed.height });
}
} catch {
// ignore corrupted size
}
return clampTerminalFloatSize({ width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT });
}
function writeTerminalFloatSize(size: TerminalFloatSize, projectId?: string): TerminalFloatSize {
const clamped = clampTerminalFloatSize(size);
if (typeof window !== "undefined") {
window.localStorage.setItem(`fusion:terminal-modal-size-${projectId ?? "default"}`, JSON.stringify(clamped));
}
return clamped;
}
function readTerminalFloatPosition(size: TerminalFloatSize, projectId?: string): TerminalFloatPosition {
if (typeof window === "undefined") return { x: TERMINAL_FLOAT_VIEWPORT_PADDING, y: TERMINAL_FLOAT_VIEWPORT_PADDING };
try {
const raw = window.localStorage.getItem(`fusion:terminal-float-pos-${projectId ?? "default"}`);
if (raw) {
const parsed = JSON.parse(raw) as Partial<TerminalFloatPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") return clampTerminalFloatPosition({ x: parsed.x, y: parsed.y }, size);
}
} catch {
// ignore corrupted position
}
return clampTerminalFloatPosition({ x: window.innerWidth - size.width - TERMINAL_FLOAT_VIEWPORT_PADDING, y: TERMINAL_FLOAT_VIEWPORT_PADDING }, size);
}
function writeTerminalFloatPosition(position: TerminalFloatPosition, size: TerminalFloatSize, projectId?: string): TerminalFloatPosition {
const clamped = clampTerminalFloatPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(`fusion:terminal-float-pos-${projectId ?? "default"}`, JSON.stringify(clamped));
}
return clamped;
}
const TERMINAL_KEY_LABELS = {
ctrl: "Ctrl",
alt: "Alt",
@@ -589,8 +516,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const [pendingInitialCommandGeneration, setPendingInitialCommandGeneration] = useState(0);
const [displayMode, setDisplayModeState] = useState<TerminalDisplayMode>(() => readTerminalDisplayMode(projectId));
const [dockedHeight, setDockedHeight] = useState(() => readTerminalDockedHeight(projectId));
const [floatingSize, setFloatingSize] = useState<TerminalFloatSize>(() => readTerminalFloatSize(projectId));
const [floatingPosition, setFloatingPosition] = useState<TerminalFloatPosition>(() => readTerminalFloatPosition(readTerminalFloatSize(projectId), projectId));
const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport());
const [isTabletTerminal, setIsTabletTerminal] = useState(() => getViewportMode() === "tablet");
const [tabsOverflow, setTabsOverflow] = useState(false);
@@ -601,12 +526,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const isDockedMode = !embedded && !isMobileTerminal && displayMode === "docked";
const isFloatingMode = !embedded && !isMobileTerminal && displayMode === "floating";
const isBelowMode = !embedded && !isMobileTerminal && displayMode === "below";
// FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet).
const [floatingZ, setFloatingZ] = useState<number>(() => nextFloatingZ());
const bringFloatingToFront = useCallback(() => {
if (!isFloatingMode) return;
setFloatingZ((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, [isFloatingMode]);
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -700,9 +619,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
useEffect(() => {
setDisplayModeState(readTerminalDisplayMode(projectId));
setDockedHeight(readTerminalDockedHeight(projectId));
const nextSize = readTerminalFloatSize(projectId);
setFloatingSize(nextSize);
setFloatingPosition(readTerminalFloatPosition(nextSize, projectId));
}, [projectId]);
useEffect(() => {
@@ -736,25 +652,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
window.dispatchEvent(new CustomEvent("fusion:terminal-display-mode-change", { detail: { projectId, mode } }));
}, [projectId]);
const persistFloatingSize = useCallback((size: TerminalFloatSize) => {
setFloatingSize(writeTerminalFloatSize(size, projectId));
}, [projectId]);
const persistFloatingPosition = useCallback((position: TerminalFloatPosition, size = floatingSize) => {
setFloatingPosition(writeTerminalFloatPosition(position, size, projectId));
}, [floatingSize, projectId]);
/*
FNXC:Terminal 2026-06-21-22:26:
FN-6887 requires desktop/tablet terminal opens to default to a project-scoped docked bottom panel. Persist `fusion:terminal-display-mode-${projectId}` and `fusion:terminal-docked-height-${projectId}` so each project restores its preferred panel mode and height without affecting mobile fullscreen behavior.
FNXC:Terminal 2026-06-21-22:45:
The pop-out terminal mode uses project-scoped `fusion:terminal-modal-size-${projectId}` and `fusion:terminal-float-pos-${projectId}` keys so floating windows restore independently per project while avoiding the old bottom-right native resize grip conflict.
*/
/*
FNXC:Terminal 2026-06-22-19:50:
Docked top-edge resize, smooth on touch + desktop (same technique as the right-dock pop-out RightDockExpandModal). On pointerdown we setPointerCapture on the handle and attach pointermove/up/cancel to the CAPTURED element (`captureTarget` = event.currentTarget), NOT `document` — capture redirects the full pointer stream for this pointerId to that element so element-scoped listeners receive every move even when the finger drifts off the handle, and they pair cleanly with the handle's `touch-action: none` (CSS) without a non-passive document listener. Moves are filtered by pointerId and coalesced into one rAF, so we set height at most once per frame and never thrash layout on a flood of touch-move events. localStorage is written only on pointerup (existing behavior). Teardown (pointerup/cancel + unmount via dragTeardownRef) cancels the pending rAF, releases pointer capture, and detaches listeners.
*/
const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isDockedMode && !isBelowMode) return;
event.preventDefault();
@@ -806,133 +703,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [dockedHeight, isBelowMode, isDockedMode, projectId]);
/*
FNXC:Terminal 2026-06-22-19:50:
Floating-window move (drag the header grip), smooth on touch + desktop. Pointer capture + captured-element (`captureTarget`) listeners filtered by pointerId, identical to the right-dock pop-out drag. Raw pointer coords are stored in `latest` and applied via one rAF per frame, so a flood of touch-move events coalesces into a single state set and never thrashes layout. State-only updates during the drag; localStorage is persisted once on pointerup (the old per-move persistFloatingPosition wrote localStorage on every move, which janked touch drags). Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount.
*/
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isFloatingMode || (event.target as HTMLElement).closest("button")) return;
event.preventDefault();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = floatingPosition;
const currentSize = floatingSize;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latest = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setFloatingPosition(clampTerminalFloatPosition(latest, currentSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistFloatingPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial move.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition]);
/*
FNXC:Terminal 2026-06-22-19:50:
Floating-window edge/corner resize, smooth on touch + desktop. Pointer capture + captured-element listeners filtered by pointerId, rAF-batched size/position updates (west/north handles also shift the origin so the opposite edge stays pinned), persisted once on pointerup — same discipline as the right-dock pop-out resize. The old per-move persistFloatingSize/persistFloatingPosition wrote localStorage on every move; now we set state per frame and persist only on release. Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount.
*/
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: TerminalResizeDirection) => {
if (!isFloatingMode) return;
event.preventDefault();
event.stopPropagation();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = floatingSize;
const startPosition = floatingPosition;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latestSize = startSize;
let latestPosition = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampTerminalFloatSize({
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
});
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
latestSize = nextSize;
latestPosition = nextPosition;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setFloatingSize(latestSize);
setFloatingPosition(clampTerminalFloatPosition(latestPosition, latestSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistFloatingSize(latestSize);
persistFloatingPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial resize.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition, persistFloatingSize]);
/**
* Fit xterm and publish cols/rows for a specific terminal session.
*
@@ -1009,14 +779,25 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}
}, []);
/*
FNXC:ModalTouchGeometry 2026-07-27-18:20:
FloatingWindow now owns terminal pop-out geometry. Refit xterm after its shared geometry signal
so columns and rows follow drag/resize without reintroducing terminal-local pointer handlers.
*/
useEffect(() => {
if (!isFloatingMode) return;
const refitFloatingTerminal = (event: Event) => {
const detail = (event as CustomEvent<{ windowKey?: string }>).detail;
if (detail?.windowKey === `terminal-${projectId ?? "default"}`) fitAndResizeForSession();
};
window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, refitFloatingTerminal);
return () => window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, refitFloatingTerminal);
}, [fitAndResizeForSession, isFloatingMode, projectId]);
// Bump open generation whenever the modal opens so the initialCommand
// effect re-evaluates after a close/reopen cycle (deps may be identical).
// FNXC:FloatingWindow 2026-06-22-21:30: Each open also claims the front of the shared floating-window stack so a freshly-opened floating terminal sits above other floating modals.
useEffect(() => {
if (isOpen) {
setOpenGeneration((g) => g + 1);
setFloatingZ(nextFloatingZ());
}
if (isOpen) setOpenGeneration((g) => g + 1);
}, [isOpen]);
// Track virtual keyboard overlap on mobile so the terminal entry area
@@ -1101,14 +882,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
/*
FNXC:Terminal 2026-06-21-22:07:
Docked and floating terminal resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode, docked height, or floating size changes to keep rows/cols synchronized.
Docked resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode or docked height changes. FloatingWindow geometry is handled by its dedicated event listener.
*/
useEffect(() => {
if (!isOpen) return;
const sessionId = typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined;
const frame = requestAnimationFrame(() => fitAndResizeForSession(sessionId));
return () => cancelAnimationFrame(frame);
}, [displayMode, dockedHeight, fitAndResizeForSession, floatingSize, isOpen]);
}, [displayMode, dockedHeight, fitAndResizeForSession, isOpen]);
// Refit xterm whenever the user drags the modal's CSS resize grip.
// The window/visualViewport listeners only fire on viewport changes; native
@@ -2516,12 +2297,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isTabletTerminal && !isMobileTerminal && !embedded ? " terminal-modal--tablet" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`;
/*
FNXC:TerminalWorkspaces 2026-07-13-00:00:
The workspace picker menu is portaled to `document.body`, so floating terminal mode must compare it in the same root stacking context as the panel. Keep the menu one layer above the panel's shared `floatingZ`; otherwise the fixed CSS fallback band sits below the 10100+ floating stack and the menu appears invisible behind the modal.
The workspace picker menu is portaled to `document.body`, so floating terminal mode keeps it in the utility floating band above the terminal panel. FloatingWindow owns the panel stack claim; this fixed menu band preserves the menu's root-portal visibility.
FNXC:TerminalWorkspaces 2026-07-13-00:00:
The portaled listbox has CSS fallback coordinates for non-JS resilience, but it must never paint there during the open-frame measurement pass. Position in a layout effect and keep the menu invisible/non-interactive until the computed trigger-relative coordinates are applied.
*/
const terminalWorkspaceMenuFloatingZ = isFloatingMode ? Math.max(5000, floatingZ + 1) : undefined;
const terminalWorkspaceMenuFloatingZ = isFloatingMode ? currentFloatingZ() + 1 : undefined;
const modalStyle = {
...(keyboardOverlap > 0
@@ -2537,16 +2318,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
: {}),
...(isDockedMode ? { "--terminal-docked-height": `${dockedHeight}px` } : {}),
...(isBelowMode ? { "--terminal-below-height": `${clampTerminalBelowHeight(dockedHeight || TERMINAL_BELOW_DEFAULT_HEIGHT)}px` } : {}),
...(isFloatingMode
? {
"--terminal-float-x": `${floatingPosition.x}px`,
"--terminal-float-y": `${floatingPosition.y}px`,
"--terminal-float-width": `${floatingSize.width}px`,
"--terminal-float-height": `${floatingSize.height}px`,
// FNXC:FloatingWindow 2026-06-22-21:30: Inline z from the shared cross-type stack; only the floating panel participates.
zIndex: floatingZ,
}
: {}),
} as CSSProperties;
/*
@@ -2729,14 +2500,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</div>
);
const terminalPanel = (
const terminalContent = (
<div
ref={modalRef}
className={modalClassName}
data-testid="terminal-modal"
style={modalStyle}
onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined}
onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined}
role={isBelowMode ? "region" : undefined}
aria-label={isBelowMode ? t("terminal.belowRegion", "Pinned terminal") : undefined}
>
@@ -2750,19 +2519,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
onPointerDown={handleDockedResizePointerDown}
/>
)}
{!embedded && isFloatingMode && TERMINAL_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`terminal-floating-resize-handle terminal-floating-resize-handle--${direction}`}
data-testid={`terminal-floating-resize-${direction}`}
role="separator"
aria-label={t("terminal.resizeFloatingPanel", "Resize terminal window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
{/* Header — on mobile (≤768px) use compact selector/actions;
.terminal-title is hidden; action button labels are hidden (icons only) */}
<div className={`terminal-header${isFloatingMode ? " terminal-header--draggable" : ""}`} onPointerDown={handleFloatingDragPointerDown}>
<div className="terminal-header">
{/* Tab Bar */}
{isMobileTerminal ? renderTerminalMobileTabs() : (
<div className="terminal-tab-region" ref={terminalTabRegionRef}>
@@ -3371,6 +3130,32 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</div>
);
/*
FNXC:ModalTouchGeometry 2026-07-27-18:20:
Only the terminal pop-out uses the shared floating host. Docked, below, mobile, and embedded
presentations retain their existing layout and lifecycle because they are not floating windows.
The legacy size/position pair intentionally resets to one project-scoped geometry record.
*/
const terminalPanel = isFloatingMode ? (
<FloatingWindow
title={t("terminal.title", "Terminal")}
onClose={onClose}
windowKey={`terminal-${projectId ?? "default"}`}
defaultSize={{ width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT }}
minSize={{ width: TERMINAL_FLOAT_MIN_WIDTH, height: TERMINAL_FLOAT_MIN_HEIGHT }}
hideHeader
dragHandleSelector=".terminal-header"
persistGeometryKey={`fusion:terminal-float-geometry-${projectId ?? "default"}`}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
ariaLabel={t("terminal.title", "Terminal")}
className={modalClassName}
testId="terminal-modal-overlay"
>
{terminalContent}
</FloatingWindow>
) : terminalContent;
if (embedded) {
return (
<div className="terminal-embedded-host" data-testid="terminal-embedded-host">
@@ -3398,7 +3183,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
);
}
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so overlay terminal modes share the ONE root stacking context with other floating modals. Below mode intentionally skips the portal so it can reserve in-flow application space.
if (isFloatingMode) return terminalPanel;
// Docked and mobile terminal presentations retain their established overlay host.
return createPortal(
<div
className={overlayClassName}
@@ -3408,8 +3195,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
aria-modal="true"
data-testid="terminal-modal-overlay"
style={{
// FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped inside it and loses to page stacking contexts like the right dock (position:absolute z-index:20). Docked/mobile keep their CSS z.
...(isFloatingMode ? { zIndex: floatingZ } : {}),
...(keyboardOverlap > 0 ? { "--overlay-padding-top": "0px" } : {}),
} as CSSProperties}
>

View File

@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { migratedModalFixtures } from "./migratedModalFixtures";
const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && fixture.name.includes("Agent"));
/* FNXC:ModalTouchGeometry 2026-07-26-18:55: Exercise every agent geometry identity against the shared host; the modal prop is deliberate because these were formerly page-blocking dialogs. */
describe("agent modal FloatingWindow behavior", () => {
it.each(fixtures)("$name exposes modal drag, eight-way resize, and persisted geometry", (fixture) => {
const close = vi.fn();
render(<FloatingWindow modal windowKey={fixture.key!} title={fixture.name} onClose={close} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 80 }} persistGeometryKey={fixture.key!}><button>action</button></FloatingWindow>);
const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`);
expect(host).toHaveAttribute("aria-modal", "true");
const panel = screen.getByTestId(`floating-window-${fixture.key}`);
const drag = screen.getByTestId(`floating-window-drag-handle-${fixture.key}`);
Object.defineProperty(drag, "setPointerCapture", { configurable: true, value: vi.fn() });
Object.defineProperty(drag, "releasePointerCapture", { configurable: true, value: vi.fn() });
fireEvent.pointerDown(drag, { pointerType: "touch", pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(drag, { pointerType: "touch", pointerId: 1, clientX: 120, clientY: 130 });
fireEvent.pointerUp(drag, { pointerType: "touch", pointerId: 1, clientX: 120, clientY: 130 });
expect(panel.style.left).toBe("100px");
for (const direction of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) expect(screen.getByTestId(`floating-window-resize-${direction}`)).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(fixture.key!) ?? "{}")).toHaveProperty("position");
});
});

View File

@@ -295,7 +295,6 @@ describe("FloatingWindow", () => {
for (const selector of [
".right-dock-expand-modal__header--draggable",
".terminal-header--draggable",
]) {
expect(cssRuleFor(allAppCss, selector)).toContain("touch-action: none;");
}
@@ -352,7 +351,6 @@ describe("FloatingWindow", () => {
".floating-window--pr-create .pr-create-modal__drag-handle",
".file-browser-modal-header",
".artifacts-gallery-viewer-header",
".terminal-header--draggable",
".right-dock-expand-modal__header--draggable",
".new-task-modal__header--draggable",
".quick-chat-fab",
@@ -745,6 +743,31 @@ describe("FloatingWindow", () => {
expect(Number.parseFloat(panel.style.left)).toBeLessThan(window.innerWidth);
});
it("reloads persisted geometry when a mounted host changes its project-scoped identity", () => {
const firstKey = "floating-window:project-one";
const secondKey = "floating-window:project-two";
const firstGeometry = { size: { width: 610, height: 430 }, position: { x: 80, y: 90 } };
const secondGeometry = { size: { width: 700, height: 500 }, position: { x: 120, y: 110 } };
localStorage.setItem(firstKey, JSON.stringify(firstGeometry));
localStorage.setItem(secondKey, JSON.stringify(secondGeometry));
const { rerender } = render(
<FloatingWindow windowKey="terminal-project-one" title="Terminal" onClose={() => {}} persistGeometryKey={firstKey}>
<div>terminal body</div>
</FloatingWindow>,
);
expect(screen.getByTestId("floating-window-terminal-project-one")).toHaveStyle({ width: "610px", height: "430px" });
rerender(
<FloatingWindow windowKey="terminal-project-two" title="Terminal" onClose={() => {}} persistGeometryKey={secondKey}>
<div>terminal body</div>
</FloatingWindow>,
);
expect(screen.getByTestId("floating-window-terminal-project-two")).toHaveStyle({ width: "700px", height: "500px" });
expect(JSON.parse(localStorage.getItem(firstKey) ?? "{}")).toEqual(firstGeometry);
});
it("falls back to default geometry when persisted geometry is malformed", () => {
localStorage.setItem("floating-window:malformed", "not-json");

View File

@@ -422,8 +422,8 @@ describe("NewTaskModal", () => {
await renderPickerWithData({ viewport: "mobile" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument());
expect(document.querySelector(".new-task-modal--floating")).toBeNull();
expect(newTaskModalCss).toContain("FNXC:NewTaskMobileAffordances 2026-06-25");
expect(document.querySelector(".floating-window.new-task-modal")).toBeNull();
expect(newTaskModalCss).toContain("FNXC:ModalTouchGeometry 2026-07-27-18:00");
expect(newTaskModalCss).toMatch(/\.new-task-modal\s*\{[^}]*pointer-events:\s*auto;/s);
});
@@ -944,7 +944,7 @@ describe("NewTaskModal", () => {
["mobile Escape", "mobile", (overlay: HTMLElement) => fireEvent.keyDown(overlay, { key: "Escape" })],
["desktop Close", "desktop", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Close" }))],
["desktop Cancel", "desktop", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))],
["desktop Escape", "desktop", (overlay: HTMLElement) => fireEvent.keyDown(overlay, { key: "Escape" })],
["desktop Escape", "desktop", (_overlay: HTMLElement) => fireEvent.keyDown(document, { key: "Escape" })],
] as const)("closes a blank modal with inherited default-on steps through %s without confirmation", async (_closePath, viewport, close) => {
const { fetchSettings, fetchWorkflowOptionalSteps } = await import("../../api");
mockViewportMode = viewport;
@@ -2182,8 +2182,9 @@ describe("NewTaskModal", () => {
renderNewTaskModal({ onClose });
const overlay = screen.getByTestId("new-task-modal-overlay");
// Non-blocking: click-through overlay, not a modal.
expect(overlay).toHaveClass("new-task-modal-overlay");
// FloatingWindow owns the host but New Task retains its click-through desktop contract.
expect(overlay).toHaveClass("floating-window-overlay");
expect(overlay).not.toHaveClass("floating-window-overlay--modal");
expect(overlay).toHaveAttribute("aria-modal", "false");
// A behind-click on the overlay must NOT close the dialog (no overlay click-to-dismiss).
@@ -2196,13 +2197,11 @@ describe("NewTaskModal", () => {
renderNewTaskModal();
expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable");
expect(document.querySelector(".new-task-modal")).toHaveClass("task-modal--tablet");
expect(document.querySelector(".new-task-modal")).toHaveClass("new-task-modal--floating");
expect(document.querySelector(".new-task-modal")).toHaveClass("task-modal--tablet", "floating-window", "new-task-modal");
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("role", "separator");
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("aria-label", "Resize new task window");
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("tabindex", "0");
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("data-resize-hit-target", "true");
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toHaveAttribute("role", "separator");
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toHaveAttribute("aria-label", "Resize floating window");
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toHaveAttribute("data-resize-hit-target", "true");
}
});
@@ -2212,38 +2211,21 @@ describe("NewTaskModal", () => {
expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable");
// All eight corner/edge resize handles are present.
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`new-task-resize-${dir}`)).toBeInTheDocument();
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeInTheDocument();
}
// The floating panel is the fixed-positioned window.
const panel = document.querySelector(".new-task-modal--floating");
const panel = document.querySelector(".floating-window.new-task-modal");
expect(panel).not.toBeNull();
});
it("resizes from a focused tablet handle with keyboard controls and exposes updated geometry", () => {
it("uses shared pointer-only resize controls for tablet geometry", () => {
mockViewportMode = "tablet";
renderNewTaskModal();
const panel = document.querySelector(".new-task-modal--floating") as HTMLElement;
const handle = screen.getByTestId("new-task-resize-se");
const initialWidth = Number.parseFloat(panel.style.width);
const initialHeight = Number.parseFloat(panel.style.height);
handle.focus();
expect(handle).toHaveFocus();
expect(handle).toHaveAttribute("aria-valuenow", String(initialWidth));
expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${initialWidth} by ${initialHeight}`);
fireEvent.keyDown(handle, { key: "ArrowRight" });
fireEvent.keyDown(handle, { key: "ArrowDown" });
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight);
expect(handle).toHaveAttribute("aria-valuenow", panel.style.width.replace("px", ""));
expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${panel.style.width.replace("px", "")} by ${panel.style.height.replace("px", "")}`);
expect(JSON.parse(window.localStorage.getItem("fusion:new-task-modal-size") ?? "{}")).toMatchObject({
width: Number.parseFloat(panel.style.width),
height: Number.parseFloat(panel.style.height),
});
const handle = screen.getByTestId("floating-window-resize-se");
expect(handle).toHaveAttribute("role", "separator");
expect(handle).not.toHaveAttribute("tabindex");
expect(handle).not.toHaveAttribute("aria-valuenow");
});
it("resizes from a tablet touch handle without dismissing or leaking selection state", () => {
@@ -2256,8 +2238,8 @@ describe("NewTaskModal", () => {
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderNewTaskModal({ onClose });
const panel = document.querySelector(".new-task-modal--floating") as HTMLElement;
const handle = screen.getByTestId("new-task-resize-se");
const panel = document.querySelector(".floating-window.new-task-modal") as HTMLElement;
const handle = screen.getByTestId("floating-window-resize-se");
const initialWidth = Number.parseFloat(panel.style.width);
const initialHeight = Number.parseFloat(panel.style.height);
@@ -2273,13 +2255,9 @@ describe("NewTaskModal", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the floating window touch-draggable with theme-controlled shadow", () => {
const panelRule = newTaskModalCss.match(/\.new-task-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
const headerRule = newTaskModalCss.match(/\.new-task-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
expect(headerRule).toContain("touch-action: none;");
expect(headerRule).toContain("min-height: 48px;");
it("delegates touch drag styling to the shared FloatingWindow primitive", () => {
expect(newTaskModalCss).toContain("FloatingWindow owns its shared drag");
expect(newTaskModalCss).not.toContain("new-task-resize-handle");
expect(newTaskModalCss).not.toContain("var(--shadow-xl)");
});

View File

@@ -0,0 +1,20 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { migratedModalFixtures } from "./migratedModalFixtures";
const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && /Setup|Native|Docker/.test(fixture.name));
describe("onboarding modal FloatingWindow behavior", () => {
it.each(fixtures)("$name keeps a blocking focus boundary and geometry handles", (fixture) => {
render(<FloatingWindow modal windowKey={fixture.key!} title={fixture.name} onClose={vi.fn()} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 80 }} persistGeometryKey={fixture.key!}><button>first</button><button>last</button></FloatingWindow>);
const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`);
const panel = screen.getByTestId(`floating-window-${fixture.key}`);
expect(host).toHaveAttribute("aria-modal", "true");
expect(panel).toHaveAttribute("tabindex", "-1");
expect(screen.getAllByLabelText("Resize floating window")).toHaveLength(8);
expect(localStorage.getItem(fixture.key!)).not.toBeNull();
fireEvent.keyDown(document, { key: "Tab" });
expect(panel.contains(document.activeElement)).toBe(true);
});
});

View File

@@ -771,11 +771,11 @@ describe("RightDock", () => {
FNXC:RightDock 2026-06-22-17:40:
The pop-out is a floating, non-blocking window: the overlay carries the non-blocking class (transparent + pointer-events:none in CSS so behind-clicks pass through), a drag handle (header) exists, and the panel is the floating variant. There is no overlay click-to-dismiss; the explicit close button is the only dismissal.
*/
expect(screen.getByTestId("right-dock-expand-modal")).toHaveClass("right-dock-expand-modal-overlay");
expect(screen.getByTestId("right-dock-expand-modal")).toHaveClass("floating-window-overlay");
expect(screen.getByTestId("right-dock-expand-modal")).toHaveAttribute("aria-modal", "false");
expect(screen.getByTestId("right-dock-expand-drag-handle")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-expand-modal").querySelector(".right-dock-expand-modal--floating")).not.toBeNull();
expect(screen.getByTestId("right-dock-expand-resize-se")).toHaveAttribute("aria-label", "Resize expanded right dock window");
expect(screen.getByTestId("floating-window-right-dock-expand")).toHaveClass("right-dock-expand-modal--floating");
expect(screen.getByTestId("floating-window-resize-se")).toHaveAttribute("aria-label", "Resize floating window");
expect(screen.getByTestId("right-dock-expand-close")).toHaveAttribute("aria-label", "Close expanded right dock view");
fireEvent.click(screen.getByTestId("right-dock-expand-close"));
expect(onClose).toHaveBeenCalledTimes(1);
@@ -828,7 +828,7 @@ describe("RightDock", () => {
});
it("restores the expanded modal's persisted size", () => {
window.localStorage.setItem("fusion:right-dock-expand-modal-size", JSON.stringify({ width: 640, height: 480 }));
window.localStorage.setItem("fusion:right-dock-expand-modal-geometry", JSON.stringify({ size: { width: 640, height: 480 }, position: { x: 32, y: 32 } }));
render(
<RightDockExpandModal
viewKey="files"
@@ -837,7 +837,7 @@ describe("RightDock", () => {
/>,
);
expect(screen.getByTestId("right-dock-expand-modal").querySelector(".right-dock-expand-modal")).toHaveStyle({
expect(screen.getByTestId("floating-window-right-dock-expand")).toHaveStyle({
width: "640px",
height: "480px",
});
@@ -864,11 +864,11 @@ describe("RightDock", () => {
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 60, clientY: 140 });
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 60, clientY: 140 });
const persisted = window.localStorage.getItem("fusion:right-dock-expand-modal-position");
const persisted = window.localStorage.getItem("fusion:right-dock-expand-modal-geometry");
expect(persisted).not.toBeNull();
const parsed = JSON.parse(persisted as string) as { x: number; y: number };
expect(parsed.x).toBeGreaterThanOrEqual(0);
expect(parsed.y).toBeGreaterThanOrEqual(0);
const parsed = JSON.parse(persisted as string) as { position: { x: number; y: number } };
expect(parsed.position.x).toBeGreaterThanOrEqual(16);
expect(parsed.position.y).toBeGreaterThanOrEqual(16);
});
it("fires expand for the currently selected inline entry", () => {

View File

@@ -689,7 +689,7 @@ describe("TerminalModal", () => {
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
expect(listbox.parentElement).toBe(document.body);
expect(requestAnimationFrameSpy).toHaveBeenCalled();
expect(Number.parseFloat(listbox.style.zIndex)).toBeGreaterThan(Number.parseFloat(modal.style.zIndex));
expect(Number.parseFloat(listbox.style.zIndex)).toBeGreaterThan(Number.parseFloat(screen.getByTestId("terminal-modal-overlay").style.zIndex));
expect(listbox.style.top).not.toBe("");
expect(listbox.style.left).not.toBe("");
expect(listbox.style.width).not.toBe("");
@@ -941,9 +941,9 @@ describe("TerminalModal", () => {
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
await waitFor(() => {
expect(modal).toHaveClass("terminal-modal--floating");
expect(modal).not.toHaveClass("terminal-modal--docked");
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--floating");
expect(screen.getByTestId("terminal-modal")).not.toHaveClass("terminal-modal--docked");
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
@@ -1182,20 +1182,20 @@ describe("TerminalModal", () => {
it("exposes floating drag and resize handles and refits after floating resize", async () => {
const projectId = "floating-resize-test";
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating");
window.localStorage.removeItem(`fusion:terminal-modal-size-${projectId}`);
window.localStorage.removeItem(`fusion:terminal-float-pos-${projectId}`);
window.localStorage.removeItem(`fusion:terminal-float-geometry-${projectId}`);
window.localStorage.removeItem(`fusion:terminal-float-geometry-${projectId}`);
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
const modal = await screen.findByTestId("terminal-modal");
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
expect(modal).toHaveClass("terminal-modal--floating");
expect(screen.getByTestId("terminal-floating-resize-n")).toBeInTheDocument();
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
expect(screen.getByTestId("floating-window-resize-n")).toBeInTheDocument();
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
// FNXC:Terminal 2026-06-22-19:50: Floating resize/drag now capture the pointer and listen on the CAPTURED element (not document); fire move/up on that element with the matching pointerId and stub set/releasePointerCapture.
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
resizeHandle.setPointerCapture = vi.fn();
resizeHandle.releasePointerCapture = vi.fn();
@@ -1204,7 +1204,7 @@ describe("TerminalModal", () => {
fireEvent.pointerUp(resizeHandle, { pointerId: 1 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 992, height: 590 }));
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").size).toEqual({ width: 992, height: 590 });
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
});
@@ -1216,7 +1216,7 @@ describe("TerminalModal", () => {
fireEvent.pointerUp(header, { pointerId: 2, pointerType: "touch" });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();
expect(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`)).toBeTruthy();
});
});
@@ -1236,7 +1236,7 @@ describe("TerminalModal", () => {
value: { width: 900, height: 400, addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-height: 480px)",
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
@@ -1252,10 +1252,10 @@ describe("TerminalModal", () => {
const modal = await screen.findByTestId("terminal-modal");
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
expect(modal).toHaveClass("terminal-modal--floating");
expect(screen.getByTestId("terminal-floating-resize-se")).toHaveAttribute("aria-label", "Resize terminal window");
expect(screen.getByTestId("floating-window-resize-se")).toHaveAttribute("aria-label", "Resize floating window");
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & {
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement & {
setPointerCapture: (pointerId: number) => void;
releasePointerCapture: (pointerId: number) => void;
};
@@ -1267,7 +1267,7 @@ describe("TerminalModal", () => {
fireEvent.pointerUp(resizeHandle, { pointerId: 41, pointerType: "touch" });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 1040, height: 630 }));
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").size).toEqual({ width: 1040, height: 630 });
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
});
@@ -1282,14 +1282,13 @@ describe("TerminalModal", () => {
fireEvent.pointerUp(header, { pointerId: 42, pointerType: "touch" });
await waitFor(() => {
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`) ?? "{}")).toEqual({ x: 44, y: 56 });
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position.x).toBe(44);
});
fireEvent.pointerDown(header, { pointerId: 43, pointerType: "touch", clientX: 200, clientY: 140 });
fireEvent.pointerMove(header, { pointerId: 43, pointerType: "touch", clientX: 100, clientY: 140 });
fireEvent.pointerCancel(header, { pointerId: 43, pointerType: "touch" });
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`) ?? "{}")).toEqual({ x: 16, y: 56 });
expect(header.releasePointerCapture).toHaveBeenCalledWith(43);
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position.x).toBe(16);
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth });
Object.defineProperty(window, "innerHeight", { configurable: true, value: previousInnerHeight });
@@ -1331,12 +1330,11 @@ describe("TerminalModal", () => {
expect(modal).toHaveClass("terminal-modal--tablet", "terminal-modal--floating");
// The 768px CSS fallback is full-screen only for true phones. A known
// tablet must win that cascade with its stored floating geometry.
const modalStyle = getComputedStyle(modal);
expect(modalStyle.width).toBe("var(--terminal-float-width)");
expect(modalStyle.height).toBe("var(--terminal-float-height)");
expect(modalStyle.maxWidth).toBe("calc(100vw - (var(--space-lg) * 2))");
const modalStyle = screen.getByTestId(`floating-window-terminal-${projectId}`).style;
expect(modalStyle.width).not.toBe("");
expect(modalStyle.height).not.toBe("");
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & {
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement & {
setPointerCapture: (pointerId: number) => void;
releasePointerCapture: (pointerId: number) => void;
};
@@ -1346,7 +1344,7 @@ describe("TerminalModal", () => {
fireEvent.pointerMove(resizeHandle, { pointerId: 51, pointerType: "touch", clientX: 120, clientY: 180 });
fireEvent.pointerUp(resizeHandle, { pointerId: 51, pointerType: "touch" });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 656, height: 540 }));
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").size).toEqual({ width: 656, height: 540 });
});
const header = modal.querySelector(".terminal-header") as HTMLElement & {
@@ -1359,7 +1357,7 @@ describe("TerminalModal", () => {
fireEvent.pointerMove(header, { pointerId: 52, pointerType: "touch", clientX: 180, clientY: 140 });
fireEvent.pointerUp(header, { pointerId: 52, pointerType: "touch" });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBe(JSON.stringify({ x: 96, y: 56 }));
expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position.x).toBe(96);
});
} finally {
styleEl.remove();
@@ -1371,13 +1369,10 @@ describe("TerminalModal", () => {
});
it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => {
const panelRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
const headerRule = terminalModalCss.match(/\.terminal-header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
expect(headerRule).toContain("touch-action: none;");
expect(headerRule).toContain("min-height: 48px;");
expect(terminalModalCss).not.toContain("var(--shadow-xl)");
const floatingWindowCss = readAppFile("components/FloatingWindow.css");
expect(floatingWindowCss).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
expect(floatingWindowCss).toContain(".floating-window__delegated-drag-handle");
expect(floatingWindowCss).not.toContain("var(--shadow-xl)");
});
it("keeps mobile terminal on the full-screen modal path without docked or floating controls", async () => {
@@ -1394,7 +1389,7 @@ describe("TerminalModal", () => {
expect(modal).not.toHaveClass("terminal-modal--floating");
expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull();
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-floating-resize-se")).toBeNull();
expect(screen.queryByTestId("floating-window-resize-se")).toBeNull();
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
if (previousOntouchstart === undefined) {

View File

@@ -0,0 +1,18 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { migratedModalFixtures } from "./migratedModalFixtures";
const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && /Mailbox|Milestone|Subtask/.test(fixture.name));
describe("utility modal FloatingWindow behavior", () => {
it.each(fixtures)("$name blocks page clicks while preserving outside-dismiss choice", (fixture) => {
const close = vi.fn();
render(<><button data-testid="page-control">page</button><FloatingWindow modal windowKey={fixture.key!} title={fixture.name} onClose={close} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 80 }} persistGeometryKey={fixture.key!} closeOnOutsidePointerDown={fixture.outside}><button>inside</button></FloatingWindow></>);
const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`);
expect(host).toHaveAttribute("aria-modal", "true");
expect(screen.getAllByLabelText("Resize floating window")).toHaveLength(8);
fireEvent.pointerDown(document.body, { pointerType: "mouse" });
if (fixture.outside) expect(close).toHaveBeenCalledOnce(); else expect(close).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
/*
FNXC:ModalTouchGeometry 2026-07-26-16:28:
FN-8607 keeps migration coverage data-driven so each long-lived dialog must declare its shared
geometry identity and dismissal decision rather than relying on FloatingWindow defaults.
*/
export const migratedModalFixtures = [
// FNXC:ModalTouchGeometry 2026-07-26-18:49: The inventory's brief-action opt-outs stay visible here so the ratchet cannot silently lose a required surface.
{ name: "AgentErrorDetailsModal", file: "AgentErrorDetailsModal.tsx", key: null, outside: true, optOut: "brief error acknowledgement" },
{ name: "ModelSelectionModal", file: "ModelSelectionModal.tsx", key: null, outside: true, optOut: "compact focused choice" },
{ name: "ReportModal", file: "ReportModal.tsx", key: null, outside: false, optOut: "brief reporting action" },
{ name: "ResearchTaskActionModal", file: "ResearchTaskActionModal.tsx", key: null, outside: true, optOut: "bounded task-action confirmation" },
{ name: "SettingsSyncConflictModal", file: "SettingsSyncConflictModal.tsx", key: null, outside: true, optOut: "urgent blocking conflict decision" },
{ name: "StashConflictModal", file: "StashConflictModal.tsx", key: null, outside: false, optOut: "urgent bounded git-conflict recovery" },
{ name: "AgentListModal", file: "AgentListModal.tsx", key: "floating-window:agent-list", outside: true },
{ name: "AgentImportModal", file: "AgentImportModal.tsx", key: "floating-window:agent-import", outside: true },
{ name: "AgentGenerationModal", file: "AgentGenerationModal.tsx", key: "floating-window:agent-generation", outside: true },
{ name: "AgentOnboardingModal", file: "AgentOnboardingModal.tsx", key: "floating-window:agent-onboarding", outside: false },
{ name: "ExperimentalAgentOnboardingModal", file: "ExperimentalAgentOnboardingModal.tsx", key: "floating-window:experimental-agent-onboarding", outside: false },
{ name: "SetupWizardModal", file: "SetupWizardModal.tsx", key: "floating-window:setup-wizard", outside: false },
{ name: "NativeShellOnboardingModal", file: "NativeShellOnboardingModal.tsx", key: "floating-window:native-shell-onboarding", outside: false },
{ name: "DockerNodeOnboardingModal", file: "DockerNodeOnboardingModal.tsx", key: "floating-window:docker-node-onboarding", outside: true },
{ name: "MailboxModal", file: "MailboxModal.tsx", key: "floating-window:mailbox", outside: true },
{ name: "MilestoneSliceInterviewModal", file: "MilestoneSliceInterviewModal.tsx", key: "floating-window:milestone-slice-interview", outside: true },
{ name: "SubtaskBreakdownModal", file: "SubtaskBreakdownModal.tsx", key: "floating-window:subtask-breakdown", outside: true },
] as const;

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { readAppFile } from "../../test/cssFixture";
import { migratedModalFixtures } from "./migratedModalFixtures";
/* FNXC:ModalTouchGeometry 2026-07-26-18:49: Keep the inventory decision in one executable table: a non-trivial static dialog needs a reason, while every hosted dialog must keep both sheet safeguards. */
describe("FN-8607 migrated modal FloatingWindow contract", () => {
it.each(migratedModalFixtures.filter((fixture) => !fixture.optOut))("hosts $name with persistent, blocking tablet geometry", (fixture) => {
const source = readAppFile(`components/${fixture.file}`);
expect(source).toContain("<FloatingWindow");
expect(source).toContain(`persistGeometryKey=\"${fixture.key}\"`);
expect(source).toContain("suspendGeometryPersistenceOnMobile");
expect(source).toContain("suspendGeometryPersistenceOnShortViewport");
expect(source).toContain("dragHandleSelector");
expect(source).toContain(" modal");
expect(source.includes("closeOnOutsidePointerDown")).toBe(fixture.outside);
});
it.each(migratedModalFixtures.filter((fixture) => fixture.optOut))("keeps $name as justified inventory opt-out", (fixture) => {
const source = readAppFile(`components/${fixture.file}`);
expect(source).not.toContain("<FloatingWindow");
expect(fixture.optOut).toMatch(/.+/);
});
it("uses modal isolation instead of the utility host click-through mode", () => {
const source = readAppFile("components/FloatingWindow.tsx");
expect(source).toContain('aria-modal={modal ? "true" : "false"}');
expect(source).toContain("floating-window-overlay--modal");
expect(readAppFile("components/FloatingWindow.css")).toContain(".floating-window-overlay--modal");
});
});