mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(settings): normalize all panels to the settings UI contract (#3575)
This commit is contained in:
@@ -65,7 +65,7 @@ const LogoutAndClearActions = () => {
|
||||
const showInlineError = error !== null && !showLogoutAndClearModal;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div>
|
||||
<SettingsMenuItem
|
||||
icon={arrowOutIcon}
|
||||
title={t('settings.clearAppData')}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user