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:
gsxdsm
2026-07-26 19:52:32 -07:00
parent 7715a83e6f
commit 43160a7aae
23 changed files with 440 additions and 220 deletions

View File

@@ -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.

View 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.

View File

@@ -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. -->
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. -->
@@ -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.
<!-- 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
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.

View File

@@ -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. | — |
| `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. |
| `AgentGenerationModal.tsx` | D | `AgentGenerationModal.tsx:169` `role="dialog"`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Generation progress can be long-running. | — |
| `AgentImportModal.tsx` | D | `AgentImportModal.tsx:473` `role="dialog" aria-modal`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Import mapping has nested scroll. | — |
@@ -60,12 +60,12 @@ modal intended to dismiss on outside pointer-down must opt in explicitly.
| `ArtifactsGallery.tsx#DocViewer` | A | `ArtifactsGallery.tsx:706` `OverlayShell`; `:593` `DocViewerProps` | already migrated | n/a | Sandboxed HTML/markdown preview; headerless delegated handle. | — |
| `ChangesDiffModal.tsx` | A | `ChangesDiffModal.tsx:123` `<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. | — |
| `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. | — |
| `DuplicateWarningModal.tsx` | D | `DuplicateWarningModal.tsx:41` `.modal-overlay`; direct inspection: no geometry mechanism. | stays static | n/a | Small duplicate-decision confirmation. | This intentionally compact confirmation should stay centered and transient rather than acquire persisted window state. |
| `ExperimentalAgentOnboardingModal.tsx` | D | `ExperimentalAgentOnboardingModal.tsx:187` `.modal-overlay`; direct inspection: no geometry mechanism. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Experimental onboarding step chrome. | — |
| `FileBrowserModal.tsx` | A | `FileBrowserModal.tsx:372` `<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. | — |
| `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. | — |
@@ -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. | — |
| `ReportModal.tsx` | D | `ReportModal.tsx:79` `role="dialog" aria-modal`; direct inspection: no geometry mechanism. | stays static | n/a | Short reporting-action dialog. | Reporting is a brief confirmation/input flow; a fixed centered dialog preserves its blocking, one-shot interaction. |
| `ResearchTaskActionModal.tsx` | D | `ResearchTaskActionModal.tsx:57` `.modal-overlay`; direct inspection: no geometry mechanism. | stays static | n/a | Small action confirmation. | The modal is a bounded task-action confirmation, so persistent drag/resize state would be needless interaction cost. |
| `RightDockExpandModal.tsx` | 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. | — |
| `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. | — |
@@ -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. | — |
| `StashConflictModal.tsx` | D | `StashConflictModal.tsx:236` `.modal-overlay role="dialog"`; direct inspection: no geometry mechanism. | stays static | n/a | Blocking git-conflict recovery. | The conflict resolver is an urgent bounded recovery decision; centered static presentation keeps the destructive choices visible. |
| `SubtaskBreakdownModal.tsx` | D | `SubtaskBreakdownModal.tsx:607` `.modal-overlay role="dialog"`; direct inspection: list `draggable` controls reorder subtasks only, not window geometry. | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Nested subtask drag/reorder; panel itself is static. | — |
| `TaskDetailModal.tsx` | B | `TaskDetailModal.tsx:6591` `useModalResizePersist`; `:3` `createPortal` | migrate → FloatingWindow | FN-8607 agent/onboarding/utility | Dense tabs, pop-out compatibility, tablet resize grip. | — |
| `TerminalModal.tsx` | 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). | — |
| `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 | 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. | — |
| `WorkflowNodeEditor.tsx` | A | `WorkflowNodeEditor.tsx:5646` `<FloatingWindow`; `:19` `createPortal` | already migrated | n/a | Full-screen workflow editor, delegated header. | — |

View File

@@ -16,25 +16,15 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 100%;
min-width: calc(var(--space-xl) * 20);
max-width: calc(100vw - var(--space-2xl) - var(--space-xs) * 2);
min-height: calc(var(--space-xl) * 13 + var(--space-sm));
max-height: calc(100dvh - var(--space-2xl) - var(--space-xs) * 2);
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
resize: both;
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 {
width: 100%;
height: 100%;

View File

@@ -30,7 +30,7 @@ import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbea
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { useConfirm } from "../hooks/useConfirm";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { FloatingWindow } from "./FloatingWindow";
import { AgentAvatar } from "./AgentAvatar";
import { FileEditor } from "./FileEditor";
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
@@ -107,6 +107,8 @@ interface AgentDetailViewProps {
initialRunId?: string | null;
preferActiveRun?: boolean;
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";
@@ -255,7 +257,7 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string
?? "";
}
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess }: AgentDetailViewProps) {
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess, floatingWindowKey = "agent-detail" }: AgentDetailViewProps) {
const { t } = useTranslation("app");
const [agent, setAgent] = useState<AgentDetail | null>(null);
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 [isLoadingMailbox, setIsLoadingMailbox] = useState(false);
const [mailboxError, setMailboxError] = useState<string | null>(null);
const agentDetailModalRef = useRef<HTMLDivElement>(null);
const bulkMenuRef = useRef<HTMLDivElement | null>(null);
const overlayMouseDownRef = useRef(false);
useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size");
const onCloseRef = useRef(onClose);
const addToastRef = useRef(addToast);
const agentRef = useRef<AgentDetail | null>(null);
@@ -902,23 +902,45 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}
return (
<div
className="agent-detail-overlay"
onMouseDown={(e) => { if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }}
onMouseUp={(e) => {
if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose();
overlayMouseDownRef.current = false;
<FloatingWindow
windowKey={floatingWindowKey}
title={t("agents.loading", "Loading agent...")}
ariaLabel={t("agents.detailLoadingLabel", "Agent detail loading")}
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:
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">
<Loader2 className="animate-spin" size={24} />
<span>{t("agents.loading", "Loading agent...")}</span>
</div>
</div>
</div>
</FloatingWindow>
);
}
@@ -928,19 +950,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const stateStyle = STATE_COLORS[agent.state];
const health = getHealthStatus();
/*
FNXC:ModalTouchGeometry 2026-07-26-19:05:
Inline Agent Detail is the supported embedded presentation exception. It fills its owner and
deliberately bypasses FloatingWindow chrome, persistence, and drag/resize affordances.
*/
const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal";
const isPauseAllDisabled = isBulkEligibilityLoading || bulkPauseEligibleCount === 0;
const isResumeAllDisabled = isBulkEligibilityLoading || bulkResumeEligibleCount === 0;
return (
<div
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}>
const detailContent = (
<div className={detailShellClassName}>
{/* Header */}
<div className="agent-detail-header">
{/* Identity area: icon + name + badges */}
@@ -960,7 +980,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<AgentAvatar agent={agent} size={36} />
</div>
<div className="agent-detail-info">
<h2>{agent.name}</h2>
<h2 id="agent-detail-modal-title">{agent.name}</h2>
<div className="agent-detail-badges">
<span
className="badge"
@@ -1299,7 +1319,39 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
</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>
);
}

View File

@@ -1,4 +1,19 @@
.create-room-modal {
/*
FNXC:ModalTouchGeometry 2026-07-26-19:25:
Create Room now lives inside FloatingWindow's body. Fill that host without creating a competing
scroll surface; the member list below remains the only picker scroll owner.
*/
.floating-window--create-room .floating-window__body {
overflow: hidden;
}
.floating-window--create-room .create-room-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border: none;
border-radius: inherit;
display: flex;
flex-direction: column;
gap: var(--space-md);
@@ -84,7 +99,7 @@
}
@media (max-width: 768px) {
.create-room-modal {
.floating-window--create-room .create-room-modal {
width: 100%;
}

View File

@@ -1,11 +1,10 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
import { AgentAvatar } from "./AgentAvatar";
import { LoadingSpinner } from "./LoadingSpinner";
import { nextFloatingZ } from "./floatingWindowStack";
import { FloatingWindow } from "./FloatingWindow";
import "./CreateRoomModal.css";
export interface RoomDraft {
@@ -52,15 +51,10 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
const nameInputRef = useRef<HTMLInputElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
/*
FNXC:ChatRoomModal 2026-07-17-15:56:
Create Room is a blocking dialog launched from Quick Chat's non-blocking FloatingWindow. Because
both surfaces portal to body, claim a fresh shared top-layer z-index on every open so the dialog
stays above its parent on desktop and the mobile full-screen Chat sheet, including after reopen.
FNXC:ModalTouchGeometry 2026-07-26-19:25:
Create Room is a blocking child of Quick Chat. The shared utility layer now claims its fresh
portal z-index on every mount, keeping this dialog above Chat without a bespoke overlay counter.
*/
const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
if (isOpen) setOverlayZ(nextFloatingZ());
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
@@ -89,6 +83,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
/*
FNXC:ModalTouchGeometry 2026-07-26-19:25:
FloatingWindow owns the modal focus boundary but not Escape dismissal. Retain this dialog's
existing Escape and explicit prior-focus restoration behavior while moving its presentation.
*/
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
@@ -150,9 +149,31 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
}
};
return createPortal(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} style={overlayZ ? { zIndex: overlayZ } : undefined}>
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label={t("createRoom.title", "Create room")} onClick={(event) => event.stopPropagation()}>
/*
FNXC:ModalTouchGeometry 2026-07-26-19:25:
This former portal dialog uses the shared FloatingWindow for clamped, persisted desktop and
tablet-touch geometry. It explicitly opts into outside pointer-down because its former backdrop
closed the dialog; FloatingWindow defaults that behavior off for durable utility windows.
*/
return (
<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">
<h3>{t("createRoom.title", "Create room")}</h3>
<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>
)}
{/*
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">
{loadingAgents ? (
<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>
</div>
</div>
</div>,
document.body,
</FloatingWindow>
);
}

View File

@@ -274,7 +274,9 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.floating-window--model-onboarding .floating-window__body,
.floating-window--git-manager .floating-window__body,
.floating-window--settings .floating-window__body,
.floating-window--planning-mode .floating-window__body {
.floating-window--planning-mode .floating-window__body,
/* FNXC:ModalTouchGeometry 2026-07-26-19:25: Create Room shares migrated dialogs' host-owned body sizing while its nested member list retains picker scrolling. */
.floating-window--create-room .floating-window__body {
overflow: hidden;
}
@@ -289,7 +291,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.floating-window--model-onboarding .model-onboarding-modal,
.floating-window--git-manager .gm-modal,
.floating-window--settings .settings-modal,
.floating-window--planning-mode .planning-modal {
.floating-window--planning-mode .planning-modal,
.floating-window--create-room .create-room-modal {
width: 100%;
height: 100%;
max-width: none;
@@ -326,6 +329,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.floating-window--setup-wizard,
.floating-window--native-shell-onboarding,
.floating-window--docker-node-onboarding,
.floating-window--agent-detail,
.floating-window--github-import,
.floating-window--mailbox,
.floating-window--milestone-slice-interview,
.floating-window--subtask-breakdown,
@@ -342,6 +347,7 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.floating-window--git-manager,
.floating-window--settings,
.floating-window--planning-mode,
.floating-window--create-room,
.floating-window--chat {
inset: 0 !important;
width: 100vw !important;
@@ -366,6 +372,8 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.floating-window--setup-wizard .floating-window__resize-handle,
.floating-window--native-shell-onboarding .floating-window__resize-handle,
.floating-window--docker-node-onboarding .floating-window__resize-handle,
.floating-window--agent-detail .floating-window__resize-handle,
.floating-window--github-import .floating-window__resize-handle,
.floating-window--mailbox .floating-window__resize-handle,
.floating-window--milestone-slice-interview .floating-window__resize-handle,
.floating-window--subtask-breakdown .floating-window__resize-handle,
@@ -382,6 +390,7 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.floating-window--git-manager .floating-window__resize-handle,
.floating-window--settings .floating-window__resize-handle,
.floating-window--planning-mode .floating-window__resize-handle,
.floating-window--create-room .floating-window__resize-handle,
.floating-window--chat .floating-window__resize-handle {
display: none;
}

View File

@@ -5,6 +5,7 @@ import {
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
@@ -58,6 +59,12 @@ export interface FloatingWindowProps {
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
*/
closeOnOutsidePointerDown?: boolean;
/** Mouse-only handlers for hosts whose historical backdrop dismissal cannot use pointer-down semantics. */
backdropMouseHandlers?: {
onMouseDown?: (event: ReactMouseEvent<HTMLDivElement>) => void;
onMouseUp?: (event: ReactMouseEvent<HTMLDivElement>) => void;
onClick?: (event: ReactMouseEvent<HTMLDivElement>) => void;
};
/** Render as a blocking dialog instead of the default coexisting utility window. */
modal?: boolean;
/** Optional legacy hook for callers whose overlay is asserted by existing tests. */
@@ -109,9 +116,14 @@ const FLOATING_WINDOW_OUTSIDE_POINTER_SAFE_SURFACE_SELECTOR = [
".node-picker-dropdown--portal",
".agent-picker-dropdown--portal",
".priority-picker-dropdown--portal",
".activity-view-menu",
].join(", ");
/*
FNXC:ModalTouchGeometry 2026-08-13-12:00:
FN-8619: Task Detail's body-portaled activity-view menu is a logical child of its modal.
Treating it as safe prevents a preference-enabled outside pointer-down from closing the host.
FNXC:FloatingWindow 2026-07-13-08:01:
FN-7943: Quick Chat's outside-pointer dismissal must treat body-portaled dropdowns as logical children of the FloatingWindow. Keep this selector in sync with the sibling FN-7916 ChatThinkingLevelControl and FN-2860 QuickEntryBox portal guards so model, thinking-level, agent, dependency, node, and priority selections do not dismiss the host chat window while bare-page clicks still close it.
*/
@@ -203,6 +215,7 @@ export function FloatingWindow({
suspendGeometryPersistenceOnMobile = false,
suspendGeometryPersistenceOnShortViewport = false,
closeOnOutsidePointerDown = false,
backdropMouseHandlers,
modal = false,
testId,
hidden = false,
@@ -618,6 +631,8 @@ export function FloatingWindow({
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
data-testid={testId ?? `floating-window-overlay-${windowKey}`}
{...backdropMouseHandlers}
// FNXC:ModalTouchGeometry 2026-08-13-12:00: FN-8619 keeps Agent Detail's paired mouse-only backdrop contract at the shared modal backdrop; this deliberately does not alter pointer-down dismissal.
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
style={{ zIndex }}
>

View File

@@ -566,14 +566,11 @@ idle and auto-translate-off views retain their original layout.
/* Wider modal for two-pane layout */
.modal.github-import-modal {
width: min(90vw, 1200px);
max-width: 95vw;
min-width: 480px;
height: 80vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
resize: both;
}
.github-import-modal__header {

View File

@@ -37,9 +37,8 @@ import {
useGitHubImportAutoTranslate,
} from "./GitHubImportTranslateControls";
import type { TFunction } from "i18next";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
import { useConfirm } from "../hooks/useConfirm";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
@@ -417,7 +416,7 @@ export function buildCheckFixTaskPrompt(
}
export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, onOpenChatWithPrefill, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
const { isEmbedded, scrollLockEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
useMobileScrollLock(isOpen && scrollLockEnabled);
const { t, i18n } = useTranslation("app");
/*
@@ -597,9 +596,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
const mountedRef = useRef(false);
const remoteLoadRequestIdRef = useRef(0);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size");
const overlayDismissProps = useOverlayDismiss(onClose);
const dismissOnOutsidePointerDown = useModalDismissPreference();
// Track which owner/repo we've already auto-loaded to prevent duplicate loads
const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null);
@@ -1578,7 +1575,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
Modal mode is kept byte-identical: same overlay wrapper, header with subtitle + close button, and overlay-dismiss props.
*/
const inner = (
<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 ? (
/*
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>
<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">
{t("git.importSubtitle", "Choose a detected remote, load open issues or pull requests, and import one into the board.")}
</p>
@@ -2023,6 +2020,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
</div>
</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) && (
<FloatingWindow
windowKey="github-import-detail"
@@ -2481,13 +2479,35 @@ export function GitHubImportModal({ isOpen, onClose, onImport, onPlanningMode, o
</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) {
return <div className="github-import-embedded right-dock-embedded-view">{inner}</div>;
}
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}
</div>
</FloatingWindow>
);
}

View File

@@ -8,25 +8,11 @@
}
.modal.task-detail-modal {
width: min(95vw, 800px);
max-width: 95vw;
min-width: 480px;
height: 85vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
resize: both;
}
/*
FNXC:TaskModalResize 2026-07-26-11:06:
The tablet-only grip expands outside Task Detail's painted corner. Its default
overflow clipping would make the 44px finger target visible in layout but
unreachable by browser hit testing, so only the explicit touch-resize surface
permits the target outside the panel; true-phone sheets retain clipped content.
*/
.modal.task-detail-modal.task-modal--touch-resize {
overflow: visible;
}
/*
@@ -1797,36 +1783,6 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
resize: none;
}
/*
FNXC:TaskModalResize 2026-08-07-00:00:
Phone CSS also matches a known 768px touch tablet. The shared runtime class
records the physical-screen-aware tablet result, restoring bounded modal
geometry and the existing resize grip without weakening true-phone sheets.
*/
.modal-overlay:has(.task-detail-modal.task-modal--tablet) {
--overlay-padding-top: 6vh;
padding-top: var(--overlay-padding-top);
align-items: flex-start;
justify-content: center;
}
.modal.task-detail-modal.task-modal--tablet {
width: 98vw;
min-width: 480px;
max-width: 98vw;
height: 92vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md));
margin: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
resize: both;
}
.task-modal--tablet .modal-resize-grip {
display: block;
}
.detail-body--chat {
display: flex;

View File

@@ -3,10 +3,10 @@ import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
import { useViewportMode } from "../hooks/useViewportMode";
import { FloatingWindow } from "./FloatingWindow";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalDismissPreference, useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useColumnLabel } from "../i18n/labels";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
@@ -4074,7 +4074,7 @@ export function TaskDetailContent({
>
<div className="modal-header">
<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}`}>
{columnLabel(task.column)}
</span>
@@ -6577,6 +6577,7 @@ export function TaskDetailContent({
projectId={projectId}
onClose={() => setSelectedSourceAgentId(null)}
addToast={addToast}
floatingWindowKey="agent-detail-task"
/>
</Suspense>
)}
@@ -6585,12 +6586,9 @@ export function TaskDetailContent({
}
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const viewportMode = useViewportMode();
const isTabletTouchResize = isTabletTouchViewport(viewportMode);
useModalResizePersist(modalRef, true, "task-detail-modal-size", { touchTargets: isTabletTouchResize });
useMobileScrollLock(true);
const overlayDismissProps = useOverlayDismiss(onClose);
const dismissOnOutsidePointerDown = useModalDismissPreference();
/*
FNXC:TaskDetailSwipeBack 2026-08-07-00:00:
Gate predictive-back animation through useViewportMode, the same physical-screen-aware
@@ -6599,30 +6597,30 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
*/
const isMobileTransition = viewportMode === "mobile";
/*
FNXC:TaskModalResize 2026-08-07-00:00:
Known touch tablets at the 768px CSS boundary resolve to `tablet` through
useViewportMode. Carry that single classification into the modal class so CSS
can override phone-sheet rules without a second breakpoint or gesture system.
*/
const isTabletTaskModal = viewportMode === "tablet";
return (
<div
className="modal-overlay open"
{...overlayDismissProps}
role="dialog"
aria-modal="true"
<FloatingWindow
windowKey="task-detail"
title="Task detail"
ariaLabelledBy="task-detail-modal-title"
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
className={`modal modal-lg task-detail-modal${isTabletTaskModal ? " task-modal--tablet" : ""}${isTabletTouchResize ? " task-modal--touch-resize" : ""}${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
ref={modalRef}
>
<TaskDetailContent
{...props}
onRequestClose={onClose}
/>
<div className={`modal modal-lg task-detail-modal${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}>
<TaskDetailContent {...props} onRequestClose={onClose} />
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -140,7 +140,7 @@ it("keeps modal mode as dialog with close button", async () => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(document.querySelector(".agent-detail-overlay")).toBeInTheDocument();
expect(document.querySelector("[data-testid='floating-window-overlay-agent-detail']")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});

View File

@@ -232,13 +232,13 @@ describe("AgentDetailView mobile scroll regression (FN-4231)", () => {
it("shows mobile task column context without empty task shells (FN-7139)", async () => {
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" }));
const { container } = render(<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(() => {
expect(screen.getAllByText((_, el) => el?.textContent === "FN-MOBILE · In Progress").length).toBeGreaterThanOrEqual(2);
});
expect(container.querySelector(".agent-detail-content")).toBeTruthy();
expect(container.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress");
expect(baseElement.querySelector(".agent-detail-content")).toBeTruthy();
expect(baseElement.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress");
});
it("tabs accept horizontal touch panning and stay non-shrinking on mobile (FN-6450, FN-6865)", async () => {

View File

@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { CreateRoomModal, validateRoomName } from "../CreateRoomModal";
import { FloatingWindow } from "../FloatingWindow";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry, expectFloatingWindowStructure } from "./floatingWindowMigration.test-helpers";
import * as apiModule from "../../api";
vi.mock("../../api", () => ({
@@ -38,6 +39,7 @@ describe("validateRoomName", () => {
describe("CreateRoomModal", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchAgents.mockResolvedValue([
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
{ id: "agent-2", name: "Beta", role: "reviewer", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
@@ -84,7 +86,7 @@ describe("CreateRoomModal", () => {
);
const chatPanel = screen.getByTestId("floating-window-chat-modal");
const firstOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
const firstOverlay = screen.getByTestId("floating-window-overlay-create-room");
expect(Number(firstOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
await screen.findByRole("button", { name: /Alpha/i });
@@ -108,7 +110,7 @@ describe("CreateRoomModal", () => {
</>,
);
const reopenedOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
const reopenedOverlay = screen.getByTestId("floating-window-overlay-create-room");
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(chatPanel.style.zIndex));
expect(Number(reopenedOverlay.style.zIndex)).toBeGreaterThan(Number(firstOverlay.style.zIndex));
});
@@ -124,7 +126,7 @@ describe("CreateRoomModal", () => {
</>,
);
const loadingOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
const loadingOverlay = screen.getByTestId("floating-window-overlay-create-room");
expect(screen.getByRole("status")).toHaveTextContent("Loading agents...");
expect(Number(loadingOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-loading").style.zIndex));
loading.unmount();
@@ -139,7 +141,7 @@ describe("CreateRoomModal", () => {
</>,
);
const emptyOverlay = document.querySelector(".create-room-modal")?.parentElement as HTMLElement;
const emptyOverlay = screen.getByTestId("floating-window-overlay-create-room");
expect(await screen.findByText("No agents in this project yet.")).toBeInTheDocument();
expect(Number(emptyOverlay.style.zIndex)).toBeGreaterThan(Number(screen.getByTestId("floating-window-chat-empty").style.zIndex));
});
@@ -151,10 +153,58 @@ describe("CreateRoomModal", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(document.querySelector(".modal-overlay.open") as Element);
fireEvent.pointerDown(screen.getByTestId("floating-window-overlay-create-room"));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("hosts the dialog in FloatingWindow with persisted touch geometry and sheet recovery", () => {
const { baseElement } = render(<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 () => {
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);

View File

@@ -25,6 +25,7 @@ const QUICK_CHAT_PORTALED_MENU_CLASSES = [
"node-picker-dropdown--portal",
"agent-picker-dropdown--portal",
"priority-picker-dropdown--portal",
"activity-view-menu",
] as const;
function cssRuleFor(css: string, selector: string): string {

View File

@@ -981,15 +981,15 @@ describe("GitHubImportModal", () => {
it("keeps the modal overlay and Escape-to-close in modal mode", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
const { baseElement } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />,
);
await waitFor(() => {
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
expect(container.querySelector(".modal-overlay")).not.toBeNull();
expect(container.querySelector(".github-import-modal--embedded")).toBeNull();
expect(baseElement.querySelector("[data-testid='floating-window-overlay-github-import']")).not.toBeNull();
expect(baseElement.querySelector(".github-import-modal--embedded")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
@@ -2900,7 +2900,7 @@ describe("GitHubImportModal", () => {
expect(importSheetRule).toBe(chatSheetRule);
expect(importSheetRule).toBe(taskSheetRule);
expect(importSheetRule).toContain("inset: 0 !important;");
expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.floating-window--github-import-detail \.floating-window__resize-handle\s*\{\s*display: none;/);
expect(source).toMatch(/@media \(max-width: 767\.98px\), \(max-height: 480px\)[\s\S]*\.floating-window--github-import-detail \.floating-window__resize-handle\s*\{\s*display: none;/);
expect(source).toContain(".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail)");
});

View File

@@ -3,6 +3,8 @@ FNXC:TaskDetailTabs 2026-06-17-08:20:
FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state.
*/
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
@@ -586,18 +588,14 @@ describe("TaskDetailModal", () => {
expect(css).not.toMatch(/task-detail-workflow-badge-mobile/);
expect(css).not.toMatch(/\.detail-title-row\s+\.detail-workflow-badge\s*\{/);
});
it("keeps desktop and mobile modal sizing guards unchanged", () => {
const css = readDashboardStylesSource();
const mobileBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".modal-overlay:has(.task-detail-modal)");
const mobileOverlayBlock = getCssRuleBlock(mobileBlock, ".modal-overlay:has(.task-detail-modal)");
const mobileModalBlock = getCssRuleBlock(mobileBlock, ".modal.task-detail-modal");
it("uses FloatingWindow's full-screen sheet guards on phone and short viewports", () => {
const css = readFileSync(resolve(__dirname, "../FloatingWindow.css"), "utf8");
const sheetBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 767.98px), (max-height: 480px)", ".floating-window--task-detail");
const sheetRule = getCssRuleBlock(sheetBlock, ".floating-window--task-detail");
expectBaseRule(css, ".modal.task-detail-modal", "width: min(95vw, 800px);");
expectBaseRule(css, ".modal.task-detail-modal", "height: 85vh;");
expect(mobileOverlayBlock).toContain("padding-top: 0;");
expect(mobileOverlayBlock).toContain("align-items: stretch;");
expect(mobileModalBlock).toContain("width: 100vw;");
expect(mobileModalBlock).toContain("height: 100dvh;");
expect(sheetRule).toContain("inset: 0 !important;");
expect(sheetRule).toContain("width: 100vw !important;");
expect(sheetRule).toContain("height: 100dvh !important;");
});
it("reconciles tablet overlay offset with task-detail max-height and widens the modal", () => {
@@ -861,7 +859,7 @@ describe("TaskDetailModal", () => {
})));
try {
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
task={makeTask({ column: "in-progress" as Column })}
onClose={noop}
@@ -872,25 +870,12 @@ describe("TaskDetailModal", () => {
addToast={noop}
/>,
);
const modal = container.querySelector(".task-detail-modal");
expect(modal).toHaveClass("task-modal--tablet");
const grip = modal?.querySelector(".modal-resize-grip") as HTMLElement;
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
expect(grip).toHaveAttribute("tabindex", "0");
expect(grip).toHaveAttribute("data-resize-hit-target", "true");
// FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must
// remain keyboard discoverable, not merely restore a touch-only grip.
modal!.style.width = "500px";
modal!.style.height = "400px";
grip.focus();
fireEvent.keyDown(grip, { key: "ArrowRight" });
fireEvent.keyDown(grip, { key: "ArrowDown" });
expect(modal!.style.width).toBe("516px");
expect(modal!.style.height).toBe("416px");
expect(grip).toHaveAttribute("aria-valuenow", "516");
expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels");
const window = container.querySelector("[data-testid='floating-window-task-detail']");
expect(window).toBeTruthy();
const handles = window!.querySelectorAll(".floating-window__resize-handle");
expect(handles).toHaveLength(8);
handles.forEach((handle) => expect(handle).toHaveAttribute("data-resize-hit-target", "true"));
expect(container.querySelector(".modal-resize-grip")).toBeNull();
} finally {
if (originalScreen) Object.defineProperty(window, "screen", originalScreen);
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
@@ -899,7 +884,7 @@ describe("TaskDetailModal", () => {
});
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
task={makeTask({ column: "in-progress" as Column })}
onClose={noop}
@@ -911,7 +896,7 @@ describe("TaskDetailModal", () => {
/>,
);
expect(container.querySelector(".modal.modal-lg")).toBeTruthy();
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
expect(container.querySelector("[data-testid='floating-window-overlay-task-detail']")).toBeTruthy();
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
expect(container.querySelector(".detail-body")).toBeTruthy();
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
@@ -999,7 +984,7 @@ describe("TaskDetailModal", () => {
});
it("keeps dense in-review and standard task controls in their shared footer", () => {
const { container, unmount } = render(
const { baseElement: container, unmount } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review" as Column })}
@@ -1032,7 +1017,7 @@ describe("TaskDetailModal", () => {
addToast={noop}
/>,
);
const standardFooter = standard.container.querySelector(".modal-actions");
const standardFooter = standard.baseElement.querySelector(".modal-actions");
expect(standardFooter).toBeTruthy();
expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
@@ -1051,7 +1036,7 @@ describe("TaskDetailModal", () => {
});
it("keeps the triage footer usable when Actions is absent", () => {
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
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();
render(
@@ -1992,7 +1977,7 @@ describe("TaskDetailModal", () => {
{ id: "FN-002", title: "Add tests", description: "Test coverage", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
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: "" },
];
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ dependencies: ["FN-001"] })}
@@ -2047,7 +2032,7 @@ describe("TaskDetailModal", () => {
});
it("renders dependency ID as label when no title or description available", () => {
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
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: "" },
];
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
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: "" },
];
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ dependencies: ["FN-001"] })}
@@ -2131,7 +2116,7 @@ describe("TaskDetailModal", () => {
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ dependencies: ["FN-001"] })}
@@ -2160,7 +2145,7 @@ describe("TaskDetailModal", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
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" }),
];
const { container } = render(
const { baseElement: container } = render(
<TaskDetailModal
initialTab="definition"
task={tasks[0]}

View File

@@ -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");
});
});

View File

@@ -15,7 +15,7 @@ export type ModalPresentation = "modal" | "embedded";
*
* - `isEmbedded` / `isModal` — the raw mode test.
* - `scrollLockEnabled` — gate for `useMobileScrollLock`; off when embedded (the host page owns scrolling).
* - `resizePersistEnabled` — gate for `useModalResizePersist`; off when embedded (the view fills its container).
* - `resizePersistEnabled` — gate for modal-only FloatingWindow geometry; off when embedded (the view fills its container).
* - `escapeEnabled` — gate for Escape-to-close handlers; off when embedded (the dock/router owns lifecycle).
* - `overlayDismissEnabled` — gate for backdrop click-to-dismiss; off when embedded (no overlay backdrop exists).
*/

View File

@@ -58,6 +58,11 @@ function readPersistableSize(node: HTMLElement): PersistedSize {
* @param storageKey localStorage key, must be stable + unique per modal
* @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry
*/
/*
FNXC:ModalTouchGeometry 2026-07-26-19:30:
FN-8619 migrated every product modal consumer to FloatingWindow. Keep this hook, its grip CSS,
and tests because the Chromium touch-geometry e2e fixture still exercises the legacy resize seam.
*/
export function useModalResizePersist(
ref: RefObject<HTMLElement | null>,
isOpen: boolean,