230 lines
8.5 KiB
TypeScript
230 lines
8.5 KiB
TypeScript
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<string, string> = {
|
|
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<string>(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 (
|
|
<div className="h-full overflow-y-auto border-r border-hairline bg-canvas p-3">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={e => 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 ? (
|
|
<div data-testid="settings-search-results">
|
|
{results.length === 0 ? (
|
|
<div className="px-2 py-1 text-xs text-slate-400">{t('search.noResults')}</div>
|
|
) : (
|
|
results.map(r => {
|
|
const active = activeSection === r.sectionId;
|
|
return (
|
|
<button
|
|
key={r.sectionId}
|
|
data-testid={`settings-search-result-${r.sectionId}`}
|
|
onClick={() => { onSelectSection(r.sectionId); setQuery(''); }}
|
|
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
|
|
active ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 hover:bg-surface'
|
|
}`}
|
|
>
|
|
<span>{labelFor(r.sectionId, r.label)}</span>
|
|
<span className="ml-1 text-2xs text-slate-400">· {r.group}</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
) : (
|
|
visibleGroups.map(group => (
|
|
<div key={group.label} className="mb-3">
|
|
<div className="section-label px-2 py-1">
|
|
{group.label}
|
|
</div>
|
|
{group.sections.map(s => (
|
|
<button key={s.id} data-testid={`settings-nav-${s.id}`} onClick={() => onSelectSection(s.id)}
|
|
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
|
|
activeSection === s.id
|
|
? 'bg-accent-soft text-accent font-semibold'
|
|
: 'text-slate-700 hover:bg-surface'
|
|
}`}>
|
|
{'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)))}
|
|
</div>
|
|
);
|
|
}
|