FN-8621: migrate complex modals to FloatingWindow
Unify complex dashboard modal presentation under persisted FloatingWindow geometry. - Migrate Create Room, Task Detail, Agent Detail, and GitHub Import modal presentations. - Preserve documented embedded and docked exceptions, dismissal behavior, and nested scrolling. - Add presentation-contract coverage and publish dashboard guidance and changesets. Files changed: ...n-8619-resize-persist-modals-floating-window.md | 7 ++ .changeset/fn-8621-create-room-floating-window.md | 7 ++ docs/dashboard-guide.md | 16 +++- docs/dashboard-modal-inventory.md | 12 +-- .../dashboard/app/components/AgentDetailView.css | 16 +--- .../dashboard/app/components/AgentDetailView.tsx | 102 ++++++++++++++++----- .../dashboard/app/components/CreateRoomModal.css | 19 +++- .../dashboard/app/components/CreateRoomModal.tsx | 57 ++++++++---- .../dashboard/app/components/FloatingWindow.css | 13 ++- .../dashboard/app/components/FloatingWindow.tsx | 15 +++ .../dashboard/app/components/GitHubImportModal.css | 11 +-- .../dashboard/app/components/GitHubImportModal.tsx | 40 ++++++-- .../dashboard/app/components/TaskDetailModal.css | 52 +---------- .../dashboard/app/components/TaskDetailModal.tsx | 58 ++++++------ .../__tests__/AgentDetailView.core.test.tsx | 2 +- .../AgentDetailView.mobile-scroll.test.tsx | 6 +- .../components/__tests__/CreateRoomModal.test.tsx | 62 +++++++++++-- .../components/__tests__/FloatingWindow.test.tsx | 1 + .../__tests__/GitHubImportModal.test.tsx | 8 +- ...etailModal.responsive-and-dependencies.test.tsx | 77 +++++++--------- .../__tests__/modal-presentation-contract.test.tsx | 74 +++++++++++++++ .../dashboard/app/hooks/useEmbeddedPresentation.ts | 2 +- .../dashboard/app/hooks/useModalResizePersist.ts | 5 + 23 files changed, 441 insertions(+), 221 deletions(-) Fusion-Task-Id: FN-8621 Fusion-Task-Lineage: 04b6f3fe-d527-4a21-a0cb-489eb20f5e91 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Agent Detail, Import Tasks, and Task Detail move and resize on tablets; geometry resets once.
|
||||||
|
category: feature
|
||||||
|
dev: Replaces size-only modal keys with floating-window identities, preserves Agent Detail mouse-pair dismissal, and retains useModalResizePersist for the Chromium fixture.
|
||||||
7
.changeset/fn-8621-create-room-floating-window.md
Normal file
7
.changeset/fn-8621-create-room-floating-window.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Make Create Room movable and resizable with saved window geometry.
|
||||||
|
category: feature
|
||||||
|
dev: Uses the floating-window:create-room geometry key and documents supported modal presentation exceptions.
|
||||||
@@ -102,7 +102,7 @@ Use `FloatingWindow` for a moveable and resizable dashboard surface rather than
|
|||||||
|
|
||||||
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-13:50: Core/workflow modal migrations use stable window keys so one shared primitive owns drag, resize, clamping, stacking, and persisted geometry. -->
|
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-13:50: Core/workflow modal migrations use stable window keys so one shared primitive owns drag, resize, clamping, stacking, and persisted geometry. -->
|
||||||
|
|
||||||
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.
|
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`, `group-task`, and `create-room`. Create Room uses `floating-window:create-room`; its member picker remains a nested scroll container within the shared window body. The former size-only `fusion:settings-modal-size`, `fusion:git-modal-size`, `fusion:planning-modal-size`, `fusion:changes-diff-modal-size`, and `fusion:model-onboarding-modal-size` keys are superseded by their matching complete geometry records. All of these windows suspend reading and writing geometry on phone and short (`max-height: 480px`) sheet viewports, so a desktop position never leaks into the sheet and a sheet never overwrites the desktop choice.
|
||||||
|
|
||||||
<!-- 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. -->
|
<!-- 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. -->
|
||||||
|
|
||||||
@@ -112,6 +112,20 @@ All non-trivial modals must use `FloatingWindow` with `hideHeader`, a modal-owne
|
|||||||
|
|
||||||
Static opt-outs are only brief single-decision alerts without reflowable content or long dwell time: `DuplicateWarningModal`, `AgentErrorDetailsModal`, `ModelSelectionModal`, `ReportModal`, `ResearchTaskActionModal`, `SettingsSyncConflictModal`, and `StashConflictModal`. Their focused acknowledgement or urgent-conflict semantics do not benefit from persistent movable geometry; additions require a documented inventory justification.
|
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.
|
||||||
|
|
||||||
|
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-19:25: FN-8621 closes the complex-modal batch by making FloatingWindow the canonical non-trivial modal host while publishing the narrow embedded and docked presentations that legitimately retain their owners. -->
|
||||||
|
### Supported presentation exceptions
|
||||||
|
|
||||||
|
Every non-trivial dashboard modal is hosted by `FloatingWindow`. It uses the physical-screen-aware `isTabletTouchViewport` contract: phones are **≤767.98px**, tablet-class touch is **≥768px plus touch**, and delegated drag plus all resize hit areas carry `data-resize-hit-target="true"` with an effective target of at least **44px**. This is hit-area-only and never a bare `(pointer: coarse)` rule. `closeOnOutsidePointerDown` defaults **off**; only a surface whose pre-migration backdrop dismissed the dialog may opt in explicitly, preserving its dismissal contract.
|
||||||
|
|
||||||
|
These are supported presentation exceptions, not silently unmigrated dialogs:
|
||||||
|
|
||||||
|
- `TerminalModal` stays docked in docked mode, including its dock-height control; only floating mode is a `FloatingWindow` with project-scoped geometry.
|
||||||
|
- `AgentDetailView` stays embedded for inline presentation, while its modal presentation uses `FloatingWindow`; the inline branch owns no floating geometry.
|
||||||
|
- `GitHubImportModal` stays embedded when `useEmbeddedPresentation` resolves embedded presentation. Its `resizePersistEnabled` modal-only gate keeps container-filling imports free of floating chrome; modal presentation uses `FloatingWindow`.
|
||||||
|
- `RightDockExpandModal` preserves dock-origin content behavior (`surface: "expand"`) while its expanded shell is a `FloatingWindow`.
|
||||||
|
|
||||||
|
A new embedded/docked exception is legitimate only when an owning container must retain its layout, lifecycle, and content origin; it must have an explicit presentation gate and an inventory justification. Brief static opt-outs remain limited to the focused, one-decision dialogs listed above and require the same documented justification.
|
||||||
|
|
||||||
## Mobile/PWA app icons
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
|
|||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `ActivityLogModal.tsx` | A | `ActivityLogModal.tsx:486` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded dock variant; header delegation. | — |
|
| `ActivityLogModal.tsx` | A | `ActivityLogModal.tsx:486` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded dock variant; header delegation. | — |
|
||||||
| `AddNodeModal.tsx` | A | `AddNodeModal.tsx:260` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
|
| `AddNodeModal.tsx` | A | `AddNodeModal.tsx:260` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
|
||||||
| `AgentDetailView.tsx` | B | `AgentDetailView.tsx:282` `useModalResizePersist`; `:912` `role="dialog"` | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Inline and modal presentations; backdrop-only mouse dismissal token. | — |
|
| `AgentDetailView.tsx` | B | `AgentDetailView.tsx` `<FloatingWindow>` | already migrated | FN-8619 | Modal uses `floating-window:agent-detail`; paired mouse-only backdrop dismissal remains. | Inline presentation is explicitly gated by `inline` because its owner retains the detail layout and lifecycle. |
|
||||||
| `AgentErrorDetailsModal.tsx` | D | `AgentErrorDetailsModal.tsx:65` `.modal-overlay`, `role="dialog"`; direct inspection: no geometry mechanism. | stays static | FN-8607 agent/onboarding/utility | Short blocking error-detail acknowledgement. | Error detail is a brief, fault-recovery acknowledgement; moving/resizing would add state to an urgent recovery path. |
|
| `AgentErrorDetailsModal.tsx` | D | `AgentErrorDetailsModal.tsx:65` `.modal-overlay`, `role="dialog"`; direct inspection: no geometry mechanism. | stays static | FN-8607 agent/onboarding/utility | Short blocking error-detail acknowledgement. | Error detail is a brief, fault-recovery acknowledgement; moving/resizing would add state to an urgent recovery path. |
|
||||||
| `AgentGenerationModal.tsx` | D | `AgentGenerationModal.tsx:169` `role="dialog"`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Generation progress can be long-running. | — |
|
| `AgentGenerationModal.tsx` | D | `AgentGenerationModal.tsx:169` `role="dialog"`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Generation progress can be long-running. | — |
|
||||||
| `AgentImportModal.tsx` | D | `AgentImportModal.tsx:473` `role="dialog" aria-modal`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Import mapping has nested scroll. | — |
|
| `AgentImportModal.tsx` | D | `AgentImportModal.tsx:473` `role="dialog" aria-modal`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Import mapping has nested scroll. | — |
|
||||||
@@ -60,12 +60,12 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
|
|||||||
| `ArtifactsGallery.tsx#DocViewer` | A | `ArtifactsGallery.tsx:706` `OverlayShell`; `:593` `DocViewerProps` | already migrated | n/a | Sandboxed HTML/markdown preview; headerless delegated handle. | — |
|
| `ArtifactsGallery.tsx#DocViewer` | A | `ArtifactsGallery.tsx:706` `OverlayShell`; `:593` `DocViewerProps` | already migrated | n/a | Sandboxed HTML/markdown preview; headerless delegated handle. | — |
|
||||||
| `ChangesDiffModal.tsx` | A | `ChangesDiffModal.tsx:123` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Diff scroll and delegated header. | — |
|
| `ChangesDiffModal.tsx` | A | `ChangesDiffModal.tsx:123` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Diff scroll and delegated header. | — |
|
||||||
| `ConnectNodeModal.tsx` | A | `ConnectNodeModal.tsx:171` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
|
| `ConnectNodeModal.tsx` | A | `ConnectNodeModal.tsx:171` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
|
||||||
| `CreateRoomModal.tsx` | D | `CreateRoomModal.tsx:3` `createPortal`; direct inspection: portal dialog has no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Member-picker nested scroll. | — |
|
| `CreateRoomModal.tsx` | D | `CreateRoomModal.tsx` `<FloatingWindow windowKey="create-room">` | already migrated | FN-8621 | `floating-window:create-room`; explicit outside-pointer dismissal; member-picker remains nested scroll owner. | — |
|
||||||
| `DockerNodeOnboardingModal.tsx` | D | `DockerNodeOnboardingModal.tsx:214` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Onboarding commands and scroll. | — |
|
| `DockerNodeOnboardingModal.tsx` | D | `DockerNodeOnboardingModal.tsx:214` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Onboarding commands and scroll. | — |
|
||||||
| `DuplicateWarningModal.tsx` | D | `DuplicateWarningModal.tsx:41` `.modal-overlay`; direct inspection: no geometry mechanism. | stays static | n/a | Small duplicate-decision confirmation. | This intentionally compact confirmation should stay centered and transient rather than acquire persisted window state. |
|
| `DuplicateWarningModal.tsx` | D | `DuplicateWarningModal.tsx:41` `.modal-overlay`; direct inspection: no geometry mechanism. | stays static | n/a | Small duplicate-decision confirmation. | This intentionally compact confirmation should stay centered and transient rather than acquire persisted window state. |
|
||||||
| `ExperimentalAgentOnboardingModal.tsx` | D | `ExperimentalAgentOnboardingModal.tsx:187` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Experimental onboarding step chrome. | — |
|
| `ExperimentalAgentOnboardingModal.tsx` | D | `ExperimentalAgentOnboardingModal.tsx:187` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Experimental onboarding step chrome. | — |
|
||||||
| `FileBrowserModal.tsx` | A | `FileBrowserModal.tsx:372` `<FloatingWindow` | already migrated | n/a | Nested Files-pane splitter is content layout, not window geometry. | — |
|
| `FileBrowserModal.tsx` | A | `FileBrowserModal.tsx:372` `<FloatingWindow` | already migrated | n/a | Nested Files-pane splitter is content layout, not window geometry. | — |
|
||||||
| `GitHubImportModal.tsx` | B | `GitHubImportModal.tsx:601` `useModalResizePersist`; `:2027` nested import-detail `<FloatingWindow` | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Root importer has resize-persist; nested detail is already floating. | — |
|
| `GitHubImportModal.tsx` | B | `GitHubImportModal.tsx` root `<FloatingWindow>`; nested import detail remains floating | already migrated | FN-8619 | Modal uses `floating-window:github-import`; nested import detail remains floating. | `useEmbeddedPresentation` / `resizePersistEnabled` explicitly keeps embedded import container-filling. |
|
||||||
| `GitManagerModal.tsx` | A | `GitManagerModal.tsx:1385` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded right-dock presentation. | — |
|
| `GitManagerModal.tsx` | A | `GitManagerModal.tsx:1385` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded right-dock presentation. | — |
|
||||||
| `GroupTaskModal.tsx` | A | `GroupTaskModal.tsx:107` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Branch-group detail and header delegation. | — |
|
| `GroupTaskModal.tsx` | A | `GroupTaskModal.tsx:107` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Branch-group detail and header delegation. | — |
|
||||||
| `MailboxModal.tsx` | D | `MailboxModal.tsx:731` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Thread/detail nested scroll. | — |
|
| `MailboxModal.tsx` | D | `MailboxModal.tsx:731` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Thread/detail nested scroll. | — |
|
||||||
@@ -80,7 +80,7 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
|
|||||||
| `PrCreateModal.tsx` | A | `PrCreateModal.tsx:549` `<FloatingWindow` | already migrated | n/a | Create-PR form; delegated header. | — |
|
| `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. |
|
| `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. |
|
| `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` | 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). | — |
|
| `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). | The expanded shell floats while content retains explicit dock-origin `surface: "expand"` behavior. |
|
||||||
| `ScheduledTasksModal.tsx` | A | `ScheduledTasksModal.tsx:560` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded automation presentation. | — |
|
| `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. | — |
|
| `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. | — |
|
| `SettingsModal.tsx` | A | `SettingsModal.tsx:4632` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Embedded destination and internal nav-width splitter. | — |
|
||||||
@@ -88,8 +88,8 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
|
|||||||
| `SetupWizardModal.tsx` | D | `SetupWizardModal.tsx:452` `.modal-overlay role="dialog"`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | First-run step chrome and blocking setup. | — |
|
| `SetupWizardModal.tsx` | D | `SetupWizardModal.tsx:452` `.modal-overlay role="dialog"`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | First-run step chrome and blocking setup. | — |
|
||||||
| `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. |
|
| `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. | — |
|
| `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. | — |
|
| `TaskDetailModal.tsx` | B | `TaskDetailModal.tsx` `<FloatingWindow layer="task-detail">` | already migrated | FN-8619 | Dense tabs and pop-out stacking retained; `task-detail-modal-size` is orphaned for `floating-window:task-detail` (one-time reset). | — |
|
||||||
| `TerminalModal.tsx` | A | Floating mode uses FloatingWindow (`terminal-<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). | — |
|
| `TerminalModal.tsx` | A | Floating mode uses FloatingWindow (`terminal-<project>`), headerless delegated drag host. | already migrated | FN-8620 | Floating legacy pair superseded by `fusion:terminal-float-geometry-<project>` (one-time reset). | Docked mode is explicitly gated and retains dock-height resize / `fusion:terminal-docked-height-<project>` because it owns dock layout. |
|
||||||
| `WorkflowAddStepModal.tsx` | A | `WorkflowAddStepModal.tsx:144` `<FloatingWindow` | already migrated | FN-8606 core/workflow (done) | Headerless delegated handle. | — |
|
| `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. | — |
|
| `WorkflowNodeEditor.tsx` | A | `WorkflowNodeEditor.tsx:5646` `<FloatingWindow`; `:19` `createPortal` | already migrated | n/a | Full-screen workflow editor, delegated header. | — |
|
||||||
|
|
||||||
|
|||||||
@@ -16,25 +16,15 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: calc(var(--space-xl) * 20);
|
height: 100%;
|
||||||
max-width: calc(100vw - var(--space-2xl) - var(--space-xs) * 2);
|
min-width: 0;
|
||||||
min-height: calc(var(--space-xl) * 13 + var(--space-sm));
|
min-height: 0;
|
||||||
max-height: calc(100dvh - var(--space-2xl) - var(--space-xs) * 2);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
resize: both;
|
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Initial size — applied only when no persisted size has been restored. */
|
|
||||||
.agent-detail-modal:not([style*="width"]) {
|
|
||||||
width: min(calc(var(--space-xl) * 37 + var(--space-md)), calc(100vw - var(--space-2xl) - var(--space-xs) * 2));
|
|
||||||
}
|
|
||||||
.agent-detail-modal:not([style*="height"]) {
|
|
||||||
height: min(85vh, calc(100dvh - var(--space-2xl) - var(--space-xs) * 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-detail-inline-shell {
|
.agent-detail-inline-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbea
|
|||||||
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { FloatingWindow } from "./FloatingWindow";
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
import { FileEditor } from "./FileEditor";
|
import { FileEditor } from "./FileEditor";
|
||||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||||
@@ -107,6 +107,8 @@ interface AgentDetailViewProps {
|
|||||||
initialRunId?: string | null;
|
initialRunId?: string | null;
|
||||||
preferActiveRun?: boolean;
|
preferActiveRun?: boolean;
|
||||||
onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void>;
|
onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void>;
|
||||||
|
/** Distinguishes the task-detail nested modal from the AgentsView window geometry. */
|
||||||
|
floatingWindowKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "dashboard" | "logs" | "mail" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections";
|
type TabId = "dashboard" | "logs" | "mail" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections";
|
||||||
@@ -255,7 +257,7 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string
|
|||||||
?? "";
|
?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess }: AgentDetailViewProps) {
|
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess, floatingWindowKey = "agent-detail" }: AgentDetailViewProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||||
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1);
|
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1);
|
||||||
@@ -276,10 +278,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
||||||
const [isLoadingMailbox, setIsLoadingMailbox] = useState(false);
|
const [isLoadingMailbox, setIsLoadingMailbox] = useState(false);
|
||||||
const [mailboxError, setMailboxError] = useState<string | null>(null);
|
const [mailboxError, setMailboxError] = useState<string | null>(null);
|
||||||
const agentDetailModalRef = useRef<HTMLDivElement>(null);
|
|
||||||
const bulkMenuRef = useRef<HTMLDivElement | null>(null);
|
const bulkMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const overlayMouseDownRef = useRef(false);
|
const overlayMouseDownRef = useRef(false);
|
||||||
useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size");
|
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
const addToastRef = useRef(addToast);
|
const addToastRef = useRef(addToast);
|
||||||
const agentRef = useRef<AgentDetail | null>(null);
|
const agentRef = useRef<AgentDetail | null>(null);
|
||||||
@@ -902,23 +902,45 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FloatingWindow
|
||||||
className="agent-detail-overlay"
|
windowKey={floatingWindowKey}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }}
|
title={t("agents.loading", "Loading agent...")}
|
||||||
onMouseUp={(e) => {
|
ariaLabel={t("agents.detailLoadingLabel", "Agent detail loading")}
|
||||||
if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose();
|
onClose={onClose}
|
||||||
overlayMouseDownRef.current = false;
|
modal
|
||||||
|
hideHeader
|
||||||
|
dragHandleSelector=".agent-detail-header"
|
||||||
|
className="floating-window--agent-detail"
|
||||||
|
defaultSize={{ width: 608, height: 640 }}
|
||||||
|
minSize={{ width: 400, height: 320 }}
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:05:
|
||||||
|
Legacy Agent Detail stored only size, while FloatingWindow requires size plus position.
|
||||||
|
Use a new key for a deliberate one-time geometry reset rather than restoring an ambiguous partial payload.
|
||||||
|
*/
|
||||||
|
persistGeometryKey={`floating-window:${floatingWindowKey}`}
|
||||||
|
suspendGeometryPersistenceOnMobile
|
||||||
|
suspendGeometryPersistenceOnShortViewport
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:05:
|
||||||
|
Agent Detail's historical dismiss guard is paired mouse-down/mouse-up on the backdrop.
|
||||||
|
Do not use closeOnOutsidePointerDown: it would dismiss earlier and include touch gestures.
|
||||||
|
*/
|
||||||
|
backdropMouseHandlers={{
|
||||||
|
onMouseDown: (e) => { if (e.target === e.currentTarget) overlayMouseDownRef.current = true; },
|
||||||
|
onMouseUp: (e) => {
|
||||||
|
if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose();
|
||||||
|
overlayMouseDownRef.current = false;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
>
|
||||||
<div className="agent-detail-modal" ref={agentDetailModalRef}>
|
<div className="agent-detail-modal">
|
||||||
<div className="agent-detail-loading">
|
<div className="agent-detail-loading">
|
||||||
<Loader2 className="animate-spin" size={24} />
|
<Loader2 className="animate-spin" size={24} />
|
||||||
<span>{t("agents.loading", "Loading agent...")}</span>
|
<span>{t("agents.loading", "Loading agent...")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FloatingWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,19 +950,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
|
|
||||||
const stateStyle = STATE_COLORS[agent.state];
|
const stateStyle = STATE_COLORS[agent.state];
|
||||||
const health = getHealthStatus();
|
const health = getHealthStatus();
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:05:
|
||||||
|
Inline Agent Detail is the supported embedded presentation exception. It fills its owner and
|
||||||
|
deliberately bypasses FloatingWindow chrome, persistence, and drag/resize affordances.
|
||||||
|
*/
|
||||||
const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal";
|
const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal";
|
||||||
const isPauseAllDisabled = isBulkEligibilityLoading || bulkPauseEligibleCount === 0;
|
const isPauseAllDisabled = isBulkEligibilityLoading || bulkPauseEligibleCount === 0;
|
||||||
const isResumeAllDisabled = isBulkEligibilityLoading || bulkResumeEligibleCount === 0;
|
const isResumeAllDisabled = isBulkEligibilityLoading || bulkResumeEligibleCount === 0;
|
||||||
|
|
||||||
return (
|
const detailContent = (
|
||||||
<div
|
<div className={detailShellClassName}>
|
||||||
className={inline ? "agent-detail-inline-shell" : "agent-detail-overlay"}
|
|
||||||
onClick={(e) => !inline && e.target === e.currentTarget && onClose()}
|
|
||||||
role={inline ? "region" : "dialog"}
|
|
||||||
aria-label={inline ? "Agent detail" : undefined}
|
|
||||||
aria-modal={inline ? undefined : "true"}
|
|
||||||
>
|
|
||||||
<div className={detailShellClassName} ref={agentDetailModalRef}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="agent-detail-header">
|
<div className="agent-detail-header">
|
||||||
{/* Identity area: icon + name + badges */}
|
{/* Identity area: icon + name + badges */}
|
||||||
@@ -960,7 +980,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
<AgentAvatar agent={agent} size={36} />
|
<AgentAvatar agent={agent} size={36} />
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-detail-info">
|
<div className="agent-detail-info">
|
||||||
<h2>{agent.name}</h2>
|
<h2 id="agent-detail-modal-title">{agent.name}</h2>
|
||||||
<div className="agent-detail-badges">
|
<div className="agent-detail-badges">
|
||||||
<span
|
<span
|
||||||
className="badge"
|
className="badge"
|
||||||
@@ -1299,7 +1319,39 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
|
||||||
|
if (inline) {
|
||||||
|
return <div className="agent-detail-inline-shell" role="region" aria-label="Agent detail">{detailContent}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingWindow
|
||||||
|
windowKey={floatingWindowKey}
|
||||||
|
title={agent.name}
|
||||||
|
ariaLabelledBy="agent-detail-modal-title"
|
||||||
|
onClose={onClose}
|
||||||
|
modal
|
||||||
|
hideHeader
|
||||||
|
dragHandleSelector=".agent-detail-header"
|
||||||
|
className="floating-window--agent-detail"
|
||||||
|
defaultSize={{ width: 608, height: 640 }}
|
||||||
|
minSize={{ width: 400, height: 320 }}
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: The legacy size-only key is deliberately replaced by FloatingWindow geometry, causing one intentional reset per user. */
|
||||||
|
persistGeometryKey={`floating-window:${floatingWindowKey}`}
|
||||||
|
suspendGeometryPersistenceOnMobile
|
||||||
|
suspendGeometryPersistenceOnShortViewport
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Preserve Agent Detail's unconditional paired mouse-only dismissal instead of broader pointer-down/touch dismissal. */
|
||||||
|
backdropMouseHandlers={{
|
||||||
|
onMouseDown: (e) => { if (e.target === e.currentTarget) overlayMouseDownRef.current = true; },
|
||||||
|
onMouseUp: (e) => {
|
||||||
|
if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose();
|
||||||
|
overlayMouseDownRef.current = false;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detailContent}
|
||||||
|
</FloatingWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
.create-room-modal {
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:25:
|
||||||
|
Create Room now lives inside FloatingWindow's body. Fill that host without creating a competing
|
||||||
|
scroll surface; the member list below remains the only picker scroll owner.
|
||||||
|
*/
|
||||||
|
.floating-window--create-room .floating-window__body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-window--create-room .create-room-modal {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: inherit;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
@@ -84,7 +99,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.create-room-modal {
|
.floating-window--create-room .create-room-modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { fetchAgents } from "../api";
|
import { fetchAgents } from "../api";
|
||||||
import type { Agent } from "@fusion/core";
|
import type { Agent } from "@fusion/core";
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
import { LoadingSpinner } from "./LoadingSpinner";
|
import { LoadingSpinner } from "./LoadingSpinner";
|
||||||
import { nextFloatingZ } from "./floatingWindowStack";
|
import { FloatingWindow } from "./FloatingWindow";
|
||||||
import "./CreateRoomModal.css";
|
import "./CreateRoomModal.css";
|
||||||
|
|
||||||
export interface RoomDraft {
|
export interface RoomDraft {
|
||||||
@@ -52,15 +51,10 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
|||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
/*
|
/*
|
||||||
FNXC:ChatRoomModal 2026-07-17-15:56:
|
FNXC:ModalTouchGeometry 2026-07-26-19:25:
|
||||||
Create Room is a blocking dialog launched from Quick Chat's non-blocking FloatingWindow. Because
|
Create Room is a blocking child of Quick Chat. The shared utility layer now claims its fresh
|
||||||
both surfaces portal to body, claim a fresh shared top-layer z-index on every open so the dialog
|
portal z-index on every mount, keeping this dialog above Chat without a bespoke overlay counter.
|
||||||
stays above its parent on desktop and the mobile full-screen Chat sheet, including after reopen.
|
|
||||||
*/
|
*/
|
||||||
const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (isOpen) setOverlayZ(nextFloatingZ());
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -89,6 +83,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
|||||||
return () => window.cancelAnimationFrame(frame);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:25:
|
||||||
|
FloatingWindow owns the modal focus boundary but not Escape dismissal. Retain this dialog's
|
||||||
|
existing Escape and explicit prior-focus restoration behavior while moving its presentation.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -150,9 +149,31 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
/*
|
||||||
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} style={overlayZ ? { zIndex: overlayZ } : undefined}>
|
FNXC:ModalTouchGeometry 2026-07-26-19:25:
|
||||||
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label={t("createRoom.title", "Create room")} onClick={(event) => event.stopPropagation()}>
|
This former portal dialog uses the shared FloatingWindow for clamped, persisted desktop and
|
||||||
|
tablet-touch geometry. It explicitly opts into outside pointer-down because its former backdrop
|
||||||
|
closed the dialog; FloatingWindow defaults that behavior off for durable utility windows.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<FloatingWindow
|
||||||
|
windowKey="create-room"
|
||||||
|
title={t("createRoom.title", "Create room")}
|
||||||
|
ariaLabel={t("createRoom.title", "Create room")}
|
||||||
|
onClose={onClose}
|
||||||
|
modal
|
||||||
|
hideHeader
|
||||||
|
dragHandleSelector=".modal-header"
|
||||||
|
className="floating-window--create-room"
|
||||||
|
defaultSize={{ width: 640, height: 640 }}
|
||||||
|
minSize={{ width: 360, height: 400 }}
|
||||||
|
persistGeometryKey="floating-window:create-room"
|
||||||
|
suspendGeometryPersistenceOnMobile
|
||||||
|
suspendGeometryPersistenceOnShortViewport
|
||||||
|
closeOnOutsidePointerDown
|
||||||
|
layer="utility"
|
||||||
|
>
|
||||||
|
<div className="modal create-room-modal">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>{t("createRoom.title", "Create room")}</h3>
|
<h3>{t("createRoom.title", "Create room")}</h3>
|
||||||
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
|
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
|
||||||
@@ -205,6 +226,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:25:
|
||||||
|
The picker, not FloatingWindow's body, remains the nested scroll owner so long member
|
||||||
|
lists preserve their independent scroll behavior inside the movable dialog.
|
||||||
|
*/}
|
||||||
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
|
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
|
||||||
{loadingAgents ? (
|
{loadingAgents ? (
|
||||||
<div className="create-room-modal-empty"><LoadingSpinner label={t("createRoom.loadingAgents", "Loading agents...")} /></div>
|
<div className="create-room-modal-empty"><LoadingSpinner label={t("createRoom.loadingAgents", "Loading agents...")} /></div>
|
||||||
@@ -241,7 +267,6 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</FloatingWindow>
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,7 +274,9 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
|
|||||||
.floating-window--model-onboarding .floating-window__body,
|
.floating-window--model-onboarding .floating-window__body,
|
||||||
.floating-window--git-manager .floating-window__body,
|
.floating-window--git-manager .floating-window__body,
|
||||||
.floating-window--settings .floating-window__body,
|
.floating-window--settings .floating-window__body,
|
||||||
.floating-window--planning-mode .floating-window__body {
|
.floating-window--planning-mode .floating-window__body,
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:25: Create Room shares migrated dialogs' host-owned body sizing while its nested member list retains picker scrolling. */
|
||||||
|
.floating-window--create-room .floating-window__body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +291,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
|
|||||||
.floating-window--model-onboarding .model-onboarding-modal,
|
.floating-window--model-onboarding .model-onboarding-modal,
|
||||||
.floating-window--git-manager .gm-modal,
|
.floating-window--git-manager .gm-modal,
|
||||||
.floating-window--settings .settings-modal,
|
.floating-window--settings .settings-modal,
|
||||||
.floating-window--planning-mode .planning-modal {
|
.floating-window--planning-mode .planning-modal,
|
||||||
|
.floating-window--create-room .create-room-modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -326,6 +329,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
|
|||||||
.floating-window--setup-wizard,
|
.floating-window--setup-wizard,
|
||||||
.floating-window--native-shell-onboarding,
|
.floating-window--native-shell-onboarding,
|
||||||
.floating-window--docker-node-onboarding,
|
.floating-window--docker-node-onboarding,
|
||||||
|
.floating-window--agent-detail,
|
||||||
|
.floating-window--github-import,
|
||||||
.floating-window--mailbox,
|
.floating-window--mailbox,
|
||||||
.floating-window--milestone-slice-interview,
|
.floating-window--milestone-slice-interview,
|
||||||
.floating-window--subtask-breakdown,
|
.floating-window--subtask-breakdown,
|
||||||
@@ -342,6 +347,7 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
|
|||||||
.floating-window--git-manager,
|
.floating-window--git-manager,
|
||||||
.floating-window--settings,
|
.floating-window--settings,
|
||||||
.floating-window--planning-mode,
|
.floating-window--planning-mode,
|
||||||
|
.floating-window--create-room,
|
||||||
.floating-window--chat {
|
.floating-window--chat {
|
||||||
inset: 0 !important;
|
inset: 0 !important;
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
@@ -366,6 +372,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
|
|||||||
.floating-window--setup-wizard .floating-window__resize-handle,
|
.floating-window--setup-wizard .floating-window__resize-handle,
|
||||||
.floating-window--native-shell-onboarding .floating-window__resize-handle,
|
.floating-window--native-shell-onboarding .floating-window__resize-handle,
|
||||||
.floating-window--docker-node-onboarding .floating-window__resize-handle,
|
.floating-window--docker-node-onboarding .floating-window__resize-handle,
|
||||||
|
.floating-window--agent-detail .floating-window__resize-handle,
|
||||||
|
.floating-window--github-import .floating-window__resize-handle,
|
||||||
.floating-window--mailbox .floating-window__resize-handle,
|
.floating-window--mailbox .floating-window__resize-handle,
|
||||||
.floating-window--milestone-slice-interview .floating-window__resize-handle,
|
.floating-window--milestone-slice-interview .floating-window__resize-handle,
|
||||||
.floating-window--subtask-breakdown .floating-window__resize-handle,
|
.floating-window--subtask-breakdown .floating-window__resize-handle,
|
||||||
@@ -382,6 +390,7 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
|
|||||||
.floating-window--git-manager .floating-window__resize-handle,
|
.floating-window--git-manager .floating-window__resize-handle,
|
||||||
.floating-window--settings .floating-window__resize-handle,
|
.floating-window--settings .floating-window__resize-handle,
|
||||||
.floating-window--planning-mode .floating-window__resize-handle,
|
.floating-window--planning-mode .floating-window__resize-handle,
|
||||||
|
.floating-window--create-room .floating-window__resize-handle,
|
||||||
.floating-window--chat .floating-window__resize-handle {
|
.floating-window--chat .floating-window__resize-handle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -58,6 +59,12 @@ export interface FloatingWindowProps {
|
|||||||
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
|
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
|
||||||
*/
|
*/
|
||||||
closeOnOutsidePointerDown?: boolean;
|
closeOnOutsidePointerDown?: boolean;
|
||||||
|
/** Mouse-only handlers for hosts whose historical backdrop dismissal cannot use pointer-down semantics. */
|
||||||
|
backdropMouseHandlers?: {
|
||||||
|
onMouseDown?: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
onMouseUp?: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
onClick?: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
};
|
||||||
/** Render as a blocking dialog instead of the default coexisting utility window. */
|
/** Render as a blocking dialog instead of the default coexisting utility window. */
|
||||||
modal?: boolean;
|
modal?: boolean;
|
||||||
/** Optional legacy hook for callers whose overlay is asserted by existing tests. */
|
/** Optional legacy hook for callers whose overlay is asserted by existing tests. */
|
||||||
@@ -109,9 +116,14 @@ const FLOATING_WINDOW_OUTSIDE_POINTER_SAFE_SURFACE_SELECTOR = [
|
|||||||
".node-picker-dropdown--portal",
|
".node-picker-dropdown--portal",
|
||||||
".agent-picker-dropdown--portal",
|
".agent-picker-dropdown--portal",
|
||||||
".priority-picker-dropdown--portal",
|
".priority-picker-dropdown--portal",
|
||||||
|
".activity-view-menu",
|
||||||
].join(", ");
|
].join(", ");
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-08-13-12:00:
|
||||||
|
FN-8619: Task Detail's body-portaled activity-view menu is a logical child of its modal.
|
||||||
|
Treating it as safe prevents a preference-enabled outside pointer-down from closing the host.
|
||||||
|
|
||||||
FNXC:FloatingWindow 2026-07-13-08:01:
|
FNXC:FloatingWindow 2026-07-13-08:01:
|
||||||
FN-7943: Quick Chat's outside-pointer dismissal must treat body-portaled dropdowns as logical children of the FloatingWindow. Keep this selector in sync with the sibling FN-7916 ChatThinkingLevelControl and FN-2860 QuickEntryBox portal guards so model, thinking-level, agent, dependency, node, and priority selections do not dismiss the host chat window while bare-page clicks still close it.
|
FN-7943: Quick Chat's outside-pointer dismissal must treat body-portaled dropdowns as logical children of the FloatingWindow. Keep this selector in sync with the sibling FN-7916 ChatThinkingLevelControl and FN-2860 QuickEntryBox portal guards so model, thinking-level, agent, dependency, node, and priority selections do not dismiss the host chat window while bare-page clicks still close it.
|
||||||
*/
|
*/
|
||||||
@@ -203,6 +215,7 @@ export function FloatingWindow({
|
|||||||
suspendGeometryPersistenceOnMobile = false,
|
suspendGeometryPersistenceOnMobile = false,
|
||||||
suspendGeometryPersistenceOnShortViewport = false,
|
suspendGeometryPersistenceOnShortViewport = false,
|
||||||
closeOnOutsidePointerDown = false,
|
closeOnOutsidePointerDown = false,
|
||||||
|
backdropMouseHandlers,
|
||||||
modal = false,
|
modal = false,
|
||||||
testId,
|
testId,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
@@ -618,6 +631,8 @@ export function FloatingWindow({
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
data-testid={testId ?? `floating-window-overlay-${windowKey}`}
|
data-testid={testId ?? `floating-window-overlay-${windowKey}`}
|
||||||
|
{...backdropMouseHandlers}
|
||||||
|
// FNXC:ModalTouchGeometry 2026-08-13-12:00: FN-8619 keeps Agent Detail's paired mouse-only backdrop contract at the shared modal backdrop; this deliberately does not alter pointer-down dismissal.
|
||||||
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
|
// 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 }}
|
style={{ zIndex }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -566,14 +566,11 @@ idle and auto-translate-off views retain their original layout.
|
|||||||
|
|
||||||
/* Wider modal for two-pane layout */
|
/* Wider modal for two-pane layout */
|
||||||
.modal.github-import-modal {
|
.modal.github-import-modal {
|
||||||
width: min(90vw, 1200px);
|
width: 100%;
|
||||||
max-width: 95vw;
|
height: 100%;
|
||||||
min-width: 480px;
|
min-width: 0;
|
||||||
height: 80vh;
|
min-height: 0;
|
||||||
min-height: 480px;
|
|
||||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
resize: both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.github-import-modal__header {
|
.github-import-modal__header {
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ import {
|
|||||||
useGitHubImportAutoTranslate,
|
useGitHubImportAutoTranslate,
|
||||||
} from "./GitHubImportTranslateControls";
|
} from "./GitHubImportTranslateControls";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||||
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
|
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
|
||||||
@@ -417,7 +416,7 @@ export function buildCheckFixTaskPrompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, onOpenChatWithPrefill, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
|
export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, onOpenChatWithPrefill, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
|
||||||
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
|
const { isEmbedded, scrollLockEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
|
||||||
useMobileScrollLock(isOpen && scrollLockEnabled);
|
useMobileScrollLock(isOpen && scrollLockEnabled);
|
||||||
const { t, i18n } = useTranslation("app");
|
const { t, i18n } = useTranslation("app");
|
||||||
/*
|
/*
|
||||||
@@ -597,9 +596,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
|
|||||||
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const remoteLoadRequestIdRef = useRef(0);
|
const remoteLoadRequestIdRef = useRef(0);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const dismissOnOutsidePointerDown = useModalDismissPreference();
|
||||||
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size");
|
|
||||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
|
||||||
|
|
||||||
// Track which owner/repo we've already auto-loaded to prevent duplicate loads
|
// Track which owner/repo we've already auto-loaded to prevent duplicate loads
|
||||||
const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null);
|
const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null);
|
||||||
@@ -1578,7 +1575,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
|
|||||||
Modal mode is kept byte-identical: same overlay wrapper, header with subtitle + close button, and overlay-dismiss props.
|
Modal mode is kept byte-identical: same overlay wrapper, header with subtitle + close button, and overlay-dismiss props.
|
||||||
*/
|
*/
|
||||||
const inner = (
|
const inner = (
|
||||||
<div className={`modal modal-lg github-import-modal${isEmbedded ? " github-import-modal--embedded" : ""}`} ref={modalRef}>
|
<div className={`modal modal-lg github-import-modal${isEmbedded ? " github-import-modal--embedded" : ""}`}>
|
||||||
{isEmbedded ? (
|
{isEmbedded ? (
|
||||||
/*
|
/*
|
||||||
FNXC:RightDockEmbedding 2026-06-22-00:40:
|
FNXC:RightDockEmbedding 2026-06-22-00:40:
|
||||||
@@ -1593,7 +1590,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
|
|||||||
) : (
|
) : (
|
||||||
<div className="modal-header github-import-modal__header">
|
<div className="modal-header github-import-modal__header">
|
||||||
<div>
|
<div>
|
||||||
<h3>{t("git.importFromGitHub", "Import from GitHub")}</h3>
|
<h3 id="github-import-modal-title">{t("git.importFromGitHub", "Import from GitHub")}</h3>
|
||||||
<p className="github-import-modal__subtitle">
|
<p className="github-import-modal__subtitle">
|
||||||
{t("git.importSubtitle", "Choose a detected remote, load open issues or pull requests, and import one into the board.")}
|
{t("git.importSubtitle", "Choose a detected remote, load open issues or pull requests, and import one into the board.")}
|
||||||
</p>
|
</p>
|
||||||
@@ -2023,6 +2020,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Import detail is already an independent FloatingWindow and remains unwrapped so it stacks above the migrated root importer. */}
|
||||||
{(selectedIssue || selectedPull) && (
|
{(selectedIssue || selectedPull) && (
|
||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
windowKey="github-import-detail"
|
windowKey="github-import-detail"
|
||||||
@@ -2481,13 +2479,35 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:05:
|
||||||
|
Embedded Import Tasks remains a container-filling presentation exception. resizePersistEnabled
|
||||||
|
continues to gate modal-only geometry behavior rather than introducing FloatingWindow chrome here.
|
||||||
|
*/
|
||||||
if (isEmbedded) {
|
if (isEmbedded) {
|
||||||
return <div className="github-import-embedded right-dock-embedded-view">{inner}</div>;
|
return <div className="github-import-embedded right-dock-embedded-view">{inner}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
<FloatingWindow
|
||||||
|
windowKey="github-import"
|
||||||
|
title={t("git.importFromGitHub", "Import from GitHub")}
|
||||||
|
ariaLabelledBy="github-import-modal-title"
|
||||||
|
onClose={onClose}
|
||||||
|
modal
|
||||||
|
hideHeader
|
||||||
|
dragHandleSelector=".github-import-modal__header"
|
||||||
|
className="floating-window--github-import"
|
||||||
|
defaultSize={{ width: 1200, height: 720 }}
|
||||||
|
minSize={{ width: 480, height: 480 }}
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: The legacy size-only key cannot restore FloatingWindow position, so a new complete geometry key intentionally resets once. */
|
||||||
|
persistGeometryKey="floating-window:github-import"
|
||||||
|
suspendGeometryPersistenceOnMobile
|
||||||
|
suspendGeometryPersistenceOnShortViewport
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Preserve the global default-off dismissal preference; unconditional pointer-down would lose the data-safety contract. */
|
||||||
|
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
|
||||||
|
>
|
||||||
{inner}
|
{inner}
|
||||||
</div>
|
</FloatingWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal.task-detail-modal {
|
.modal.task-detail-modal {
|
||||||
width: min(95vw, 800px);
|
width: 100%;
|
||||||
max-width: 95vw;
|
height: 100%;
|
||||||
min-width: 480px;
|
min-width: 0;
|
||||||
height: 85vh;
|
min-height: 0;
|
||||||
min-height: 480px;
|
|
||||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
resize: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
FNXC:TaskModalResize 2026-07-26-11:06:
|
|
||||||
The tablet-only grip expands outside Task Detail's painted corner. Its default
|
|
||||||
overflow clipping would make the 44px finger target visible in layout but
|
|
||||||
unreachable by browser hit testing, so only the explicit touch-resize surface
|
|
||||||
permits the target outside the panel; true-phone sheets retain clipped content.
|
|
||||||
*/
|
|
||||||
.modal.task-detail-modal.task-modal--touch-resize {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1797,36 +1783,6 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
FNXC:TaskModalResize 2026-08-07-00:00:
|
|
||||||
Phone CSS also matches a known 768px touch tablet. The shared runtime class
|
|
||||||
records the physical-screen-aware tablet result, restoring bounded modal
|
|
||||||
geometry and the existing resize grip without weakening true-phone sheets.
|
|
||||||
*/
|
|
||||||
.modal-overlay:has(.task-detail-modal.task-modal--tablet) {
|
|
||||||
--overlay-padding-top: 6vh;
|
|
||||||
padding-top: var(--overlay-padding-top);
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.task-detail-modal.task-modal--tablet {
|
|
||||||
width: 98vw;
|
|
||||||
min-width: 480px;
|
|
||||||
max-width: 98vw;
|
|
||||||
height: 92vh;
|
|
||||||
min-height: 480px;
|
|
||||||
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md));
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
resize: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-modal--tablet .modal-resize-grip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.detail-body--chat {
|
.detail-body--chat {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react";
|
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react";
|
||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
|
import { FloatingWindow } from "./FloatingWindow";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useModalDismissPreference, useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import { useColumnLabel } from "../i18n/labels";
|
import { useColumnLabel } from "../i18n/labels";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
@@ -4074,7 +4074,7 @@ export function TaskDetailContent({
|
|||||||
>
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div className="detail-title-row">
|
<div className="detail-title-row">
|
||||||
<span className="detail-id">{task.id}</span>
|
<span className="detail-id" id="task-detail-modal-title">{task.id}</span>
|
||||||
<span className={`detail-column-badge badge-${task.column}`}>
|
<span className={`detail-column-badge badge-${task.column}`}>
|
||||||
{columnLabel(task.column)}
|
{columnLabel(task.column)}
|
||||||
</span>
|
</span>
|
||||||
@@ -6577,6 +6577,7 @@ export function TaskDetailContent({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClose={() => setSelectedSourceAgentId(null)}
|
onClose={() => setSelectedSourceAgentId(null)}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
floatingWindowKey="agent-detail-task"
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -6585,12 +6586,9 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
|
||||||
const viewportMode = useViewportMode();
|
const viewportMode = useViewportMode();
|
||||||
const isTabletTouchResize = isTabletTouchViewport(viewportMode);
|
|
||||||
useModalResizePersist(modalRef, true, "task-detail-modal-size", { touchTargets: isTabletTouchResize });
|
|
||||||
useMobileScrollLock(true);
|
useMobileScrollLock(true);
|
||||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
const dismissOnOutsidePointerDown = useModalDismissPreference();
|
||||||
/*
|
/*
|
||||||
FNXC:TaskDetailSwipeBack 2026-08-07-00:00:
|
FNXC:TaskDetailSwipeBack 2026-08-07-00:00:
|
||||||
Gate predictive-back animation through useViewportMode, the same physical-screen-aware
|
Gate predictive-back animation through useViewportMode, the same physical-screen-aware
|
||||||
@@ -6599,30 +6597,30 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
|||||||
*/
|
*/
|
||||||
const isMobileTransition = viewportMode === "mobile";
|
const isMobileTransition = viewportMode === "mobile";
|
||||||
|
|
||||||
/*
|
|
||||||
FNXC:TaskModalResize 2026-08-07-00:00:
|
|
||||||
Known touch tablets at the 768px CSS boundary resolve to `tablet` through
|
|
||||||
useViewportMode. Carry that single classification into the modal class so CSS
|
|
||||||
can override phone-sheet rules without a second breakpoint or gesture system.
|
|
||||||
*/
|
|
||||||
const isTabletTaskModal = viewportMode === "tablet";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FloatingWindow
|
||||||
className="modal-overlay open"
|
windowKey="task-detail"
|
||||||
{...overlayDismissProps}
|
title="Task detail"
|
||||||
role="dialog"
|
ariaLabelledBy="task-detail-modal-title"
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
|
modal
|
||||||
|
hideHeader
|
||||||
|
dragHandleSelector=".task-detail-content > .modal-header"
|
||||||
|
className="floating-window--task-detail"
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Task Detail shares its layer with Quick Chat and pop-outs so interaction order remains coordinated by floatingWindowStack. */
|
||||||
|
layer="task-detail"
|
||||||
|
defaultSize={{ width: 800, height: 680 }}
|
||||||
|
minSize={{ width: 480, height: 480 }}
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Replace legacy size-only persistence with complete geometry and suspend it for phone and short sheet layouts. */
|
||||||
|
persistGeometryKey="floating-window:task-detail"
|
||||||
|
suspendGeometryPersistenceOnMobile
|
||||||
|
suspendGeometryPersistenceOnShortViewport
|
||||||
|
/* FNXC:ModalTouchGeometry 2026-07-26-19:05: Keep outside dismissal preference-gated; unconditional pointer-down would regress the default-off contract. */
|
||||||
|
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`modal modal-lg task-detail-modal${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}>
|
||||||
className={`modal modal-lg task-detail-modal${isTabletTaskModal ? " task-modal--tablet" : ""}${isTabletTouchResize ? " task-modal--touch-resize" : ""}${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
|
<TaskDetailContent {...props} onRequestClose={onClose} />
|
||||||
ref={modalRef}
|
|
||||||
>
|
|
||||||
<TaskDetailContent
|
|
||||||
{...props}
|
|
||||||
onRequestClose={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FloatingWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ it("keeps modal mode as dialog with close button", async () => {
|
|||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.querySelector(".agent-detail-overlay")).toBeInTheDocument();
|
expect(document.querySelector("[data-testid='floating-window-overlay-agent-detail']")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -232,13 +232,13 @@ describe("AgentDetailView mobile scroll regression (FN-4231)", () => {
|
|||||||
it("shows mobile task column context without empty task shells (FN-7139)", async () => {
|
it("shows mobile task column context without empty task shells (FN-7139)", async () => {
|
||||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" }));
|
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" }));
|
||||||
|
|
||||||
const { container } = render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
const { baseElement } = render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-MOBILE · In Progress").length).toBeGreaterThanOrEqual(2);
|
expect(screen.getAllByText((_, el) => el?.textContent === "FN-MOBILE · In Progress").length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
expect(container.querySelector(".agent-detail-content")).toBeTruthy();
|
expect(baseElement.querySelector(".agent-detail-content")).toBeTruthy();
|
||||||
expect(container.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress");
|
expect(baseElement.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tabs accept horizontal touch panning and stay non-shrinking on mobile (FN-6450, FN-6865)", async () => {
|
it("tabs accept horizontal touch panning and stay non-shrinking on mobile (FN-6450, FN-6865)", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { CreateRoomModal, validateRoomName } from "../CreateRoomModal";
|
import { CreateRoomModal, validateRoomName } from "../CreateRoomModal";
|
||||||
import { FloatingWindow } from "../FloatingWindow";
|
import { FloatingWindow } from "../FloatingWindow";
|
||||||
|
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry, expectFloatingWindowStructure } from "./floatingWindowMigration.test-helpers";
|
||||||
import * as apiModule from "../../api";
|
import * as apiModule from "../../api";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
@@ -38,6 +39,7 @@ describe("validateRoomName", () => {
|
|||||||
describe("CreateRoomModal", () => {
|
describe("CreateRoomModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
mockFetchAgents.mockResolvedValue([
|
mockFetchAgents.mockResolvedValue([
|
||||||
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
|
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
|
||||||
{ id: "agent-2", name: "Beta", role: "reviewer", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
|
{ id: "agent-2", name: "Beta", role: "reviewer", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
|
||||||
@@ -84,7 +86,7 @@ describe("CreateRoomModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chatPanel = screen.getByTestId("floating-window-chat-modal");
|
const chatPanel = screen.getByTestId("floating-window-chat-modal");
|
||||||
const firstOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
|
const firstOverlay = screen.getByTestId("floating-window-overlay-create-room");
|
||||||
expect(Number(firstOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
|
expect(Number(firstOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
|
||||||
await screen.findByRole("button", { name: /Alpha/i });
|
await screen.findByRole("button", { name: /Alpha/i });
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ describe("CreateRoomModal", () => {
|
|||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const reopenedOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
|
const reopenedOverlay = screen.getByTestId("floating-window-overlay-create-room");
|
||||||
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
|
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
|
||||||
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(firstOverlay.style.zIndex));
|
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(firstOverlay.style.zIndex));
|
||||||
});
|
});
|
||||||
@@ -124,7 +126,7 @@ describe("CreateRoomModal", () => {
|
|||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadingOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
|
const loadingOverlay = screen.getByTestId("floating-window-overlay-create-room");
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("Loading agents...");
|
expect(screen.getByRole("status")).toHaveTextContent("Loading agents...");
|
||||||
expect(Number(loadingOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-loading").style.zIndex));
|
expect(Number(loadingOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-loading").style.zIndex));
|
||||||
loading.unmount();
|
loading.unmount();
|
||||||
@@ -139,7 +141,7 @@ describe("CreateRoomModal", () => {
|
|||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
|
const emptyOverlay = screen.getByTestId("floating-window-overlay-create-room");
|
||||||
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
|
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
|
||||||
expect(Number(emptyOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-empty").style.zIndex));
|
expect(Number(emptyOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-empty").style.zIndex));
|
||||||
});
|
});
|
||||||
@@ -151,10 +153,58 @@ describe("CreateRoomModal", () => {
|
|||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
fireEvent.click(document.querySelector(".modal-overlay.open") as Element);
|
fireEvent.pointerDown(screen.getByTestId("floating-window-overlay-create-room"));
|
||||||
expect(onClose).toHaveBeenCalledTimes(2);
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hosts the dialog in FloatingWindow with persisted touch geometry and sheet recovery", () => {
|
||||||
|
const { baseElement } = render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
|
||||||
|
const panel = expectFloatingWindowStructure("create-room");
|
||||||
|
const dialog = within(baseElement).getByRole("dialog", { name: "Create room" });
|
||||||
|
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||||
|
assertRenderedModalTouchGeometry("create-room", panel.querySelector(".modal-header") as HTMLElement);
|
||||||
|
assertModalGeometryRecoveryAndSheetContracts("create-room", () => render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the room name, restores prior focus, and keeps the member list as the scroll owner", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { rerender, baseElement } = render(
|
||||||
|
<>
|
||||||
|
<button type="button">Room launcher</button>
|
||||||
|
<CreateRoomModal isOpen={false} onClose={onClose} onCreate={vi.fn()} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const launcher = screen.getByRole("button", { name: "Room launcher" });
|
||||||
|
launcher.focus();
|
||||||
|
rerender(<><button type="button">Room launcher</button><CreateRoomModal isOpen onClose={onClose} onCreate={vi.fn()} /></>);
|
||||||
|
const nameInput = await within(baseElement).findByLabelText("Room name");
|
||||||
|
await waitFor(() => expect(nameInput).toHaveFocus());
|
||||||
|
const memberList = within(baseElement).getByTestId("create-room-member-list");
|
||||||
|
expect(memberList).toHaveClass("create-room-modal-member-list");
|
||||||
|
expect(getComputedStyle(memberList).overflowY).toBe("auto");
|
||||||
|
rerender(<><button type="button">Room launcher</button><CreateRoomModal isOpen={false} onClose={onClose} onCreate={vi.fn()} /></>);
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Room launcher" })).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading, empty, no-match, populated, and selected-member picker states", async () => {
|
||||||
|
mockFetchAgents.mockImplementationOnce(() => new Promise(() => {}));
|
||||||
|
const loading = render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent("Loading agents...");
|
||||||
|
loading.unmount();
|
||||||
|
|
||||||
|
mockFetchAgents.mockResolvedValueOnce([]);
|
||||||
|
const empty = render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
|
||||||
|
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
|
||||||
|
await userEvent.type(await screen.findByLabelText("Members"), "zzz");
|
||||||
|
expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
|
||||||
|
await userEvent.clear(screen.getByLabelText("Members"));
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
|
||||||
|
expect(screen.getByTestId("create-room-selected-chips")).toHaveTextContent("Alpha");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows search-specific empty state copy", async () => {
|
it("shows search-specific empty state copy", async () => {
|
||||||
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
|
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const QUICK_CHAT_PORTALED_MENU_CLASSES = [
|
|||||||
"node-picker-dropdown--portal",
|
"node-picker-dropdown--portal",
|
||||||
"agent-picker-dropdown--portal",
|
"agent-picker-dropdown--portal",
|
||||||
"priority-picker-dropdown--portal",
|
"priority-picker-dropdown--portal",
|
||||||
|
"activity-view-menu",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function cssRuleFor(css: string, selector: string): string {
|
function cssRuleFor(css: string, selector: string): string {
|
||||||
|
|||||||
@@ -981,15 +981,15 @@ describe("GitHubImportModal", () => {
|
|||||||
|
|
||||||
it("keeps the modal overlay and Escape-to-close in modal mode", async () => {
|
it("keeps the modal overlay and Escape-to-close in modal mode", async () => {
|
||||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||||
const { container } = render(
|
const { baseElement } = render(
|
||||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />,
|
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(container.querySelector(".modal-overlay")).not.toBeNull();
|
expect(baseElement.querySelector("[data-testid='floating-window-overlay-github-import']")).not.toBeNull();
|
||||||
expect(container.querySelector(".github-import-modal--embedded")).toBeNull();
|
expect(baseElement.querySelector(".github-import-modal--embedded")).toBeNull();
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -2900,7 +2900,7 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(importSheetRule).toBe(chatSheetRule);
|
expect(importSheetRule).toBe(chatSheetRule);
|
||||||
expect(importSheetRule).toBe(taskSheetRule);
|
expect(importSheetRule).toBe(taskSheetRule);
|
||||||
expect(importSheetRule).toContain("inset: 0 !important;");
|
expect(importSheetRule).toContain("inset: 0 !important;");
|
||||||
expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.floating-window--github-import-detail \.floating-window__resize-handle\s*\{\s*display: none;/);
|
expect(source).toMatch(/@media \(max-width: 767\.98px\), \(max-height: 480px\)[\s\S]*\.floating-window--github-import-detail \.floating-window__resize-handle\s*\{\s*display: none;/);
|
||||||
expect(source).toContain(".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail)");
|
expect(source).toContain(".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ FNXC:TaskDetailTabs 2026-06-17-08:20:
|
|||||||
FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state.
|
FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import {
|
import {
|
||||||
@@ -586,18 +588,14 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(css).not.toMatch(/task-detail-workflow-badge-mobile/);
|
expect(css).not.toMatch(/task-detail-workflow-badge-mobile/);
|
||||||
expect(css).not.toMatch(/\.detail-title-row\s+\.detail-workflow-badge\s*\{/);
|
expect(css).not.toMatch(/\.detail-title-row\s+\.detail-workflow-badge\s*\{/);
|
||||||
});
|
});
|
||||||
it("keeps desktop and mobile modal sizing guards unchanged", () => {
|
it("uses FloatingWindow's full-screen sheet guards on phone and short viewports", () => {
|
||||||
const css = readDashboardStylesSource();
|
const css = readFileSync(resolve(__dirname, "../FloatingWindow.css"), "utf8");
|
||||||
const mobileBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".modal-overlay:has(.task-detail-modal)");
|
const sheetBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 767.98px), (max-height: 480px)", ".floating-window--task-detail");
|
||||||
const mobileOverlayBlock = getCssRuleBlock(mobileBlock, ".modal-overlay:has(.task-detail-modal)");
|
const sheetRule = getCssRuleBlock(sheetBlock, ".floating-window--task-detail");
|
||||||
const mobileModalBlock = getCssRuleBlock(mobileBlock, ".modal.task-detail-modal");
|
|
||||||
|
|
||||||
expectBaseRule(css, ".modal.task-detail-modal", "width: min(95vw, 800px);");
|
expect(sheetRule).toContain("inset: 0 !important;");
|
||||||
expectBaseRule(css, ".modal.task-detail-modal", "height: 85vh;");
|
expect(sheetRule).toContain("width: 100vw !important;");
|
||||||
expect(mobileOverlayBlock).toContain("padding-top: 0;");
|
expect(sheetRule).toContain("height: 100dvh !important;");
|
||||||
expect(mobileOverlayBlock).toContain("align-items: stretch;");
|
|
||||||
expect(mobileModalBlock).toContain("width: 100vw;");
|
|
||||||
expect(mobileModalBlock).toContain("height: 100dvh;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reconciles tablet overlay offset with task-detail max-height and widens the modal", () => {
|
it("reconciles tablet overlay offset with task-detail max-height and widens the modal", () => {
|
||||||
@@ -861,7 +859,7 @@ describe("TaskDetailModal", () => {
|
|||||||
})));
|
})));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={makeTask({ column: "in-progress" as Column })}
|
task={makeTask({ column: "in-progress" as Column })}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
@@ -872,25 +870,12 @@ describe("TaskDetailModal", () => {
|
|||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const modal = container.querySelector(".task-detail-modal");
|
const window = container.querySelector("[data-testid='floating-window-task-detail']");
|
||||||
expect(modal).toHaveClass("task-modal--tablet");
|
expect(window).toBeTruthy();
|
||||||
const grip = modal?.querySelector(".modal-resize-grip") as HTMLElement;
|
const handles = window!.querySelectorAll(".floating-window__resize-handle");
|
||||||
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
|
expect(handles).toHaveLength(8);
|
||||||
expect(grip).toHaveAttribute("tabindex", "0");
|
handles.forEach((handle) => expect(handle).toHaveAttribute("data-resize-hit-target", "true"));
|
||||||
expect(grip).toHaveAttribute("data-resize-hit-target", "true");
|
expect(container.querySelector(".modal-resize-grip")).toBeNull();
|
||||||
|
|
||||||
// FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must
|
|
||||||
// remain keyboard discoverable, not merely restore a touch-only grip.
|
|
||||||
modal!.style.width = "500px";
|
|
||||||
modal!.style.height = "400px";
|
|
||||||
grip.focus();
|
|
||||||
fireEvent.keyDown(grip, { key: "ArrowRight" });
|
|
||||||
fireEvent.keyDown(grip, { key: "ArrowDown" });
|
|
||||||
|
|
||||||
expect(modal!.style.width).toBe("516px");
|
|
||||||
expect(modal!.style.height).toBe("416px");
|
|
||||||
expect(grip).toHaveAttribute("aria-valuenow", "516");
|
|
||||||
expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels");
|
|
||||||
} finally {
|
} finally {
|
||||||
if (originalScreen) Object.defineProperty(window, "screen", originalScreen);
|
if (originalScreen) Object.defineProperty(window, "screen", originalScreen);
|
||||||
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
|
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
|
||||||
@@ -899,7 +884,7 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
|
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={makeTask({ column: "in-progress" as Column })}
|
task={makeTask({ column: "in-progress" as Column })}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
@@ -911,7 +896,7 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(container.querySelector(".modal.modal-lg")).toBeTruthy();
|
expect(container.querySelector(".modal.modal-lg")).toBeTruthy();
|
||||||
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
|
expect(container.querySelector("[data-testid='floating-window-overlay-task-detail']")).toBeTruthy();
|
||||||
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
|
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
|
||||||
expect(container.querySelector(".detail-body")).toBeTruthy();
|
expect(container.querySelector(".detail-body")).toBeTruthy();
|
||||||
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
|
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
|
||||||
@@ -999,7 +984,7 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps dense in-review and standard task controls in their shared footer", () => {
|
it("keeps dense in-review and standard task controls in their shared footer", () => {
|
||||||
const { container, unmount } = render(
|
const { baseElement: container, unmount } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ column: "in-review" as Column })}
|
task={makeTask({ column: "in-review" as Column })}
|
||||||
@@ -1032,7 +1017,7 @@ describe("TaskDetailModal", () => {
|
|||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const standardFooter = standard.container.querySelector(".modal-actions");
|
const standardFooter = standard.baseElement.querySelector(".modal-actions");
|
||||||
|
|
||||||
expect(standardFooter).toBeTruthy();
|
expect(standardFooter).toBeTruthy();
|
||||||
expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
|
expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
|
||||||
@@ -1051,7 +1036,7 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the triage footer usable when Actions is absent", () => {
|
it("keeps the triage footer usable when Actions is absent", () => {
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ column: "triage" as Column })}
|
task={makeTask({ column: "triage" as Column })}
|
||||||
@@ -1822,7 +1807,7 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(summary.container.querySelector(".merge-details-card a")).toHaveAttribute("href", "https://github.com/owner/repo/pull/42");
|
expect(summary.baseElement.querySelector(".merge-details-card a")).toHaveAttribute("href", "https://github.com/owner/repo/pull/42");
|
||||||
summary.unmount();
|
summary.unmount();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -1992,7 +1977,7 @@ describe("TaskDetailModal", () => {
|
|||||||
{ id: "FN-002", title: "Add tests", description: "Test coverage", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
{ id: "FN-002", title: "Add tests", description: "Test coverage", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
|
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
|
||||||
@@ -2027,7 +2012,7 @@ describe("TaskDetailModal", () => {
|
|||||||
{ id: "FN-001", description: "Login is broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
{ id: "FN-001", description: "Login is broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001"] })}
|
task={makeTask({ dependencies: ["FN-001"] })}
|
||||||
@@ -2047,7 +2032,7 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders dependency ID as label when no title or description available", () => {
|
it("renders dependency ID as label when no title or description available", () => {
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001"] })}
|
task={makeTask({ dependencies: ["FN-001"] })}
|
||||||
@@ -2074,7 +2059,7 @@ describe("TaskDetailModal", () => {
|
|||||||
{ id: "FN-001", title: longTitle, description: "Short desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
{ id: "FN-001", title: longTitle, description: "Short desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001"] })}
|
task={makeTask({ dependencies: ["FN-001"] })}
|
||||||
@@ -2101,7 +2086,7 @@ describe("TaskDetailModal", () => {
|
|||||||
{ id: "FN-001", title: "Very long title that gets truncated in the UI but should show full text on hover", description: "Desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
{ id: "FN-001", title: "Very long title that gets truncated in the UI but should show full text on hover", description: "Desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001"] })}
|
task={makeTask({ dependencies: ["FN-001"] })}
|
||||||
@@ -2131,7 +2116,7 @@ describe("TaskDetailModal", () => {
|
|||||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001"] })}
|
task={makeTask({ dependencies: ["FN-001"] })}
|
||||||
@@ -2160,7 +2145,7 @@ describe("TaskDetailModal", () => {
|
|||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ dependencies: ["FN-001"] })}
|
task={makeTask({ dependencies: ["FN-001"] })}
|
||||||
@@ -2222,7 +2207,7 @@ describe("TaskDetailModal", () => {
|
|||||||
makeTask({ id: "FN-101", title: "Stale blockedBy dependent", column: "todo" as Column, blockedBy: "FN-099" }),
|
makeTask({ id: "FN-101", title: "Stale blockedBy dependent", column: "todo" as Column, blockedBy: "FN-099" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { container } = render(
|
const { baseElement: container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={tasks[0]}
|
task={tasks[0]}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const component = (name: string) => readFileSync(resolve(__dirname, "..", `${name}.tsx`), "utf8");
|
||||||
|
const projectDoc = (name: string) => readFileSync(resolve(__dirname, "..", "..", "..", "..", "..", "docs", name), "utf8");
|
||||||
|
const inventory = () => projectDoc("dashboard-modal-inventory.md");
|
||||||
|
const guide = () => projectDoc("dashboard-guide.md");
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:25:
|
||||||
|
FN-8621 publishes one source-level contract for complex presentations. Keep this intentionally
|
||||||
|
structural: it protects the shared host and explicit presentation gates without mounting slow,
|
||||||
|
duplicated modal fixtures for every surface.
|
||||||
|
*/
|
||||||
|
describe("complex modal presentation contract", () => {
|
||||||
|
it("keeps each floating complex modal on FloatingWindow with persisted geometry", () => {
|
||||||
|
const floatingSurfaces = [
|
||||||
|
["CreateRoomModal", "floating-window:create-room"],
|
||||||
|
["AgentDetailView", "floating-window:${floatingWindowKey}"],
|
||||||
|
["GitHubImportModal", "floating-window:github-import"],
|
||||||
|
["TerminalModal", "fusion:terminal-float-geometry-"],
|
||||||
|
["RightDockExpandModal", "fusion:right-dock-expand-modal-geometry"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [surface, geometryKey] of floatingSurfaces) {
|
||||||
|
const source = component(surface);
|
||||||
|
expect(source, surface).toContain("<FloatingWindow");
|
||||||
|
expect(source, surface).toContain("persistGeometryKey");
|
||||||
|
expect(source, surface).toContain(geometryKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires explicit outside-dismiss preservation rather than relying on the default", () => {
|
||||||
|
expect(component("CreateRoomModal")).toContain("closeOnOutsidePointerDown");
|
||||||
|
expect(component("GitHubImportModal")).toContain("closeOnOutsidePointerDown={dismissOnOutsidePointerDown}");
|
||||||
|
expect(component("AgentDetailView")).toContain("backdropMouseHandlers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains documented embedded, docked, and dock-origin presentation gates", () => {
|
||||||
|
const agentDetail = component("AgentDetailView");
|
||||||
|
const githubImport = component("GitHubImportModal");
|
||||||
|
const terminal = component("TerminalModal");
|
||||||
|
const rightDock = component("RightDockExpandModal");
|
||||||
|
|
||||||
|
expect(agentDetail).toContain("if (inline)");
|
||||||
|
expect(githubImport).toContain("useEmbeddedPresentation(presentation)");
|
||||||
|
expect(githubImport).toContain("if (isEmbedded)");
|
||||||
|
expect(githubImport).toContain("resizePersistEnabled");
|
||||||
|
expect(terminal).toContain("const terminalPanel = isFloatingMode ? (");
|
||||||
|
expect(terminal).toContain("isDockedMode");
|
||||||
|
expect(rightDock).toContain("surface: \"expand\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the canonical guide and inventory reconciled with complex-modal migration state", () => {
|
||||||
|
const currentInventory = inventory();
|
||||||
|
const currentGuide = guide();
|
||||||
|
|
||||||
|
for (const surface of ["CreateRoomModal.tsx", "AgentDetailView.tsx", "GitHubImportModal.tsx", "TerminalModal.tsx", "RightDockExpandModal.tsx"]) {
|
||||||
|
const row = currentInventory.split("\n").find((line) => line.startsWith(`| \`${surface}\``));
|
||||||
|
expect(row, surface).toBeDefined();
|
||||||
|
expect(row, surface).toContain("already migrated");
|
||||||
|
expect(row, surface).not.toContain("migrate →");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(currentGuide).toContain("Supported presentation exceptions");
|
||||||
|
expect(currentGuide).toContain("floating-window:create-room");
|
||||||
|
expect(currentGuide).toContain("`closeOnOutsidePointerDown` defaults **off**");
|
||||||
|
expect(currentGuide).toContain("TerminalModal");
|
||||||
|
expect(currentGuide).toContain("AgentDetailView");
|
||||||
|
expect(currentGuide).toContain("GitHubImportModal");
|
||||||
|
expect(currentGuide).toContain("RightDockExpandModal");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ export type ModalPresentation = "modal" | "embedded";
|
|||||||
*
|
*
|
||||||
* - `isEmbedded` / `isModal` — the raw mode test.
|
* - `isEmbedded` / `isModal` — the raw mode test.
|
||||||
* - `scrollLockEnabled` — gate for `useMobileScrollLock`; off when embedded (the host page owns scrolling).
|
* - `scrollLockEnabled` — gate for `useMobileScrollLock`; off when embedded (the host page owns scrolling).
|
||||||
* - `resizePersistEnabled` — gate for `useModalResizePersist`; off when embedded (the view fills its container).
|
* - `resizePersistEnabled` — gate for modal-only FloatingWindow geometry; off when embedded (the view fills its container).
|
||||||
* - `escapeEnabled` — gate for Escape-to-close handlers; off when embedded (the dock/router owns lifecycle).
|
* - `escapeEnabled` — gate for Escape-to-close handlers; off when embedded (the dock/router owns lifecycle).
|
||||||
* - `overlayDismissEnabled` — gate for backdrop click-to-dismiss; off when embedded (no overlay backdrop exists).
|
* - `overlayDismissEnabled` — gate for backdrop click-to-dismiss; off when embedded (no overlay backdrop exists).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ function readPersistableSize(node: HTMLElement): PersistedSize {
|
|||||||
* @param storageKey localStorage key, must be stable + unique per modal
|
* @param storageKey localStorage key, must be stable + unique per modal
|
||||||
* @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry
|
* @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
FNXC:ModalTouchGeometry 2026-07-26-19:30:
|
||||||
|
FN-8619 migrated every product modal consumer to FloatingWindow. Keep this hook, its grip CSS,
|
||||||
|
and tests because the Chromium touch-geometry e2e fixture still exercises the legacy resize seam.
|
||||||
|
*/
|
||||||
export function useModalResizePersist(
|
export function useModalResizePersist(
|
||||||
ref: RefObject<HTMLElement | null>,
|
ref: RefObject<HTMLElement | null>,
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
|
|||||||
Reference in New Issue
Block a user