refactor: remove pattern domain and migrate renderer to workflow-only

- Delete src/shared/domain/pattern.ts and all pattern-specific renderer
  components (PatternEditor, NewSessionModal, pattern-graph directory,
  patternGraph lib) and their tests
- Migrate all renderer imports from @shared/domain/pattern to
  @shared/domain/workflow (ReasoningEffort, reasoningEffortOptions,
  WorkflowOrchestrationMode, AgentNodeConfig, WorkflowDefinition)
- Update App.tsx: remove workflowToPattern bridge, createDraftPattern,
  NewSessionModal; sessions now created directly with default workflow
- Update ChatPane, ActivityPanel, Sidebar to accept WorkflowDefinition
  instead of PatternDefinition and derive agents/mode from workflow
- Update SettingsPanel: remove PatternsSection nav item, pattern editing
  state, and pattern-related props; update text references
- Update RunTimeline and modeAccent/modeVisuals records to use
  WorkflowOrchestrationMode (drop magentic mode entry)
- Update sessionActivity.ts to use generic agent interface

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-06 22:34:42 +02:00
co-authored by Copilot
parent 059714326a
commit 69d0804161
18 changed files with 122 additions and 4443 deletions
+20 -14
View File
@@ -17,20 +17,19 @@ import {
} from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline';
import { inferProvider } from '@shared/domain/models';
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
/* ── Mode accent colours ───────────────────────────────────── */
const modeAccent: Record<OrchestrationMode, { dot: string; bar: string; label: string }> = {
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
magentic: { dot: 'bg-[var(--color-text-muted)]', bar: 'bg-[var(--color-text-muted)] opacity-60', label: 'text-[var(--color-text-muted)]' },
};
/* ── Helpers ───────────────────────────────────────────────── */
@@ -50,13 +49,12 @@ function formatEffort(effort: string | undefined): string | undefined {
return labels[effort] ?? effort;
}
const modeLabels: Record<OrchestrationMode, string> = {
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
magentic: 'Magentic',
};
/* ── Section header ────────────────────────────────────────── */
@@ -79,8 +77,8 @@ function AgentRow({
agentUsage,
}: {
row: AgentActivityRow;
agent?: PatternAgentDefinition;
accent: (typeof modeAccent)[OrchestrationMode];
agent?: AgentNodeConfig;
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}) {
@@ -213,7 +211,7 @@ interface ActivityPanelProps {
onJumpToMessage?: (messageId: string) => void;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
pattern: PatternDefinition;
workflow: WorkflowDefinition;
session: SessionRecord;
sessionRequestUsage?: SessionRequestUsageState;
turnEvents?: TurnEventLog;
@@ -224,21 +222,29 @@ export function ActivityPanel({
onJumpToMessage,
onDiscard,
onOpenCommitComposer,
pattern,
workflow,
session,
sessionRequestUsage,
turnEvents,
}: ActivityPanelProps) {
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const activityRows = useMemo(
() => buildAgentActivityRows(activity, pattern.agents),
[activity, pattern.agents],
() => buildAgentActivityRows(activity, workflowAgents),
[activity, workflowAgents],
);
const isBusy = session.status === 'running';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
const accent = modeAccent[pattern.mode] ?? modeAccent.single;
const accent = modeAccent[workflowMode] ?? modeAccent.single;
return (
<div className="flex h-full flex-col">
@@ -272,7 +278,7 @@ export function ActivityPanel({
{activityRows.length}
</span>
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
{modeLabels[pattern.mode]}
{modeLabels[workflowMode]}
</span>
</SectionHeader>
@@ -285,7 +291,7 @@ export function ActivityPanel({
return (
<AgentRow
accent={accent}
agent={pattern.agents[index]}
agent={workflowAgents[index]}
agentUsage={agentUsage}
isLast={index === activityRows.length - 1}
key={row.key}
@@ -8,7 +8,7 @@ import {
providerMeta,
type ModelDefinition,
} from '@shared/domain/models';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
import { ProviderIcon } from './ProviderIcons';
+19 -12
View File
@@ -27,7 +27,7 @@ import {
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
@@ -48,7 +48,7 @@ type DisplayItem =
interface ChatPaneProps {
project: ProjectRecord;
pattern: PatternDefinition;
workflow: WorkflowDefinition;
session: SessionRecord;
availableModels: ReadonlyArray<ModelDefinition>;
toolingSettings: WorkspaceToolingSettings;
@@ -85,7 +85,7 @@ interface ChatPaneProps {
export function ChatPane({
project,
pattern,
workflow,
session,
availableModels,
toolingSettings,
@@ -184,8 +184,15 @@ export function ChatPane({
const interactionMode: InteractionMode = session.interactionMode ?? 'interactive';
const isPlanMode = interactionMode === 'plan';
const isScratchpad = isScratchpadProject(project);
const isSingleAgent = pattern.agents.length === 1;
const primaryAgent = pattern.agents[0];
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const isSingleAgent = workflowAgents.length === 1;
const primaryAgent = workflowAgents[0];
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
@@ -223,7 +230,7 @@ export function ChatPane({
const mcpServers = toolingSettings.mcpServers;
const lspProfiles = toolingSettings.lspProfiles;
const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0;
const hasToolCallApproval = pattern.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false;
const hasToolCallApproval = workflow.settings.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false;
const approvalTools = useMemo(
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
[runtimeTools, toolingSettings],
@@ -233,9 +240,9 @@ export function ChatPane({
() => new Set(
isApprovalOverridden
? session.approvalSettings!.autoApprovedToolNames
: pattern.approvalPolicy?.autoApprovedToolNames ?? [],
: workflow.settings.approvalPolicy?.autoApprovedToolNames ?? [],
),
[isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy],
[isApprovalOverridden, session.approvalSettings, workflow.settings.approvalPolicy],
);
const effectiveAutoApprovedCount = useMemo(() => {
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
@@ -366,8 +373,8 @@ export function ChatPane({
<h2 className="font-display truncate text-[13px] font-semibold leading-tight text-[var(--color-text-primary)]">{session.title}</h2>
<p className="truncate text-[11px] leading-tight text-[var(--color-text-muted)]">
{isScratchpad
? `Scratchpad · ${pattern.name}`
: `${project.name} · ${pattern.name} · ${pattern.mode}`}
? `Scratchpad · ${workflow.name}`
: `${project.name} · ${workflow.name} · ${workflowMode}`}
{!isScratchpad && project.git?.status === 'ready' && (() => {
const git = project.git;
const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD'];
@@ -442,11 +449,11 @@ export function ChatPane({
{isScratchpad ? (
<>
Scratchpad is ready for ad-hoc questions using{' '}
<span className="text-[var(--color-text-secondary)]">{pattern.name}</span>
<span className="text-[var(--color-text-secondary)]">{workflow.name}</span>
</>
) : (
<>
Using <span className="text-[var(--color-text-secondary)]">{pattern.name}</span> in{' '}
Using <span className="text-[var(--color-text-secondary)]">{workflow.name}</span> in{' '}
<span className="text-[var(--color-text-secondary)]">{project.name}</span>
</>
)}
-157
View File
@@ -1,157 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Star, X } from 'lucide-react';
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
interface NewSessionModalProps {
projects: ProjectRecord[];
patterns: PatternDefinition[];
defaultProjectId?: string;
onClose: () => void;
onCreate: (projectId: string, patternId: string) => void;
onTogglePatternFavorite?: (patternId: string, isFavorite: boolean) => void;
}
export function NewSessionModal({
projects,
patterns,
defaultProjectId,
onClose,
onCreate,
onTogglePatternFavorite,
}: NewSessionModalProps) {
const userProjects = useMemo(
() => projects.filter((p) => !isScratchpadProject(p)),
[projects],
);
const [projectId, setProjectId] = useState(defaultProjectId ?? userProjects[0]?.id ?? '');
const availablePatterns = useMemo(
() =>
patterns
.filter((pattern) => pattern.availability !== 'unavailable')
.sort((a, b) => {
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
return 0;
}),
[patterns],
);
const [patternId, setPatternId] = useState(availablePatterns[0]?.id ?? '');
useEffect(() => {
if (!availablePatterns.some((pattern) => pattern.id === patternId)) {
setPatternId(availablePatterns[0]?.id ?? '');
}
}, [availablePatterns, patternId]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const canCreate = projectId && patternId;
return (
<div className="overlay-backdrop-enter fixed inset-0 z-50 flex items-center justify-center bg-[#07080e]/90 backdrop-blur-sm" role="dialog" aria-modal="true" aria-labelledby="new-session-title">
<div className="overlay-panel-enter w-full max-w-md rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-4">
<h2 id="new-session-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">New Session</h2>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
{/* Body */}
<div className="space-y-4 px-5 py-5">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Project</span>
<select
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => setProjectId(e.target.value)}
value={projectId}
>
{userProjects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Pattern</span>
<div className="space-y-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-0)] p-1.5">
{availablePatterns.map((p) => (
<div
key={p.id}
className={`flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition ${
patternId === p.id
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)] ring-1 ring-[var(--color-border-glow)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)]'
}`}
onClick={() => setPatternId(p.id)}
>
<span className="flex-1 truncate">
{p.name}
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">({p.mode})</span>
</span>
{onTogglePatternFavorite && (
<button
className={`shrink-0 transition ${
p.isFavorite
? 'text-[var(--color-status-warning)] hover:text-[var(--color-status-warning)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={(e) => {
e.stopPropagation();
onTogglePatternFavorite(p.id, !p.isFavorite);
}}
title={p.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
type="button"
>
<Star className={`size-3.5 ${p.isFavorite ? 'fill-current' : ''}`} />
</button>
)}
</div>
))}
</div>
{patternId && (
<p className="text-[12px] text-[var(--color-text-muted)]">
{availablePatterns.find((p) => p.id === patternId)?.description}
</p>
)}
</label>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-[var(--color-border)] px-5 py-3">
<button
className="rounded-lg px-4 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-[var(--color-accent-sky)] disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canCreate}
onClick={() => canCreate && onCreate(projectId, patternId)}
type="button"
>
Start Session
</button>
</div>
</div>
</div>
);
}
-751
View File
@@ -1,751 +0,0 @@
import { useState } from 'react';
import {
AlertCircle,
ArrowLeftRight,
CheckCircle,
ChevronDown,
ChevronLeft,
GitFork,
Library,
Link2,
ListOrdered,
Lock,
MessageSquare,
Plus,
ShieldCheck,
Trash2,
Unlink,
Users,
type LucideIcon,
} from 'lucide-react';
import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval';
import type { ModelDefinition } from '@shared/domain/models';
import {
addAgentToGraph,
removeAgentFromGraph,
resolvePatternGraph,
syncPatternGraph,
validatePatternDefinition,
type OrchestrationMode,
type PatternDefinition,
type PatternAgentDefinition,
type PatternGraph,
} from '@shared/domain/pattern';
import {
listApprovalToolDefinitions,
type ApprovalToolDefinition,
type ApprovalToolKind,
type RuntimeToolDefinition,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { useClickOutside } from '@renderer/hooks/useClickOutside';
import { ToggleSwitch } from '@renderer/components/ui';
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
interface PatternEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
pattern: PatternDefinition;
isBuiltin: boolean;
toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
workspaceAgents: WorkspaceAgentDefinition[];
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
onChange: (pattern: PatternDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
}
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 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-[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-all duration-200 focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{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({
availableModels,
pattern,
isBuiltin,
toolingSettings,
runtimeTools,
workspaceAgents,
onSaveWorkspaceAgent,
onChange,
onDelete,
onSave,
onBack,
}: PatternEditorProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [addAgentMenuOpen, setAddAgentMenuOpen] = useState(false);
const addAgentMenuRef = useClickOutside<HTMLDivElement>(() => setAddAgentMenuOpen(false), addAgentMenuOpen);
const issues = validatePatternDefinition(pattern);
const graph = resolvePatternGraph(pattern);
function emitChange(nextPattern: PatternDefinition) {
onChange({ ...nextPattern, graph: resolvePatternGraph(nextPattern) });
}
function emitModeChange(nextPattern: PatternDefinition) {
onChange(syncPatternGraph(nextPattern));
}
function emitGraphChange(nextGraph: PatternGraph) {
onChange({ ...pattern, graph: nextGraph });
}
function addAgent() {
const newAgent: PatternAgentDefinition = {
id: `agent-${crypto.randomUUID()}`,
name: `Agent ${pattern.agents.length + 1}`,
description: '',
instructions: '',
model: 'gpt-5.4',
reasoningEffort: 'high',
};
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
setAddAgentMenuOpen(false);
}
function addAgentFromLibrary(wa: WorkspaceAgentDefinition) {
const newAgent: PatternAgentDefinition = {
id: `agent-${crypto.randomUUID()}`,
name: wa.name,
description: wa.description,
instructions: wa.instructions,
model: wa.model,
reasoningEffort: wa.reasoningEffort,
copilot: wa.copilot,
workspaceAgentId: wa.id,
};
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
setAddAgentMenuOpen(false);
}
async function promoteAgent(agentId: string) {
const agent = pattern.agents.find((a) => a.id === agentId);
if (!agent || agent.workspaceAgentId) return;
const timestamp = new Date().toISOString();
const workspaceAgent: WorkspaceAgentDefinition = {
id: `agent-${crypto.randomUUID()}`,
name: agent.name,
description: agent.description,
instructions: agent.instructions,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
copilot: agent.copilot,
createdAt: timestamp,
updatedAt: timestamp,
};
await onSaveWorkspaceAgent(workspaceAgent);
updateAgent(agentId, { workspaceAgentId: workspaceAgent.id, overrides: undefined });
}
function unlinkAgent(agentId: string) {
updateAgent(agentId, { workspaceAgentId: undefined, overrides: undefined });
}
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
emitChange({
...pattern,
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
});
}
function removeAgent(agentId: string) {
if (pattern.agents.length <= 1) {
return;
}
const updatedGraph = removeAgentFromGraph(graph, pattern.mode, agentId);
onChange({
...pattern,
agents: pattern.agents.filter((a) => a.id !== agentId),
graph: updatedGraph,
});
setSelectedNodeId(null);
}
function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) {
emitChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) });
}
function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean {
return pattern.approvalPolicy?.rules.some((r) => r.kind === kind) ?? false;
}
function checkpointAgentIds(kind: ApprovalCheckpointKind): string[] | undefined {
return pattern.approvalPolicy?.rules.find((r) => r.kind === kind)?.agentIds;
}
function toggleCheckpoint(kind: ApprovalCheckpointKind, enabled: boolean) {
updateApprovalPolicy((current) => {
const otherRules = (current?.rules ?? []).filter((r) => r.kind !== kind);
if (!enabled) {
return { rules: otherRules };
}
return { rules: [...otherRules, { kind }] };
});
}
function setCheckpointAgentScope(kind: ApprovalCheckpointKind, agentIds: string[] | undefined) {
updateApprovalPolicy((current) => {
const rules = (current?.rules ?? []).map((r) =>
r.kind === kind ? { ...r, agentIds } : r,
);
return { rules };
});
}
const approvalTools = listApprovalToolDefinitions(toolingSettings, runtimeTools);
const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
function toggleToolAutoApproval(toolId: string) {
const current = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
if (current.has(toolId)) {
current.delete(toolId);
} else {
current.add(toolId);
}
updateApprovalPolicy((policy) => ({
rules: policy?.rules ?? [],
autoApprovedToolNames: current.size > 0 ? [...current] : undefined,
}));
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
<div className="flex items-center gap-3">
<button
className="no-drag flex size-8 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={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
{pattern.name || 'Untitled pattern'}
</h3>
<p className="text-[12px] text-[var(--color-text-muted)]">
{isBuiltin ? 'Built-in pattern' : 'Custom pattern'}
</p>
</div>
</div>
<div className="no-drag flex items-center gap-2">
{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"
onClick={onDelete}
type="button"
>
<Trash2 className="size-3.5" />
Delete
</button>
)}
<button
className="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={onSave}
type="button"
>
Save
</button>
</div>
</div>
{/* Body — graph canvas + inspector split */}
<div className="flex min-h-0 flex-1">
{/* Left column: graph canvas + settings below */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Validation banner */}
<div className="px-5 pt-4">
{issues.length > 0 ? (
<div className="space-y-1.5">
{issues.map((issue, i) => (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
issue.level === 'error'
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
}`}
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-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
<CheckCircle className="size-3.5" />
Pattern is valid
</div>
)}
</div>
{/* Graph canvas */}
<div className="flex items-center justify-between px-5 pt-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Topology
</h4>
<div className="relative" ref={addAgentMenuRef}>
<button
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={() => workspaceAgents.length > 0 ? setAddAgentMenuOpen(!addAgentMenuOpen) : addAgent()}
type="button"
>
<Plus className="size-3" />
Add agent
{workspaceAgents.length > 0 && <ChevronDown className="size-3" />}
</button>
{addAgentMenuOpen && (
<div className="absolute right-0 z-50 mt-1 w-56 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-xl">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={addAgent}
type="button"
>
<Plus className="size-3.5" />
New inline agent
</button>
<div className="my-1 border-t border-[var(--color-border)]" />
<div className="px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
From library
</span>
</div>
{workspaceAgents.map((wa) => (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
key={wa.id}
onClick={() => addAgentFromLibrary(wa)}
type="button"
>
<Library className="size-3.5 text-[var(--color-accent)]" />
<span className="truncate">{wa.name}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="min-h-[300px] flex-1 px-5 py-3">
<PatternGraphCanvas
pattern={pattern}
availableModels={availableModels}
onGraphChange={emitGraphChange}
onAgentRemove={removeAgent}
onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId}
/>
</div>
{/* Scrollable settings below graph */}
<div className="max-h-[45%] overflow-y-auto border-t border-[var(--color-border-subtle)] px-5 py-5">
<div className="space-y-8">
{/* General */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
General
</h4>
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Name"
onChange={(v) => emitChange({ ...pattern, name: v })}
placeholder="Pattern name"
value={pattern.name}
/>
<InputField
label="Description"
onChange={(v) => emitChange({ ...pattern, description: v })}
placeholder="What this pattern does..."
value={pattern.description}
/>
</div>
</section>
{/* Mode selector */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Orchestration Mode
</h4>
<div className="grid grid-cols-3 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-2.5 text-left transition-all duration-200 ${
selected
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
: disabled
? 'cursor-not-allowed border-[var(--color-border-subtle)] opacity-40'
: 'border-[var(--color-border)] hover:border-[var(--color-border)] hover:bg-[var(--color-glass)]'
}`}
disabled={disabled}
key={mode}
onClick={() => emitModeChange({ ...pattern, mode })}
type="button"
>
<div className="flex items-center gap-1.5">
<Icon
className={`size-3.5 ${selected ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-muted)]'}`}
/>
<span
className={`text-[11px] font-semibold ${
selected ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-secondary)]'
}`}
>
{info.label}
</span>
</div>
<p className="mt-1 text-[10px] leading-snug text-[var(--color-text-muted)]">
{info.description}
</p>
</button>
);
})}
</div>
</section>
{/* Approval checkpoints */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Approval Checkpoints
</h4>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
</p>
<div className="space-y-3">
<ApprovalCheckpointRow
agents={pattern.agents}
enabled={isCheckpointEnabled('tool-call')}
kind="tool-call"
label="Tool call approval"
description="Require approval before the agent executes tool calls"
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
scopedAgentIds={checkpointAgentIds('tool-call')}
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Auto-approved tools
</div>
<p className="mb-3 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Tools marked as auto-approved will skip manual review.
Sessions can override these defaults from the Activity panel.
</p>
{approvalTools.length === 0 ? (
<p className="py-2 text-center text-[11px] text-[var(--color-text-muted)]">
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
</p>
) : (
<ToolApprovalGroupedList
autoApprovedSet={autoApprovedSet}
onToggle={toggleToolAutoApproval}
tools={approvalTools}
/>
)}
</ApprovalCheckpointRow>
<ApprovalCheckpointRow
agents={pattern.agents}
enabled={isCheckpointEnabled('final-response')}
kind="final-response"
label="Final response review"
description="Review and approve assistant messages before publication"
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
scopedAgentIds={checkpointAgentIds('final-response')}
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
/>
</div>
</section>
</div>
</div>
</div>
{/* Right column: node inspector */}
<div className="w-[320px] shrink-0 overflow-y-auto border-l border-[var(--color-border-subtle)] bg-[var(--color-glass)]">
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Inspector
</h4>
</div>
<PatternGraphInspector
availableModels={availableModels}
agents={pattern.agents}
graph={graph}
mode={pattern.mode}
selectedNodeId={selectedNodeId}
workspaceAgents={workspaceAgents}
onAgentChange={updateAgent}
onAgentRemove={removeAgent}
onAgentPromote={promoteAgent}
onAgentUnlink={unlinkAgent}
onGraphChange={emitGraphChange}
/>
</div>
</div>
</div>
);
}
/* ── Approval checkpoint row ───────────────────────────────── */
function ApprovalCheckpointRow({
agents,
children,
enabled,
kind: _kind,
label,
description,
onToggle,
scopedAgentIds,
onScopeChange,
}: {
agents: PatternAgentDefinition[];
children?: React.ReactNode;
enabled: boolean;
kind: ApprovalCheckpointKind;
label: string;
description: string;
onToggle: (enabled: boolean) => void;
scopedAgentIds: string[] | undefined;
onScopeChange: (agentIds: string[] | undefined) => void;
}){
const isAllAgents = !scopedAgentIds || scopedAgentIds.length === 0;
function toggleAgentScope(agentId: string) {
const current = scopedAgentIds ?? [];
const next = current.includes(agentId)
? current.filter((id) => id !== agentId)
: [...current, agentId];
onScopeChange(next.length > 0 ? next : undefined);
}
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
<button className="flex w-full items-center gap-3 text-left" onClick={() => onToggle(!enabled)} type="button">
<ShieldCheck className={`size-4 shrink-0 ${enabled ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-muted)]'}`} />
<div className="min-w-0 flex-1">
<span className="text-[12px] font-medium text-[var(--color-text-primary)]">{label}</span>
<p className="text-[11px] text-[var(--color-text-muted)]">{description}</p>
</div>
<ToggleSwitch enabled={enabled} />
</button>
{/* Agent scope selector */}
{enabled && agents.length > 1 && (
<div className="mt-3 border-t border-[var(--color-border-subtle)] pt-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">Scope</span>
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition-all duration-200 ${
isAllAgents
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onScopeChange(undefined)}
type="button"
>
All agents
</button>
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition-all duration-200 ${
!isAllAgents
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onScopeChange([])}
type="button"
>
Selected agents
</button>
</div>
{!isAllAgents && (
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => {
const isSelected = scopedAgentIds?.includes(agent.id) ?? false;
return (
<button
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] ring-1 ring-[var(--color-border-glow)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
key={agent.id}
onClick={() => toggleAgentScope(agent.id)}
type="button"
>
{agent.name || 'Unnamed'}
</button>
);
})}
</div>
)}
</div>
)}
{/* Optional additional content (e.g. tool auto-approval list) */}
{enabled && children && (
<div className="mt-3 border-t border-[var(--color-border-subtle)] pt-3">
{children}
</div>
)}
</div>
);
}
/* ── Tool auto-approval grouped list ───────────────────────── */
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
const approvalKindLabels: Record<ApprovalToolKind, string> = {
builtin: 'Built-in',
mcp: 'MCP Servers',
lsp: 'Language Servers',
mixed: 'Other',
};
function ToolApprovalGroupedList({
tools,
autoApprovedSet,
onToggle,
}: {
tools: ApprovalToolDefinition[];
autoApprovedSet: Set<string>;
onToggle: (toolId: string) => void;
}) {
const groups = approvalKindOrder
.map((kind) => ({ kind, tools: tools.filter((t) => t.kind === kind) }))
.filter((g) => g.tools.length > 0);
const showHeaders = groups.length > 1;
return (
<div>
{groups.map((group, i) => (
<div key={group.kind}>
{showHeaders && (
<div className={`text-[9px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)] ${i > 0 ? 'mt-3' : ''} mb-1`}>
{approvalKindLabels[group.kind]}
</div>
)}
{group.tools.map((tool) => (
<ToolApprovalToggleRow
enabled={autoApprovedSet.has(tool.id)}
key={tool.id}
onToggle={() => onToggle(tool.id)}
tool={tool}
/>
))}
</div>
))}
</div>
);
}
function ToolApprovalToggleRow({
tool,
enabled,
onToggle,
}: {
tool: ApprovalToolDefinition;
enabled: boolean;
onToggle: () => void;
}) {
const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined);
return (
<button
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]/60"
onClick={onToggle}
type="button"
>
<div className="min-w-0 flex-1">
<span className="truncate text-[12px] font-medium text-[var(--color-text-secondary)]">{tool.label}</span>
{detail && <div className="truncate text-[10px] text-[var(--color-text-muted)]">{detail}</div>}
</div>
<ToggleSwitch enabled={enabled} />
</button>
);
}
+4 -5
View File
@@ -25,7 +25,7 @@ import {
truncateContent,
type CollapsedTimelineEvent,
} from '@renderer/lib/runTimelineFormatting';
import type { OrchestrationMode } from '@shared/domain/pattern';
import type { WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
@@ -33,13 +33,12 @@ import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummary
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: string }> = {
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; ring: string; text: string }> = {
single: { dot: 'bg-[#245CF9]', ring: 'ring-[#245CF9]/30', text: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', ring: 'ring-[var(--color-status-warning)]/30', text: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', ring: 'ring-[var(--color-status-success)]/30', text: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', ring: 'ring-[var(--color-accent-sky)]/30', text: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', ring: 'ring-[var(--color-accent-purple)]/30', text: 'text-[var(--color-accent-purple)]' },
magentic: { dot: 'bg-[var(--color-text-muted)]', ring: 'ring-[var(--color-text-muted)]/30', text: 'text-[var(--color-text-muted)]' },
};
/* ── Status badges ─────────────────────────────────────────── */
@@ -284,7 +283,7 @@ function RunCard({
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}) {
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
const accent = modeAccent[run.workflowMode] ?? modeAccent.single;
const statusStyle = runStatusStyles[run.status];
const duration = formatRunDuration(run.startedAt, run.completedAt);
@@ -308,7 +307,7 @@ function RunCard({
<Bot className={`size-3 shrink-0 ${accent.text}`} />
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-[var(--color-text-secondary)]">
{run.patternName}
{run.workflowName}
</span>
{/* Status */}
+22 -149
View File
@@ -1,8 +1,7 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ArrowUpRight, ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
@@ -13,7 +12,6 @@ import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidec
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
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';
@@ -29,7 +27,6 @@ import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type Works
interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
sidecarCapabilities?: SidecarCapabilities;
theme: AppearanceTheme;
@@ -39,9 +36,6 @@ interface SettingsPanelProps {
initialSection?: SettingsSection;
onRefreshCapabilities: () => void;
onClose: () => void;
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
onDeletePattern: (patternId: string) => Promise<void>;
onNewPattern: () => PatternDefinition;
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
onDeleteWorkflow: (workflowId: string) => Promise<void>;
onNewWorkflow: () => WorkflowDefinition;
@@ -68,10 +62,9 @@ interface SettingsPanelProps {
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' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -100,7 +93,6 @@ const navGroups: NavGroup[] = [
{
label: 'Workflows',
items: [
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
],
@@ -120,14 +112,8 @@ const navGroups: NavGroup[] = [
},
];
function modeBadgeClasses(pattern: PatternDefinition) {
if (pattern.availability === 'unavailable') return 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]';
return 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]';
}
export function SettingsPanel({
availableModels,
patterns,
workflows,
sidecarCapabilities,
theme,
@@ -137,9 +123,6 @@ export function SettingsPanel({
initialSection,
onRefreshCapabilities,
onClose,
onSavePattern,
onDeletePattern,
onNewPattern,
onSaveWorkflow,
onDeleteWorkflow,
onNewWorkflow,
@@ -166,44 +149,13 @@ export function SettingsPanel({
onGetQuota,
workflowTemplates,
onCreateWorkflowFromTemplate,
onUpgradePatternToWorkflow,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
if (editingPattern) {
const isBuiltin = editingPattern.id.startsWith('pattern-');
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<PatternEditor
availableModels={availableModels}
isBuiltin={isBuiltin}
onBack={() => setEditingPattern(null)}
onChange={setEditingPattern}
onDelete={
async () => {
await onDeletePattern(editingPattern.id);
setEditingPattern(null);
}
}
onSave={async () => {
await onSavePattern(editingPattern);
setEditingPattern(null);
}}
pattern={editingPattern}
runtimeTools={sidecarCapabilities?.runtimeTools}
toolingSettings={toolingSettings}
workspaceAgents={workspaceAgents}
onSaveWorkspaceAgent={onSaveWorkspaceAgent}
/>
</div>
);
}
if (editingWorkflow) {
const api = getElectronApi();
return (
@@ -299,9 +251,9 @@ export function SettingsPanel({
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<WorkspaceAgentEditor
agent={editingWorkspaceAgent}
availableModels={availableModels}
<WorkspaceAgentEditor
agent={editingWorkspaceAgent}
availableModels={availableModels}
onBack={() => setEditingWorkspaceAgent(null)}
onChange={setEditingWorkspaceAgent}
onDelete={
@@ -312,14 +264,14 @@ export function SettingsPanel({
}
: undefined
}
onSave={async () => {
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
setEditingWorkspaceAgent(null);
}}
patterns={patterns}
/>
</div>
);
onSave={async () => {
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
setEditingWorkspaceAgent(null);
}}
workflows={workflows}
/>
</div>
);
}
return (
@@ -391,14 +343,6 @@ export function SettingsPanel({
onGetQuota={onGetQuota}
/>
)}
{activeSection === 'patterns' && (
<PatternsSection
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
onNewPattern={() => setEditingPattern(onNewPattern())}
onUpgradePatternToWorkflow={onUpgradePatternToWorkflow}
patterns={patterns}
/>
)}
{activeSection === 'workflows' && (
<WorkflowsSection
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
@@ -411,7 +355,7 @@ export function SettingsPanel({
{activeSection === 'agents' && (
<WorkspaceAgentsSection
agents={workspaceAgents}
patterns={patterns}
workflows={workflows}
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
/>
@@ -624,77 +568,6 @@ function ConnectionSection({
);
}
function PatternsSection({
patterns,
onEditPattern,
onNewPattern,
onUpgradePatternToWorkflow,
}: {
patterns: PatternDefinition[];
onEditPattern: (pattern: PatternDefinition) => void;
onNewPattern: () => void;
onUpgradePatternToWorkflow?: (patternId: string) => Promise<void>;
}) {
return (
<div>
<SectionHeader
description="Define reusable agent configurations for your sessions"
title="Workflow Patterns"
>
<SectionAction label="New Pattern" onClick={onNewPattern} />
</SectionHeader>
<div className="space-y-1">
{patterns.map((pattern) => (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
key={pattern.id}
onClick={() => onEditPattern(pattern)}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{pattern.name}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${modeBadgeClasses(pattern)}`}>
{pattern.mode}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{pattern.description}</p>
</div>
<div className="flex items-center gap-2">
<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>
))}
</div>
</div>
);
}
const categoryLabels: Record<WorkflowTemplateCategory, string> = {
'orchestration': 'Orchestration',
'data-pipeline': 'Data Pipeline',
@@ -850,19 +723,19 @@ function WorkflowsSection({
function WorkspaceAgentsSection({
agents,
patterns,
workflows,
onEditAgent,
onNewAgent,
}: {
agents: WorkspaceAgentDefinition[];
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
onNewAgent: () => void;
}) {
return (
<div>
<SectionHeader
description="Define reusable agents that can be shared across multiple patterns"
description="Define reusable agents that can be shared across multiple workflows"
title="Workspace Agents"
>
<SectionAction label="New Agent" onClick={onNewAgent} />
@@ -875,14 +748,14 @@ function WorkspaceAgentsSection({
No workspace agents yet
</p>
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
Create agents here and reference them in multiple patterns.
Changes to a workspace agent automatically propagate to all linked patterns.
Create agents here and reference them in multiple workflows.
Changes to a workspace agent automatically propagate to all linked workflows.
</p>
</div>
) : (
<div className="space-y-1">
{agents.map((agent) => {
const usageCount = findWorkspaceAgentUsages(agent.id, patterns).length;
const usageCount = findWorkspaceAgentUsages(agent.id, workflows).length;
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
@@ -904,7 +777,7 @@ function WorkspaceAgentsSection({
<div className="flex items-center gap-2">
{usageCount > 0 && (
<span className="text-[12px] text-[var(--color-text-muted)]">
{usageCount} pattern{usageCount === 1 ? '' : 's'}
{usageCount} workflow{usageCount === 1 ? '' : 's'}
</span>
)}
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
@@ -1355,7 +1228,7 @@ function TroubleshootingSection({
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
Restore Aryx to its initial state. This permanently removes all sessions, custom workflows,
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
is not affected.
</p>
+22 -22
View File
@@ -13,7 +13,6 @@ import {
GitBranch,
GitFork,
ListOrdered,
Lock,
MessageSquare,
MoreHorizontal,
Pencil,
@@ -28,7 +27,7 @@ import {
type LucideIcon,
} from 'lucide-react';
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
import { resolveWorkflowAgentNodes, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { SessionRecord } from '@shared/domain/session';
@@ -59,13 +58,12 @@ interface SidebarProps {
/* ── Mode icon + accent colour mapping ─────────────────────── */
const modeVisuals: Record<OrchestrationMode, { icon: LucideIcon; color: string }> = {
const modeVisuals: Record<WorkflowOrchestrationMode, { icon: LucideIcon; color: string }> = {
single: { icon: MessageSquare, color: 'text-[#245CF9]' },
sequential: { icon: ListOrdered, color: 'text-[var(--color-status-warning)]' },
concurrent: { icon: GitFork, color: 'text-[var(--color-status-success)]' },
handoff: { icon: ArrowLeftRight, color: 'text-[var(--color-accent-sky)]' },
'group-chat': { icon: Users, color: 'text-[var(--color-accent-purple)]' },
magentic: { icon: Lock, color: 'text-[var(--color-text-muted)]' },
};
/* ── Relative time helper ──────────────────────────────────── */
@@ -178,7 +176,7 @@ function ActionMenuItem({
function SessionItem({
session,
pattern,
workflow,
isActive,
isRenaming,
onSelect,
@@ -187,7 +185,7 @@ function SessionItem({
onRenameCancel,
}: {
session: SessionRecord;
pattern?: PatternDefinition;
workflow?: WorkflowDefinition;
isActive: boolean;
isRenaming: boolean;
onSelect: () => void;
@@ -199,10 +197,10 @@ function SessionItem({
const isError = session.status === 'error';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const mode = pattern?.mode ?? 'single';
const mode = workflow?.settings.orchestrationMode ?? 'single';
const visual = modeVisuals[mode];
const ModeIcon = visual.icon;
const agentCount = pattern?.agents.length ?? 1;
const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1;
const [renameText, setRenameText] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -363,7 +361,7 @@ function SessionItem({
function ProjectGroup({
project,
sessions,
patterns,
workflows,
selectedSessionId,
renamingSessionId,
onSessionSelect,
@@ -377,7 +375,7 @@ function ProjectGroup({
}: {
project: ProjectRecord;
sessions: SessionRecord[];
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
selectedSessionId?: string;
renamingSessionId?: string;
onSessionSelect: (sessionId: string) => void;
@@ -392,11 +390,11 @@ function ProjectGroup({
const [expanded, setExpanded] = useState(true);
const isScratchpad = isScratchpadProject(project);
const patternMap = useMemo(() => {
const map = new Map<string, PatternDefinition>();
for (const p of patterns) map.set(p.id, p);
const workflowMap = useMemo(() => {
const map = new Map<string, WorkflowDefinition>();
for (const w of workflows) map.set(w.id, w);
return map;
}, [patterns]);
}, [workflows]);
const visibleSessions = useMemo(() =>
sessions
@@ -519,7 +517,7 @@ function ProjectGroup({
onOpenMenu={(e) => onOpenMenu(session.id, e)}
onRenameSubmit={(title) => onRenameSubmit(session.id, title)}
onRenameCancel={onRenameCancel}
pattern={patternMap.get(session.patternId)}
workflow={workflowMap.get(session.workflowId)}
session={session}
/>
))}
@@ -575,11 +573,13 @@ export function Sidebar({
const isQueryActive = searchText.trim().length > 0;
const patternMap = useMemo(() => {
const map = new Map<string, PatternDefinition>();
for (const p of workspace.patterns) map.set(p.id, p);
const workflowMap = useMemo(() => {
const map = new Map<string, WorkflowDefinition>();
for (const w of workspace.workflows) {
map.set(w.id, w);
}
return map;
}, [workspace.patterns]);
}, [workspace.workflows]);
const queryResults = useMemo(() => {
if (!isQueryActive) return [];
@@ -694,7 +694,7 @@ export function Sidebar({
onOpenMenu={(e) => handleOpenMenu(session.id, e)}
onRenameSubmit={(title) => handleRenameSubmit(session.id, title)}
onRenameCancel={() => setRenamingSessionId(undefined)}
pattern={patternMap.get(session.patternId)}
workflow={workflowMap.get(session.workflowId)}
session={session}
/>
))
@@ -715,7 +715,7 @@ export function Sidebar({
onRenameSubmit={handleRenameSubmit}
onRenameCancel={() => setRenamingSessionId(undefined)}
renamingSessionId={renamingSessionId}
patterns={workspace.patterns}
workflows={[...workflowMap.values()]}
project={scratchpadProject}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
@@ -764,7 +764,7 @@ export function Sidebar({
onRefreshGitContext={onRefreshGitContext}
onOpenProjectSettings={onOpenProjectSettings}
renamingSessionId={renamingSessionId}
patterns={workspace.patterns}
workflows={[...workflowMap.values()]}
project={project}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
+1 -1
View File
@@ -7,7 +7,7 @@ import { useClickOutside } from '@renderer/hooks/useClickOutside';
import type { ApprovalToolDefinition, LspProfileDefinition, McpServerDefinition, SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
import { groupApprovalToolsByProvider, type ApprovalToolGroup } from '@shared/domain/tooling';
import { findModel, inferProvider, providerMeta, type ModelDefinition } from '@shared/domain/models';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
import { RotateCcw, Server, ShieldCheck } from 'lucide-react';
/* ── Tier badge ────────────────────────────────────────────── */
@@ -1,126 +0,0 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { CircleUser, Link2, Shuffle, Layers, Radio, Bot } from 'lucide-react';
import type { GraphNodeData } from '@renderer/lib/patternGraph';
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
'user-input': CircleUser,
'user-output': CircleUser,
agent: Bot, // fallback when no provider is resolved
distributor: Shuffle,
collector: Layers,
orchestrator: Radio,
};
const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; text: string }> = {
'user-input': { bg: 'bg-[var(--color-accent)]/10', border: 'border-[var(--color-accent)]/30', text: 'text-[var(--color-accent-sky)]' },
'user-output': { bg: 'bg-[var(--color-accent)]/10', border: 'border-[var(--color-accent)]/30', text: 'text-[var(--color-accent-sky)]' },
agent: { bg: 'bg-[var(--color-surface-2)]/80', border: 'border-[var(--color-border)]/40', text: 'text-[var(--color-text-primary)]' },
distributor: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
collector: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
orchestrator: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
};
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
const colors = kindColors[data.kind] ?? kindColors.agent;
const isAgent = data.kind === 'agent';
const renderIcon = () => {
if (isAgent && data.provider) {
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
}
const FallbackIcon = kindIcons[data.kind] ?? Bot;
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
};
return (
<div
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md backdrop-blur-sm transition ${
colors.bg
} ${selected ? 'ring-2 ring-[var(--color-accent)]/50' : ''} ${colors.border}`}
>
{renderIcon()}
<div className="min-w-0 flex-1">
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
{data.label}
</div>
{isAgent && data.modelLabel && (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
)}
</div>
{data.readOnly && (
<span className="ml-1 rounded bg-[var(--color-surface-3)]/50 px-1 py-0.5 text-[8px] font-medium text-[var(--color-text-muted)]">
SYS
</span>
)}
{data.isLinked && (
<span className="ml-1 flex size-4 items-center justify-center rounded bg-[var(--color-accent)]/15" title="Linked workspace agent">
<Link2 className="size-2.5 text-[var(--color-accent)]" />
</span>
)}
</div>
);
}
const handleStyles = {
system: '!size-2 !border-[var(--color-border)] !bg-[var(--color-text-secondary)]',
agent: '!size-2 !border-[var(--color-accent-sky)] !bg-[var(--color-accent)]',
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
};
/* user-input: source only (no incoming handle)
user-output: target only (no outgoing handle) */
export const UserInputNode = memo(function UserInputNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.system} />
</>
);
});
export const UserOutputNode = memo(function UserOutputNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.system} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
</>
);
});
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.system} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.system} />
</>
);
});
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
</>
);
});
export const graphNodeTypes = {
userInputNode: UserInputNode,
userOutputNode: UserOutputNode,
systemNode: SystemNode,
agentNode: AgentNode,
};
@@ -1,221 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Panel,
MarkerType,
useNodesState,
useEdgesState,
useReactFlow,
type Node,
type Edge,
type OnConnect,
type OnEdgesChange,
type OnNodesChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { LayoutGrid } from 'lucide-react';
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern';
import type { ModelDefinition } from '@shared/domain/models';
import {
addEdge,
autoLayoutGraph,
fromCanvasPositions,
isConnectionAllowed,
isEdgeDeletionAllowed,
removeEdge,
toCanvasEdges,
toCanvasNodes,
type GraphNodeData,
} from '@renderer/lib/patternGraph';
import { graphNodeTypes } from './GraphNodes';
interface PatternGraphCanvasProps {
pattern: PatternDefinition;
availableModels?: ReadonlyArray<ModelDefinition>;
onGraphChange: (graph: PatternGraph) => void;
onAgentRemove: (agentId: string) => void;
onNodeSelect: (nodeId: string | null) => void;
selectedNodeId: string | null;
}
function PatternGraphCanvasInner({
pattern,
availableModels,
onGraphChange,
onAgentRemove,
onNodeSelect,
selectedNodeId,
}: PatternGraphCanvasProps) {
const { fitView } = useReactFlow();
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
const draggingRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState(
toCanvasNodes(graph, pattern.agents, availableModels),
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
toCanvasEdges(graph, pattern.mode),
);
// Sync canvas when pattern changes externally
useEffect(() => {
setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
setEdges(toCanvasEdges(graph, pattern.mode));
}, [graph, pattern.agents, pattern.mode, availableModels, setNodes, setEdges]);
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
(changes) => {
// Intercept node removals and route agent deletions through the
// authoritative removal path (which also removes from pattern.agents).
const removals = changes.filter((c) => c.type === 'remove');
const nonRemovals = changes.filter((c) => c.type !== 'remove');
for (const removal of removals) {
if (removal.type === 'remove') {
const graphNode = graph.nodes.find((n) => n.id === removal.id);
if (graphNode?.kind === 'agent' && graphNode.agentId) {
onAgentRemove(graphNode.agentId);
}
}
}
if (nonRemovals.length > 0) {
onNodesChange(nonRemovals);
}
const hasDragStart = nonRemovals.some(
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
);
const hasDragStop = nonRemovals.some(
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
);
if (hasDragStart) {
draggingRef.current = true;
}
if (hasDragStop && draggingRef.current) {
draggingRef.current = false;
setNodes((currentNodes) => {
const updatedGraph = fromCanvasPositions(pattern, currentNodes);
onGraphChange(updatedGraph);
return currentNodes;
});
}
},
[onNodesChange, pattern, graph, onGraphChange, onAgentRemove, setNodes],
);
const handleEdgesChange: OnEdgesChange = useCallback(
(changes) => {
// Route edge removals through the authoritative graph.
// Non-deletable edges are already protected by the per-edge `deletable`
// flag, so React Flow will not emit removal changes for them.
const removals = changes.filter((c) => c.type === 'remove');
if (removals.length > 0 && isEdgeDeletionAllowed(pattern.mode)) {
let updatedGraph = graph;
for (const removal of removals) {
if (removal.type === 'remove') {
updatedGraph = removeEdge(updatedGraph, removal.id);
}
}
onGraphChange(updatedGraph);
}
const nonRemovals = changes.filter((c) => c.type !== 'remove');
if (nonRemovals.length > 0) {
onEdgesChange(nonRemovals);
}
},
[onEdgesChange, pattern.mode, graph, onGraphChange],
);
const handleConnect: OnConnect = useCallback(
(connection) => {
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
return;
}
if (connection.source && connection.target) {
const updatedGraph = addEdge(graph, connection.source, connection.target);
onGraphChange(updatedGraph);
}
},
[graph, pattern.mode, onGraphChange],
);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<GraphNodeData>) => {
onNodeSelect(node.id);
},
[onNodeSelect],
);
const handlePaneClick = useCallback(() => {
onNodeSelect(null);
}, [onNodeSelect]);
const handleAutoLayout = useCallback(() => {
const layouted = autoLayoutGraph(graph);
onGraphChange(layouted);
// Allow React to render the new positions before fitting
requestAnimationFrame(() => fitView({ padding: 0.3 }));
}, [graph, onGraphChange, fitView]);
return (
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
<ReactFlow
nodes={nodes.map((n) => ({
...n,
selected: n.id === selectedNodeId,
}))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
nodeTypes={graphNodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'default',
style: { stroke: '#245CF9', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#245CF9' },
}}
connectionLineStyle={{ stroke: '#245CF9', strokeWidth: 1.5 }}
deleteKeyCode="Delete"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
<Panel position="top-right">
<button
type="button"
onClick={handleAutoLayout}
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 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
title="Auto-layout nodes"
>
<LayoutGrid className="size-3.5" />
Auto layout
</button>
</Panel>
</ReactFlow>
</div>
);
}
export function PatternGraphCanvas(props: PatternGraphCanvasProps) {
return (
<ReactFlowProvider>
<PatternGraphCanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -1,331 +0,0 @@
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Library, Link2, Radio, Shuffle, Trash2, Unlink } from 'lucide-react';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import type {
OrchestrationMode,
PatternAgentDefinition,
PatternGraph,
PatternGraphNodeKind,
} from '@shared/domain/pattern';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import {
canMoveSequential,
findAgentForNode,
swapSequentialOrder,
} from '@renderer/lib/patternGraph';
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
interface PatternGraphInspectorProps {
availableModels: ReadonlyArray<ModelDefinition>;
agents: PatternAgentDefinition[];
graph: PatternGraph;
mode: OrchestrationMode;
selectedNodeId: string | null;
workspaceAgents: WorkspaceAgentDefinition[];
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
onAgentRemove: (agentId: string) => void;
onAgentPromote: (agentId: string) => void;
onAgentUnlink: (agentId: string) => void;
onGraphChange: (graph: PatternGraph) => void;
}
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const base =
'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';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${base} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={base}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
const kindIcons: Record<PatternGraphNodeKind, typeof Bot> = {
'user-input': CircleUser,
'user-output': CircleUser,
agent: Bot,
distributor: Shuffle,
collector: Layers,
orchestrator: Radio,
};
const kindLabels: Partial<Record<PatternGraphNodeKind, string>> = {
'user-input': 'User Input',
'user-output': 'User Output',
distributor: 'Distributor',
collector: 'Collector',
orchestrator: 'Orchestrator',
};
const kindDescriptions: Partial<Record<PatternGraphNodeKind, string>> = {
'user-input': 'Entry point — receives user messages.',
'user-output': 'Exit point — returns final response to the user.',
distributor: 'Fans user input to all agents in parallel.',
collector: 'Aggregates parallel agent responses.',
orchestrator: 'Manages group chat round-robin turns.',
};
function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
const Icon = kindIcons[kind];
return (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-accent)]/10">
<Icon className="size-4 text-[var(--color-accent-sky)]" />
</div>
<div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">{kindLabels[kind]}</div>
<span className="rounded bg-[var(--color-surface-3)]/50 px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
System node
</span>
</div>
</div>
<p className="text-[12px] leading-relaxed text-[var(--color-text-muted)]">{kindDescriptions[kind]}</p>
</div>
);
}
function AgentNodeInspector({
agent,
availableModels,
mode,
graph,
nodeId,
workspaceAgents,
onAgentChange,
onAgentRemove,
onAgentPromote,
onAgentUnlink,
onGraphChange,
}: {
agent: PatternAgentDefinition;
availableModels: ReadonlyArray<ModelDefinition>;
mode: OrchestrationMode;
graph: PatternGraph;
nodeId: string;
workspaceAgents: WorkspaceAgentDefinition[];
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
onAgentRemove: (agentId: string) => void;
onAgentPromote: (agentId: string) => void;
onAgentUnlink: (agentId: string) => void;
onGraphChange: (graph: PatternGraph) => void;
}) {
const model = findModel(agent.model, availableModels);
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
const isLinked = Boolean(agent.workspaceAgentId);
const linkedWorkspaceAgent = isLinked
? workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId)
: undefined;
return (
<div className="space-y-4">
{/* Linked workspace agent banner */}
{isLinked && (
<div className="flex items-center justify-between rounded-lg border border-[var(--color-accent)]/20 bg-[var(--color-accent)]/5 px-3 py-2">
<div className="flex items-center gap-2">
<Link2 className="size-3.5 text-[var(--color-accent)]" />
<span className="text-[11px] text-[var(--color-text-secondary)]">
Linked to <strong className="text-[var(--color-text-primary)]">{linkedWorkspaceAgent?.name ?? 'unknown agent'}</strong>
</span>
</div>
<button
className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={() => onAgentUnlink(agent.id)}
title="Unlink from workspace agent (convert to inline)"
type="button"
>
<Unlink className="size-3" />
Unlink
</button>
</div>
)}
<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-[var(--color-surface-2)]">
<Bot className="size-4 text-[var(--color-text-secondary)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">{agent.name || 'Unnamed'}</div>
</div>
<div className="flex items-center gap-1">
{showReorder && (
<>
<button
className="flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-muted)]"
disabled={!canUp}
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'up'))}
title="Move earlier in sequence"
type="button"
>
<ChevronUp className="size-3.5" />
</button>
<button
className="flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-muted)]"
disabled={!canDown}
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'down'))}
title="Move later in sequence"
type="button"
>
<ChevronDown className="size-3.5" />
</button>
</>
)}
<button
className="flex items-center gap-1 text-[12px] text-[var(--color-text-muted)] transition hover:text-red-400"
onClick={() => onAgentRemove(agent.id)}
type="button"
>
<Trash2 className="size-3" />
</button>
</div>
</div>
<InputField
label="Name"
onChange={(v) => onAgentChange(agent.id, { name: v })}
value={agent.name}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
onAgentChange(agent.id, {
model: value,
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
});
}}
value={agent.model}
/>
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => onAgentChange(agent.id, { reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={resolveReasoningEffort(model, agent.reasoningEffort)}
/>
</div>
<InputField
label="Description"
onChange={(v) => onAgentChange(agent.id, { description: v })}
placeholder="What this agent does..."
value={agent.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => onAgentChange(agent.id, { instructions: v })}
placeholder="System prompt for this agent..."
value={agent.instructions}
/>
{/* Promote / Unlink action */}
{!isLinked && (
<button
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[var(--color-border)] px-3 py-2 text-[12px] text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)]/30 hover:bg-[var(--color-accent)]/5 hover:text-[var(--color-accent)]"
onClick={() => onAgentPromote(agent.id)}
title="Save this agent to the workspace library for reuse across patterns"
type="button"
>
<Library className="size-3.5" />
Save to Agent Library
</button>
)}
</div>
);
}
export function PatternGraphInspector({
availableModels,
agents,
graph,
mode,
selectedNodeId,
workspaceAgents,
onAgentChange,
onAgentRemove,
onAgentPromote,
onAgentUnlink,
onGraphChange,
}: PatternGraphInspectorProps) {
if (!selectedNodeId) {
return (
<div className="flex h-full items-center justify-center p-4">
<p className="text-center text-[12px] text-[var(--color-text-muted)]">
Select a node on the graph to inspect it
</p>
</div>
);
}
const node = graph.nodes.find((n) => n.id === selectedNodeId);
if (!node) {
return null;
}
if (node.kind !== 'agent') {
return (
<div className="p-4">
<SystemNodeInspector kind={node.kind} />
</div>
);
}
const agent = findAgentForNode(selectedNodeId, graph, agents);
if (!agent) {
return null;
}
return (
<div className="p-4">
<AgentNodeInspector
agent={agent}
availableModels={availableModels}
mode={mode}
graph={graph}
nodeId={selectedNodeId}
workspaceAgents={workspaceAgents}
onAgentChange={onAgentChange}
onAgentRemove={onAgentRemove}
onAgentPromote={onAgentPromote}
onAgentUnlink={onAgentUnlink}
onGraphChange={onGraphChange}
/>
</div>
);
}