mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +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}
|
patterns={workspace.patterns}
|
||||||
workflows={workspace.workflows}
|
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}
|
sidecarCapabilities={sidecarCapabilities}
|
||||||
theme={workspace.settings.theme}
|
theme={workspace.settings.theme}
|
||||||
toolingSettings={workspace.settings.tooling}
|
toolingSettings={workspace.settings.tooling}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||||
@@ -8,6 +8,7 @@ import { ToggleSwitch } from '@renderer/components/ui';
|
|||||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||||
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
||||||
|
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||||
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } 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 { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||||
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate';
|
||||||
import {
|
import {
|
||||||
normalizeLspProfileDefinition,
|
normalizeLspProfileDefinition,
|
||||||
normalizeMcpServerDefinition,
|
normalizeMcpServerDefinition,
|
||||||
@@ -64,6 +66,9 @@ interface SettingsPanelProps {
|
|||||||
onResetLocalWorkspace: () => Promise<void>;
|
onResetLocalWorkspace: () => Promise<void>;
|
||||||
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
||||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
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';
|
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||||
@@ -159,6 +164,9 @@ export function SettingsPanel({
|
|||||||
onResetLocalWorkspace,
|
onResetLocalWorkspace,
|
||||||
onResolveUserDiscoveredTooling,
|
onResolveUserDiscoveredTooling,
|
||||||
onGetQuota,
|
onGetQuota,
|
||||||
|
workflowTemplates,
|
||||||
|
onCreateWorkflowFromTemplate,
|
||||||
|
onUpgradePatternToWorkflow,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||||
@@ -197,6 +205,7 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editingWorkflow) {
|
if (editingWorkflow) {
|
||||||
|
const api = getElectronApi();
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
<WorkflowEditor
|
<WorkflowEditor
|
||||||
@@ -221,6 +230,14 @@ export function SettingsPanel({
|
|||||||
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
|
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
|
||||||
setEditingWorkflow(null);
|
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}
|
workflow={editingWorkflow}
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
/>
|
/>
|
||||||
@@ -378,6 +395,7 @@ export function SettingsPanel({
|
|||||||
<PatternsSection
|
<PatternsSection
|
||||||
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
|
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
|
||||||
onNewPattern={() => setEditingPattern(onNewPattern())}
|
onNewPattern={() => setEditingPattern(onNewPattern())}
|
||||||
|
onUpgradePatternToWorkflow={onUpgradePatternToWorkflow}
|
||||||
patterns={patterns}
|
patterns={patterns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -386,6 +404,8 @@ export function SettingsPanel({
|
|||||||
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
||||||
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
|
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
|
workflowTemplates={workflowTemplates}
|
||||||
|
onCreateWorkflowFromTemplate={onCreateWorkflowFromTemplate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeSection === 'agents' && (
|
{activeSection === 'agents' && (
|
||||||
@@ -608,10 +628,12 @@ function PatternsSection({
|
|||||||
patterns,
|
patterns,
|
||||||
onEditPattern,
|
onEditPattern,
|
||||||
onNewPattern,
|
onNewPattern,
|
||||||
|
onUpgradePatternToWorkflow,
|
||||||
}: {
|
}: {
|
||||||
patterns: PatternDefinition[];
|
patterns: PatternDefinition[];
|
||||||
onEditPattern: (pattern: PatternDefinition) => void;
|
onEditPattern: (pattern: PatternDefinition) => void;
|
||||||
onNewPattern: () => void;
|
onNewPattern: () => void;
|
||||||
|
onUpgradePatternToWorkflow?: (patternId: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -643,6 +665,27 @@ function PatternsSection({
|
|||||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
{pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'}
|
{pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'}
|
||||||
</span>
|
</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)]" />
|
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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({
|
function WorkflowsSection({
|
||||||
workflows,
|
workflows,
|
||||||
onEditWorkflow,
|
onEditWorkflow,
|
||||||
onNewWorkflow,
|
onNewWorkflow,
|
||||||
|
workflowTemplates,
|
||||||
|
onCreateWorkflowFromTemplate,
|
||||||
}: {
|
}: {
|
||||||
workflows: WorkflowDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
onEditWorkflow: (workflow: WorkflowDefinition) => void;
|
onEditWorkflow: (workflow: WorkflowDefinition) => void;
|
||||||
onNewWorkflow: () => 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -704,6 +763,87 @@ function WorkflowsSection({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
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 { ModelDefinition } from '@shared/domain/models';
|
||||||
import type {
|
import type {
|
||||||
@@ -18,6 +18,7 @@ import { createId } from '@shared/utils/ids';
|
|||||||
import { ToggleSwitch } from '@renderer/components/ui';
|
import { ToggleSwitch } from '@renderer/components/ui';
|
||||||
|
|
||||||
import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
|
import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
|
||||||
|
import { ExportDropdown, ExportModal, ImportModal } from './workflow/WorkflowExportImportPanel';
|
||||||
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
|
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
|
||||||
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
|
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ interface WorkflowEditorProps {
|
|||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onExportWorkflow?: (format: 'yaml' | 'mermaid' | 'dot') => Promise<{ content: string }>;
|
||||||
|
onImportWorkflow?: (content: string, format: 'yaml' | 'json') => Promise<WorkflowDefinition>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkflowBreadcrumb {
|
interface WorkflowBreadcrumb {
|
||||||
@@ -158,10 +161,15 @@ export function WorkflowEditor({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onSave,
|
onSave,
|
||||||
onBack,
|
onBack,
|
||||||
|
onExportWorkflow,
|
||||||
|
onImportWorkflow,
|
||||||
}: WorkflowEditorProps) {
|
}: WorkflowEditorProps) {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<WorkflowBreadcrumb[]>([]);
|
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 ──────────────────────────── */
|
/* ── Active workflow resolution ──────────────────────────── */
|
||||||
|
|
||||||
@@ -371,6 +379,39 @@ export function WorkflowEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="no-drag flex items-center gap-2">
|
<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 && (
|
{onDelete && (
|
||||||
<button
|
<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"
|
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>
|
||||||
</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>
|
</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 {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type OnNodesChange,
|
type OnNodesChange,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
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 { WorkflowDefinition, WorkflowGraph } from '@shared/domain/workflow';
|
||||||
import type { ModelDefinition } from '@shared/domain/models';
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
@@ -54,9 +54,11 @@ function WorkflowGraphCanvasInner({
|
|||||||
onEdgeSelect,
|
onEdgeSelect,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
}: WorkflowGraphCanvasProps) {
|
}: WorkflowGraphCanvasProps) {
|
||||||
const { fitView } = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
|
const { fitView } = reactFlow;
|
||||||
const graph = workflow.graph;
|
const graph = workflow.graph;
|
||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
||||||
toCanvasNodes(graph, availableModels, workflows),
|
toCanvasNodes(graph, availableModels, workflows),
|
||||||
@@ -185,6 +187,36 @@ function WorkflowGraphCanvasInner({
|
|||||||
requestAnimationFrame(() => fitView({ padding: 0.3 }));
|
requestAnimationFrame(() => fitView({ padding: 0.3 }));
|
||||||
}, [graph, onGraphChange, fitView]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
|
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -199,8 +231,10 @@ function WorkflowGraphCanvasInner({
|
|||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onEdgeClick={handleEdgeClick}
|
onEdgeClick={handleEdgeClick}
|
||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
|
onMoveEnd={() => setZoomLevel(reactFlow.getZoom())}
|
||||||
nodeTypes={workflowNodeTypes}
|
nodeTypes={workflowNodeTypes}
|
||||||
edgeTypes={workflowEdgeTypes}
|
edgeTypes={workflowEdgeTypes}
|
||||||
|
selectionOnDrag
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.3 }}
|
fitViewOptions={{ padding: 0.3 }}
|
||||||
minZoom={0.3}
|
minZoom={0.3}
|
||||||
@@ -231,6 +265,38 @@ function WorkflowGraphCanvasInner({
|
|||||||
Auto layout
|
Auto layout
|
||||||
</button>
|
</button>
|
||||||
</Panel>
|
</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>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user