feat: add Workflow Designer frontend (Phase 1)

Implement the visual workflow editor UI with ReactFlow canvas,
node palette, inspector panel, and full SettingsPanel integration.

New files:
- workflowGraph.ts: graph utilities (layout, connection rules, edges)
- WorkflowGraphNodes.tsx: custom ReactFlow nodes per workflow kind
- WorkflowGraphCanvas.tsx: ReactFlow canvas with drag, connect, layout
- WorkflowGraphInspector.tsx: node/edge inspector with agent config
- WorkflowNodePalette.tsx: categorized node palette for adding nodes
- WorkflowEditor.tsx: full-screen editor with validation and settings

Modified files:
- SettingsPanel.tsx: add workflows section, nav item, editing state
- App.tsx: wire workflow CRUD props and createDraftWorkflow factory

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-05 17:53:33 +02:00
co-authored by Copilot
parent 19a764d297
commit f011311514
8 changed files with 1629 additions and 2 deletions
+59
View File
@@ -49,6 +49,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
import type { ProjectGitFileReference } from '@shared/domain/project';
import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
@@ -121,6 +122,50 @@ function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefini
};
}
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): WorkflowDefinition {
const timestamp = nowIso();
const startId = createId('wf-start');
const agentId = createId('wf-agent');
const endId = createId('wf-end');
return {
id: createId('workflow'),
name: 'New Workflow',
description: '',
graph: {
nodes: [
{ id: startId, kind: 'start', label: 'Start', position: { x: 0, y: 100 }, config: { kind: 'start' } },
{
id: agentId,
kind: 'agent',
label: 'Primary Agent',
position: { x: 250, y: 100 },
config: {
kind: 'agent',
id: createId('agent'),
name: 'Primary Agent',
description: 'General-purpose assistant.',
instructions: 'You are a helpful coding assistant working inside the selected project.',
model: defaultModelId,
reasoningEffort: defaultReasoningEffort,
},
},
{ id: endId, kind: 'end', label: 'End', position: { x: 500, y: 100 }, config: { kind: 'end' } },
],
edges: [
{ id: `edge-${startId}-to-${agentId}`, source: startId, target: agentId, kind: 'direct' },
{ id: `edge-${agentId}-to-${endId}`, source: agentId, target: endId, kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
maxIterations: 5,
},
createdAt: timestamp,
updatedAt: timestamp,
};
}
export default function App() {
const api = getElectronApi();
const [workspace, setWorkspace] = useState<WorkspaceState>();
@@ -723,6 +768,9 @@ export default function App() {
onDeletePattern={async (id) => {
await api.deletePattern(id);
}}
onDeleteWorkflow={async (id) => {
await api.deleteWorkflow(id);
}}
onNewLspProfile={createDraftLspProfile}
onNewMcpServer={createDraftMcpServer}
onNewPattern={() => {
@@ -733,6 +781,13 @@ export default function App() {
resolveReasoningEffort(defaultModel, 'high'),
);
}}
onNewWorkflow={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftWorkflow(
defaultModel?.id ?? 'gpt-5.4',
resolveReasoningEffort(defaultModel, 'high'),
);
}}
onRefreshCapabilities={refreshCapabilities}
onSaveLspProfile={async (profile) => {
await api.saveLspProfile({ profile });
@@ -743,6 +798,9 @@ export default function App() {
onSavePattern={async (pattern) => {
await api.savePattern({ pattern });
}}
onSaveWorkflow={async (workflow) => {
await api.saveWorkflow({ workflow });
}}
onSaveWorkspaceAgent={async (agent) => {
await api.saveWorkspaceAgent({ agent });
}}
@@ -769,6 +827,7 @@ export default function App() {
setShowSettings(false);
}}
patterns={workspace.patterns}
workflows={workspace.workflows}
sidecarCapabilities={sidecarCapabilities}
theme={workspace.settings.theme}
toolingSettings={workspace.settings.tooling}
+100 -2
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
@@ -13,6 +14,7 @@ import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } fro
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
@@ -26,6 +28,7 @@ import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type Works
interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
sidecarCapabilities?: SidecarCapabilities;
theme: AppearanceTheme;
toolingSettings: WorkspaceToolingSettings;
@@ -37,6 +40,9 @@ interface SettingsPanelProps {
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
onDeletePattern: (patternId: string) => Promise<void>;
onNewPattern: () => PatternDefinition;
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
onDeleteWorkflow: (workflowId: string) => Promise<void>;
onNewWorkflow: () => WorkflowDefinition;
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
onDeleteMcpServer: (serverId: string) => Promise<void>;
onNewMcpServer: () => McpServerDefinition;
@@ -60,7 +66,7 @@ interface SettingsPanelProps {
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
}
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -90,6 +96,7 @@ const navGroups: NavGroup[] = [
label: 'Workflows',
items: [
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
],
},
@@ -116,6 +123,7 @@ function modeBadgeClasses(pattern: PatternDefinition) {
export function SettingsPanel({
availableModels,
patterns,
workflows,
sidecarCapabilities,
theme,
toolingSettings,
@@ -127,6 +135,9 @@ export function SettingsPanel({
onSavePattern,
onDeletePattern,
onNewPattern,
onSaveWorkflow,
onDeleteWorkflow,
onNewWorkflow,
onSaveMcpServer,
onDeleteMcpServer,
onNewMcpServer,
@@ -151,6 +162,7 @@ export function SettingsPanel({
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
@@ -184,6 +196,29 @@ export function SettingsPanel({
);
}
if (editingWorkflow) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<WorkflowEditor
availableModels={availableModels}
onBack={() => setEditingWorkflow(null)}
onChange={setEditingWorkflow}
onDelete={
async () => {
await onDeleteWorkflow(editingWorkflow.id);
setEditingWorkflow(null);
}
}
onSave={async () => {
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
setEditingWorkflow(null);
}}
workflow={editingWorkflow}
/>
</div>
);
}
if (editingMcpServer) {
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
return (
@@ -337,6 +372,13 @@ export function SettingsPanel({
patterns={patterns}
/>
)}
{activeSection === 'workflows' && (
<WorkflowsSection
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
workflows={workflows}
/>
)}
{activeSection === 'agents' && (
<WorkspaceAgentsSection
agents={workspaceAgents}
@@ -601,6 +643,62 @@ function PatternsSection({
);
}
function WorkflowsSection({
workflows,
onEditWorkflow,
onNewWorkflow,
}: {
workflows: WorkflowDefinition[];
onEditWorkflow: (workflow: WorkflowDefinition) => void;
onNewWorkflow: () => void;
}) {
return (
<div>
<SectionHeader
description="Design multi-step agent workflows with visual graphs"
title="Workflows"
>
<SectionAction label="New Workflow" onClick={onNewWorkflow} />
</SectionHeader>
<div className="space-y-1">
{workflows.map((wf) => {
const agentCount = wf.graph.nodes.filter((n) => n.kind === 'agent').length;
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
key={wf.id}
onClick={() => onEditWorkflow(wf)}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{wf.name}</span>
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
{wf.settings.executionMode}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{wf.description}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] text-[var(--color-text-muted)]">
{agentCount} agent{agentCount === 1 ? '' : 's'}
</span>
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
</div>
</button>
);
})}
{workflows.length === 0 && (
<p className="px-4 py-6 text-center text-[12px] text-[var(--color-text-muted)]">
No workflows yet. Create one to get started.
</p>
)}
</div>
</div>
);
}
function WorkspaceAgentsSection({
agents,
patterns,
+397
View File
@@ -0,0 +1,397 @@
import { useState } from 'react';
import { AlertCircle, CheckCircle, ChevronLeft, Trash2 } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type {
WorkflowDefinition,
WorkflowGraph,
WorkflowNode,
WorkflowNodeConfig,
WorkflowNodeKind,
WorkflowEdge,
AgentNodeConfig,
} 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 { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
}
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 'code-executor':
return { kind: 'code-executor' };
case 'function-executor':
return { kind: 'function-executor', functionRef: '' };
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 'code-executor':
return 'Code Executor';
case 'function-executor':
return 'Function';
case 'sub-workflow':
return 'Sub-Workflow';
case 'request-port':
return 'Request Port';
}
}
/* ── Main editor ───────────────────────────────────────────── */
export function WorkflowEditor({
availableModels,
workflow,
onChange,
onDelete,
onSave,
onBack,
}: WorkflowEditorProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const issues = validateWorkflowDefinition(workflow);
function emitChange(next: WorkflowDefinition) {
onChange(next);
}
function emitGraphChange(graph: WorkflowGraph) {
onChange({ ...workflow, 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({
...workflow.graph,
nodes: [...workflow.graph.nodes, newNode],
});
setSelectedNodeId(nodeId);
setSelectedEdgeId(null);
}
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
emitGraphChange({
...workflow.graph,
nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
});
}
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
emitGraphChange({
...workflow.graph,
nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
});
}
function handleNodeRemove(nodeId: string) {
const node = workflow.graph.nodes.find((n) => n.id === nodeId);
if (!node || node.kind === 'start' || node.kind === 'end') {
return;
}
emitGraphChange({
nodes: workflow.graph.nodes.filter((n) => n.id !== nodeId),
edges: workflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
});
setSelectedNodeId(null);
}
function handleEdgeChange(edgeId: string, patch: Partial<WorkflowEdge>) {
emitGraphChange({
...workflow.graph,
edges: workflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
});
}
function handleEdgeRemove(edgeId: string) {
emitGraphChange({
...workflow.graph,
edges: workflow.graph.edges.filter((e) => e.id !== edgeId),
});
setSelectedEdgeId(null);
}
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">
{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>
{/* 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 onAddNode={handleAddNode} />
</div>
{/* Center column: validation + canvas + settings */}
<div className="flex min-w-0 flex-1 flex-col">
{/* 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 */}
<div className="min-h-0 flex-1 px-5 pb-2 pt-4">
<WorkflowGraphCanvas
availableModels={availableModels}
onEdgeSelect={setSelectedEdgeId}
onGraphChange={emitGraphChange}
onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId}
workflow={workflow}
/>
</div>
{/* Settings below canvas */}
<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}
onEdgeChange={handleEdgeChange}
onEdgeRemove={handleEdgeRemove}
onNodeChange={handleNodeChange}
onNodeConfigChange={handleNodeConfigChange}
onNodeRemove={handleNodeRemove}
selectedEdgeId={selectedEdgeId}
selectedNodeId={selectedNodeId}
workflow={workflow}
/>
</div>
</div>
</div>
);
}
/* ── Settings panel below canvas ───────────────────────────── */
function WorkflowSettingsPanel({
workflow,
onChange,
}: {
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
}) {
return (
<div className="shrink-0 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>
<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>
</div>
</div>
);
}
@@ -0,0 +1,241 @@
import { useCallback, useEffect, useRef } from 'react';
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Panel,
MiniMap,
MarkerType,
useNodesState,
useEdgesState,
useReactFlow,
type Node,
type Edge,
type OnConnect,
type OnEdgesChange,
type OnNodesChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { LayoutGrid } from 'lucide-react';
import type { WorkflowDefinition, WorkflowGraph } from '@shared/domain/workflow';
import type { ModelDefinition } from '@shared/domain/models';
import {
addWorkflowEdge,
autoLayoutWorkflowGraph,
fromCanvasPositions,
isWorkflowConnectionAllowed,
removeWorkflowEdge,
toCanvasEdges,
toCanvasNodes,
type WorkflowGraphNodeData,
} from '@renderer/lib/workflowGraph';
import { workflowNodeTypes } from './WorkflowGraphNodes';
interface WorkflowGraphCanvasProps {
workflow: WorkflowDefinition;
availableModels?: ReadonlyArray<ModelDefinition>;
onGraphChange: (graph: WorkflowGraph) => void;
onNodeSelect: (nodeId: string | null) => void;
onEdgeSelect: (edgeId: string | null) => void;
selectedNodeId: string | null;
}
function WorkflowGraphCanvasInner({
workflow,
availableModels,
onGraphChange,
onNodeSelect,
onEdgeSelect,
selectedNodeId,
}: WorkflowGraphCanvasProps) {
const { fitView } = useReactFlow();
const graph = workflow.graph;
const draggingRef = useRef(false);
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
toCanvasNodes(graph, availableModels),
);
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
toCanvasEdges(graph),
);
useEffect(() => {
setNodes(toCanvasNodes(graph, availableModels));
setEdges(toCanvasEdges(graph));
}, [graph, availableModels, setNodes, setEdges]);
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
(changes) => {
const removals = changes.filter((c) => c.type === 'remove');
const nonRemovals = changes.filter((c) => c.type !== 'remove');
if (removals.length > 0) {
let updatedGraph = graph;
for (const removal of removals) {
if (removal.type === 'remove') {
const node = graph.nodes.find((n) => n.id === removal.id);
if (node && node.kind !== 'start' && node.kind !== 'end') {
updatedGraph = {
...updatedGraph,
nodes: updatedGraph.nodes.filter((n) => n.id !== removal.id),
edges: updatedGraph.edges.filter((e) => e.source !== removal.id && e.target !== removal.id),
};
}
}
}
if (updatedGraph !== graph) {
onGraphChange(updatedGraph);
onNodeSelect(null);
}
}
if (nonRemovals.length > 0) {
onNodesChangeBase(nonRemovals);
}
const hasDragStart = nonRemovals.some(
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
);
const hasDragStop = nonRemovals.some(
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
);
if (hasDragStart) {
draggingRef.current = true;
}
if (hasDragStop && draggingRef.current) {
draggingRef.current = false;
setNodes((currentNodes) => {
const updatedGraph = fromCanvasPositions(workflow, currentNodes);
onGraphChange(updatedGraph);
return currentNodes;
});
}
},
[onNodesChangeBase, workflow, graph, onGraphChange, onNodeSelect, setNodes],
);
const handleEdgesChange: OnEdgesChange = useCallback(
(changes) => {
const removals = changes.filter((c) => c.type === 'remove');
if (removals.length > 0) {
let updatedGraph = graph;
for (const removal of removals) {
if (removal.type === 'remove') {
updatedGraph = removeWorkflowEdge(updatedGraph, removal.id);
}
}
onGraphChange(updatedGraph);
onEdgeSelect(null);
}
const nonRemovals = changes.filter((c) => c.type !== 'remove');
if (nonRemovals.length > 0) {
onEdgesChangeBase(nonRemovals);
}
},
[onEdgesChangeBase, graph, onGraphChange, onEdgeSelect],
);
const handleConnect: OnConnect = useCallback(
(connection) => {
if (!isWorkflowConnectionAllowed(connection, graph)) {
return;
}
if (connection.source && connection.target) {
const updatedGraph = addWorkflowEdge(graph, connection.source, connection.target);
onGraphChange(updatedGraph);
}
},
[graph, onGraphChange],
);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<WorkflowGraphNodeData>) => {
onNodeSelect(node.id);
onEdgeSelect(null);
},
[onNodeSelect, onEdgeSelect],
);
const handleEdgeClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
onEdgeSelect(edge.id);
onNodeSelect(null);
},
[onEdgeSelect, onNodeSelect],
);
const handlePaneClick = useCallback(() => {
onNodeSelect(null);
onEdgeSelect(null);
}, [onNodeSelect, onEdgeSelect]);
const handleAutoLayout = useCallback(() => {
const layouted = autoLayoutWorkflowGraph(graph);
onGraphChange(layouted);
requestAnimationFrame(() => fitView({ padding: 0.3 }));
}, [graph, onGraphChange, fitView]);
return (
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
<ReactFlow
nodes={nodes.map((n) => ({
...n,
selected: n.id === selectedNodeId,
}))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onPaneClick={handlePaneClick}
nodeTypes={workflowNodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'default',
style: { stroke: '#6366f1', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
}}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
deleteKeyCode="Delete"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
<MiniMap
className="!rounded-lg !border !border-[var(--color-border)] !bg-[var(--color-surface-1)]"
maskColor="rgba(0,0,0,0.6)"
nodeColor="#3f3f46"
/>
<Panel position="top-right">
<button
type="button"
onClick={handleAutoLayout}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
title="Auto-layout nodes"
>
<LayoutGrid className="size-3.5" />
Auto layout
</button>
</Panel>
</ReactFlow>
</div>
);
}
export function WorkflowGraphCanvas(props: WorkflowGraphCanvasProps) {
return (
<ReactFlowProvider>
<WorkflowGraphCanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,354 @@
import { Bot, Code, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import type {
WorkflowDefinition,
WorkflowEdge,
WorkflowNode,
WorkflowNodeConfig,
AgentNodeConfig,
} from '@shared/domain/workflow';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
interface WorkflowGraphInspectorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workflow: WorkflowDefinition;
selectedNodeId: string | null;
selectedEdgeId: string | null;
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;
}
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}
/>
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => patchConfig({ reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.reasoningEffort}
/>
</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 Code> = {
'code-executor': Code,
'function-executor': 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 coming in Phase 2.
</div>
</div>
);
}
/* ── Edge inspector ────────────────────────────────────────── */
function EdgeInspector({
edge,
onEdgeChange,
onEdgeRemove,
}: {
edge: WorkflowEdge;
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
onEdgeRemove: (edgeId: string) => void;
}) {
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>
</label>
<InputField
label="Label"
onChange={(v) => onEdgeChange(edge.id, { label: v || undefined })}
placeholder="Optional edge label"
value={edge.label ?? ''}
/>
</div>
);
}
/* ── Main inspector component ──────────────────────────────── */
export function WorkflowGraphInspector({
availableModels,
workflow,
selectedNodeId,
selectedEdgeId,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
onEdgeChange,
onEdgeRemove,
}: 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} onEdgeChange={onEdgeChange} onEdgeRemove={onEdgeRemove} />
</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>
);
}
return (
<div className="p-4">
<PlaceholderNodeInspector
node={selectedNode}
onNodeChange={onNodeChange}
onNodeRemove={onNodeRemove}
/>
</div>
);
}
@@ -0,0 +1,159 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Bot, Circle, Code, FunctionSquare, GitBranch, Radio, Play, Square } from 'lucide-react';
import type { WorkflowGraphNodeData } from '@renderer/lib/workflowGraph';
import type { WorkflowNodeKind } from '@shared/domain/workflow';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
/* ── Styling constants ─────────────────────────────────────── */
const kindColors: Record<WorkflowNodeKind, { bg: string; border: string; text: string }> = {
start: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
end: { bg: 'bg-rose-500/10', border: 'border-rose-500/30', text: 'text-rose-400' },
agent: { bg: 'bg-[var(--color-surface-2)]/80', border: 'border-[var(--color-border)]/40', text: 'text-[var(--color-text-primary)]' },
'code-executor': { bg: 'bg-sky-500/10', border: 'border-sky-500/30', text: 'text-sky-400' },
'function-executor': { bg: 'bg-violet-500/10', border: 'border-violet-500/30', text: 'text-violet-400' },
'sub-workflow': { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
'request-port': { bg: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-400' },
};
const kindIcons: Record<WorkflowNodeKind, typeof Bot> = {
start: Play,
end: Square,
agent: Bot,
'code-executor': Code,
'function-executor': FunctionSquare,
'sub-workflow': GitBranch,
'request-port': Radio,
};
const handleStyles = {
flow: '!size-2 !border-[var(--color-border)] !bg-[var(--color-text-secondary)]',
agent: '!size-2 !border-[var(--color-accent-sky)] !bg-[var(--color-accent)]',
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
};
/* ── Shared node content ───────────────────────────────────── */
function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) {
const colors = kindColors[data.kind] ?? kindColors.agent;
const isAgent = data.kind === 'agent';
const renderIcon = () => {
if (isAgent && data.provider) {
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
}
const FallbackIcon = kindIcons[data.kind] ?? Circle;
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
};
return (
<div
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md backdrop-blur-sm transition ${
colors.bg
} ${selected ? 'ring-2 ring-[var(--color-accent)]/50' : ''} ${colors.border}`}
>
{renderIcon()}
<div className="min-w-0 flex-1">
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
{data.label}
</div>
{isAgent && data.modelLabel && (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
)}
</div>
</div>
);
}
/* ── Node type components (all memoized) ───────────────────── */
export const StartNode = memo(function StartNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
</>
);
});
export const EndNode = memo(function EndNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
</>
);
});
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
</>
);
});
export const CodeExecutorNode = memo(function CodeExecutorNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
</>
);
});
export const FunctionExecutorNode = memo(function FunctionExecutorNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
</>
);
});
export const SubWorkflowNode = memo(function SubWorkflowNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
</>
);
});
export const RequestPortNode = memo(function RequestPortNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as WorkflowGraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
</>
);
});
/* ── Node type map for ReactFlow ───────────────────────────── */
export const workflowNodeTypes = {
startNode: StartNode,
endNode: EndNode,
agentNode: AgentNode,
codeExecutorNode: CodeExecutorNode,
functionExecutorNode: FunctionExecutorNode,
subWorkflowNode: SubWorkflowNode,
requestPortNode: RequestPortNode,
};
@@ -0,0 +1,82 @@
import { Bot, Code, FunctionSquare, GitBranch, Play, Radio, Square } from 'lucide-react';
import type { WorkflowNodeKind } from '@shared/domain/workflow';
interface WorkflowNodePaletteProps {
onAddNode: (kind: WorkflowNodeKind) => void;
}
interface PaletteItem {
kind: WorkflowNodeKind;
label: string;
icon: typeof Bot;
color: string;
}
interface PaletteGroup {
label: string;
items: PaletteItem[];
}
const paletteGroups: PaletteGroup[] = [
{
label: 'Flow Control',
items: [
{ kind: 'start', label: 'Start', icon: Play, color: 'text-emerald-400' },
{ kind: 'end', label: 'End', icon: Square, color: 'text-rose-400' },
],
},
{
label: 'Agents',
items: [
{ kind: 'agent', label: 'Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
],
},
{
label: 'Processing',
items: [
{ kind: 'code-executor', label: 'Code', icon: Code, color: 'text-sky-400' },
{ kind: 'function-executor', label: 'Function', icon: FunctionSquare, color: 'text-violet-400' },
],
},
{
label: 'Integration',
items: [
{ kind: 'sub-workflow', label: 'Sub-Workflow', icon: GitBranch, color: 'text-amber-400' },
{ kind: 'request-port', label: 'Port', icon: Radio, color: 'text-teal-400' },
],
},
];
export function WorkflowNodePalette({ onAddNode }: WorkflowNodePaletteProps) {
return (
<div className="space-y-4 p-3">
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Nodes
</h4>
{paletteGroups.map((group) => (
<div key={group.label}>
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">
{group.label}
</span>
<div className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
return (
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
key={item.kind}
onClick={() => onAddNode(item.kind)}
type="button"
>
<Icon className={`size-3.5 ${item.color}`} />
{item.label}
</button>
);
})}
</div>
</div>
))}
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
import dagre from '@dagrejs/dagre';
import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
import type {
WorkflowDefinition,
WorkflowEdge as WfEdge,
WorkflowEdgeKind,
WorkflowGraph,
WorkflowNode,
WorkflowNodeKind,
} from '@shared/domain/workflow';
import type { ModelProvider } from '@shared/domain/models';
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
/* ── Canvas node data ──────────────────────────────────────── */
export interface WorkflowGraphNodeData extends Record<string, unknown> {
label: string;
kind: WorkflowNodeKind;
/** AI provider inferred from the agent's model (agent nodes only). */
provider?: ModelProvider;
/** Short display name for the agent's model (agent nodes only). */
modelLabel?: string;
}
/* ── View-model projection ─────────────────────────────────── */
function resolveNodeType(kind: WorkflowNodeKind): string {
switch (kind) {
case 'start':
return 'startNode';
case 'end':
return 'endNode';
case 'agent':
return 'agentNode';
case 'code-executor':
return 'codeExecutorNode';
case 'function-executor':
return 'functionExecutorNode';
case 'sub-workflow':
return 'subWorkflowNode';
case 'request-port':
return 'requestPortNode';
}
}
export function toCanvasNodes(
graph: WorkflowGraph,
models?: ReadonlyArray<ModelDefinition>,
): Node<WorkflowGraphNodeData>[] {
return graph.nodes.map((node) => {
let provider: ModelProvider | undefined;
let modelLabel: string | undefined;
if (node.kind === 'agent' && node.config.kind === 'agent') {
const modelId = node.config.model;
if (modelId) {
provider = inferProvider(modelId);
const modelDef = models ? findModel(modelId, models) : undefined;
modelLabel = modelDef?.name ?? modelId;
}
}
const isSystemNode = node.kind === 'start' || node.kind === 'end';
return {
id: node.id,
type: resolveNodeType(node.kind),
position: { x: node.position.x, y: node.position.y },
data: {
label: node.label,
kind: node.kind,
provider,
modelLabel,
},
draggable: true,
selectable: true,
deletable: !isSystemNode,
};
});
}
/* ── Edge styling ──────────────────────────────────────────── */
const EDGE_COLORS: Record<WorkflowEdgeKind, { stroke: string; markerColor: string }> = {
direct: { stroke: '#6366f1', markerColor: '#6366f1' },
'fan-out': { stroke: '#f59e0b', markerColor: '#f59e0b' },
'fan-in': { stroke: '#10b981', markerColor: '#10b981' },
};
const EDGE_DASH: Record<WorkflowEdgeKind, string | undefined> = {
direct: undefined,
'fan-out': '6 3',
'fan-in': '2 2',
};
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];
return {
id: edge.id,
source: edge.source,
target: edge.target,
type: 'default',
animated: edge.kind === 'fan-out',
deletable: true,
label: edge.label,
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
style: {
stroke: color.stroke,
strokeWidth: 1.5,
...(dashArray ? { strokeDasharray: dashArray } : {}),
},
};
});
}
/* ── Position sync ─────────────────────────────────────────── */
export function fromCanvasPositions(
workflow: WorkflowDefinition,
canvasNodes: Node[],
): WorkflowGraph {
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
return {
nodes: workflow.graph.nodes.map((node) => {
const pos = positionMap.get(node.id);
return pos ? { ...node, position: { x: Math.round(pos.x), y: Math.round(pos.y) } } : node;
}),
edges: workflow.graph.edges,
};
}
/* ── Connection rules ──────────────────────────────────────── */
export function isWorkflowConnectionAllowed(
connection: Connection,
graph: WorkflowGraph,
): boolean {
if (!connection.source || !connection.target) {
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) {
return false;
}
// Start nodes cannot receive incoming edges
if (targetNode.kind === 'start') {
return false;
}
// End nodes cannot have outgoing edges
if (sourceNode.kind === 'end') {
return false;
}
// No duplicate edges
if (graph.edges.some((e) => e.source === connection.source && e.target === connection.target)) {
return false;
}
return true;
}
/* ── Graph mutation helpers ─────────────────────────────────── */
function edgeId(source: string, target: string): string {
return `edge-${source}-to-${target}`;
}
export function addWorkflowEdge(graph: WorkflowGraph, source: string, target: string): WorkflowGraph {
const newEdge: WfEdge = {
id: edgeId(source, target),
source,
target,
kind: 'direct',
};
if (graph.edges.some((e) => e.source === source && e.target === target)) {
return graph;
}
return { ...graph, edges: [...graph.edges, newEdge] };
}
export function removeWorkflowEdge(graph: WorkflowGraph, removeEdgeId: string): WorkflowGraph {
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
}
/* ── Auto-layout via dagre ─────────────────────────────────── */
const NODE_WIDTH = 170;
const NODE_HEIGHT = 52;
export function autoLayoutWorkflowGraph(graph: WorkflowGraph): WorkflowGraph {
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: 'LR',
nodesep: 60,
ranksep: 120,
marginx: 20,
marginy: 20,
});
for (const node of graph.nodes) {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of graph.edges) {
g.setEdge(edge.source, edge.target);
}
dagre.layout(g);
const layoutedNodes = graph.nodes.map((node) => {
const dagreNode = g.node(node.id);
return {
...node,
position: {
x: Math.round(dagreNode.x - NODE_WIDTH / 2),
y: Math.round(dagreNode.y - NODE_HEIGHT / 2),
},
};
});
return { nodes: layoutedNodes, edges: graph.edges };
}