diff --git a/src/renderer/components/WorkflowEditor.tsx b/src/renderer/components/WorkflowEditor.tsx index 8ef13b6..25dff13 100644 --- a/src/renderer/components/WorkflowEditor.tsx +++ b/src/renderer/components/WorkflowEditor.tsx @@ -296,6 +296,7 @@ export function WorkflowEditor({ onNodeRemove={handleNodeRemove} selectedEdgeId={selectedEdgeId} selectedNodeId={selectedNodeId} + validationIssues={issues} workflow={workflow} /> diff --git a/src/renderer/components/workflow/ConditionEditor.tsx b/src/renderer/components/workflow/ConditionEditor.tsx new file mode 100644 index 0000000..673f82b --- /dev/null +++ b/src/renderer/components/workflow/ConditionEditor.tsx @@ -0,0 +1,285 @@ +import { useCallback } from 'react'; +import { Info, Plus, Trash2 } from 'lucide-react'; + +import type { EdgeCondition, WorkflowConditionRule } from '@shared/domain/workflow'; + +type ConditionType = 'none' | 'always' | 'property' | 'message-type' | 'expression'; + +interface ConditionEditorProps { + condition: EdgeCondition | undefined; + onChange: (condition: EdgeCondition | undefined) => void; + disabled?: boolean; +} + +const OPERATOR_OPTIONS: { value: WorkflowConditionRule['operator']; label: string }[] = [ + { value: 'equals', label: '=' }, + { value: 'not-equals', label: '≠' }, + { value: 'contains', label: 'contains' }, + { value: 'gt', label: '>' }, + { value: 'lt', label: '<' }, + { value: 'regex', label: 'matches regex' }, +]; + +const inputClasses = + 'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50'; + +const selectClasses = + 'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50'; + +function resolveConditionType(condition: EdgeCondition | undefined): ConditionType { + if (!condition) return 'none'; + return condition.type; +} + +function emptyRule(): WorkflowConditionRule { + return { propertyPath: '', operator: 'equals', value: '' }; +} + +function defaultConditionForType(type: ConditionType): EdgeCondition | undefined { + switch (type) { + case 'none': + return undefined; + case 'always': + return { type: 'always' }; + case 'property': + return { type: 'property', combinator: 'and', rules: [emptyRule()] }; + case 'message-type': + return { type: 'message-type', typeName: '' }; + case 'expression': + return { type: 'expression', expression: '' }; + } +} + +/* ── Property rules editor ─────────────────────────────────── */ + +function PropertyRuleRow({ + rule, + index, + disabled, + onRuleChange, + onRemove, + canRemove, +}: { + rule: WorkflowConditionRule; + index: number; + disabled?: boolean; + onRuleChange: (index: number, patch: Partial) => void; + onRemove: (index: number) => void; + canRemove: boolean; +}) { + return ( +
+ onRuleChange(index, { propertyPath: e.target.value })} + placeholder="property.path" + style={{ flex: 2 }} + value={rule.propertyPath} + /> + + onRuleChange(index, { value: e.target.value })} + placeholder="value" + style={{ flex: 2 }} + value={rule.value} + /> + {canRemove && ( + + )} +
+ ); +} + +function PropertyRulesEditor({ + condition, + disabled, + onChange, +}: { + condition: Extract; + disabled?: boolean; + onChange: (condition: EdgeCondition) => void; +}) { + const rules = condition.rules; + + const handleRuleChange = useCallback( + (index: number, patch: Partial) => { + const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r)); + onChange({ ...condition, rules: updated }); + }, + [rules, condition, onChange], + ); + + const handleRemoveRule = useCallback( + (index: number) => { + onChange({ ...condition, rules: rules.filter((_, i) => i !== index) }); + }, + [rules, condition, onChange], + ); + + const handleAddRule = useCallback(() => { + onChange({ ...condition, rules: [...rules, emptyRule()] }); + }, [rules, condition, onChange]); + + const handleCombinatorChange = useCallback( + (combinator: 'and' | 'or') => { + onChange({ ...condition, combinator }); + }, + [condition, onChange], + ); + + return ( +
+ {rules.length >= 2 && ( +
+ Combine +
+ + +
+
+ )} + + {rules.map((rule, i) => ( + 1} + disabled={disabled} + index={i} + key={i} + onRemove={handleRemoveRule} + onRuleChange={handleRuleChange} + rule={rule} + /> + ))} + + +
+ ); +} + +/* ── Main condition editor ─────────────────────────────────── */ + +export function ConditionEditor({ condition, onChange, disabled }: ConditionEditorProps) { + const currentType = resolveConditionType(condition); + + const handleTypeChange = useCallback( + (type: ConditionType) => { + onChange(defaultConditionForType(type)); + }, + [onChange], + ); + + return ( +
+ + + {condition?.type === 'property' && ( + + )} + + {condition?.type === 'message-type' && ( + + )} + + {condition?.type === 'expression' && ( +
+ +
+ + + Supported: ==, !=, >, <, contains, matches. Combine with && or || + +
+
+ )} +
+ ); +} diff --git a/src/renderer/components/workflow/WorkflowGraphCanvas.tsx b/src/renderer/components/workflow/WorkflowGraphCanvas.tsx index dce494b..8e679f3 100644 --- a/src/renderer/components/workflow/WorkflowGraphCanvas.tsx +++ b/src/renderer/components/workflow/WorkflowGraphCanvas.tsx @@ -33,6 +33,7 @@ import { } from '@renderer/lib/workflowGraph'; import { workflowNodeTypes } from './WorkflowGraphNodes'; +import { workflowEdgeTypes } from './WorkflowGraphEdges'; interface WorkflowGraphCanvasProps { workflow: WorkflowDefinition; @@ -197,6 +198,7 @@ function WorkflowGraphCanvasInner({ onEdgeClick={handleEdgeClick} onPaneClick={handlePaneClick} nodeTypes={workflowNodeTypes} + edgeTypes={workflowEdgeTypes} fitView fitViewOptions={{ padding: 0.3 }} minZoom={0.3} diff --git a/src/renderer/components/workflow/WorkflowGraphEdges.tsx b/src/renderer/components/workflow/WorkflowGraphEdges.tsx new file mode 100644 index 0000000..13b5f4f --- /dev/null +++ b/src/renderer/components/workflow/WorkflowGraphEdges.tsx @@ -0,0 +1,14 @@ +import { BaseEdge, type EdgeProps } from '@xyflow/react'; + +function SelfLoopEdge({ id, sourceX, sourceY, style, markerEnd }: EdgeProps) { + const loopHeight = 60; + const loopWidth = 40; + + const path = `M ${sourceX} ${sourceY} C ${sourceX + loopWidth} ${sourceY - loopHeight}, ${sourceX - loopWidth} ${sourceY - loopHeight}, ${sourceX} ${sourceY}`; + + return ; +} + +export const workflowEdgeTypes = { + selfLoop: SelfLoopEdge, +} as const; diff --git a/src/renderer/components/workflow/WorkflowGraphInspector.tsx b/src/renderer/components/workflow/WorkflowGraphInspector.tsx index 535911e..fddc8b2 100644 --- a/src/renderer/components/workflow/WorkflowGraphInspector.tsx +++ b/src/renderer/components/workflow/WorkflowGraphInspector.tsx @@ -1,4 +1,5 @@ -import { Bot, Code, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react'; +import { useCallback } from 'react'; +import { AlertCircle, Bot, Code, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react'; import { findModel, @@ -7,19 +8,23 @@ import { type ModelDefinition, } from '@shared/domain/models'; import type { + EdgeCondition, WorkflowDefinition, WorkflowEdge, WorkflowNode, WorkflowNodeConfig, + WorkflowValidationIssue, AgentNodeConfig, } from '@shared/domain/workflow'; import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields'; +import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor'; interface WorkflowGraphInspectorProps { availableModels: ReadonlyArray; workflow: WorkflowDefinition; selectedNodeId: string | null; selectedEdgeId: string | null; + validationIssues?: WorkflowValidationIssue[]; onNodeChange: (nodeId: string, patch: Partial) => void; onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void; onNodeRemove: (nodeId: string) => void; @@ -238,13 +243,25 @@ function PlaceholderNodeInspector({ function EdgeInspector({ edge, + validationIssues, onEdgeChange, onEdgeRemove, }: { edge: WorkflowEdge; + validationIssues?: WorkflowValidationIssue[]; onEdgeChange: (edgeId: string, patch: Partial) => void; onEdgeRemove: (edgeId: string) => void; }) { + const isFanIn = edge.kind === 'fan-in'; + const edgeIssues = validationIssues?.filter((i) => i.edgeId === edge.id) ?? []; + + const handleConditionChange = useCallback( + (condition: EdgeCondition | undefined) => { + onEdgeChange(edge.id, { condition }); + }, + [edge.id, onEdgeChange], + ); + return (
@@ -272,12 +289,83 @@ function EdgeInspector({ + {/* Loop controls */} +
+ + {edge.isLoop && ( + + )} +
+ + {/* Condition editor */} +
+ + {isFanIn && ( +

+ Fan-in edges cannot have conditions +

+ )} +
+ onEdgeChange(edge.id, { label: v || undefined })} placeholder="Optional edge label" value={edge.label ?? ''} /> + + {/* Validation issues for this edge */} + {edgeIssues.length > 0 && ( +
+ {edgeIssues.map((issue, i) => ( +
+ + {issue.message} +
+ ))} +
+ )}
); } @@ -289,6 +377,7 @@ export function WorkflowGraphInspector({ workflow, selectedNodeId, selectedEdgeId, + validationIssues, onNodeChange, onNodeConfigChange, onNodeRemove, @@ -305,7 +394,12 @@ export function WorkflowGraphInspector({ if (selectedEdge) { return (
- +
); } diff --git a/src/renderer/lib/workflowGraph.ts b/src/renderer/lib/workflowGraph.ts index 4d4da28..6059f7b 100644 --- a/src/renderer/lib/workflowGraph.ts +++ b/src/renderer/lib/workflowGraph.ts @@ -94,19 +94,42 @@ const EDGE_DASH: Record = { 'fan-in': '2 2', }; +const LOOP_EDGE_COLOR = { stroke: '#c084fc', markerColor: '#c084fc' }; +const LOOP_EDGE_DASH = '8 4'; + +function buildEdgeLabel(edge: WfEdge): string | undefined { + const parts: string[] = []; + + if (edge.condition && edge.condition.type !== 'always') { + parts.push('⚡'); + } + + if (edge.label) { + parts.push(edge.label); + } + + if (edge.isLoop && edge.maxIterations) { + parts.push(`🔁 ×${edge.maxIterations}`); + } + + return parts.length > 0 ? parts.join(' ') : undefined; +} + export function toCanvasEdges(graph: WorkflowGraph): Edge[] { return graph.edges.map((edge) => { - const color = EDGE_COLORS[edge.kind] ?? EDGE_COLORS.direct; - const dashArray = EDGE_DASH[edge.kind]; + const isLoop = edge.isLoop === true; + const isSelfLoop = edge.source === edge.target; + const color = isLoop ? LOOP_EDGE_COLOR : (EDGE_COLORS[edge.kind] ?? EDGE_COLORS.direct); + const dashArray = isLoop ? LOOP_EDGE_DASH : EDGE_DASH[edge.kind]; return { id: edge.id, source: edge.source, target: edge.target, - type: 'default', - animated: edge.kind === 'fan-out', + type: isSelfLoop ? 'selfLoop' : 'default', + animated: isLoop || edge.kind === 'fan-out', deletable: true, - label: edge.label, + label: buildEdgeLabel(edge), markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor }, style: { stroke: color.stroke, @@ -144,10 +167,6 @@ export function isWorkflowConnectionAllowed( return false; } - if (connection.source === connection.target) { - return false; - } - const sourceNode = graph.nodes.find((n) => n.id === connection.source); const targetNode = graph.nodes.find((n) => n.id === connection.target); if (!sourceNode || !targetNode) {