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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 23:42:11 -07:00
parent 27b0cbb05a
commit 411806163f
7 changed files with 168 additions and 39 deletions

View File

@@ -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).

View File

@@ -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/<task-id-lower>` 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.

View File

@@ -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 {

View File

@@ -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<PrOptionsUser[]>([]);
const [labels, setLabels] = useState<PrOptionsLabel[]>([]);
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(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
return (
<FloatingWindow
windowKey="pr-create"
title={t("pr.createTitle", "Create Pull Request")}
onClose={onClose}
hideHeader
dragHandleSelector=".pr-create-modal__drag-handle"
className="floating-window--pr-create"
defaultSize={{ width: 720, height: 680 }}
minSize={{ width: 480, height: 420 }}
persistGeometryKey="floating-window:pr-create"
>
{/**
* 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.
*/}
<div
ref={modalRef}
className="modal modal-lg pr-create-modal"
@@ -507,7 +518,7 @@ export function PrCreateModal({
aria-modal="true"
aria-labelledby={headingId}
>
<div className="modal-header">
<div className="modal-header pr-create-modal__drag-handle">
<h2 id={headingId}>{t("pr.createTitle", "Create Pull Request")}</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
<X size={20} />
@@ -708,7 +719,6 @@ export function PrCreateModal({
</button>
</div>
</div>
</div>,
document.body,
</FloatingWindow>
);
}

View File

@@ -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();

View File

@@ -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<ComponentProps<typeof PrCre
return handles;
}
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() });
}
describe("PrCreateModal", () => {
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(
<div data-testid="trap" style={{ containerType: "inline-size" }}>
<PrCreateModal open taskId="FN-4756" onClose={vi.fn()} onCreated={vi.fn()} addToast={vi.fn()} />
@@ -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 () => {

View File

@@ -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", () => {