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:
7
.changeset/fn-8607-floating-modal-contract.md
Normal file
7
.changeset/fn-8607-floating-modal-contract.md
Normal 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.
|
||||
7
.changeset/fn-8620-bespoke-geometry-modals.md
Normal file
7
.changeset/fn-8620-bespoke-geometry-modals.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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. | — |
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}>
|
||||
×
|
||||
</button>
|
||||
<h3>{t("newTaskModal.title", "New Task")}</h3>
|
||||
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
|
||||
×
|
||||
</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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user