From 2ce208e36d6c5c0bd9081f6f04f47d09277697bb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 00:35:41 -0700 Subject: [PATCH] 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 --- .../fn-7036-automation-modal-floating.md | 7 ++ .../app/components/ScheduledTasksModal.tsx | 43 +++++--- .../dashboard/app/components/ScriptsModal.css | 78 +++++++------ .../__tests__/ScheduledTasksModal.test.tsx | 103 ++++++++++++++++-- 4 files changed, 172 insertions(+), 59 deletions(-) create mode 100644 .changeset/fn-7036-automation-modal-floating.md diff --git a/.changeset/fn-7036-automation-modal-floating.md b/.changeset/fn-7036-automation-modal-floating.md new file mode 100644 index 0000000000..72691b5073 --- /dev/null +++ b/.changeset/fn-7036-automation-modal-floating.md @@ -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. diff --git a/packages/dashboard/app/components/ScheduledTasksModal.tsx b/packages/dashboard/app/components/ScheduledTasksModal.tsx index 5e03be2848..70252cfb60 100644 --- a/packages/dashboard/app/components/ScheduledTasksModal.tsx +++ b/packages/dashboard/app/components/ScheduledTasksModal.tsx @@ -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(() => 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(null); - const modalRef = useRef(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 ( -
-
-
+ + {/** + * 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. + */} +
+

{t("schedule.title", "Automations")}

@@ -460,6 +467,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation {renderContent()}
-
+
); } diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index ed8619a954..3dee893fcc 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -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 { diff --git a/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx b/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx index a3248da83b..4700b1747f 100644 --- a/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx @@ -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 { 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(); 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(); + + 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(); + + 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(); + render(); 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();