From 871609db5bf6f1dca7e291340f17c96eec693696 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 5 Apr 2026 19:44:02 +0200 Subject: [PATCH] feat(workflows): add sub-workflow inspector, breadcrumb navigation, and reference UI Phase 3 frontend: sub-workflow node inspector with reference/inline mode toggle, breadcrumb drill-down navigation for nested workflows, read-only view for referenced workflows, inline workflow editing with stack propagation, sub-workflow label badges on canvas nodes, and referential integrity error display on delete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/SettingsPanel.tsx | 13 +- src/renderer/components/WorkflowEditor.tsx | 249 ++++++++++++++++-- .../workflow/SubWorkflowInspector.tsx | 228 ++++++++++++++++ .../workflow/WorkflowGraphCanvas.tsx | 8 +- .../workflow/WorkflowGraphInspector.tsx | 21 ++ .../workflow/WorkflowGraphNodes.tsx | 15 +- src/renderer/lib/workflowGraph.ts | 16 ++ 7 files changed, 523 insertions(+), 27 deletions(-) create mode 100644 src/renderer/components/workflow/SubWorkflowInspector.tsx diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 547f072..2169b9e 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -205,8 +205,16 @@ export function SettingsPanel({ onChange={setEditingWorkflow} onDelete={ async () => { - await onDeleteWorkflow(editingWorkflow.id); - setEditingWorkflow(null); + try { + await onDeleteWorkflow(editingWorkflow.id); + setEditingWorkflow(null); + } catch (err) { + window.alert( + err instanceof Error + ? err.message + : 'Cannot delete this workflow. It may be referenced by other workflows.', + ); + } } } onSave={async () => { @@ -214,6 +222,7 @@ export function SettingsPanel({ setEditingWorkflow(null); }} workflow={editingWorkflow} + workflows={workflows} /> ); diff --git a/src/renderer/components/WorkflowEditor.tsx b/src/renderer/components/WorkflowEditor.tsx index 25dff13..43501a1 100644 --- a/src/renderer/components/WorkflowEditor.tsx +++ b/src/renderer/components/WorkflowEditor.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { AlertCircle, CheckCircle, ChevronLeft, Trash2 } from 'lucide-react'; +import { Fragment, useCallback, useMemo, useState } from 'react'; +import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Info, Trash2 } from 'lucide-react'; import type { ModelDefinition } from '@shared/domain/models'; import type { @@ -10,6 +10,7 @@ import type { WorkflowNodeKind, WorkflowEdge, AgentNodeConfig, + SubWorkflowConfig, } from '@shared/domain/workflow'; import { validateWorkflowDefinition } from '@shared/domain/workflow'; import { createId } from '@shared/utils/ids'; @@ -22,12 +23,21 @@ import { WorkflowNodePalette } from './workflow/WorkflowNodePalette'; interface WorkflowEditorProps { availableModels: ReadonlyArray; workflow: WorkflowDefinition; + workflows: ReadonlyArray; onChange: (workflow: WorkflowDefinition) => void; onDelete?: () => void; onSave: () => void; onBack: () => void; } +interface WorkflowBreadcrumb { + workflow: WorkflowDefinition; + nodeId: string; + nodeLabel: string; + /** true when drilling into a referenced (not inline) workflow — view-only */ + readOnly: boolean; +} + function InputField({ label, value, @@ -113,11 +123,36 @@ function defaultLabelForKind(kind: WorkflowNodeKind): string { } } +/* ── Minimal inline workflow factory ────────────────────────── */ + +function createMinimalInlineWorkflow(): WorkflowDefinition { + const now = new Date().toISOString(); + return { + id: createId('inline-wf'), + name: 'Inline Workflow', + description: '', + graph: { + nodes: [ + { id: createId('wf-start'), kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: createId('wf-end'), kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } }, + ], + edges: [], + }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + createdAt: now, + updatedAt: now, + }; +} + /* ── Main editor ───────────────────────────────────────────── */ export function WorkflowEditor({ availableModels, workflow, + workflows, onChange, onDelete, onSave, @@ -125,14 +160,65 @@ export function WorkflowEditor({ }: WorkflowEditorProps) { const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); - const issues = validateWorkflowDefinition(workflow); + const [breadcrumbs, setBreadcrumbs] = useState([]); + + /* ── Active workflow resolution ──────────────────────────── */ + + const activeWorkflow = useMemo(() => { + if (breadcrumbs.length === 0) return workflow; + return breadcrumbs[breadcrumbs.length - 1].workflow; + }, [workflow, breadcrumbs]); + + const isReadOnly = breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1].readOnly; + + const issues = validateWorkflowDefinition(activeWorkflow); + + /* ── Change propagation ─────────────────────────────────── */ + + function propagateActiveChange(updatedActive: WorkflowDefinition) { + if (breadcrumbs.length === 0) { + onChange(updatedActive); + return; + } + + // Walk back up the breadcrumb stack, embedding each level's workflow + // into the parent's sub-workflow node config. + let child = updatedActive; + const newCrumbs = [...breadcrumbs]; + newCrumbs[newCrumbs.length - 1] = { ...newCrumbs[newCrumbs.length - 1], workflow: child }; + + for (let i = newCrumbs.length - 1; i >= 0; i--) { + const crumb = newCrumbs[i]; + const parent = i === 0 ? workflow : newCrumbs[i - 1].workflow; + const updatedParent: WorkflowDefinition = { + ...parent, + graph: { + ...parent.graph, + nodes: parent.graph.nodes.map((n) => { + if (n.id !== crumb.nodeId) return n; + return { + ...n, + config: { kind: 'sub-workflow' as const, inlineWorkflow: child } satisfies SubWorkflowConfig, + }; + }), + }, + }; + child = updatedParent; + if (i > 0) { + newCrumbs[i - 1] = { ...newCrumbs[i - 1], workflow: updatedParent }; + } + } + + setBreadcrumbs(newCrumbs); + onChange(child); + } function emitChange(next: WorkflowDefinition) { - onChange(next); + propagateActiveChange(next); } function emitGraphChange(graph: WorkflowGraph) { - onChange({ ...workflow, graph }); + propagateActiveChange({ ...activeWorkflow, graph }); } function handleAddNode(kind: WorkflowNodeKind) { @@ -145,8 +231,8 @@ export function WorkflowEditor({ config: defaultConfigForKind(kind), }; emitGraphChange({ - ...workflow.graph, - nodes: [...workflow.graph.nodes, newNode], + ...activeWorkflow.graph, + nodes: [...activeWorkflow.graph.nodes, newNode], }); setSelectedNodeId(nodeId); setSelectedEdgeId(null); @@ -154,46 +240,116 @@ export function WorkflowEditor({ function handleNodeChange(nodeId: string, patch: Partial) { emitGraphChange({ - ...workflow.graph, - nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)), + ...activeWorkflow.graph, + nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)), }); } function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) { emitGraphChange({ - ...workflow.graph, - nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)), + ...activeWorkflow.graph, + nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)), }); } function handleNodeRemove(nodeId: string) { - const node = workflow.graph.nodes.find((n) => n.id === nodeId); + const node = activeWorkflow.graph.nodes.find((n) => n.id === nodeId); if (!node || node.kind === 'start' || node.kind === 'end') { return; } emitGraphChange({ - nodes: workflow.graph.nodes.filter((n) => n.id !== nodeId), - edges: workflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId), + nodes: activeWorkflow.graph.nodes.filter((n) => n.id !== nodeId), + edges: activeWorkflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId), }); setSelectedNodeId(null); } function handleEdgeChange(edgeId: string, patch: Partial) { emitGraphChange({ - ...workflow.graph, - edges: workflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)), + ...activeWorkflow.graph, + edges: activeWorkflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)), }); } function handleEdgeRemove(edgeId: string) { emitGraphChange({ - ...workflow.graph, - edges: workflow.graph.edges.filter((e) => e.id !== edgeId), + ...activeWorkflow.graph, + edges: activeWorkflow.graph.edges.filter((e) => e.id !== edgeId), }); setSelectedEdgeId(null); } + /* ── Drill-down ─────────────────────────────────────────── */ + + const handleDrillIntoSubWorkflow = useCallback( + (node: WorkflowNode) => { + if (node.config.kind !== 'sub-workflow') return; + + const config = node.config as SubWorkflowConfig; + + if (config.workflowId) { + // Reference mode — find the referenced workflow and show it read-only + const ref = workflows.find((wf) => wf.id === config.workflowId); + if (!ref) return; + setBreadcrumbs((prev) => [ + ...prev, + { workflow: ref, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: true }, + ]); + } else { + // Inline mode — create a minimal workflow if needed, then drill in + let inlineWf = config.inlineWorkflow; + if (!inlineWf) { + inlineWf = createMinimalInlineWorkflow(); + // Persist the new inline workflow into the node + handleNodeConfigChange(node.id, { kind: 'sub-workflow', inlineWorkflow: inlineWf }); + } + setBreadcrumbs((prev) => [ + ...prev, + { workflow: inlineWf, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: false }, + ]); + } + setSelectedNodeId(null); + setSelectedEdgeId(null); + }, + [workflows, handleNodeConfigChange], + ); + + /* ── Breadcrumb sync: when the top-level workflow changes externally, + re-resolve the breadcrumb stack from the current workflow to keep + inline sub-workflows in sync. ──────────────────────────────────── */ + + // Kept simple: if breadcrumbs exist and the innermost is inline, + // re-resolve the active workflow from the current top-level workflow + // so external saves don't desync. Referenced (read-only) crumbs + // already point to a stable workflow object. + useMemo(() => { + if (breadcrumbs.length === 0) return; + let current: WorkflowDefinition = workflow; + const synced: WorkflowBreadcrumb[] = []; + for (const crumb of breadcrumbs) { + if (crumb.readOnly) { + synced.push(crumb); + continue; + } + const parentNode = current.graph.nodes.find((n) => n.id === crumb.nodeId); + if ( + !parentNode || + parentNode.config.kind !== 'sub-workflow' || + !(parentNode.config as SubWorkflowConfig).inlineWorkflow + ) { + // Breadcrumb target no longer exists — pop remaining crumbs + break; + } + const resolved = (parentNode.config as SubWorkflowConfig).inlineWorkflow!; + synced.push({ ...crumb, workflow: resolved }); + current = resolved; + } + if (synced.length !== breadcrumbs.length || synced.some((s, i) => s.workflow !== breadcrumbs[i].workflow)) { + setBreadcrumbs(synced); + } + }, [workflow]); // intentionally excluding breadcrumbs to avoid loops + return (
{/* Header */} @@ -234,6 +390,52 @@ export function WorkflowEditor({
+ {/* Breadcrumb bar */} + {breadcrumbs.length > 0 && ( +
+ + {breadcrumbs.map((crumb, index) => ( + + + + + ))} +
+ )} + + {/* Read-only banner for referenced sub-workflows */} + {isReadOnly && ( +
+ + + This is a referenced workflow. Open it separately to edit. + + +
+ )} + {/* Body — palette + canvas + inspector */}
{/* Left palette */} @@ -277,18 +479,22 @@ export function WorkflowEditor({ onGraphChange={emitGraphChange} onNodeSelect={setSelectedNodeId} selectedNodeId={selectedNodeId} - workflow={workflow} + workflow={activeWorkflow} + workflows={workflows} />
{/* Settings below canvas */} - + {breadcrumbs.length === 0 && ( + + )} {/* Right inspector */}
diff --git a/src/renderer/components/workflow/SubWorkflowInspector.tsx b/src/renderer/components/workflow/SubWorkflowInspector.tsx new file mode 100644 index 0000000..8f41c01 --- /dev/null +++ b/src/renderer/components/workflow/SubWorkflowInspector.tsx @@ -0,0 +1,228 @@ +import { useCallback } from 'react'; +import { ExternalLink, GitBranch, Link, Pencil, Trash2 } from 'lucide-react'; + +import type { + SubWorkflowConfig, + WorkflowDefinition, + WorkflowNode, + WorkflowNodeConfig, +} from '@shared/domain/workflow'; + +interface SubWorkflowInspectorProps { + node: WorkflowNode; + workflows: ReadonlyArray; + currentWorkflowId: string; + onNodeChange: (nodeId: string, patch: Partial) => void; + onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void; + onNodeRemove: (nodeId: string) => void; + onDrillIntoSubWorkflow: (node: WorkflowNode) => void; +} + +type SourceMode = 'reference' | 'inline'; + +function resolveSourceMode(config: SubWorkflowConfig): SourceMode { + if (config.workflowId) return 'reference'; + if (config.inlineWorkflow) return 'inline'; + return 'reference'; +} + +function InputField({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) { + return ( + + ); +} + +export function SubWorkflowInspector({ + node, + workflows, + currentWorkflowId, + onNodeChange, + onNodeConfigChange, + onNodeRemove, + onDrillIntoSubWorkflow, +}: SubWorkflowInspectorProps) { + const config = node.config as SubWorkflowConfig; + const sourceMode = resolveSourceMode(config); + + const availableWorkflows = workflows.filter((wf) => wf.id !== currentWorkflowId); + const selectedWorkflow = config.workflowId + ? workflows.find((wf) => wf.id === config.workflowId) + : undefined; + + const handleModeChange = useCallback( + (mode: SourceMode) => { + if (mode === 'reference') { + onNodeConfigChange(node.id, { kind: 'sub-workflow', workflowId: config.workflowId }); + } else { + onNodeConfigChange(node.id, { + kind: 'sub-workflow', + inlineWorkflow: config.inlineWorkflow, + }); + } + }, + [node.id, config.workflowId, config.inlineWorkflow, onNodeConfigChange], + ); + + const handleWorkflowSelect = useCallback( + (workflowId: string) => { + onNodeConfigChange(node.id, { + kind: 'sub-workflow', + workflowId: workflowId || undefined, + }); + }, + [node.id, onNodeConfigChange], + ); + + const handleDrillIn = useCallback(() => { + onDrillIntoSubWorkflow(node); + }, [node, onDrillIntoSubWorkflow]); + + const inlineNodeCount = config.inlineWorkflow?.graph?.nodes?.length ?? 0; + const inlineEdgeCount = config.inlineWorkflow?.graph?.edges?.length ?? 0; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+ {node.label || 'Sub-Workflow'} +
+
+ +
+ + {/* Label */} + onNodeChange(node.id, { label: v })} + placeholder="Display label" + value={node.label} + /> + + {/* Source mode selector */} +
+ Source +
+ + +
+
+ + {/* Reference mode */} + {sourceMode === 'reference' && ( +
+ + + {selectedWorkflow?.description && ( +

+ {selectedWorkflow.description} +

+ )} + + +
+ )} + + {/* Inline mode */} + {sourceMode === 'inline' && ( +
+ {config.inlineWorkflow ? ( +
+ + + {inlineNodeCount} node{inlineNodeCount !== 1 ? 's' : ''},{' '} + {inlineEdgeCount} edge{inlineEdgeCount !== 1 ? 's' : ''} + +
+ ) : ( +

+ No inline workflow configured yet. +

+ )} + + +
+ )} +
+ ); +} diff --git a/src/renderer/components/workflow/WorkflowGraphCanvas.tsx b/src/renderer/components/workflow/WorkflowGraphCanvas.tsx index 8e679f3..11c978a 100644 --- a/src/renderer/components/workflow/WorkflowGraphCanvas.tsx +++ b/src/renderer/components/workflow/WorkflowGraphCanvas.tsx @@ -38,6 +38,7 @@ import { workflowEdgeTypes } from './WorkflowGraphEdges'; interface WorkflowGraphCanvasProps { workflow: WorkflowDefinition; availableModels?: ReadonlyArray; + workflows?: ReadonlyArray; onGraphChange: (graph: WorkflowGraph) => void; onNodeSelect: (nodeId: string | null) => void; onEdgeSelect: (edgeId: string | null) => void; @@ -47,6 +48,7 @@ interface WorkflowGraphCanvasProps { function WorkflowGraphCanvasInner({ workflow, availableModels, + workflows, onGraphChange, onNodeSelect, onEdgeSelect, @@ -57,16 +59,16 @@ function WorkflowGraphCanvasInner({ const draggingRef = useRef(false); const [nodes, setNodes, onNodesChangeBase] = useNodesState( - toCanvasNodes(graph, availableModels), + toCanvasNodes(graph, availableModels, workflows), ); const [edges, setEdges, onEdgesChangeBase] = useEdgesState( toCanvasEdges(graph), ); useEffect(() => { - setNodes(toCanvasNodes(graph, availableModels)); + setNodes(toCanvasNodes(graph, availableModels, workflows)); setEdges(toCanvasEdges(graph)); - }, [graph, availableModels, setNodes, setEdges]); + }, [graph, availableModels, workflows, setNodes, setEdges]); const handleNodesChange: OnNodesChange> = useCallback( (changes) => { diff --git a/src/renderer/components/workflow/WorkflowGraphInspector.tsx b/src/renderer/components/workflow/WorkflowGraphInspector.tsx index fddc8b2..1781242 100644 --- a/src/renderer/components/workflow/WorkflowGraphInspector.tsx +++ b/src/renderer/components/workflow/WorkflowGraphInspector.tsx @@ -18,10 +18,12 @@ import type { } from '@shared/domain/workflow'; import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields'; import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor'; +import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector'; interface WorkflowGraphInspectorProps { availableModels: ReadonlyArray; workflow: WorkflowDefinition; + workflows: ReadonlyArray; selectedNodeId: string | null; selectedEdgeId: string | null; validationIssues?: WorkflowValidationIssue[]; @@ -30,6 +32,7 @@ interface WorkflowGraphInspectorProps { onNodeRemove: (nodeId: string) => void; onEdgeChange: (edgeId: string, patch: Partial) => void; onEdgeRemove: (edgeId: string) => void; + onDrillIntoSubWorkflow: (node: WorkflowNode) => void; } function InputField({ @@ -375,6 +378,7 @@ function EdgeInspector({ export function WorkflowGraphInspector({ availableModels, workflow, + workflows, selectedNodeId, selectedEdgeId, validationIssues, @@ -383,6 +387,7 @@ export function WorkflowGraphInspector({ onNodeRemove, onEdgeChange, onEdgeRemove, + onDrillIntoSubWorkflow, }: WorkflowGraphInspectorProps) { const selectedNode = selectedNodeId ? workflow.graph.nodes.find((n) => n.id === selectedNodeId) @@ -436,6 +441,22 @@ export function WorkflowGraphInspector({ ); } + if (selectedNode.kind === 'sub-workflow') { + return ( +
+ +
+ ); + } + return (
{ if (isAgent && data.provider) { @@ -62,6 +63,18 @@ function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; {isAgent && data.modelLabel && (
{data.modelLabel}
)} + {isSubWorkflow && data.subWorkflowLabel && ( +
+ {data.subWorkflowLabel === 'Inline' ? ( + Inline + ) : ( + + + {data.subWorkflowLabel} + + )} +
+ )}
); diff --git a/src/renderer/lib/workflowGraph.ts b/src/renderer/lib/workflowGraph.ts index 6059f7b..d81102d 100644 --- a/src/renderer/lib/workflowGraph.ts +++ b/src/renderer/lib/workflowGraph.ts @@ -8,6 +8,7 @@ import type { WorkflowGraph, WorkflowNode, WorkflowNodeKind, + SubWorkflowConfig, } from '@shared/domain/workflow'; import type { ModelProvider } from '@shared/domain/models'; import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models'; @@ -21,6 +22,8 @@ export interface WorkflowGraphNodeData extends Record { provider?: ModelProvider; /** Short display name for the agent's model (agent nodes only). */ modelLabel?: string; + /** Sub-workflow label: referenced workflow name or "Inline" (sub-workflow nodes only). */ + subWorkflowLabel?: string; } /* ── View-model projection ─────────────────────────────────── */ @@ -47,10 +50,12 @@ function resolveNodeType(kind: WorkflowNodeKind): string { export function toCanvasNodes( graph: WorkflowGraph, models?: ReadonlyArray, + workflows?: ReadonlyArray, ): Node[] { return graph.nodes.map((node) => { let provider: ModelProvider | undefined; let modelLabel: string | undefined; + let subWorkflowLabel: string | undefined; if (node.kind === 'agent' && node.config.kind === 'agent') { const modelId = node.config.model; @@ -61,6 +66,16 @@ export function toCanvasNodes( } } + if (node.kind === 'sub-workflow' && node.config.kind === 'sub-workflow') { + const swConfig = node.config as SubWorkflowConfig; + if (swConfig.workflowId && workflows) { + const ref = workflows.find((wf) => wf.id === swConfig.workflowId); + subWorkflowLabel = ref?.name ?? swConfig.workflowId; + } else if (swConfig.inlineWorkflow) { + subWorkflowLabel = 'Inline'; + } + } + const isSystemNode = node.kind === 'start' || node.kind === 'end'; return { @@ -72,6 +87,7 @@ export function toCanvasNodes( kind: node.kind, provider, modelLabel, + subWorkflowLabel, }, draggable: true, selectable: true,