mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user