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
@@ -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>
);
}