mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 18:38:35 +02:00
feat(workflows): add sub-workflow inspector, breadcrumb navigation, and reference UI
Phase 3 frontend: sub-workflow node inspector with reference/inline mode toggle, breadcrumb drill-down navigation for nested workflows, read-only view for referenced workflows, inline workflow editing with stack propagation, sub-workflow label badges on canvas nodes, and referential integrity error display on delete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ExternalLink, GitBranch, Link, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
SubWorkflowConfig,
|
||||
WorkflowDefinition,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
} from '@shared/domain/workflow';
|
||||
|
||||
interface SubWorkflowInspectorProps {
|
||||
node: WorkflowNode;
|
||||
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||
currentWorkflowId: string;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
|
||||
}
|
||||
|
||||
type SourceMode = 'reference' | 'inline';
|
||||
|
||||
function resolveSourceMode(config: SubWorkflowConfig): SourceMode {
|
||||
if (config.workflowId) return 'reference';
|
||||
if (config.inlineWorkflow) return 'inline';
|
||||
return 'reference';
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubWorkflowInspector({
|
||||
node,
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
onDrillIntoSubWorkflow,
|
||||
}: SubWorkflowInspectorProps) {
|
||||
const config = node.config as SubWorkflowConfig;
|
||||
const sourceMode = resolveSourceMode(config);
|
||||
|
||||
const availableWorkflows = workflows.filter((wf) => wf.id !== currentWorkflowId);
|
||||
const selectedWorkflow = config.workflowId
|
||||
? workflows.find((wf) => wf.id === config.workflowId)
|
||||
: undefined;
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: SourceMode) => {
|
||||
if (mode === 'reference') {
|
||||
onNodeConfigChange(node.id, { kind: 'sub-workflow', workflowId: config.workflowId });
|
||||
} else {
|
||||
onNodeConfigChange(node.id, {
|
||||
kind: 'sub-workflow',
|
||||
inlineWorkflow: config.inlineWorkflow,
|
||||
});
|
||||
}
|
||||
},
|
||||
[node.id, config.workflowId, config.inlineWorkflow, onNodeConfigChange],
|
||||
);
|
||||
|
||||
const handleWorkflowSelect = useCallback(
|
||||
(workflowId: string) => {
|
||||
onNodeConfigChange(node.id, {
|
||||
kind: 'sub-workflow',
|
||||
workflowId: workflowId || undefined,
|
||||
});
|
||||
},
|
||||
[node.id, onNodeConfigChange],
|
||||
);
|
||||
|
||||
const handleDrillIn = useCallback(() => {
|
||||
onDrillIntoSubWorkflow(node);
|
||||
}, [node, onDrillIntoSubWorkflow]);
|
||||
|
||||
const inlineNodeCount = config.inlineWorkflow?.graph?.nodes?.length ?? 0;
|
||||
const inlineEdgeCount = config.inlineWorkflow?.graph?.edges?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<GitBranch className="size-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{node.label || 'Sub-Workflow'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
|
||||
{/* Source mode selector */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Source</span>
|
||||
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-0.5">
|
||||
<button
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||
sourceMode === 'reference'
|
||||
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => handleModeChange('reference')}
|
||||
type="button"
|
||||
>
|
||||
Reference
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||
sourceMode === 'inline'
|
||||
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => handleModeChange('inline')}
|
||||
type="button"
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference mode */}
|
||||
{sourceMode === 'reference' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
Workflow
|
||||
</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => handleWorkflowSelect(e.target.value)}
|
||||
value={config.workflowId ?? ''}
|
||||
>
|
||||
<option value="">Select a workflow…</option>
|
||||
{availableWorkflows.map((wf) => (
|
||||
<option key={wf.id} value={wf.id}>
|
||||
{wf.name || 'Untitled'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedWorkflow?.description && (
|
||||
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{selectedWorkflow.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!config.workflowId || !selectedWorkflow}
|
||||
onClick={handleDrillIn}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Open Sub-Workflow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline mode */}
|
||||
{sourceMode === 'inline' && (
|
||||
<div className="space-y-3">
|
||||
{config.inlineWorkflow ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-[12px] text-amber-400">
|
||||
<Link className="size-3 shrink-0" />
|
||||
<span>
|
||||
{inlineNodeCount} node{inlineNodeCount !== 1 ? 's' : ''},{' '}
|
||||
{inlineEdgeCount} edge{inlineEdgeCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
No inline workflow configured yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10"
|
||||
onClick={handleDrillIn}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit Inline Workflow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { workflowEdgeTypes } from './WorkflowGraphEdges';
|
||||
interface WorkflowGraphCanvasProps {
|
||||
workflow: WorkflowDefinition;
|
||||
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||
workflows?: ReadonlyArray<WorkflowDefinition>;
|
||||
onGraphChange: (graph: WorkflowGraph) => void;
|
||||
onNodeSelect: (nodeId: string | null) => void;
|
||||
onEdgeSelect: (edgeId: string | null) => void;
|
||||
@@ -47,6 +48,7 @@ interface WorkflowGraphCanvasProps {
|
||||
function WorkflowGraphCanvasInner({
|
||||
workflow,
|
||||
availableModels,
|
||||
workflows,
|
||||
onGraphChange,
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
@@ -57,16 +59,16 @@ function WorkflowGraphCanvasInner({
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
||||
toCanvasNodes(graph, availableModels),
|
||||
toCanvasNodes(graph, availableModels, workflows),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
|
||||
toCanvasEdges(graph),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(toCanvasNodes(graph, availableModels));
|
||||
setNodes(toCanvasNodes(graph, availableModels, workflows));
|
||||
setEdges(toCanvasEdges(graph));
|
||||
}, [graph, availableModels, setNodes, setEdges]);
|
||||
}, [graph, availableModels, workflows, setNodes, setEdges]);
|
||||
|
||||
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
|
||||
(changes) => {
|
||||
|
||||
@@ -18,10 +18,12 @@ import type {
|
||||
} from '@shared/domain/workflow';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
||||
import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector';
|
||||
|
||||
interface WorkflowGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
workflow: WorkflowDefinition;
|
||||
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
validationIssues?: WorkflowValidationIssue[];
|
||||
@@ -30,6 +32,7 @@ interface WorkflowGraphInspectorProps {
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||
onEdgeRemove: (edgeId: string) => void;
|
||||
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
@@ -375,6 +378,7 @@ function EdgeInspector({
|
||||
export function WorkflowGraphInspector({
|
||||
availableModels,
|
||||
workflow,
|
||||
workflows,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
validationIssues,
|
||||
@@ -383,6 +387,7 @@ export function WorkflowGraphInspector({
|
||||
onNodeRemove,
|
||||
onEdgeChange,
|
||||
onEdgeRemove,
|
||||
onDrillIntoSubWorkflow,
|
||||
}: WorkflowGraphInspectorProps) {
|
||||
const selectedNode = selectedNodeId
|
||||
? workflow.graph.nodes.find((n) => n.id === selectedNodeId)
|
||||
@@ -436,6 +441,22 @@ export function WorkflowGraphInspector({
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'sub-workflow') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SubWorkflowInspector
|
||||
currentWorkflowId={workflow.id}
|
||||
node={selectedNode}
|
||||
onDrillIntoSubWorkflow={onDrillIntoSubWorkflow}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeConfigChange={onNodeConfigChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
workflows={workflows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<PlaceholderNodeInspector
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Bot, Circle, Code, FunctionSquare, GitBranch, Radio, Play, Square } from 'lucide-react';
|
||||
import { Bot, Circle, Code, FunctionSquare, GitBranch, Link2, Radio, Play, Square } from 'lucide-react';
|
||||
|
||||
import type { WorkflowGraphNodeData } from '@renderer/lib/workflowGraph';
|
||||
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
||||
@@ -39,6 +39,7 @@ const handleStyles = {
|
||||
function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) {
|
||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||
const isAgent = data.kind === 'agent';
|
||||
const isSubWorkflow = data.kind === 'sub-workflow';
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAgent && data.provider) {
|
||||
@@ -62,6 +63,18 @@ function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData;
|
||||
{isAgent && data.modelLabel && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
|
||||
)}
|
||||
{isSubWorkflow && data.subWorkflowLabel && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{data.subWorkflowLabel === 'Inline' ? (
|
||||
<span className="rounded bg-amber-500/15 px-1 py-0.5 text-amber-400">Inline</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Link2 className="inline size-2.5" />
|
||||
{data.subWorkflowLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user