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
+31 -92
View File
@@ -7,7 +7,6 @@ import { ChatPane } from '@renderer/components/ChatPane';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
@@ -39,47 +38,20 @@ import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
import {
buildAvailableModelCatalog,
findModel,
normalizePatternModels,
normalizeWorkflowModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
import { type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import type { ProjectGitFileReference } from '@shared/domain/project';
import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
import { createId, nowIso } from '@shared/utils/ids';
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
const timestamp = nowIso();
return syncPatternGraph({
id: createId('custom-pattern'),
name: 'New Pattern',
description: '',
mode: 'single',
availability: 'available',
maxIterations: 1,
approvalPolicy: createDefaultToolApprovalPolicy(),
agents: [
{
id: createId('agent'),
name: 'Primary Agent',
description: 'General-purpose assistant.',
instructions: 'You are a helpful coding assistant working inside the selected project.',
model: defaultModelId,
reasoningEffort: defaultReasoningEffort,
},
],
createdAt: timestamp,
updatedAt: timestamp,
});
}
function createDraftMcpServer(): McpServerDefinition {
const timestamp = nowIso();
return {
@@ -122,7 +94,7 @@ function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefini
};
}
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): WorkflowDefinition {
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort?: ReasoningEffort): WorkflowDefinition {
const timestamp = nowIso();
const startId = createId('wf-start');
const agentId = createId('wf-agent');
@@ -181,7 +153,6 @@ export default function App() {
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
const [projectSettingsId, setProjectSettingsId] = useState<string>();
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
@@ -286,23 +257,23 @@ export default function App() {
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
[sidecarCapabilities?.models],
);
const patternForSession = useMemo(() => {
const workflowForSession = useMemo(() => {
if (!selectedSession) {
return undefined;
}
const basePattern = workspace?.patterns.find((pattern) => pattern.id === selectedSession.patternId);
if (!basePattern) {
const baseWorkflow = workspace?.workflows.find((workflow) => workflow.id === selectedSession.workflowId);
if (!baseWorkflow) {
return undefined;
}
const patternWithSessionConfig =
const workflowWithSessionConfig =
projectForSession && selectedSession.sessionModelConfig
? applySessionModelConfig(basePattern, selectedSession)
: basePattern;
? applySessionModelConfig(baseWorkflow, selectedSession)
: baseWorkflow;
return normalizePatternModels(patternWithSessionConfig, availableModels);
}, [availableModels, projectForSession, selectedSession, workspace?.patterns]);
return normalizeWorkflowModels(workflowWithSessionConfig, availableModels);
}, [availableModels, projectForSession, selectedSession, workspace?.workflows]);
const activityForSession = useMemo(
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities],
@@ -361,8 +332,6 @@ export default function App() {
commandPaletteOpenRef.current = commandPaletteOpen;
const projectSettingsIdRef = useRef(projectSettingsId);
projectSettingsIdRef.current = projectSettingsId;
const newSessionProjectIdRef = useRef(newSessionProjectId);
newSessionProjectIdRef.current = newSessionProjectId;
// ── Global keyboard shortcuts ──
useEffect(() => {
@@ -431,11 +400,6 @@ export default function App() {
setShowSettings(false);
return;
}
if (newSessionProjectIdRef.current) {
e.preventDefault();
setNewSessionProjectId(undefined);
return;
}
// If nothing is open, cancel a running turn on the selected session
if (ws) {
@@ -460,7 +424,10 @@ export default function App() {
ws.selectedProjectId ??
ws.projects.find((p) => !isScratchpadProject(p))?.id;
if (defaultProjectId) {
setNewSessionProjectId(defaultProjectId);
const defaultWorkflow = ws.workflows.find((w) => w.isFavorite) ?? ws.workflows[0];
if (defaultWorkflow) {
void api.createSession({ projectId: defaultProjectId, workflowId: defaultWorkflow.id });
}
}
}
return;
@@ -584,17 +551,17 @@ export default function App() {
const handleCreateScratchpad = useCallback(() => {
if (!workspace) return;
const singlePatterns = workspace.patterns
.filter((p) => p.mode === 'single' && p.availability !== 'unavailable')
const singleWorkflows = workspace.workflows
.filter((w) => (w.settings.orchestrationMode ?? 'single') === 'single')
.sort((a, b) => {
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
return 0;
});
const defaultPattern = singlePatterns[0];
if (defaultPattern) {
void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, patternId: defaultPattern.id });
const defaultWorkflow = singleWorkflows[0];
if (defaultWorkflow) {
void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, workflowId: defaultWorkflow.id });
}
}, [api, workspace]);
@@ -645,7 +612,7 @@ export default function App() {
</div>
</div>
);
} else if (selectedSession && patternForSession && projectForSession) {
} else if (selectedSession && workflowForSession && projectForSession) {
content = (
<ChatPane
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
@@ -715,7 +682,7 @@ export default function App() {
mcpProbingServerIds={workspace.mcpProbingServerIds}
onTerminalToggle={handleTerminalToggle}
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
pattern={patternForSession}
workflow={workflowForSession}
project={projectForSession}
runtimeTools={sidecarCapabilities?.runtimeTools}
session={selectedSession}
@@ -734,7 +701,7 @@ export default function App() {
onDiscard={handleDiscardRunChanges}
onJumpToMessage={jumpToMessage}
onOpenCommitComposer={handleOpenCommitComposer}
pattern={patternForSession}
workflow={workflowForSession}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
@@ -765,22 +732,11 @@ export default function App() {
onDeleteMcpServer={async (id) => {
await api.deleteMcpServer(id);
}}
onDeletePattern={async (id) => {
await api.deletePattern(id);
}}
onDeleteWorkflow={async (id) => {
await api.deleteWorkflow(id);
}}
onNewLspProfile={createDraftLspProfile}
onNewMcpServer={createDraftMcpServer}
onNewPattern={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftPattern(
defaultModel?.id ?? 'gpt-5.4',
resolveReasoningEffort(defaultModel, 'high'),
);
}}
onNewWorkflow={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftWorkflow(
@@ -795,9 +751,6 @@ export default function App() {
onSaveMcpServer={async (server) => {
await api.saveMcpServer({ server });
}}
onSavePattern={async (pattern) => {
await api.savePattern({ pattern });
}}
onSaveWorkflow={async (workflow) => {
await api.saveWorkflow({ workflow });
}}
@@ -826,15 +779,11 @@ export default function App() {
setSessionActivities({});
setShowSettings(false);
}}
patterns={workspace.patterns}
workflows={workspace.workflows}
workflowTemplates={workspace.workflowTemplates}
onCreateWorkflowFromTemplate={async (templateId, name) => {
await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined });
}}
onUpgradePatternToWorkflow={async (patternId) => {
await api.upgradePatternToWorkflow({ patternId, options: { save: true } });
}}
sidecarCapabilities={sidecarCapabilities}
theme={workspace.settings.theme}
toolingSettings={workspace.settings.tooling}
@@ -884,7 +833,10 @@ export default function App() {
onAddProject={() => void api.addProject()}
onCreateScratchpad={() => handleCreateScratchpad()}
onNewProjectSession={(projectId) => {
setNewSessionProjectId(projectId);
const defaultWorkflow = workspace.workflows.find((w) => w.isFavorite) ?? workspace.workflows[0];
if (defaultWorkflow) {
void api.createSession({ projectId, workflowId: defaultWorkflow.id });
}
}}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
@@ -920,22 +872,6 @@ export default function App() {
}
/>
{newSessionProjectId && (
<NewSessionModal
defaultProjectId={newSessionProjectId}
onClose={() => setNewSessionProjectId(undefined)}
onCreate={(projectId, patternId) => {
setNewSessionProjectId(undefined);
void api.createSession({ projectId, patternId });
}}
onTogglePatternFavorite={(patternId, isFavorite) => {
void api.setPatternFavorite({ patternId, isFavorite });
}}
patterns={workspace.patterns}
projects={workspace.projects}
/>
)}
{showDiscoveryModal && (
<DiscoveredToolingModal
onClose={() => setShowDiscoveryModal(false)}
@@ -987,7 +923,10 @@ export default function App() {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => {
setNewSessionProjectId(projectId);
const defaultWorkflow = workspace.workflows.find((w) => w.isFavorite) ?? workspace.workflows[0];
if (defaultWorkflow) {
void api.createSession({ projectId, workflowId: defaultWorkflow.id });
}
}}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
+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>
);
}
-389
View File
@@ -1,389 +0,0 @@
import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
import type {
OrchestrationMode,
PatternAgentDefinition,
PatternDefinition,
PatternGraph,
PatternGraphEdge,
PatternGraphNode,
PatternGraphNodeKind,
} from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern';
import type { ModelProvider } from '@shared/domain/models';
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
/* ── Canvas node data ──────────────────────────────────────── */
export interface GraphNodeData extends Record<string, unknown> {
label: string;
kind: PatternGraphNodeKind;
agentId?: string;
order?: number;
readOnly: boolean;
/** AI provider inferred from the agent's model (agent nodes only). */
provider?: ModelProvider;
/** Short display name for the agent's model (agent nodes only). */
modelLabel?: string;
/** True when the agent references a workspace agent definition. */
isLinked?: boolean;
}
/* ── View-model projection ─────────────────────────────────── */
const SYSTEM_NODE_LABELS: Partial<Record<PatternGraphNodeKind, string>> = {
'user-input': 'User Input',
'user-output': 'User Output',
distributor: 'Distributor',
collector: 'Collector',
orchestrator: 'Orchestrator',
};
function isSystemNode(kind: PatternGraphNodeKind): boolean {
return kind !== 'agent';
}
function resolveNodeLabel(node: PatternGraphNode, agents: PatternAgentDefinition[]): string {
if (node.kind === 'agent' && node.agentId) {
const agent = agents.find((a) => a.id === node.agentId);
return agent?.name || node.agentId;
}
return SYSTEM_NODE_LABELS[node.kind] ?? node.kind;
}
function resolveNodeType(kind: PatternGraphNodeKind): string {
switch (kind) {
case 'user-input':
return 'userInputNode';
case 'user-output':
return 'userOutputNode';
case 'agent':
return 'agentNode';
default:
return 'systemNode';
}
}
export function toCanvasNodes(
graph: PatternGraph,
agents: PatternAgentDefinition[],
models?: ReadonlyArray<ModelDefinition>,
): Node<GraphNodeData>[] {
return graph.nodes.map((node) => {
let provider: ModelProvider | undefined;
let modelLabel: string | undefined;
if (node.kind === 'agent' && node.agentId) {
const agent = agents.find((a) => a.id === node.agentId);
if (agent?.model) {
provider = inferProvider(agent.model);
const modelDef = models ? findModel(agent.model, models) : undefined;
modelLabel = modelDef?.name ?? agent.model;
}
}
const isLinked = node.kind === 'agent' && node.agentId
? Boolean(agents.find((a) => a.id === node.agentId)?.workspaceAgentId)
: false;
return {
id: node.id,
type: resolveNodeType(node.kind),
position: { x: node.position.x, y: node.position.y },
data: {
label: resolveNodeLabel(node, agents),
kind: node.kind,
agentId: node.agentId,
order: node.order,
readOnly: isSystemNode(node.kind),
provider,
modelLabel,
isLinked,
},
draggable: true,
selectable: true,
deletable: node.kind === 'agent',
};
});
}
/** Determines whether an edge can be deleted by the user.
*
* Deletable edges:
* - handoff: agent ↔ agent edges (handoff routes)
* - group-chat: orchestrator ↔ agent edges (participation links)
*
* Structural edges (user-input/output, distributor/collector) are never deletable.
* Sequential, concurrent, and single modes have fully structural topologies.
*/
function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph: PatternGraph): boolean {
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
const targetNode = graph.nodes.find((n) => n.id === edge.target);
if (!sourceNode || !targetNode) {
return false;
}
if (mode === 'handoff') {
return sourceNode.kind === 'agent' && targetNode.kind === 'agent';
}
if (mode === 'group-chat') {
const kinds = new Set([sourceNode.kind, targetNode.kind]);
return kinds.has('orchestrator') && kinds.has('agent');
}
return false;
}
const EDGE_COLORS = {
structural: { stroke: '#3f3f46', markerColor: '#3f3f46' }, // zinc-700 — muted
agentToAgent: { stroke: '#6366f1', markerColor: '#6366f1' }, // indigo-500 — distinct
};
function resolveEdgeColor(edge: PatternGraphEdge, graph: PatternGraph): typeof EDGE_COLORS.structural {
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
const targetNode = graph.nodes.find((n) => n.id === edge.target);
if (sourceNode?.kind === 'agent' && targetNode?.kind === 'agent') {
return EDGE_COLORS.agentToAgent;
}
return EDGE_COLORS.structural;
}
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
return graph.edges.map((edge) => {
const color = resolveEdgeColor(edge, graph);
return {
id: edge.id,
source: edge.source,
target: edge.target,
type: 'default',
animated: mode === 'handoff',
deletable: isEdgeDeletable(edge, mode, graph),
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
style: { stroke: color.stroke, strokeWidth: 1.5 },
};
});
}
export function fromCanvasPositions(
pattern: PatternDefinition,
canvasNodes: Node<GraphNodeData>[],
): PatternGraph {
const graph = resolvePatternGraph(pattern);
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
return {
nodes: graph.nodes.map((node) => {
const pos = positionMap.get(node.id);
return pos ? { ...node, position: { x: Math.round(pos.x), y: Math.round(pos.y) } } : node;
}),
edges: graph.edges,
};
}
/* ── Connection rules ──────────────────────────────────────── */
export function isConnectionAllowed(
connection: Connection,
mode: OrchestrationMode,
graph: PatternGraph,
): boolean {
if (!connection.source || !connection.target) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
const targetNode = graph.nodes.find((n) => n.id === connection.target);
if (!sourceNode || !targetNode) {
return false;
}
switch (mode) {
case 'single':
case 'sequential':
case 'magentic':
return false;
case 'concurrent':
return false;
case 'handoff':
return sourceNode.kind === 'agent' && targetNode.kind === 'agent';
case 'group-chat': {
const kinds = new Set([sourceNode.kind, targetNode.kind]);
return kinds.has('orchestrator') && kinds.has('agent');
}
default:
return false;
}
}
/** Whether any edges can be deleted by the user in this mode. */
export function isEdgeDeletionAllowed(mode: OrchestrationMode): boolean {
return mode === 'handoff' || mode === 'group-chat';
}
/* ── Graph mutation helpers ─────────────────────────────────── */
function edgeId(source: string, target: string): string {
return `edge-${source}-to-${target}`;
}
export function addEdge(graph: PatternGraph, source: string, target: string): PatternGraph {
const newEdge: PatternGraphEdge = {
id: edgeId(source, target),
source,
target,
};
if (graph.edges.some((e) => e.source === source && e.target === target)) {
return graph;
}
return { ...graph, edges: [...graph.edges, newEdge] };
}
export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGraph {
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
}
/* ── Sequential reorder ────────────────────────────────────── */
export function swapSequentialOrder(
graph: PatternGraph,
agentNodeId: string,
direction: 'up' | 'down',
): PatternGraph {
const agentNodes = graph.nodes
.filter((n) => n.kind === 'agent')
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const currentIndex = agentNodes.findIndex((n) => n.id === agentNodeId);
if (currentIndex < 0) {
return graph;
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= agentNodes.length) {
return graph;
}
const currentNode = agentNodes[currentIndex]!;
const swapNode = agentNodes[swapIndex]!;
// Swap order values
const updatedNodes = graph.nodes.map((n) => {
if (n.id === currentNode.id) {
return { ...n, order: swapNode.order, position: { ...swapNode.position } };
}
if (n.id === swapNode.id) {
return { ...n, order: currentNode.order, position: { ...currentNode.position } };
}
return n;
});
// Rebuild linear edges: input → agent₁ → agent₂ → ... → output
const inputNode = graph.nodes.find((n) => n.kind === 'user-input');
const outputNode = graph.nodes.find((n) => n.kind === 'user-output');
const sortedAgents = updatedNodes
.filter((n) => n.kind === 'agent')
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const path = [
...(inputNode ? [inputNode.id] : []),
...sortedAgents.map((n) => n.id),
...(outputNode ? [outputNode.id] : []),
];
const newEdges: PatternGraphEdge[] = [];
for (let i = 0; i < path.length - 1; i += 1) {
newEdges.push({ id: edgeId(path[i]!, path[i + 1]!), source: path[i]!, target: path[i + 1]! });
}
return { nodes: updatedNodes, edges: newEdges };
}
/** Check if an agent node can be moved up or down in sequential order. */
export function canMoveSequential(
graph: PatternGraph,
agentNodeId: string,
direction: 'up' | 'down',
): boolean {
const agentNodes = graph.nodes
.filter((n) => n.kind === 'agent')
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const index = agentNodes.findIndex((n) => n.id === agentNodeId);
if (index < 0) {
return false;
}
return direction === 'up' ? index > 0 : index < agentNodes.length - 1;
}
/* ── Agent node for selected inspector ─────────────────────── */
export function findAgentForNode(
nodeId: string,
graph: PatternGraph,
agents: PatternAgentDefinition[],
): PatternAgentDefinition | undefined {
const node = graph.nodes.find((n) => n.id === nodeId);
if (!node?.agentId) {
return undefined;
}
return agents.find((a) => a.id === node.agentId);
}
/* ── Auto-layout via dagre ─────────────────────────────────── */
import dagre from '@dagrejs/dagre';
const NODE_WIDTH = 170;
const NODE_HEIGHT = 52;
/**
* Re-compute all node positions using dagre's layered layout algorithm.
* Direction is always left-to-right to match the graph flow.
* Returns a new graph with updated positions; edges are unchanged.
*/
export function autoLayoutGraph(graph: PatternGraph): PatternGraph {
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: 'LR',
nodesep: 60,
ranksep: 120,
marginx: 20,
marginy: 20,
});
for (const node of graph.nodes) {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of graph.edges) {
g.setEdge(edge.source, edge.target);
}
dagre.layout(g);
const layoutedNodes = graph.nodes.map((node) => {
const dagreNode = g.node(node.id);
return {
...node,
position: {
x: Math.round(dagreNode.x - NODE_WIDTH / 2),
y: Math.round(dagreNode.y - NODE_HEIGHT / 2),
},
};
});
return { nodes: layoutedNodes, edges: graph.edges };
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import type { SessionEventRecord } from '@shared/domain/event';
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
@@ -93,7 +93,7 @@ export function pruneSessionActivities(
export function buildAgentActivityRows(
current: SessionActivityState | undefined,
agents: PatternDefinition['agents'],
agents: ReadonlyArray<{ id: string; name: string }>,
): AgentActivityRow[] {
return agents.map((agent) => {
const activity = current?.[agent.id] ?? current?.[agent.name];
File diff suppressed because it is too large Load Diff
-383
View File
@@ -1,383 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { addAgentToGraph, createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
import {
addEdge,
autoLayoutGraph,
canMoveSequential,
findAgentForNode,
isConnectionAllowed,
isEdgeDeletionAllowed,
removeEdge,
swapSequentialOrder,
toCanvasEdges,
toCanvasNodes,
} from '@renderer/lib/patternGraph';
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
function findPattern(mode: string): PatternDefinition {
return patterns.find((p) => p.mode === mode)!;
}
describe('pattern graph view model', () => {
test('projects sequential graph into canvas nodes with correct kinds and labels', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents);
expect(nodes.length).toBe(pattern.agents.length + 2);
const userInput = nodes.find((n) => n.data.kind === 'user-input');
const userOutput = nodes.find((n) => n.data.kind === 'user-output');
expect(userInput).toBeDefined();
expect(userOutput).toBeDefined();
expect(userInput!.data.readOnly).toBe(true);
expect(userOutput!.data.readOnly).toBe(true);
expect(userInput!.type).toBe('userInputNode');
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
expect(agentNodes.length).toBe(pattern.agents.length);
expect(agentNodes[0]!.data.label).toBe(pattern.agents[0]!.name);
expect(agentNodes[0]!.data.readOnly).toBe(false);
expect(agentNodes[0]!.type).toBe('agentNode');
});
test('projects concurrent graph with distributor and collector system nodes', () => {
const pattern = findPattern('concurrent');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents);
const edges = toCanvasEdges(graph, pattern.mode);
const distributor = nodes.find((n) => n.data.kind === 'distributor');
const collector = nodes.find((n) => n.data.kind === 'collector');
expect(distributor).toBeDefined();
expect(collector).toBeDefined();
expect(distributor!.data.readOnly).toBe(true);
expect(collector!.data.readOnly).toBe(true);
expect(edges.length).toBeGreaterThan(0);
expect(edges.every((e) => !e.animated)).toBe(true);
});
test('handoff graph edges are animated', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const edges = toCanvasEdges(graph, pattern.mode);
expect(edges.every((e) => e.animated)).toBe(true);
});
test('group chat graph includes orchestrator system node', () => {
const pattern = findPattern('group-chat');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents);
const orchestrator = nodes.find((n) => n.data.kind === 'orchestrator');
expect(orchestrator).toBeDefined();
expect(orchestrator!.data.readOnly).toBe(true);
expect(orchestrator!.data.label).toBe('Orchestrator');
});
test('findAgentForNode resolves agent from graph node id', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const firstAgent = pattern.agents[0]!;
const agent = findAgentForNode(`agent-node-${firstAgent.id}`, graph, pattern.agents);
expect(agent).toBeDefined();
expect(agent!.id).toBe(firstAgent.id);
const noAgent = findAgentForNode('system-user-input', graph, pattern.agents);
expect(noAgent).toBeUndefined();
});
});
describe('pattern graph connection rules', () => {
test('sequential mode disallows all new connections', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
const allowed = isConnectionAllowed(
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
'sequential',
graph,
);
expect(allowed).toBe(false);
});
test('handoff mode allows agent-to-agent connections', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
const allowed = isConnectionAllowed(
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
'handoff',
graph,
);
expect(allowed).toBe(true);
});
test('handoff mode blocks system-to-agent connections', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const inputNode = graph.nodes.find((n) => n.kind === 'user-input')!;
const agentNode = graph.nodes.find((n) => n.kind === 'agent')!;
const allowed = isConnectionAllowed(
{ source: inputNode.id, target: agentNode.id, sourceHandle: null, targetHandle: null },
'handoff',
graph,
);
expect(allowed).toBe(false);
});
test('concurrent mode disallows all new connections', () => {
const pattern = findPattern('concurrent');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
const allowed = isConnectionAllowed(
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
'concurrent',
graph,
);
expect(allowed).toBe(false);
});
test('group-chat mode allows orchestrator-to-agent connections', () => {
const pattern = findPattern('group-chat');
const graph = resolvePatternGraph(pattern);
const orchestratorNode = graph.nodes.find((n) => n.kind === 'orchestrator')!;
const agentNode = graph.nodes.find((n) => n.kind === 'agent')!;
const orcToAgent = isConnectionAllowed(
{ source: orchestratorNode.id, target: agentNode.id, sourceHandle: null, targetHandle: null },
'group-chat',
graph,
);
expect(orcToAgent).toBe(true);
const agentToOrc = isConnectionAllowed(
{ source: agentNode.id, target: orchestratorNode.id, sourceHandle: null, targetHandle: null },
'group-chat',
graph,
);
expect(agentToOrc).toBe(true);
});
test('group-chat mode disallows agent-to-agent connections', () => {
const pattern = findPattern('group-chat');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
const allowed = isConnectionAllowed(
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
'group-chat',
graph,
);
expect(allowed).toBe(false);
});
});
describe('pattern graph mutation helpers', () => {
test('addEdge adds a new edge between agent nodes', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
const initialEdgeCount = graph.edges.length;
const updated = addEdge(graph, agentNodes[1]!.id, agentNodes[2]!.id);
expect(updated.edges.length).toBe(initialEdgeCount + 1);
const duplicated = addEdge(updated, agentNodes[1]!.id, agentNodes[2]!.id);
expect(duplicated.edges.length).toBe(initialEdgeCount + 1);
});
test('removeEdge removes an edge by id', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const firstEdge = graph.edges[0]!;
const updated = removeEdge(graph, firstEdge.id);
expect(updated.edges.length).toBe(graph.edges.length - 1);
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
});
test('addAgentToGraph inserts a wired agent node for sequential mode', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const newAgent = { id: 'new-1', name: 'New Agent', description: '', instructions: '', model: 'gpt-5.4' };
const updated = addAgentToGraph(graph, pattern.mode, newAgent);
expect(updated.nodes.length).toBe(graph.nodes.length + 1);
const newNode = updated.nodes.find((n) => n.agentId === 'new-1');
expect(newNode).toBeDefined();
expect(newNode!.kind).toBe('agent');
// New agent is wired into the chain, so edge count stays at agents+1
expect(updated.edges.length).toBe(graph.edges.length + 1);
});
});
describe('sequential reorder', () => {
test('swapSequentialOrder swaps two adjacent agents and rebuilds edges', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const firstId = agentNodes[0]!.id;
const secondId = agentNodes[1]!.id;
const swapped = swapSequentialOrder(graph, firstId, 'down');
const newFirst = swapped.nodes.find((n) => n.id === firstId)!;
const newSecond = swapped.nodes.find((n) => n.id === secondId)!;
expect(newFirst.order).toBe(1);
expect(newSecond.order).toBe(0);
// Verify linear edges still form a valid chain
const sortedAgents = swapped.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
expect(sortedAgents[0]!.id).toBe(secondId);
expect(sortedAgents[1]!.id).toBe(firstId);
});
test('canMoveSequential respects boundary conditions', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
expect(canMoveSequential(graph, agentNodes[0]!.id, 'up')).toBe(false);
expect(canMoveSequential(graph, agentNodes[0]!.id, 'down')).toBe(true);
expect(canMoveSequential(graph, agentNodes[agentNodes.length - 1]!.id, 'down')).toBe(false);
expect(canMoveSequential(graph, agentNodes[agentNodes.length - 1]!.id, 'up')).toBe(true);
});
});
describe('edge deletion rules', () => {
test('handoff and group-chat modes allow edge deletion', () => {
expect(isEdgeDeletionAllowed('handoff')).toBe(true);
expect(isEdgeDeletionAllowed('group-chat')).toBe(true);
expect(isEdgeDeletionAllowed('sequential')).toBe(false);
expect(isEdgeDeletionAllowed('concurrent')).toBe(false);
expect(isEdgeDeletionAllowed('single')).toBe(false);
});
test('handoff canvas edges mark only agent-to-agent edges as deletable', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const edges = toCanvasEdges(graph, 'handoff');
const deletableEdges = edges.filter((e) => e.deletable);
const nonDeletableEdges = edges.filter((e) => !e.deletable);
// Agent-to-agent edges should be deletable
expect(deletableEdges.length).toBeGreaterThan(0);
// Structural edges (user-input → triage, agent → user-output) should not
expect(nonDeletableEdges.length).toBeGreaterThan(0);
});
test('group-chat marks orchestrator↔agent edges as deletable', () => {
const pattern = findPattern('group-chat');
const graph = resolvePatternGraph(pattern);
const edges = toCanvasEdges(graph, 'group-chat');
const deletableEdges = edges.filter((e) => e.deletable);
const nonDeletableEdges = edges.filter((e) => !e.deletable);
// Orchestrator↔agent edges should be deletable
expect(deletableEdges.length).toBeGreaterThan(0);
// Structural edges (user-input → orchestrator, orchestrator → user-output)
expect(nonDeletableEdges.length).toBeGreaterThan(0);
});
test('user-input nodes use userInputNode type and user-output use userOutputNode type', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents);
const inputNode = nodes.find((n) => n.data.kind === 'user-input');
const outputNode = nodes.find((n) => n.data.kind === 'user-output');
expect(inputNode!.type).toBe('userInputNode');
expect(outputNode!.type).toBe('userOutputNode');
});
test('edges have directional arrow markers', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const edges = toCanvasEdges(graph, pattern.mode);
expect(edges.length).toBeGreaterThan(0);
for (const edge of edges) {
expect(edge.markerEnd).toBeDefined();
expect((edge.markerEnd as { type: string }).type).toBe('arrowclosed');
}
});
test('agent nodes include provider and model label when models catalog is provided', () => {
const { modelCatalog } = require('@shared/domain/models');
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents, modelCatalog);
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
expect(agentNodes.length).toBeGreaterThan(0);
for (const node of agentNodes) {
expect(node.data.provider).toBeDefined();
expect(node.data.modelLabel).toBeDefined();
expect(node.data.modelLabel!.length).toBeGreaterThan(0);
}
});
test('agent nodes infer provider from model id without models catalog', () => {
const pattern = findPattern('sequential');
const graph = resolvePatternGraph(pattern);
const nodes = toCanvasNodes(graph, pattern.agents);
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
for (const node of agentNodes) {
// Provider should still be inferred from model id prefix
expect(node.data.provider).toBeDefined();
// Without catalog, modelLabel falls back to the raw model id
expect(node.data.modelLabel).toBeDefined();
}
});
});
describe('auto-layout', () => {
test('autoLayoutGraph repositions nodes without changing edges', () => {
const pattern = findPattern('handoff');
const graph = resolvePatternGraph(pattern);
const layouted = autoLayoutGraph(graph);
expect(layouted.nodes.length).toBe(graph.nodes.length);
expect(layouted.edges.length).toBe(graph.edges.length);
expect(layouted.edges).toEqual(graph.edges);
const positionsChanged = layouted.nodes.some((n, i) => {
const orig = graph.nodes[i]!;
return n.position.x !== orig.position.x || n.position.y !== orig.position.y;
});
expect(positionsChanged).toBe(true);
});
test('autoLayoutGraph produces finite positions for all modes', () => {
for (const mode of ['sequential', 'concurrent', 'handoff', 'group-chat'] as const) {
const pattern = findPattern(mode);
const graph = resolvePatternGraph(pattern);
const layouted = autoLayoutGraph(graph);
expect(layouted.nodes.length).toBe(graph.nodes.length);
for (const node of layouted.nodes) {
expect(Number.isFinite(node.position.x)).toBe(true);
expect(Number.isFinite(node.position.y)).toBe(true);
}
}
});
});
-538
View File
@@ -1,538 +0,0 @@
import { describe, expect, test } from 'bun:test';
import {
addAgentToGraph,
createBuiltinPatterns,
removeAgentFromGraph,
resolvePatternGraph,
syncPatternGraph,
type PatternAgentDefinition,
validatePatternDefinition,
} from '@shared/domain/pattern';
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
function createAgent(id: string, name = `Agent ${id}`): PatternAgentDefinition {
return {
id,
name,
description: `${name} description`,
instructions: `${name} instructions`,
model: 'gpt-5.4',
reasoningEffort: 'medium',
};
}
describe('pattern validation', () => {
test('builtin patterns are valid except explicitly unavailable modes', () => {
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
const validPatterns = patterns.filter((pattern) => pattern.availability !== 'unavailable');
for (const pattern of validPatterns) {
expect(validatePatternDefinition(pattern)).toEqual([]);
}
});
test('builtin patterns require tool-call approval by default', () => {
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
for (const pattern of patterns) {
expect(pattern.approvalPolicy?.rules).toContainEqual({ kind: 'tool-call' });
}
});
test('magentic pattern is marked unavailable', () => {
const magentic = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'magentic',
);
expect(magentic).toBeDefined();
expect(validatePatternDefinition(magentic!)[0]?.message).toContain('unsupported');
});
test('single-agent mode reports agent count, warning, and model issues together', () => {
const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'single',
);
expect(singlePattern).toBeDefined();
const issues = validatePatternDefinition({
...singlePattern!,
agents: [
{
...singlePattern!.agents[0],
instructions: ' ',
},
{
...singlePattern!.agents[0],
id: 'agent-reviewer',
name: 'Reviewer',
model: '',
},
],
});
expect(issues.find((issue) => issue.field === 'agents')?.message).toBe(
'Single-agent chat requires exactly one agent.',
);
expect(issues.find((issue) => issue.field === 'agents.instructions')?.level).toBe('warning');
expect(issues.find((issue) => issue.field === 'agents.instructions')?.message).toBe(
'Agent "Primary Agent" should have instructions.',
);
expect(issues.find((issue) => issue.field === 'agents.model')?.message).toBe(
'Agent "Reviewer" requires a model identifier.',
);
});
test('multi-agent orchestration modes reject single-agent configurations', () => {
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
const handoff = patterns.find((pattern) => pattern.mode === 'handoff');
const groupChat = patterns.find((pattern) => pattern.mode === 'group-chat');
expect(handoff).toBeDefined();
expect(groupChat).toBeDefined();
expect(
validatePatternDefinition({
...handoff!,
agents: handoff!.agents.slice(0, 1),
}).find((issue) => issue.field === 'agents')?.message,
).toBe('Handoff orchestration requires at least two agents.');
expect(
validatePatternDefinition({
...groupChat!,
agents: groupChat!.agents.slice(0, 1),
}).find((issue) => issue.field === 'agents')?.message,
).toBe('Group chat requires at least two agents.');
});
test('handoff builtin instructions clearly separate triage and specialist ownership', () => {
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find((pattern) => pattern.mode === 'handoff');
expect(handoff).toBeDefined();
expect(handoff?.agents[0].instructions).toContain('hand off before inspecting files');
expect(handoff?.agents[0].instructions).toContain('Do not claim that you delegated');
expect(handoff?.agents[1].instructions).toContain('own the substantive answer');
expect(handoff?.agents[2].instructions).toContain('own the substantive answer');
});
test('group chat builtin instructions frame iterative drafting and review', () => {
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'group-chat',
);
expect(groupChat).toBeDefined();
expect(groupChat?.agents[0].instructions).toContain('refine your earlier draft');
expect(groupChat?.agents[1].instructions).toContain('specific improvements');
expect(groupChat?.agents[1].instructions).toContain('instead of restarting the conversation');
});
test('approval policy rejects unknown agent references', () => {
const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'single',
);
expect(singlePattern).toBeDefined();
const issues = validatePatternDefinition({
...singlePattern!,
approvalPolicy: {
rules: [
{
kind: 'tool-call',
agentIds: ['agent-missing'],
},
],
},
});
expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe(
'Approval checkpoint "tool-call" references unknown agent "agent-missing".',
);
});
test('approval policy rejects unknown auto-approved tool references when tool names are provided', () => {
const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'single',
);
expect(singlePattern).toBeDefined();
const issues = validatePatternDefinition({
...singlePattern!,
approvalPolicy: {
rules: [{ kind: 'tool-call' }],
autoApprovedToolNames: ['web_fetch', 'unknown.tool'],
},
}, ['web_fetch']);
expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe(
'Approval auto-approve references unknown tool "unknown.tool".',
);
});
test('builtin patterns seed graph topology for each orchestration mode', () => {
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
const single = patterns.find((pattern) => pattern.mode === 'single');
const concurrent = patterns.find((pattern) => pattern.mode === 'concurrent');
const handoff = patterns.find((pattern) => pattern.mode === 'handoff');
const groupChat = patterns.find((pattern) => pattern.mode === 'group-chat');
expect(single).toBeDefined();
expect(concurrent).toBeDefined();
expect(handoff).toBeDefined();
expect(groupChat).toBeDefined();
expect(resolvePatternGraph(single!).nodes.map((node) => node.kind)).toEqual([
'user-input',
'agent',
'user-output',
]);
expect(resolvePatternGraph(concurrent!).nodes.map((node) => node.kind)).toEqual([
'user-input',
'distributor',
'agent',
'agent',
'agent',
'collector',
'user-output',
]);
expect(resolvePatternGraph(handoff!).edges).toContainEqual(
expect.objectContaining({
source: 'system-user-input',
target: 'agent-node-agent-handoff-triage',
}),
);
expect(resolvePatternGraph(handoff!).edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-triage',
target: 'agent-node-agent-handoff-ux',
}),
);
expect(resolvePatternGraph(groupChat!).nodes.map((node) => node.kind)).toContain('orchestrator');
});
test('syncPatternGraph rebuilds sequential topology from the current agent list', () => {
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'sequential',
);
expect(sequential).toBeDefined();
const updated = syncPatternGraph({
...sequential!,
agents: [
...sequential!.agents,
{
id: 'agent-sequential-final',
name: 'Final Reviewer',
description: 'Adds a final pass.',
instructions: 'Do a last review.',
model: 'gpt-5.4',
reasoningEffort: 'medium',
},
],
});
const graph = resolvePatternGraph(updated);
expect(graph.nodes.filter((node) => node.kind === 'agent')).toHaveLength(4);
expect(graph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-reviewer',
target: 'agent-node-agent-sequential-final',
}),
);
expect(graph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-final',
target: 'system-user-output',
}),
);
});
test('addAgentToGraph appends a new sequential agent before user output', () => {
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'sequential',
);
expect(sequential).toBeDefined();
const updatedGraph = addAgentToGraph(
resolvePatternGraph(sequential!),
sequential!.mode,
createAgent('agent-sequential-final', 'Final Reviewer'),
);
expect(updatedGraph.nodes.filter((node) => node.kind === 'agent')).toHaveLength(4);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-reviewer',
target: 'agent-node-agent-sequential-final',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-final',
target: 'system-user-output',
}),
);
expect(updatedGraph.edges).not.toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-reviewer',
target: 'system-user-output',
}),
);
});
test('addAgentToGraph wires concurrent agents between the distributor and collector', () => {
const concurrent = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'concurrent',
);
expect(concurrent).toBeDefined();
const updatedGraph = addAgentToGraph(
resolvePatternGraph(concurrent!),
concurrent!.mode,
createAgent('agent-concurrent-final', 'Final Implementer'),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'system-distributor',
target: 'agent-node-agent-concurrent-final',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-concurrent-final',
target: 'system-collector',
}),
);
});
test('addAgentToGraph wires handoff specialists to the entry agent and output', () => {
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'handoff',
);
expect(handoff).toBeDefined();
const updatedGraph = addAgentToGraph(
resolvePatternGraph(handoff!),
handoff!.mode,
createAgent('agent-handoff-docs', 'Docs Specialist'),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-triage',
target: 'agent-node-agent-handoff-docs',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-docs',
target: 'agent-node-agent-handoff-triage',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-docs',
target: 'system-user-output',
}),
);
});
test('addAgentToGraph wires group-chat agents to the orchestrator', () => {
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'group-chat',
);
expect(groupChat).toBeDefined();
const updatedGraph = addAgentToGraph(
resolvePatternGraph(groupChat!),
groupChat!.mode,
createAgent('agent-group-editor', 'Editor'),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'system-orchestrator',
target: 'agent-node-agent-group-editor',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-group-editor',
target: 'system-orchestrator',
}),
);
});
test('addAgentToGraph rejects additions in single-agent mode', () => {
const single = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'single',
);
expect(single).toBeDefined();
expect(() =>
addAgentToGraph(resolvePatternGraph(single!), single!.mode, createAgent('agent-extra', 'Extra Agent')))
.toThrow('Single-agent chat requires exactly one agent.');
});
test('removeAgentFromGraph stitches linear gaps and re-numbers remaining agent orders', () => {
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'sequential',
);
expect(sequential).toBeDefined();
const updatedGraph = removeAgentFromGraph(
resolvePatternGraph(sequential!),
sequential!.mode,
'agent-sequential-builder',
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-analyst',
target: 'agent-node-agent-sequential-reviewer',
}),
);
expect(updatedGraph.edges).not.toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-analyst',
target: 'agent-node-agent-sequential-builder',
}),
);
expect(updatedGraph.edges).not.toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-sequential-builder',
target: 'agent-node-agent-sequential-reviewer',
}),
);
expect(
updatedGraph.nodes
.filter((node) => node.kind === 'agent')
.map((node) => node.order),
).toEqual([0, 1]);
});
test('removeAgentFromGraph cleans up concurrent fan-out and fan-in edges', () => {
const concurrent = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'concurrent',
);
expect(concurrent).toBeDefined();
const updatedGraph = removeAgentFromGraph(
resolvePatternGraph(concurrent!),
concurrent!.mode,
'agent-concurrent-product',
);
expect(updatedGraph.nodes.some((node) => node.agentId === 'agent-concurrent-product')).toBe(false);
expect(updatedGraph.edges.some((edge) => edge.target === 'agent-node-agent-concurrent-product')).toBe(false);
expect(updatedGraph.edges.some((edge) => edge.source === 'agent-node-agent-concurrent-product')).toBe(false);
});
test('removeAgentFromGraph rewires a removed handoff entry agent to the next specialist', () => {
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'handoff',
);
expect(handoff).toBeDefined();
const updatedGraph = removeAgentFromGraph(
resolvePatternGraph(handoff!),
handoff!.mode,
'agent-handoff-triage',
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'system-user-input',
target: 'agent-node-agent-handoff-ux',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-ux',
target: 'system-user-output',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-ux',
target: 'agent-node-agent-handoff-runtime',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-handoff-runtime',
target: 'agent-node-agent-handoff-ux',
}),
);
});
test('removeAgentFromGraph preserves orchestrator routes for the remaining group-chat agents', () => {
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'group-chat',
);
expect(groupChat).toBeDefined();
const updatedGraph = removeAgentFromGraph(
resolvePatternGraph(groupChat!),
groupChat!.mode,
'agent-group-reviewer',
);
expect(updatedGraph.nodes.some((node) => node.agentId === 'agent-group-reviewer')).toBe(false);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'system-orchestrator',
target: 'agent-node-agent-group-writer',
}),
);
expect(updatedGraph.edges).toContainEqual(
expect.objectContaining({
source: 'agent-node-agent-group-writer',
target: 'system-orchestrator',
}),
);
});
test('graph validation rejects branched sequential topology', () => {
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
(pattern) => pattern.mode === 'sequential',
);
expect(sequential).toBeDefined();
const issues = validatePatternDefinition({
...sequential!,
graph: {
...resolvePatternGraph(sequential!),
edges: [
...resolvePatternGraph(sequential!).edges,
{
id: 'edge-system-user-input-to-agent-node-agent-sequential-builder-duplicate',
source: 'system-user-input',
target: 'agent-node-agent-sequential-builder',
},
],
},
});
expect(issues.find((issue) => issue.field === 'graph')?.message).toContain('single path');
});
});