Files
aryx/src/renderer/components/WorkflowEditor.tsx
T
David KayaandCopilot 5ac6980b3f fix: prevent settings panel from collapsing graph canvas
The center column was a flex column with the canvas as flex-1 min-h-0
and the settings panel as shrink-0. When settings content grew tall
(especially with the orchestration mode panel expanded), the canvas
collapsed to zero height.

Switch the center column to a scrollable container with the canvas
having a guaranteed minimum height via clamp(360px, 50vh, 100%).
Settings flow naturally below and the whole column scrolls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:33:59 +02:00

862 lines
33 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>
</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 ?? ''}
/>
</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>
{/* Telemetry */}
<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)]">OpenTelemetry</div>
<p className="text-[12px] text-[var(--color-text-muted)]">Export telemetry data via OpenTelemetry</p>
</div>
<button
className="cursor-pointer"
onClick={() =>
onChange({
...workflow,
settings: {
...workflow.settings,
telemetry: {
...workflow.settings.telemetry,
openTelemetry: !workflow.settings.telemetry?.openTelemetry,
},
},
})
}
type="button"
>
<ToggleSwitch enabled={workflow.settings.telemetry?.openTelemetry === true} />
</button>
</div>
<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)]">Filter Sensitive Data</div>
<p className="text-[12px] text-[var(--color-text-muted)]">Redact sensitive information from telemetry</p>
</div>
<button
className="cursor-pointer"
onClick={() =>
onChange({
...workflow,
settings: {
...workflow.settings,
telemetry: {
...workflow.settings.telemetry,
sensitiveData: !workflow.settings.telemetry?.sensitiveData,
},
},
})
}
type="button"
>
<ToggleSwitch enabled={workflow.settings.telemetry?.sensitiveData === true} />
</button>
</div>
{/* State Scopes */}
<div className="col-span-2 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
State Scopes
</span>
<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>
);
}