mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 21:28:44 +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 { CommandPalette } from '@renderer/components/CommandPalette';
|
||||||
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
||||||
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
||||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
|
||||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||||
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||||
@@ -39,47 +38,20 @@ import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
|
|||||||
import {
|
import {
|
||||||
buildAvailableModelCatalog,
|
buildAvailableModelCatalog,
|
||||||
findModel,
|
findModel,
|
||||||
normalizePatternModels,
|
normalizeWorkflowModels,
|
||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
|
|
||||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
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 { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||||
import { applySessionModelConfig } from '@shared/domain/session';
|
import { applySessionModelConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
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 { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
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 {
|
function createDraftMcpServer(): McpServerDefinition {
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
return {
|
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 timestamp = nowIso();
|
||||||
const startId = createId('wf-start');
|
const startId = createId('wf-start');
|
||||||
const agentId = createId('wf-agent');
|
const agentId = createId('wf-agent');
|
||||||
@@ -181,7 +153,6 @@ export default function App() {
|
|||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
|
||||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
@@ -286,23 +257,23 @@ export default function App() {
|
|||||||
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
|
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
|
||||||
[sidecarCapabilities?.models],
|
[sidecarCapabilities?.models],
|
||||||
);
|
);
|
||||||
const patternForSession = useMemo(() => {
|
const workflowForSession = useMemo(() => {
|
||||||
if (!selectedSession) {
|
if (!selectedSession) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePattern = workspace?.patterns.find((pattern) => pattern.id === selectedSession.patternId);
|
const baseWorkflow = workspace?.workflows.find((workflow) => workflow.id === selectedSession.workflowId);
|
||||||
if (!basePattern) {
|
if (!baseWorkflow) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const patternWithSessionConfig =
|
const workflowWithSessionConfig =
|
||||||
projectForSession && selectedSession.sessionModelConfig
|
projectForSession && selectedSession.sessionModelConfig
|
||||||
? applySessionModelConfig(basePattern, selectedSession)
|
? applySessionModelConfig(baseWorkflow, selectedSession)
|
||||||
: basePattern;
|
: baseWorkflow;
|
||||||
|
|
||||||
return normalizePatternModels(patternWithSessionConfig, availableModels);
|
return normalizeWorkflowModels(workflowWithSessionConfig, availableModels);
|
||||||
}, [availableModels, projectForSession, selectedSession, workspace?.patterns]);
|
}, [availableModels, projectForSession, selectedSession, workspace?.workflows]);
|
||||||
const activityForSession = useMemo(
|
const activityForSession = useMemo(
|
||||||
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
|
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
|
||||||
[selectedSession, sessionActivities],
|
[selectedSession, sessionActivities],
|
||||||
@@ -361,8 +332,6 @@ export default function App() {
|
|||||||
commandPaletteOpenRef.current = commandPaletteOpen;
|
commandPaletteOpenRef.current = commandPaletteOpen;
|
||||||
const projectSettingsIdRef = useRef(projectSettingsId);
|
const projectSettingsIdRef = useRef(projectSettingsId);
|
||||||
projectSettingsIdRef.current = projectSettingsId;
|
projectSettingsIdRef.current = projectSettingsId;
|
||||||
const newSessionProjectIdRef = useRef(newSessionProjectId);
|
|
||||||
newSessionProjectIdRef.current = newSessionProjectId;
|
|
||||||
|
|
||||||
// ── Global keyboard shortcuts ──
|
// ── Global keyboard shortcuts ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -431,11 +400,6 @@ export default function App() {
|
|||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newSessionProjectIdRef.current) {
|
|
||||||
e.preventDefault();
|
|
||||||
setNewSessionProjectId(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If nothing is open, cancel a running turn on the selected session
|
// If nothing is open, cancel a running turn on the selected session
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -460,7 +424,10 @@ export default function App() {
|
|||||||
ws.selectedProjectId ??
|
ws.selectedProjectId ??
|
||||||
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
ws.projects.find((p) => !isScratchpadProject(p))?.id;
|
||||||
if (defaultProjectId) {
|
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;
|
return;
|
||||||
@@ -584,17 +551,17 @@ export default function App() {
|
|||||||
|
|
||||||
const handleCreateScratchpad = useCallback(() => {
|
const handleCreateScratchpad = useCallback(() => {
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
const singlePatterns = workspace.patterns
|
const singleWorkflows = workspace.workflows
|
||||||
.filter((p) => p.mode === 'single' && p.availability !== 'unavailable')
|
.filter((w) => (w.settings.orchestrationMode ?? 'single') === 'single')
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.isFavorite && !b.isFavorite) return -1;
|
if (a.isFavorite && !b.isFavorite) return -1;
|
||||||
if (!a.isFavorite && b.isFavorite) return 1;
|
if (!a.isFavorite && b.isFavorite) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultPattern = singlePatterns[0];
|
const defaultWorkflow = singleWorkflows[0];
|
||||||
if (defaultPattern) {
|
if (defaultWorkflow) {
|
||||||
void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, patternId: defaultPattern.id });
|
void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, workflowId: defaultWorkflow.id });
|
||||||
}
|
}
|
||||||
}, [api, workspace]);
|
}, [api, workspace]);
|
||||||
|
|
||||||
@@ -645,7 +612,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (selectedSession && patternForSession && projectForSession) {
|
} else if (selectedSession && workflowForSession && projectForSession) {
|
||||||
content = (
|
content = (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
|
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
|
||||||
@@ -715,7 +682,7 @@ export default function App() {
|
|||||||
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
||||||
onTerminalToggle={handleTerminalToggle}
|
onTerminalToggle={handleTerminalToggle}
|
||||||
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
||||||
pattern={patternForSession}
|
workflow={workflowForSession}
|
||||||
project={projectForSession}
|
project={projectForSession}
|
||||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
@@ -734,7 +701,7 @@ export default function App() {
|
|||||||
onDiscard={handleDiscardRunChanges}
|
onDiscard={handleDiscardRunChanges}
|
||||||
onJumpToMessage={jumpToMessage}
|
onJumpToMessage={jumpToMessage}
|
||||||
onOpenCommitComposer={handleOpenCommitComposer}
|
onOpenCommitComposer={handleOpenCommitComposer}
|
||||||
pattern={patternForSession}
|
workflow={workflowForSession}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
sessionRequestUsage={requestUsageForSession}
|
sessionRequestUsage={requestUsageForSession}
|
||||||
turnEvents={turnEventsForSession}
|
turnEvents={turnEventsForSession}
|
||||||
@@ -765,22 +732,11 @@ export default function App() {
|
|||||||
onDeleteMcpServer={async (id) => {
|
onDeleteMcpServer={async (id) => {
|
||||||
await api.deleteMcpServer(id);
|
await api.deleteMcpServer(id);
|
||||||
}}
|
}}
|
||||||
onDeletePattern={async (id) => {
|
|
||||||
await api.deletePattern(id);
|
|
||||||
}}
|
|
||||||
onDeleteWorkflow={async (id) => {
|
onDeleteWorkflow={async (id) => {
|
||||||
await api.deleteWorkflow(id);
|
await api.deleteWorkflow(id);
|
||||||
}}
|
}}
|
||||||
onNewLspProfile={createDraftLspProfile}
|
onNewLspProfile={createDraftLspProfile}
|
||||||
onNewMcpServer={createDraftMcpServer}
|
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={() => {
|
onNewWorkflow={() => {
|
||||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
||||||
return createDraftWorkflow(
|
return createDraftWorkflow(
|
||||||
@@ -795,9 +751,6 @@ export default function App() {
|
|||||||
onSaveMcpServer={async (server) => {
|
onSaveMcpServer={async (server) => {
|
||||||
await api.saveMcpServer({ server });
|
await api.saveMcpServer({ server });
|
||||||
}}
|
}}
|
||||||
onSavePattern={async (pattern) => {
|
|
||||||
await api.savePattern({ pattern });
|
|
||||||
}}
|
|
||||||
onSaveWorkflow={async (workflow) => {
|
onSaveWorkflow={async (workflow) => {
|
||||||
await api.saveWorkflow({ workflow });
|
await api.saveWorkflow({ workflow });
|
||||||
}}
|
}}
|
||||||
@@ -826,15 +779,11 @@ export default function App() {
|
|||||||
setSessionActivities({});
|
setSessionActivities({});
|
||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
}}
|
}}
|
||||||
patterns={workspace.patterns}
|
|
||||||
workflows={workspace.workflows}
|
workflows={workspace.workflows}
|
||||||
workflowTemplates={workspace.workflowTemplates}
|
workflowTemplates={workspace.workflowTemplates}
|
||||||
onCreateWorkflowFromTemplate={async (templateId, name) => {
|
onCreateWorkflowFromTemplate={async (templateId, name) => {
|
||||||
await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined });
|
await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined });
|
||||||
}}
|
}}
|
||||||
onUpgradePatternToWorkflow={async (patternId) => {
|
|
||||||
await api.upgradePatternToWorkflow({ patternId, options: { save: true } });
|
|
||||||
}}
|
|
||||||
sidecarCapabilities={sidecarCapabilities}
|
sidecarCapabilities={sidecarCapabilities}
|
||||||
theme={workspace.settings.theme}
|
theme={workspace.settings.theme}
|
||||||
toolingSettings={workspace.settings.tooling}
|
toolingSettings={workspace.settings.tooling}
|
||||||
@@ -884,7 +833,10 @@ export default function App() {
|
|||||||
onAddProject={() => void api.addProject()}
|
onAddProject={() => void api.addProject()}
|
||||||
onCreateScratchpad={() => handleCreateScratchpad()}
|
onCreateScratchpad={() => handleCreateScratchpad()}
|
||||||
onNewProjectSession={(projectId) => {
|
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)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
|
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 && (
|
{showDiscoveryModal && (
|
||||||
<DiscoveredToolingModal
|
<DiscoveredToolingModal
|
||||||
onClose={() => setShowDiscoveryModal(false)}
|
onClose={() => setShowDiscoveryModal(false)}
|
||||||
@@ -987,7 +923,10 @@ export default function App() {
|
|||||||
void api.selectProject(projectId);
|
void api.selectProject(projectId);
|
||||||
}}
|
}}
|
||||||
onNewSession={(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}
|
onCreateScratchpad={handleCreateScratchpad}
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
|
|||||||
@@ -17,20 +17,19 @@ import {
|
|||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||||
import { inferProvider } from '@shared/domain/models';
|
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 { ProjectGitFileReference } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { ProviderIcon } from './ProviderIcons';
|
import { ProviderIcon } from './ProviderIcons';
|
||||||
|
|
||||||
/* ── Mode accent colours ───────────────────────────────────── */
|
/* ── 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]' },
|
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)]' },
|
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)]' },
|
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)]' },
|
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)]' },
|
'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 ───────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────── */
|
||||||
@@ -50,13 +49,12 @@ function formatEffort(effort: string | undefined): string | undefined {
|
|||||||
return labels[effort] ?? effort;
|
return labels[effort] ?? effort;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeLabels: Record<OrchestrationMode, string> = {
|
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
|
||||||
single: 'Single agent',
|
single: 'Single agent',
|
||||||
sequential: 'Sequential',
|
sequential: 'Sequential',
|
||||||
concurrent: 'Concurrent',
|
concurrent: 'Concurrent',
|
||||||
handoff: 'Handoff',
|
handoff: 'Handoff',
|
||||||
'group-chat': 'Group chat',
|
'group-chat': 'Group chat',
|
||||||
magentic: 'Magentic',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Section header ────────────────────────────────────────── */
|
/* ── Section header ────────────────────────────────────────── */
|
||||||
@@ -79,8 +77,8 @@ function AgentRow({
|
|||||||
agentUsage,
|
agentUsage,
|
||||||
}: {
|
}: {
|
||||||
row: AgentActivityRow;
|
row: AgentActivityRow;
|
||||||
agent?: PatternAgentDefinition;
|
agent?: AgentNodeConfig;
|
||||||
accent: (typeof modeAccent)[OrchestrationMode];
|
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
agentUsage?: AgentUsageAccumulator;
|
agentUsage?: AgentUsageAccumulator;
|
||||||
}) {
|
}) {
|
||||||
@@ -213,7 +211,7 @@ interface ActivityPanelProps {
|
|||||||
onJumpToMessage?: (messageId: string) => void;
|
onJumpToMessage?: (messageId: string) => void;
|
||||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||||
onOpenCommitComposer?: () => void;
|
onOpenCommitComposer?: () => void;
|
||||||
pattern: PatternDefinition;
|
workflow: WorkflowDefinition;
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
sessionRequestUsage?: SessionRequestUsageState;
|
sessionRequestUsage?: SessionRequestUsageState;
|
||||||
turnEvents?: TurnEventLog;
|
turnEvents?: TurnEventLog;
|
||||||
@@ -224,21 +222,29 @@ export function ActivityPanel({
|
|||||||
onJumpToMessage,
|
onJumpToMessage,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onOpenCommitComposer,
|
onOpenCommitComposer,
|
||||||
pattern,
|
workflow,
|
||||||
session,
|
session,
|
||||||
sessionRequestUsage,
|
sessionRequestUsage,
|
||||||
turnEvents,
|
turnEvents,
|
||||||
}: ActivityPanelProps) {
|
}: 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(
|
const activityRows = useMemo(
|
||||||
() => buildAgentActivityRows(activity, pattern.agents),
|
() => buildAgentActivityRows(activity, workflowAgents),
|
||||||
[activity, pattern.agents],
|
[activity, workflowAgents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isBusy = session.status === 'running';
|
const isBusy = session.status === 'running';
|
||||||
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
||||||
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
|
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
|
||||||
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
|
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
|
||||||
const accent = modeAccent[pattern.mode] ?? modeAccent.single;
|
const accent = modeAccent[workflowMode] ?? modeAccent.single;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -272,7 +278,7 @@ export function ActivityPanel({
|
|||||||
{activityRows.length}
|
{activityRows.length}
|
||||||
</span>
|
</span>
|
||||||
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
|
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
|
||||||
{modeLabels[pattern.mode]}
|
{modeLabels[workflowMode]}
|
||||||
</span>
|
</span>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
@@ -285,7 +291,7 @@ export function ActivityPanel({
|
|||||||
return (
|
return (
|
||||||
<AgentRow
|
<AgentRow
|
||||||
accent={accent}
|
accent={accent}
|
||||||
agent={pattern.agents[index]}
|
agent={workflowAgents[index]}
|
||||||
agentUsage={agentUsage}
|
agentUsage={agentUsage}
|
||||||
isLast={index === activityRows.length - 1}
|
isLast={index === activityRows.length - 1}
|
||||||
key={row.key}
|
key={row.key}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
providerMeta,
|
providerMeta,
|
||||||
type ModelDefinition,
|
type ModelDefinition,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
|
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
|
||||||
|
|
||||||
import { ProviderIcon } from './ProviderIcons';
|
import { ProviderIcon } from './ProviderIcons';
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
type ModelDefinition,
|
type ModelDefinition,
|
||||||
} from '@shared/domain/models';
|
} 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 { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||||
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||||
@@ -48,7 +48,7 @@ type DisplayItem =
|
|||||||
|
|
||||||
interface ChatPaneProps {
|
interface ChatPaneProps {
|
||||||
project: ProjectRecord;
|
project: ProjectRecord;
|
||||||
pattern: PatternDefinition;
|
workflow: WorkflowDefinition;
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
toolingSettings: WorkspaceToolingSettings;
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
@@ -85,7 +85,7 @@ interface ChatPaneProps {
|
|||||||
|
|
||||||
export function ChatPane({
|
export function ChatPane({
|
||||||
project,
|
project,
|
||||||
pattern,
|
workflow,
|
||||||
session,
|
session,
|
||||||
availableModels,
|
availableModels,
|
||||||
toolingSettings,
|
toolingSettings,
|
||||||
@@ -184,8 +184,15 @@ export function ChatPane({
|
|||||||
const interactionMode: InteractionMode = session.interactionMode ?? 'interactive';
|
const interactionMode: InteractionMode = session.interactionMode ?? 'interactive';
|
||||||
const isPlanMode = interactionMode === 'plan';
|
const isPlanMode = interactionMode === 'plan';
|
||||||
const isScratchpad = isScratchpadProject(project);
|
const isScratchpad = isScratchpadProject(project);
|
||||||
const isSingleAgent = pattern.agents.length === 1;
|
const workflowAgents = useMemo(
|
||||||
const primaryAgent = pattern.agents[0];
|
() => 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 selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
|
||||||
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
|
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
|
||||||
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
|
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
|
||||||
@@ -223,7 +230,7 @@ export function ChatPane({
|
|||||||
const mcpServers = toolingSettings.mcpServers;
|
const mcpServers = toolingSettings.mcpServers;
|
||||||
const lspProfiles = toolingSettings.lspProfiles;
|
const lspProfiles = toolingSettings.lspProfiles;
|
||||||
const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0;
|
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(
|
const approvalTools = useMemo(
|
||||||
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
|
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
|
||||||
[runtimeTools, toolingSettings],
|
[runtimeTools, toolingSettings],
|
||||||
@@ -233,9 +240,9 @@ export function ChatPane({
|
|||||||
() => new Set(
|
() => new Set(
|
||||||
isApprovalOverridden
|
isApprovalOverridden
|
||||||
? session.approvalSettings!.autoApprovedToolNames
|
? 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 effectiveAutoApprovedCount = useMemo(() => {
|
||||||
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
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>
|
<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)]">
|
<p className="truncate text-[11px] leading-tight text-[var(--color-text-muted)]">
|
||||||
{isScratchpad
|
{isScratchpad
|
||||||
? `Scratchpad · ${pattern.name}`
|
? `Scratchpad · ${workflow.name}`
|
||||||
: `${project.name} · ${pattern.name} · ${pattern.mode}`}
|
: `${project.name} · ${workflow.name} · ${workflowMode}`}
|
||||||
{!isScratchpad && project.git?.status === 'ready' && (() => {
|
{!isScratchpad && project.git?.status === 'ready' && (() => {
|
||||||
const git = project.git;
|
const git = project.git;
|
||||||
const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD'];
|
const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD'];
|
||||||
@@ -442,11 +449,11 @@ export function ChatPane({
|
|||||||
{isScratchpad ? (
|
{isScratchpad ? (
|
||||||
<>
|
<>
|
||||||
Scratchpad is ready for ad-hoc questions using{' '}
|
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>
|
<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,
|
truncateContent,
|
||||||
type CollapsedTimelineEvent,
|
type CollapsedTimelineEvent,
|
||||||
} from '@renderer/lib/runTimelineFormatting';
|
} 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 { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||||
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
||||||
@@ -33,13 +33,12 @@ import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummary
|
|||||||
|
|
||||||
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
|
/* ── 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]' },
|
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)]' },
|
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)]' },
|
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)]' },
|
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)]' },
|
'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 ─────────────────────────────────────────── */
|
/* ── Status badges ─────────────────────────────────────────── */
|
||||||
@@ -284,7 +283,7 @@ function RunCard({
|
|||||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||||
onOpenCommitComposer?: () => void;
|
onOpenCommitComposer?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
|
const accent = modeAccent[run.workflowMode] ?? modeAccent.single;
|
||||||
const statusStyle = runStatusStyles[run.status];
|
const statusStyle = runStatusStyles[run.status];
|
||||||
const duration = formatRunDuration(run.startedAt, run.completedAt);
|
const duration = formatRunDuration(run.startedAt, run.completedAt);
|
||||||
|
|
||||||
@@ -308,7 +307,7 @@ function RunCard({
|
|||||||
<Bot className={`size-3 shrink-0 ${accent.text}`} />
|
<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)]">
|
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||||
{run.patternName}
|
{run.workflowName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
|
||||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||||
import { ToggleSwitch } from '@renderer/components/ui';
|
import { ToggleSwitch } from '@renderer/components/ui';
|
||||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
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 type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
import type { ModelDefinition } from '@shared/domain/models';
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||||
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate';
|
import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate';
|
||||||
@@ -29,7 +27,6 @@ import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type Works
|
|||||||
|
|
||||||
interface SettingsPanelProps {
|
interface SettingsPanelProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
patterns: PatternDefinition[];
|
|
||||||
workflows: WorkflowDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
sidecarCapabilities?: SidecarCapabilities;
|
sidecarCapabilities?: SidecarCapabilities;
|
||||||
theme: AppearanceTheme;
|
theme: AppearanceTheme;
|
||||||
@@ -39,9 +36,6 @@ interface SettingsPanelProps {
|
|||||||
initialSection?: SettingsSection;
|
initialSection?: SettingsSection;
|
||||||
onRefreshCapabilities: () => void;
|
onRefreshCapabilities: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
|
||||||
onDeletePattern: (patternId: string) => Promise<void>;
|
|
||||||
onNewPattern: () => PatternDefinition;
|
|
||||||
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
|
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
|
||||||
onDeleteWorkflow: (workflowId: string) => Promise<void>;
|
onDeleteWorkflow: (workflowId: string) => Promise<void>;
|
||||||
onNewWorkflow: () => WorkflowDefinition;
|
onNewWorkflow: () => WorkflowDefinition;
|
||||||
@@ -68,10 +62,9 @@ interface SettingsPanelProps {
|
|||||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||||
workflowTemplates?: WorkflowTemplateDefinition[];
|
workflowTemplates?: WorkflowTemplateDefinition[];
|
||||||
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
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 {
|
interface NavItem {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
@@ -100,7 +93,6 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
label: 'Workflows',
|
label: 'Workflows',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
|
||||||
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
|
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
|
||||||
{ id: 'agents', label: 'Agents', icon: <UserCircle 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({
|
export function SettingsPanel({
|
||||||
availableModels,
|
availableModels,
|
||||||
patterns,
|
|
||||||
workflows,
|
workflows,
|
||||||
sidecarCapabilities,
|
sidecarCapabilities,
|
||||||
theme,
|
theme,
|
||||||
@@ -137,9 +123,6 @@ export function SettingsPanel({
|
|||||||
initialSection,
|
initialSection,
|
||||||
onRefreshCapabilities,
|
onRefreshCapabilities,
|
||||||
onClose,
|
onClose,
|
||||||
onSavePattern,
|
|
||||||
onDeletePattern,
|
|
||||||
onNewPattern,
|
|
||||||
onSaveWorkflow,
|
onSaveWorkflow,
|
||||||
onDeleteWorkflow,
|
onDeleteWorkflow,
|
||||||
onNewWorkflow,
|
onNewWorkflow,
|
||||||
@@ -166,44 +149,13 @@ export function SettingsPanel({
|
|||||||
onGetQuota,
|
onGetQuota,
|
||||||
workflowTemplates,
|
workflowTemplates,
|
||||||
onCreateWorkflowFromTemplate,
|
onCreateWorkflowFromTemplate,
|
||||||
onUpgradePatternToWorkflow,
|
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
|
||||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
||||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||||
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | 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) {
|
if (editingWorkflow) {
|
||||||
const api = getElectronApi();
|
const api = getElectronApi();
|
||||||
return (
|
return (
|
||||||
@@ -299,9 +251,9 @@ export function SettingsPanel({
|
|||||||
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
|
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
<WorkspaceAgentEditor
|
<WorkspaceAgentEditor
|
||||||
agent={editingWorkspaceAgent}
|
agent={editingWorkspaceAgent}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
onBack={() => setEditingWorkspaceAgent(null)}
|
onBack={() => setEditingWorkspaceAgent(null)}
|
||||||
onChange={setEditingWorkspaceAgent}
|
onChange={setEditingWorkspaceAgent}
|
||||||
onDelete={
|
onDelete={
|
||||||
@@ -312,14 +264,14 @@ export function SettingsPanel({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
|
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
|
||||||
setEditingWorkspaceAgent(null);
|
setEditingWorkspaceAgent(null);
|
||||||
}}
|
}}
|
||||||
patterns={patterns}
|
workflows={workflows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -391,14 +343,6 @@ export function SettingsPanel({
|
|||||||
onGetQuota={onGetQuota}
|
onGetQuota={onGetQuota}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeSection === 'patterns' && (
|
|
||||||
<PatternsSection
|
|
||||||
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
|
|
||||||
onNewPattern={() => setEditingPattern(onNewPattern())}
|
|
||||||
onUpgradePatternToWorkflow={onUpgradePatternToWorkflow}
|
|
||||||
patterns={patterns}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeSection === 'workflows' && (
|
{activeSection === 'workflows' && (
|
||||||
<WorkflowsSection
|
<WorkflowsSection
|
||||||
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
||||||
@@ -411,7 +355,7 @@ export function SettingsPanel({
|
|||||||
{activeSection === 'agents' && (
|
{activeSection === 'agents' && (
|
||||||
<WorkspaceAgentsSection
|
<WorkspaceAgentsSection
|
||||||
agents={workspaceAgents}
|
agents={workspaceAgents}
|
||||||
patterns={patterns}
|
workflows={workflows}
|
||||||
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
|
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
|
||||||
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
|
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> = {
|
const categoryLabels: Record<WorkflowTemplateCategory, string> = {
|
||||||
'orchestration': 'Orchestration',
|
'orchestration': 'Orchestration',
|
||||||
'data-pipeline': 'Data Pipeline',
|
'data-pipeline': 'Data Pipeline',
|
||||||
@@ -850,19 +723,19 @@ function WorkflowsSection({
|
|||||||
|
|
||||||
function WorkspaceAgentsSection({
|
function WorkspaceAgentsSection({
|
||||||
agents,
|
agents,
|
||||||
patterns,
|
workflows,
|
||||||
onEditAgent,
|
onEditAgent,
|
||||||
onNewAgent,
|
onNewAgent,
|
||||||
}: {
|
}: {
|
||||||
agents: WorkspaceAgentDefinition[];
|
agents: WorkspaceAgentDefinition[];
|
||||||
patterns: PatternDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
|
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
|
||||||
onNewAgent: () => void;
|
onNewAgent: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<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"
|
title="Workspace Agents"
|
||||||
>
|
>
|
||||||
<SectionAction label="New Agent" onClick={onNewAgent} />
|
<SectionAction label="New Agent" onClick={onNewAgent} />
|
||||||
@@ -875,14 +748,14 @@ function WorkspaceAgentsSection({
|
|||||||
No workspace agents yet
|
No workspace agents yet
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
|
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
|
||||||
Create agents here and reference them in multiple patterns.
|
Create agents here and reference them in multiple workflows.
|
||||||
Changes to a workspace agent automatically propagate to all linked patterns.
|
Changes to a workspace agent automatically propagate to all linked workflows.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const usageCount = findWorkspaceAgentUsages(agent.id, patterns).length;
|
const usageCount = findWorkspaceAgentUsages(agent.id, workflows).length;
|
||||||
return (
|
return (
|
||||||
<button
|
<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)]"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
{usageCount > 0 && (
|
{usageCount > 0 && (
|
||||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
{usageCount} pattern{usageCount === 1 ? '' : 's'}
|
{usageCount} workflow{usageCount === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
|
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
|
||||||
@@ -1355,7 +1228,7 @@ function TroubleshootingSection({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
|
<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)]">
|
<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
|
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
|
||||||
is not affected.
|
is not affected.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
GitFork,
|
GitFork,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Lock,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -28,7 +27,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} 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 { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
|
||||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
@@ -59,13 +58,12 @@ interface SidebarProps {
|
|||||||
|
|
||||||
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
/* ── 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]' },
|
single: { icon: MessageSquare, color: 'text-[#245CF9]' },
|
||||||
sequential: { icon: ListOrdered, color: 'text-[var(--color-status-warning)]' },
|
sequential: { icon: ListOrdered, color: 'text-[var(--color-status-warning)]' },
|
||||||
concurrent: { icon: GitFork, color: 'text-[var(--color-status-success)]' },
|
concurrent: { icon: GitFork, color: 'text-[var(--color-status-success)]' },
|
||||||
handoff: { icon: ArrowLeftRight, color: 'text-[var(--color-accent-sky)]' },
|
handoff: { icon: ArrowLeftRight, color: 'text-[var(--color-accent-sky)]' },
|
||||||
'group-chat': { icon: Users, color: 'text-[var(--color-accent-purple)]' },
|
'group-chat': { icon: Users, color: 'text-[var(--color-accent-purple)]' },
|
||||||
magentic: { icon: Lock, color: 'text-[var(--color-text-muted)]' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Relative time helper ──────────────────────────────────── */
|
/* ── Relative time helper ──────────────────────────────────── */
|
||||||
@@ -178,7 +176,7 @@ function ActionMenuItem({
|
|||||||
|
|
||||||
function SessionItem({
|
function SessionItem({
|
||||||
session,
|
session,
|
||||||
pattern,
|
workflow,
|
||||||
isActive,
|
isActive,
|
||||||
isRenaming,
|
isRenaming,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -187,7 +185,7 @@ function SessionItem({
|
|||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
}: {
|
}: {
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
pattern?: PatternDefinition;
|
workflow?: WorkflowDefinition;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isRenaming: boolean;
|
isRenaming: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
@@ -199,10 +197,10 @@ function SessionItem({
|
|||||||
const isError = session.status === 'error';
|
const isError = session.status === 'error';
|
||||||
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
||||||
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
|
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 visual = modeVisuals[mode];
|
||||||
const ModeIcon = visual.icon;
|
const ModeIcon = visual.icon;
|
||||||
const agentCount = pattern?.agents.length ?? 1;
|
const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1;
|
||||||
|
|
||||||
const [renameText, setRenameText] = useState(session.title);
|
const [renameText, setRenameText] = useState(session.title);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -363,7 +361,7 @@ function SessionItem({
|
|||||||
function ProjectGroup({
|
function ProjectGroup({
|
||||||
project,
|
project,
|
||||||
sessions,
|
sessions,
|
||||||
patterns,
|
workflows,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
renamingSessionId,
|
renamingSessionId,
|
||||||
onSessionSelect,
|
onSessionSelect,
|
||||||
@@ -377,7 +375,7 @@ function ProjectGroup({
|
|||||||
}: {
|
}: {
|
||||||
project: ProjectRecord;
|
project: ProjectRecord;
|
||||||
sessions: SessionRecord[];
|
sessions: SessionRecord[];
|
||||||
patterns: PatternDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
selectedSessionId?: string;
|
selectedSessionId?: string;
|
||||||
renamingSessionId?: string;
|
renamingSessionId?: string;
|
||||||
onSessionSelect: (sessionId: string) => void;
|
onSessionSelect: (sessionId: string) => void;
|
||||||
@@ -392,11 +390,11 @@ function ProjectGroup({
|
|||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const isScratchpad = isScratchpadProject(project);
|
const isScratchpad = isScratchpadProject(project);
|
||||||
|
|
||||||
const patternMap = useMemo(() => {
|
const workflowMap = useMemo(() => {
|
||||||
const map = new Map<string, PatternDefinition>();
|
const map = new Map<string, WorkflowDefinition>();
|
||||||
for (const p of patterns) map.set(p.id, p);
|
for (const w of workflows) map.set(w.id, w);
|
||||||
return map;
|
return map;
|
||||||
}, [patterns]);
|
}, [workflows]);
|
||||||
|
|
||||||
const visibleSessions = useMemo(() =>
|
const visibleSessions = useMemo(() =>
|
||||||
sessions
|
sessions
|
||||||
@@ -519,7 +517,7 @@ function ProjectGroup({
|
|||||||
onOpenMenu={(e) => onOpenMenu(session.id, e)}
|
onOpenMenu={(e) => onOpenMenu(session.id, e)}
|
||||||
onRenameSubmit={(title) => onRenameSubmit(session.id, title)}
|
onRenameSubmit={(title) => onRenameSubmit(session.id, title)}
|
||||||
onRenameCancel={onRenameCancel}
|
onRenameCancel={onRenameCancel}
|
||||||
pattern={patternMap.get(session.patternId)}
|
workflow={workflowMap.get(session.workflowId)}
|
||||||
session={session}
|
session={session}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -575,11 +573,13 @@ export function Sidebar({
|
|||||||
|
|
||||||
const isQueryActive = searchText.trim().length > 0;
|
const isQueryActive = searchText.trim().length > 0;
|
||||||
|
|
||||||
const patternMap = useMemo(() => {
|
const workflowMap = useMemo(() => {
|
||||||
const map = new Map<string, PatternDefinition>();
|
const map = new Map<string, WorkflowDefinition>();
|
||||||
for (const p of workspace.patterns) map.set(p.id, p);
|
for (const w of workspace.workflows) {
|
||||||
|
map.set(w.id, w);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [workspace.patterns]);
|
}, [workspace.workflows]);
|
||||||
|
|
||||||
const queryResults = useMemo(() => {
|
const queryResults = useMemo(() => {
|
||||||
if (!isQueryActive) return [];
|
if (!isQueryActive) return [];
|
||||||
@@ -694,7 +694,7 @@ export function Sidebar({
|
|||||||
onOpenMenu={(e) => handleOpenMenu(session.id, e)}
|
onOpenMenu={(e) => handleOpenMenu(session.id, e)}
|
||||||
onRenameSubmit={(title) => handleRenameSubmit(session.id, title)}
|
onRenameSubmit={(title) => handleRenameSubmit(session.id, title)}
|
||||||
onRenameCancel={() => setRenamingSessionId(undefined)}
|
onRenameCancel={() => setRenamingSessionId(undefined)}
|
||||||
pattern={patternMap.get(session.patternId)}
|
workflow={workflowMap.get(session.workflowId)}
|
||||||
session={session}
|
session={session}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -715,7 +715,7 @@ export function Sidebar({
|
|||||||
onRenameSubmit={handleRenameSubmit}
|
onRenameSubmit={handleRenameSubmit}
|
||||||
onRenameCancel={() => setRenamingSessionId(undefined)}
|
onRenameCancel={() => setRenamingSessionId(undefined)}
|
||||||
renamingSessionId={renamingSessionId}
|
renamingSessionId={renamingSessionId}
|
||||||
patterns={workspace.patterns}
|
workflows={[...workflowMap.values()]}
|
||||||
project={scratchpadProject}
|
project={scratchpadProject}
|
||||||
selectedSessionId={workspace.selectedSessionId}
|
selectedSessionId={workspace.selectedSessionId}
|
||||||
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
|
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
|
||||||
@@ -764,7 +764,7 @@ export function Sidebar({
|
|||||||
onRefreshGitContext={onRefreshGitContext}
|
onRefreshGitContext={onRefreshGitContext}
|
||||||
onOpenProjectSettings={onOpenProjectSettings}
|
onOpenProjectSettings={onOpenProjectSettings}
|
||||||
renamingSessionId={renamingSessionId}
|
renamingSessionId={renamingSessionId}
|
||||||
patterns={workspace.patterns}
|
workflows={[...workflowMap.values()]}
|
||||||
project={project}
|
project={project}
|
||||||
selectedSessionId={workspace.selectedSessionId}
|
selectedSessionId={workspace.selectedSessionId}
|
||||||
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
|
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 type { ApprovalToolDefinition, LspProfileDefinition, McpServerDefinition, SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
|
||||||
import { groupApprovalToolsByProvider, type ApprovalToolGroup } from '@shared/domain/tooling';
|
import { groupApprovalToolsByProvider, type ApprovalToolGroup } from '@shared/domain/tooling';
|
||||||
import { findModel, inferProvider, providerMeta, type ModelDefinition } from '@shared/domain/models';
|
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';
|
import { RotateCcw, Server, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
/* ── Tier badge ────────────────────────────────────────────── */
|
/* ── 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 { SessionEventRecord } from '@shared/domain/event';
|
||||||
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
|
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export function pruneSessionActivities(
|
|||||||
|
|
||||||
export function buildAgentActivityRows(
|
export function buildAgentActivityRows(
|
||||||
current: SessionActivityState | undefined,
|
current: SessionActivityState | undefined,
|
||||||
agents: PatternDefinition['agents'],
|
agents: ReadonlyArray<{ id: string; name: string }>,
|
||||||
): AgentActivityRow[] {
|
): AgentActivityRow[] {
|
||||||
return agents.map((agent) => {
|
return agents.map((agent) => {
|
||||||
const activity = current?.[agent.id] ?? current?.[agent.name];
|
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