mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
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:
@@ -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: ==, !=, >, <, contains, matches. Combine with && 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user