From f0113115143713aef0dc11618ffd3e071d848e16 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 5 Apr 2026 17:53:33 +0200 Subject: [PATCH] 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> --- src/renderer/App.tsx | 59 +++ src/renderer/components/SettingsPanel.tsx | 102 ++++- src/renderer/components/WorkflowEditor.tsx | 397 ++++++++++++++++++ .../workflow/WorkflowGraphCanvas.tsx | 241 +++++++++++ .../workflow/WorkflowGraphInspector.tsx | 354 ++++++++++++++++ .../workflow/WorkflowGraphNodes.tsx | 159 +++++++ .../workflow/WorkflowNodePalette.tsx | 82 ++++ src/renderer/lib/workflowGraph.ts | 237 +++++++++++ 8 files changed, 1629 insertions(+), 2 deletions(-) create mode 100644 src/renderer/components/WorkflowEditor.tsx create mode 100644 src/renderer/components/workflow/WorkflowGraphCanvas.tsx create mode 100644 src/renderer/components/workflow/WorkflowGraphInspector.tsx create mode 100644 src/renderer/components/workflow/WorkflowGraphNodes.tsx create mode 100644 src/renderer/components/workflow/WorkflowNodePalette.tsx create mode 100644 src/renderer/lib/workflowGraph.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index aee3a1d..661aff3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(); @@ -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} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index b9787c7..547f072 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -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; patterns: PatternDefinition[]; + workflows: WorkflowDefinition[]; sidecarCapabilities?: SidecarCapabilities; theme: AppearanceTheme; toolingSettings: WorkspaceToolingSettings; @@ -37,6 +40,9 @@ interface SettingsPanelProps { onSavePattern: (pattern: PatternDefinition) => Promise; onDeletePattern: (patternId: string) => Promise; onNewPattern: () => PatternDefinition; + onSaveWorkflow: (workflow: WorkflowDefinition) => Promise; + onDeleteWorkflow: (workflowId: string) => Promise; + onNewWorkflow: () => WorkflowDefinition; onSaveMcpServer: (server: McpServerDefinition) => Promise; onDeleteMcpServer: (serverId: string) => Promise; onNewMcpServer: () => McpServerDefinition; @@ -60,7 +66,7 @@ interface SettingsPanelProps { onGetQuota?: () => Promise>; } -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: }, + { id: 'workflows', label: 'Workflows', icon: }, { id: 'agents', label: 'Agents', icon: }, ], }, @@ -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(initialSection ?? 'appearance'); const [editingPattern, setEditingPattern] = useState(null); + const [editingWorkflow, setEditingWorkflow] = useState(null); const [editingMcpServer, setEditingMcpServer] = useState(null); const [editingLspProfile, setEditingLspProfile] = useState(null); const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState(null); @@ -184,6 +196,29 @@ export function SettingsPanel({ ); } + if (editingWorkflow) { + return ( +
+ setEditingWorkflow(null)} + onChange={setEditingWorkflow} + onDelete={ + async () => { + await onDeleteWorkflow(editingWorkflow.id); + setEditingWorkflow(null); + } + } + onSave={async () => { + await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow)); + setEditingWorkflow(null); + }} + workflow={editingWorkflow} + /> +
+ ); + } + if (editingMcpServer) { const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id); return ( @@ -337,6 +372,13 @@ export function SettingsPanel({ patterns={patterns} /> )} + {activeSection === 'workflows' && ( + setEditingWorkflow(structuredClone(wf))} + onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())} + workflows={workflows} + /> + )} {activeSection === 'agents' && ( void; + onNewWorkflow: () => void; +}) { + return ( +
+ + + + +
+ {workflows.map((wf) => { + const agentCount = wf.graph.nodes.filter((n) => n.kind === 'agent').length; + return ( + + ); + })} + {workflows.length === 0 && ( +

+ No workflows yet. Create one to get started. +

+ )} +
+
+ ); +} + function WorkspaceAgentsSection({ agents, patterns, diff --git a/src/renderer/components/WorkflowEditor.tsx b/src/renderer/components/WorkflowEditor.tsx new file mode 100644 index 0000000..8ef13b6 --- /dev/null +++ b/src/renderer/components/WorkflowEditor.tsx @@ -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; + 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 ( +