From 411806163f5d86c18348fe254e188e22539b45f3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 23:42:11 -0700 Subject: [PATCH] FN-7170: make Create PR dialog draggable and resizable Move the Create PR dialog onto the shared floating window shell. - Render PrCreateModal in FloatingWindow with persisted desktop geometry and embedded-header dragging. - Add CSS that lets the modal fill its floating panel while preserving a full-screen mobile layout. - Update docs, release notes, and modal tests for floating behavior, resize handles, and non-overlay dismissal. Files changed: .changeset/fn-7170-pr-create-modal-floating.md | 7 ++ docs/dashboard-guide.md | 1 + .../dashboard/app/components/PrCreateModal.css | 63 +++++++++++---- .../dashboard/app/components/PrCreateModal.tsx | 28 ++++--- .../__tests__/PrCreateModal.layout.test.tsx | 6 +- .../components/__tests__/PrCreateModal.test.tsx | 94 +++++++++++++++++++--- .../app/components/__tests__/TaskCard.test.tsx | 8 +- 7 files changed, 168 insertions(+), 39 deletions(-) Fusion-Task-Id: FN-7170 Fusion-Task-Lineage: 03452d6a-0da7-4891-a58e-6d93a93383f7 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7170-pr-create-modal-floating.md | 7 ++ docs/dashboard-guide.md | 1 + .../app/components/PrCreateModal.css | 63 ++++++++++--- .../app/components/PrCreateModal.tsx | 28 ++++-- .../__tests__/PrCreateModal.layout.test.tsx | 6 +- .../__tests__/PrCreateModal.test.tsx | 94 ++++++++++++++++--- .../components/__tests__/TaskCard.test.tsx | 8 +- 7 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 .changeset/fn-7170-pr-create-modal-floating.md diff --git a/.changeset/fn-7170-pr-create-modal-floating.md b/.changeset/fn-7170-pr-create-modal-floating.md new file mode 100644 index 0000000000..c8aaa63e9d --- /dev/null +++ b/.changeset/fn-7170-pr-create-modal-floating.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: The Create PR dialog is now movable and resizable like other Fusion pop-outs. +category: feature +dev: PrCreateModal now renders inside the shared FloatingWindow (windowKey "pr-create", persistGeometryKey "floating-window:pr-create") instead of a fixed .modal-overlay; geometry persists, mobile stays full-screen via CSS, and overlay click-to-dismiss was dropped (close via X / Cancel / Escape). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6cb78169cf..01e548be4d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1001,6 +1001,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - Task Detail and list split-pane PR affordances follow the live project auto-merge setting: when auto-merge is off, manual **Create PR** / merge actions are shown; when it is on, the tab shows the automatic auto-merge hint unless a per-task override changes the effective behavior. - The **Workflow** tab resolves the effective workflow for both explicitly selected and default-inherited tasks. Its overview, expandable graph preview, configured step details, and live step results refresh when switching tasks or projects without showing stale rows from the previous task. - The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit and push real merge changes, or report success without an empty commit when the selected base is already merged; preflight then refreshes so normal PR creation can continue once all checks pass. +- The **Create Pull Request** modal is a floating pop-out like Plan Mission, New Task, and Automations: drag its header or resize from desktop edges/corners, while mobile keeps the full-screen dialog layout. Close it with **X**, **Cancel**, or **Escape**. - The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. - AI title/body generation is bounded to 60 seconds and is canceled if the dialog request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever. - The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL. diff --git a/packages/dashboard/app/components/PrCreateModal.css b/packages/dashboard/app/components/PrCreateModal.css index 4edc1e6495..dd362bfb72 100644 --- a/packages/dashboard/app/components/PrCreateModal.css +++ b/packages/dashboard/app/components/PrCreateModal.css @@ -1,13 +1,36 @@ +/* +FNXC:PrCreateModal 2026-06-27-00:00: +FN-7170 hosts Create PR in FloatingWindow: desktop geometry belongs to the shared floating shell while the embedded modal fills that panel, and mobile forces the floating shell full-screen with resize handles hidden so the dialog matches the Plan Mission / Automations contract. +*/ +.floating-window--pr-create .floating-window__body { + overflow: hidden; +} + +.floating-window--pr-create .modal.pr-create-modal { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + min-width: 0; + min-height: 0; + border: 0; + border-radius: inherit; + box-shadow: none; +} + +.floating-window--pr-create .pr-create-modal__drag-handle { + cursor: grab; + user-select: none; + touch-action: none; +} + +.floating-window--pr-create .pr-create-modal__drag-handle:active { + cursor: grabbing; +} + .pr-create-modal { display: flex; flex-direction: column; - width: min(calc(var(--space-2xl) * 20), 90vw); - height: min(80vh, calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg))); - min-width: min(calc(var(--space-2xl) * 15), 100vw); - min-height: calc(var(--space-2xl) * 12); - max-width: calc(100vw - var(--space-lg)); - max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg)); - resize: both; overflow: hidden; } @@ -279,13 +302,25 @@ Commit preview rows render DOM order as SHA, subject, author. The grid must mirr } @media (max-width: 768px) { - .pr-create-modal { - min-width: 0; - width: 100%; - height: auto; - max-width: 100%; - max-height: 100%; - resize: none; + .floating-window--pr-create { + inset: 0 !important; + width: 100vw !important; + height: 100dvh !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: 100vw !important; + max-height: 100dvh !important; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .floating-window--pr-create .floating-window__resize-handle { + display: none; + } + + .floating-window--pr-create .modal.pr-create-modal { + border-radius: 0; } .pr-create-modal__body { diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index 52fb033d38..417a5bb22a 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; -import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react"; import { getErrorMessage, type PrInfo, type StructuredGhError } from "@fusion/core"; @@ -16,7 +15,7 @@ import { type PrPreflightResponse, } from "../api"; import type { ToastType } from "../hooks/useToast"; -import { useModalResizePersist } from "../hooks/useModalResizePersist"; +import { FloatingWindow } from "./FloatingWindow"; import "./PrCreateModal.css"; interface PrCreateModalProps { @@ -192,8 +191,6 @@ export function PrCreateModal({ const [assignees, setAssignees] = useState([]); const [labels, setLabels] = useState([]); - useModalResizePersist(modalRef, open, "fusion:pr-create-modal-size"); - const applyPreferredBase = useCallback((baseOverride?: string, nextPreflight?: PrPreflightResponse | null, nextOptions?: PrOptionsResponse | null) => { if (baseBranchTouchedRef.current) { return; @@ -498,8 +495,22 @@ export function PrCreateModal({ if (!open) return null; - return createPortal( -
event.target === event.currentTarget && onClose()}> + return ( + + {/** + * FNXC:PrCreateModal 2026-06-27-00:00: + * FN-7170 moves Create PR onto the shared FloatingWindow shell so it matches Plan Mission, Automations, and New Task: desktop users can drag the embedded modal header and resize from every FloatingWindow edge/corner, mobile stays full-screen through CSS, and geometry persists with persistGeometryKey="floating-window:pr-create". Overlay click-to-dismiss is intentionally dropped because FloatingWindow is non-blocking/click-through; close remains available via X, Cancel, and Escape. + */}
-
+

{t("pr.createTitle", "Create Pull Request")}

-
, - document.body, +
); } diff --git a/packages/dashboard/app/components/__tests__/PrCreateModal.layout.test.tsx b/packages/dashboard/app/components/__tests__/PrCreateModal.layout.test.tsx index 2d7a5c0a90..b083d045ef 100644 --- a/packages/dashboard/app/components/__tests__/PrCreateModal.layout.test.tsx +++ b/packages/dashboard/app/components/__tests__/PrCreateModal.layout.test.tsx @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ fetchPrPreflight: vi.fn(), fetchPrOptions: vi.fn(), createPr: vi.fn(), + pushPrBranch: vi.fn(), + resolvePrConflicts: vi.fn(), })); vi.mock("../../api", () => ({ @@ -15,6 +17,8 @@ vi.mock("../../api", () => ({ fetchPrPreflight: mocks.fetchPrPreflight, fetchPrOptions: mocks.fetchPrOptions, createPr: mocks.createPr, + pushPrBranch: mocks.pushPrBranch, + resolvePrConflicts: mocks.resolvePrConflicts, })); describe("PrCreateModal layout", () => { @@ -56,7 +60,7 @@ describe("PrCreateModal layout", () => { await waitFor(() => expect(mocks.generatePrMetadata).toHaveBeenCalled()); - const dialog = screen.getByRole("dialog"); + const dialog = screen.getByRole("dialog", { name: "Create Pull Request" }); const modal = dialog.classList.contains("modal") ? dialog : dialog.closest(".modal"); expect(modal).toBeTruthy(); diff --git a/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx b/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx index d41a8cdb64..16b12b7d1f 100644 --- a/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act } from "react"; import type { ComponentProps } from "react"; @@ -5,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PrCreateModal } from "../PrCreateModal"; import type { PrInfo } from "@fusion/core"; +const prCreateModalCss = readFileSync("app/components/PrCreateModal.css", "utf8"); + const mocks = vi.hoisted(() => ({ generatePrMetadata: vi.fn(), fetchPrPreflight: vi.fn(), @@ -74,6 +77,16 @@ async function renderModalLoaded(overrides?: Partial { beforeEach(() => { localStorage.clear(); @@ -92,7 +105,7 @@ describe("PrCreateModal", () => { expect(document.body.querySelector('[role="dialog"]')).toBeNull(); }); - it("portals out of a container-type containing block", async () => { + it("renders inside the shared floating window and portals out of a container-type containing block", async () => { const { container } = render(
@@ -103,10 +116,68 @@ describe("PrCreateModal", () => { const trap = within(container).getByTestId("trap"); expect(trap.querySelector('[role="dialog"]')).toBeNull(); - const dialog = screen.getByRole("dialog"); - expect(dialog).toBeInTheDocument(); - expect(dialog.parentElement).toHaveClass("modal-overlay", "open"); - expect(dialog.parentElement?.parentElement).toBe(document.body); + const panel = screen.getByTestId("floating-window-pr-create"); + expect(panel).toHaveClass("floating-window--pr-create", "floating-window--headerless"); + expect(panel.style.width).toBe("720px"); + expect(panel.style.height).toBe("680px"); + expect(screen.queryByTestId("floating-window-drag-handle-pr-create")).toBeNull(); + expect(panel.parentElement).toHaveClass("floating-window-overlay"); + expect(panel.parentElement?.parentElement).toBe(document.body); + + const dialog = screen.getByRole("dialog", { name: "Create Pull Request" }); + expect(dialog).toHaveClass("modal", "pr-create-modal"); + expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1); + expect(dialog.querySelector(":scope > .modal-header")).toHaveClass("pr-create-modal__drag-handle"); + expect(dialog.querySelector(":scope > .modal-actions")?.parentElement).toBe(dialog); + }); + + it("drags and resizes the Create PR floating window from the embedded header", async () => { + setViewport(1200, 1000); + await renderModalLoaded(); + + const panel = screen.getByTestId("floating-window-pr-create"); + const header = screen.getByRole("dialog", { name: "Create Pull Request" }).querySelector(".pr-create-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: 7, clientX: 120, clientY: 80 }); + fireEvent.pointerMove(panel, { pointerId: 7, clientX: 220, clientY: 150 }); + fireEvent.pointerUp(panel, { pointerId: 7, clientX: 220, clientY: 150 }); + }); + + await waitFor(() => { + expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft); + expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop); + }); + + const initialWidth = Number.parseFloat(panel.style.width); + const initialHeight = Number.parseFloat(panel.style.height); + const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement; + stubPointerCapture(resizeHandle); + + act(() => { + fireEvent.pointerDown(resizeHandle, { pointerId: 8, clientX: 700, clientY: 600 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 8, clientX: 820, clientY: 720 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 8, clientX: 820, clientY: 720 }); + }); + + await waitFor(() => { + expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth); + expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight); + }); + }); + + it("keeps mobile Create PR full-screen and hides resize handles by CSS contract", () => { + const mobileBlock = prCreateModalCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--pr-create \.modal\.pr-create-modal\s*\{[\s\S]*?\n\}/)?.[0]; + + expect(mobileBlock).toContain(".floating-window--pr-create"); + expect(mobileBlock).toContain("width: 100vw !important;"); + expect(mobileBlock).toContain("height: 100dvh !important;"); + expect(mobileBlock).toContain(".floating-window--pr-create .floating-window__resize-handle"); + expect(mobileBlock).toContain("display: none;"); }); it("portals independently of an outer modal overlay", async () => { @@ -130,7 +201,8 @@ describe("PrCreateModal", () => { expect(outerShell.querySelector('[role="dialog"]')).toBeNull(); const overlays = Array.from(document.body.querySelectorAll(".modal-overlay.open")); - expect(overlays).toHaveLength(2); + expect(overlays).toHaveLength(1); + expect(screen.getByTestId("floating-window-pr-create")).toBeInTheDocument(); const outerOverlay = within(container).getByTestId("outer-overlay"); const innerDialog = screen.getByRole("dialog", { name: "Create Pull Request" }); @@ -515,12 +587,12 @@ describe("PrCreateModal", () => { expect((await screen.findAllByText(/gh auth login/i)).length).toBeGreaterThan(0); }); - it("closes on overlay click", async () => { + it("does not close from bare page clicks because the floating shell is non-blocking", async () => { const { onClose } = await renderModalLoaded(); - const overlay = document.querySelector(".modal-overlay.open"); - expect(overlay).toBeTruthy(); - fireEvent.click(overlay as Element); - expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.pointerDown(document.body); + fireEvent.click(document.body); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId("floating-window-pr-create")).toBeInTheDocument(); }); it("closes on escape", async () => { diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 304a096ee6..0fd6169a52 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -1946,13 +1946,13 @@ describe("TaskCard", () => { ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); - // WS-003 has no result → name falls back to the raw id; all others resolve from result.workflowStepName. + // WS-003 has no result → name falls back to the humanized workflow id; all others resolve from result.workflowStepName. expect(stepNames).toEqual([ "Step 0", "Step 1", "Browser Verification", "Frontend UX Design", - "WS-003", + "WS 003", "Code Review Gate", ]); @@ -2032,8 +2032,8 @@ describe("TaskCard", () => { ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); - // Blank result name → fall back to id; WS-003 (no result) → id. - expect(stepNames).toEqual(["WS-002", "WS-003"]); + // Blank result name → fall back to the humanized id; WS-003 (no result) → humanized id. + expect(stepNames).toEqual(["WS 002", "WS 003"]); }); it("shows drop indicator on file dragover and removes on dragleave", () => {