fix(settings): normalize all panels to the settings UI contract (#3575)

This commit is contained in:
Cyrus Gray
2026-06-10 17:52:08 +05:30
committed by GitHub
parent 94c5d22931
commit 4bc8e62e91
26 changed files with 458 additions and 317 deletions
@@ -65,7 +65,7 @@ const LogoutAndClearActions = () => {
const showInlineError = error !== null && !showLogoutAndClearModal;
return (
<div className="mt-6">
<div>
<SettingsMenuItem
icon={arrowOutIcon}
title={t('settings.clearAppData')}
+3 -31
View File
@@ -100,17 +100,6 @@ const MascotIcon = (
</svg>
);
const PrivacyIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
);
const NotificationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -253,20 +242,8 @@ const SettingsHome = () => {
],
};
// --- 🔒 Privacy group (Security + Approvals moved to Developer & Diagnostics) ---
const privacySecurityGroup: SettingsGroup = {
id: 'privacy-security',
label: t('settings.privacySecurity.privacy'),
items: [
{
id: 'privacy',
title: t('settings.privacySecurity.privacy'),
description: t('settings.privacySecurity.privacyDesc'),
icon: PrivacyIcon,
onClick: () => navigateToSettings('privacy'),
},
],
};
// Privacy is reached from the Account hub (Settings → Account → Privacy);
// it is intentionally not duplicated as a top-level row here.
// --- 🔔 Notifications group ---
const notificationsGroup: SettingsGroup = {
@@ -299,12 +276,7 @@ const SettingsHome = () => {
};
// --- Always-visible groups ---
const visibleGroups: SettingsGroup[] = [
accountGroup,
assistantGroup,
privacySecurityGroup,
notificationsGroup,
];
const visibleGroups: SettingsGroup[] = [accountGroup, assistantGroup, notificationsGroup];
// Billing / Rewards / Wallet are NOT in Settings — per the design doc they
// live in the avatar menu (monetisation out of the settings tree).
@@ -39,14 +39,15 @@ const SettingsSectionPage = ({ title, description, items, footer }: SettingsSect
breadcrumbs={breadcrumbs}
/>
<div>
{/* 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. */}
<div className="px-4 pb-5">
{description && (
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 px-5 pb-3">
{description}
</p>
<p className="mb-3 px-1 text-xs text-stone-500 dark:text-neutral-400">{description}</p>
)}
<div>
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{items.map((item, index) => (
<SettingsMenuItem
key={item.id}
@@ -61,7 +62,16 @@ const SettingsSectionPage = ({ title, description, items, footer }: SettingsSect
))}
</div>
{footer}
{footer && (
<>
{/* Divider + card, mirroring how SettingsHome separates its
trailing groups (e.g. the destructive logout/clear card). */}
<div className="mx-1 mt-6 mb-2 border-t border-stone-200 dark:border-neutral-800" />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{footer}
</div>
</>
)}
</div>
</div>
);
@@ -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();
@@ -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 (
<div className={`px-5 pt-5 pb-3 ${className}`}>
<div className="flex items-center">
{/* Back button */}
{showBackButton && onBack && (
<button
onClick={onBack}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2"
aria-label={t('common.back')}>
<svg
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
)}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center min-w-0">
{/* Back button */}
{showBackButton && onBack && (
<button
onClick={onBack}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2"
aria-label={t('common.back')}>
<svg
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
)}
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav aria-label={t('common.breadcrumb')} className="mr-1">
<ol className="flex items-center gap-1">
{breadcrumbs.map((crumb, i) => (
<li key={i} className="flex items-center gap-1">
{crumb.onClick ? (
<button
onClick={crumb.onClick}
className="text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
{crumb.label}
</button>
) : (
<span className="text-xs text-stone-400 dark:text-neutral-500">
{crumb.label}
</span>
)}
<svg
aria-hidden="true"
className="w-3 h-3 text-stone-300 dark:text-neutral-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</li>
))}
</ol>
</nav>
)}
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav aria-label={t('common.breadcrumb')} className="mr-1">
<ol className="flex items-center gap-1">
{breadcrumbs.map((crumb, i) => (
<li key={i} className="flex items-center gap-1">
{crumb.onClick ? (
<button
onClick={crumb.onClick}
className="text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
{crumb.label}
</button>
) : (
<span className="text-xs text-stone-400 dark:text-neutral-500">
{crumb.label}
</span>
)}
<svg
aria-hidden="true"
className="w-3 h-3 text-stone-300 dark:text-neutral-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</li>
))}
</ol>
</nav>
)}
{/* Title */}
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{title ?? t('nav.settings')}
</h2>
{/* Title */}
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{title ?? t('nav.settings')}
</h2>
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
</div>
);
@@ -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];
@@ -220,7 +220,7 @@ const AgentAccessPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.agentAccess.title')}
showBackButton
@@ -0,0 +1,110 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AgentActivityPanel from './AgentActivityPanel';
const navigateBack = vi.fn();
vi.mock('../hooks/useSettingsNavigation', () => ({
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 }) => (
<div data-testid="settings-header">
<span data-testid="settings-header-title">{title}</span>
<button type="button" data-testid="settings-header-back" onClick={onBack}>
back
</button>
</div>
),
}));
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('<AgentActivityPanel />', () => {
it('renders the shared SettingsHeader and the five level options once loaded', async () => {
render(<AgentActivityPanel />);
// 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(<AgentActivityPanel />);
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(<AgentActivityPanel />);
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' },
})
);
});
});
});
@@ -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<ActivityLevelSettings | null>(null);
const [monthlyCost, setMonthlyCost] = useState<MonthlyCostSummary | null>(null);
const [status, setStatus] = useState<Status>('loading');
@@ -102,80 +105,87 @@ export default function AgentActivityPanel() {
}
return (
<div className="flex flex-col gap-4 p-4">
<div>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('activityLevel.title')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400 mt-1">
<div className="z-10 relative">
{/* 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. */}
<SettingsHeader
title={t('settings.assistant.backgroundActivity')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="flex flex-col gap-4 p-4">
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('activityLevel.description')}
</p>
</div>
{monthlyCost && monthlyCost.total_cost_usd > 0 && (
<div className="px-3 py-2 rounded-md bg-stone-100 dark:bg-neutral-800 text-sm">
<span className="font-medium text-stone-700 dark:text-neutral-300">
{t('activityLevel.currentMonth').replace(
'{amount}',
monthlyCost.total_cost_usd.toFixed(2)
)}
</span>
</div>
)}
{monthlyCost && monthlyCost.total_cost_usd > 0 && (
<div className="px-3 py-2 rounded-md bg-stone-100 dark:bg-neutral-800 text-sm">
<span className="font-medium text-stone-700 dark:text-neutral-300">
{t('activityLevel.currentMonth').replace(
'{amount}',
monthlyCost.total_cost_usd.toFixed(2)
)}
</span>
</div>
)}
<div className="flex flex-col gap-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 (
<button
key={key}
onClick={() => handleLevelChange(apiKey)}
disabled={status === 'saving'}
className={`w-full text-left px-4 py-3 rounded-lg border transition-colors ${
isSelected
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:border-stone-300 dark:hover:border-neutral-700'
} ${status === 'saving' ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t(`activityLevel.${key as LevelKey}`)}
</span>
{value === 2 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-stone-200 dark:bg-neutral-700 text-stone-600 dark:text-neutral-400">
{t('activityLevel.default')}
<div className="flex flex-col gap-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 (
<button
key={key}
onClick={() => handleLevelChange(apiKey)}
disabled={status === 'saving'}
className={`w-full text-left px-4 py-3 rounded-lg border transition-colors ${
isSelected
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:border-stone-300 dark:hover:border-neutral-700'
} ${status === 'saving' ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t(`activityLevel.${key as LevelKey}`)}
</span>
)}
{value === 2 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-stone-200 dark:bg-neutral-700 text-stone-600 dark:text-neutral-400">
{t('activityLevel.default')}
</span>
)}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t(`activityLevel.${key as LevelKey}Desc`)}
</p>
</div>
<div className="text-xs font-mono text-stone-500 dark:text-neutral-400 shrink-0 ml-4">
{costMin === 0 && costMax === 0
? t('activityLevel.costFree')
: t('activityLevel.costRange')
.replace('{min}', String(costMin))
.replace('{max}', String(costMax))}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t(`activityLevel.${key as LevelKey}Desc`)}
</p>
</div>
<div className="text-xs font-mono text-stone-500 dark:text-neutral-400 shrink-0 ml-4">
{costMin === 0 && costMax === 0
? t('activityLevel.costFree')
: t('activityLevel.costRange')
.replace('{min}', String(costMin))
.replace('{max}', String(costMax))}
</div>
</div>
</button>
);
})}
</div>
</button>
);
})}
</div>
<div role="status" aria-live="polite" aria-atomic="true" className="text-xs min-h-[1rem]">
{status === 'saving' && (
<span className="text-stone-500">{t('autonomy.statusSaving')}</span>
)}
{status === 'saved' && (
<span className="text-sage-700 dark:text-sage-400">{t('activityLevel.saved')}</span>
)}
{error && <span className="text-coral-600">{error}</span>}
<div role="status" aria-live="polite" aria-atomic="true" className="text-xs min-h-[1rem]">
{status === 'saving' && (
<span className="text-stone-500">{t('autonomy.statusSaving')}</span>
)}
{status === 'saved' && (
<span className="text-sage-700 dark:text-sage-400">{t('activityLevel.saved')}</span>
)}
{error && <span className="text-coral-600">{error}</span>}
</div>
</div>
</div>
);
@@ -82,7 +82,7 @@ const ApprovalHistoryPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.approvalHistory.title')}
showBackButton
@@ -12,14 +12,6 @@ vi.mock('../hooks/useSettingsNavigation', () => ({
}),
}));
vi.mock('../components/PageBackButton', () => ({
default: ({ label, onClick }: { label: string; onClick: () => void }) => (
<button type="button" data-testid="page-back-button" onClick={onClick}>
{label}
</button>
),
}));
const openUrlMock = vi.fn();
vi.mock('../../../utils/openUrl', () => ({ openUrl: (url: string) => openUrlMock(url) }));
@@ -83,7 +75,9 @@ describe('<BillingPanel />', () => {
render(<Panel />);
await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByTestId('page-back-button'));
// The SettingsHeader back button (aria-label "Back") and the inline
// "Back to settings" button both route through navigateBack.
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: 'Back to settings' }));
expect(navigateBack).toHaveBeenCalledTimes(2);
});
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { BILLING_DASHBOARD_URL } from '../../../utils/links';
import { openUrl } from '../../../utils/openUrl';
import PageBackButton from '../components/PageBackButton';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = createDebug('openhuman:billing-panel');
@@ -40,75 +40,60 @@ const BillingPanel = () => {
}, []);
return (
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl">
<PageBackButton
label={t('common.back')}
onClick={navigateBack}
trailingContent={
breadcrumbs.length > 0 ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-stone-500 dark:text-neutral-400">
{breadcrumbs.map((crumb, index) => (
<button
key={`${crumb.label}-${index}`}
type="button"
onClick={crumb.onClick}
className="rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1 font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{crumb.label}
</button>
))}
</div>
) : null
}
/>
<div className="z-10 relative">
<SettingsHeader
title={t('nav.avatarMenu.billing')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="mt-6 rounded-3xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft">
<div className="max-w-xl space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-neutral-400">
{t('settings.billing.movedToWeb')}
</p>
<h1 className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.billing.openDashboard')}
</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-neutral-300">
{t('settings.billing.movedToWebDesc')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="inline-flex items-center rounded-full bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600">
{t('settings.billing.openDashboard')}
</button>
<button
type="button"
onClick={navigateBack}
className="inline-flex items-center rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-neutral-200 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{t('settings.billing.backToSettings')}
</button>
</div>
{status === 'opening' && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.openingBrowser')}
</p>
)}
{status === 'idle' && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.browserNotOpen')}
</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('settings.billing.browserOpenFailed')}
</p>
)}
<div className="p-4">
<div className="max-w-xl space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-neutral-400">
{t('settings.billing.movedToWeb')}
</p>
<h1 className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.billing.openDashboard')}
</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-neutral-300">
{t('settings.billing.movedToWebDesc')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="inline-flex items-center rounded-full bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600">
{t('settings.billing.openDashboard')}
</button>
<button
type="button"
onClick={navigateBack}
className="inline-flex items-center rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-neutral-200 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{t('settings.billing.backToSettings')}
</button>
</div>
{status === 'opening' && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.openingBrowser')}
</p>
)}
{status === 'idle' && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.browserNotOpen')}
</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('settings.billing.browserOpenFailed')}
</p>
)}
</div>
</div>
</div>
@@ -75,7 +75,7 @@ const ComposioTriagePanel = () => {
if (loading) {
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('composio.triageTitle')}
showBackButton
@@ -92,7 +92,7 @@ const ComposioTriagePanel = () => {
}
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('composio.triageTitle')}
showBackButton
@@ -277,19 +277,19 @@ const DevicesPanel = () => {
return (
<div className="z-10 relative">
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<SettingsHeader
title={t('devices.title')}
showBackButton={breadcrumbs.length > 0}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<button
onClick={handleOpenPairModal}
className="text-xs font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors px-3 py-1.5 rounded-lg flex-shrink-0">
{t('devices.pairIphone')}
</button>
</div>
<SettingsHeader
title={t('devices.title')}
showBackButton={breadcrumbs.length > 0}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
<button
onClick={handleOpenPairModal}
className="text-xs font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors px-3 py-1.5 rounded-lg flex-shrink-0">
{t('devices.pairIphone')}
</button>
}
/>
<div className="px-5 pb-3 flex items-center gap-2">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200 border border-amber-200 dark:border-amber-800/60">
@@ -187,7 +187,7 @@ const PermissionsPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.permissions.title')}
showBackButton
@@ -97,7 +97,7 @@ const PrivacyPanel = () => {
};
return (
<div data-testid="settings-privacy-panel">
<div data-testid="settings-privacy-panel" className="z-10 relative">
<SettingsHeader
title={t('privacy.title')}
showBackButton={true}
@@ -201,7 +201,7 @@ const RecoveryPhrasePanel = () => {
const canSave = mode === 'generate' ? confirmed : isImportComplete;
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('mnemonic.title')}
showBackButton
@@ -126,7 +126,7 @@ const SandboxSettingsPanel = () => {
if (!isTauri()) {
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="z-10 relative mx-auto max-w-2xl px-4 py-8">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
@@ -141,7 +141,7 @@ const SandboxSettingsPanel = () => {
if (isLoading) {
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="z-10 relative mx-auto max-w-2xl px-4 py-8">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
@@ -155,7 +155,7 @@ const SandboxSettingsPanel = () => {
}
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="z-10 relative mx-auto max-w-2xl px-4 py-8">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
@@ -64,7 +64,7 @@ const SecurityPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('keyring.settings.title')}
onBack={navigateBack}
@@ -116,7 +116,7 @@ const TeamInvitesPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('invites.title')}
showBackButton={true}
@@ -90,7 +90,7 @@ const TeamManagementPanel = () => {
if (!teamEntry) {
return (
<div className="">
<div className="z-10 relative">
<SettingsHeader
title={t('team.management')}
showBackButton={true}
@@ -106,7 +106,7 @@ const TeamManagementPanel = () => {
if (!isAdmin) {
return (
<div className="">
<div className="z-10 relative">
<SettingsHeader
title={t('team.management')}
showBackButton={true}
@@ -123,7 +123,7 @@ const TeamManagementPanel = () => {
const { team } = teamEntry;
return (
<div className="">
<div className="z-10 relative">
<SettingsHeader
title={t('team.manageTitle').replace('{name}', team.name)}
showBackButton={true}
@@ -129,7 +129,7 @@ const TeamMembersPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('team.members')}
showBackButton={true}
@@ -253,7 +253,7 @@ const TeamPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.account.team')}
showBackButton={true}
@@ -115,7 +115,7 @@ const VoiceDebugPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('voice.debugTitle')}
showBackButton={true}
@@ -446,35 +446,35 @@ const WalletBalancesPanel = () => {
};
return (
<div>
<div className="flex items-center justify-between pr-4">
<SettingsHeader
title={t('walletBalances.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<button
type="button"
onClick={() => void loadBalances()}
disabled={loading}
aria-label={t('walletBalances.refresh')}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 disabled:opacity-50 transition-colors">
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{t('walletBalances.refresh')}
</button>
</div>
<div className="z-10 relative">
<SettingsHeader
title={t('walletBalances.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
<button
type="button"
onClick={() => void loadBalances()}
disabled={loading}
aria-label={t('walletBalances.refresh')}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 disabled:opacity-50 transition-colors">
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{t('walletBalances.refresh')}
</button>
}
/>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 mx-4 mb-4 overflow-hidden">
{renderContent()}
+9 -4
View File
@@ -578,8 +578,7 @@ const Settings = () => {
/>
<Route path="team/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
<Route path="team/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
{/* BillingPanel intentionally uses its own wider layout. */}
<Route path="billing" element={<BillingPanel />} />
<Route path="billing" element={wrapSettingsPage(<BillingPanel />)} />
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
<Route path="security" element={wrapSettingsPage(<SecurityPanel />)} />
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
@@ -660,8 +659,14 @@ const Settings = () => {
path="analysis-views"
element={wrapSettingsPage(<AnalysisViewsPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="intelligence" element={<Intelligence />} />
<Route path="webhooks-triggers" element={<Webhooks />} />
<Route
path="intelligence"
element={wrapSettingsPage(<Intelligence />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="webhooks-triggers"
element={wrapSettingsPage(<Webhooks />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
<Route path="composio-routing" element={wrapSettingsPage(<ComposioPanel />)} />
{/* Mobile devices */}