From 29ce30b73e09cbfdf92e43cdf32d29c3753f7885 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:22:14 +0530 Subject: [PATCH] fix(settings): unify settings navigation + enforce panel UI contract (#3599) --- .github/workflows/pr-ci.yml | 19 + .../dashboard/CostDashboardPanel.tsx | 111 ++- app/src/components/settings/SettingsHome.tsx | 286 ++++--- .../settings/__tests__/SettingsHome.test.tsx | 97 ++- .../__tests__/settingsRouteRegistry.test.ts | 188 +++++ .../settings/controls/SettingsBadge.tsx | 36 + .../settings/controls/SettingsCheckbox.tsx | 52 ++ .../settings/controls/SettingsEmptyState.tsx | 17 + .../settings/controls/SettingsListItem.tsx | 50 ++ .../settings/controls/SettingsNumberField.tsx | 84 ++ .../settings/controls/SettingsRow.tsx | 67 ++ .../settings/controls/SettingsSection.tsx | 35 + .../settings/controls/SettingsSelect.tsx | 51 ++ .../settings/controls/SettingsStatusLine.tsx | 57 ++ .../settings/controls/SettingsSwitch.tsx | 52 ++ .../settings/controls/SettingsTextArea.tsx | 34 + .../settings/controls/SettingsTextField.tsx | 18 + .../controls/__tests__/controls.test.tsx | 180 ++++ app/src/components/settings/controls/index.ts | 35 + .../useSettingsNavigation.coverage.test.tsx | 235 ++++++ .../__tests__/useSettingsNavigation.test.tsx | 12 +- .../settings/hooks/useSettingsNavigation.ts | 443 +++++----- .../components/settings/panels/AIPanel.tsx | 591 +++++++------ .../components/settings/panels/AboutPanel.tsx | 208 +++-- .../settings/panels/AgentAccessPanel.tsx | 350 ++++---- .../settings/panels/AgentActivityPanel.tsx | 37 +- .../settings/panels/AgentChatPanel.tsx | 134 +-- .../settings/panels/AgentEditorPage.test.tsx | 96 +++ .../settings/panels/AgentEditorPage.tsx | 434 +++++----- .../settings/panels/AgentsPanel.tsx | 83 +- .../settings/panels/AppearancePanel.tsx | 103 +-- .../settings/panels/ApprovalHistoryPanel.tsx | 143 ++-- .../panels/AutocompleteDebugPanel.tsx | 504 +++++------ .../settings/panels/AutocompletePanel.tsx | 268 +++--- .../settings/panels/AutonomyPanel.tsx | 142 ++-- .../settings/panels/BillingPanel.tsx | 27 +- .../settings/panels/CompanionPanel.tsx | 184 +++-- .../settings/panels/ComposioPanel.tsx | 176 ++-- .../settings/panels/ComposioTriagePanel.tsx | 122 ++- .../settings/panels/CronJobsPanel.tsx | 89 +- .../settings/panels/DevWorkflowPanel.tsx | 264 +++--- .../settings/panels/DeveloperOptionsPanel.tsx | 575 +++---------- .../settings/panels/DevicesPanel.tsx | 166 ++-- .../settings/panels/EmbeddingsPanel.tsx | 320 +++---- .../settings/panels/EventLogPanel.tsx | 60 +- .../settings/panels/HeartbeatPanel.tsx | 17 +- .../settings/panels/LedgerUsagePanel.tsx | 17 +- .../settings/panels/LocalModelDebugPanel.tsx | 6 +- .../settings/panels/MascotPanel.tsx | 157 ++-- .../settings/panels/McpServerPanel.test.tsx | 25 + .../settings/panels/McpServerPanel.tsx | 170 ++-- .../settings/panels/MemoryDataPanel.tsx | 16 +- .../settings/panels/MemoryDebugPanel.tsx | 448 +++++----- .../settings/panels/MemorySyncPanel.tsx | 6 +- .../settings/panels/MigrationPanel.tsx | 167 ++-- .../settings/panels/ModelHealthPanel.tsx | 45 +- .../panels/NotificationRoutingPanel.tsx | 223 +++-- .../settings/panels/NotificationsPanel.tsx | 133 ++- .../panels/NotificationsTabbedPanel.tsx | 8 +- .../settings/panels/PermissionsPanel.tsx | 74 +- .../settings/panels/PersonaPanel.tsx | 192 ++--- .../settings/panels/PrivacyPanel.tsx | 278 +++---- .../settings/panels/RecoveryPhrasePanel.tsx | 58 +- .../settings/panels/SandboxSettingsPanel.tsx | 365 ++++---- .../panels/ScreenAwarenessDebugPanel.tsx | 274 +++--- .../panels/ScreenIntelligencePanel.tsx | 184 +++-- .../settings/panels/SearchPanel.tsx | 71 +- .../settings/panels/SecurityPanel.tsx | 151 ++-- .../settings/panels/TaskSourcesPanel.test.tsx | 48 ++ .../settings/panels/TaskSourcesPanel.tsx | 330 ++++---- .../components/settings/panels/TasksPanel.tsx | 4 +- .../settings/panels/TeamInvitesPanel.tsx | 209 +++-- .../settings/panels/TeamManagementPanel.tsx | 479 +++++------ .../settings/panels/TeamMembersPanel.tsx | 386 ++++----- .../components/settings/panels/TeamPanel.tsx | 270 +++--- .../panels/ToolPolicyDiagnosticsPanel.tsx | 11 +- .../settings/panels/ToolsPanel.test.tsx | 27 + .../components/settings/panels/ToolsPanel.tsx | 98 +-- .../settings/panels/VoiceDebugPanel.tsx | 288 +++---- .../components/settings/panels/VoicePanel.tsx | 782 +++++++++--------- .../settings/panels/WalletBalancesPanel.tsx | 72 +- .../settings/panels/WebhooksDebugPanel.tsx | 281 ++++--- .../settings/panels/WorkflowRunnerPanel.tsx | 2 +- .../__tests__/AgentAccessPanel.test.tsx | 20 +- .../panels/__tests__/AgentChatPanel.test.tsx | 191 +++++ .../__tests__/AutocompleteDebugPanel.test.tsx | 566 +++++++++++++ .../__tests__/AutocompletePanel.test.tsx | 138 +++- .../panels/__tests__/AutonomyPanel.test.tsx | 82 ++ .../__tests__/DeveloperOptionsPanel.test.tsx | 4 +- .../panels/__tests__/DevicesPanel.test.tsx | 28 +- .../panels/__tests__/EmbeddingsPanel.test.tsx | 709 ++++++++++++++++ .../__tests__/LocalModelDebugPanel.test.tsx | 184 ++++- .../panels/__tests__/MascotPanel.test.tsx | 76 +- .../__tests__/MemoryDebugPanel.test.tsx | 351 +++++++- .../__tests__/RecoveryPhrasePanel.test.tsx | 4 +- .../__tests__/SandboxSettingsPanel.test.tsx | 98 ++- .../ScreenAwarenessDebugPanel.test.tsx | 312 +++++++ .../ScreenIntelligencePanel.test.tsx | 157 +++- .../panels/__tests__/SecurityPanel.test.tsx | 237 ++++++ .../__tests__/TeamInvitesPanel.test.tsx | 218 ++++- .../__tests__/TeamManagementPanel.test.tsx | 251 ++++++ .../__tests__/TeamMembersPanel.test.tsx | 315 ++++++- .../panels/__tests__/TeamPanel.test.tsx | 289 ++++++- .../panels/__tests__/VoiceDebugPanel.test.tsx | 175 +++- .../panels/__tests__/VoicePanel.test.tsx | 379 +++++++++ .../__tests__/WebhooksDebugPanel.test.tsx | 317 ++++++- .../panels/autocomplete/AppFilterSection.tsx | 26 +- .../autocomplete/CompletionStyleSection.tsx | 70 +- .../panels/billing/AutoRechargeSection.tsx | 139 ++-- .../panels/billing/PayAsYouGoCard.tsx | 55 +- .../panels/billing/SubscriptionPlans.tsx | 55 +- .../__tests__/AutoRechargeSection.test.tsx | 294 +++++++ .../__tests__/SubscriptionPlans.test.tsx | 97 +++ .../panels/devices/PairPhoneModal.tsx | 108 ++- .../local-model/ModelDownloadSection.test.tsx | 119 +++ .../local-model/ModelDownloadSection.tsx | 217 ++--- .../local-model/ModelStatusSection.test.tsx | 145 +++- .../panels/local-model/ModelStatusSection.tsx | 181 ++-- .../settings/search/settingsSearchRegistry.ts | 572 ++----------- .../settings/settingsRouteRegistry.ts | 622 ++++++++++++++ app/src/pages/Intelligence.tsx | 40 +- app/src/pages/Settings.tsx | 45 +- app/src/pages/Webhooks.tsx | 138 ++-- app/src/pages/__tests__/Webhooks.test.tsx | 202 +++++ .../pages/settings/voice/PttSettingsPanel.tsx | 168 ++-- .../specs/autocomplete-flow.spec.ts | 4 +- .../specs/settings-advanced-config.spec.ts | 8 +- .../specs/settings-ai-skills.spec.ts | 2 +- .../settings-feature-preferences.spec.ts | 13 +- .../specs/settings-leaf-workflows.spec.ts | 2 +- 130 files changed, 14504 insertions(+), 7526 deletions(-) create mode 100644 app/src/components/settings/__tests__/settingsRouteRegistry.test.ts create mode 100644 app/src/components/settings/controls/SettingsBadge.tsx create mode 100644 app/src/components/settings/controls/SettingsCheckbox.tsx create mode 100644 app/src/components/settings/controls/SettingsEmptyState.tsx create mode 100644 app/src/components/settings/controls/SettingsListItem.tsx create mode 100644 app/src/components/settings/controls/SettingsNumberField.tsx create mode 100644 app/src/components/settings/controls/SettingsRow.tsx create mode 100644 app/src/components/settings/controls/SettingsSection.tsx create mode 100644 app/src/components/settings/controls/SettingsSelect.tsx create mode 100644 app/src/components/settings/controls/SettingsStatusLine.tsx create mode 100644 app/src/components/settings/controls/SettingsSwitch.tsx create mode 100644 app/src/components/settings/controls/SettingsTextArea.tsx create mode 100644 app/src/components/settings/controls/SettingsTextField.tsx create mode 100644 app/src/components/settings/controls/__tests__/controls.test.tsx create mode 100644 app/src/components/settings/controls/index.ts create mode 100644 app/src/components/settings/hooks/__tests__/useSettingsNavigation.coverage.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/AgentChatPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/AutocompleteDebugPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/EmbeddingsPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/ScreenAwarenessDebugPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/SecurityPanel.test.tsx create mode 100644 app/src/components/settings/panels/__tests__/TeamManagementPanel.test.tsx create mode 100644 app/src/components/settings/panels/billing/__tests__/AutoRechargeSection.test.tsx create mode 100644 app/src/components/settings/panels/billing/__tests__/SubscriptionPlans.test.tsx create mode 100644 app/src/components/settings/settingsRouteRegistry.ts create mode 100644 app/src/pages/__tests__/Webhooks.test.tsx diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 5fa1035fe..29d1ed79a 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -296,6 +296,14 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: "0" + # Coverage-instrumented test binaries are dominated by DWARF debug info, + # which exhausts the runner disk and SIGBUSes the linker. Drop debug info + # entirely for this job — llvm-cov line/region coverage is read from the + # embedded __llvm_covmap/__llvm_covfun sections (filenames + line numbers + # included), NOT from DWARF, so coverage numbers are unaffected. This is + # the maximum-shrink setting; line-tables-only was still too large for the + # instrumented build. + CARGO_PROFILE_DEV_DEBUG: "0" steps: - name: Free disk space run: | @@ -410,7 +418,18 @@ jobs: timeout-minutes: 30 env: CARGO_INCREMENTAL: "0" + # Trim DWARF debug info so test-binary linking doesn't exhaust runner disk + # (SIGBUS). Tests don't need full debug info; backtraces keep file/line. + CARGO_PROFILE_DEV_DEBUG: line-tables-only steps: + - name: Free disk space + run: | + # Reclaim space on the build partition before compiling/linking the + # Rust test binaries (host toolcache is bind-mounted at /__t inside the + # container). Mirrors the rust-core-coverage cleanup. + rm -rf /__t/* || true + df -h /__w || true + - name: Checkout code uses: actions/checkout@v5 with: diff --git a/app/src/components/dashboard/CostDashboardPanel.tsx b/app/src/components/dashboard/CostDashboardPanel.tsx index 2a41b99a6..65631b0f3 100644 --- a/app/src/components/dashboard/CostDashboardPanel.tsx +++ b/app/src/components/dashboard/CostDashboardPanel.tsx @@ -8,7 +8,9 @@ import { } from '../../hooks/useCostDashboard'; import { useT } from '../../lib/i18n/I18nContext'; import SettingsHeader from '../settings/components/SettingsHeader'; +import { SettingsStatusLine } from '../settings/controls'; import { useSettingsNavigation } from '../settings/hooks/useSettingsNavigation'; +import Button from '../ui/Button'; import BudgetSummary from './BudgetSummary'; import CostBarChart from './CostBarChart'; import DashboardSkeleton from './DashboardSkeleton'; @@ -52,14 +54,14 @@ const CostDashboardPanel = () => { />
-

+

{t('settings.costDashboard.subtitle')}

{(lastUpdated !== null || usageLogUpdated !== null) && ( + className="inline-flex items-center gap-1.5 text-[11px] text-neutral-500 dark:text-neutral-400"> { {`${t('settings.costDashboard.updated')} ${relativeTime(Math.max(lastUpdated ?? 0, usageLogUpdated ?? 0), t)}`} )} - + leadingIcon={ + + }> + {t('settings.costDashboard.refresh')} +
{error && ( -
- {error} +
+
)} {usageLogError && ( -
- {usageLogError} +
+
)} {data && !data.enabled && ( @@ -121,12 +120,12 @@ const CostDashboardPanel = () => { />
+ className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
-

+

{t('settings.costDashboard.sevenDayCost')}

- + {t('settings.costDashboard.utcNote')}
@@ -140,12 +139,12 @@ const CostDashboardPanel = () => {
+ className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
-

+

{t('settings.costDashboard.sevenDayTokens')}

- + {t('settings.costDashboard.stackedNote')}
@@ -153,12 +152,12 @@ const CostDashboardPanel = () => {
+ className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
-

+

{t('settings.costDashboard.modelBreakdown')}

-

+

{t('settings.costDashboard.modelBreakdownHint')}

@@ -166,12 +165,12 @@ const CostDashboardPanel = () => {
+ className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
-

+

{t('settings.costDashboard.categoryDistribution')}

-

+

{t('settings.costDashboard.categoryDistributionHint')}

@@ -181,20 +180,20 @@ const CostDashboardPanel = () => { currency={usageLog.currency} /> ) : usageLogLoading ? ( -
+
{t('settings.costDashboard.loading')}
) : null}
+ className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
-

+

{t('settings.costDashboard.usageLog')}

-

+

{usageLog ? t('settings.costDashboard.usageLogHint') .replace('{days}', String(usageLog.days)) @@ -205,7 +204,7 @@ const CostDashboardPanel = () => {

{usageLog && ( - + {t('settings.costDashboard.logTotal') .replace('{requests}', String(usageLog.request_count)) .replace( @@ -218,7 +217,7 @@ const CostDashboardPanel = () => { {usageLog ? ( ) : usageLogLoading ? ( -
+
{t('settings.costDashboard.loading')}
) : null} @@ -226,11 +225,11 @@ const CostDashboardPanel = () => { {!hasAnyCost && (
-
+ className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 px-4 py-6 text-center"> +
{t('settings.costDashboard.noData')}
-
+
{t('settings.costDashboard.noDataHint')}
@@ -247,7 +246,7 @@ const CATEGORY_COLORS = [ 'bg-sage-500', 'bg-amber-500', 'bg-coral-500', - 'bg-stone-500 dark:bg-neutral-400', + 'bg-neutral-500 dark:bg-neutral-400', ]; const CategoryDistribution = ({ @@ -260,7 +259,7 @@ const CategoryDistribution = ({ const { t } = useT(); if (categories.length === 0) { return ( -
+
{t('settings.costDashboard.noCategories')}
); @@ -270,7 +269,7 @@ const CategoryDistribution = ({
+ className="flex h-3 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800"> {categories.map((category, index) => (
(
+ className="rounded-lg border border-neutral-200 dark:border-neutral-800 px-3 py-2">
- + {category.category}
- + {formatCurrency(category.cost_usd, currency)}
-
+
{`${category.percent_of_total.toFixed(1)}%`} {t('settings.costDashboard.categoryMeta') @@ -317,7 +316,7 @@ const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; curr const { t } = useT(); if (records.length === 0) { return ( -
+
{t('settings.costDashboard.noUsageLog')}
); @@ -327,7 +326,7 @@ const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; curr
- + @@ -341,34 +340,34 @@ const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; curr {records.map(record => ( + className="border-b border-neutral-100 transition-colors last:border-0 hover:bg-neutral-50/60 dark:border-neutral-800/60 dark:hover:bg-neutral-800/40"> diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 5108667d0..974192e9c 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -144,6 +144,67 @@ const DataSyncIcon = ( ); +const AiIcon = ( + + + +); + +const AgentsIcon = ( + + + +); + +const FeaturesIcon = ( + + + + +); + +const IntegrationsIcon = ( + + + +); + +const CryptoIcon = ( + + + +); + // --------------------------------------------------------------------------- // Group header (visual separator label above each settings card) // --------------------------------------------------------------------------- @@ -156,8 +217,8 @@ const GroupHeader = ({ label }: { label: string }) => ) : ( - // Empty label → a plain divider (the doc places Developer & Diagnostics and - // About after a divider, not under their own section headers). + // Empty label → a plain divider (Developer & Diagnostics and About sit + // after a divider, not under their own section headers).
); @@ -175,92 +236,131 @@ const SettingsHome = () => { const [searchQuery, setSearchQuery] = useState(''); const isSearching = searchQuery.trim().length > 0; - // --- 👤 Account group --- - const accountGroup: SettingsGroup = { - id: 'account', - label: t('settings.groups.account'), - items: [ - { - // The Account row opens the account hub (recovery phrase, team, - // connections, privacy, sign-out) — named after what it actually holds. - id: 'profile', - title: t('pages.settings.accountSection.title'), - description: t('pages.settings.accountSection.description'), - icon: AccountIcon, - onClick: () => navigateToSettings('account'), - }, - { - id: 'language', - title: t('settings.language'), - description: t('settings.languageDesc'), - icon: LanguageIcon, - rightElement: , - }, - { - id: 'appearance', - title: t('settings.appearance.title'), - description: t('settings.appearance.menuDesc'), - icon: AppearanceIcon, - onClick: () => navigateToSettings('appearance'), - }, - { - id: 'devices', - title: t('settings.account.devices'), - description: t('settings.account.devicesDesc'), - icon: DevicesIcon, - onClick: () => navigateToSettings('devices'), - }, - { - id: 'data-sync', - title: t('settings.dataSync.title'), - description: t('settings.dataSync.menuDesc'), - icon: DataSyncIcon, - onClick: () => navigateToSettings('memory-sync'), - }, - ], - }; + // --- Account group items --- + // Account (hub), Language (inline), Appearance, Devices, Data Sync. + const accountItems: SettingsItem[] = [ + { + id: 'profile', + title: t('pages.settings.accountSection.title'), + description: t('pages.settings.accountSection.description'), + icon: AccountIcon, + onClick: () => navigateToSettings('account'), + }, + { + id: 'language', + title: t('settings.language'), + description: t('settings.languageDesc'), + icon: LanguageIcon, + rightElement: , + }, + { + id: 'appearance', + title: t('settings.appearance.title'), + description: t('settings.appearance.menuDesc'), + icon: AppearanceIcon, + onClick: () => navigateToSettings('appearance'), + }, + { + id: 'devices', + title: t('settings.account.devices'), + description: t('settings.account.devicesDesc'), + icon: DevicesIcon, + onClick: () => navigateToSettings('devices'), + }, + { + id: 'data-sync', + title: t('settings.dataSync.title'), + description: t('settings.dataSync.menuDesc'), + icon: DataSyncIcon, + onClick: () => navigateToSettings('memory-sync'), + }, + ]; - // --- 🤖 Assistant group --- - const assistantGroup: SettingsGroup = { - id: 'assistant', - label: t('settings.groups.assistant'), - items: [ - { - id: 'persona', - title: t('settings.assistant.personality'), - description: t('settings.assistant.personalityDesc'), - icon: PersonalityIcon, - onClick: () => navigateToSettings('persona'), - }, - { - id: 'mascot', - title: t('settings.assistant.faceMascot'), - description: t('settings.assistant.faceMascotDesc'), - icon: MascotIcon, - onClick: () => navigateToSettings('mascot'), - }, - ], - }; + // --- Assistant group items --- + // AI & Models, Agents, Personality, Face & Mascot. + const assistantItems: SettingsItem[] = [ + { + id: 'ai', + title: t('pages.settings.aiSection.title'), + description: t('pages.settings.aiSection.description'), + icon: AiIcon, + onClick: () => navigateToSettings('ai'), + }, + { + id: 'agents-settings', + title: t('settings.agentsSection.title'), + description: t('settings.agentsSection.menuDesc'), + icon: AgentsIcon, + onClick: () => navigateToSettings('agents-settings'), + }, + { + id: 'persona', + title: t('settings.assistant.personality'), + description: t('settings.assistant.personalityDesc'), + icon: PersonalityIcon, + onClick: () => navigateToSettings('persona'), + }, + { + id: 'mascot', + title: t('settings.assistant.faceMascot'), + description: t('settings.assistant.faceMascotDesc'), + icon: MascotIcon, + onClick: () => navigateToSettings('mascot'), + }, + ]; - // Privacy is reached from the Account hub (Settings → Account → Privacy); - // it is intentionally not duplicated as a top-level row here. + // --- Features & Integrations group items --- + // Features section, Composio/Integrations section. + const featuresIntegrationsItems: SettingsItem[] = [ + { + id: 'features', + title: t('pages.settings.featuresSection.title'), + description: t('pages.settings.featuresSection.description'), + icon: FeaturesIcon, + onClick: () => navigateToSettings('features'), + }, + { + id: 'composio', + title: t('pages.settings.composioSection.title'), + description: t('pages.settings.composioSection.description'), + icon: IntegrationsIcon, + onClick: () => navigateToSettings('composio'), + }, + ]; - // --- 🔔 Notifications group --- - const notificationsGroup: SettingsGroup = { - id: 'notifications', - label: t('settings.groups.notifications'), - items: [ - { - id: 'notifications-hub', - title: t('settings.notifications.menuTitle'), - description: t('settings.notifications.menuDesc'), - icon: NotificationsIcon, - onClick: () => navigateToSettings('notifications-hub'), - }, - ], - }; + // --- Notifications group --- + const notificationsItems: SettingsItem[] = [ + { + id: 'notifications-hub', + title: t('settings.notifications.menuTitle'), + description: t('settings.notifications.menuDesc'), + icon: NotificationsIcon, + onClick: () => navigateToSettings('notifications-hub'), + }, + ]; - // --- ℹ️ About group (always visible; no section header — just a divider) --- + // --- Crypto group --- + const cryptoItems: SettingsItem[] = [ + { + id: 'crypto', + title: t('settings.cryptoSection.title'), + description: t('settings.cryptoSection.menuDesc'), + icon: CryptoIcon, + onClick: () => navigateToSettings('crypto'), + }, + ]; + + // The layman-facing merged card combines: Account, Assistant, + // Features & Integrations, Notifications, Crypto rows in one flat card. + const laymanItems: SettingsItem[] = [ + ...accountItems, + ...assistantItems, + ...featuresIntegrationsItems, + ...notificationsItems, + ...cryptoItems, + ]; + + // --- About group (always visible; no section header — just a divider) --- const aboutGroup: SettingsGroup = { id: 'about', label: '', @@ -275,16 +375,9 @@ const SettingsHome = () => { ], }; - // --- Always-visible groups --- - 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). - // --- Developer & Diagnostics (gated) --- - // The Developer & Diagnostics entry is hidden when developer mode is off. - // About is always accessible — that's where the toggle lives (chicken-and-egg). - // No section header — it sits after a divider, then About (per the doc). + // Hidden when developer mode is off. + // About is always accessible — that's where the toggle lives. const developerGroup: SettingsGroup | null = developerMode ? { id: 'developer', @@ -301,10 +394,6 @@ const SettingsHome = () => { } : null; - // The layman groups (Account / Assistant / Privacy / Notifications) render as a - // single flat card with no section subheadings. Developer & Diagnostics (when - // on) and About sit after a divider, each in their own card. - const laymanItems: SettingsItem[] = visibleGroups.flatMap(group => group.items); const trailingGroups: SettingsGroup[] = [...(developerGroup ? [developerGroup] : []), aboutGroup]; return ( @@ -319,7 +408,8 @@ const SettingsHome = () => { settings menu is hidden to avoid a confusing double list. */} {isSearching ? null : (
- {/* Merged layman card — no Account/Assistant/… subheadings. */} + {/* Merged layman card — Account / Assistant / Features & Integrations / + Notifications / Crypto in one flat card. No sub-section headers. */}
diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index 10328d440..08657e94f 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -175,11 +175,12 @@ describe('SettingsHome', () => { }); describe('items no longer on the home screen', () => { - it('no longer renders Agents / Crypto section pages on the home screen', () => { - // These moved into the Developer & Diagnostics sub-tree (Agents & Autonomy). + it('renders Agents and Crypto section hubs on the home screen', () => { + // Pass A surfaced agents-settings and crypto on the home screen as part of + // the merged layman card (assistant group + crypto group). renderSettingsHome(); - expect(screen.queryByTestId('settings-nav-agents-settings')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-crypto')).not.toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-agents-settings')).toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-crypto')).toBeInTheDocument(); }); it('no longer renders Alerts / stand-alone Notifications on the home screen', () => { @@ -196,10 +197,13 @@ describe('SettingsHome', () => { expect(screen.queryByText('Log out')).not.toBeInTheDocument(); }); - it('no longer renders Features / AI Configuration / Rewards / Restart Tour on the home screen', () => { + it('renders Features and AI section hubs on home; Rewards and Restart Tour remain absent', () => { + // Pass A moved Features and AI onto the home screen as merged-card entries. + // Rewards and Restart Tour are not home items (Rewards lives in the avatar + // menu; Restart Tour is in Developer & Diagnostics only). renderSettingsHome(); - expect(screen.queryByText('Features')).not.toBeInTheDocument(); - expect(screen.queryByText('AI Configuration')).not.toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-features')).toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-ai')).toBeInTheDocument(); expect(screen.queryByText('Rewards')).not.toBeInTheDocument(); expect(screen.queryByText('Restart Tour')).not.toBeInTheDocument(); }); @@ -318,6 +322,85 @@ describe('SettingsHome', () => { }); }); + // --------------------------------------------------------------------------- + // Pass A — newly-surfaced section entries + // --------------------------------------------------------------------------- + + describe('Pass A section hubs', () => { + it('renders the 5 newly-surfaced section entry hubs in the merged card', () => { + // Pass A merged AI, Agents, Features, Integrations (composio), and Crypto + // directly onto the home screen as section-hub entries. All 5 must render + // as navigable items in the settings-group-main card. + renderSettingsHome(); + expect(screen.getByTestId('settings-nav-ai')).toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-agents-settings')).toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-features')).toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-composio')).toBeInTheDocument(); + expect(screen.getByTestId('settings-nav-crypto')).toBeInTheDocument(); + }); + + it('clicking ai hub navigates to ai', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + await user.click(screen.getByTestId('settings-nav-ai')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('ai'); + }); + + it('clicking agents-settings hub navigates to agents-settings', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + await user.click(screen.getByTestId('settings-nav-agents-settings')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('agents-settings'); + }); + + it('clicking features hub navigates to features', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + await user.click(screen.getByTestId('settings-nav-features')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('features'); + }); + + it('clicking composio hub navigates to composio', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + await user.click(screen.getByTestId('settings-nav-composio')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('composio'); + }); + + it('clicking crypto hub navigates to crypto', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + await user.click(screen.getByTestId('settings-nav-crypto')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('crypto'); + }); + }); + + describe('navigation — account group items (lines 261, 268, 275)', () => { + it('navigates to devices when Devices is clicked (line 268)', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByTestId('settings-nav-devices')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('devices'); + }); + + it('navigates to memory-sync when Data Sync is clicked (line 275)', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByTestId('settings-nav-data-sync')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('memory-sync'); + }); + + it('navigates to appearance when Appearance is clicked (line 261)', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByTestId('settings-nav-appearance')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('appearance'); + }); + }); + // Clear App Data flow moved to LogoutAndClearActions (rendered on Account // page) — see LogoutAndClearActions.test.tsx. }); diff --git a/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts b/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts new file mode 100644 index 000000000..2ec4e304d --- /dev/null +++ b/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for settingsRouteRegistry helpers. + * + * Covers the four exported helper functions and edge-cases that ensure the + * registry stays internally consistent (no duplicate ids, every entry has a + * reachable route, etc.). + */ +import { describe, expect, it } from 'vitest'; + +import { + entriesForSection, + entryRoute, + findEntryById, + findEntryByRoute, + SETTINGS_ROUTE_REGISTRY, +} from '../settingsRouteRegistry'; + +// --------------------------------------------------------------------------- +// entryRoute +// --------------------------------------------------------------------------- + +describe('entryRoute', () => { + it('returns the explicit route when set', () => { + // 'notifications' entry has route: 'notifications' set explicitly. + const entry = findEntryById('notifications'); + expect(entry).toBeDefined(); + expect(entryRoute(entry!)).toBe('notifications'); + }); + + it('falls back to the id when no explicit route is set', () => { + const entry = findEntryById('persona'); + expect(entry).toBeDefined(); + expect(entryRoute(entry!)).toBe('persona'); + }); + + it('returns the overridden route for build-info (→ about)', () => { + const entry = findEntryById('build-info'); + expect(entry).toBeDefined(); + expect(entryRoute(entry!)).toBe('about'); + }); +}); + +// --------------------------------------------------------------------------- +// findEntryById +// --------------------------------------------------------------------------- + +describe('findEntryById', () => { + it('returns the entry for a known id', () => { + const entry = findEntryById('about'); + expect(entry).toBeDefined(); + expect(entry!.id).toBe('about'); + }); + + it('returns undefined for an unknown id', () => { + expect(findEntryById('does-not-exist')).toBeUndefined(); + }); + + it('returns the correct section for agents-settings', () => { + const entry = findEntryById('agents-settings'); + expect(entry).toBeDefined(); + expect(entry!.section).toBe('home'); + }); + + it('returns the correct section for a developer-only entry', () => { + const entry = findEntryById('cron-jobs'); + expect(entry).toBeDefined(); + expect(entry!.section).toBe('developer'); + expect(entry!.devOnly).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// findEntryByRoute +// --------------------------------------------------------------------------- + +describe('findEntryByRoute', () => { + it('returns an entry for a known route', () => { + const entry = findEntryByRoute('persona'); + expect(entry).toBeDefined(); + expect(entry!.id).toBe('persona'); + }); + + it('returns undefined for an unknown route', () => { + expect(findEntryByRoute('messaging')).toBeUndefined(); + }); + + it('returns the build-info entry when looking up the "about" route alias', () => { + // build-info has route: 'about', so findEntryByRoute('about') returns + // whichever comes first — likely the canonical 'about' entry itself. + // The important assertion: the route is reachable. + const entry = findEntryByRoute('about'); + expect(entry).toBeDefined(); + }); + + it('does not match partial/substring routes — no collision between "ai" and "ai-debug"', () => { + const entry = findEntryByRoute('ai'); + expect(entry).toBeDefined(); + expect(entry!.id).toBe('ai'); + // There should be no entry with id 'ai-debug' in the registry. + expect(findEntryByRoute('ai-debug')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// entriesForSection +// --------------------------------------------------------------------------- + +describe('entriesForSection', () => { + it('returns only entries belonging to the requested section', () => { + const cryptoEntries = entriesForSection('crypto'); + expect(cryptoEntries.length).toBeGreaterThan(0); + cryptoEntries.forEach(e => expect(e.section).toBe('crypto')); + }); + + it('excludes hidden deep-links', () => { + // 'approval-history' is section: 'agents' + hiddenDeepLink: true. + const agentEntries = entriesForSection('agents'); + const ids = agentEntries.map(e => e.id); + expect(ids).not.toContain('approval-history'); + }); + + it('returns the composio section entries', () => { + const composioEntries = entriesForSection('composio'); + const ids = composioEntries.map(e => e.id); + expect(ids).toContain('task-sources'); + expect(ids).toContain('composio-routing'); + expect(ids).toContain('webhooks-triggers'); + }); + + it('returns multiple developer entries', () => { + const devEntries = entriesForSection('developer'); + expect(devEntries.length).toBeGreaterThan(5); + devEntries.forEach(e => { + expect(e.section).toBe('developer'); + expect(e.hiddenDeepLink).not.toBe(true); + }); + }); + + it('returns home section entries (section hubs)', () => { + const homeEntries = entriesForSection('home'); + const ids = homeEntries.map(e => e.id); + // Non-hidden home entries include the main section hubs. + expect(ids).toContain('account'); + expect(ids).toContain('ai'); + expect(ids).toContain('agents-settings'); + expect(ids).toContain('features'); + expect(ids).toContain('composio'); + expect(ids).toContain('notifications-hub'); + expect(ids).toContain('crypto'); + expect(ids).toContain('about'); + // billing is hiddenDeepLink so should be excluded. + expect(ids).not.toContain('billing'); + }); + + it('returns empty array for a section that has no non-hidden entries', () => { + // All home entries are reachable so this just validates the helper signature. + const result = entriesForSection('account'); + expect(Array.isArray(result)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Registry-level integrity checks +// --------------------------------------------------------------------------- + +describe('SETTINGS_ROUTE_REGISTRY integrity', () => { + it('has no duplicate ids', () => { + const ids = SETTINGS_ROUTE_REGISTRY.map(e => e.id); + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); + }); + + it('every entry has a non-empty id and titleKey', () => { + SETTINGS_ROUTE_REGISTRY.forEach(entry => { + expect(entry.id.length).toBeGreaterThan(0); + expect(entry.titleKey.length).toBeGreaterThan(0); + }); + }); + + it('contains the 5 newly-surfaced section hubs on home', () => { + const homeIds = entriesForSection('home').map(e => e.id); + expect(homeIds).toContain('ai'); + expect(homeIds).toContain('agents-settings'); + expect(homeIds).toContain('features'); + expect(homeIds).toContain('composio'); + expect(homeIds).toContain('crypto'); + }); +}); diff --git a/app/src/components/settings/controls/SettingsBadge.tsx b/app/src/components/settings/controls/SettingsBadge.tsx new file mode 100644 index 000000000..6adf56560 --- /dev/null +++ b/app/src/components/settings/controls/SettingsBadge.tsx @@ -0,0 +1,36 @@ +import { type ReactNode } from 'react'; + +export type SettingsBadgeVariant = 'neutral' | 'primary' | 'success' | 'warning' | 'danger'; + +export interface SettingsBadgeProps { + variant: SettingsBadgeVariant; + children: ReactNode; + className?: string; +} + +const VARIANTS: Record = { + neutral: + 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 border-neutral-200 dark:border-neutral-700', + primary: + 'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-500/30', + success: + 'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30', + warning: + 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30', + danger: + 'bg-coral-50 dark:bg-coral-500/10 text-coral-600 dark:text-coral-300 border-coral-200 dark:border-coral-500/30', +}; + +const SettingsBadge = ({ variant, children, className }: SettingsBadgeProps) => { + const classes = [ + 'inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium leading-none border', + VARIANTS[variant], + className ?? '', + ] + .filter(Boolean) + .join(' '); + + return {children}; +}; + +export default SettingsBadge; diff --git a/app/src/components/settings/controls/SettingsCheckbox.tsx b/app/src/components/settings/controls/SettingsCheckbox.tsx new file mode 100644 index 000000000..998626e15 --- /dev/null +++ b/app/src/components/settings/controls/SettingsCheckbox.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react'; + +export interface SettingsCheckboxProps { + id: string; + checked: boolean; + onCheckedChange: (next: boolean) => void; + disabled?: boolean; + indeterminate?: boolean; + 'data-testid'?: string; +} + +function SettingsCheckbox({ + id, + checked, + onCheckedChange, + disabled = false, + indeterminate = false, + 'data-testid': testId, +}: SettingsCheckboxProps) { + const innerRef = useRef(null); + + useEffect(() => { + if (innerRef.current) { + innerRef.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + const classes = + 'h-4 w-4 rounded-sm cursor-pointer border border-neutral-300 dark:border-neutral-600 ' + + 'bg-white dark:bg-neutral-800 accent-primary-500 ' + + 'transition-colors duration-150 ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 ' + + 'focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900 ' + + 'disabled:opacity-50 disabled:cursor-not-allowed'; + + return ( + onCheckedChange(e.target.checked)} + className={classes} + /> + ); +} + +SettingsCheckbox.displayName = 'SettingsCheckbox'; + +export default SettingsCheckbox; diff --git a/app/src/components/settings/controls/SettingsEmptyState.tsx b/app/src/components/settings/controls/SettingsEmptyState.tsx new file mode 100644 index 000000000..f3c40bf86 --- /dev/null +++ b/app/src/components/settings/controls/SettingsEmptyState.tsx @@ -0,0 +1,17 @@ +export interface SettingsEmptyStateProps { + label: string; + className?: string; +} + +const SettingsEmptyState = ({ label, className }: SettingsEmptyStateProps) => { + const classes = [ + 'px-4 py-4 text-xs text-neutral-400 dark:text-neutral-500 italic', + className ?? '', + ] + .filter(Boolean) + .join(' '); + + return

{label}

; +}; + +export default SettingsEmptyState; diff --git a/app/src/components/settings/controls/SettingsListItem.tsx b/app/src/components/settings/controls/SettingsListItem.tsx new file mode 100644 index 000000000..a015bf49f --- /dev/null +++ b/app/src/components/settings/controls/SettingsListItem.tsx @@ -0,0 +1,50 @@ +import { type ReactNode } from 'react'; + +import Button from '../../ui/Button'; + +export interface SettingsListItemProps { + label: string; + badge?: ReactNode; + onRemove?: () => void; + removeLabel: string; + mono?: boolean; + 'data-testid'?: string; +} + +const SettingsListItem = ({ + label, + badge, + onRemove, + removeLabel, + mono = false, + 'data-testid': testId, +}: SettingsListItemProps) => { + const labelClass = [ + 'text-xs text-neutral-800 dark:text-neutral-100 truncate', + mono ? 'font-mono' : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
  • +
    + {label} + {badge && {badge}} +
    + {onRemove && ( + + )} +
  • + ); +}; + +export default SettingsListItem; diff --git a/app/src/components/settings/controls/SettingsNumberField.tsx b/app/src/components/settings/controls/SettingsNumberField.tsx new file mode 100644 index 000000000..2d7e0f12a --- /dev/null +++ b/app/src/components/settings/controls/SettingsNumberField.tsx @@ -0,0 +1,84 @@ +import Input from '../../ui/Input'; + +export interface SettingsNumberFieldProps { + id: string; + value: string; + onChange: (v: string) => void; + onCommit: () => void; + /** Optional unit suffix shown beside the field (e.g. "seconds"). */ + unit?: string; + /** Optional bounds — when both are set, a "{min}–{max}" range hint renders. */ + min?: number; + max?: number; + /** Step granularity; default 1. Pass a fraction for decimal fields. */ + step?: number; + disabled?: boolean; + invalid?: boolean; + 'aria-label': string; + 'data-testid'?: string; +} + +const SettingsNumberField = ({ + id, + value, + onChange, + onCommit, + unit, + min, + max, + step = 1, + disabled = false, + invalid = false, + 'aria-label': ariaLabel, + 'data-testid': testId, +}: SettingsNumberFieldProps) => { + const containerClass = ['flex items-center gap-2', disabled ? 'opacity-50' : ''] + .filter(Boolean) + .join(' '); + + const hasRange = min !== undefined && max !== undefined; + const hasMeta = Boolean(unit) || hasRange; + + return ( +
    + onChange(e.target.value)} + onBlur={onCommit} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + onCommit(); + } + }} + /> + {hasMeta && ( +
    + {unit && ( + + {unit} + + )} + {hasRange && ( + + {min}–{max} + + )} +
    + )} +
    + ); +}; + +export default SettingsNumberField; diff --git a/app/src/components/settings/controls/SettingsRow.tsx b/app/src/components/settings/controls/SettingsRow.tsx new file mode 100644 index 000000000..2b7c2ff94 --- /dev/null +++ b/app/src/components/settings/controls/SettingsRow.tsx @@ -0,0 +1,67 @@ +import { type ReactNode } from 'react'; + +export interface SettingsRowProps { + htmlFor?: string; + label?: string; + description?: string; + control: ReactNode; + stacked?: boolean; + disabled?: boolean; + className?: string; + 'data-testid'?: string; +} + +const SettingsRow = ({ + htmlFor, + label, + description, + control, + stacked = false, + disabled = false, + className, + 'data-testid': testId, +}: SettingsRowProps) => { + const containerBase = stacked + ? 'flex flex-col gap-2 px-4 py-3' + : 'flex items-center justify-between gap-4 px-4 py-3'; + const disabledClass = disabled ? 'opacity-50 pointer-events-none' : ''; + const containerClass = [containerBase, disabledClass, className ?? ''].filter(Boolean).join(' '); + + const labelEl = + label && htmlFor ? ( + + ) : label ? ( + {label} + ) : null; + + const labelBlock = + labelEl || description ? ( +
    + {labelEl} + {description && ( +

    + {description} +

    + )} +
    + ) : null; + + const controlWrapper = stacked ? ( +
    {control}
    + ) : ( +
    {control}
    + ); + + return ( +
    + {labelBlock} + {controlWrapper} +
    + ); +}; + +export default SettingsRow; diff --git a/app/src/components/settings/controls/SettingsSection.tsx b/app/src/components/settings/controls/SettingsSection.tsx new file mode 100644 index 000000000..4a96c6d13 --- /dev/null +++ b/app/src/components/settings/controls/SettingsSection.tsx @@ -0,0 +1,35 @@ +import { type ReactNode } from 'react'; + +export interface SettingsSectionProps { + title?: string; + description?: string; + children: ReactNode; + className?: string; +} + +const SettingsSection = ({ title, description, children, className }: SettingsSectionProps) => { + const base = + 'rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden'; + + return ( +
    + {title && ( +
    + {/* Real heading (h3, one level below SettingsHeader's h2) for a11y + and so getByRole('heading') keeps resolving section titles. */} +

    + {title} +

    + {description && ( +

    + {description} +

    + )} +
    + )} +
    {children}
    +
    + ); +}; + +export default SettingsSection; diff --git a/app/src/components/settings/controls/SettingsSelect.tsx b/app/src/components/settings/controls/SettingsSelect.tsx new file mode 100644 index 000000000..a78efbcb2 --- /dev/null +++ b/app/src/components/settings/controls/SettingsSelect.tsx @@ -0,0 +1,51 @@ +import { forwardRef, type SelectHTMLAttributes } from 'react'; + +export type SettingsSelectSize = 'sm' | 'md'; + +export interface SettingsSelectProps extends SelectHTMLAttributes { + inputSize?: SettingsSelectSize; + 'data-testid'?: string; +} + +// Inline chevron SVG as a background-image data-URI (stroke #a3a3a3 = neutral-400) +const CHEVRON_BG = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' stroke='%23a3a3a3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E\")"; + +const SettingsSelect = forwardRef( + ({ inputSize = 'md', className, 'data-testid': testId, style, ...rest }, ref) => { + const sizeClass = inputSize === 'sm' ? 'h-8 pl-2.5' : 'h-9 pl-3'; + + const classes = [ + 'block border border-neutral-300 dark:border-neutral-700', + 'bg-white dark:bg-neutral-900', + 'text-neutral-900 dark:text-neutral-100', + 'text-sm rounded-lg', + 'focus:outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20', + 'transition-colors duration-150', + 'cursor-pointer appearance-none bg-no-repeat pr-7', + sizeClass, + 'disabled:opacity-50 disabled:cursor-not-allowed', + className ?? '', + ] + .filter(Boolean) + .join(' '); + + return ( +
    {t('settings.costDashboard.when')} {t('settings.costDashboard.category')} {t('settings.costDashboard.model')}
    -
    +
    {formatDateTime(record.timestamp)}
    - + {record.category} -
    +
    {record.model}
    -
    +
    {record.provider ?? t('settings.costDashboard.unknownProvider')}
    {formatTokens(record.input_tokens)} {formatTokens(record.output_tokens)} - + {formatCurrency(record.cost_usd, currency)} - + {shortId(record.session_id)}