FN-7036: make Automations popup movable and resizable

Render the Automations modal in the shared floating-window shell while preserving embedded and mobile behavior.

- Wrap the modal presentation in FloatingWindow with persisted geometry, drag handle, and resize sizing.
- Update Automations modal CSS so desktop fills the floating shell and mobile remains full-screen with resize handles hidden.
- Cover floating-window rendering, dragging, resizing, mobile CSS contract, and embedded mode in ScheduledTasksModal tests.
- Add a minor changeset for the published Fusion package.

Files changed:
 .changeset/fn-7036-automation-modal-floating.md    |   7 ++
 .../app/components/ScheduledTasksModal.tsx         |  43 +++++----
 packages/dashboard/app/components/ScriptsModal.css |  78 +++++++++-------
 .../__tests__/ScheduledTasksModal.test.tsx         | 103 +++++++++++++++++++--
 4 files changed, 172 insertions(+), 59 deletions(-)

Fusion-Task-Id: FN-7036

Fusion-Task-Lineage: 3d6926b7-ec6d-4179-ad17-573bcfef297f
This commit is contained in:
gsxdsm
2026-06-26 00:35:41 -07:00
parent 98a5052b65
commit 2ce208e36d
4 changed files with 172 additions and 59 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Automations popup is now movable and resizable like other Fusion pop-outs.
category: feature
dev: ScheduledTasksModal modal presentation now renders inside the shared FloatingWindow (windowKey "automation", persistGeometryKey "floating-window:automation"); embedded presentation unchanged. Mobile stays full-screen by CSS.

View File

@@ -1,7 +1,7 @@
// ScheduledTasksModal renders schedule/routine cards using .scheduling-*, .routine-*, // ScheduledTasksModal renders schedule/routine cards using .scheduling-*, .routine-*,
// .schedule-form classes that live in ScriptsModal.css. Both modals share that file. // .schedule-form classes that live in ScriptsModal.css. Both modals share that file.
import "./ScriptsModal.css"; import "./ScriptsModal.css";
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus, Zap, Globe, Folder, X } from "lucide-react"; import { Plus, Zap, Globe, Folder, X } from "lucide-react";
import type { Routine, RoutineCreateInput } from "@fusion/core"; import type { Routine, RoutineCreateInput } from "@fusion/core";
@@ -16,9 +16,8 @@ import {
import { RoutineCard } from "./RoutineCard"; import { RoutineCard } from "./RoutineCard";
import { RoutineEditor } from "./RoutineEditor"; import { RoutineEditor } from "./RoutineEditor";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { FloatingWindow } from "./FloatingWindow";
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */ /** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
const POLL_INTERVAL_MS = 30_000; const POLL_INTERVAL_MS = 30_000;
@@ -28,11 +27,11 @@ export type SchedulingScope = "global" | "project";
/** /**
* FNXC:AutomationsEmbedded 2026-06-22-00:00: * FNXC:AutomationsEmbedded 2026-06-22-00:00:
* Automations can render either as a fixed modal overlay ("modal", the default and historical path) or inline * Automations can render either as a draggable/resizable floating modal ("modal", the default path) or inline
* as a main-content-area view ("embedded"). The embedded presentation fills the main panel like Command Center: * as a main-content-area view ("embedded"). The embedded presentation fills the main panel like Command Center:
* no overlay, no card/shadow/border chrome, a plain `.cc-header`-style title row, and a responsive two-pane * no overlay, no card/shadow/border chrome, a plain `.cc-header`-style title row, and a responsive two-pane
* body (list + detail) that collapses to a single column below ~900px. The modal path is kept byte-identical; * body (list + detail) that collapses to a single column below ~900px. Floating chrome and Escape-to-close are
* modal-only behaviors (scroll lock via resize-persist, escape-to-close, overlay dismiss) are disabled when embedded. * modal-only behaviors; the embedded presentation bypasses FloatingWindow entirely.
*/ */
interface ScheduledTasksModalProps { interface ScheduledTasksModalProps {
onClose: () => void; onClose: () => void;
@@ -45,7 +44,7 @@ interface ScheduledTasksModalProps {
export function ScheduledTasksModal({ onClose, addToast, projectId, presentation = "modal" }: ScheduledTasksModalProps) { export function ScheduledTasksModal({ onClose, addToast, projectId, presentation = "modal" }: ScheduledTasksModalProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { isEmbedded, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation); const { isEmbedded, escapeEnabled } = useEmbeddedPresentation(presentation);
// Scope state: defaults to "project" when projectId exists, else "global" // Scope state: defaults to "project" when projectId exists, else "global"
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global"); const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
@@ -58,10 +57,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
// FNXC:AutomationsEmbedded 2026-06-22-00:00: Two-pane embedded layout tracks the routine selected in the left list to render its detail on the right. // FNXC:AutomationsEmbedded 2026-06-22-00:00: Two-pane embedded layout tracks the routine selected in the left list to render its detail on the right.
const [selectedRoutineId, setSelectedRoutineId] = useState<string | null>(null); const [selectedRoutineId, setSelectedRoutineId] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
// Resize-persist is a modal-only affordance; the embedded view fills its host and never resizes.
useModalResizePersist(modalRef, resizePersistEnabled, "fusion:automation-modal-size");
// Build scope options for API calls // Build scope options for API calls
const scopeOptions = useMemo(() => ({ const scopeOptions = useMemo(() => ({
scope: activeScope, scope: activeScope,
@@ -124,8 +119,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [onClose, routineView, escapeEnabled]); }, [onClose, routineView, escapeEnabled]);
const overlayDismissProps = useOverlayDismiss(onClose);
// ── Routine CRUD handlers ─────────────────────────────────────────────── // ── Routine CRUD handlers ───────────────────────────────────────────────
const handleCreateRoutine = useCallback( const handleCreateRoutine = useCallback(
@@ -440,11 +433,25 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
); );
} }
// ── Modal (fixed overlay) presentation ────────────────────────────────── // ── Modal (floating window) presentation ────────────────────────────────
return ( return (
<div className="modal-overlay open" {...overlayDismissProps}> <FloatingWindow
<div ref={modalRef} className="modal modal-lg automation-modal" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title"> windowKey="automation"
<div className="modal-header"> title={t("schedule.title", "Automations")}
onClose={onClose}
hideHeader
dragHandleSelector=".automation-modal__drag-handle"
className="floating-window--automation"
defaultSize={{ width: 720, height: 640 }}
minSize={{ width: 420, height: 360 }}
persistGeometryKey="floating-window:automation"
>
{/**
* FNXC:Automations 2026-06-26-00:00:
* FN-7036 moves the desktop Automations popup into the shared FloatingWindow shell so it matches Plan Mission and Workflow editor drag, resize, stack, clamp, and geometry-persistence behavior. Mobile remains full-screen through the ScriptsModal.css floating-window contract, while the embedded main-content presentation above bypasses all FloatingWindow chrome.
*/}
<div className="modal modal-lg automation-modal" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
<div className="modal-header automation-modal__drag-handle">
<div className="detail-title-row"> <div className="detail-title-row">
<Zap size={20} className="icon-triage" /> <Zap size={20} className="icon-triage" />
<h3 id="schedules-modal-title">{t("schedule.title", "Automations")}</h3> <h3 id="schedules-modal-title">{t("schedule.title", "Automations")}</h3>
@@ -460,6 +467,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
{renderContent()} {renderContent()}
</div> </div>
</div> </div>
</div> </FloatingWindow>
); );
} }

View File

@@ -103,14 +103,35 @@ The Automations toolbar should match Artifacts' controls row: a plain body row w
/* === Automation (ScheduledTasksModal) === */ /* === Automation (ScheduledTasksModal) === */
.modal.automation-modal { .modal.automation-modal {
width: min(95vw, 720px);
max-width: 95vw;
min-width: 0;
height: 80vh;
min-height: calc(var(--space-2xl) * 15);
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg));
overflow: hidden; overflow: hidden;
resize: both; }
/*
FNXC:Automations 2026-06-26-00:00:
FN-7036 hosts the Automations popup in FloatingWindow on desktop, so the inner modal fills the movable/resizable panel instead of owning fixed overlay dimensions or CSS resize. Mobile uses the same floating shell but forces a full-screen panel and hides resize handles; the embedded Automations view keeps its separate .automations-embedded path.
*/
.floating-window--automation .floating-window__body {
overflow: hidden;
}
.floating-window--automation .modal.automation-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border: 0;
border-radius: inherit;
box-shadow: none;
}
.floating-window--automation .automation-modal__drag-handle {
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window--automation .automation-modal__drag-handle:active {
cursor: grabbing;
} }
.schedule-modal-content { .schedule-modal-content {
@@ -1210,8 +1231,8 @@ The activity-log embedded (.activity-log-embedded / .activity-log-modal--embedde
/* /*
FNXC:AutomationsEmbedded 2026-06-22-00:00: FNXC:AutomationsEmbedded 2026-06-22-00:00:
Automations can render inline in the main content area (presentation="embedded") instead of as a fixed modal overlay. Automations can render inline in the main content area (presentation="embedded") instead of as a floating modal popup.
The embedded root fills its host and sheds all modal chrome — no overlay, no card/shadow/border/radius — so the view The embedded root fills its host and sheds all modal chrome — no FloatingWindow, overlay, card/shadow/border/radius — so the view
blends into the main panel like Command Center. The view container carries --space-lg padding and a plain .cc-header blends into the main panel like Command Center. The view container carries --space-lg padding and a plain .cc-header
title row (reused from Command Center). The body is a responsive two-pane layout (list + detail) via container query title row (reused from Command Center). The body is a responsive two-pane layout (list + detail) via container query
when supported, falling back to a min-width media breakpoint, that collapses to a single column below ~900px. when supported, falling back to a min-width media breakpoint, that collapses to a single column below ~900px.
@@ -4302,32 +4323,25 @@ Refresh button pinned at the end of the section nav strip (replaces the removed
will-change: transform; will-change: transform;
} }
/* Same treatment for the Automations modal — same min-width: 480px would .floating-window--automation {
otherwise force it wider than narrow viewports. inset: 0 !important;
FNXC:Automations 2026-06-22-16:00: scope the viewport-takeover to the width: 100vw !important;
dialog presentation only. The embedded Automations view uses the distinct height: 100dvh !important;
.automations-embedded / .automations-embedded-view classes (it does NOT min-width: 100vw !important;
carry .automation-modal), so it is unaffected here — but the guard keeps min-height: 100dvh !important;
this rule from ever leaking onto an embedded variant should the markup max-width: 100vw !important;
share the base class later. The embedded view fills its pane and scrolls max-height: 100dvh !important;
via .automations-embedded-view (inline-size:100% + overflow-y:auto). */ border: 0;
.modal-overlay:has(.automation-modal:not(.automation-modal--embedded)) { border-radius: 0;
padding-top: 0; box-shadow: none;
align-items: stretch;
justify-content: stretch;
} }
.modal.automation-modal:not(.automation-modal--embedded) { .floating-window--automation .floating-window__resize-handle {
width: 100vw; display: none;
min-width: 0; }
max-width: 100vw;
height: 100dvh; .floating-window--automation .modal.automation-modal {
min-height: 0;
max-height: 100dvh;
margin: 0;
border: none;
border-radius: 0; border-radius: 0;
resize: none;
} }
.gm-layout { .gm-layout {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { ScheduledTasksModal } from "../ScheduledTasksModal"; import { ScheduledTasksModal } from "../ScheduledTasksModal";
@@ -86,6 +86,16 @@ vi.mock("../CustomModelDropdown", () => ({
), ),
})); }));
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
}
function stubPointerCapture(element: HTMLElement) {
Object.defineProperty(element, "setPointerCapture", { configurable: true, value: vi.fn() });
Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() });
}
function makeRoutine(overrides: Partial<Routine> = {}): Routine { function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return { return {
id: "routine-001", id: "routine-001",
@@ -114,13 +124,17 @@ describe("ScheduledTasksModal", () => {
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockFetchAutomations.mockResolvedValue([]); mockFetchAutomations.mockResolvedValue([]);
mockFetchRoutines.mockResolvedValue([]); mockFetchRoutines.mockResolvedValue([]);
localStorage.removeItem("floating-window:automation");
localStorage.removeItem("fusion:automation-modal-size");
setViewport(1200, 900);
}); });
it("renders the unified automations modal", async () => { it("renders the unified automations modal", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />); render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
expect(screen.getByText("Automations")).toBeDefined(); expect(screen.getByText("Automations")).toBeDefined();
expect(screen.getByRole("dialog").getAttribute("aria-labelledby")).toBe("schedules-modal-title"); const dialogs = screen.getAllByRole("dialog");
expect(dialogs.some((dialog) => dialog.getAttribute("aria-labelledby") === "schedules-modal-title")).toBe(true);
expect(screen.getByRole("button", { name: "Close" })).toBeDefined(); expect(screen.getByRole("button", { name: "Close" })).toBeDefined();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined(); expect(screen.getByText("No automations yet")).toBeDefined();
@@ -130,6 +144,76 @@ describe("ScheduledTasksModal", () => {
expect(mockFetchAutomations).not.toHaveBeenCalled(); expect(mockFetchAutomations).not.toHaveBeenCalled();
}); });
it("renders Automations inside a headerless floating window with default geometry", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
const panel = screen.getByTestId("floating-window-automation");
expect(panel).toHaveClass("floating-window--automation");
expect(panel).toHaveClass("floating-window--headerless");
expect(panel.style.width).toBe("720px");
expect(panel.style.height).toBe("640px");
expect(screen.queryByTestId("floating-window-drag-handle-automation")).toBeNull();
expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1);
const title = screen.getByText("Automations");
expect(title.closest(".automation-modal__drag-handle")).toBeTruthy();
await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined();
});
});
it("drags and resizes the desktop Automations floating window", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
const panel = screen.getByTestId("floating-window-automation");
const header = screen.getByText("Automations").closest(".automation-modal__drag-handle") as HTMLElement;
stubPointerCapture(panel);
const initialLeft = Number.parseFloat(panel.style.left);
const initialTop = Number.parseFloat(panel.style.top);
act(() => {
fireEvent.pointerDown(header, { pointerId: 11, clientX: 120, clientY: 80 });
fireEvent.pointerMove(panel, { pointerId: 11, clientX: 220, clientY: 140 });
fireEvent.pointerUp(panel, { pointerId: 11, clientX: 220, clientY: 140 });
});
await waitFor(() => {
expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft);
expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop);
});
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement;
stubPointerCapture(resizeHandle);
const widthAfterDrag = Number.parseFloat(panel.style.width);
const heightAfterDrag = Number.parseFloat(panel.style.height);
act(() => {
fireEvent.pointerDown(resizeHandle, { pointerId: 12, clientX: 700, clientY: 600 });
fireEvent.pointerMove(resizeHandle, { pointerId: 12, clientX: 760, clientY: 650 });
fireEvent.pointerUp(resizeHandle, { pointerId: 12, clientX: 760, clientY: 650 });
});
await waitFor(() => {
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(widthAfterDrag);
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(heightAfterDrag);
});
});
it("keeps mobile Automations full-screen and hides resize handles by CSS contract", () => {
const source = readFileSync(resolve(__dirname, "../ScriptsModal.css"), "utf8");
const mobileBlock = source
.match(/@media \(max-width: 768px\)\s*\{[\s\S]*?\n\}/g)
?.find((block) => block.includes(".floating-window--automation")) ?? "";
expect(mobileBlock).toContain(".floating-window--automation");
expect(mobileBlock).toContain("width: 100vw !important;");
expect(mobileBlock).toContain("height: 100dvh !important;");
expect(mobileBlock).toContain(".floating-window--automation .floating-window__resize-handle");
expect(mobileBlock).toContain("display: none;");
});
it("shows routine cards and the new automation button when routines exist", async () => { it("shows routine cards and the new automation button when routines exist", async () => {
mockFetchRoutines.mockResolvedValue([ mockFetchRoutines.mockResolvedValue([
makeRoutine({ name: "Database Backup", command: "npx runfusion.ai backup --create" }), makeRoutine({ name: "Database Backup", command: "npx runfusion.ai backup --create" }),
@@ -146,17 +230,17 @@ describe("ScheduledTasksModal", () => {
it("renders scope controls in the toolbar below the modal header", async () => { it("renders scope controls in the toolbar below the modal header", async () => {
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "Scoped Routine" })]); mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "Scoped Routine" })]);
const { container } = render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />); render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Scoped Routine")).toBeDefined(); expect(screen.getByText("Scoped Routine")).toBeDefined();
}); });
const header = container.querySelector(".modal-header"); const header = document.querySelector(".modal-header");
const toolbar = container.querySelector(".scheduling-toolbar"); const toolbar = document.querySelector(".scheduling-toolbar");
const toolbarLeft = container.querySelector(".scheduling-toolbar-left"); const toolbarLeft = document.querySelector(".scheduling-toolbar-left");
const toolbarRight = container.querySelector(".scheduling-toolbar-right"); const toolbarRight = document.querySelector(".scheduling-toolbar-right");
const scopeSelector = container.querySelector(".scheduling-scope-selector"); const scopeSelector = document.querySelector(".scheduling-scope-selector");
const newAutomationButton = screen.getByRole("button", { name: /new automation/i }); const newAutomationButton = screen.getByRole("button", { name: /new automation/i });
expect(header).toBeTruthy(); expect(header).toBeTruthy();
@@ -386,7 +470,8 @@ describe("ScheduledTasksModal", () => {
}); });
expect(screen.getByText("Automations")).toBeDefined(); expect(screen.getByText("Automations")).toBeDefined();
expect(container.querySelector(".automations-embedded")).not.toBeNull(); expect(container.querySelector(".automations-embedded")).not.toBeNull();
// No fixed overlay backdrop, no dialog role, no modal close button in embedded mode. // No floating window, fixed overlay backdrop, dialog role, or modal close button in embedded mode.
expect(screen.queryByTestId("floating-window-automation")).toBeNull();
expect(container.querySelector(".modal-overlay")).toBeNull(); expect(container.querySelector(".modal-overlay")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); expect(screen.queryByRole("button", { name: "Close" })).toBeNull();