Files
aryx/src/renderer/components/WorkflowEditor.tsx
T
David KayaandCopilot a3e0bd9244 fix: hide unimplemented telemetry toggles from workflow settings
Remove the OpenTelemetry and Filter Sensitive Data toggles from the
workflow settings panel. The backend does not consume these settings,
so exposing them is misleading. The domain model and DTOs are retained
so existing workflow definitions remain valid.

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

822 lines
32 KiB
TypeScript

import { Fragment, useCallback, useMemo, useState } from 'react';
import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Download, Info, Plus, Trash2, Upload, X } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type {
WorkflowDefinition,
WorkflowGraph,
WorkflowNode,
WorkflowNodeConfig,
WorkflowNodeKind,
WorkflowEdge,
AgentNodeConfig,
SubWorkflowConfig,
WorkflowStateScope,
} from '@shared/domain/workflow';
import { validateWorkflowDefinition } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
import { ToggleSwitch } from '@renderer/components/ui';
import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
import { ExportDropdown, ExportModal, ImportModal } from './workflow/WorkflowExportImportPanel';
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
onChange: (workflow: WorkflowDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
onExportWorkflow?: (format: 'yaml' | 'mermaid' | 'dot') => Promise<{ content: string }>;
onImportWorkflow?: (content: string, format: 'yaml' | 'json') => Promise<WorkflowDefinition>;
}
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,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const baseClasses =
'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-all duration-200 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={`${baseClasses} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={baseClasses}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
/* ── Default configs for new nodes ─────────────────────────── */
function defaultConfigForKind(kind: WorkflowNodeKind): WorkflowNodeConfig {
switch (kind) {
case 'start':
return { kind: 'start' };
case 'end':
return { kind: 'end' };
case 'agent':
return {
kind: 'agent',
id: createId('agent'),
name: 'New Agent',
description: '',
instructions: '',
model: 'gpt-5.4',
reasoningEffort: 'high',
};
case 'invoke-function':
return { kind: 'invoke-function', functionName: '', arguments: {} };
case 'sub-workflow':
return { kind: 'sub-workflow' };
case 'request-port':
return { kind: 'request-port', portId: '', requestType: '', responseType: '' };
}
}
function defaultLabelForKind(kind: WorkflowNodeKind): string {
switch (kind) {
case 'start':
return 'Start';
case 'end':
return 'End';
case 'agent':
return 'New Agent';
case 'invoke-function':
return 'Function Tool';
case 'sub-workflow':
return 'Sub-Workflow';
case 'request-port':
return 'Request Port';
}
}
/* ── 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,
onBack,
onExportWorkflow,
onImportWorkflow,
}: WorkflowEditorProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<WorkflowBreadcrumb[]>([]);
const [showExportDropdown, setShowExportDropdown] = useState(false);
const [exportResult, setExportResult] = useState<{ format: 'yaml' | 'mermaid' | 'dot'; content: string } | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
/* ── 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);
const disabledPaletteKinds = useMemo(() => {
const disabled = new Set<WorkflowNodeKind>();
if (activeWorkflow.graph.nodes.some((n) => n.kind === 'start')) disabled.add('start');
if (activeWorkflow.graph.nodes.some((n) => n.kind === 'end')) disabled.add('end');
return disabled;
}, [activeWorkflow.graph.nodes]);
/* ── 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) {
propagateActiveChange(next);
}
function emitGraphChange(graph: WorkflowGraph) {
propagateActiveChange({ ...activeWorkflow, graph });
}
function handleAddNode(kind: WorkflowNodeKind) {
const nodeId = createId(`wf-${kind}`);
const newNode: WorkflowNode = {
id: nodeId,
kind,
label: defaultLabelForKind(kind),
position: { x: 300, y: 200 },
config: defaultConfigForKind(kind),
};
emitGraphChange({
...activeWorkflow.graph,
nodes: [...activeWorkflow.graph.nodes, newNode],
});
setSelectedNodeId(nodeId);
setSelectedEdgeId(null);
}
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
emitGraphChange({
...activeWorkflow.graph,
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
});
}
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
emitGraphChange({
...activeWorkflow.graph,
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
});
}
function handleNodeRemove(nodeId: string) {
const node = activeWorkflow.graph.nodes.find((n) => n.id === nodeId);
if (!node || node.kind === 'start' || node.kind === 'end') {
return;
}
emitGraphChange({
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<WorkflowEdge>) {
emitGraphChange({
...activeWorkflow.graph,
edges: activeWorkflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
});
}
function handleEdgeRemove(edgeId: string) {
emitGraphChange({
...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 (
<div className="flex h-full flex-col">
{/* Header */}
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
<div className="flex items-center gap-3">
<button
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
{workflow.name || 'Untitled workflow'}
</h3>
<p className="text-[12px] text-[var(--color-text-muted)]">Workflow Designer</p>
</div>
</div>
<div className="no-drag flex items-center gap-2">
{onExportWorkflow && (
<div className="relative">
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={() => setShowExportDropdown((prev) => !prev)}
type="button"
aria-expanded={showExportDropdown}
aria-haspopup="listbox"
>
<Download className="size-3.5" />
Export
</button>
{showExportDropdown && (
<ExportDropdown
onSelectFormat={async (format) => {
const result = await onExportWorkflow(format);
setExportResult({ format, content: result.content });
}}
onClose={() => setShowExportDropdown(false)}
/>
)}
</div>
)}
{onImportWorkflow && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={() => setShowImportModal(true)}
type="button"
>
<Upload className="size-3.5" />
Import
</button>
)}
{onDelete && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
onClick={onDelete}
type="button"
>
<Trash2 className="size-3.5" />
Delete
</button>
)}
<button
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
onClick={onSave}
type="button"
>
Save
</button>
</div>
</div>
{/* Breadcrumb bar */}
{breadcrumbs.length > 0 && (
<div className="flex items-center gap-1 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
<button
className="text-[12px] text-[var(--color-accent)] hover:underline"
onClick={() => { setBreadcrumbs([]); setSelectedNodeId(null); setSelectedEdgeId(null); }}
type="button"
>
{workflow.name || 'Untitled'}
</button>
{breadcrumbs.map((crumb, index) => (
<Fragment key={`${crumb.nodeId}-${index}`}>
<ChevronRight className="size-3 text-[var(--color-text-muted)]" />
<button
className={`text-[12px] ${
index === breadcrumbs.length - 1
? 'font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-accent)] hover:underline'
}`}
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, index + 1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
type="button"
>
{crumb.nodeLabel}
</button>
</Fragment>
))}
</div>
)}
{/* Read-only banner for referenced sub-workflows */}
{isReadOnly && (
<div className="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
<Info className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[12px] text-[var(--color-text-muted)]">
This is a referenced workflow. Open it separately to edit.
</span>
<button
className="ml-auto text-[12px] text-[var(--color-accent)] hover:underline"
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, -1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
type="button"
>
Go back
</button>
</div>
)}
{/* Body — palette + canvas + inspector */}
<div className="flex min-h-0 flex-1">
{/* Left palette */}
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowNodePalette disabledKinds={disabledPaletteKinds} onAddNode={handleAddNode} />
</div>
{/* Center column: validation + canvas + settings */}
<div className="min-w-0 flex-1 overflow-y-auto">
{/* Validation banner */}
<div className="px-5 pt-4">
{issues.length > 0 ? (
<div className="space-y-1.5">
{issues.map((issue, i) => (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
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.5 shrink-0" />
{issue.message}
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
<CheckCircle className="size-3.5" />
Workflow is valid
</div>
)}
</div>
{/* Graph canvas — guaranteed minimum height so settings never collapse it */}
<div className="min-h-[360px] px-5 pb-2 pt-4" style={{ height: 'clamp(360px, 50vh, 100%)' }}>
<WorkflowGraphCanvas
availableModels={availableModels}
onEdgeSelect={setSelectedEdgeId}
onGraphChange={emitGraphChange}
onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId}
workflow={activeWorkflow}
workflows={workflows}
/>
</div>
{/* Settings below canvas */}
{breadcrumbs.length === 0 && (
<WorkflowSettingsPanel workflow={workflow} onChange={emitChange} />
)}
</div>
{/* Right inspector */}
<div className="w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowGraphInspector
availableModels={availableModels}
onDrillIntoSubWorkflow={handleDrillIntoSubWorkflow}
onEdgeChange={handleEdgeChange}
onEdgeRemove={handleEdgeRemove}
onNodeChange={handleNodeChange}
onNodeConfigChange={handleNodeConfigChange}
onNodeRemove={handleNodeRemove}
selectedEdgeId={selectedEdgeId}
selectedNodeId={selectedNodeId}
validationIssues={issues}
workflow={activeWorkflow}
workflows={workflows}
/>
</div>
</div>
{exportResult && (
<ExportModal
format={exportResult.format}
content={exportResult.content}
onClose={() => setExportResult(null)}
/>
)}
{showImportModal && onImportWorkflow && (
<ImportModal
onImport={async (content, format) => {
const imported = await onImportWorkflow(content, format);
onChange(imported);
return imported;
}}
onClose={() => setShowImportModal(false)}
/>
)}
</div>
);
}
/* ── Settings panel below canvas ───────────────────────────── */
function StateScopeInitialValues({
initialValues,
onChange,
}: {
initialValues: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}) {
const entries = Object.entries(initialValues);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--color-text-muted)]">Initial Values</span>
<button
className="flex size-5 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
onClick={() => {
const key = `key${entries.length + 1}`;
onChange({ ...initialValues, [key]: '' });
}}
title="Add initial value"
type="button"
>
<Plus className="size-3" />
</button>
</div>
{entries.map(([key, value]) => (
<div className="flex items-center gap-1.5" key={key}>
<input
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
const next = { ...initialValues };
const val = next[key];
delete next[key];
next[e.target.value] = val;
onChange(next);
}}
placeholder="key"
value={key}
/>
<input
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
onChange({ ...initialValues, [key]: e.target.value });
}}
placeholder="value"
value={typeof value === 'string' ? value : JSON.stringify(value) ?? ''}
/>
<button
className="flex size-5 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => {
const next = { ...initialValues };
delete next[key];
onChange(next);
}}
title="Remove value"
type="button"
>
<X className="size-2.5" />
</button>
</div>
))}
</div>
);
}
function WorkflowSettingsPanel({
workflow,
onChange,
}: {
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
}) {
return (
<div className="border-t border-[var(--color-border)] px-5 py-4">
<h4 className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Settings
</h4>
{/* Orchestration mode — prominent, above general settings */}
<div className="mb-5">
<OrchestrationModePanel workflow={workflow} onChange={onChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<InputField
label="Name"
onChange={(v) => onChange({ ...workflow, name: v })}
placeholder="Workflow name"
value={workflow.name}
/>
<InputField
label="Description"
onChange={(v) => onChange({ ...workflow, description: v })}
placeholder="What this workflow does"
value={workflow.description}
/>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Execution Mode</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) =>
onChange({
...workflow,
settings: { ...workflow.settings, executionMode: e.target.value as 'off-thread' | 'lockstep' },
})
}
value={workflow.settings.executionMode}
>
<option value="off-thread">Off-thread</option>
<option value="lockstep">Lockstep</option>
</select>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Off-thread executes in the background without blocking. Lockstep waits for each step to finish before proceeding.
</p>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Max Iterations</span>
<input
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-all duration-200 focus:border-[var(--color-accent)]/50"
min={1}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
onChange({
...workflow,
settings: {
...workflow.settings,
maxIterations: Number.isNaN(raw) ? undefined : raw,
},
});
}}
placeholder="e.g. 5"
type="number"
value={workflow.settings.maxIterations ?? ''}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Safety limit on total step executions to prevent runaway loops.
</p>
</label>
<div className="col-span-2 flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-3">
<div>
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Checkpointing</div>
<p className="text-[12px] text-[var(--color-text-muted)]">Enable state checkpointing between steps</p>
</div>
<button
className="cursor-pointer"
onClick={() =>
onChange({
...workflow,
settings: {
...workflow.settings,
checkpointing: { enabled: !workflow.settings.checkpointing.enabled },
},
})
}
type="button"
>
<ToggleSwitch enabled={workflow.settings.checkpointing.enabled} />
</button>
</div>
{/* State Scopes */}
<div className="col-span-2 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
State Scopes
</span>
<p className="mt-0.5 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Named containers for shared state that persists across workflow steps.
</p>
</div>
<button
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
onClick={() => {
const scopes = [...(workflow.settings.stateScopes ?? []), { name: '', description: '', initialValues: {} }];
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
title="Add state scope"
type="button"
>
<Plus className="size-3.5" />
</button>
</div>
{(workflow.settings.stateScopes ?? []).map((scope, idx) => (
<div
className="space-y-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-3"
key={idx}
>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
const scopes = [...(workflow.settings.stateScopes ?? [])];
scopes[idx] = { ...scopes[idx], name: e.target.value };
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
placeholder="Scope name"
value={scope.name}
/>
<button
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => {
const scopes = (workflow.settings.stateScopes ?? []).filter((_, i) => i !== idx);
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
title="Remove scope"
type="button"
>
<X className="size-3" />
</button>
</div>
<input
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
const scopes = [...(workflow.settings.stateScopes ?? [])];
scopes[idx] = { ...scopes[idx], description: e.target.value };
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
placeholder="Description (optional)"
value={scope.description ?? ''}
/>
<StateScopeInitialValues
initialValues={scope.initialValues ?? {}}
onChange={(initialValues) => {
const scopes = [...(workflow.settings.stateScopes ?? [])];
scopes[idx] = { ...scopes[idx], initialValues };
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
/>
</div>
))}
</div>
</div>
</div>
);
}