FN-6155: render prompt fullscreen editor in a portal

Keep the workflow prompt expander usable in the fullscreen editor across desktop and mobile.

- render the expanded prompt and gate editor in a document-body portal instead of reusing the inline inspector container
- keep the inline prompt textarea compact while wiring fullscreen edits, focus, and collapse controls through the portal overlay
- extend workflow node editor coverage for desktop and mobile fullscreen expand, collapse, Escape, empty prompt, and edit persistence flows

Files changed:
 packages/dashboard/app/components/WorkflowNodeEditor.tsx          |  81 +++++++------
 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx          | 127 +++++++++++++++++++--
 2 files changed, 164 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-6155

Fusion-Task-Lineage: 5d0a6367-932a-4fbc-bded-ebad3073d1a6
This commit is contained in:
gsxdsm
2026-06-09 20:41:49 -07:00
parent a2b1f73794
commit 59ee449ba1
2 changed files with 164 additions and 44 deletions

View File

@@ -14,6 +14,7 @@ import {
type Node as FlowNode,
type Edge as FlowEdge,
} from "@xyflow/react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
@@ -2123,9 +2124,42 @@ function InnerEditor({
}, [overrideColumnBinding, agents.length, projectId, addToast]);
const overlayProps = useOverlayDismiss(requestClose);
const promptFullscreenOverlay =
isPromptExpanded && (selectedNode?.data.kind === "prompt" || selectedNode?.data.kind === "gate")
? createPortal(
<div
className="wf-prompt-editor wf-prompt-editor--fullscreen"
onKeyDown={handlePromptFullscreenKeyDown}
>
<div className="wf-prompt-fullscreen-header">
<span>{t("workflowEditor.editingPrompt", "Editing Prompt")}</span>
<button
type="button"
className="btn btn-sm wf-prompt-expand-btn"
onClick={handleTogglePromptExpand}
aria-label={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
title={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
>
<Minimize2 size={14} />
</button>
</div>
<label className="wf-field">
<span>Prompt</span>
<textarea
rows={undefined}
value={selectedNodePromptValue}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
autoFocus
/>
</label>
</div>,
document.body,
)
: null;
return (
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
<>
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
<div
className="modal wf-editor-modal"
ref={modalRef}
@@ -3144,43 +3178,24 @@ function InnerEditor({
</label>
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
<div
className={`wf-prompt-editor${isPromptExpanded ? " wf-prompt-editor--fullscreen" : ""}`}
onKeyDown={handlePromptFullscreenKeyDown}
>
{isPromptExpanded && (
<div className="wf-prompt-fullscreen-header">
<span>{t("workflowEditor.editingPrompt", "Editing Prompt")}</span>
<button
type="button"
className="btn btn-sm wf-prompt-expand-btn"
onClick={handleTogglePromptExpand}
aria-label={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
title={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
>
<Minimize2 size={14} />
</button>
</div>
)}
<div className="wf-prompt-editor">
<label className="wf-field">
<span>Prompt</span>
<textarea
rows={isPromptExpanded ? undefined : 5}
rows={5}
value={selectedNodePromptValue}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
/>
</label>
{!isPromptExpanded && (
<button
type="button"
className="btn btn-sm wf-prompt-expand-btn wf-prompt-expand-btn--inline"
onClick={handleTogglePromptExpand}
aria-label={t("workflowEditor.expandPrompt", "Expand prompt editor")}
title={t("workflowEditor.expandPrompt", "Expand prompt editor")}
>
<Maximize2 size={14} />
</button>
)}
<button
type="button"
className="btn btn-sm wf-prompt-expand-btn wf-prompt-expand-btn--inline"
onClick={handleTogglePromptExpand}
aria-label={t("workflowEditor.expandPrompt", "Expand prompt editor")}
title={t("workflowEditor.expandPrompt", "Expand prompt editor")}
>
<Maximize2 size={14} />
</button>
</div>
) : null}
@@ -4177,7 +4192,9 @@ function InnerEditor({
/>
)}
</div>
</div>
</div>
{promptFullscreenOverlay}
</>
);
}

View File

@@ -83,6 +83,16 @@ import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
function getPromptFullscreenOverlay() {
return document.body.querySelector(".wf-prompt-editor--fullscreen") as HTMLElement | null;
}
function getPromptFullscreenTextarea() {
const overlay = getPromptFullscreenOverlay();
expect(overlay).not.toBeNull();
return within(overlay!).getByLabelText("Prompt") as HTMLTextAreaElement;
}
function mockWorkflowEditorViewport(mode: "desktop" | "mobile" | "tablet" = "desktop") {
Object.defineProperty(window, "matchMedia", {
writable: true,
@@ -528,19 +538,22 @@ describe("WorkflowNodeEditor", () => {
const expand = await screen.findByRole("button", { name: "Expand prompt editor" });
expect(expand).toBeInTheDocument();
const promptEditor = expand.closest(".wf-prompt-editor");
expect(promptEditor).not.toBeNull();
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
const inlinePromptEditor = expand.closest(".wf-prompt-editor");
expect(inlinePromptEditor).not.toBeNull();
expect(inlinePromptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
expect(getPromptFullscreenOverlay()).toBeNull();
fireEvent.click(expand);
expect(promptEditor).toHaveClass("wf-prompt-editor--fullscreen");
expect(screen.getByRole("button", { name: "Collapse prompt editor" })).toBeVisible();
expect(screen.getByLabelText("Prompt")).not.toHaveAttribute("rows");
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
expect(inlinePromptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
expect(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" })).toBeVisible();
expect(getPromptFullscreenTextarea()).not.toHaveAttribute("rows");
fireEvent.click(screen.getByRole("button", { name: "Collapse prompt editor" }));
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
expect(getPromptFullscreenOverlay()).toBeNull();
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
@@ -553,13 +566,12 @@ describe("WorkflowNodeEditor", () => {
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
const promptEditor = screen.getByRole("button", { name: "Collapse prompt editor" }).closest(".wf-prompt-editor");
expect(promptEditor).not.toBeNull();
expect(promptEditor).toHaveClass("wf-prompt-editor--fullscreen");
const promptEditor = getPromptFullscreenOverlay();
expect(promptEditor).toBeInTheDocument();
fireEvent.keyDown(promptEditor!, { key: "Escape" });
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
expect(getPromptFullscreenOverlay()).toBeNull();
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
@@ -577,6 +589,97 @@ describe("WorkflowNodeEditor", () => {
expect(within(inspector).getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
it("opens the fullscreen prompt editor from the mobile prompt inspector", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
const inspector = await screen.findByTestId("wf-node-inspector");
fireEvent.click(within(inspector).getByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
expect(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" })).toBeVisible();
expect(getPromptFullscreenTextarea()).toHaveFocus();
});
it("closes the fullscreen prompt editor with the mobile collapse button", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
expect(getPromptFullscreenOverlay()).toBeNull();
});
it("closes the fullscreen prompt editor with Escape on mobile", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
fireEvent.keyDown(fullscreenPromptEditor!, { key: "Escape" });
expect(getPromptFullscreenOverlay()).toBeNull();
});
it("persists mobile fullscreen prompt edits back to the inline textarea", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
fireEvent.change(getPromptFullscreenTextarea(), { target: { value: "mobile edit" } });
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
const inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByLabelText("Prompt")).toHaveValue("mobile edit");
});
it("opens the fullscreen prompt editor for empty mobile gate prompts", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
fireEvent.click(await screen.findByTestId("wf-node-gate"));
const inspector = await screen.findByTestId("wf-node-inspector");
fireEvent.click(within(inspector).getByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
expect(within(fullscreenPromptEditor!).getByLabelText("Prompt")).toHaveValue("");
});
it("does not show the prompt expand button for non-prompt nodes", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([scriptDef()]);