diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7d0a42eb7a..50d52d23cf 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -128,7 +128,7 @@ Behavior: - The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow. - On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs. - On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available. -- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. +- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls. - Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split. - The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens diff --git a/packages/dashboard/app/components/MobileWorkflowGraphView.css b/packages/dashboard/app/components/MobileWorkflowGraphView.css index 6b635cc6d1..31c4854f46 100644 --- a/packages/dashboard/app/components/MobileWorkflowGraphView.css +++ b/packages/dashboard/app/components/MobileWorkflowGraphView.css @@ -105,7 +105,12 @@ font-size: 0.78rem; } +/* +FNXC:WorkflowSimpleEditor 2026-06-17-03:02: +Simple-editor step order is editable on touch and compact desktop surfaces, so move controls use the same minimum touch target and focus/active treatment as existing outline buttons without creating hidden shells for read-only or structural rows. +*/ .mobile-wf-node-expand, +.mobile-wf-node-move, .mobile-wf-connect-button { display: inline-flex; align-items: center; @@ -122,21 +127,30 @@ box-shadow var(--transition-fast); } -.mobile-wf-node-expand { +.mobile-wf-node-expand, +.mobile-wf-node-move { width: var(--wf-editor-touch-target); } +.mobile-wf-node-move:disabled { + cursor: not-allowed; + opacity: var(--opacity-disabled, 0.5); + transform: none; +} + .mobile-wf-connect-button { gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); } .mobile-wf-node-expand:hover, +.mobile-wf-node-move:not(:disabled):hover, .mobile-wf-connect-button:hover { background: var(--bg-tertiary); } .mobile-wf-node-expand:focus-visible, +.mobile-wf-node-move:focus-visible, .mobile-wf-connect-button:focus-visible, .mobile-wf-connect-select:focus-visible { outline: none; @@ -144,6 +158,7 @@ } .mobile-wf-node-expand:active, +.mobile-wf-node-move:not(:disabled):active, .mobile-wf-connect-button:active { transform: scale(0.97); } diff --git a/packages/dashboard/app/components/MobileWorkflowGraphView.tsx b/packages/dashboard/app/components/MobileWorkflowGraphView.tsx index 64a8f61640..808e8394d0 100644 --- a/packages/dashboard/app/components/MobileWorkflowGraphView.tsx +++ b/packages/dashboard/app/components/MobileWorkflowGraphView.tsx @@ -1,7 +1,7 @@ -import { ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react"; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { MobileWorkflowNodeSummary } from "./workflow-mobile-graph"; +import type { MobileWorkflowNodeSummary, WorkflowNodeReorderDirection } from "./workflow-mobile-graph"; import "./MobileWorkflowGraphView.css"; interface MobileWorkflowGraphViewProps { @@ -11,6 +11,17 @@ interface MobileWorkflowGraphViewProps { onSelectNode: (id: string) => void; onSelectEdge: (id: string) => void; onCreateConnection?: (source: string, target: string) => void; + canReorder?: boolean; + onMoveNode?: (id: string, direction: WorkflowNodeReorderDirection) => void; +} + +function reorderAvailability(rows: MobileWorkflowNodeSummary[], index: number) { + const row = rows[index]; + if (!row?.editable) return { up: false, down: false }; + return { + up: rows[index - 1]?.editable === true, + down: rows[index + 1]?.editable === true, + }; } function NodeRow({ @@ -21,6 +32,10 @@ function NodeRow({ onSelectNode, onSelectEdge, onCreateConnection, + canReorder, + onMoveNode, + canMoveUp, + canMoveDown, }: { row: MobileWorkflowNodeSummary; depth: number; @@ -29,6 +44,10 @@ function NodeRow({ onSelectNode: (id: string) => void; onSelectEdge: (id: string) => void; onCreateConnection?: (source: string, target: string) => void; + canReorder?: boolean; + onMoveNode?: (id: string, direction: WorkflowNodeReorderDirection) => void; + canMoveUp: boolean; + canMoveDown: boolean; }) { const { t } = useTranslation("app"); const hasChildren = row.children.length > 0; @@ -37,6 +56,7 @@ function NodeRow({ const selected = selectedNodeId === row.id; const connectionTargets = row.connectionTargets ?? []; const canCreateConnection = !!onCreateConnection && row.editable && connectionTargets.length > 0; + const showReorderControls = !!canReorder && !!onMoveNode && row.editable; return (
@@ -58,17 +78,43 @@ function NodeRow({ {row.editable ? : null} - {hasChildren ? ( + {hasChildren || showReorderControls ? (
- + {showReorderControls ? ( + <> + + + + ) : null} + {hasChildren ? ( + + ) : null}
) : null}
@@ -146,18 +192,25 @@ function NodeRow({ )} {hasChildren && expanded ? (
- {row.children.map((child) => ( - - ))} + {row.children.map((child, index) => { + const move = reorderAvailability(row.children, index); + return ( + + ); + })}
) : null} @@ -171,6 +224,8 @@ export function MobileWorkflowGraphView({ onSelectNode, onSelectEdge, onCreateConnection, + canReorder, + onMoveNode, }: MobileWorkflowGraphViewProps) { const { t } = useTranslation("app"); if (rows.length === 0) { @@ -183,18 +238,25 @@ export function MobileWorkflowGraphView({ return (
- {rows.map((row) => ( - - ))} + {rows.map((row, index) => { + const move = reorderAvailability(rows, index); + return ( + + ); + })}
); } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index baa6566634..a47d4a3e12 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -85,7 +85,12 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView"; -import { buildMobileWorkflowGraph, type MobileWorkflowConnectionTarget } from "./workflow-mobile-graph"; +import { + buildMobileWorkflowGraph, + reorderWorkflowNode, + type MobileWorkflowConnectionTarget, + type WorkflowNodeReorderDirection, +} from "./workflow-mobile-graph"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions"; @@ -1278,6 +1283,17 @@ function InnerEditor({ [createConnectionEdge], ); + /** + * FNXC:WorkflowSimpleEditor 2026-06-17-03:08: + * Custom-workflow simple editors need read-only-safe reordering without a canvas drag gesture. Swap sibling node positions through the shared mobile graph helper so built-ins stay gated, selection remains untouched, and the existing IR save path persists the new position-derived order. + */ + const onMoveSimpleNode = useCallback( + (nodeId: string, direction: WorkflowNodeReorderDirection) => { + setNodes((ns) => reorderWorkflowNode(ns, nodeId, direction)); + }, + [setNodes], + ); + // Dragging a step node into a column band sets node.column (position-based // hit testing against the ordered bands — see workflow-flow-mapping). const onNodeDragStop = useCallback( @@ -2575,6 +2591,8 @@ function InnerEditor({ setSelectedNodeId(null); }} onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection} + canReorder={!isBuiltin} + onMoveNode={onMoveSimpleNode} /> )} diff --git a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts index ddb8d7850e..b1463b2628 100644 --- a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts +++ b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts @@ -47,12 +47,14 @@ describe("MobileWorkflowGraphView CSS contract", () => { const nodeMainActiveRule = findRule([graphCss], /\.mobile-wf-node-main:active\s*\{[^}]*\}/); expect(nodeMainActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/); - const nodeExpandHoverRule = findRule([graphCss], /\.mobile-wf-node-expand:hover\s*\{[^}]*\}/); + const nodeExpandHoverRule = findRule([graphCss], /\.mobile-wf-node-expand:hover,\s*\.mobile-wf-node-move:not\(:disabled\):hover,\s*\.mobile-wf-connect-button:hover\s*\{[^}]*\}/); expect(nodeExpandHoverRule).toMatch(/background\s*:\s*var\(--bg-tertiary\)\s*;/); - const nodeExpandFocusRule = findRule([graphCss], /\.mobile-wf-node-expand:focus-visible\s*\{[^}]*\}/); + const nodeExpandFocusRule = findRule([graphCss], /\.mobile-wf-node-expand:focus-visible,\s*\.mobile-wf-node-move:focus-visible,\s*\.mobile-wf-connect-button:focus-visible,\s*\.mobile-wf-connect-select:focus-visible\s*\{[^}]*\}/); expect(nodeExpandFocusRule).toMatch(/box-shadow\s*:\s*var\(--focus-ring-strong\)\s*;/); - const nodeExpandActiveRule = findRule([graphCss], /\.mobile-wf-node-expand:active\s*\{[^}]*\}/); + const nodeExpandActiveRule = findRule([graphCss], /\.mobile-wf-node-expand:active,\s*\.mobile-wf-node-move:not\(:disabled\):active,\s*\.mobile-wf-connect-button:active\s*\{[^}]*\}/); expect(nodeExpandActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/); + const nodeMoveSizeRule = findRule([graphCss], /\.mobile-wf-node-expand,\s*\.mobile-wf-node-move\s*\{[^}]*\}/); + expect(nodeMoveSizeRule).toMatch(/width\s*:\s*var\(--wf-editor-touch-target\)\s*;/); const edgeChipHoverRule = findRule([graphCss], /\.mobile-wf-edge-chip:hover\s*\{[^}]*\}/); expect(edgeChipHoverRule).toMatch(/background\s*:\s*var\(--bg-secondary\)\s*;/); diff --git a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx index 5fa88f4c87..bd44e301d1 100644 --- a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx @@ -13,6 +13,15 @@ const rows: MobileWorkflowNodeSummary[] = [ outgoing: [{ id: "e1", source: "start", target: "prompt", targetLabel: "Prompt", label: "success" }], children: [], }, + { + id: "prompt", + label: "Prompt", + kind: "prompt", + summary: "Draft prompt", + editable: true, + outgoing: [], + children: [], + }, { id: "loop", label: "Review loop", @@ -22,18 +31,38 @@ const rows: MobileWorkflowNodeSummary[] = [ outgoing: [], children: [ { - id: "loop::child", - label: "Loop step", + id: "loop::child-a", + label: "Loop step A", kind: "prompt", summary: "Not configured", editable: true, parentId: "loop", - templateLocalId: "child", + templateLocalId: "child-a", + outgoing: [], + children: [], + }, + { + id: "loop::child-b", + label: "Loop step B", + kind: "script", + summary: "Not configured", + editable: true, + parentId: "loop", + templateLocalId: "child-b", outgoing: [], children: [], }, ], }, + { + id: "end", + label: "End", + kind: "end", + summary: "", + editable: false, + outgoing: [], + children: [], + }, ]; describe("MobileWorkflowGraphView", () => { @@ -68,8 +97,92 @@ describe("MobileWorkflowGraphView", () => { />, ); - expect(screen.getByTestId("mobile-wf-node-loop::child")).toBeInTheDocument(); + expect(screen.getByTestId("mobile-wf-node-loop::child-a")).toBeInTheDocument(); fireEvent.click(within(screen.getByTestId("mobile-wf-node-loop")).getByRole("button", { name: /collapse/i })); - expect(screen.queryByTestId("mobile-wf-node-loop::child")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mobile-wf-node-loop::child-a")).not.toBeInTheDocument(); + }); + + it("exposes move controls for editable sibling rows and calls the move callback", () => { + const onMoveNode = vi.fn(); + render( + {}} + onSelectEdge={() => {}} + canReorder + onMoveNode={onMoveNode} + />, + ); + + expect(screen.queryByTestId("mobile-wf-node-move-up-start")).not.toBeInTheDocument(); + expect(screen.getByTestId("mobile-wf-node-move-up-prompt")).toBeDisabled(); + fireEvent.click(screen.getByTestId("mobile-wf-node-move-down-prompt")); + expect(onMoveNode).toHaveBeenCalledWith("prompt", "down"); + + fireEvent.click(screen.getByTestId("mobile-wf-node-move-up-loop")); + expect(onMoveNode).toHaveBeenCalledWith("loop", "up"); + expect(screen.getByTestId("mobile-wf-node-move-down-loop")).toBeDisabled(); + expect(screen.queryByTestId("mobile-wf-node-move-down-end")).not.toBeInTheDocument(); + }); + + it("hides move controls for read-only built-ins without empty action shells", () => { + render( + {}} + onSelectEdge={() => {}} + canReorder={false} + onMoveNode={() => {}} + />, + ); + + expect(screen.queryByTestId(/mobile-wf-node-move-/)).not.toBeInTheDocument(); + expect(within(screen.getByTestId("mobile-wf-node-prompt")).queryByRole("button", { name: /move/i })).not.toBeInTheDocument(); + }); + + it("renders template-child move controls with child-level boundaries", () => { + const onMoveNode = vi.fn(); + render( + {}} + onSelectEdge={() => {}} + canReorder + onMoveNode={onMoveNode} + />, + ); + + expect(screen.getByTestId("mobile-wf-node-move-up-loop::child-a")).toBeDisabled(); + fireEvent.click(screen.getByTestId("mobile-wf-node-move-down-loop::child-a")); + expect(onMoveNode).toHaveBeenCalledWith("loop::child-a", "down"); + fireEvent.click(screen.getByTestId("mobile-wf-node-move-up-loop::child-b")); + expect(onMoveNode).toHaveBeenCalledWith("loop::child-b", "up"); + expect(screen.getByTestId("mobile-wf-node-move-down-loop::child-b")).toBeDisabled(); + }); + + it("proves the simple editor reorder symptom is gone through callback controls", () => { + const onMoveNode = vi.fn(); + render( + {}} + onSelectEdge={() => {}} + canReorder + onMoveNode={onMoveNode} + />, + ); + + const movePromptDown = within(screen.getByTestId("mobile-wf-node-prompt")).getByRole("button", { name: "Move down" }); + fireEvent.click(movePromptDown); + + expect(onMoveNode).toHaveBeenCalledWith("prompt", "down"); }); }); diff --git a/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts b/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts index 31e859d21a..037f015736 100644 --- a/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react"; -import { buildMobileWorkflowGraph } from "../workflow-mobile-graph"; +import { buildMobileWorkflowGraph, reorderWorkflowNode } from "../workflow-mobile-graph"; import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; import { columnBandNodeId, foreachChildFlowId } from "../workflow-flow-mapping"; @@ -30,6 +30,79 @@ function edge(id: string, source: string, target: string, condition = "success") }; } +function rowOrder(nodes: FlowNode[]): string[] { + return buildMobileWorkflowGraph(nodes, []).map((row) => row.id); +} + +describe("reorderWorkflowNode", () => { + it("swaps adjacent editable top-level siblings in the same column and re-derives the new order", () => { + const nodes = [ + node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }), + node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }), + node("c", "gate", 0, 160, { data: { kind: "gate", label: "C", column: "todo" } }), + ]; + + const reordered = reorderWorkflowNode(nodes, "b", "up"); + + expect(rowOrder(reordered)).toEqual(["b", "a", "c"]); + expect(reordered.find((n) => n.id === "b")?.position).toEqual({ x: 0, y: 0 }); + expect(reordered.find((n) => n.id === "a")?.position).toEqual({ x: 0, y: 80 }); + }); + + it("does not move past same-group boundaries", () => { + const nodes = [ + node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }), + node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }), + ]; + + expect(reorderWorkflowNode(nodes, "a", "up")).toBe(nodes); + expect(reorderWorkflowNode(nodes, "b", "down")).toBe(nodes); + }); + + it("does not move top-level nodes across column groups", () => { + const nodes = [ + node("todo-a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }), + node("doing-a", "script", 0, 80, { data: { kind: "script", label: "B", column: "doing" } }), + ]; + + expect(reorderWorkflowNode(nodes, "todo-a", "down")).toBe(nodes); + expect(rowOrder(reorderWorkflowNode(nodes, "doing-a", "up"))).toEqual(["todo-a", "doing-a"]); + }); + + it("reorders template children only within the same parent", () => { + const first = foreachChildFlowId("each", "first"); + const second = foreachChildFlowId("each", "second"); + const other = foreachChildFlowId("other", "first"); + const nodes = [ + node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each" } }), + node(first, "prompt", 20, 60, { parentId: "each", data: { kind: "prompt", label: "First" } }), + node(second, "script", 20, 120, { parentId: "each", data: { kind: "script", label: "Second" } }), + node("other", "loop", 0, 200, { data: { kind: "loop", label: "Other" } }), + node(other, "prompt", 20, 60, { parentId: "other", data: { kind: "prompt", label: "Other child" } }), + ]; + + const rows = buildMobileWorkflowGraph(reorderWorkflowNode(nodes, second, "up"), []); + + expect(rows.find((row) => row.id === "each")?.children.map((child) => child.id)).toEqual([second, first]); + expect(rows.find((row) => row.id === "other")?.children.map((child) => child.id)).toEqual([other]); + }); + + it("refuses to reorder non-editable nodes or swap with a non-editable neighbor", () => { + const nodes = [ + node("start", "start", 0, 0, { data: { kind: "start", label: "Start", column: "todo" } }), + node("step", "prompt", 0, 80, { data: { kind: "prompt", label: "Step", column: "todo" } }), + node(columnBandNodeId("todo"), "start", -40, 0, { + type: "group", + data: { kind: "start", label: "Todo", column: "todo" }, + }), + ]; + + expect(reorderWorkflowNode(nodes, "start", "down")).toBe(nodes); + expect(reorderWorkflowNode(nodes, "step", "up")).toBe(nodes); + expect(reorderWorkflowNode(nodes, columnBandNodeId("todo"), "down")).toBe(nodes); + }); +}); + describe("buildMobileWorkflowGraph", () => { it("returns ordered linear rows with outgoing edge destinations", () => { const rows = buildMobileWorkflowGraph( diff --git a/packages/dashboard/app/components/workflow-mobile-graph.ts b/packages/dashboard/app/components/workflow-mobile-graph.ts index 2a34d8a97e..66174c242e 100644 --- a/packages/dashboard/app/components/workflow-mobile-graph.ts +++ b/packages/dashboard/app/components/workflow-mobile-graph.ts @@ -56,6 +56,47 @@ function compareNodePosition( return Math.round(a.position.x) - Math.round(b.position.x); } +function isEditableWorkflowNode(node: FlowNode): boolean { + return node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id); +} + +function isSameReorderGroup( + target: FlowNode, + candidate: FlowNode, +): boolean { + if (isColumnBandNode(candidate.id)) return false; + if (target.parentId || candidate.parentId) return target.parentId === candidate.parentId; + return target.data.column === candidate.data.column; +} + +export type WorkflowNodeReorderDirection = "up" | "down"; + +/** + * FNXC:WorkflowSimpleEditor 2026-06-17-02:55: + * Simple-editor order is derived from React Flow positions through compareNodePosition, so move controls must swap sibling positions instead of inventing a second ordering field. Keep moves inside the same column group for top-level nodes and inside the same parent group for template children so the re-derived outline and persisted IR stay consistent with canvas placement. + */ +export function reorderWorkflowNode( + nodes: FlowNode[], + nodeId: string, + direction: WorkflowNodeReorderDirection, +): FlowNode[] { + const target = nodes.find((node) => node.id === nodeId); + if (!target || !isEditableWorkflowNode(target)) return nodes; + + const siblings = nodes + .filter((node) => isSameReorderGroup(target, node)) + .sort(compareNodePosition); + const targetIndex = siblings.findIndex((node) => node.id === nodeId); + const neighbor = siblings[targetIndex + (direction === "up" ? -1 : 1)]; + if (!neighbor || !isEditableWorkflowNode(neighbor)) return nodes; + + return nodes.map((node) => { + if (node.id === target.id) return { ...node, position: { ...neighbor.position } }; + if (node.id === neighbor.id) return { ...node, position: { ...target.position } }; + return node; + }); +} + function buildColumnNameMap(columns: WorkflowIrColumn[], nodes: FlowNode[]) { const names = new Map(columns.map((column) => [column.id, column.name || column.id])); for (const node of nodes) {