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-*,
// .schedule-form classes that live in ScriptsModal.css. Both modals share that file.
import "./ScriptsModal.css";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Zap, Globe, Folder, X } from "lucide-react";
import type { Routine, RoutineCreateInput } from "@fusion/core";
@@ -16,9 +16,8 @@ import {
import { RoutineCard } from "./RoutineCard";
import { RoutineEditor } from "./RoutineEditor";
import type { ToastType } from "../hooks/useToast";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { FloatingWindow } from "./FloatingWindow";
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
const POLL_INTERVAL_MS = 30_000;
@@ -28,11 +27,11 @@ export type SchedulingScope = "global" | "project";
/**
* 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:
* 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;
* modal-only behaviors (scroll lock via resize-persist, escape-to-close, overlay dismiss) are disabled when embedded.
* body (list + detail) that collapses to a single column below ~900px. Floating chrome and Escape-to-close are
* modal-only behaviors; the embedded presentation bypasses FloatingWindow entirely.
*/
interface ScheduledTasksModalProps {
onClose: () => void;
@@ -45,7 +44,7 @@ interface ScheduledTasksModalProps {
export function ScheduledTasksModal({ onClose, addToast, projectId, presentation = "modal" }: ScheduledTasksModalProps) {
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"
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.
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
const scopeOptions = useMemo(() => ({
scope: activeScope,
@@ -124,8 +119,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, routineView, escapeEnabled]);
const overlayDismissProps = useOverlayDismiss(onClose);
// ── Routine CRUD handlers ───────────────────────────────────────────────
const handleCreateRoutine = useCallback(
@@ -440,11 +433,25 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
);
}
// ── Modal (fixed overlay) presentation ──────────────────────────────────
// ── Modal (floating window) presentation ────────────────────────────────
return (
<div className="modal-overlay open" {...overlayDismissProps}>
<div ref={modalRef} className="modal modal-lg automation-modal" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
<div className="modal-header">
<FloatingWindow
windowKey="automation"
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">
<Zap size={20} className="icon-triage" />
<h3 id="schedules-modal-title">{t("schedule.title", "Automations")}</h3>
@@ -460,6 +467,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
{renderContent()}
</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) === */
.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;
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 {
@@ -1210,8 +1231,8 @@ The activity-log embedded (.activity-log-embedded / .activity-log-modal--embedde
/*
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.
The embedded root fills its host and sheds all modal chrome — no overlay, no card/shadow/border/radius — so the view
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 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
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.
@@ -4302,32 +4323,25 @@ Refresh button pinned at the end of the section nav strip (replaces the removed
will-change: transform;
}
/* Same treatment for the Automations modal — same min-width: 480px would
otherwise force it wider than narrow viewports.
FNXC:Automations 2026-06-22-16:00: scope the viewport-takeover to the
dialog presentation only. The embedded Automations view uses the distinct
.automations-embedded / .automations-embedded-view classes (it does NOT
carry .automation-modal), so it is unaffected here — but the guard keeps
this rule from ever leaking onto an embedded variant should the markup
share the base class later. The embedded view fills its pane and scrolls
via .automations-embedded-view (inline-size:100% + overflow-y:auto). */
.modal-overlay:has(.automation-modal:not(.automation-modal--embedded)) {
padding-top: 0;
align-items: stretch;
justify-content: stretch;
.floating-window--automation {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 100vw !important;
min-height: 100dvh !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: 0;
border-radius: 0;
box-shadow: none;
}
.modal.automation-modal:not(.automation-modal--embedded) {
width: 100vw;
min-width: 0;
max-width: 100vw;
height: 100dvh;
min-height: 0;
max-height: 100dvh;
margin: 0;
border: none;
.floating-window--automation .floating-window__resize-handle {
display: none;
}
.floating-window--automation .modal.automation-modal {
border-radius: 0;
resize: none;
}
.gm-layout {

View File

@@ -1,5 +1,5 @@
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 { resolve } from "node:path";
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 {
return {
id: "routine-001",
@@ -114,13 +124,17 @@ describe("ScheduledTasksModal", () => {
mockConfirm.mockResolvedValue(true);
mockFetchAutomations.mockResolvedValue([]);
mockFetchRoutines.mockResolvedValue([]);
localStorage.removeItem("floating-window:automation");
localStorage.removeItem("fusion:automation-modal-size");
setViewport(1200, 900);
});
it("renders the unified automations modal", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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();
await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined();
@@ -130,6 +144,76 @@ describe("ScheduledTasksModal", () => {
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 () => {
mockFetchRoutines.mockResolvedValue([
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 () => {
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "Scoped Routine" })]);
const { container } = render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Scoped Routine")).toBeDefined();
});
const header = container.querySelector(".modal-header");
const toolbar = container.querySelector(".scheduling-toolbar");
const toolbarLeft = container.querySelector(".scheduling-toolbar-left");
const toolbarRight = container.querySelector(".scheduling-toolbar-right");
const scopeSelector = container.querySelector(".scheduling-scope-selector");
const header = document.querySelector(".modal-header");
const toolbar = document.querySelector(".scheduling-toolbar");
const toolbarLeft = document.querySelector(".scheduling-toolbar-left");
const toolbarRight = document.querySelector(".scheduling-toolbar-right");
const scopeSelector = document.querySelector(".scheduling-scope-selector");
const newAutomationButton = screen.getByRole("button", { name: /new automation/i });
expect(header).toBeTruthy();
@@ -386,7 +470,8 @@ describe("ScheduledTasksModal", () => {
});
expect(screen.getByText("Automations")).toBeDefined();
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(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();