Files
aryx/src/renderer/components/PatternEditor.tsx
T
David KayaandCopilot 018100a9a8 feat: fix settings header overlap, redesign pattern editor
- Fix SettingsPanel and PatternEditor headers with pt-12 to clear
  the title bar overlay zone
- Replace mode dropdown with visual mode selector cards showing
  icon, description, and inline flow diagrams for each pattern
- Add flow pill diagrams (Single, Sequential, Concurrent, Handoff,
  Group Chat, Magentic) illustrating how agents interact
- Add reasoning effort selector (Low/Medium/High/Maximum) to
  agent cards — previously not exposed in the editor
- Improve agent cards with numbered badges and 3-column layout
  for name, model, and reasoning fields

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-21 19:54:22 +01:00

466 lines
16 KiB
TypeScript

import { useState, type ReactNode } from 'react';
import {
AlertCircle,
ArrowLeftRight,
CheckCircle,
ChevronLeft,
GitFork,
ListOrdered,
Lock,
MessageSquare,
Plus,
Trash2,
Users,
type LucideIcon,
} from 'lucide-react';
import {
validatePatternDefinition,
type OrchestrationMode,
type PatternDefinition,
type ReasoningEffort,
type PatternAgentDefinition,
} from '@shared/domain/pattern';
interface PatternEditorProps {
pattern: PatternDefinition;
isBuiltin: boolean;
onChange: (pattern: PatternDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
}
const reasoningEfforts: { value: ReasoningEffort; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Maximum' },
];
interface ModeInfo {
icon: LucideIcon;
label: string;
description: string;
}
const modeInfo: Record<OrchestrationMode, ModeInfo> = {
single: {
icon: MessageSquare,
label: 'Single',
description: 'Direct conversation with one agent',
},
sequential: {
icon: ListOrdered,
label: 'Sequential',
description: 'Agents process in order, each refining the result',
},
concurrent: {
icon: GitFork,
label: 'Concurrent',
description: 'Multiple agents respond in parallel',
},
handoff: {
icon: ArrowLeftRight,
label: 'Handoff',
description: 'Triage agent routes to specialists',
},
'group-chat': {
icon: Users,
label: 'Group Chat',
description: 'Agents iterate in managed round-robin',
},
magentic: {
icon: Lock,
label: 'Magentic',
description: 'Not yet available in .NET',
},
};
function FlowPill({ children, variant = 'agent' }: { children: ReactNode; variant?: 'user' | 'agent' }) {
return (
<span
className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-semibold leading-none ${
variant === 'user'
? 'bg-indigo-500/20 text-indigo-400'
: 'bg-zinc-700/60 text-zinc-400'
}`}
>
{children}
</span>
);
}
function FlowArrow({ label }: { label?: string }) {
return <span className="text-[10px] leading-none text-zinc-600">{label ?? '→'}</span>;
}
function ModeFlowDiagram({ mode }: { mode: OrchestrationMode }) {
switch (mode) {
case 'single':
return (
<div className="flex items-center gap-1">
<FlowPill variant="user">You</FlowPill>
<FlowArrow />
<FlowPill>Agent</FlowPill>
<FlowArrow />
<FlowPill variant="user">You</FlowPill>
</div>
);
case 'sequential':
return (
<div className="flex items-center gap-1">
<FlowPill>A</FlowPill>
<FlowArrow />
<FlowPill>A</FlowPill>
<FlowArrow />
<FlowPill>A</FlowPill>
</div>
);
case 'concurrent':
return (
<div className="flex items-center gap-1">
<FlowPill variant="user">You</FlowPill>
<FlowArrow />
<div className="flex gap-0.5">
<FlowPill>A</FlowPill>
<FlowPill>A</FlowPill>
<FlowPill>A</FlowPill>
</div>
<FlowArrow />
<FlowPill variant="user">You</FlowPill>
</div>
);
case 'handoff':
return (
<div className="flex items-center gap-1">
<FlowPill>Triage</FlowPill>
<FlowArrow label="⇄" />
<FlowPill>S</FlowPill>
<FlowPill>S</FlowPill>
</div>
);
case 'group-chat':
return (
<div className="flex items-center gap-1">
<FlowPill>A</FlowPill>
<FlowArrow label="⇄" />
<FlowPill>A</FlowPill>
<FlowArrow label="⇄" />
<FlowPill>A</FlowPill>
</div>
);
case 'magentic':
return <span className="text-[10px] italic text-zinc-700">Coming soon</span>;
default:
return null;
}
}
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const baseClasses =
'w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-[13px] text-zinc-100 placeholder-zinc-600 outline-none transition focus:border-indigo-500/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-zinc-400">{label}</span>
{multiline ? (
<textarea
className={`${baseClasses} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={baseClasses}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave, onBack }: PatternEditorProps) {
const issues = validatePatternDefinition(pattern);
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
onChange({
...pattern,
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
});
}
return (
<div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */}
<div className="flex items-center justify-between border-b border-zinc-800 px-6 pb-3 pt-12">
<div className="flex items-center gap-3">
<button
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h3 className="text-sm font-semibold text-zinc-100">
{pattern.name || 'Untitled pattern'}
</h3>
<p className="text-[12px] text-zinc-500">
{isBuiltin ? 'Built-in pattern' : 'Custom pattern'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!isBuiltin && onDelete && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-red-400 transition hover:bg-red-500/10"
onClick={onDelete}
type="button"
>
<Trash2 className="size-3.5" />
Delete
</button>
)}
<button
className="rounded-lg bg-indigo-600 px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-indigo-500"
onClick={onSave}
type="button"
>
Save
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="mx-auto max-w-2xl space-y-8">
{/* Validation banner */}
{issues.length > 0 ? (
<div className="space-y-2">
{issues.map((issue, i) => (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] ${
issue.level === 'error'
? 'bg-red-500/10 text-red-300'
: 'bg-amber-500/10 text-amber-300'
}`}
key={`${issue.field ?? 'v'}-${i}`}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
{issue.message}
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-[13px] text-emerald-300">
<CheckCircle className="size-3.5" />
Pattern is valid and ready to use
</div>
)}
{/* Name + description */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
General
</h4>
<InputField
label="Name"
onChange={(v) => onChange({ ...pattern, name: v })}
placeholder="Pattern name"
value={pattern.name}
/>
<InputField
label="Description"
multiline
onChange={(v) => onChange({ ...pattern, description: v })}
placeholder="What this pattern does..."
value={pattern.description}
/>
</section>
{/* Mode selector cards */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
Orchestration Mode
</h4>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => {
const info = modeInfo[mode];
const Icon = info.icon;
const selected = pattern.mode === mode;
const disabled = mode === 'magentic';
return (
<button
className={`flex flex-col rounded-xl border p-3 text-left transition ${
selected
? 'border-indigo-500/40 bg-indigo-500/5 ring-1 ring-indigo-500/20'
: disabled
? 'cursor-not-allowed border-zinc-800/50 opacity-40'
: 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/60'
}`}
disabled={disabled}
key={mode}
onClick={() => onChange({ ...pattern, mode })}
type="button"
>
<div className="flex items-center gap-2">
<Icon
className={`size-4 ${selected ? 'text-indigo-400' : 'text-zinc-500'}`}
/>
<span
className={`text-[12px] font-semibold ${
selected ? 'text-indigo-200' : 'text-zinc-300'
}`}
>
{info.label}
</span>
</div>
<p className="mt-1 text-[11px] leading-snug text-zinc-500">
{info.description}
</p>
<div className="mt-2.5">
<ModeFlowDiagram mode={mode} />
</div>
</button>
);
})}
</div>
</section>
{/* Agents */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
Agents ({pattern.agents.length})
</h4>
<button
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={() =>
onChange({
...pattern,
agents: [
...pattern.agents,
{
id: `agent-${crypto.randomUUID()}`,
name: `Agent ${pattern.agents.length + 1}`,
description: '',
instructions: '',
model: 'gpt-5.4',
reasoningEffort: 'high',
},
],
})
}
type="button"
>
<Plus className="size-3" />
Add agent
</button>
</div>
<div className="space-y-3">
{pattern.agents.map((agent, index) => (
<div
className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4"
key={agent.id}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded bg-zinc-700/60 text-[10px] font-bold text-zinc-400">
{index + 1}
</span>
<span className="text-[12px] font-medium text-zinc-300">
{agent.name || 'Unnamed agent'}
</span>
</div>
{pattern.agents.length > 1 && (
<button
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
onClick={() =>
onChange({
...pattern,
agents: pattern.agents.filter((a) => a.id !== agent.id),
})
}
type="button"
>
<Trash2 className="size-3" />
Remove
</button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<InputField
label="Name"
onChange={(v) => updateAgent(agent.id, { name: v })}
value={agent.name}
/>
<InputField
label="Model"
onChange={(v) => updateAgent(agent.id, { model: v })}
placeholder="e.g. gpt-5.4"
value={agent.model}
/>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-zinc-400">Reasoning</span>
<select
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50"
onChange={(e) =>
updateAgent(agent.id, {
reasoningEffort: e.target.value as ReasoningEffort,
})
}
value={agent.reasoningEffort ?? 'high'}
>
{reasoningEfforts.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-3">
<InputField
label="Description"
onChange={(v) => updateAgent(agent.id, { description: v })}
placeholder="What this agent does..."
value={agent.description}
/>
</div>
<div className="mt-3">
<InputField
label="Instructions"
multiline
onChange={(v) => updateAgent(agent.id, { instructions: v })}
placeholder="System prompt for this agent..."
value={agent.instructions}
/>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
}