feat: allow saved workspace agents in the workflow editor

Add the ability to select from saved workspace agents when configuring
agent nodes in the workflow editor. Previously, adding an agent node
always created a new inline agent with empty fields.

Changes:
- Thread workspaceAgents prop through SettingsPanel → WorkflowEditor →
  WorkflowGraphInspector → AgentNodeInspector
- Add Inline/Saved Agent source toggle in the agent node inspector
- Add workspace agent picker dropdown with stale-agent detection
- Add override-aware fields that show inherited values from the saved
  agent with per-field reset buttons and visual indicators
- Support clean bidirectional switching between inline and linked modes
- Show saved workspace agents as directly-addable items in the node
  palette with link icon, separated from the generic New Agent button
- Add handleAddWorkspaceAgentNode to create pre-linked agent nodes

The underlying data model (AgentNodeConfig.workspaceAgentId, overrides)
and resolution logic (resolveWorkflowAgentNode) already existed but were
never exposed in the UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-14 16:29:07 +02:00
co-authored by Copilot
parent bed6427dfc
commit 0e9d77c745
4 changed files with 438 additions and 55 deletions
@@ -199,6 +199,7 @@ export function SettingsPanel({
}}
workflow={editingWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
);
+40 -1
View File
@@ -13,6 +13,7 @@ import type {
SubWorkflowConfig,
WorkflowStateScope,
} from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { validateWorkflowDefinition, isBuilderBasedMode, syncBuilderModeEdgeIterations } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
import { ToggleSwitch } from '@renderer/components/ui';
@@ -25,6 +26,7 @@ import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
onChange: (workflow: WorkflowDefinition) => void;
@@ -152,6 +154,7 @@ function createMinimalInlineWorkflow(): WorkflowDefinition {
export function WorkflowEditor({
availableModels,
workspaceAgents,
workflow,
workflows,
onChange,
@@ -251,6 +254,36 @@ export function WorkflowEditor({
setSelectedEdgeId(null);
}
function handleAddWorkspaceAgentNode(agentId: string) {
const agent = workspaceAgents.find((a) => a.id === agentId);
if (!agent) return;
const nodeId = createId('wf-agent');
const config: AgentNodeConfig = {
kind: 'agent',
id: createId('agent'),
name: agent.name,
description: agent.description,
instructions: agent.instructions,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
copilot: agent.copilot,
workspaceAgentId: agent.id,
};
const newNode: WorkflowNode = {
id: nodeId,
kind: 'agent',
label: agent.name,
position: { x: 300, y: 200 },
config,
};
emitGraphChange({
...activeWorkflow.graph,
nodes: [...activeWorkflow.graph.nodes, newNode],
});
setSelectedNodeId(nodeId);
setSelectedEdgeId(null);
}
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
emitGraphChange({
...activeWorkflow.graph,
@@ -486,7 +519,12 @@ export function WorkflowEditor({
<div className="flex min-h-0 flex-1">
{/* Left palette */}
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowNodePalette disabledKinds={disabledPaletteKinds} onAddNode={handleAddNode} />
<WorkflowNodePalette
disabledKinds={disabledPaletteKinds}
onAddNode={handleAddNode}
onAddWorkspaceAgentNode={handleAddWorkspaceAgentNode}
workspaceAgents={workspaceAgents}
/>
</div>
{/* Center column: validation + canvas + settings */}
@@ -551,6 +589,7 @@ export function WorkflowEditor({
validationIssues={issues}
workflow={activeWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
</div>
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { AlertCircle, Bot, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { AlertCircle, Bot, FunctionSquare, GitBranch, Info, Link2, Radio, RotateCcw, Trash2, Unlink } from 'lucide-react';
import {
findModel,
@@ -16,8 +16,10 @@ import type {
WorkflowOrchestrationMode,
WorkflowValidationIssue,
AgentNodeConfig,
WorkflowAgentOverrides,
} from '@shared/domain/workflow';
import { isBuilderBasedMode } from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { InvokeFunctionInspector } from '@renderer/components/workflow/InvokeFunctionInspector';
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
@@ -26,6 +28,7 @@ import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowI
interface WorkflowGraphInspectorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
selectedNodeId: string | null;
@@ -39,6 +42,12 @@ interface WorkflowGraphInspectorProps {
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
}
const inputBaseClass =
'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';
const selectClass =
'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)] outline-none transition focus:border-[var(--color-accent)]/50';
function InputField({
label,
value,
@@ -52,21 +61,19 @@ function InputField({
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`}
className={`${inputBaseClass} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={base}
className={inputBaseClass}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
@@ -76,37 +83,176 @@ function InputField({
);
}
/* ── Overridable field ─────────────────────────────────────── */
function OverridableField({
label,
baseValue,
overrideValue,
onChange,
onReset,
multiline,
placeholder,
}: {
label: string;
baseValue: string;
overrideValue: string | undefined;
onChange: (value: string) => void;
onReset: () => void;
multiline?: boolean;
placeholder?: string;
}) {
const isOverridden = overrideValue !== undefined;
const displayValue = overrideValue ?? baseValue;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{isOverridden && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={onReset}
title="Reset to saved agent value"
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
{multiline ? (
<textarea
className={`${inputBaseClass} min-h-20 resize-y ${isOverridden ? 'border-[var(--color-accent)]/30' : ''}`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? baseValue}
value={displayValue}
/>
) : (
<input
className={`${inputBaseClass} ${isOverridden ? 'border-[var(--color-accent)]/30' : ''}`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? baseValue}
value={displayValue}
/>
)}
</div>
);
}
/* ── Agent node inspector ──────────────────────────────────── */
function AgentNodeInspector({
node,
availableModels,
workspaceAgents,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
}: {
node: WorkflowNode;
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
onNodeRemove: (nodeId: string) => void;
}) {
const config = node.config as AgentNodeConfig;
const model = findModel(config.model, availableModels);
const isLinked = !!config.workspaceAgentId;
const baseAgent = useMemo(
() => isLinked ? workspaceAgents.find((a) => a.id === config.workspaceAgentId) : undefined,
[isLinked, config.workspaceAgentId, workspaceAgents],
);
const isStale = isLinked && !baseAgent;
const resolvedModel = isLinked && baseAgent
? config.overrides?.model ?? baseAgent.model
: config.model;
const model = findModel(resolvedModel, availableModels);
function setConfig(next: AgentNodeConfig) {
onNodeConfigChange(node.id, next);
}
function patchConfig(patch: Partial<AgentNodeConfig>) {
onNodeConfigChange(node.id, { ...config, ...patch });
setConfig({ ...config, ...patch });
}
function patchOverride<K extends keyof WorkflowAgentOverrides>(
field: K,
value: WorkflowAgentOverrides[K],
) {
if (!baseAgent) return;
const baseValue = baseAgent[field];
if (value === baseValue) {
// Value matches base — remove the override
resetOverride(field);
} else {
setConfig({
...config,
overrides: { ...config.overrides, [field]: value },
});
}
}
function resetOverride(field: keyof WorkflowAgentOverrides) {
if (!config.overrides) return;
const { [field]: _, ...rest } = config.overrides;
const cleaned = Object.keys(rest).length > 0 ? rest : undefined;
setConfig({ ...config, overrides: cleaned });
}
function handleSelectWorkspaceAgent(agentId: string) {
const agent = workspaceAgents.find((a) => a.id === agentId);
if (!agent) return;
setConfig({
...config,
workspaceAgentId: agentId,
name: agent.name,
description: agent.description,
instructions: agent.instructions,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
copilot: agent.copilot,
overrides: undefined,
});
onNodeChange(node.id, { label: agent.name });
}
function handleDetach() {
if (!baseAgent) return;
const overrides = config.overrides ?? {};
setConfig({
...config,
workspaceAgentId: undefined,
overrides: undefined,
name: overrides.name ?? baseAgent.name,
description: overrides.description ?? baseAgent.description,
instructions: overrides.instructions ?? baseAgent.instructions,
model: overrides.model ?? baseAgent.model,
reasoningEffort: overrides.reasoningEffort ?? baseAgent.reasoningEffort,
});
}
function handleSwitchToLinked() {
if (workspaceAgents.length === 0) return;
handleSelectWorkspaceAgent(workspaceAgents[0].id);
}
const displayName = isLinked && baseAgent
? config.overrides?.name ?? baseAgent.name
: config.name;
return (
<div className="space-y-4">
{/* Header */}
<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-primary)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{config.name || 'Unnamed Agent'}
{displayName || 'Unnamed Agent'}
</div>
</div>
<button
@@ -119,56 +265,229 @@ function AgentNodeInspector({
</button>
</div>
{/* Source selector */}
<div className="space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Source</span>
<div className="flex gap-1">
<button
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12px] font-medium transition-all ${
!isLinked
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)] ring-1 ring-[var(--color-accent)]/25'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => { if (isLinked) handleDetach(); }}
type="button"
>
Inline
</button>
<button
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12px] font-medium transition-all ${
isLinked
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)] ring-1 ring-[var(--color-accent)]/25'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => { if (!isLinked) handleSwitchToLinked(); }}
type="button"
>
<Link2 className="size-3" />
Saved Agent
</button>
</div>
</div>
{/* Workspace agent picker (linked mode) */}
{isLinked && (
<div className="space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Agent</span>
{workspaceAgents.length > 0 ? (
<select
className={selectClass}
onChange={(e) => handleSelectWorkspaceAgent(e.target.value)}
value={config.workspaceAgentId ?? ''}
>
{isStale && (
<option disabled value={config.workspaceAgentId}>
(Deleted agent)
</option>
)}
{workspaceAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
) : (
<p className="rounded-lg bg-[var(--color-surface-2)] px-3 py-2 text-[12px] text-[var(--color-text-muted)]">
No saved agents. Create agents in Settings Agents.
</p>
)}
{isStale && (
<div className="flex items-start gap-1.5 rounded-lg bg-[var(--color-status-warning)]/10 px-2.5 py-1.5 text-[11px] text-[var(--color-status-warning)]">
<AlertCircle className="mt-0.5 size-3 shrink-0" />
<span>The linked agent was deleted. Select another or switch to inline.</span>
</div>
)}
{baseAgent && (
<button
className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-muted)] transition hover:text-[var(--color-text-secondary)]"
onClick={handleDetach}
title="Detach from saved agent and use inline configuration"
type="button"
>
<Unlink className="size-3" />
Detach to inline
</button>
)}
</div>
)}
{/* Label field (always shown) */}
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
<InputField
label="Agent Name"
onChange={(v) => patchConfig({ name: v })}
placeholder="Agent name"
value={config.name}
/>
<InputField
label="Description"
onChange={(v) => patchConfig({ description: v })}
placeholder="What this agent does"
value={config.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => patchConfig({ instructions: v })}
placeholder="System instructions for this agent"
value={config.instructions}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchConfig({
model: value,
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
});
}}
value={config.model}
/>
<div className="space-y-1.5">
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => patchConfig({ reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.reasoningEffort}
{/* Inline mode — direct editing */}
{!isLinked && (
<>
<InputField
label="Agent Name"
onChange={(v) => patchConfig({ name: v })}
placeholder="Agent name"
value={config.name}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
<InputField
label="Description"
onChange={(v) => patchConfig({ description: v })}
placeholder="What this agent does"
value={config.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => patchConfig({ instructions: v })}
placeholder="System instructions for this agent"
value={config.instructions}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchConfig({
model: value,
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
});
}}
value={config.model}
/>
<div className="space-y-1.5">
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => patchConfig({ reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.reasoningEffort}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
</>
)}
{/* Linked mode — overridable fields */}
{isLinked && baseAgent && (
<>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/50 px-3 py-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Configuration inherited from <strong className="text-[var(--color-text-secondary)]">{baseAgent.name}</strong>. Edit fields below to override per-node.
</div>
<OverridableField
baseValue={baseAgent.name}
label="Agent Name"
onChange={(v) => patchOverride('name', v)}
onReset={() => resetOverride('name')}
overrideValue={config.overrides?.name}
placeholder="Agent name"
/>
<OverridableField
baseValue={baseAgent.description}
label="Description"
onChange={(v) => patchOverride('description', v)}
onReset={() => resetOverride('description')}
overrideValue={config.overrides?.description}
placeholder="What this agent does"
/>
<OverridableField
baseValue={baseAgent.instructions}
label="Instructions"
multiline
onChange={(v) => patchOverride('instructions', v)}
onReset={() => resetOverride('instructions')}
overrideValue={config.overrides?.instructions}
placeholder="System instructions for this agent"
/>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Model</span>
{config.overrides?.model !== undefined && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={() => resetOverride('model')}
title="Reset to saved agent value"
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchOverride('model', value);
patchOverride('reasoningEffort', resolveReasoningEffort(m, config.overrides?.reasoningEffort ?? baseAgent.reasoningEffort));
}}
value={resolvedModel}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Reasoning</span>
{config.overrides?.reasoningEffort !== undefined && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={() => resetOverride('reasoningEffort')}
title="Reset to saved agent value"
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
<ReasoningEffortSelect
label=""
onChange={(value) => patchOverride('reasoningEffort', value)}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.overrides?.reasoningEffort ?? baseAgent.reasoningEffort}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
</>
)}
</div>
);
}
@@ -404,6 +723,7 @@ function EdgeInspector({
export function WorkflowGraphInspector({
availableModels,
workspaceAgents,
workflow,
workflows,
selectedNodeId,
@@ -464,6 +784,7 @@ export function WorkflowGraphInspector({
onNodeChange={onNodeChange}
onNodeConfigChange={onNodeConfigChange}
onNodeRemove={onNodeRemove}
workspaceAgents={workspaceAgents}
/>
</div>
);
@@ -1,9 +1,12 @@
import { Bot, FunctionSquare, GitBranch, Play, Radio, Square } from 'lucide-react';
import { Bot, FunctionSquare, GitBranch, Link2, Play, Radio, Square } from 'lucide-react';
import type { WorkflowNodeKind } from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
interface WorkflowNodePaletteProps {
onAddNode: (kind: WorkflowNodeKind) => void;
onAddWorkspaceAgentNode: (agentId: string) => void;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
disabledKinds?: ReadonlySet<WorkflowNodeKind>;
}
@@ -30,7 +33,7 @@ const paletteGroups: PaletteGroup[] = [
{
label: 'Agents',
items: [
{ kind: 'agent', label: 'Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
{ kind: 'agent', label: 'New Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
],
},
{
@@ -48,7 +51,7 @@ const paletteGroups: PaletteGroup[] = [
},
];
export function WorkflowNodePalette({ onAddNode, disabledKinds }: WorkflowNodePaletteProps) {
export function WorkflowNodePalette({ onAddNode, onAddWorkspaceAgentNode, workspaceAgents, disabledKinds }: WorkflowNodePaletteProps) {
return (
<div className="space-y-4 p-3">
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
@@ -81,6 +84,25 @@ export function WorkflowNodePalette({ onAddNode, disabledKinds }: WorkflowNodePa
</button>
);
})}
{/* Saved workspace agents in the Agents group */}
{group.label === 'Agents' && workspaceAgents.length > 0 && (
<>
<div className="mx-1 my-1.5 border-t border-[var(--color-border)]" />
{workspaceAgents.map((agent) => (
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
key={agent.id}
onClick={() => onAddWorkspaceAgentNode(agent.id)}
title={agent.description || agent.name}
type="button"
>
<Link2 className="size-3.5 text-[var(--color-accent)]" />
<span className="truncate">{agent.name}</span>
</button>
))}
</>
)}
</div>
</div>
))}