mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 07:28:42 +02:00
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:
+31
-92
@@ -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)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user