From 55df0c3b61d635e22dd16cfc4af61186c87a2eac Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 5 Apr 2026 21:28:43 +0200 Subject: [PATCH] feat: implement Phase 5 workflow frontend features - Template Gallery: category-filtered grid in WorkflowsSection with template cards showing name, description, category/source badges, and node/agent counts with 'Use Template' action - Export/Import: dropdown export (YAML/Mermaid/DOT) and import (YAML/JSON) modals in WorkflowEditor header toolbar via new WorkflowExportImportPanel component - Pattern-to-Workflow upgrade: ArrowUpRight icon button on each pattern row in PatternsSection calling upgradePatternToWorkflow - Canvas polish: zoom in/out/fit-view controls panel at bottom-right of WorkflowGraphCanvas, zoom percentage display, Ctrl+A select-all keyboard shortcut, and selectionOnDrag enabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 7 + src/renderer/components/SettingsPanel.tsx | 142 +++++++++++- src/renderer/components/WorkflowEditor.tsx | 62 +++++- .../workflow/WorkflowExportImportPanel.tsx | 208 ++++++++++++++++++ .../workflow/WorkflowGraphCanvas.tsx | 72 +++++- 5 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/workflow/WorkflowExportImportPanel.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 661aff3..763a622 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -828,6 +828,13 @@ export default function App() { }} patterns={workspace.patterns} workflows={workspace.workflows} + workflowTemplates={workspace.workflowTemplates} + onCreateWorkflowFromTemplate={async (templateId, name) => { + await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined }); + }} + onUpgradePatternToWorkflow={async (patternId) => { + await api.upgradePatternToWorkflow({ patternId, options: { save: true } }); + }} 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 2169b9e..c55c52c 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react'; -import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react'; +import { ArrowUpRight, 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'; @@ -8,6 +8,7 @@ import { ToggleSwitch } from '@renderer/components/ui'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor'; +import { getElectronApi } from '@renderer/lib/electronApi'; import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar'; import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; @@ -15,6 +16,7 @@ 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 type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate'; import { normalizeLspProfileDefinition, normalizeMcpServerDefinition, @@ -64,6 +66,9 @@ interface SettingsPanelProps { onResetLocalWorkspace: () => Promise; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; onGetQuota?: () => Promise>; + workflowTemplates?: WorkflowTemplateDefinition[]; + onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise; + onUpgradePatternToWorkflow?: (patternId: string) => Promise; } export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; @@ -159,6 +164,9 @@ export function SettingsPanel({ onResetLocalWorkspace, onResolveUserDiscoveredTooling, onGetQuota, + workflowTemplates, + onCreateWorkflowFromTemplate, + onUpgradePatternToWorkflow, }: SettingsPanelProps) { const [activeSection, setActiveSection] = useState(initialSection ?? 'appearance'); const [editingPattern, setEditingPattern] = useState(null); @@ -197,6 +205,7 @@ export function SettingsPanel({ } if (editingWorkflow) { + const api = getElectronApi(); return (
{ + const result = await api.exportWorkflow({ workflowId: editingWorkflow.id, format }); + return result; + }} + onImportWorkflow={async (content, format) => { + const result = await api.importWorkflow({ content, format, options: { save: false } }); + return result.workflow; + }} workflow={editingWorkflow} workflows={workflows} /> @@ -378,6 +395,7 @@ export function SettingsPanel({ setEditingPattern(structuredClone(pattern))} onNewPattern={() => setEditingPattern(onNewPattern())} + onUpgradePatternToWorkflow={onUpgradePatternToWorkflow} patterns={patterns} /> )} @@ -386,6 +404,8 @@ export function SettingsPanel({ onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))} onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())} workflows={workflows} + workflowTemplates={workflowTemplates} + onCreateWorkflowFromTemplate={onCreateWorkflowFromTemplate} /> )} {activeSection === 'agents' && ( @@ -608,10 +628,12 @@ function PatternsSection({ patterns, onEditPattern, onNewPattern, + onUpgradePatternToWorkflow, }: { patterns: PatternDefinition[]; onEditPattern: (pattern: PatternDefinition) => void; onNewPattern: () => void; + onUpgradePatternToWorkflow?: (patternId: string) => Promise; }) { return (
@@ -643,6 +665,27 @@ function PatternsSection({ {pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'} + {onUpgradePatternToWorkflow && ( + { + e.stopPropagation(); + void onUpgradePatternToWorkflow(pattern.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + e.preventDefault(); + void onUpgradePatternToWorkflow(pattern.id); + } + }} + tabIndex={0} + > + + + )}
@@ -652,15 +695,31 @@ function PatternsSection({ ); } +const categoryLabels: Record = { + 'orchestration': 'Orchestration', + 'data-pipeline': 'Data Pipeline', + 'human-in-loop': 'Human-in-Loop', +}; + function WorkflowsSection({ workflows, onEditWorkflow, onNewWorkflow, + workflowTemplates, + onCreateWorkflowFromTemplate, }: { workflows: WorkflowDefinition[]; onEditWorkflow: (workflow: WorkflowDefinition) => void; onNewWorkflow: () => void; + workflowTemplates?: WorkflowTemplateDefinition[]; + onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise; }) { + const [categoryFilter, setCategoryFilter] = useState('all'); + + const filteredTemplates = (workflowTemplates ?? []).filter( + (t) => categoryFilter === 'all' || t.category === categoryFilter, + ); + return (
)}
+ + {/* Template Gallery */} + {workflowTemplates && workflowTemplates.length > 0 && ( +
+
+

Templates

+

+ Start from a pre-built workflow template +

+
+ +
+ {(['all', 'orchestration', 'data-pipeline', 'human-in-loop'] as const).map((cat) => ( + + ))} +
+ +
+ {filteredTemplates.map((template) => { + const nodeCount = template.workflow.graph.nodes.length; + const agentCount = template.workflow.graph.nodes.filter((n) => n.kind === 'agent').length; + return ( +
+
+
{template.name}
+
+ + {categoryLabels[template.category]} + + + {template.source} + +
+
+

+ {template.description} +

+
+ + {nodeCount} node{nodeCount === 1 ? '' : 's'} · {agentCount} agent{agentCount === 1 ? '' : 's'} + + {onCreateWorkflowFromTemplate && ( + + )} +
+
+ ); + })} +
+ + {filteredTemplates.length === 0 && ( +

+ No templates in this category. +

+ )} +
+ )}
); } diff --git a/src/renderer/components/WorkflowEditor.tsx b/src/renderer/components/WorkflowEditor.tsx index 8b283b5..9b1f1d2 100644 --- a/src/renderer/components/WorkflowEditor.tsx +++ b/src/renderer/components/WorkflowEditor.tsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useMemo, useState } from 'react'; -import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Info, Plus, Trash2, X } from 'lucide-react'; +import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Download, Info, Plus, Trash2, Upload, X } from 'lucide-react'; import type { ModelDefinition } from '@shared/domain/models'; import type { @@ -18,6 +18,7 @@ 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'; @@ -29,6 +30,8 @@ interface WorkflowEditorProps { onDelete?: () => void; onSave: () => void; onBack: () => void; + onExportWorkflow?: (format: 'yaml' | 'mermaid' | 'dot') => Promise<{ content: string }>; + onImportWorkflow?: (content: string, format: 'yaml' | 'json') => Promise; } interface WorkflowBreadcrumb { @@ -158,10 +161,15 @@ export function WorkflowEditor({ onDelete, onSave, onBack, + onExportWorkflow, + onImportWorkflow, }: WorkflowEditorProps) { const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [breadcrumbs, setBreadcrumbs] = useState([]); + 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 ──────────────────────────── */ @@ -371,6 +379,39 @@ export function WorkflowEditor({
+ {onExportWorkflow && ( +
+ + {showExportDropdown && ( + { + const result = await onExportWorkflow(format); + setExportResult({ format, content: result.content }); + }} + onClose={() => setShowExportDropdown(false)} + /> + )} +
+ )} + {onImportWorkflow && ( + + )} {onDelete && (
+ + {exportResult && ( + setExportResult(null)} + /> + )} + + {showImportModal && onImportWorkflow && ( + { + const imported = await onImportWorkflow(content, format); + onChange(imported); + return imported; + }} + onClose={() => setShowImportModal(false)} + /> + )} ); } diff --git a/src/renderer/components/workflow/WorkflowExportImportPanel.tsx b/src/renderer/components/workflow/WorkflowExportImportPanel.tsx new file mode 100644 index 0000000..87e0d80 --- /dev/null +++ b/src/renderer/components/workflow/WorkflowExportImportPanel.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react'; +import { Check, Copy, Download, Upload, X } from 'lucide-react'; + +import type { WorkflowDefinition } from '@shared/domain/workflow'; + +type ExportFormat = 'yaml' | 'mermaid' | 'dot'; +type ImportFormat = 'yaml' | 'json'; + +/* ── Export Modal ──────────────────────────────────────────── */ + +interface ExportModalProps { + format: ExportFormat; + content: string; + onClose: () => void; +} + +export function ExportModal({ format, content, onClose }: ExportModalProps) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + void navigator.clipboard.writeText(content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="export-modal-title" + > +
+
+

+ Export — {format.toUpperCase()} +

+ +
+ +
+          {content}
+        
+ +
+ +
+
+
+ ); +} + +/* ── Import Modal ──────────────────────────────────────────── */ + +interface ImportModalProps { + onImport: (content: string, format: ImportFormat) => Promise; + onClose: () => void; +} + +export function ImportModal({ onImport, onClose }: ImportModalProps) { + const [content, setContent] = useState(''); + const [format, setFormat] = useState('yaml'); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + + async function handleImport() { + if (!content.trim()) { + setError('Please paste workflow content'); + return; + } + setError(null); + setImporting(true); + try { + await onImport(content, format); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed'); + } finally { + setImporting(false); + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="import-modal-title" + > +
+
+

+ Import Workflow +

+ +
+ +
+ {(['yaml', 'json'] as const).map((f) => ( + + ))} +
+ +