feat(workflows): add condition editor, loop controls, and edge visualization

Phase 2 frontend: visual condition editor with property rule builder, expression
input, message-type selector, loop toggle with max iterations, self-loop edge
rendering, conditional/loop edge badges, and per-edge validation display.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-05 19:08:11 +02:00
co-authored by Copilot
parent 8f6830dca1
commit ea9444ddac
6 changed files with 426 additions and 11 deletions
@@ -296,6 +296,7 @@ export function WorkflowEditor({
onNodeRemove={handleNodeRemove}
selectedEdgeId={selectedEdgeId}
selectedNodeId={selectedNodeId}
validationIssues={issues}
workflow={workflow}
/>
</div>
@@ -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<WorkflowConditionRule>) => void;
onRemove: (index: number) => void;
canRemove: boolean;
}) {
return (
<div className="flex items-center gap-1.5">
<input
className={inputClasses}
disabled={disabled}
onChange={(e) => onRuleChange(index, { propertyPath: e.target.value })}
placeholder="property.path"
style={{ flex: 2 }}
value={rule.propertyPath}
/>
<select
className={selectClasses}
disabled={disabled}
onChange={(e) =>
onRuleChange(index, { operator: e.target.value as WorkflowConditionRule['operator'] })
}
style={{ flex: 1.2 }}
value={rule.operator}
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
<input
className={inputClasses}
disabled={disabled}
onChange={(e) => onRuleChange(index, { value: e.target.value })}
placeholder="value"
style={{ flex: 2 }}
value={rule.value}
/>
{canRemove && (
<button
className="flex size-6 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)] disabled:pointer-events-none disabled:opacity-50"
disabled={disabled}
onClick={() => onRemove(index)}
title="Remove rule"
type="button"
>
<Trash2 className="size-3" />
</button>
)}
</div>
);
}
function PropertyRulesEditor({
condition,
disabled,
onChange,
}: {
condition: Extract<EdgeCondition, { type: 'property' }>;
disabled?: boolean;
onChange: (condition: EdgeCondition) => void;
}) {
const rules = condition.rules;
const handleRuleChange = useCallback(
(index: number, patch: Partial<WorkflowConditionRule>) => {
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 (
<div className="space-y-2">
{rules.length >= 2 && (
<div className="flex items-center gap-2">
<span className="text-[11px] text-[var(--color-text-muted)]">Combine</span>
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]">
<button
className={`px-2.5 py-1 text-[11px] font-medium transition ${
(condition.combinator ?? 'and') === 'and'
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
disabled={disabled}
onClick={() => handleCombinatorChange('and')}
type="button"
>
AND
</button>
<button
className={`px-2.5 py-1 text-[11px] font-medium transition ${
condition.combinator === 'or'
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
disabled={disabled}
onClick={() => handleCombinatorChange('or')}
type="button"
>
OR
</button>
</div>
</div>
)}
{rules.map((rule, i) => (
<PropertyRuleRow
canRemove={rules.length > 1}
disabled={disabled}
index={i}
key={i}
onRemove={handleRemoveRule}
onRuleChange={handleRuleChange}
rule={rule}
/>
))}
<button
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10 disabled:pointer-events-none disabled:opacity-50"
disabled={disabled}
onClick={handleAddRule}
type="button"
>
<Plus className="size-3" />
Add Rule
</button>
</div>
);
}
/* ── Main condition editor ─────────────────────────────────── */
export function ConditionEditor({ condition, onChange, disabled }: ConditionEditorProps) {
const currentType = resolveConditionType(condition);
const handleTypeChange = useCallback(
(type: ConditionType) => {
onChange(defaultConditionForType(type));
},
[onChange],
);
return (
<div className="space-y-2.5">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Condition
</span>
<select
className={selectClasses}
disabled={disabled}
onChange={(e) => handleTypeChange(e.target.value as ConditionType)}
value={currentType}
>
<option value="none">None</option>
<option value="always">Always</option>
<option value="property">Property Rule</option>
<option value="message-type">Message Type</option>
<option value="expression">Expression</option>
</select>
</label>
{condition?.type === 'property' && (
<PropertyRulesEditor condition={condition} disabled={disabled} onChange={onChange} />
)}
{condition?.type === 'message-type' && (
<label className="block space-y-1.5">
<span className="text-[11px] text-[var(--color-text-muted)]">Type Name</span>
<input
className={inputClasses}
disabled={disabled}
onChange={(e) => onChange({ ...condition, typeName: e.target.value })}
placeholder="e.g. ApprovalResponse"
value={condition.typeName}
/>
</label>
)}
{condition?.type === 'expression' && (
<div className="space-y-1.5">
<label className="block space-y-1.5">
<span className="text-[11px] text-[var(--color-text-muted)]">Expression</span>
<input
className={`${inputClasses} font-mono`}
disabled={disabled}
onChange={(e) => onChange({ ...condition, expression: e.target.value })}
placeholder='e.g. result.status == "done"'
value={condition.expression}
/>
</label>
<div className="flex items-start gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2.5 py-2">
<Info className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Supported: ==, !=, &gt;, &lt;, contains, matches. Combine with &amp;&amp; or ||
</span>
</div>
</div>
)}
</div>
);
}
@@ -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}
@@ -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 <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />;
}
export const workflowEdgeTypes = {
selfLoop: SelfLoopEdge,
} as const;
@@ -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<ModelDefinition>;
workflow: WorkflowDefinition;
selectedNodeId: string | null;
selectedEdgeId: string | null;
validationIssues?: WorkflowValidationIssue[];
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => 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<WorkflowEdge>) => 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -272,12 +289,83 @@ function EdgeInspector({
</select>
</label>
{/* Loop controls */}
<div className="space-y-2.5">
<label className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Loop Edge
</span>
<input
checked={edge.isLoop === true}
className="size-4 accent-[var(--color-accent)]"
onChange={(e) => {
if (e.target.checked) {
onEdgeChange(edge.id, { isLoop: true, maxIterations: edge.maxIterations ?? 10 });
} else {
onEdgeChange(edge.id, { isLoop: undefined, maxIterations: undefined });
}
}}
type="checkbox"
/>
</label>
{edge.isLoop && (
<label className="block space-y-1.5">
<span className="text-[11px] text-[var(--color-text-muted)]">Max Iterations</span>
<input
className="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"
min={1}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
onEdgeChange(edge.id, {
maxIterations: Number.isNaN(raw) ? undefined : Math.max(1, raw),
});
}}
type="number"
value={edge.maxIterations ?? 10}
/>
</label>
)}
</div>
{/* Condition editor */}
<div className="space-y-1.5">
<ConditionEditor
condition={edge.condition}
disabled={isFanIn}
onChange={handleConditionChange}
/>
{isFanIn && (
<p className="text-[11px] text-[var(--color-text-muted)]">
Fan-in edges cannot have conditions
</p>
)}
</div>
<InputField
label="Label"
onChange={(v) => onEdgeChange(edge.id, { label: v || undefined })}
placeholder="Optional edge label"
value={edge.label ?? ''}
/>
{/* Validation issues for this edge */}
{edgeIssues.length > 0 && (
<div className="space-y-1">
{edgeIssues.map((issue, i) => (
<div
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
issue.level === 'error'
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
}`}
key={`${issue.field ?? 'v'}-${i}`}
>
<AlertCircle className="mt-0.5 size-3 shrink-0" />
<span>{issue.message}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -289,6 +377,7 @@ export function WorkflowGraphInspector({
workflow,
selectedNodeId,
selectedEdgeId,
validationIssues,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
@@ -305,7 +394,12 @@ export function WorkflowGraphInspector({
if (selectedEdge) {
return (
<div className="p-4">
<EdgeInspector edge={selectedEdge} onEdgeChange={onEdgeChange} onEdgeRemove={onEdgeRemove} />
<EdgeInspector
edge={selectedEdge}
onEdgeChange={onEdgeChange}
onEdgeRemove={onEdgeRemove}
validationIssues={validationIssues}
/>
</div>
);
}
+28 -9
View File
@@ -94,19 +94,42 @@ const EDGE_DASH: Record<WorkflowEdgeKind, string | undefined> = {
'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) {