diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx
index d7b23b8f2..687627526 100644
--- a/app/src/components/settings/SettingsHome.tsx
+++ b/app/src/components/settings/SettingsHome.tsx
@@ -134,20 +134,20 @@ const SettingsHome = () => {
onClick: () => navigateToSettings('appearance'),
},
{
- id: 'agent-access',
- title: t('settings.agentAccess.title'),
- description: t('settings.agentAccess.menuDesc'),
+ id: 'agents-settings',
+ title: t('settings.agentsSection.title'),
+ description: t('settings.agentsSection.menuDesc'),
icon: (
),
- onClick: () => navigateToSettings('agent-access'),
+ onClick: () => navigateToSettings('agents-settings'),
},
{
id: 'mascot',
@@ -165,22 +165,6 @@ const SettingsHome = () => {
),
onClick: () => navigateToSettings('mascot'),
},
- {
- id: 'persona',
- title: t('settings.persona.menuTitle'),
- description: t('settings.persona.menuDesc'),
- icon: (
-
- ),
- onClick: () => navigateToSettings('persona'),
- },
],
},
// Features tile (Screen Awareness / Messaging Channels / Notifications /
diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx
index 4fcc9dbc2..57cddfef8 100644
--- a/app/src/components/settings/__tests__/SettingsHome.test.tsx
+++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx
@@ -156,12 +156,13 @@ describe('SettingsHome', () => {
expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications');
});
- it('navigates to persona settings when Persona is clicked', async () => {
+ it('navigates to the Agents section when Agents is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
- await user.click(screen.getByText('Persona').closest('button')!);
- expect(mockNavigateToSettings).toHaveBeenCalledWith('persona');
+ // Persona, Agent OS access, etc. now live under the Agents section page.
+ await user.click(screen.getByText('Agents').closest('button')!);
+ expect(mockNavigateToSettings).toHaveBeenCalledWith('agents-settings');
});
it('navigates to /notifications inbox when Alerts is clicked', async () => {
diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts
index 7c5fe8ff7..3859a0626 100644
--- a/app/src/components/settings/hooks/useSettingsNavigation.ts
+++ b/app/src/components/settings/hooks/useSettingsNavigation.ts
@@ -4,6 +4,8 @@ import { useLocation, useNavigate } from 'react-router-dom';
export type SettingsRoute =
| 'home'
| 'agents'
+ | 'agents-settings'
+ | 'agent-access'
| 'account'
| 'features'
| 'messaging'
@@ -122,6 +124,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/persona')) return 'persona';
if (path.includes('/settings/appearance')) return 'appearance';
+ // `agents-settings` (the Agents section page) must be checked before the
+ // shorter `agents` (the manage-agents registry panel) so it isn't swallowed.
+ if (path.includes('/settings/agents-settings')) return 'agents-settings';
+ if (path.includes('/settings/agent-access')) return 'agent-access';
if (path.includes('/settings/agents')) return 'agents';
if (path.includes('/settings/mcp-server')) return 'mcp-server';
if (path.includes('/settings/dev-workflow')) return 'dev-workflow';
@@ -181,14 +187,27 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
onClick: () => navigate('/settings/developer-options'),
};
+ const agentsCrumb: BreadcrumbItem = {
+ label: 'Agents',
+ onClick: () => navigate('/settings/agents-settings'),
+ };
+
const getBreadcrumbs = (): BreadcrumbItem[] => {
switch (currentRoute) {
// Section pages
case 'account':
case 'features':
case 'ai':
+ case 'agents-settings':
return [settingsCrumb];
+ // Leaf panels under the Agents section
+ case 'agents':
+ case 'agent-access':
+ case 'autonomy':
+ case 'persona':
+ return [settingsCrumb, agentsCrumb];
+
// Leaf panels under account
case 'recovery-phrase':
case 'wallet-balances':
@@ -233,7 +252,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'notification-routing':
case 'mcp-server':
case 'dev-workflow':
- case 'autonomy':
return [settingsCrumb, developerCrumb];
// Developer options section page
@@ -251,10 +269,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'mascot':
return [settingsCrumb];
- // Persona panel sits at the top level of Settings.
- case 'persona':
- return [settingsCrumb];
-
// Appearance (theme) panel sits at the top level of Settings.
case 'appearance':
return [settingsCrumb];
diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx
index ace9608a9..8cd1e522c 100644
--- a/app/src/components/settings/panels/AgentAccessPanel.tsx
+++ b/app/src/components/settings/panels/AgentAccessPanel.tsx
@@ -197,15 +197,19 @@ const AgentAccessPanel = () => {
{!isTauri() && (
-
{t('settings.agentAccess.desktopOnly')}
+
+ {t('settings.agentAccess.desktopOnly')}
+
)}
{isLoading ? (
-
{t('settings.agentAccess.loading')}
+
+ {t('settings.agentAccess.loading')}
+
) : (
<>
-
+
{t('settings.agentAccess.accessMode')}
@@ -216,27 +220,33 @@ const AgentAccessPanel = () => {
onClick={() => selectTier(p.id)}
className={`text-left rounded-lg border p-3 transition ${
level === p.id
- ? 'border-primary-500 bg-primary-50'
- : 'border-line hover:border-primary-300'
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
+ : 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500'
}`}>
- {p.title}
+
+ {p.title}
+
{p.id === 'supervised' && (
-
+
{t('settings.agentAccess.defaultTag')}
)}
-
{p.description}
+
+ {p.description}
+
))}
{level === 'full' && (
-
+
{t('settings.agentAccess.fullWarning')}
)}
@@ -253,10 +263,10 @@ const AgentAccessPanel = () => {
onChange={e => toggleWorkspaceOnly(e.target.checked)}
/>
-
+
{t('settings.agentAccess.confine.label')}
-
+
{t('settings.agentAccess.confine.desc')}
@@ -272,10 +282,10 @@ const AgentAccessPanel = () => {
onChange={e => toggleTaskPlanApproval(e.target.checked)}
/>
-
+
{t('settings.agentAccess.requireTaskPlanApproval.label')}
-
+
{t('settings.agentAccess.requireTaskPlanApproval.desc')}
@@ -284,21 +294,27 @@ const AgentAccessPanel = () => {
{/* Granted folders (trusted roots) — extra read/write reach. */}
-
+
{t('settings.agentAccess.grantedFolders')}
-
{t('settings.agentAccess.grantedDesc')}
+
+ {t('settings.agentAccess.grantedDesc')}
+
{trustedRoots.length === 0 ? (
- {t('settings.agentAccess.noneGranted')}
+
+ {t('settings.agentAccess.noneGranted')}
+
) : (
{trustedRoots.map(r => (
-
- {r.path}
+ className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
+
+ {r.path}
+
-
+
{r.access === 'readwrite'
? t('settings.agentAccess.readWrite')
: t('settings.agentAccess.readOnly')}
@@ -306,7 +322,7 @@ const AgentAccessPanel = () => {
@@ -321,13 +337,13 @@ const AgentAccessPanel = () => {
onChange={e => setNewRootPath(e.target.value)}
placeholder={t('settings.agentAccess.pathPlaceholder')}
aria-label={t('settings.agentAccess.pathPlaceholder')}
- className="flex-1 rounded border border-line px-2 py-1 text-xs font-mono"
+ className="flex-1 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-xs font-mono"
/>
@@ -344,23 +360,29 @@ const AgentAccessPanel = () => {
prompted for, via the in-chat approval card. Read-only here with
a Remove action to re-enable prompting for a tool. */}
-
+
{t('settings.agentAccess.alwaysAllow')}
-
{t('settings.agentAccess.alwaysAllowDesc')}
+
+ {t('settings.agentAccess.alwaysAllowDesc')}
+
{autoApprove.length === 0 ? (
- {t('settings.agentAccess.alwaysAllowNone')}
+
+ {t('settings.agentAccess.alwaysAllowNone')}
+
) : (
{autoApprove.map(tool => (
-
- {tool}
+ className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
+
+ {tool}
+
@@ -372,13 +394,17 @@ const AgentAccessPanel = () => {
{/* Auto-save status — changes persist on selection; no manual save. */}
{error ? (
- {error}
+ {error}
) : isSaving ? (
- {t('settings.agentAccess.saving')}
+
+ {t('settings.agentAccess.saving')}
+
) : savedNote ? (
- ✓ {savedNote}
+ ✓ {savedNote}
) : (
- {t('settings.agentAccess.changesApply')}
+
+ {t('settings.agentAccess.changesApply')}
+
)}
>
diff --git a/app/src/components/settings/panels/AgentEditorPage.test.tsx b/app/src/components/settings/panels/AgentEditorPage.test.tsx
new file mode 100644
index 000000000..47db602c6
--- /dev/null
+++ b/app/src/components/settings/panels/AgentEditorPage.test.tsx
@@ -0,0 +1,134 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
+import AgentEditorPage from './AgentEditorPage';
+
+vi.mock('../../../services/api/agentRegistryApi', () => ({
+ agentRegistryApi: {
+ list: vi.fn(),
+ get: vi.fn(),
+ availableTools: vi.fn(),
+ createCustom: vi.fn(),
+ update: vi.fn(),
+ setEnabled: vi.fn(),
+ remove: vi.fn(),
+ },
+}));
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async importOriginal => {
+ const actual = await importOriginal();
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}));
+
+const mockGet = vi.mocked(agentRegistryApi.get);
+const mockAvailableTools = vi.mocked(agentRegistryApi.availableTools);
+const mockCreate = vi.mocked(agentRegistryApi.createCustom);
+const mockUpdate = vi.mocked(agentRegistryApi.update);
+
+function agent(overrides: Partial = {}): AgentRegistryEntry {
+ return {
+ id: 'finance',
+ name: 'Finance',
+ description: 'Crunches numbers.',
+ source: 'custom',
+ enabled: true,
+ model: 'reasoning-v1',
+ system_prompt: 'Be precise.',
+ tool_allowlist: ['memory.search'],
+ ...overrides,
+ };
+}
+
+function renderAt(path: string) {
+ return render(
+
+
+ } />
+ } />
+
+
+ );
+}
+
+describe('AgentEditorPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockAvailableTools.mockResolvedValue([
+ { name: 'web_search', description: 'Search the web for information.' },
+ { name: 'memory.search', description: 'Search the user memory store.' },
+ ]);
+ });
+
+ it('creates a custom agent from the form', async () => {
+ mockCreate.mockResolvedValue(agent({ id: 'helper', name: 'Helper' }));
+ renderAt('/settings/agents/new');
+
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Helper' } });
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Helps out.' } });
+ // Model dropdown offers known tiers/hints.
+ expect(screen.getByRole('option', { name: 'reasoning-v1' })).toBeInTheDocument();
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'hint:coding' } });
+
+ fireEvent.click(screen.getByRole('button', { name: /Create agent/ }));
+
+ await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
+ const arg = mockCreate.mock.calls[0][0];
+ expect(arg.id).toBe('helper'); // auto-slugified from name
+ expect(arg.name).toBe('Helper');
+ expect(arg.model).toBe('hint:coding');
+ expect(mockNavigate).toHaveBeenCalledWith('/settings/agents');
+ });
+
+ it('picks tools from the searchable modal and shows chips', async () => {
+ renderAt('/settings/agents/new');
+
+ fireEvent.click(screen.getByText('Add tools'));
+ await waitFor(() => expect(mockAvailableTools).toHaveBeenCalled());
+
+ // Tool descriptions are shown in the modal.
+ expect(await screen.findByText('Search the web for information.')).toBeInTheDocument();
+
+ // Search filters the list.
+ fireEvent.change(screen.getByLabelText('Search tools…'), { target: { value: 'web' } });
+ expect(screen.queryByText('Search the user memory store.')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('web_search'));
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
+
+ // Chip for the selected tool appears on the page.
+ await waitFor(() => expect(screen.getAllByText('web_search').length).toBeGreaterThan(0));
+ });
+
+ it('loads an existing agent for editing with a read-only name', async () => {
+ mockGet.mockResolvedValue(agent());
+ renderAt('/settings/agents/edit/finance');
+
+ await waitFor(() => expect(mockGet).toHaveBeenCalledWith('finance'));
+ // Name is read-only in edit mode — no editable Name input is rendered.
+ expect(screen.queryByLabelText('Name')).toBeNull();
+ expect(screen.getByDisplayValue('Crunches numbers.')).toBeInTheDocument();
+ expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('reasoning-v1');
+
+ fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Updated.' } });
+ fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('finance', expect.any(Object)));
+ });
+
+ it('shows a read-only notice for built-in agents instead of the form', async () => {
+ mockGet.mockResolvedValue(agent({ id: 'researcher', name: 'Researcher', source: 'default' }));
+ renderAt('/settings/agents/edit/researcher');
+
+ await waitFor(() => expect(mockGet).toHaveBeenCalledWith('researcher'));
+ expect(screen.getByText(/Built-in agents can.t be edited/)).toBeInTheDocument();
+ // No editable form fields are rendered.
+ expect(screen.queryByLabelText('Description')).toBeNull();
+ expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
+ });
+});
diff --git a/app/src/components/settings/panels/AgentEditorPage.tsx b/app/src/components/settings/panels/AgentEditorPage.tsx
new file mode 100644
index 000000000..50677708f
--- /dev/null
+++ b/app/src/components/settings/panels/AgentEditorPage.tsx
@@ -0,0 +1,627 @@
+/**
+ * AgentEditorPage — Settings > Agents > (New | Edit).
+ *
+ * Full-page editor for a registry agent (replaces the old in-panel modal).
+ * Routes: `/settings/agents/new` (create) and `/settings/agents/edit/:id`
+ * (edit a default override or a custom agent).
+ *
+ * Field rules:
+ * - Name is the page title; it is editable only when creating. On edit it is
+ * shown read-only (the agent's identity stays stable).
+ * - Description is a textarea.
+ * - Model is a dropdown of known route hints / tiers, with a custom-id escape
+ * hatch for BYOK provider model ids. Empty = inherit (no override).
+ * - Allowed tools open a searchable modal with chip-style selection; each tool
+ * shows its description. `["*"]` means "all tools".
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { LuPlus, LuSearch, LuX } from 'react-icons/lu';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useT } from '../../../lib/i18n/I18nContext';
+import {
+ agentRegistryApi,
+ type AgentRegistryEntry,
+ type AgentToolInfo,
+} from '../../../services/api/agentRegistryApi';
+import SettingsHeader from '../components/SettingsHeader';
+
+// Known model options — mirrors the Rust tier constants + route hints
+// (src/openhuman/config/schema/types.rs, inference/provider/router.rs).
+// Empty string means "inherit" (no override). Any other value not in this list
+// is treated as a raw BYOK provider model id (custom).
+const MODEL_HINTS = [
+ 'hint:reasoning',
+ 'hint:chat',
+ 'hint:agentic',
+ 'hint:coding',
+ 'hint:summarization',
+];
+const MODEL_TIERS = [
+ 'reasoning-v1',
+ 'reasoning-quick-v1',
+ 'chat-v1',
+ 'agentic-v1',
+ 'coding-v1',
+ 'summarization-v1',
+];
+const KNOWN_MODELS = new Set([...MODEL_HINTS, ...MODEL_TIERS]);
+const CUSTOM_MODEL = '__custom__';
+const ALL_TOOLS = '*';
+
+function slugify(name: string): string {
+ return name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+const inputClass =
+ 'w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50';
+
+const AgentEditorPage = () => {
+ const { t } = useT();
+ const navigate = useNavigate();
+ const { id: routeId } = useParams<{ id: string }>();
+ const backToList = useCallback(() => navigate('/settings/agents'), [navigate]);
+ const isCreate = !routeId;
+
+ const [loading, setLoading] = useState(!isCreate);
+ const [loadError, setLoadError] = useState(null);
+ const [isCustom, setIsCustom] = useState(true);
+
+ // Form state.
+ const [name, setName] = useState('');
+ const [agentId, setAgentId] = useState('');
+ const [idTouched, setIdTouched] = useState(!isCreate);
+ const [description, setDescription] = useState('');
+ const [model, setModel] = useState('');
+ const [customModelMode, setCustomModelMode] = useState(false);
+ const [systemPrompt, setSystemPrompt] = useState('');
+ const [toolAllowlist, setToolAllowlist] = useState([]);
+
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [toolsOpen, setToolsOpen] = useState(false);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (isCreate || !routeId) return;
+ let cancelled = false;
+ const load = async () => {
+ setLoading(true);
+ setLoadError(null);
+ try {
+ const agent = await agentRegistryApi.get(routeId);
+ if (cancelled) return;
+ if (!agent) {
+ setLoadError(t('settings.agents.editor.notFound'));
+ return;
+ }
+ populate(agent);
+ } catch (err) {
+ if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+
+ const populate = (agent: AgentRegistryEntry) => {
+ setIsCustom(agent.source === 'custom');
+ setName(agent.name);
+ setAgentId(agent.id);
+ setDescription(agent.description);
+ const m = agent.model ?? '';
+ setModel(m);
+ setCustomModelMode(m !== '' && !KNOWN_MODELS.has(m));
+ setSystemPrompt(agent.system_prompt ?? '');
+ setToolAllowlist(agent.tool_allowlist ?? []);
+ };
+
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, [isCreate, routeId, t]);
+
+ const handleName = (value: string) => {
+ setName(value);
+ if (isCreate && !idTouched) setAgentId(slugify(value));
+ };
+
+ const allToolsSelected = toolAllowlist.length === 1 && toolAllowlist[0] === ALL_TOOLS;
+
+ const canSubmit =
+ !submitting &&
+ description.trim().length > 0 &&
+ (isCreate ? name.trim().length > 0 && agentId.trim().length > 0 : true);
+
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ setError(null);
+ const trimmedModel = model.trim();
+ try {
+ let saved: AgentRegistryEntry;
+ if (isCreate) {
+ saved = await agentRegistryApi.createCustom({
+ id: agentId.trim() || slugify(name),
+ name: name.trim(),
+ description: description.trim(),
+ model: trimmedModel || null,
+ system_prompt: systemPrompt.trim() || null,
+ tool_allowlist: toolAllowlist,
+ });
+ } else {
+ saved = await agentRegistryApi.update(routeId, {
+ description: description.trim(),
+ // Always send a string so "inherit" (empty) clears any prior override.
+ model: trimmedModel,
+ system_prompt: systemPrompt.trim() || null,
+ tool_allowlist: toolAllowlist,
+ });
+ }
+ if (mountedRef.current && saved) backToList();
+ } catch (err) {
+ if (mountedRef.current) setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ if (mountedRef.current) setSubmitting(false);
+ }
+ };
+
+ const title = isCreate
+ ? t('settings.agents.editor.createTitle')
+ : name || t('settings.agents.editor.editTitle');
+
+ const breadcrumbs = [
+ { label: 'Settings', onClick: () => navigate('/settings') },
+ { label: t('settings.agents.title'), onClick: () => navigate('/settings/agents') },
+ ];
+
+ const selectValue = customModelMode ? CUSTOM_MODEL : model;
+
+ const onModelSelect = (value: string) => {
+ if (value === CUSTOM_MODEL) {
+ setCustomModelMode(true);
+ setModel('');
+ } else {
+ setCustomModelMode(false);
+ setModel(value);
+ }
+ };
+
+ return (
+
+
+
+
+ {loading ? (
+
+
+
{t('common.loading')}
+
+ ) : loadError ? (
+
+ {t('settings.agents.loadError')}: {loadError}
+
+ ) : !isCreate && !isCustom ? (
+ // Built-in agents can't be edited; they may only be enabled/disabled
+ // or reset from the agents list.
+
+
+ {t('settings.agents.editor.builtInReadonly')}
+
+
+
+ ) : (
+
+ {/* Name — editable only on create; read-only identity on edit. */}
+ {isCreate ? (
+
+ handleName(e.target.value)}
+ className={inputClass}
+ />
+
+ ) : (
+
+
+
+ {name}
+
+
+ {isCustom
+ ? t('settings.agents.sourceCustom')
+ : t('settings.agents.sourceDefault')}
+
+
+
+ )}
+
+ {/* ID — editable only on create. */}
+ {isCreate ? (
+
+ {
+ setIdTouched(true);
+ setAgentId(e.target.value);
+ }}
+ className={`${inputClass} font-mono`}
+ />
+
+ ) : (
+
+
+ {agentId}
+
+
+ )}
+
+
+
+
+ {/* Model — dropdown of known hints/tiers + custom escape hatch. */}
+
+
+ {customModelMode && (
+ setModel(e.target.value)}
+ placeholder={t('settings.agents.editor.modelCustomPlaceholder')}
+ className={`${inputClass} mt-2 font-mono`}
+ />
+ )}
+
+
+
+
+
+ {/* Allowed tools — chips + modal picker. */}
+
+
+
+ {allToolsSelected ? (
+
+ {t('settings.agents.editor.toolsAllSelected')}
+
+ ) : toolAllowlist.length === 0 ? (
+
+ {t('settings.agents.editor.toolsNoneSelected')}
+
+ ) : (
+ toolAllowlist.map(tool => (
+
+ {tool}
+
+
+ ))
+ )}
+
+
+
+
+
+ {!isCreate && !isCustom && (
+
+ {t('settings.agents.editor.defaultsNote')}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+ {toolsOpen && (
+
setToolAllowlist(prev => (prev[0] === ALL_TOOLS ? [] : [ALL_TOOLS]))}
+ onToggleTool={tool =>
+ setToolAllowlist(prev => {
+ const base = prev[0] === ALL_TOOLS ? [] : prev;
+ return base.includes(tool) ? base.filter(x => x !== tool) : [...base, tool];
+ })
+ }
+ onClose={() => setToolsOpen(false)}
+ />
+ )}
+
+ );
+};
+
+function ToolsPickerModal({
+ allToolsSelected,
+ selected,
+ onToggleAll,
+ onToggleTool,
+ onClose,
+}: {
+ allToolsSelected: boolean;
+ selected: string[];
+ onToggleAll: () => void;
+ onToggleTool: (tool: string) => void;
+ onClose: () => void;
+}) {
+ const { t } = useT();
+ const [tools, setTools] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [query, setQuery] = useState('');
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const list = await agentRegistryApi.availableTools();
+ setTools(list);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return tools;
+ return tools.filter(
+ tool => tool.name.toLowerCase().includes(q) || tool.description.toLowerCase().includes(q)
+ );
+ }, [tools, query]);
+
+ const selectedCount = allToolsSelected ? tools.length : selected.length;
+
+ return (
+
+
+
+
+
+ {t('settings.agents.editor.toolsModalTitle')}
+
+
+ {t('settings.agents.editor.toolsSelectedCount').replace(
+ '{count}',
+ String(selectedCount)
+ )}
+
+
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder={t('settings.agents.editor.toolsSearchPlaceholder')}
+ aria-label={t('settings.agents.editor.toolsSearchPlaceholder')}
+ className={`${inputClass} pl-8`}
+ />
+
+
+
+
+
+
+ {loading ? (
+
+
+
{t('settings.agents.editor.toolsLoading')}
+
+ ) : error ? (
+
+ {t('settings.agents.editor.toolsLoadError')}: {error}
+
+ ) : filtered.length === 0 ? (
+
+ {t('settings.agents.editor.toolsEmpty')}
+
+ ) : (
+
+ {filtered.map(tool => {
+ const checked = allToolsSelected || selected.includes(tool.name);
+ return (
+ -
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function Checkbox({ checked, className = '' }: { checked: boolean; className?: string }) {
+ return (
+
+ {checked && (
+
+ )}
+
+ );
+}
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export default AgentEditorPage;
diff --git a/app/src/components/settings/panels/AgentsPanel.test.tsx b/app/src/components/settings/panels/AgentsPanel.test.tsx
index 64df1154a..d738ed113 100644
--- a/app/src/components/settings/panels/AgentsPanel.test.tsx
+++ b/app/src/components/settings/panels/AgentsPanel.test.tsx
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
@@ -8,6 +9,7 @@ vi.mock('../../../services/api/agentRegistryApi', () => ({
agentRegistryApi: {
list: vi.fn(),
get: vi.fn(),
+ availableTools: vi.fn(),
createCustom: vi.fn(),
update: vi.fn(),
setEnabled: vi.fn(),
@@ -15,8 +17,14 @@ vi.mock('../../../services/api/agentRegistryApi', () => ({
},
}));
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async importOriginal => {
+ const actual = await importOriginal();
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
vi.mock('../hooks/useSettingsNavigation', () => ({
- useSettingsNavigation: () => ({ navigateBack: vi.fn() }),
+ useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../SettingsHeader', () => ({
@@ -26,6 +34,13 @@ vi.mock('../SettingsHeader', () => ({
const mockList = vi.mocked(agentRegistryApi.list);
const mockSetEnabled = vi.mocked(agentRegistryApi.setEnabled);
+const renderPanel = () =>
+ render(
+
+
+
+ );
+
function agent(overrides: Partial = {}): AgentRegistryEntry {
return {
id: 'researcher',
@@ -54,7 +69,7 @@ describe('AgentsPanel', () => {
});
it('lists agents with their source badges', async () => {
- render();
+ renderPanel();
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
expect(screen.getByText('Orchestrator')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
@@ -64,7 +79,7 @@ describe('AgentsPanel', () => {
it('toggles a non-orchestrator agent via setEnabled', async () => {
mockSetEnabled.mockResolvedValue(agent({ id: 'researcher', enabled: false }));
- render();
+ renderPanel();
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
const switches = screen.getAllByRole('switch');
@@ -75,17 +90,27 @@ describe('AgentsPanel', () => {
await waitFor(() => expect(mockSetEnabled).toHaveBeenCalledWith('researcher', false));
});
- it('opens the create editor', async () => {
- render();
+ it('navigates to the create editor page', async () => {
+ renderPanel();
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /New agent/ }));
- expect(screen.getByRole('button', { name: 'Create agent' })).toBeInTheDocument();
- expect(screen.getByText('ID')).toBeInTheDocument();
+ expect(mockNavigate).toHaveBeenCalledWith('/settings/agents/new');
+ });
+
+ it('only offers Edit for custom agents and navigates to the edit page', async () => {
+ renderPanel();
+ await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument());
+ // Two built-ins (orchestrator, researcher) + one custom (finance) — only the
+ // custom agent exposes an Edit button.
+ const editButtons = screen.getAllByRole('button', { name: /Edit/ });
+ expect(editButtons).toHaveLength(1);
+ fireEvent.click(editButtons[0]);
+ expect(mockNavigate).toHaveBeenCalledWith('/settings/agents/edit/finance');
});
it('shows an error when loading fails', async () => {
mockList.mockRejectedValueOnce(new Error('boom'));
- render();
+ renderPanel();
await waitFor(() => expect(screen.getByText(/Couldn't load agents/)).toBeInTheDocument());
});
});
diff --git a/app/src/components/settings/panels/AgentsPanel.tsx b/app/src/components/settings/panels/AgentsPanel.tsx
index 25c3a5c88..f42c04248 100644
--- a/app/src/components/settings/panels/AgentsPanel.tsx
+++ b/app/src/components/settings/panels/AgentsPanel.tsx
@@ -7,46 +7,27 @@
* built-in saves an override), and delete a custom agent / reset a built-in
* override.
*/
-import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { LuPencil, LuPlus, LuRotateCcw, LuTrash2 } from 'react-icons/lu';
+import { useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
-import {
- agentRegistryApi,
- type AgentRegistryEntry,
- type UpdateAgentInput,
-} from '../../../services/api/agentRegistryApi';
+import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ORCHESTRATOR_ID = 'orchestrator';
-function slugify(name: string): string {
- return name
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9_-]+/g, '-')
- .replace(/^-+|-+$/g, '');
-}
-
-function splitLines(value: string): string[] {
- return value
- .split('\n')
- .map(line => line.trim())
- .filter(Boolean);
-}
-
const AgentsPanel = () => {
const { t } = useT();
- const { navigateBack } = useSettingsNavigation();
+ const navigate = useNavigate();
+ const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [agents, setAgents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [actionError, setActionError] = useState(null);
const [busyId, setBusyId] = useState(null);
- const [editing, setEditing] = useState(null);
- const [creating, setCreating] = useState(false);
const mountedRef = useRef(true);
const load = useCallback(async () => {
@@ -108,76 +89,63 @@ const AgentsPanel = () => {
[load, t]
);
- const handleSaved = useCallback((saved: AgentRegistryEntry) => {
- setAgents(prev => {
- const exists = prev.some(a => a.id === saved.id);
- return exists ? prev.map(a => (a.id === saved.id ? saved : a)) : [...prev, saved];
- });
- setEditing(null);
- setCreating(false);
- }, []);
-
return (
-
-
+
+
-
-
- {t('settings.agents.subtitle')}
-
-
+
+
+
+ {t('settings.agents.subtitle')}
+
+
+
+
+ {actionError && (
+
+ {actionError}
+
+ )}
+
+ {loading ? (
+
+
+
{t('common.loading')}
+
+ ) : error ? (
+
+ {t('settings.agents.loadError')}: {error}
+
+ ) : agents.length === 0 ? (
+
+ {t('settings.agents.empty')}
+
+ ) : (
+
+ {agents.map(agent => (
+ handleToggle(agent)}
+ onEdit={() => navigate(`/settings/agents/edit/${agent.id}`)}
+ onRemove={() => handleRemove(agent)}
+ />
+ ))}
+
+ )}
-
- {actionError && (
-
- {actionError}
-
- )}
-
- {loading ? (
-
-
-
{t('common.loading')}
-
- ) : error ? (
-
- {t('settings.agents.loadError')}: {error}
-
- ) : agents.length === 0 ? (
-
- {t('settings.agents.empty')}
-
- ) : (
-
- {agents.map(agent => (
- handleToggle(agent)}
- onEdit={() => setEditing(agent)}
- onRemove={() => handleRemove(agent)}
- />
- ))}
-
- )}
-
- {(editing || creating) && (
-
{
- setEditing(null);
- setCreating(false);
- }}
- onSaved={handleSaved}
- />
- )}
);
};
@@ -204,70 +172,68 @@ function AgentRow({
: t('settings.agents.toolsCount').replace('{count}', String(tools.length));
return (
-
-
-
-
-
-
- {agent.name}
-
-
- {isCustom ? t('settings.agents.sourceCustom') : t('settings.agents.sourceDefault')}
-
-
-
- {agent.description}
-
-
- {agent.id}
- {agent.model && (
-
- {t('settings.agents.modelLabel')}: {agent.model}
-
- )}
-
- {t('settings.agents.toolsLabel')}: {toolsLabel}
-
-
+
-
+
+
+
+ {agent.name}
+
+
+ {isCustom ? t('settings.agents.sourceCustom') : t('settings.agents.sourceDefault')}
+
-
-
-
+
+
+
+
+ {agent.description}
+
+
+ {agent.id}
+ {agent.model && (
+
+ {t('settings.agents.modelLabel')}: {agent.model}
+
+ )}
+
+ {t('settings.agents.toolsLabel')}: {toolsLabel}
+
-
+ {/* Built-in agents can't be edited — only custom agents expose Edit.
+ Built-ins keep the toggle (enable/disable) and Reset (clear override). */}
+ {isCustom && (
+
+ )}