FN-6143: add fullscreen prompt editor toggle

Add a fullscreen expand/collapse control for workflow prompt editing.

- add inline maximize/minimize controls for workflow prompt and gate editors
- style the fullscreen prompt overlay with responsive spacing and textarea sizing
- cover expand/collapse behavior with prompt-editor tests and add the published package changeset
- keep the mobile send-back promote action visible in task cards

Files changed:
 .changeset/fn-6143-workflow-prompt-fullscreen.md   |   5 +
 packages/dashboard/app/components/TaskCard.css     |   1 +
 packages/dashboard/app/components/WorkflowNodeEditor.css          |  77 ++++++++++++++++
 packages/dashboard/app/components/WorkflowNodeEditor.tsx          |  58 ++++++++++--
 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx          | 102 +++++++++++++++++++++
 5 files changed, 234 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-6143

Fusion-Task-Lineage: 787a33e3-a6fe-44c4-9cb8-c24b3e86e935
This commit is contained in:
gsxdsm
2026-06-09 16:49:59 -07:00
parent d67f61d03e
commit 99661c17fd
5 changed files with 234 additions and 9 deletions

View File

@@ -1437,6 +1437,7 @@
}
.card-promote-action.card-send-back-btn {
opacity: 1;
margin-left: auto;
}

View File

@@ -665,6 +665,79 @@
box-shadow: var(--focus-ring);
}
/* ── Prompt fullscreen editor ─────────────────────────────────── */
.wf-prompt-editor {
position: relative;
}
.wf-prompt-editor--fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
background: var(--surface);
padding: max(var(--space-lg), env(safe-area-inset-top, 0))
max(var(--space-lg), env(safe-area-inset-right, 0))
max(var(--space-lg), env(safe-area-inset-bottom, 0))
max(var(--space-lg), env(safe-area-inset-left, 0));
display: flex;
flex-direction: column;
}
.wf-prompt-editor--fullscreen .wf-field {
flex: 1;
display: flex;
flex-direction: column;
}
.wf-prompt-editor--fullscreen textarea {
flex: 1;
min-height: unset;
resize: none;
border-radius: var(--radius-md);
font-size: 0.9375rem;
line-height: 1.6;
}
.wf-prompt-fullscreen-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.wf-prompt-expand-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.wf-prompt-expand-btn--inline {
position: absolute;
top: calc(var(--space-xs) + 1.2em + var(--space-xs));
right: var(--space-xs);
z-index: 2;
color: var(--text-muted);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: calc(var(--space-xs) / 2) var(--space-xs);
}
.wf-prompt-expand-btn--inline:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.wf-prompt-editor:not(.wf-prompt-editor--fullscreen) textarea {
padding-right: calc(var(--space-xl) + var(--space-md));
}
.wf-inspector-note {
margin: 0;
font-size: 0.78rem;
@@ -1242,6 +1315,10 @@
}
@media (max-width: 768px) {
.wf-prompt-editor--fullscreen {
padding: var(--space-sm);
}
.modal-overlay:has(.wf-editor-modal),
.modal-overlay:has(.wf-create-modal) {
padding-top: 0;

View File

@@ -15,7 +15,7 @@ import {
type Edge as FlowEdge,
} from "@xyflow/react";
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 } from "lucide-react";
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";
import { getErrorMessage } from "@fusion/core";
import {
@@ -1879,6 +1879,16 @@ function InnerEditor({
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const handleTogglePromptExpand = useCallback(() => {
setIsPromptExpanded((prev) => !prev);
}, []);
const handlePromptFullscreenKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (!isPromptExpanded || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setIsPromptExpanded(false);
}, [isPromptExpanded]);
const selectedNodePromptValue =
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
? String(
@@ -3134,14 +3144,44 @@ function InnerEditor({
</label>
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
<label className="wf-field">
<span>Prompt</span>
<textarea
rows={5}
value={selectedNodePromptValue}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
/>
</label>
<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>
)}
<label className="wf-field">
<span>Prompt</span>
<textarea
rows={isPromptExpanded ? undefined : 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>
)}
</div>
) : null}
{selectedNode.data.kind === "prompt" ? (

View File

@@ -241,6 +241,35 @@ function def(): WorkflowDefinition {
};
}
function scriptDef(): WorkflowDefinition {
return {
id: "WF-SCRIPT",
kind: "workflow",
name: "Script workflow",
description: "",
ir: {
version: "v2",
name: "Script workflow",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "run", kind: "script", column: "triage", config: { scriptName: "test" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "run", condition: "success" },
{ from: "run", to: "end", condition: "success" },
],
},
layout: { start: { x: 0, y: 20 }, run: { x: 120, y: 60 }, end: { x: 360, y: 240 } },
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
describe("workflow-flow-mapping", () => {
it("round-trips IR through flow and back, preserving structure and layout", () => {
const original = def();
@@ -488,6 +517,79 @@ describe("WorkflowNodeEditor", () => {
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
});
it("shows a prompt expand button and toggles fullscreen for prompt nodes", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
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");
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");
fireEvent.click(screen.getByRole("button", { name: "Collapse prompt editor" }));
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
it("collapses the fullscreen prompt editor on Escape", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
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");
fireEvent.keyDown(promptEditor!, { key: "Escape" });
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
it("shows the prompt expand button for gate nodes with an empty prompt", 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");
expect(within(inspector).getByLabelText("Prompt")).toHaveValue("");
expect(within(inspector).getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
it("does not show the prompt expand button for non-prompt nodes", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([scriptDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
fireEvent.click(await screen.findByTestId("wf-node-script"));
const inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByLabelText("Script name")).toBeInTheDocument();
expect(within(inspector).queryByRole("button", { name: "Expand prompt editor" })).not.toBeInTheDocument();
});
it("renders nothing when closed", () => {
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
expect(container).toBeEmptyDOMElement();