feat: add workspace agents for reusable agent definitions across patterns

Introduce a workspace-level agent library that allows users to define
agents once and reference them from multiple orchestration patterns.

Domain model:
- Add WorkspaceAgentDefinition type with name, model, instructions, etc.
- Extend PatternAgentDefinition with optional workspaceAgentId and overrides
- Add resolution helpers that merge workspace agent base with per-pattern overrides
- Extend WorkspaceSettings with agents array and normalize on load

IPC & main process:
- Add saveWorkspaceAgent/deleteWorkspaceAgent IPC channels and handlers
- Resolve workspace agent references in buildEffectivePattern before
  sending to sidecar (no C# changes needed)

Settings UI:
- Add 'Agents' tab under Orchestration in the Settings panel
- Create WorkspaceAgentEditor component using ToolingEditorShell
- Show usage count (which patterns reference each agent)

Pattern editor integration:
- Add agent picker dropdown: 'New inline agent' or 'From library'
- Show linked badge (chain icon) on referenced agent graph nodes
- Show linked workspace agent banner in the inspector
- Add 'Save to Agent Library' action to promote inline agents
- Add 'Unlink' action to convert referenced agents back to inline

Tests:
- Add unit tests for resolution helpers (resolvePatternAgent,
  resolvePatternAgents, findWorkspaceAgentUsages, normalize)
- Update existing tooling test for new agents field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 17:09:54 +02:00
co-authored by Copilot
parent 10316a2871
commit e39fffaf3b
17 changed files with 808 additions and 17 deletions
+45 -5
View File
@@ -36,6 +36,11 @@ import {
type ReasoningEffort,
validatePatternDefinition,
} from '@shared/domain/pattern';
import {
normalizeWorkspaceAgentDefinition,
resolvePatternAgents,
type WorkspaceAgentDefinition,
} from '@shared/domain/workspaceAgent';
import {
applyDiscoveredMcpServerStatus,
listAcceptedDiscoveredMcpServers,
@@ -813,6 +818,39 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async saveWorkspaceAgent(agent: WorkspaceAgentDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const agents = workspace.settings.agents ?? [];
const existingIndex = agents.findIndex((current) => current.id === agent.id);
const timestamp = nowIso();
const candidate = normalizeWorkspaceAgentDefinition({
...agent,
createdAt: existingIndex >= 0 ? agents[existingIndex].createdAt : timestamp,
updatedAt: timestamp,
});
if (!candidate.name) {
throw new Error('Workspace agent name is required.');
}
if (existingIndex >= 0) {
agents[existingIndex] = candidate;
} else {
agents.push(candidate);
}
workspace.settings.agents = agents;
return this.persistAndBroadcast(workspace);
}
async deleteWorkspaceAgent(agentId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.settings.agents = (workspace.settings.agents ?? []).filter(
(agent) => agent.id !== agentId,
);
return this.persistAndBroadcast(workspace);
}
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
@@ -955,7 +993,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session),
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
@@ -1014,7 +1052,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session),
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
@@ -1068,7 +1106,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session),
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
project,
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
@@ -2919,10 +2957,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
private async buildEffectivePattern(
pattern: PatternDefinition,
session: SessionRecord,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): Promise<PatternDefinition> {
const resolvedPattern = resolvePatternAgents(pattern, workspaceAgents);
const patternWithSessionConfig = session.sessionModelConfig
? applySessionModelConfig(pattern, session)
: pattern;
? applySessionModelConfig(resolvedPattern, session)
: resolvedPattern;
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
const modelCatalog = await this.loadAvailableModelCatalog();
+7
View File
@@ -34,6 +34,7 @@ import type {
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SaveWorkspaceAgentInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
SetProjectAgentProfileEnabledInput,
@@ -148,6 +149,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
service.deleteLspProfile(profileId),
);
ipcMain.handle(ipcChannels.saveWorkspaceAgent, (_event, input: SaveWorkspaceAgentInput) =>
service.saveWorkspaceAgent(input.agent),
);
ipcMain.handle(ipcChannels.deleteWorkspaceAgent, (_event, agentId: string) =>
service.deleteWorkspaceAgent(agentId),
);
ipcMain.handle(ipcChannels.describeTerminal, () => service.describeTerminal());
ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal());
ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal());
+2
View File
@@ -37,6 +37,8 @@ const api: ElectronApi = {
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
saveWorkspaceAgent: (input) => ipcRenderer.invoke(ipcChannels.saveWorkspaceAgent, input),
deleteWorkspaceAgent: (agentId) => ipcRenderer.invoke(ipcChannels.deleteWorkspaceAgent, agentId),
describeTerminal: () => ipcRenderer.invoke(ipcChannels.describeTerminal),
createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal),
restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal),
+26
View File
@@ -49,6 +49,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
import type { ProjectGitFileReference } from '@shared/domain/project';
import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
import { createId, nowIso } from '@shared/utils/ids';
@@ -106,6 +107,20 @@ function createDraftLspProfile(): LspProfileDefinition {
};
}
function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefinition {
const timestamp = nowIso();
return {
id: createId('agent'),
name: '',
description: '',
instructions: '',
model: defaultModelId,
reasoningEffort: 'high',
createdAt: timestamp,
updatedAt: timestamp,
};
}
export default function App() {
const api = getElectronApi();
const [workspace, setWorkspace] = useState<WorkspaceState>();
@@ -728,6 +743,17 @@ export default function App() {
onSavePattern={async (pattern) => {
await api.savePattern({ pattern });
}}
onSaveWorkspaceAgent={async (agent) => {
await api.saveWorkspaceAgent({ agent });
}}
onDeleteWorkspaceAgent={async (id) => {
await api.deleteWorkspaceAgent(id);
}}
onNewWorkspaceAgent={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftWorkspaceAgent(defaultModel?.id ?? 'gpt-5.4');
}}
workspaceAgents={workspace.settings.agents ?? []}
onSetTheme={(theme) => void api.setTheme(theme)}
notificationsEnabled={workspace.settings.notificationsEnabled !== false}
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
+96 -8
View File
@@ -3,14 +3,18 @@ import {
AlertCircle,
ArrowLeftRight,
CheckCircle,
ChevronDown,
ChevronLeft,
GitFork,
Library,
Link2,
ListOrdered,
Lock,
MessageSquare,
Plus,
ShieldCheck,
Trash2,
Unlink,
Users,
type LucideIcon,
} from 'lucide-react';
@@ -35,7 +39,9 @@ import {
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';
@@ -46,6 +52,8 @@ interface PatternEditorProps {
isBuiltin: boolean;
toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
workspaceAgents: WorkspaceAgentDefinition[];
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
onChange: (pattern: PatternDefinition) => void;
onDelete?: () => void;
onSave: () => void;
@@ -134,12 +142,16 @@ export function PatternEditor({
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);
@@ -166,6 +178,47 @@ export function PatternEditor({
};
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>) {
@@ -313,14 +366,46 @@ export function PatternEditor({
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Topology
</h4>
<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={addAgent}
type="button"
>
<Plus className="size-3" />
Add agent
</button>
<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">
@@ -473,8 +558,11 @@ export function PatternEditor({
graph={graph}
mode={pattern.mode}
selectedNodeId={selectedNodeId}
workspaceAgents={workspaceAgents}
onAgentChange={updateAgent}
onAgentRemove={removeAgent}
onAgentPromote={promoteAgent}
onAgentUnlink={unlinkAgent}
onGraphChange={emitGraphChange}
/>
</div>
+121 -2
View File
@@ -1,11 +1,12 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
@@ -20,6 +21,7 @@ import {
type McpServerDefinition,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
@@ -41,6 +43,10 @@ interface SettingsPanelProps {
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
onDeleteLspProfile: (profileId: string) => Promise<void>;
onNewLspProfile: () => LspProfileDefinition;
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
onDeleteWorkspaceAgent: (agentId: string) => Promise<void>;
onNewWorkspaceAgent: () => WorkspaceAgentDefinition;
workspaceAgents: WorkspaceAgentDefinition[];
onSetTheme: (theme: AppearanceTheme) => void;
notificationsEnabled: boolean;
onSetNotificationsEnabled: (enabled: boolean) => void;
@@ -54,7 +60,7 @@ interface SettingsPanelProps {
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
}
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -84,6 +90,7 @@ const navGroups: NavGroup[] = [
label: 'Orchestration',
items: [
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
],
},
{
@@ -126,6 +133,10 @@ export function SettingsPanel({
onSaveLspProfile,
onDeleteLspProfile,
onNewLspProfile,
onSaveWorkspaceAgent,
onDeleteWorkspaceAgent,
onNewWorkspaceAgent,
workspaceAgents,
onSetTheme,
notificationsEnabled,
onSetNotificationsEnabled,
@@ -142,6 +153,7 @@ export function SettingsPanel({
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
if (editingPattern) {
const isBuiltin = editingPattern.id.startsWith('pattern-');
@@ -167,6 +179,8 @@ export function SettingsPanel({
pattern={editingPattern}
runtimeTools={sidecarCapabilities?.runtimeTools}
toolingSettings={toolingSettings}
workspaceAgents={workspaceAgents}
onSaveWorkspaceAgent={onSaveWorkspaceAgent}
/>
</div>
);
@@ -222,6 +236,33 @@ export function SettingsPanel({
);
}
if (editingWorkspaceAgent) {
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<WorkspaceAgentEditor
agent={editingWorkspaceAgent}
availableModels={availableModels}
onBack={() => setEditingWorkspaceAgent(null)}
onChange={setEditingWorkspaceAgent}
onDelete={
exists
? async () => {
await onDeleteWorkspaceAgent(editingWorkspaceAgent.id);
setEditingWorkspaceAgent(null);
}
: undefined
}
onSave={async () => {
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
setEditingWorkspaceAgent(null);
}}
patterns={patterns}
/>
</div>
);
}
return (
<div className="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<div className="drag-region flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-3">
@@ -298,6 +339,14 @@ export function SettingsPanel({
patterns={patterns}
/>
)}
{activeSection === 'agents' && (
<WorkspaceAgentsSection
agents={workspaceAgents}
patterns={patterns}
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
/>
)}
{activeSection === 'mcp-servers' && (
<McpServersSection
onEditServer={(server) => setEditingMcpServer(structuredClone(server))}
@@ -554,6 +603,76 @@ function PatternsSection({
);
}
function WorkspaceAgentsSection({
agents,
patterns,
onEditAgent,
onNewAgent,
}: {
agents: WorkspaceAgentDefinition[];
patterns: PatternDefinition[];
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
onNewAgent: () => void;
}) {
return (
<div>
<SectionHeader
description="Define reusable agents that can be shared across multiple patterns"
title="Workspace Agents"
>
<SectionAction label="New Agent" onClick={onNewAgent} />
</SectionHeader>
{agents.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] px-6 py-10 text-center">
<UserCircle className="mx-auto size-8 text-[var(--color-text-muted)]" />
<p className="mt-3 text-[13px] font-medium text-[var(--color-text-secondary)]">
No workspace agents yet
</p>
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
Create agents here and reference them in multiple patterns.
Changes to a workspace agent automatically propagate to all linked patterns.
</p>
</div>
) : (
<div className="space-y-1">
{agents.map((agent) => {
const usageCount = findWorkspaceAgentUsages(agent.id, patterns).length;
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
key={agent.id}
onClick={() => onEditAgent(agent)}
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)]">{agent.name}</span>
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{agent.model}
</span>
</div>
{agent.description && (
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{agent.description}</p>
)}
</div>
<div className="flex items-center gap-2">
{usageCount > 0 && (
<span className="text-[12px] text-[var(--color-text-muted)]">
{usageCount} pattern{usageCount === 1 ? '' : 's'}
</span>
)}
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
</div>
</button>
);
})}
</div>
)}
</div>
);
}
function McpServersSection({
servers,
onEditServer,
@@ -1,6 +1,6 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { CircleUser, Shuffle, Layers, Radio, Bot } from 'lucide-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';
@@ -56,6 +56,11 @@ function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: b
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>
);
}
@@ -1,4 +1,4 @@
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Radio, Shuffle, Trash2 } from 'lucide-react';
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Library, Link2, Radio, Shuffle, Trash2, Unlink } from 'lucide-react';
import {
findModel,
@@ -12,6 +12,7 @@ import type {
PatternGraph,
PatternGraphNodeKind,
} from '@shared/domain/pattern';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import {
canMoveSequential,
findAgentForNode,
@@ -25,8 +26,11 @@ interface PatternGraphInspectorProps {
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;
}
@@ -118,8 +122,11 @@ function AgentNodeInspector({
mode,
graph,
nodeId,
workspaceAgents,
onAgentChange,
onAgentRemove,
onAgentPromote,
onAgentUnlink,
onGraphChange,
}: {
agent: PatternAgentDefinition;
@@ -127,17 +134,45 @@ function AgentNodeInspector({
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)]">
@@ -218,6 +253,19 @@ function AgentNodeInspector({
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>
);
}
@@ -228,8 +276,11 @@ export function PatternGraphInspector({
graph,
mode,
selectedNodeId,
workspaceAgents,
onAgentChange,
onAgentRemove,
onAgentPromote,
onAgentUnlink,
onGraphChange,
}: PatternGraphInspectorProps) {
if (!selectedNodeId) {
@@ -268,8 +319,11 @@ export function PatternGraphInspector({
mode={mode}
graph={graph}
nodeId={selectedNodeId}
workspaceAgents={workspaceAgents}
onAgentChange={onAgentChange}
onAgentRemove={onAgentRemove}
onAgentPromote={onAgentPromote}
onAgentUnlink={onAgentUnlink}
onGraphChange={onGraphChange}
/>
</div>
@@ -0,0 +1,138 @@
import { FormField, TextInput, TextareaInput } from '@renderer/components/ui';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { findModel, type ModelDefinition } from '@shared/domain/models';
import { resolveReasoningEffort } from '@shared/domain/models';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { PatternDefinition } from '@shared/domain/pattern';
import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent';
import { ToolingEditorShell } from './ToolingEditorShell';
import { Link2, Workflow } from 'lucide-react';
function validateWorkspaceAgent(agent: WorkspaceAgentDefinition): string | undefined {
if (!agent.name.trim()) return 'Agent name is required.';
if (!agent.model.trim()) return 'Model is required.';
return undefined;
}
export function WorkspaceAgentEditor({
agent,
onChange,
onBack,
onSave,
onDelete,
availableModels,
patterns,
}: {
agent: WorkspaceAgentDefinition;
onChange: (agent: WorkspaceAgentDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
}) {
const validationError = validateWorkspaceAgent(agent);
const usages = findWorkspaceAgentUsages(agent.id, patterns);
return (
<ToolingEditorShell
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
subtitle="Reusable agent definition"
title={agent.name || 'Untitled Agent'}
>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
General
</h4>
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange({ ...agent, name: value })}
placeholder="e.g. Code Reviewer, Architect, QA Agent"
value={agent.name}
/>
</FormField>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
AI Configuration
</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<ModelSelect
models={availableModels}
onChange={(model) => {
const m = findModel(model, availableModels);
onChange({
...agent,
model,
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
});
}}
value={agent.model}
/>
</div>
<div>
<ReasoningEffortSelect
onChange={(value) => onChange({ ...agent, reasoningEffort: value })}
supportedEfforts={findModel(agent.model, availableModels)?.supportedReasoningEfforts}
value={agent.reasoningEffort}
/>
</div>
</div>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Agent Identity
</h4>
<FormField label="Description">
<TextareaInput
onChange={(value) => onChange({ ...agent, description: value })}
placeholder="A short description of this agent's role and purpose"
rows={2}
value={agent.description}
/>
</FormField>
<FormField label="Instructions">
<TextareaInput
onChange={(value) => onChange({ ...agent, instructions: value })}
placeholder="System instructions that define this agent's behavior"
rows={8}
value={agent.instructions}
/>
</FormField>
</section>
{usages.length > 0 && (
<section className="space-y-3">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Used By
</h4>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)]">
{usages.map((usage) => (
<div
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
key={usage.patternId}
>
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[13px] text-[var(--color-text-secondary)]">
{usage.patternName}
</span>
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
</div>
))}
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
Referenced by {usages.length} pattern{usages.length === 1 ? '' : 's'}.
Changes to this agent will affect all linked patterns.
</p>
</section>
)}
</ToolingEditorShell>
);
}
+7
View File
@@ -25,6 +25,8 @@ export interface GraphNodeData extends Record<string, unknown> {
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 ─────────────────────────────────── */
@@ -81,6 +83,10 @@ export function toCanvasNodes(
}
}
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),
@@ -93,6 +99,7 @@ export function toCanvasNodes(
readOnly: isSystemNode(node.kind),
provider,
modelLabel,
isLinked,
},
draggable: true,
selectable: true,
+2
View File
@@ -26,6 +26,8 @@ export const ipcChannels = {
deleteMcpServer: 'tooling:mcp:delete',
saveLspProfile: 'tooling:lsp:save',
deleteLspProfile: 'tooling:lsp:delete',
saveWorkspaceAgent: 'workspace-agents:save',
deleteWorkspaceAgent: 'workspace-agents:delete',
describeTerminal: 'terminal:describe',
createTerminal: 'terminal:create',
restartTerminal: 'terminal:restart',
+7
View File
@@ -21,6 +21,7 @@ import type {
import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
export interface CreateSessionInput {
projectId: string;
@@ -118,6 +119,10 @@ export interface SaveLspProfileInput {
profile: LspProfileDefinition;
}
export interface SaveWorkspaceAgentInput {
agent: WorkspaceAgentDefinition;
}
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
export interface RescanProjectConfigsInput {
@@ -273,6 +278,8 @@ export interface ElectronApi {
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
saveWorkspaceAgent(input: SaveWorkspaceAgentInput): Promise<WorkspaceState>;
deleteWorkspaceAgent(agentId: string): Promise<WorkspaceState>;
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
+5
View File
@@ -33,6 +33,7 @@ export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; lab
];
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
import type { PatternAgentOverrides } from '@shared/domain/workspaceAgent';
export interface PatternAgentDefinition {
id: string;
@@ -42,6 +43,10 @@ export interface PatternAgentDefinition {
model: string;
reasoningEffort?: ReasoningEffort;
copilot?: PatternAgentCopilotConfig;
/** When set, this agent references a workspace agent instead of being fully inline. */
workspaceAgentId?: string;
/** Per-pattern overrides applied on top of the workspace agent base. */
overrides?: PatternAgentOverrides;
}
export interface PatternGraphPosition {
+4
View File
@@ -6,6 +6,8 @@ import {
type ProjectDiscoveredTooling,
} from '@shared/domain/discoveredTooling';
import { nowIso } from '@shared/utils/ids';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { normalizeWorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
export type McpServerTransport = 'local' | 'http' | 'sse';
@@ -63,6 +65,7 @@ export interface WorkspaceSettings {
theme: AppearanceTheme;
tooling: WorkspaceToolingSettings;
discoveredUserTooling: DiscoveredToolingState;
agents?: WorkspaceAgentDefinition[];
terminalHeight?: number;
notificationsEnabled?: boolean;
minimizeToTray?: boolean;
@@ -207,6 +210,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
},
discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling),
agents: (settings?.agents ?? []).map(normalizeWorkspaceAgentDefinition),
...(terminalHeight !== undefined ? { terminalHeight } : {}),
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
+104
View File
@@ -0,0 +1,104 @@
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
import type { PatternAgentDefinition, PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
export interface WorkspaceAgentDefinition {
id: string;
name: string;
description: string;
instructions: string;
model: string;
reasoningEffort?: ReasoningEffort;
copilot?: PatternAgentCopilotConfig;
createdAt: string;
updatedAt: string;
}
export interface PatternAgentOverrides {
name?: string;
description?: string;
instructions?: string;
model?: string;
reasoningEffort?: ReasoningEffort;
}
export interface WorkspaceAgentUsage {
patternId: string;
patternName: string;
}
/**
* Resolves a single pattern agent by merging its workspace agent base with
* per-pattern overrides. Returns the agent unchanged if it is inline.
*/
export function resolvePatternAgent(
agent: PatternAgentDefinition,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): PatternAgentDefinition {
if (!agent.workspaceAgentId) {
return agent;
}
const base = workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId);
if (!base) {
return agent;
}
const overrides = agent.overrides ?? {};
return {
id: agent.id,
name: overrides.name ?? base.name,
description: overrides.description ?? base.description,
instructions: overrides.instructions ?? base.instructions,
model: overrides.model ?? base.model,
reasoningEffort: overrides.reasoningEffort ?? base.reasoningEffort,
copilot: base.copilot,
workspaceAgentId: agent.workspaceAgentId,
overrides: agent.overrides,
};
}
/**
* Resolves all agents in a pattern, producing a new pattern whose agents
* have workspace-agent references merged with their base definitions.
*/
export function resolvePatternAgents(
pattern: PatternDefinition,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): PatternDefinition {
return {
...pattern,
agents: pattern.agents.map((agent) => resolvePatternAgent(agent, workspaceAgents)),
};
}
/**
* Returns every pattern that references the given workspace agent.
*/
export function findWorkspaceAgentUsages(
agentId: string,
patterns: ReadonlyArray<PatternDefinition>,
): WorkspaceAgentUsage[] {
const usages: WorkspaceAgentUsage[] = [];
for (const pattern of patterns) {
if (pattern.agents.some((a) => a.workspaceAgentId === agentId)) {
usages.push({ patternId: pattern.id, patternName: pattern.name });
}
}
return usages;
}
/**
* Normalizes a workspace agent definition, trimming string fields and
* ensuring consistent shape.
*/
export function normalizeWorkspaceAgentDefinition(
agent: WorkspaceAgentDefinition,
): WorkspaceAgentDefinition {
return {
...agent,
name: agent.name.trim(),
description: agent.description.trim(),
instructions: agent.instructions.trim(),
model: agent.model.trim(),
};
}