+ {/* Mirror the SettingsHome layout: padded container, items in a single
+ rounded-border card, and the optional footer in its own matching card
+ so section pages and the home list look identical. */}
+
{description && (
-
- {description}
-
+
{description}
)}
-
+
{items.map((item, index) => (
- {footer}
+ {footer && (
+ <>
+ {/* Divider + card, mirroring how SettingsHome separates its
+ trailing groups (e.g. the destructive logout/clear card). */}
+
+
+ {footer}
+
+ >
+ )}
);
diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx
index fe3d61c58..10328d440 100644
--- a/app/src/components/settings/__tests__/SettingsHome.test.tsx
+++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx
@@ -145,10 +145,11 @@ describe('SettingsHome', () => {
expect(screen.queryByTestId('settings-nav-companion')).not.toBeInTheDocument();
});
- it('renders the Privacy group items', () => {
+ it('does not surface Privacy/Security/Approvals on the home list', () => {
renderSettingsHome();
- // Privacy stays; Security + Approvals moved to Developer & Diagnostics.
- expect(screen.getByTestId('settings-nav-privacy')).toBeInTheDocument();
+ // Privacy is reached via the Account hub; Security + Approvals live under
+ // Developer & Diagnostics. None of them are top-level home rows.
+ expect(screen.queryByTestId('settings-nav-privacy')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-security')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-approval-history')).not.toBeInTheDocument();
});
@@ -236,14 +237,6 @@ describe('SettingsHome', () => {
expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot');
});
- it('navigates to privacy when Privacy is clicked', async () => {
- const user = userEvent.setup();
- renderSettingsHome();
-
- await user.click(screen.getByTestId('settings-nav-privacy'));
- expect(mockNavigateToSettings).toHaveBeenCalledWith('privacy');
- });
-
it('navigates to notifications-hub when Notifications is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
diff --git a/app/src/components/settings/components/SettingsHeader.tsx b/app/src/components/settings/components/SettingsHeader.tsx
index 0431c7573..a64984a27 100644
--- a/app/src/components/settings/components/SettingsHeader.tsx
+++ b/app/src/components/settings/components/SettingsHeader.tsx
@@ -1,3 +1,5 @@
+import type { ReactNode } from 'react';
+
import { useT } from '../../../lib/i18n/I18nContext';
interface BreadcrumbItem {
@@ -11,6 +13,13 @@ interface SettingsHeaderProps {
showBackButton?: boolean;
onBack?: () => void;
breadcrumbs?: BreadcrumbItem[];
+ /**
+ * Optional right-aligned action (e.g. a refresh or pair-device button).
+ * Rendered at the end of the header row so panels keep the canonical
+ * "SettingsHeader as first child" structure instead of wrapping the header
+ * in an ad-hoc flex row.
+ */
+ action?: ReactNode;
}
const SettingsHeader = ({
@@ -19,73 +28,78 @@ const SettingsHeader = ({
showBackButton = false,
onBack,
breadcrumbs,
+ action,
}: SettingsHeaderProps) => {
const { t } = useT();
return (
-
- {/* Back button */}
- {showBackButton && onBack && (
-
- )}
+
+
+ {/* Back button */}
+ {showBackButton && onBack && (
+
+ )}
- {/* Breadcrumbs */}
- {breadcrumbs && breadcrumbs.length > 0 && (
-
- )}
+ {/* Breadcrumbs */}
+ {breadcrumbs && breadcrumbs.length > 0 && (
+
+ )}
- {/* Title */}
-
- {title ?? t('nav.settings')}
-
+ {/* Title */}
+
+ {title ?? t('nav.settings')}
+
+
+
+ {action &&
{action}
}
);
diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts
index f597ceda7..2011b6c44 100644
--- a/app/src/components/settings/hooks/useSettingsNavigation.ts
+++ b/app/src/components/settings/hooks/useSettingsNavigation.ts
@@ -51,8 +51,22 @@ export type SettingsRoute =
| 'dev-workflow'
| 'sandbox-settings'
| 'permissions'
+ | 'activity-level'
| 'devices'
- | 'heartbeat';
+ | 'heartbeat'
+ | 'security'
+ | 'migration'
+ | 'companion'
+ | 'embeddings'
+ | 'ledger-usage'
+ | 'cost-dashboard'
+ | 'search'
+ | 'skills-runner'
+ | 'event-log'
+ | 'model-health'
+ | 'analysis-views'
+ | 'tool-policy-diagnostics'
+ | 'about';
export interface BreadcrumbItem {
label: string;
@@ -145,12 +159,28 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
// 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/sandbox-settings')) return 'sandbox-settings';
+ if (path.includes('/settings/activity-level')) return 'activity-level';
if (path.includes('/settings/permissions')) return 'permissions';
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';
if (path.includes('/settings/heartbeat')) return 'heartbeat';
+ // `tool-policy-diagnostics` must precede the shorter `tools` check above is
+ // unaffected (distinct prefix), but keep it explicit here for clarity.
+ if (path.includes('/settings/tool-policy-diagnostics')) return 'tool-policy-diagnostics';
+ if (path.includes('/settings/security')) return 'security';
+ if (path.includes('/settings/migration')) return 'migration';
+ if (path.includes('/settings/companion')) return 'companion';
+ if (path.includes('/settings/embeddings')) return 'embeddings';
+ if (path.includes('/settings/ledger-usage')) return 'ledger-usage';
+ if (path.includes('/settings/cost-dashboard')) return 'cost-dashboard';
+ if (path.includes('/settings/skills-runner')) return 'skills-runner';
+ if (path.includes('/settings/event-log')) return 'event-log';
+ if (path.includes('/settings/model-health')) return 'model-health';
+ if (path.includes('/settings/analysis-views')) return 'analysis-views';
+ if (path.includes('/settings/search')) return 'search';
+ if (path.includes('/settings/about')) return 'about';
return 'home';
};
@@ -241,6 +271,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'agents':
case 'agent-access':
case 'sandbox-settings':
+ case 'activity-level':
case 'autonomy':
case 'persona':
return [settingsCrumb, agentsCrumb];
@@ -253,6 +284,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
// Leaf panels under account
case 'team':
case 'privacy':
+ case 'security':
+ case 'migration':
return [settingsCrumb, accountCrumb];
case 'billing':
@@ -263,11 +296,15 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'autocomplete':
case 'messaging':
case 'tools':
+ case 'companion':
return [settingsCrumb, featuresCrumb];
// Leaf panels under AI
case 'voice':
case 'llm':
+ case 'embeddings':
+ case 'ledger-usage':
+ case 'cost-dashboard':
return [settingsCrumb, aiCrumb];
// Team sub-pages
@@ -293,6 +330,12 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'mcp-server':
case 'dev-workflow':
case 'heartbeat':
+ case 'search':
+ case 'skills-runner':
+ case 'event-log':
+ case 'model-health':
+ case 'analysis-views':
+ case 'tool-policy-diagnostics':
case 'notifications-hub': // Notifications hub section page lives under Advanced.
return [settingsCrumb, developerCrumb];
@@ -308,6 +351,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'devices':
return [settingsCrumb];
+ // About sits at the top level of Settings (and hosts the Developer Mode
+ // toggle), so its trail is just Settings.
+ case 'about':
+ return [settingsCrumb];
+
// Data Sync is a top-level leaf in the Account group (#3301).
case 'memory-sync':
return [settingsCrumb];
diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx
index 7c3266bbb..2ae213b1c 100644
--- a/app/src/components/settings/panels/AgentAccessPanel.tsx
+++ b/app/src/components/settings/panels/AgentAccessPanel.tsx
@@ -220,7 +220,7 @@ const AgentAccessPanel = () => {
};
return (
-
+
({
+ useSettingsNavigation: () => ({
+ navigateBack,
+ breadcrumbs: [{ label: 'Settings' }, { label: 'Agents' }],
+ }),
+}));
+
+// Mock SettingsHeader so the test does not depend on i18n resolution of the
+// header title (which varies with the active locale / brand rename). We assert
+// the panel wiring instead: that the shared header renders with a back button
+// and that the level options drive the RPC.
+vi.mock('../components/SettingsHeader', () => ({
+ default: ({ title, onBack }: { title: string; onBack?: () => void }) => (
+
+ {title}
+
+
+ ),
+}));
+
+const callCoreRpc = vi.fn();
+vi.mock('../../../services/coreRpcClient', () => ({
+ callCoreRpc: (arg: { method: string; params: unknown }) => callCoreRpc(arg),
+}));
+
+function settingsResult(level = 2) {
+ return {
+ result: {
+ level,
+ level_label: 'moderate',
+ sync_interval_secs: 3600,
+ heartbeat_enabled: true,
+ subconscious_enabled: true,
+ token_budget_per_cycle: null,
+ estimated_monthly_cost_min_usd: 1,
+ estimated_monthly_cost_max_usd: 5,
+ },
+ };
+}
+
+const costResult = { result: { month: '2026-06', total_cost_usd: 0, total_syncs: 0 } };
+
+/** All buttons except the mocked SettingsHeader back button = the level options. */
+function levelButtons() {
+ return screen
+ .getAllByRole('button')
+ .filter(b => b.getAttribute('data-testid') !== 'settings-header-back');
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ callCoreRpc.mockImplementation((arg: { method: string }) => {
+ switch (arg.method) {
+ case 'openhuman.config_get_activity_level_settings':
+ return Promise.resolve(settingsResult());
+ case 'openhuman.memory_sources_monthly_cost_summary':
+ return Promise.resolve(costResult);
+ case 'openhuman.config_update_activity_level_settings':
+ return Promise.resolve(settingsResult(4));
+ default:
+ return Promise.reject(new Error(`unexpected method ${arg.method}`));
+ }
+ });
+});
+
+describe('', () => {
+ it('renders the shared SettingsHeader and the five level options once loaded', async () => {
+ render();
+
+ // Header only renders after the initial load resolves (loading state has no
+ // header), so this also asserts the panel left the loading state.
+ await screen.findByTestId('settings-header');
+ expect(levelButtons()).toHaveLength(5);
+ });
+
+ it('invokes the back handler from the SettingsHeader', async () => {
+ render();
+ await screen.findByTestId('settings-header');
+
+ fireEvent.click(screen.getByTestId('settings-header-back'));
+ expect(navigateBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('persists a new level selection via the update RPC', async () => {
+ render();
+ await screen.findByTestId('settings-header');
+
+ // The last option is "Always-on" (level 4 -> api key "always_on").
+ const options = levelButtons();
+ fireEvent.click(options[options.length - 1]);
+
+ await waitFor(() => {
+ expect(callCoreRpc).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'openhuman.config_update_activity_level_settings',
+ params: { level: 'always_on' },
+ })
+ );
+ });
+ });
+});
diff --git a/app/src/components/settings/panels/AgentActivityPanel.tsx b/app/src/components/settings/panels/AgentActivityPanel.tsx
index dc8058d9c..e6ce8aba0 100644
--- a/app/src/components/settings/panels/AgentActivityPanel.tsx
+++ b/app/src/components/settings/panels/AgentActivityPanel.tsx
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
+import SettingsHeader from '../components/SettingsHeader';
+import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface ActivityLevelSettings {
level: number;
@@ -47,6 +49,7 @@ function getCostMax(level: number): number {
export default function AgentActivityPanel() {
const { t } = useT();
+ const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [settings, setSettings] = useState(null);
const [monthlyCost, setMonthlyCost] = useState(null);
const [status, setStatus] = useState('loading');
@@ -102,80 +105,87 @@ export default function AgentActivityPanel() {
}
return (
-
-
-
- {t('activityLevel.title')}
-
-
+
+ {/* Header title intentionally reuses the menu-row label key
+ (settings.assistant.backgroundActivity) so the page heading always
+ matches the entry the user clicked — including the "Subconscious"
+ brand rename — instead of the internal "Agent activity level" copy. */}
+
+
+
{t('activityLevel.description')}
-
- {monthlyCost && monthlyCost.total_cost_usd > 0 && (
-
-
- {t('activityLevel.currentMonth').replace(
- '{amount}',
- monthlyCost.total_cost_usd.toFixed(2)
- )}
-
-
- )}
+ {monthlyCost && monthlyCost.total_cost_usd > 0 && (
+
+
+ {t('activityLevel.currentMonth').replace(
+ '{amount}',
+ monthlyCost.total_cost_usd.toFixed(2)
+ )}
+
+
+ )}
-
- {LEVELS.map(({ key, value }) => {
- const isSelected = settings?.level === value;
- const apiKey = key === 'alwaysOn' ? 'always_on' : (key as string);
- const costMin = getCostMin(value);
- const costMax = getCostMax(value);
- return (
-