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>
This commit is contained in:
David Kaya
2026-04-05 21:28:43 +02:00
co-authored by Copilot
parent 4971dcf9fc
commit 55df0c3b61
5 changed files with 486 additions and 5 deletions
+7
View File
@@ -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}
+141 -1
View File
@@ -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<void>;
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
workflowTemplates?: WorkflowTemplateDefinition[];
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
onUpgradePatternToWorkflow?: (patternId: string) => Promise<void>;
}
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<SettingsSection>(initialSection ?? 'appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
@@ -197,6 +205,7 @@ export function SettingsPanel({
}
if (editingWorkflow) {
const api = getElectronApi();
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<WorkflowEditor
@@ -221,6 +230,14 @@ export function SettingsPanel({
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
setEditingWorkflow(null);
}}
onExportWorkflow={async (format) => {
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({
<PatternsSection
onEditPattern={(pattern) => 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<void>;
}) {
return (
<div>
@@ -643,6 +665,27 @@ function PatternsSection({
<span className="text-[12px] text-[var(--color-text-muted)]">
{pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'}
</span>
{onUpgradePatternToWorkflow && (
<span
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] opacity-0 transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)] group-hover:opacity-100"
title="Convert to workflow"
role="button"
onClick={(e) => {
e.stopPropagation();
void onUpgradePatternToWorkflow(pattern.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
void onUpgradePatternToWorkflow(pattern.id);
}
}}
tabIndex={0}
>
<ArrowUpRight className="size-3.5" />
</span>
)}
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
</div>
</button>
@@ -652,15 +695,31 @@ function PatternsSection({
);
}
const categoryLabels: Record<WorkflowTemplateCategory, string> = {
'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<void>;
}) {
const [categoryFilter, setCategoryFilter] = useState<WorkflowTemplateCategory | 'all'>('all');
const filteredTemplates = (workflowTemplates ?? []).filter(
(t) => categoryFilter === 'all' || t.category === categoryFilter,
);
return (
<div>
<SectionHeader
@@ -704,6 +763,87 @@ function WorkflowsSection({
</p>
)}
</div>
{/* Template Gallery */}
{workflowTemplates && workflowTemplates.length > 0 && (
<div className="mt-8">
<div className="mb-3">
<h4 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Templates</h4>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Start from a pre-built workflow template
</p>
</div>
<div className="mb-4 flex gap-1.5">
{(['all', 'orchestration', 'data-pipeline', 'human-in-loop'] as const).map((cat) => (
<button
key={cat}
className={`rounded-lg px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
categoryFilter === cat
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => setCategoryFilter(cat)}
type="button"
>
{cat === 'all' ? 'All' : categoryLabels[cat]}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
{filteredTemplates.map((template) => {
const nodeCount = template.workflow.graph.nodes.length;
const agentCount = template.workflow.graph.nodes.filter((n) => n.kind === 'agent').length;
return (
<div
key={template.id}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)]/50 p-4 transition-all duration-200 hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-1)]"
>
<div className="mb-2 flex items-start justify-between gap-2">
<h5 className="text-[13px] font-medium text-[var(--color-text-primary)]">{template.name}</h5>
<div className="flex shrink-0 gap-1">
<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)]">
{categoryLabels[template.category]}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
template.source === 'builtin'
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'
}`}>
{template.source}
</span>
</div>
</div>
<p className="mb-3 line-clamp-2 text-[12px] leading-relaxed text-[var(--color-text-muted)]">
{template.description}
</p>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--color-text-muted)]">
{nodeCount} node{nodeCount === 1 ? '' : 's'} · {agentCount} agent{agentCount === 1 ? '' : 's'}
</span>
{onCreateWorkflowFromTemplate && (
<button
className="rounded-lg bg-[var(--color-accent)] px-3 py-1 text-[11px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
onClick={() => void onCreateWorkflowFromTemplate(template.id)}
type="button"
>
Use Template
</button>
)}
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<p className="py-6 text-center text-[12px] text-[var(--color-text-muted)]">
No templates in this category.
</p>
)}
</div>
)}
</div>
);
}
+61 -1
View File
@@ -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<WorkflowDefinition>;
}
interface WorkflowBreadcrumb {
@@ -158,10 +161,15 @@ export function WorkflowEditor({
onDelete,
onSave,
onBack,
onExportWorkflow,
onImportWorkflow,
}: WorkflowEditorProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<WorkflowBreadcrumb[]>([]);
const [showExportDropdown, setShowExportDropdown] = useState(false);
const [exportResult, setExportResult] = useState<{ format: 'yaml' | 'mermaid' | 'dot'; content: string } | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
/* ── Active workflow resolution ──────────────────────────── */
@@ -371,6 +379,39 @@ export function WorkflowEditor({
</div>
</div>
<div className="no-drag flex items-center gap-2">
{onExportWorkflow && (
<div className="relative">
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={() => setShowExportDropdown((prev) => !prev)}
type="button"
aria-expanded={showExportDropdown}
aria-haspopup="listbox"
>
<Download className="size-3.5" />
Export
</button>
{showExportDropdown && (
<ExportDropdown
onSelectFormat={async (format) => {
const result = await onExportWorkflow(format);
setExportResult({ format, content: result.content });
}}
onClose={() => setShowExportDropdown(false)}
/>
)}
</div>
)}
{onImportWorkflow && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={() => setShowImportModal(true)}
type="button"
>
<Upload className="size-3.5" />
Import
</button>
)}
{onDelete && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
@@ -509,6 +550,25 @@ export function WorkflowEditor({
/>
</div>
</div>
{exportResult && (
<ExportModal
format={exportResult.format}
content={exportResult.content}
onClose={() => setExportResult(null)}
/>
)}
{showImportModal && onImportWorkflow && (
<ImportModal
onImport={async (content, format) => {
const imported = await onImportWorkflow(content, format);
onChange(imported);
return imported;
}}
onClose={() => setShowImportModal(false)}
/>
)}
</div>
);
}
@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="export-modal-title"
>
<div className="w-full max-w-lg rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)] p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 id="export-modal-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
Export {format.toUpperCase()}
</h3>
<button
className="flex size-7 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={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<pre className="max-h-80 overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4 font-mono text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{content}
</pre>
<div className="mt-4 flex justify-end">
<button
className="flex items-center gap-1.5 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={handleCopy}
type="button"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
</div>
</div>
);
}
/* ── Import Modal ──────────────────────────────────────────── */
interface ImportModalProps {
onImport: (content: string, format: ImportFormat) => Promise<WorkflowDefinition>;
onClose: () => void;
}
export function ImportModal({ onImport, onClose }: ImportModalProps) {
const [content, setContent] = useState('');
const [format, setFormat] = useState<ImportFormat>('yaml');
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="import-modal-title"
>
<div className="w-full max-w-lg rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)] p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 id="import-modal-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
Import Workflow
</h3>
<button
className="flex size-7 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={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<div className="mb-3 flex gap-2">
{(['yaml', 'json'] as const).map((f) => (
<button
key={f}
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
format === f
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => setFormat(f)}
type="button"
>
{f.toUpperCase()}
</button>
))}
</div>
<textarea
className="min-h-48 w-full resize-y rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4 font-mono text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50"
onChange={(e) => setContent(e.target.value)}
placeholder={`Paste your ${format.toUpperCase()} workflow definition here...`}
value={content}
/>
{error && (
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">{error}</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="flex items-center gap-1.5 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)] disabled:opacity-50"
disabled={importing || !content.trim()}
onClick={handleImport}
type="button"
>
<Upload className="size-3.5" />
{importing ? 'Importing…' : 'Import'}
</button>
</div>
</div>
</div>
);
}
/* ── Export Dropdown ───────────────────────────────────────── */
interface ExportDropdownProps {
onSelectFormat: (format: ExportFormat) => void;
onClose: () => void;
}
export function ExportDropdown({ onSelectFormat, onClose }: ExportDropdownProps) {
const formats: { value: ExportFormat; label: string }[] = [
{ value: 'yaml', label: 'YAML' },
{ value: 'mermaid', label: 'Mermaid' },
{ value: 'dot', label: 'DOT (Graphviz)' },
];
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div
className="absolute right-0 top-full z-50 mt-1 w-40 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] py-1 shadow-xl"
role="listbox"
>
{formats.map((f) => (
<button
key={f.value}
className="flex w-full items-center gap-2 px-3 py-2 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)]"
onClick={() => { onSelectFormat(f.value); onClose(); }}
role="option"
aria-selected={false}
type="button"
>
<Download className="size-3" />
{f.label}
</button>
))}
</div>
</>
);
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ReactFlow,
ReactFlowProvider,
@@ -17,7 +17,7 @@ import {
type OnNodesChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { LayoutGrid } from 'lucide-react';
import { LayoutGrid, Maximize2, ZoomIn, ZoomOut } from 'lucide-react';
import type { WorkflowDefinition, WorkflowGraph } from '@shared/domain/workflow';
import type { ModelDefinition } from '@shared/domain/models';
@@ -54,9 +54,11 @@ function WorkflowGraphCanvasInner({
onEdgeSelect,
selectedNodeId,
}: WorkflowGraphCanvasProps) {
const { fitView } = useReactFlow();
const reactFlow = useReactFlow();
const { fitView } = reactFlow;
const graph = workflow.graph;
const draggingRef = useRef(false);
const [zoomLevel, setZoomLevel] = useState(1);
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
toCanvasNodes(graph, availableModels, workflows),
@@ -185,6 +187,36 @@ function WorkflowGraphCanvasInner({
requestAnimationFrame(() => fitView({ padding: 0.3 }));
}, [graph, onGraphChange, fitView]);
const handleZoomIn = useCallback(() => {
reactFlow.zoomIn();
}, [reactFlow]);
const handleZoomOut = useCallback(() => {
reactFlow.zoomOut();
}, [reactFlow]);
const handleFitView = useCallback(() => {
fitView({ padding: 0.3 });
}, [fitView]);
// Ctrl+A to select all nodes
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
setNodes((currentNodes) =>
currentNodes.map((n) => ({ ...n, selected: true })),
);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [setNodes]);
return (
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
<ReactFlow
@@ -199,8 +231,10 @@ function WorkflowGraphCanvasInner({
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onPaneClick={handlePaneClick}
onMoveEnd={() => setZoomLevel(reactFlow.getZoom())}
nodeTypes={workflowNodeTypes}
edgeTypes={workflowEdgeTypes}
selectionOnDrag
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
@@ -231,6 +265,38 @@ function WorkflowGraphCanvasInner({
Auto layout
</button>
</Panel>
<Panel position="bottom-right">
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 p-1 shadow-sm backdrop-blur">
<button
type="button"
onClick={handleZoomOut}
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
title="Zoom out"
>
<ZoomOut className="size-3.5" />
</button>
<span className="min-w-[3rem] text-center text-[11px] font-medium text-[var(--color-text-secondary)]">
{Math.round(zoomLevel * 100)}%
</span>
<button
type="button"
onClick={handleZoomIn}
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
title="Zoom in"
>
<ZoomIn className="size-3.5" />
</button>
<div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />
<button
type="button"
onClick={handleFitView}
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
title="Fit view"
>
<Maximize2 className="size-3.5" />
</button>
</div>
</Panel>
</ReactFlow>
</div>
);