feat: add frontend orchestration mode UI to workflow designer

- Add OrchestrationModePanel with mode cards for all 5 modes (single,
  sequential, concurrent, handoff, group-chat)
- Add handoff settings panel: triage agent selector, tool-call
  filtering, return-to-previous toggle, custom instructions
- Add group-chat settings panel: selection strategy display, max
  rounds input
- Add auto-inference indicator when mode is not explicitly set
- Add scaffold dialog when changing mode with existing agents
- Add mode badge (top-left pill) on workflow graph canvas
- Wire orchestration mode panel into WorkflowSettingsPanel above
  general settings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-06 23:30:01 +02:00
co-authored by Copilot
parent e265714225
commit f4cb4c768b
3 changed files with 541 additions and 2 deletions
@@ -21,6 +21,7 @@ import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
import { ExportDropdown, ExportModal, ImportModal } from './workflow/WorkflowExportImportPanel';
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
@@ -655,6 +656,12 @@ function WorkflowSettingsPanel({
<h4 className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Settings
</h4>
{/* Orchestration mode — prominent, above general settings */}
<div className="mb-5">
<OrchestrationModePanel workflow={workflow} onChange={onChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<InputField
label="Name"
@@ -0,0 +1,503 @@
import { useCallback, useMemo, useState } from 'react';
import {
ArrowRightLeft,
Bot,
ChevronDown,
Layers,
MessageCircle,
Repeat,
Route,
User,
} from 'lucide-react';
import type {
HandoffModeSettings,
GroupChatModeSettings,
WorkflowDefinition,
WorkflowNode,
WorkflowOrchestrationMode,
} from '@shared/domain/workflow';
import {
createDefaultModeSettings,
inferWorkflowOrchestrationMode,
isBuilderBasedMode,
isGraphBasedMode,
scaffoldGraphForMode,
} from '@shared/domain/workflow';
import { FormField, InfoCallout, SelectInput, ToggleSwitch } from '@renderer/components/ui';
/* ── Mode metadata ─────────────────────────────────────────── */
interface ModeOption {
value: WorkflowOrchestrationMode;
label: string;
description: string;
icon: typeof Bot;
accentClass: string;
}
const modeOptions: ModeOption[] = [
{
value: 'single',
label: 'Single Agent',
description: 'One agent handles the full conversation directly.',
icon: User,
accentClass: 'text-emerald-400',
},
{
value: 'sequential',
label: 'Sequential',
description: 'Agents execute in order, each seeing the full conversation history.',
icon: ArrowRightLeft,
accentClass: 'text-sky-400',
},
{
value: 'concurrent',
label: 'Concurrent',
description: 'Agents execute in parallel via fan-out, results collected at barrier.',
icon: Layers,
accentClass: 'text-amber-400',
},
{
value: 'handoff',
label: 'Handoff',
description: 'Triage agent routes requests to specialists via handoff tool calls.',
icon: Route,
accentClass: 'text-violet-400',
},
{
value: 'group-chat',
label: 'Group Chat',
description: 'Agents take turns in a managed conversation loop.',
icon: MessageCircle,
accentClass: 'text-rose-400',
},
];
const modeMap = new Map(modeOptions.map((m) => [m.value, m]));
/* ── Scaffold confirmation dialog ──────────────────────────── */
function ScaffoldDialog({
fromMode,
toMode,
onAccept,
onDecline,
}: {
fromMode: string;
toMode: WorkflowOrchestrationMode;
onAccept: () => void;
onDecline: () => void;
}) {
const target = modeMap.get(toMode);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="scaffold-dialog-title"
>
<div
className="mx-4 w-full max-w-md animate-[palette-enter_0.2s_ease-out] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-2)] p-6 shadow-2xl"
>
<h3
id="scaffold-dialog-title"
className="mb-2 text-[14px] font-semibold text-[var(--color-text-primary)]"
>
Restructure graph for {target?.label ?? toMode}?
</h3>
<p className="mb-5 text-[13px] leading-relaxed text-[var(--color-text-secondary)]">
This will update edges to match the <span className="font-medium text-[var(--color-text-primary)]">{target?.label ?? toMode}</span> topology while keeping your existing agents.
{fromMode && (
<span className="mt-1 block text-[12px] text-[var(--color-text-muted)]">
Changing from {fromMode}.
</span>
)}
</p>
<div className="flex items-center justify-end gap-2.5">
<button
type="button"
onClick={onDecline}
className="rounded-lg px-4 py-2 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
>
Keep current graph
</button>
<button
type="button"
onClick={onAccept}
className="rounded-lg bg-[var(--color-accent)] px-4 py-2 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-hover)]"
>
Restructure
</button>
</div>
</div>
</div>
);
}
/* ── Mode selector card ────────────────────────────────────── */
function ModeCard({
option,
selected,
onClick,
}: {
option: ModeOption;
selected: boolean;
onClick: () => void;
}) {
const Icon = option.icon;
return (
<button
type="button"
onClick={onClick}
className={`group flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-all duration-200 ${
selected
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)] shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_16px_rgba(36,92,249,0.06)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-1)] hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-2)]'
}`}
aria-pressed={selected}
>
<div className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg ${
selected ? 'bg-[var(--color-accent)]/15' : 'bg-[var(--color-surface-3)]'
}`}>
<Icon className={`size-3.5 ${selected ? 'text-[var(--color-accent)]' : option.accentClass}`} />
</div>
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-medium ${
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]'
}`}>
{option.label}
</div>
<div className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
{option.description}
</div>
</div>
</button>
);
}
/* ── Handoff settings sub-panel ────────────────────────────── */
function HandoffSettingsPanel({
settings,
agentNodes,
onChange,
}: {
settings: HandoffModeSettings;
agentNodes: WorkflowNode[];
onChange: (settings: HandoffModeSettings) => void;
}) {
const triageOptions = useMemo(() => [
{ value: '', label: 'First agent (default)' },
...agentNodes.map((node) => ({
value: node.id,
label: node.label || (node.config.kind === 'agent' ? node.config.name : node.id),
})),
], [agentNodes]);
const filteringOptions = [
{ value: 'none', label: 'None — All tools available' },
{ value: 'handoff-only', label: 'Handoff-only — Restrict to handoff tools' },
{ value: 'all', label: 'All — Full tool filtering' },
];
return (
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
<div className="flex items-center gap-2">
<Route className="size-3.5 text-violet-400" />
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Handoff Settings
</span>
</div>
<FormField label="Triage Agent">
<SelectInput
value={settings.triageAgentNodeId ?? ''}
options={triageOptions}
onChange={(v) => onChange({ ...settings, triageAgentNodeId: v || undefined })}
/>
</FormField>
<FormField label="Tool-Call Filtering">
<SelectInput
value={settings.toolCallFiltering}
options={filteringOptions}
onChange={(v) => onChange({ ...settings, toolCallFiltering: v as HandoffModeSettings['toolCallFiltering'] })}
/>
</FormField>
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
<div>
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Return to Previous</div>
<p className="text-[11px] text-[var(--color-text-muted)]">Allow specialists to hand back to the previous agent</p>
</div>
<button type="button" className="cursor-pointer" onClick={() => onChange({ ...settings, returnToPrevious: !settings.returnToPrevious })}>
<ToggleSwitch enabled={settings.returnToPrevious} />
</button>
</div>
<FormField label="Custom Handoff Instructions">
<textarea
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
rows={3}
placeholder="Override default handoff guidance (optional)"
value={settings.handoffInstructions ?? ''}
onChange={(e) => onChange({ ...settings, handoffInstructions: e.target.value || undefined })}
/>
</FormField>
</div>
);
}
/* ── Group-chat settings sub-panel ─────────────────────────── */
function GroupChatSettingsPanel({
settings,
onChange,
}: {
settings: GroupChatModeSettings;
onChange: (settings: GroupChatModeSettings) => void;
}) {
return (
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
<div className="flex items-center gap-2">
<MessageCircle className="size-3.5 text-rose-400" />
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Group Chat Settings
</span>
</div>
<FormField label="Selection Strategy">
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
<Repeat className="size-3.5 text-[var(--color-text-muted)]" />
<span className="text-[13px] text-[var(--color-text-primary)]">Round Robin</span>
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">Only strategy</span>
</div>
</FormField>
<FormField label="Max Rounds">
<input
type="number"
min={1}
max={100}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
placeholder="5"
value={settings.maxRounds}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
onChange({ ...settings, maxRounds: Number.isNaN(raw) ? 5 : Math.max(1, Math.min(100, raw)) });
}}
/>
</FormField>
</div>
);
}
/* ── Main orchestration mode panel ─────────────────────────── */
export function OrchestrationModePanel({
workflow,
onChange,
}: {
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
}) {
const [expanded, setExpanded] = useState(true);
const [scaffoldRequest, setScaffoldRequest] = useState<{
newMode: WorkflowOrchestrationMode;
fromLabel: string;
} | null>(null);
const currentMode = workflow.settings.orchestrationMode;
const inferredMode = useMemo(() => inferWorkflowOrchestrationMode(workflow), [workflow]);
const effectiveMode = currentMode ?? inferredMode;
const effectiveMeta = modeMap.get(effectiveMode);
const agentNodes = useMemo(
() => workflow.graph.nodes.filter((n) => n.kind === 'agent'),
[workflow.graph.nodes],
);
const handoffSettings: HandoffModeSettings = useMemo(
() => workflow.settings.modeSettings?.handoff ?? createDefaultModeSettings('handoff')!.handoff!,
[workflow.settings.modeSettings?.handoff],
);
const groupChatSettings: GroupChatModeSettings = useMemo(
() => workflow.settings.modeSettings?.groupChat ?? createDefaultModeSettings('group-chat')!.groupChat!,
[workflow.settings.modeSettings?.groupChat],
);
const applyModeChange = useCallback(
(newMode: WorkflowOrchestrationMode, restructure: boolean) => {
const modeSettings = createDefaultModeSettings(newMode);
const base: WorkflowDefinition = {
...workflow,
settings: {
...workflow.settings,
orchestrationMode: newMode,
modeSettings: modeSettings
? { ...workflow.settings.modeSettings, ...modeSettings }
: workflow.settings.modeSettings,
},
};
if (restructure) {
const existingAgents = workflow.graph.nodes.filter((n) => n.kind === 'agent');
const newGraph = scaffoldGraphForMode(newMode, existingAgents.length > 0 ? existingAgents : undefined);
onChange({ ...base, graph: newGraph });
} else {
onChange(base);
}
},
[workflow, onChange],
);
const handleModeSelect = useCallback(
(newMode: WorkflowOrchestrationMode) => {
if (newMode === currentMode) return;
const hasAgents = agentNodes.length > 0;
const fromLabel = currentMode
? (modeMap.get(currentMode)?.label ?? currentMode)
: `Auto (${modeMap.get(inferredMode)?.label ?? inferredMode})`;
if (hasAgents) {
setScaffoldRequest({ newMode, fromLabel });
} else {
applyModeChange(newMode, true);
}
},
[currentMode, inferredMode, agentNodes.length, applyModeChange],
);
const handleHandoffChange = useCallback(
(handoff: HandoffModeSettings) => {
onChange({
...workflow,
settings: {
...workflow.settings,
modeSettings: { ...workflow.settings.modeSettings, handoff },
},
});
},
[workflow, onChange],
);
const handleGroupChatChange = useCallback(
(groupChat: GroupChatModeSettings) => {
onChange({
...workflow,
settings: {
...workflow.settings,
modeSettings: { ...workflow.settings.modeSettings, groupChat },
},
});
},
[workflow, onChange],
);
return (
<>
<div className="space-y-4">
{/* Section header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between"
aria-expanded={expanded}
>
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Orchestration Mode
</h4>
<div className="flex items-center gap-2">
{!currentMode && (
<span className="rounded-md bg-[var(--color-surface-3)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-muted)]">
Auto
</span>
)}
{effectiveMeta && (
<span className={`text-[11px] font-medium ${effectiveMeta.accentClass}`}>
{effectiveMeta.label}
</span>
)}
<ChevronDown
className={`size-3.5 text-[var(--color-text-muted)] transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
/>
</div>
</button>
{expanded && (
<div className="space-y-4">
{/* Mode cards */}
<div className="grid grid-cols-1 gap-2">
{modeOptions.map((option) => (
<ModeCard
key={option.value}
option={option}
selected={effectiveMode === option.value}
onClick={() => handleModeSelect(option.value)}
/>
))}
</div>
{/* Auto-inference indicator */}
{!currentMode && (
<InfoCallout>
Mode is auto-inferred from graph shape as <span className="font-medium text-[var(--color-text-primary)]">{effectiveMeta?.label}</span>.
Select a mode explicitly to lock it.
</InfoCallout>
)}
{/* Builder-based mode info */}
{isBuilderBasedMode(effectiveMode) && (
<InfoCallout>
In {effectiveMeta?.label} mode, the framework manages agent routing automatically. The graph defines participants.
</InfoCallout>
)}
{/* Graph-based mode info */}
{isGraphBasedMode(effectiveMode) && currentMode && (
<InfoCallout>
This mode uses the graph topology for execution. Connect agents with the appropriate edge types.
</InfoCallout>
)}
{/* Mode-specific settings */}
{effectiveMode === 'handoff' && (
<HandoffSettingsPanel
settings={handoffSettings}
agentNodes={agentNodes}
onChange={handleHandoffChange}
/>
)}
{effectiveMode === 'group-chat' && (
<GroupChatSettingsPanel
settings={groupChatSettings}
onChange={handleGroupChatChange}
/>
)}
</div>
)}
</div>
{/* Scaffold confirmation dialog */}
{scaffoldRequest && (
<ScaffoldDialog
fromMode={scaffoldRequest.fromLabel}
toMode={scaffoldRequest.newMode}
onAccept={() => {
applyModeChange(scaffoldRequest.newMode, true);
setScaffoldRequest(null);
}}
onDecline={() => {
applyModeChange(scaffoldRequest.newMode, false);
setScaffoldRequest(null);
}}
/>
)}
</>
);
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
ReactFlowProvider,
@@ -19,8 +19,9 @@ import {
import '@xyflow/react/dist/style.css';
import { LayoutGrid, Maximize2, ZoomIn, ZoomOut } from 'lucide-react';
import type { WorkflowDefinition, WorkflowGraph } from '@shared/domain/workflow';
import type { WorkflowDefinition, WorkflowGraph, WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { ModelDefinition } from '@shared/domain/models';
import { inferWorkflowOrchestrationMode } from '@shared/domain/workflow';
import {
addWorkflowEdge,
autoLayoutWorkflowGraph,
@@ -60,6 +61,19 @@ function WorkflowGraphCanvasInner({
const draggingRef = useRef(false);
const [zoomLevel, setZoomLevel] = useState(1);
const modeBadge = useMemo(() => {
const mode = inferWorkflowOrchestrationMode(workflow);
const explicit = !!workflow.settings.orchestrationMode;
const meta: Record<WorkflowOrchestrationMode, { label: string; icon: string; color: string }> = {
single: { label: 'Single', icon: '●', color: 'text-emerald-400' },
sequential: { label: 'Sequential', icon: '→', color: 'text-sky-400' },
concurrent: { label: 'Concurrent', icon: '⇉', color: 'text-amber-400' },
handoff: { label: 'Handoff', icon: '⇢', color: 'text-violet-400' },
'group-chat': { label: 'Group Chat', icon: '⟳', color: 'text-rose-400' },
};
return { mode, explicit, ...meta[mode] };
}, [workflow]);
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
toCanvasNodes(graph, availableModels, workflows),
);
@@ -249,6 +263,21 @@ function WorkflowGraphCanvasInner({
deleteKeyCode="Delete"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
<Panel position="top-left">
<div className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 shadow-sm backdrop-blur">
<span className={`text-[12px] font-semibold leading-none ${modeBadge.color}`}>
{modeBadge.icon}
</span>
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{modeBadge.label}
</span>
{!modeBadge.explicit && (
<span className="rounded bg-[var(--color-surface-3)] px-1.5 py-px text-[10px] text-[var(--color-text-muted)]">
auto
</span>
)}
</div>
</Panel>
<MiniMap
className="!rounded-lg !border !border-[var(--color-border)] !bg-[var(--color-surface-1)]"
maskColor="rgba(0,0,0,0.6)"