import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex'; interface SettingsSidebarProps { activeSection?: string; onSelectSection: (section: string) => void; isAdmin: boolean; /** * Whether A2A is enabled server-side. The A2A Delegations section is per-user * but its API route (`/api/local/a2a/delegations`) only mounts when a2a.enabled, * so the section is hidden unless this is true (defaults false = fail-closed). */ a2aEnabled?: boolean; } /** * Settings navigation, restructured to match the * 2026-05-21-settings-ui-and-config-restructure-design.md (Step 3). * * The form components themselves are intentionally not rewritten in this * step โ€” Provider/Workers/etc keep reading `provider.*` for now and will * show as partially empty against the v2 API. Steps 7-9 swap those forms * to read the new `llm.*` / `gateway.*` keys. * * Old sidebar ids (provider, workspace, tools, browser-settings, * search-filter) still parse via `urlState.ts` and are redirected to * their new homes by `LEGACY_SECTION_REDIRECT` in this file. This keeps * old bookmarks/links working through the transition. */ export const CONFIG_GROUPS = [ { // Per-user personal settings. Labeled "Preference" so per-user items // (mascot Pets, notifications, reflection history) are clearly grouped // and discoverable โ€” users previously could not find Pets under "User". label: 'Preference', sections: [ { id: 'preferences', label: 'Preferences' }, { id: 'notifications', label: '๐Ÿ”” Notifications' }, { id: 'pets', label: 'โ—‰ Pets' }, { id: 'memory-learning', label: '๐Ÿง  Reflection history', labelKey: 'memoryLearning.navLabel' }, { id: 'a2a-delegations', label: '๐Ÿ”‘ A2A Delegations', labelKey: 'delegations.navLabel' }, ], }, { label: 'System', adminOnly: true, sections: [ { id: 'branding', label: 'Branding' }, { id: 'paths-storage', label: 'Paths & Storage' }, { id: 'execution', label: 'Execution' }, { id: 'auth', label: 'Authentication' }, { id: 'organizations', label: '๐Ÿข Organizations' }, { id: 'push-notifications', label: 'Web Push (Server)' }, ], }, { label: 'LLM', adminOnly: true, sections: [ { id: 'llm-workers', label: 'Workers' }, // Step 8: Gateway Keys absorbed into Gateway Server as the // "Virtual Keys" section. Bookmarks to `gateway-keys` are // redirected via LEGACY_SECTION_REDIRECT below. { id: 'gateway-server', label: 'Gateway Server' }, { id: 'llm-metrics', label: 'Metrics' }, ], }, { label: 'Agent Runtime', adminOnly: true, sections: [ { id: 'ask-subtasks', label: 'Ask / Subtasks' }, { id: 'context', label: 'Context' }, { id: 'safety', label: 'Safety' }, { id: 'reflection', label: 'Reflection' }, { id: 'skills-quota', label: 'Skill Quotas' }, ], }, { label: 'Tools', adminOnly: true, sections: [ { id: 'tools-web', label: 'Web & Search' }, { id: 'tools-browser', label: 'Browser Runtime' }, { id: 'tools-media', label: 'Media & Documents' }, { id: 'tools-external', label: 'External Services' }, { id: 'search-filter', label: 'Search Filter' }, ], }, { label: 'MCP & Connections', adminOnly: true, sections: [ { id: 'mcp', label: 'MCP Runtime' }, ], }, { label: 'SSH', adminOnly: true, sections: [ { id: 'ssh', label: 'Admin SSH' }, ], }, { label: 'Network', adminOnly: true, sections: [ { id: 'server-tls', label: 'HTTPS / TLS' }, ], }, ] as const; /** * Old sidebar id โ†’ new id mapping. Used by `SettingsPage` to upgrade * URLs / bookmarks left over from the pre-Step-3 sidebar layout. Keep * each entry until the underlying old id is fully removed from * `SETTINGS_SECTIONS` in `urlState.ts`. * * `tools` (the catch-all tab) maps to the first new Tools sub-section. * Power users coming in via that old URL should land on something * visible rather than a blank screen. */ export const LEGACY_SECTION_REDIRECT: Record = { provider: 'llm-workers', workspace: 'paths-storage', tools: 'tools-web', 'browser-settings': 'tools-browser', // browser-sessions never had a Settings page in the new layout โ€” // it lives in User Folder. Keep mapping so an old URL still goes // somewhere sensible. 'browser-sessions': 'preferences', // Step 8: Gateway Keys folded into Gateway Server. Bookmarks land // on the parent form which now hosts the Virtual Keys section. 'gateway-keys': 'gateway-server', skills: 'preferences', }; /** Sections that any authenticated user (not just admin) can access. */ export const USER_SECTIONS: string[] = CONFIG_GROUPS .filter(g => !('adminOnly' in g) || !g.adminOnly) .flatMap(g => g.sections.map(s => s.id)); /** Sections gated on a runtime feature flag rather than admin role. */ function isSectionAvailable(sectionId: string, a2aEnabled: boolean): boolean { if (sectionId === 'a2a-delegations') return a2aEnabled; return true; } export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEnabled = false }: SettingsSidebarProps) { const { t } = useTranslation('settings'); const [query, setQuery] = useState(''); const visibleGroups = CONFIG_GROUPS .filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly) .map(g => ({ ...g, sections: g.sections.filter(s => isSectionAvailable(s.id, a2aEnabled)) })) .filter(g => g.sections.length > 0); // Only sections the current user can actually open are searchable. const visibleIds = useMemo( () => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))), [visibleGroups], ); const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]); const results = useMemo(() => searchSettings(query, index), [query, index]); const searching = query.trim().length > 0; const labelFor = (id: string, fallback: string) => { for (const g of visibleGroups) { const s = g.sections.find(x => x.id === id); if (s) return 'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label; } return fallback; }; return (
setQuery(e.target.value)} placeholder={t('search.placeholder')} aria-label={t('search.placeholder')} data-testid="settings-search" className="mb-3 w-full rounded-md border border-hairline bg-surface px-2 py-1.5 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring" /> {searching ? (
{results.length === 0 ? (
{t('search.noResults')}
) : ( results.map(r => { const active = activeSection === r.sectionId; return ( ); }) )}
) : ( visibleGroups.map(group => (
{group.label}
{group.sections.map(s => ( ))}
)))}
); }