Files
aryx/src/renderer/components/workflow/WorkflowGraphInspector.tsx
T
David KayaandCopilot 778c3b4a5b fix: synchronize workflow iteration settings across edges, mode settings, and scaffold
For builder-based orchestration modes (group-chat, handoff), the loop edge
maxIterations, workflow settings.maxIterations, and mode-specific settings
(e.g. groupChat.maxRounds) were independently editable but only the mode
settings controlled runtime behavior. This caused confusion when changing
one did not update the others.

Changes:
- Add syncBuilderModeEdgeIterations() to keep loop edge maxIterations in
  sync with the authoritative mode settings for builder-based modes
- Wire sync into normalizeWorkflowDefinition so edges are consistent on
  load/normalization
- Update scaffoldGraphForMode to accept optional settings instead of
  hardcoded iteration values (4 for handoff, 5 for group-chat)
- Sync settings.maxIterations <-> groupChat.maxRounds bidirectionally
  when either changes in the UI
- Make loop edge controls read-only in the graph inspector for
  builder-based modes with explanatory text
- Add 8 consistency tests validating built-in workflows, scaffold
  behavior, and normalization sync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 11:04:38 +02:00

527 lines
18 KiB
TypeScript

import { useCallback } from 'react';
import { AlertCircle, Bot, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import type {
EdgeCondition,
WorkflowDefinition,
WorkflowEdge,
WorkflowNode,
WorkflowNodeConfig,
WorkflowOrchestrationMode,
WorkflowValidationIssue,
AgentNodeConfig,
} from '@shared/domain/workflow';
import { isBuilderBasedMode } from '@shared/domain/workflow';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { InvokeFunctionInspector } from '@renderer/components/workflow/InvokeFunctionInspector';
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
import { RequestPortInspector } from '@renderer/components/workflow/RequestPortInspector';
import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector';
interface WorkflowGraphInspectorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<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;
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
onEdgeRemove: (edgeId: string) => void;
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
}
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const base =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${base} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={base}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
/* ── Agent node inspector ──────────────────────────────────── */
function AgentNodeInspector({
node,
availableModels,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
}: {
node: WorkflowNode;
availableModels: ReadonlyArray<ModelDefinition>;
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
onNodeRemove: (nodeId: string) => void;
}) {
const config = node.config as AgentNodeConfig;
const model = findModel(config.model, availableModels);
function patchConfig(patch: Partial<AgentNodeConfig>) {
onNodeConfigChange(node.id, { ...config, ...patch });
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
<Bot className="size-4 text-[var(--color-text-primary)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{config.name || 'Unnamed Agent'}
</div>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => onNodeRemove(node.id)}
title="Remove node"
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
<InputField
label="Agent Name"
onChange={(v) => patchConfig({ name: v })}
placeholder="Agent name"
value={config.name}
/>
<InputField
label="Description"
onChange={(v) => patchConfig({ description: v })}
placeholder="What this agent does"
value={config.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => patchConfig({ instructions: v })}
placeholder="System instructions for this agent"
value={config.instructions}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchConfig({
model: value,
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
});
}}
value={config.model}
/>
<div className="space-y-1.5">
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => patchConfig({ reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.reasoningEffort}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
</div>
);
}
/* ── System node inspector ─────────────────────────────────── */
function SystemNodeInspector({ node }: { node: WorkflowNode }) {
const kindLabels: Record<string, string> = {
start: 'Start Node',
end: 'End Node',
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
<Info className="size-4 text-[var(--color-text-muted)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{kindLabels[node.kind] ?? node.kind}
</div>
</div>
<p className="text-[12px] text-[var(--color-text-muted)]">
System node cannot be edited or removed.
</p>
</div>
);
}
/* ── Placeholder inspector for future node kinds ───────────── */
const placeholderIcons: Record<string, typeof FunctionSquare> = {
'sub-workflow': GitBranch,
'request-port': Radio,
};
function PlaceholderNodeInspector({
node,
onNodeChange,
onNodeRemove,
}: {
node: WorkflowNode;
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeRemove: (nodeId: string) => void;
}) {
const Icon = placeholderIcons[node.kind] ?? Info;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
<Icon className="size-4 text-[var(--color-text-muted)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{node.label || node.kind}
</div>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => onNodeRemove(node.id)}
title="Remove node"
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
<div className="rounded-lg border border-[var(--color-status-warning)]/20 bg-[var(--color-status-warning)]/5 px-3 py-2 text-[12px] text-[var(--color-status-warning)]">
Full configuration for this node type is not yet available.
</div>
</div>
);
}
/* ── Edge inspector ────────────────────────────────────────── */
function EdgeInspector({
edge,
orchestrationMode,
validationIssues,
onEdgeChange,
onEdgeRemove,
}: {
edge: WorkflowEdge;
orchestrationMode?: WorkflowOrchestrationMode;
validationIssues?: WorkflowValidationIssue[];
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
onEdgeRemove: (edgeId: string) => void;
}) {
const isFanIn = edge.kind === 'fan-in';
const builderMode = isBuilderBasedMode(orchestrationMode);
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">
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">Edge</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => onEdgeRemove(edge.id)}
title="Remove edge"
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Kind</span>
<select
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => onEdgeChange(edge.id, { kind: e.target.value as WorkflowEdge['kind'] })}
value={edge.kind}
>
<option value="direct">Direct</option>
<option value="fan-out">Fan-out</option>
<option value="fan-in">Fan-in</option>
</select>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Direct connects nodes sequentially. Fan-out splits into parallel branches. Fan-in waits for all branches to complete.
</p>
</label>
{/* Loop controls */}
<div className="space-y-2.5">
<div className="space-y-1">
<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)]"
disabled={builderMode}
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>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{builderMode
? 'Loop edges are managed by the orchestration mode settings.'
: 'Creates an iterative cycle, re-executing the path between these nodes up to a set limit.'}
</p>
</div>
{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 disabled:opacity-60"
disabled={builderMode}
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}
/>
{builderMode && (
<p className="text-[11px] text-[var(--color-text-muted)]">
Derived from the {orchestrationMode === 'group-chat' ? 'Max Rounds' : 'Max Iterations'} setting.
</p>
)}
</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>
);
}
/* ── Main inspector component ──────────────────────────────── */
export function WorkflowGraphInspector({
availableModels,
workflow,
workflows,
selectedNodeId,
selectedEdgeId,
validationIssues,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
onEdgeChange,
onEdgeRemove,
onDrillIntoSubWorkflow,
}: WorkflowGraphInspectorProps) {
const selectedNode = selectedNodeId
? workflow.graph.nodes.find((n) => n.id === selectedNodeId)
: undefined;
const selectedEdge = selectedEdgeId
? workflow.graph.edges.find((e) => e.id === selectedEdgeId)
: undefined;
if (selectedEdge) {
return (
<div className="p-4">
<EdgeInspector
edge={selectedEdge}
orchestrationMode={workflow.settings.orchestrationMode}
onEdgeChange={onEdgeChange}
onEdgeRemove={onEdgeRemove}
validationIssues={validationIssues}
/>
</div>
);
}
if (!selectedNode) {
return (
<div className="flex h-full items-center justify-center p-4">
<p className="text-center text-[12px] text-[var(--color-text-muted)]">
Select a node or edge to inspect
</p>
</div>
);
}
if (selectedNode.kind === 'start' || selectedNode.kind === 'end') {
return (
<div className="p-4">
<SystemNodeInspector node={selectedNode} />
</div>
);
}
if (selectedNode.kind === 'agent') {
return (
<div className="p-4">
<AgentNodeInspector
availableModels={availableModels}
node={selectedNode}
onNodeChange={onNodeChange}
onNodeConfigChange={onNodeConfigChange}
onNodeRemove={onNodeRemove}
/>
</div>
);
}
if (selectedNode.kind === 'sub-workflow') {
return (
<div className="p-4">
<SubWorkflowInspector
currentWorkflowId={workflow.id}
node={selectedNode}
onDrillIntoSubWorkflow={onDrillIntoSubWorkflow}
onNodeChange={onNodeChange}
onNodeConfigChange={onNodeConfigChange}
onNodeRemove={onNodeRemove}
workflows={workflows}
/>
</div>
);
}
if (selectedNode.kind === 'invoke-function') {
return (
<div className="p-4">
<InvokeFunctionInspector
node={selectedNode}
validationIssues={validationIssues}
onNodeChange={onNodeChange}
onNodeConfigChange={onNodeConfigChange}
onNodeRemove={onNodeRemove}
/>
</div>
);
}
if (selectedNode.kind === 'request-port') {
return (
<div className="p-4">
<RequestPortInspector
node={selectedNode}
validationIssues={validationIssues}
onNodeChange={onNodeChange}
onNodeConfigChange={onNodeConfigChange}
onNodeRemove={onNodeRemove}
/>
</div>
);
}
// Fallback for any future unknown node kinds
return (
<div className="p-4">
<PlaceholderNodeInspector
node={selectedNode}
onNodeChange={onNodeChange}
onNodeRemove={onNodeRemove}
/>
</div>
);
}