diff --git a/.changeset/fn-6523-mobile-workflow-connect.md b/.changeset/fn-6523-mobile-workflow-connect.md new file mode 100644 index 0000000000..dfcf3010a4 --- /dev/null +++ b/.changeset/fn-6523-mobile-workflow-connect.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Enable creating workflow node connections from the mobile workflow editor. diff --git a/docs/workflow-editor.md b/docs/workflow-editor.md index a2fc6977e1..efaf14906d 100644 --- a/docs/workflow-editor.md +++ b/docs/workflow-editor.md @@ -61,7 +61,7 @@ Some nodes expose specialized inspector fields: for example prompt execution det ## Edges, conditions, and rework -Create edges by connecting node handles on the graph. A new connection defaults to a **success** edge. The edge inspector lets you edit routing details when the source node supports it: +Create edges by connecting node handles on the graph. In the mobile and compact simple graph, use a node's **Connect** action and target picker to create the same edge without dragging on the canvas; built-in workflows hide this mutation control because they are read-only. A new connection defaults to a **success** edge. The edge inspector lets you edit routing details when the source node supports it: - **Success / failure conditions:** prompt, script, gate, code, and for-each style sources can route on `success` or `failure`. - **Outcome conditions:** review-style nodes route verdicts as `outcome:` values. The shipped verdict list is `approve`, `revise`, `rethink`, and `unavailable`. diff --git a/packages/dashboard/app/components/MobileWorkflowGraphView.css b/packages/dashboard/app/components/MobileWorkflowGraphView.css index adb7886f5c..6b635cc6d1 100644 --- a/packages/dashboard/app/components/MobileWorkflowGraphView.css +++ b/packages/dashboard/app/components/MobileWorkflowGraphView.css @@ -19,13 +19,24 @@ padding-left: calc(var(--mobile-wf-depth, 0) * var(--space-md)); } +.mobile-wf-node-actions { + display: inline-flex; + align-items: stretch; + gap: var(--space-xs); +} + +.mobile-wf-node-actions--connect { + justify-content: flex-end; + padding-left: calc((var(--mobile-wf-depth, 0) * var(--space-md)) + var(--space-xs)); +} + .mobile-wf-node-main { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-sm); min-width: 0; - min-height: var(--wf-editor-touch-target, 44px); + min-height: var(--wf-editor-touch-target); padding: var(--space-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -94,12 +105,12 @@ font-size: 0.78rem; } -.mobile-wf-node-expand { +.mobile-wf-node-expand, +.mobile-wf-connect-button { display: inline-flex; align-items: center; justify-content: center; - width: var(--wf-editor-touch-target, 44px); - min-height: var(--wf-editor-touch-target, 44px); + min-height: var(--wf-editor-touch-target); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-secondary); @@ -111,19 +122,61 @@ box-shadow var(--transition-fast); } -.mobile-wf-node-expand:hover { +.mobile-wf-node-expand { + width: var(--wf-editor-touch-target); +} + +.mobile-wf-connect-button { + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); +} + +.mobile-wf-node-expand:hover, +.mobile-wf-connect-button:hover { background: var(--bg-tertiary); } -.mobile-wf-node-expand:focus-visible { +.mobile-wf-node-expand:focus-visible, +.mobile-wf-connect-button:focus-visible, +.mobile-wf-connect-select:focus-visible { outline: none; box-shadow: var(--focus-ring-strong); } -.mobile-wf-node-expand:active { +.mobile-wf-node-expand:active, +.mobile-wf-connect-button:active { transform: scale(0.97); } +.mobile-wf-connect-picker { + display: grid; + gap: var(--space-xs); + padding-left: calc((var(--mobile-wf-depth, 0) * var(--space-md)) + var(--space-xs)); +} + +.mobile-wf-connect-label { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.mobile-wf-connect-select { + width: 100%; +} + +@media (max-width: 768px) { + .mobile-wf-node-row { + grid-template-columns: minmax(0, 1fr); + } + + .mobile-wf-node-actions { + justify-content: flex-end; + } + + .mobile-wf-connect-button { + flex: 1; + } +} + .mobile-wf-node-meta { display: flex; flex-wrap: wrap; diff --git a/packages/dashboard/app/components/MobileWorkflowGraphView.tsx b/packages/dashboard/app/components/MobileWorkflowGraphView.tsx index a6e3f8225c..64a8f61640 100644 --- a/packages/dashboard/app/components/MobileWorkflowGraphView.tsx +++ b/packages/dashboard/app/components/MobileWorkflowGraphView.tsx @@ -10,6 +10,7 @@ interface MobileWorkflowGraphViewProps { selectedEdgeId?: string | null; onSelectNode: (id: string) => void; onSelectEdge: (id: string) => void; + onCreateConnection?: (source: string, target: string) => void; } function NodeRow({ @@ -19,6 +20,7 @@ function NodeRow({ selectedEdgeId, onSelectNode, onSelectEdge, + onCreateConnection, }: { row: MobileWorkflowNodeSummary; depth: number; @@ -26,11 +28,15 @@ function NodeRow({ selectedEdgeId?: string | null; onSelectNode: (id: string) => void; onSelectEdge: (id: string) => void; + onCreateConnection?: (source: string, target: string) => void; }) { const { t } = useTranslation("app"); const hasChildren = row.children.length > 0; const [expanded, setExpanded] = useState(depth === 0); + const [connectPickerOpen, setConnectPickerOpen] = useState(false); const selected = selectedNodeId === row.id; + const connectionTargets = row.connectionTargets ?? []; + const canCreateConnection = !!onCreateConnection && row.editable && connectionTargets.length > 0; return (
@@ -53,17 +59,70 @@ function NodeRow({ {row.editable ? : null} {hasChildren ? ( - +
+ +
) : null}
+ {canCreateConnection ? ( +
+ +
+ ) : null} + {/* + FNXC:WorkflowEditor 2026-06-16-23:45: + Mobile and compact simple editing do not render the React Flow canvas, so drag-to-connect handles are unavailable. This picker gives touch users a non-canvas path while the editor still owns edge validation and construction. + */} + {canCreateConnection && connectPickerOpen ? ( +
+ + +
+ ) : null} {(row.columnName || row.outgoing.length > 0) && (
))}
@@ -110,6 +170,7 @@ export function MobileWorkflowGraphView({ selectedEdgeId, onSelectNode, onSelectEdge, + onCreateConnection, }: MobileWorkflowGraphViewProps) { const { t } = useTranslation("app"); if (rows.length === 0) { @@ -131,6 +192,7 @@ export function MobileWorkflowGraphView({ selectedEdgeId={selectedEdgeId} onSelectNode={onSelectNode} onSelectEdge={onSelectEdge} + onCreateConnection={onCreateConnection} /> ))} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 9e17eeafbe..baa6566634 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -85,7 +85,7 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView"; -import { buildMobileWorkflowGraph } from "./workflow-mobile-graph"; +import { buildMobileWorkflowGraph, type MobileWorkflowConnectionTarget } from "./workflow-mobile-graph"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions"; @@ -1234,8 +1234,8 @@ function InnerEditor({ // which dedupes on source/target/handles and would block parallel // success+failure edges between the same pair (KTD-3). buildConnectionEdge // reimplements addEdge's sanity guards plus the author-time cycle guard (KTD-9). - const onConnect = useCallback( - (connection: Connection) => { + const createConnectionEdge = useCallback( + (connection: Connection, options: { selectCreatedEdge?: boolean } = {}) => { const result = buildConnectionEdge(connection, edges, nodes); if ("error" in result) { if (result.error === "cycle") { @@ -1246,14 +1246,38 @@ function InnerEditor({ ), "warning", ); + } else if (result.error === "duplicate") { + addToast( + t("workflowNodes.duplicateBlocked", "That connection already exists"), + "warning", + ); } return; } setEdges((eds) => [...eds, result.edge]); + if (options.selectCreatedEdge) { + setSelectedEdgeId(result.edge.id); + setSelectedNodeId(null); + setInspectorCollapsed(false); + } }, [edges, nodes, setEdges, addToast, t], ); + const onConnect = useCallback( + (connection: Connection) => { + createConnectionEdge(connection); + }, + [createConnectionEdge], + ); + + const onCreateSimpleConnection = useCallback( + (source: string, target: string) => { + createConnectionEdge({ source, target, sourceHandle: null, targetHandle: null }, { selectCreatedEdge: true }); + }, + [createConnectionEdge], + ); + // 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( @@ -1974,10 +1998,40 @@ function InnerEditor({ }), [models, agents, skills], ); - const mobileGraphRows = useMemo( - () => buildMobileWorkflowGraph(nodesForRender, edges, columns, catalogs, t), - [nodesForRender, edges, columns, catalogs, t], - ); + const mobileConnectionTargetsBySource = useMemo(() => { + const targetNodes = nodesForRender + .filter((node) => !isColumnBandNode(node.id) && node.data.kind !== "start") + .map((node): MobileWorkflowConnectionTarget => ({ + id: node.id, + label: node.data.label || node.id, + kind: node.data.kind, + })); + + const targetsBySource = new Map(); + for (const source of nodesForRender) { + if ( + isColumnBandNode(source.id) + || source.data.kind === "start" + || source.data.kind === "end" + ) { + continue; + } + const targets = targetNodes.filter((target) => target.id !== source.id); + if (targets.length > 0) targetsBySource.set(source.id, targets); + } + return targetsBySource; + }, [nodesForRender]); + + const mobileGraphRows = useMemo(() => { + const attachConnectionTargets = (rows: ReturnType): ReturnType => + rows.map((row) => ({ + ...row, + connectionTargets: isBuiltin ? [] : mobileConnectionTargetsBySource.get(row.id) ?? [], + children: attachConnectionTargets(row.children), + })); + + return attachConnectionTargets(buildMobileWorkflowGraph(nodesForRender, edges, columns, catalogs, t)); + }, [nodesForRender, edges, columns, catalogs, t, isBuiltin, mobileConnectionTargetsBySource]); const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model"; @@ -2520,6 +2574,7 @@ function InnerEditor({ setSelectedEdgeId(id); setSelectedNodeId(null); }} + onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection} /> )} diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index b4a166de8a..357a08f005 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -290,6 +290,41 @@ function scriptDef(): WorkflowDefinition { }; } +function plainConnectDef(): WorkflowDefinition { + return { + id: "WF-PLAIN-CONNECT", + kind: "workflow", + name: "Plain connect", + description: "", + ir: { + version: "v2", + name: "Plain connect", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "draft", kind: "step-review", column: "triage", config: { type: "code" } }, + { id: "review", kind: "prompt", column: "triage", config: { prompt: "review" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "draft", condition: "success" }, + { from: "review", to: "end", condition: "success" }, + ], + }, + layout: { + start: { x: 0, y: 20 }, + draft: { x: 120, y: 60 }, + review: { x: 240, y: 120 }, + 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(); @@ -436,6 +471,85 @@ describe("WorkflowNodeEditor", () => { expect(screen.getByTestId("wf-mobile-add-gate-gate")).toBeInTheDocument(); }); + it("creates a condition-capable edge from the mobile simple graph without the canvas", async () => { + mockWorkflowEditorViewport("mobile"); + vi.mocked(fetchWorkflows).mockResolvedValue([def()]); + + render( {}} addToast={() => {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "QA" })); + await screen.findByText("Save"); + const shell = await screen.findByTestId("wf-mobile-shell"); + expect(within(shell).queryByTestId("rf__wrapper")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mobile-wf-connect-start")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mobile-wf-connect-end")).not.toBeInTheDocument(); + + fireEvent.click(await screen.findByTestId("mobile-wf-connect-lint")); + fireEvent.change(screen.getByTestId("mobile-wf-connect-target-lint"), { target: { value: "end" } }); + + const inspector = await screen.findByTestId("wf-edge-inspector"); + expect(within(inspector).getByTestId("wf-edge-condition")).toHaveValue("success"); + expect(screen.getAllByText("end").length).toBeGreaterThan(1); + }); + + it("creates a verdict-source edge in the desktop compact simple graph", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([plainConnectDef()]); + + render( {}} addToast={() => {}} />); + + expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Plain connect"); + fireEvent.click(screen.getByTestId("wf-layout-toggle")); + await screen.findByTestId("wf-mobile-shell"); + + fireEvent.click(await screen.findByTestId("mobile-wf-connect-draft")); + fireEvent.change(screen.getByTestId("mobile-wf-connect-target-draft"), { target: { value: "review" } }); + + const inspector = await screen.findByTestId("wf-edge-inspector"); + expect(within(inspector).queryByTestId("wf-edge-condition")).not.toBeInTheDocument(); + expect(within(inspector).getByTestId("wf-edge-verdict")).toHaveValue(""); + }); + + it("hides simple-graph connection controls for built-in read-only workflows", async () => { + mockWorkflowEditorViewport("mobile"); + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + + render( {}} addToast={() => {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "Default coding workflow" })); + await screen.findByTestId("wf-mobile-shell"); + expect(screen.queryByTestId(/mobile-wf-connect-/)).not.toBeInTheDocument(); + }); + + it("rejects cyclic simple-graph connections with a toast", async () => { + mockWorkflowEditorViewport("mobile"); + const addToast = vi.fn(); + vi.mocked(fetchWorkflows).mockResolvedValue([def()]); + + render( {}} addToast={addToast} />); + + fireEvent.click(await screen.findByRole("button", { name: "QA" })); + await screen.findByTestId("wf-mobile-shell"); + + fireEvent.click(await screen.findByTestId("mobile-wf-connect-merge")); + fireEvent.change(screen.getByTestId("mobile-wf-connect-target-merge"), { target: { value: "lint" } }); + await waitFor(() => expect(addToast).toHaveBeenCalledWith( + "That connection would create a cycle — only rework edges inside a for-each template may loop back", + "warning", + )); + expect(screen.queryByTestId("wf-edge-inspector")).not.toBeInTheDocument(); + }); + + it("offers connection controls for editable foreach template children in the simple graph", async () => { + mockWorkflowEditorViewport("mobile"); + vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]); + + render( {}} addToast={() => {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "Stepwise" })); + await screen.findByTestId("wf-mobile-shell"); + expect(await screen.findByTestId(`mobile-wf-connect-${foreachChildFlowId("loop", "exec")}`)).toBeInTheDocument(); + }); + it("surfaces built-in simple-editor actions at desktop width", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); diff --git a/packages/dashboard/app/components/workflow-mobile-graph.ts b/packages/dashboard/app/components/workflow-mobile-graph.ts index d76bf4cce6..2a34d8a97e 100644 --- a/packages/dashboard/app/components/workflow-mobile-graph.ts +++ b/packages/dashboard/app/components/workflow-mobile-graph.ts @@ -17,6 +17,12 @@ export interface MobileWorkflowEdgeSummary { kind?: string; } +export interface MobileWorkflowConnectionTarget { + id: string; + label: string; + kind: WorkflowFlowNodeData["kind"]; +} + export interface MobileWorkflowNodeSummary { id: string; label: string; @@ -27,6 +33,7 @@ export interface MobileWorkflowNodeSummary { parentId?: string; templateLocalId?: string; outgoing: MobileWorkflowEdgeSummary[]; + connectionTargets?: MobileWorkflowConnectionTarget[]; children: MobileWorkflowNodeSummary[]; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index edd3bb6e58..f7ef1e68db 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6905,6 +6905,10 @@ "gateBlocks": "Gate (blocks)", "gateMode": "Gate mode", "insertTemplate": "Insert template {{name}}", + "mobileConnect": "Connect", + "mobileConnectTarget": "Target node", + "mobileConnectChooseTarget": "Choose a target…", + "duplicateBlocked": "That connection already exists", "interpreterOnly": "This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.", "joinAll": "All branches", "joinAny": "Any branch", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 3492c793ea..fb67c15cd7 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -6792,6 +6792,10 @@ "summaryReviewType": "Revisión {{type}}", "trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo.", "insertTemplate": "Insertar plantilla {{name}}", + "mobileConnect": "Conectar", + "mobileConnectTarget": "Nodo de destino", + "mobileConnectChooseTarget": "Elige un destino…", + "duplicateBlocked": "Esa conexión ya existe", "templateFilterLabel": "Filtrar plantillas", "templateFilterPlaceholder": "Filtrar plantillas", "templateSeamConflict": "Este fragmento duplica la unión \"{{seam}}\" que ya está en el lienzo, por lo que no se puede insertar.", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 1195ed1917..b04cb02dde 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6792,6 +6792,10 @@ "summaryReviewType": "Examen {{type}}", "trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer.", "insertTemplate": "Insérer le modèle {{name}}", + "mobileConnect": "Connecter", + "mobileConnectTarget": "Nœud cible", + "mobileConnectChooseTarget": "Choisissez une cible…", + "duplicateBlocked": "Cette connexion existe déjà", "templateFilterLabel": "Filtrer les modèles", "templateFilterPlaceholder": "Filtrer les modèles", "templateSeamConflict": "Ce fragment duplique la jointure « {{seam}} » déjà présente sur le canevas, il ne peut donc pas être inséré.", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 18892afaed..17467ac0c2 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -6792,6 +6792,10 @@ "summaryReviewType": "{{type}} 검토", "trivialGraphHint": "이 워크플로는 시작 → 끝만 실행합니다. 위 팔레트에서 단계를 추가하여 구성하세요.", "insertTemplate": "{{name}} 템플릿 삽입", + "mobileConnect": "연결", + "mobileConnectTarget": "대상 노드", + "mobileConnectChooseTarget": "대상을 선택하세요…", + "duplicateBlocked": "해당 연결이 이미 있습니다", "templateFilterLabel": "템플릿 필터", "templateFilterPlaceholder": "템플릿 필터", "templateSeamConflict": "이 조각은 이미 캔버스에 있는 \"{{seam}}\" 이음새와 중복되므로 삽입할 수 없습니다.", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 4dc490a62b..bb76b0c35e 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -6792,6 +6792,10 @@ "summaryReviewType": "{{type}} 审查", "trivialGraphHint": "此工作流仅运行开始→结束。从上方面板添加步骤以构建工作流。", "insertTemplate": "插入模板 {{name}}", + "mobileConnect": "连接", + "mobileConnectTarget": "目标节点", + "mobileConnectChooseTarget": "选择目标…", + "duplicateBlocked": "该连接已存在", "templateFilterLabel": "筛选模板", "templateFilterPlaceholder": "筛选模板", "templateSeamConflict": "此片段与画布上已存在的“{{seam}}”接缝重复,无法插入。", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index f008ffbc41..ee2638c565 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -6792,6 +6792,10 @@ "summaryReviewType": "{{type}} 審查", "trivialGraphHint": "這個工作流程只會執行 start → end。請從上方的選盤新增步驟來建構它。", "insertTemplate": "插入範本 {{name}}", + "mobileConnect": "連接", + "mobileConnectTarget": "目標節點", + "mobileConnectChooseTarget": "選擇目標…", + "duplicateBlocked": "該連接已存在", "templateFilterLabel": "篩選範本", "templateFilterPlaceholder": "篩選範本", "templateSeamConflict": "這個片段與畫布上已有的「{{seam}}」接縫重複,因此無法插入。",