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} + + + )} + + +