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. -->
|
||||
|
||||
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.
|
||||
|
||||
@@ -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. | — |
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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.
|
||||
* - `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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user