fix(settings): unify settings navigation + enforce panel UI contract (#3599)

This commit is contained in:
Cyrus Gray
2026-06-11 11:52:14 -07:00
committed by GitHub
parent 20695fae4d
commit 29ce30b73e
130 changed files with 14504 additions and 7526 deletions
+19
View File
@@ -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:
@@ -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 = () => {
/>
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-stone-500 dark:text-neutral-400 max-w-prose">
<p className="text-xs text-neutral-500 dark:text-neutral-400 max-w-prose">
{t('settings.costDashboard.subtitle')}
</p>
<div className="flex items-center gap-2 shrink-0">
{(lastUpdated !== null || usageLogUpdated !== null) && (
<span
data-testid="cost-dashboard-updated"
className="inline-flex items-center gap-1.5 text-[11px] text-stone-500 dark:text-neutral-400">
className="inline-flex items-center gap-1.5 text-[11px] text-neutral-500 dark:text-neutral-400">
<span
aria-hidden
className={`inline-block h-1.5 w-1.5 rounded-full ${isFetching || usageLogFetching ? 'bg-ocean-500 animate-pulse' : 'bg-sage-500'}`}
@@ -67,35 +69,32 @@ const CostDashboardPanel = () => {
{`${t('settings.costDashboard.updated')} ${relativeTime(Math.max(lastUpdated ?? 0, usageLogUpdated ?? 0), t)}`}
</span>
)}
<button
<Button
type="button"
variant="secondary"
size="xs"
data-testid="cost-dashboard-refresh"
onClick={() => void Promise.all([refetch(), refetchUsageLog()])}
disabled={isFetching || usageLogFetching}
aria-label={t('settings.costDashboard.refresh')}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 dark:border-neutral-800 px-2 py-1 text-[11px] text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-50 transition-colors">
<RefreshIcon
className={`h-3.5 w-3.5 ${isFetching || usageLogFetching ? 'animate-spin' : ''}`}
/>
<span>{t('settings.costDashboard.refresh')}</span>
</button>
leadingIcon={
<RefreshIcon
className={`h-3.5 w-3.5 ${isFetching || usageLogFetching ? 'animate-spin' : ''}`}
/>
}>
{t('settings.costDashboard.refresh')}
</Button>
</div>
</div>
{error && (
<div
role="alert"
className="rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300"
data-testid="cost-dashboard-error">
{error}
<div role="alert" data-testid="cost-dashboard-error">
<SettingsStatusLine saving={false} error={error} savingLabel="" />
</div>
)}
{usageLogError && (
<div
role="alert"
className="rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300"
data-testid="cost-dashboard-usage-error">
{usageLogError}
<div role="alert" data-testid="cost-dashboard-usage-error">
<SettingsStatusLine saving={false} error={usageLogError} savingLabel="" />
</div>
)}
{data && !data.enabled && (
@@ -121,12 +120,12 @@ const CostDashboardPanel = () => {
/>
<section
data-testid="cost-dashboard-cost-chart"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-2 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.costDashboard.sevenDayCost')}
</h2>
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.utcNote')}
</span>
</header>
@@ -140,12 +139,12 @@ const CostDashboardPanel = () => {
</section>
<section
data-testid="cost-dashboard-token-chart"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-2 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.costDashboard.sevenDayTokens')}
</h2>
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.stackedNote')}
</span>
</header>
@@ -153,12 +152,12 @@ const CostDashboardPanel = () => {
</section>
<section
data-testid="cost-dashboard-model-table"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-2">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.costDashboard.modelBreakdown')}
</h2>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.modelBreakdownHint')}
</p>
</header>
@@ -166,12 +165,12 @@ const CostDashboardPanel = () => {
</section>
<section
data-testid="cost-dashboard-category-distribution"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-3">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.costDashboard.categoryDistribution')}
</h2>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.categoryDistributionHint')}
</p>
</header>
@@ -181,20 +180,20 @@ const CostDashboardPanel = () => {
currency={usageLog.currency}
/>
) : usageLogLoading ? (
<div className="text-xs text-stone-500 dark:text-neutral-400">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.loading')}
</div>
) : null}
</section>
<section
data-testid="cost-dashboard-usage-log"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-3 flex items-baseline justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.costDashboard.usageLog')}
</h2>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{usageLog
? t('settings.costDashboard.usageLogHint')
.replace('{days}', String(usageLog.days))
@@ -205,7 +204,7 @@ const CostDashboardPanel = () => {
</p>
</div>
{usageLog && (
<span className="shrink-0 text-[11px] text-stone-500 dark:text-neutral-400">
<span className="shrink-0 text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.logTotal')
.replace('{requests}', String(usageLog.request_count))
.replace(
@@ -218,7 +217,7 @@ const CostDashboardPanel = () => {
{usageLog ? (
<UsageLogTable records={usageLog.records} currency={usageLog.currency} />
) : usageLogLoading ? (
<div className="text-xs text-stone-500 dark:text-neutral-400">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.costDashboard.loading')}
</div>
) : null}
@@ -226,11 +225,11 @@ const CostDashboardPanel = () => {
{!hasAnyCost && (
<div
data-testid="cost-dashboard-empty"
className="rounded-xl border border-dashed border-stone-300 dark:border-neutral-700 px-4 py-6 text-center">
<div className="text-sm font-medium text-stone-700 dark:text-neutral-200">
className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 px-4 py-6 text-center">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
{t('settings.costDashboard.noData')}
</div>
<div className="text-[11px] text-stone-500 dark:text-neutral-400 mt-1">
<div className="text-[11px] text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.costDashboard.noDataHint')}
</div>
</div>
@@ -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 (
<div className="text-xs text-stone-500 dark:text-neutral-400 italic py-2">
<div className="text-xs text-neutral-500 dark:text-neutral-400 italic py-2">
{t('settings.costDashboard.noCategories')}
</div>
);
@@ -270,7 +269,7 @@ const CategoryDistribution = ({
<div className="space-y-3">
<div
aria-hidden
className="flex h-3 w-full overflow-hidden rounded-full bg-stone-200 dark:bg-neutral-800">
className="flex h-3 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
{categories.map((category, index) => (
<div
key={category.category}
@@ -283,22 +282,22 @@ const CategoryDistribution = ({
{categories.map((category, index) => (
<div
key={category.category}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2">
className="rounded-lg border border-neutral-200 dark:border-neutral-800 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className={`h-2 w-2 rounded-full ${CATEGORY_COLORS[index % CATEGORY_COLORS.length]}`}
/>
<span className="truncate text-xs font-medium text-stone-800 dark:text-neutral-100">
<span className="truncate text-xs font-medium text-neutral-800 dark:text-neutral-100">
{category.category}
</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums text-stone-900 dark:text-neutral-50">
<span className="shrink-0 text-xs font-semibold tabular-nums text-neutral-900 dark:text-neutral-50">
{formatCurrency(category.cost_usd, currency)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-stone-500 dark:text-neutral-400">
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-neutral-500 dark:text-neutral-400">
<span>{`${category.percent_of_total.toFixed(1)}%`}</span>
<span>
{t('settings.costDashboard.categoryMeta')
@@ -317,7 +316,7 @@ const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; curr
const { t } = useT();
if (records.length === 0) {
return (
<div className="text-xs text-stone-500 dark:text-neutral-400 italic py-2">
<div className="text-xs text-neutral-500 dark:text-neutral-400 italic py-2">
{t('settings.costDashboard.noUsageLog')}
</div>
);
@@ -327,7 +326,7 @@ const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; curr
<div className="overflow-x-auto -mx-1">
<table className="w-full min-w-[760px] text-xs">
<thead>
<tr className="border-b border-stone-200 text-left text-[10px] uppercase tracking-wide text-stone-500 dark:border-neutral-800 dark:text-neutral-400">
<tr className="border-b border-neutral-200 text-left text-[10px] uppercase tracking-wide text-neutral-500 dark:border-neutral-800 dark:text-neutral-400">
<Th>{t('settings.costDashboard.when')}</Th>
<Th>{t('settings.costDashboard.category')}</Th>
<Th>{t('settings.costDashboard.model')}</Th>
@@ -341,34 +340,34 @@ const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; curr
{records.map(record => (
<tr
key={record.id}
className="border-b border-stone-100 transition-colors last:border-0 hover:bg-stone-50/60 dark:border-neutral-800/60 dark:hover:bg-neutral-800/40">
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">
<Td>
<div className="tabular-nums text-stone-700 dark:text-neutral-200">
<div className="tabular-nums text-neutral-700 dark:text-neutral-200">
{formatDateTime(record.timestamp)}
</div>
</Td>
<Td>
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-[10px] font-medium text-stone-700 ring-1 ring-inset ring-stone-200 dark:bg-neutral-800 dark:text-neutral-200 dark:ring-neutral-700">
<span className="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-700 ring-1 ring-inset ring-neutral-200 dark:bg-neutral-800 dark:text-neutral-200 dark:ring-neutral-700">
{record.category}
</span>
</Td>
<Td>
<div className="max-w-[16rem] truncate font-medium text-stone-800 dark:text-neutral-100">
<div className="max-w-[16rem] truncate font-medium text-neutral-800 dark:text-neutral-100">
{record.model}
</div>
<div className="text-[10px] text-stone-500 dark:text-neutral-400">
<div className="text-[10px] text-neutral-500 dark:text-neutral-400">
{record.provider ?? t('settings.costDashboard.unknownProvider')}
</div>
</Td>
<Td align="right">{formatTokens(record.input_tokens)}</Td>
<Td align="right">{formatTokens(record.output_tokens)}</Td>
<Td align="right">
<span className="font-semibold tabular-nums text-stone-900 dark:text-neutral-50">
<span className="font-semibold tabular-nums text-neutral-900 dark:text-neutral-50">
{formatCurrency(record.cost_usd, currency)}
</span>
</Td>
<Td>
<span className="font-mono text-[10px] text-stone-500 dark:text-neutral-400">
<span className="font-mono text-[10px] text-neutral-500 dark:text-neutral-400">
{shortId(record.session_id)}
</span>
</Td>
+188 -98
View File
@@ -144,6 +144,67 @@ const DataSyncIcon = (
</svg>
);
const AiIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
);
const AgentsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
);
const FeaturesIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
const IntegrationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
);
const CryptoIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V6m0 10c-1.11 0-2.08-.402-2.599-1M12 16v2m0-12a9 9 0 100 18 9 9 0 000-18z"
/>
</svg>
);
// ---------------------------------------------------------------------------
// Group header (visual separator label above each settings card)
// ---------------------------------------------------------------------------
@@ -156,8 +217,8 @@ const GroupHeader = ({ label }: { label: string }) =>
</span>
</div>
) : (
// 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).
<div className="mx-1 mt-6 mb-2 border-t border-stone-200 dark:border-neutral-800" />
);
@@ -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: <LanguageSelect ariaLabel={t('settings.language')} />,
},
{
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: <LanguageSelect ariaLabel={t('settings.language')} />,
},
{
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 : (
<div className="px-4 pt-3 pb-5">
{/* Merged layman card — no Account/Assistant/… subheadings. */}
{/* Merged layman card — Account / Assistant / Features & Integrations /
Notifications / Crypto in one flat card. No sub-section headers. */}
<div
data-testid="settings-group-main"
className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
@@ -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.
});
@@ -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');
});
});
@@ -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<SettingsBadgeVariant, string> = {
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 <span className={classes}>{children}</span>;
};
export default SettingsBadge;
@@ -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<HTMLInputElement>(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 (
<input
ref={innerRef}
id={id}
type="checkbox"
checked={checked}
disabled={disabled}
data-testid={testId}
onChange={e => onCheckedChange(e.target.checked)}
className={classes}
/>
);
}
SettingsCheckbox.displayName = 'SettingsCheckbox';
export default SettingsCheckbox;
@@ -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 <p className={classes}>{label}</p>;
};
export default SettingsEmptyState;
@@ -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 (
<li className="flex items-center justify-between gap-3 px-4 py-2.5" data-testid={testId}>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={labelClass}>{label}</span>
{badge && <span className="flex-shrink-0">{badge}</span>}
</div>
{onRemove && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onRemove}
aria-label={removeLabel}
className="text-coral-500 dark:text-coral-400 hover:text-coral-600 dark:hover:text-coral-300 flex-shrink-0">
{removeLabel}
</Button>
)}
</li>
);
};
export default SettingsListItem;
@@ -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 (
<div className={containerClass} data-testid={testId}>
<Input
id={id}
type="number"
inputMode="numeric"
inputSize="sm"
className="w-24"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
invalid={invalid}
aria-label={ariaLabel}
onChange={e => onChange(e.target.value)}
onBlur={onCommit}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
onCommit();
}
}}
/>
{hasMeta && (
<div className="flex flex-col leading-tight">
{unit && (
<span className="text-xs font-medium text-neutral-600 dark:text-neutral-400">
{unit}
</span>
)}
{hasRange && (
<span className="text-[11px] text-neutral-400 dark:text-neutral-500">
{min}&#x2013;{max}
</span>
)}
</div>
)}
</div>
);
};
export default SettingsNumberField;
@@ -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
htmlFor={htmlFor}
className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{label}
</label>
) : label ? (
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">{label}</span>
) : null;
const labelBlock =
labelEl || description ? (
<div className={stacked ? undefined : 'flex-1 min-w-0'}>
{labelEl}
{description && (
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
{description}
</p>
)}
</div>
) : null;
const controlWrapper = stacked ? (
<div className="w-full">{control}</div>
) : (
<div className="flex-shrink-0">{control}</div>
);
return (
<div className={containerClass} data-testid={testId}>
{labelBlock}
{controlWrapper}
</div>
);
};
export default SettingsRow;
@@ -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 (
<div className={[base, className ?? ''].filter(Boolean).join(' ')}>
{title && (
<div className="px-4 pt-4 pb-0">
{/* Real heading (h3, one level below SettingsHeader's h2) for a11y
and so getByRole('heading') keeps resolving section titles. */}
<h3 className="text-xs font-semibold tracking-wide text-neutral-500 dark:text-neutral-400">
{title}
</h3>
{description && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
{description}
</p>
)}
</div>
)}
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">{children}</div>
</div>
);
};
export default SettingsSection;
@@ -0,0 +1,51 @@
import { forwardRef, type SelectHTMLAttributes } from 'react';
export type SettingsSelectSize = 'sm' | 'md';
export interface SettingsSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
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<HTMLSelectElement, SettingsSelectProps>(
({ 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 (
<select
ref={ref}
data-testid={testId}
className={classes}
style={{
backgroundImage: CHEVRON_BG,
backgroundPosition: 'right 0.5rem center',
backgroundSize: '12px 12px',
...style,
}}
{...rest}
/>
);
}
);
SettingsSelect.displayName = 'SettingsSelect';
export default SettingsSelect;
@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
export interface SettingsStatusLineProps {
saving: boolean;
savedNote?: string | null;
error?: string | null;
savingLabel: string;
className?: string;
}
const SettingsStatusLine = ({
saving,
savedNote,
error,
savingLabel,
className,
}: SettingsStatusLineProps) => {
const wrapperClass = ['min-h-[1.25rem] text-xs', className ?? ''].filter(Boolean).join(' ');
let content: ReactNode = null;
if (error) {
content = <span className="text-coral-600 dark:text-coral-300">{error}</span>;
} else if (saving) {
content = (
<span className="text-neutral-500 dark:text-neutral-400 animate-pulse">{savingLabel}</span>
);
} else if (savedNote) {
content = (
<span className="text-sage-700 dark:text-sage-300 flex items-center gap-1">
<svg
aria-hidden="true"
className="inline-block w-3 h-3 flex-shrink-0"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{savedNote}
</span>
);
}
return (
<div className={wrapperClass} aria-live="polite" aria-atomic="true">
{content}
</div>
);
};
export default SettingsStatusLine;
@@ -0,0 +1,52 @@
export interface SettingsSwitchProps {
id: string;
checked: boolean;
onCheckedChange: (next: boolean) => void;
disabled?: boolean;
'aria-label'?: string;
'data-testid'?: string;
}
const SettingsSwitch = ({
id,
checked,
onCheckedChange,
disabled = false,
'aria-label': ariaLabel,
'data-testid': testId,
}: SettingsSwitchProps) => {
const trackBase =
'relative inline-flex h-[22px] w-[38px] flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ' +
'transition-colors duration-200 ease-in-out ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 ' +
'focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900 ' +
'motion-reduce:transition-none ' +
'disabled:cursor-not-allowed disabled:opacity-50';
const trackColor = checked ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-600';
const thumbBase =
'pointer-events-none inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow-sm ring-0 ' +
'transition-transform duration-200 ease-in-out motion-reduce:transition-none';
const thumbPosition = checked ? 'translate-x-[16px]' : 'translate-x-0';
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
data-testid={testId}
onClick={() => {
if (!disabled) onCheckedChange(!checked);
}}
className={[trackBase, trackColor].join(' ')}>
<span className={[thumbBase, thumbPosition].join(' ')} />
</button>
);
};
export default SettingsSwitch;
@@ -0,0 +1,34 @@
import { forwardRef, type TextareaHTMLAttributes } from 'react';
export interface SettingsTextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
invalid?: boolean;
rows?: number;
}
const SettingsTextArea = forwardRef<HTMLTextAreaElement, SettingsTextAreaProps>(
({ invalid = false, className, ...rest }, ref) => {
const ringClass = invalid
? 'border-coral-400 focus:border-coral-500 focus:ring-coral-500/20'
: 'border-neutral-300 dark:border-neutral-700 focus:border-primary-500 focus:ring-primary-500/20';
const classes = [
'block w-full rounded-lg border',
'bg-white dark:bg-neutral-900',
'text-neutral-900 dark:text-neutral-100',
'placeholder-neutral-400 dark:placeholder-neutral-500',
'text-sm px-3 py-2',
'focus:outline-none focus:ring-2',
'transition-colors duration-150',
'disabled:opacity-50',
ringClass,
className ?? '',
]
.filter(Boolean)
.join(' ');
return <textarea ref={ref} className={classes} {...rest} />;
}
);
SettingsTextArea.displayName = 'SettingsTextArea';
export default SettingsTextArea;
@@ -0,0 +1,18 @@
import { forwardRef } from 'react';
import Input, { type InputProps } from '../../ui/Input';
export interface SettingsTextFieldProps extends InputProps {
mono?: boolean;
}
const SettingsTextField = forwardRef<HTMLInputElement, SettingsTextFieldProps>(
({ mono, className, ...rest }, ref) => {
const monoClass = mono ? 'font-mono' : '';
const merged = [monoClass, className ?? ''].filter(Boolean).join(' ');
return <Input ref={ref} className={merged || undefined} {...rest} />;
}
);
SettingsTextField.displayName = 'SettingsTextField';
export default SettingsTextField;
@@ -0,0 +1,180 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import SettingsListItem from '../SettingsListItem';
import SettingsNumberField from '../SettingsNumberField';
import SettingsSection from '../SettingsSection';
import SettingsSwitch from '../SettingsSwitch';
// ──────────────────────────────────────────────────────────────────────────────
// SettingsSwitch
// ──────────────────────────────────────────────────────────────────────────────
describe('SettingsSwitch', () => {
it('renders with role="switch" and correct aria-checked', () => {
render(<SettingsSwitch id="test-switch" checked={false} onCheckedChange={() => undefined} />);
const btn = screen.getByRole('switch');
expect(btn).toBeInTheDocument();
expect(btn).toHaveAttribute('aria-checked', 'false');
});
it('reflects checked=true in aria-checked', () => {
render(<SettingsSwitch id="test-switch" checked={true} onCheckedChange={() => undefined} />);
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
});
it('calls onCheckedChange with toggled value on click', () => {
const handler = vi.fn();
render(<SettingsSwitch id="test-switch" checked={false} onCheckedChange={handler} />);
fireEvent.click(screen.getByRole('switch'));
expect(handler).toHaveBeenCalledWith(true);
});
it('does not call onCheckedChange when disabled', () => {
const handler = vi.fn();
render(<SettingsSwitch id="test-switch" checked={false} onCheckedChange={handler} disabled />);
fireEvent.click(screen.getByRole('switch'));
expect(handler).not.toHaveBeenCalled();
});
it('accepts aria-label and data-testid', () => {
render(
<SettingsSwitch
id="test-switch"
checked={false}
onCheckedChange={() => undefined}
aria-label="Enable feature"
data-testid="my-switch"
/>
);
expect(screen.getByTestId('my-switch')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Enable feature' })).toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// SettingsListItem
// ──────────────────────────────────────────────────────────────────────────────
describe('SettingsListItem', () => {
it('renders the label', () => {
render(
<ul>
<SettingsListItem label="example-tool" removeLabel="Remove" />
</ul>
);
expect(screen.getByText('example-tool')).toBeInTheDocument();
});
it('renders badge when provided', () => {
render(
<ul>
<SettingsListItem
label="example-tool"
badge={<span data-testid="badge">Read-write</span>}
removeLabel="Remove"
/>
</ul>
);
expect(screen.getByTestId('badge')).toBeInTheDocument();
});
it('calls onRemove when remove button is clicked', () => {
const onRemove = vi.fn();
render(
<ul>
<SettingsListItem label="example-tool" onRemove={onRemove} removeLabel="Remove" />
</ul>
);
fireEvent.click(screen.getByText('Remove'));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('does not render a remove button when onRemove is absent', () => {
render(
<ul>
<SettingsListItem label="example-tool" removeLabel="Remove" />
</ul>
);
expect(screen.queryByText('Remove')).not.toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// SettingsSection
// ──────────────────────────────────────────────────────────────────────────────
describe('SettingsSection', () => {
it('renders title and children', () => {
render(
<SettingsSection title="Access controls">
<div>child content</div>
</SettingsSection>
);
expect(screen.getByText('Access controls')).toBeInTheDocument();
expect(screen.getByText('child content')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(
<SettingsSection title="Access controls" description="Configure access here.">
<div />
</SettingsSection>
);
expect(screen.getByText('Configure access here.')).toBeInTheDocument();
});
it('renders children without a title', () => {
render(
<SettingsSection>
<div data-testid="child" />
</SettingsSection>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// SettingsNumberField
// ──────────────────────────────────────────────────────────────────────────────
describe('SettingsNumberField', () => {
const defaultProps = {
id: 'timeout-field',
value: '120',
onChange: vi.fn(),
onCommit: vi.fn(),
unit: 'seconds',
min: 1,
max: 3600,
'aria-label': 'Action timeout',
};
it('renders the input and unit label', () => {
render(<SettingsNumberField {...defaultProps} />);
expect(screen.getByLabelText('Action timeout')).toBeInTheDocument();
expect(screen.getByText('seconds')).toBeInTheDocument();
});
it('renders the minmax range hint', () => {
render(<SettingsNumberField {...defaultProps} />);
expect(screen.getByText('13600')).toBeInTheDocument();
});
it('calls onCommit on blur', () => {
const onCommit = vi.fn();
render(<SettingsNumberField {...defaultProps} onCommit={onCommit} />);
fireEvent.blur(screen.getByLabelText('Action timeout'));
expect(onCommit).toHaveBeenCalledTimes(1);
});
it('calls onCommit on Enter keydown', () => {
const onCommit = vi.fn();
render(<SettingsNumberField {...defaultProps} onCommit={onCommit} />);
fireEvent.keyDown(screen.getByLabelText('Action timeout'), { key: 'Enter' });
expect(onCommit).toHaveBeenCalledTimes(1);
});
it('calls onChange when value changes', () => {
const onChange = vi.fn();
render(<SettingsNumberField {...defaultProps} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('Action timeout'), { target: { value: '300' } });
expect(onChange).toHaveBeenCalledWith('300');
});
});
@@ -0,0 +1,35 @@
export { default as SettingsBadge } from './SettingsBadge';
export type { SettingsBadgeProps, SettingsBadgeVariant } from './SettingsBadge';
export { default as SettingsCheckbox } from './SettingsCheckbox';
export type { SettingsCheckboxProps } from './SettingsCheckbox';
export { default as SettingsEmptyState } from './SettingsEmptyState';
export type { SettingsEmptyStateProps } from './SettingsEmptyState';
export { default as SettingsListItem } from './SettingsListItem';
export type { SettingsListItemProps } from './SettingsListItem';
export { default as SettingsNumberField } from './SettingsNumberField';
export type { SettingsNumberFieldProps } from './SettingsNumberField';
export { default as SettingsRow } from './SettingsRow';
export type { SettingsRowProps } from './SettingsRow';
export { default as SettingsSection } from './SettingsSection';
export type { SettingsSectionProps } from './SettingsSection';
export { default as SettingsSelect } from './SettingsSelect';
export type { SettingsSelectProps, SettingsSelectSize } from './SettingsSelect';
export { default as SettingsStatusLine } from './SettingsStatusLine';
export type { SettingsStatusLineProps } from './SettingsStatusLine';
export { default as SettingsSwitch } from './SettingsSwitch';
export type { SettingsSwitchProps } from './SettingsSwitch';
export { default as SettingsTextArea } from './SettingsTextArea';
export type { SettingsTextAreaProps } from './SettingsTextArea';
export { default as SettingsTextField } from './SettingsTextField';
export type { SettingsTextFieldProps } from './SettingsTextField';
@@ -0,0 +1,235 @@
/**
* Coverage-focused tests for useSettingsNavigation.
*
* These tests supplement the existing breadcrumb tests with:
* - Exact-match route resolution (no substring collisions).
* - Canonical breadcrumbs for a representative route in each section.
* - Composio section page and leaf breadcrumbs.
* - Verification that the removed 'messaging' route returns 'home'.
*/
import { screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import { useSettingsNavigation } from '../useSettingsNavigation';
/** Renders breadcrumb labels and the currentRoute for assertion. */
const NavigationProbe = () => {
const { breadcrumbs, currentRoute } = useSettingsNavigation();
return (
<div>
<div data-testid="breadcrumbs">{breadcrumbs.map(b => b.label).join(' > ')}</div>
<div data-testid="current-route">{currentRoute}</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Section: home (no breadcrumbs at root)
// ---------------------------------------------------------------------------
describe('home route', () => {
test('/settings resolves to home with empty breadcrumbs', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('home');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('');
});
});
// ---------------------------------------------------------------------------
// Section: account — representative leaf
// ---------------------------------------------------------------------------
describe('account section', () => {
test('privacy returns Settings > Account breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/privacy'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Account');
});
test('security returns Settings > Account breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/security'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Account');
});
test('team returns Settings > Account breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/team'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Account');
});
});
// ---------------------------------------------------------------------------
// Section: ai — representative leaf
// ---------------------------------------------------------------------------
describe('ai section', () => {
test('llm returns Settings > AI & Models breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/llm'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > AI & Models');
});
test('voice returns Settings > AI & Models breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/voice'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > AI & Models');
});
// Exact-match check: /settings/ai must not substring-match into a longer route.
test('/settings/ai resolves to section page "ai" (not a deeper route)', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/ai'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('ai');
// ai is a home-level section hub — breadcrumb is just Settings.
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
});
// ---------------------------------------------------------------------------
// Section: agents — representative leaf
// ---------------------------------------------------------------------------
describe('agents section', () => {
test('autonomy returns Settings > Agents breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/autonomy'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Agents');
});
test('agent-access returns Settings > Agents breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/agent-access'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Agents');
});
test('agents-settings section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/agents-settings'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('agents-settings');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
});
// ---------------------------------------------------------------------------
// Section: features — representative leaf
// ---------------------------------------------------------------------------
describe('features section', () => {
test('tools returns Settings > Features breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/tools'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Features');
});
test('companion returns Settings > Features breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/companion'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Features');
});
});
// ---------------------------------------------------------------------------
// Section: composio — section page + leaf
// ---------------------------------------------------------------------------
describe('composio section', () => {
test('composio section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/composio'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('composio');
// composio is a home-level section hub — breadcrumb is just Settings.
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('task-sources returns Settings > Integrations breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/task-sources'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Integrations');
});
test('webhooks-triggers returns Settings > Integrations breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/webhooks-triggers'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Integrations');
});
});
// ---------------------------------------------------------------------------
// Section: notifications — section page + leaf
// ---------------------------------------------------------------------------
describe('notifications section', () => {
test('notifications-hub section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/notifications-hub'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('notifications-hub');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('notifications leaf returns Settings > Notifications', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/notifications'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Notifications');
});
});
// ---------------------------------------------------------------------------
// Section: crypto — section page + leaf
// ---------------------------------------------------------------------------
describe('crypto section', () => {
test('crypto section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/crypto'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('crypto');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('recovery-phrase returns Settings > Crypto', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/recovery-phrase'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
});
test('wallet-balances returns Settings > Crypto', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/wallet-balances'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
});
});
// ---------------------------------------------------------------------------
// Section: developer — representative leaf
// ---------------------------------------------------------------------------
describe('developer section', () => {
test('cron-jobs returns Settings > Developer Options', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/cron-jobs'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('intelligence returns Settings > Developer Options', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/intelligence'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('developer-options section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/developer-options'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('developer-options');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
});
// ---------------------------------------------------------------------------
// Removed routes / unknown slugs
// ---------------------------------------------------------------------------
describe('unknown / removed routes', () => {
test('"messaging" route (removed) resolves to home', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/messaging'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('home');
});
test('completely unknown slug resolves to home', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/not-a-real-route'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('home');
});
});
// ---------------------------------------------------------------------------
// Exact-match: no substring collision between /settings/ai and longer paths
// ---------------------------------------------------------------------------
describe('no substring collision', () => {
test('/settings/ai does not match /settings/agent-access or /settings/agents', () => {
// Verifies that exact first-segment extraction prevents "ai" from matching
// routes whose slugs merely contain "ai" as a substring.
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/ai'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('ai');
// Must not bleed into the agents section.
expect(screen.getByTestId('breadcrumbs')).not.toHaveTextContent('Agents');
});
});
@@ -18,16 +18,16 @@ describe('useSettingsNavigation breadcrumbs', () => {
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('notifications-hub returns Settings > Developer Options', () => {
test('notifications-hub returns Settings (section page)', () => {
// notifications-hub is now a home-level section hub — its breadcrumb is just Settings.
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/notifications-hub'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('notifications panel nests under Settings > Developer Options > Notifications', () => {
test('notifications panel nests under Settings > Notifications', () => {
// notifications is a leaf under the notifications section — breadcrumb is Settings > Notifications.
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/notifications'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent(
'Settings > Developer Options > Notifications'
);
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Notifications');
});
test('tasks returns Settings > Developer Options', () => {
@@ -1,6 +1,24 @@
// [settings] navigation hook — route resolution and breadcrumb derivation.
// Uses the settingsRouteRegistry as the single source of truth so that every
// registered route automatically yields a correct breadcrumb trail without
// maintaining a parallel switch-statement.
import debug from 'debug';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
entryRoute,
findEntryByRoute,
SETTINGS_ROUTE_REGISTRY,
type SettingsSection,
} from '../settingsRouteRegistry';
const log = debug('settings:nav');
// ---------------------------------------------------------------------------
// SettingsRoute type — derived from the registry so it stays in sync.
// ---------------------------------------------------------------------------
export type SettingsRoute =
| 'home'
| 'agents'
@@ -8,7 +26,6 @@ export type SettingsRoute =
| 'agent-access'
| 'account'
| 'features'
| 'messaging'
| 'cron-jobs'
| 'screen-intelligence'
| 'autocomplete'
@@ -46,6 +63,7 @@ export type SettingsRoute =
| 'webhooks-triggers'
| 'composio-triggers'
| 'composio-routing'
| 'composio'
| 'task-sources'
| 'tasks'
| 'mcp-server'
@@ -83,6 +101,82 @@ interface SettingsNavigationHook {
breadcrumbs: BreadcrumbItem[];
}
// ---------------------------------------------------------------------------
// Route extraction
//
// Prior implementation used `path.includes()` which is fragile against
// substring collisions (e.g. '/settings/ai' matching '/settings/ai-debug').
// We now extract the slug via an exact-segment split so each path maps to
// exactly one route, then fall back to the registry for known routes.
// ---------------------------------------------------------------------------
/** Extract the settings sub-path from a full pathname. */
const extractSettingsSlug = (pathname: string): string => {
// Strip the leading /settings/ and take the first path segment.
// e.g. /settings/agents/edit/123 → 'agents'
// e.g. /settings/team/manage/456/members → 'team/manage/456/members'
const match = /^\/settings\/(.+)$/.exec(pathname);
if (!match) return '';
return match[1];
};
const getCurrentRoute = (pathname: string): SettingsRoute => {
const slug = extractSettingsSlug(pathname);
if (!slug) return 'home';
// --- special-cased team sub-routes (dynamic segments) ---
if (/^team\/manage\/.+\/members/.test(slug)) return 'team-members';
if (/^team\/manage\/.+\/invites/.test(slug)) return 'team-invites';
if (/^team\/manage\//.test(slug)) return 'team';
if (/^team\/members/.test(slug)) return 'team-members';
if (/^team\/invites/.test(slug)) return 'team-invites';
if (/^team(\/|$)/.test(slug)) return 'team';
// --- agent editor sub-routes ---
if (/^agents\/(new|edit)/.test(slug)) return 'agents';
// --- exact first-segment lookup via registry ---
const firstSegment = slug.split('/')[0];
// Try to find the route by first segment first (most routes are single-segment).
const entry = findEntryByRoute(firstSegment);
if (entry) {
log('getCurrentRoute: %s → %s', pathname, entry.id);
return entry.id as SettingsRoute;
}
// A few routes have ids that don't match their URL segment (build-info → about).
// Check all registry entries whose resolved route matches.
const byRoute = SETTINGS_ROUTE_REGISTRY.find(e => entryRoute(e) === firstSegment);
if (byRoute) {
log('getCurrentRoute (via route alias): %s → %s', pathname, byRoute.id);
return byRoute.id as SettingsRoute;
}
// Legacy redirect targets that don't have a registry entry.
if (firstSegment === 'notification-routing') return 'notification-routing';
log('getCurrentRoute: unknown slug "%s", defaulting to home', firstSegment);
return 'home';
};
// ---------------------------------------------------------------------------
// Section → breadcrumb label mapping (static, no i18n hook dependency).
// Breadcrumb labels are intentionally English-only for now (the existing
// implementation was also English). A future pass can thread the translator.
// ---------------------------------------------------------------------------
const SECTION_LABEL: Record<SettingsSection, string> = {
home: 'Settings',
account: 'Account',
ai: 'AI & Models',
agents: 'Agents',
features: 'Features',
composio: 'Integrations',
crypto: 'Crypto',
notifications: 'Notifications',
developer: 'Developer Options',
};
export const useSettingsNavigation = (): SettingsNavigationHook => {
const navigate = useNavigate();
const location = useLocation();
@@ -99,97 +193,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
[navigate]
);
// Determine current settings route from URL
const getCurrentRoute = (): SettingsRoute => {
const path = location.pathname;
// Check specific team management paths first (more specific)
if (path.includes('/settings/team/manage/') && path.includes('/members')) return 'team-members';
if (path.includes('/settings/team/manage/') && path.includes('/invites')) return 'team-invites';
if (path.includes('/settings/team/manage/')) return 'team';
// Then check regular team paths (less specific)
if (path.includes('/settings/team/members')) return 'team-members';
if (path.includes('/settings/team/invites')) return 'team-invites';
if (path.includes('/settings/team')) return 'team';
if (path.includes('/settings/account')) return 'account';
if (path.includes('/settings/features')) return 'features';
if (path.includes('/settings/messaging')) return 'messaging';
if (path.includes('/settings/cron-jobs')) return 'cron-jobs';
if (path.includes('/settings/screen-awareness-debug')) return 'screen-awareness-debug';
if (path.includes('/settings/screen-intelligence')) return 'screen-intelligence';
if (path.includes('/settings/autocomplete-debug')) return 'autocomplete-debug';
if (path.includes('/settings/autocomplete')) return 'autocomplete';
if (path.includes('/settings/privacy')) return 'privacy';
if (path.includes('/settings/billing')) return 'billing';
if (path.includes('/settings/developer-options')) return 'developer-options';
if (path.includes('/settings/autonomy')) return 'autonomy';
if (path.includes('/settings/llm')) return 'llm';
if (path.includes('/settings/ai')) return 'ai';
if (path.includes('/settings/local-model-debug')) return 'local-model-debug';
if (path.includes('/settings/voice-debug')) return 'voice-debug';
if (path.includes('/settings/voice')) return 'voice';
if (path.includes('/settings/tools')) return 'tools';
if (path.includes('/settings/memory-sync')) return 'memory-sync';
if (path.includes('/settings/memory-data')) return 'memory-data';
if (path.includes('/settings/memory-debug')) return 'memory-debug';
if (path.includes('/settings/webhooks-debug')) return 'webhooks-debug';
if (path.includes('/settings/webhooks-triggers')) return 'webhooks-triggers';
if (path.includes('/settings/composio-triggers')) return 'composio-triggers';
if (path.includes('/settings/composio-routing')) return 'composio-routing';
if (path.includes('/settings/task-sources')) return 'task-sources';
// `tasks` is checked after `task-sources` so the longer, hyphenated route
// isn't shadowed (the two prefixes don't actually overlap, but ordering
// here keeps the intent obvious).
if (path.includes('/settings/tasks')) return 'tasks';
if (path.includes('/settings/intelligence')) return 'intelligence';
if (path.includes('/settings/crypto')) return 'crypto';
if (path.includes('/settings/recovery-phrase')) return 'recovery-phrase';
if (path.includes('/settings/wallet-balances')) return 'wallet-balances';
if (path.includes('/settings/agent-chat')) return 'agent-chat';
// Notification routes must be checked in specificity order so the more
// specific `notification-routing` path doesn't get swallowed by the
// shorter `notifications` prefix.
if (path.includes('/settings/notification-routing')) return 'notification-routing';
// `notifications-hub` must be checked before the shorter `notifications`
// prefix (the tabbed settings panel) so it isn't swallowed.
if (path.includes('/settings/notifications-hub')) return 'notifications-hub';
if (path.includes('/settings/notifications')) return 'notifications';
if (path.includes('/settings/devices')) return 'devices';
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/persona')) return 'persona';
if (path.includes('/settings/appearance')) return 'appearance';
// `approval-history` is an explicit leaf route under Agent access; it has a
// distinct prefix from `agent-access`, so ordering between them is cosmetic.
if (path.includes('/settings/approval-history')) return 'approval-history';
// `agents-settings` (the Agents section page) must be checked before the
// 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';
};
const currentRoute = getCurrentRoute();
const currentRoute = getCurrentRoute(location.pathname);
const navigateToSettings = useCallback(
(route: SettingsRoute | string = 'home') => {
@@ -221,172 +225,78 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
goBackWithFallback('/home');
}, [goBackWithFallback]);
// -------------------------------------------------------------------------
// Breadcrumbs — derived from the registry.
//
// The root crumb is always "Settings" (pointing to /settings).
// Section pages (section === 'home') trail: [Settings].
// Leaf panels trail: [Settings] > [Section label].
// Special multi-level trails (team sub-pages, approval-history) are handled
// explicitly below.
// -------------------------------------------------------------------------
const settingsCrumb: BreadcrumbItem = { label: 'Settings', onClick: () => navigate('/settings') };
const accountCrumb: BreadcrumbItem = {
label: 'Account',
onClick: () => navigate('/settings/account'),
};
const featuresCrumb: BreadcrumbItem = {
label: 'Features',
onClick: () => navigate('/settings/features'),
};
const aiCrumb: BreadcrumbItem = { label: 'AI', onClick: () => navigate('/settings/ai') };
const teamCrumb: BreadcrumbItem = { label: 'Team', onClick: () => navigate('/settings/team') };
const developerCrumb: BreadcrumbItem = {
label: 'Developer Options',
onClick: () => navigate('/settings/developer-options'),
};
const agentAccessCrumb: BreadcrumbItem = {
label: 'Agent access',
onClick: () => navigate('/settings/agent-access'),
};
const agentsCrumb: BreadcrumbItem = {
label: 'Agents',
onClick: () => navigate('/settings/agents-settings'),
};
const cryptoCrumb: BreadcrumbItem = {
label: 'Crypto',
onClick: () => navigate('/settings/crypto'),
};
const notificationsHubCrumb: BreadcrumbItem = {
label: 'Notifications',
onClick: () => navigate('/settings/notifications-hub'),
};
const getBreadcrumbs = (): BreadcrumbItem[] => {
switch (currentRoute) {
// Section pages
case 'account':
case 'features':
case 'ai':
case 'agents-settings':
case 'crypto':
return [settingsCrumb];
if (currentRoute === 'home') return [];
// Leaf panels under the Agents section
case 'agents':
case 'agent-access':
case 'sandbox-settings':
case 'activity-level':
case 'autonomy':
case 'persona':
return [settingsCrumb, agentsCrumb];
// Leaf panels under the Crypto section
case 'recovery-phrase':
case 'wallet-balances':
return [settingsCrumb, cryptoCrumb];
// Leaf panels under account
case 'team':
case 'privacy':
case 'security':
case 'migration':
return [settingsCrumb, accountCrumb];
case 'billing':
return [settingsCrumb];
// Leaf panels under features
case 'screen-intelligence':
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
case 'team-members':
case 'team-invites':
return [settingsCrumb, accountCrumb, teamCrumb];
// Developer sub-pages
case 'agent-chat':
case 'cron-jobs':
case 'screen-awareness-debug':
case 'autocomplete-debug':
case 'voice-debug':
case 'local-model-debug':
case 'webhooks-debug':
case 'memory-data':
case 'memory-debug':
case 'intelligence':
case 'webhooks-triggers':
case 'composio-triggers':
case 'composio-routing':
case 'tasks':
case 'notification-routing':
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];
// Developer options section page
case 'developer-options':
return [settingsCrumb];
// Notification preferences panel is a leaf under the Advanced →
// Notifications hub.
case 'notifications':
return [settingsCrumb, developerCrumb, notificationsHubCrumb];
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];
// Permissions panel lives at the top level of Settings (Assistant group).
case 'permissions':
return [settingsCrumb];
// Mascot appearance panel sits at the top level of Settings.
case 'mascot':
return [settingsCrumb];
// Appearance (theme) panel sits at the top level of Settings.
case 'appearance':
return [settingsCrumb];
// Approval history is a leaf under Agent access, which itself lives under
// the Agents section — so the trail is Settings → Agents → Agent access.
case 'approval-history':
return [settingsCrumb, agentsCrumb, agentAccessCrumb];
case 'home':
default:
return [];
// Special cases with deeper trails.
if (currentRoute === 'team-members' || currentRoute === 'team-invites') {
return [
settingsCrumb,
{ label: SECTION_LABEL.account, onClick: () => navigate('/settings/account') },
{ label: 'Team', onClick: () => navigate('/settings/team') },
];
}
if (currentRoute === 'approval-history') {
return [
settingsCrumb,
{ label: SECTION_LABEL.agents, onClick: () => navigate('/settings/agents-settings') },
{ label: 'Agent access', onClick: () => navigate('/settings/agent-access') },
];
}
// Notification preferences panel nests under notifications-hub.
if (currentRoute === 'notifications') {
return [
settingsCrumb,
{
label: SECTION_LABEL.notifications,
onClick: () => navigate('/settings/notifications-hub'),
},
];
}
// Legacy redirect target — kept working but mapped to developer.
if (currentRoute === 'notification-routing') {
return [
settingsCrumb,
{ label: SECTION_LABEL.developer, onClick: () => navigate('/settings/developer-options') },
];
}
// Look up the entry in the registry using the current route.
// The currentRoute is the entry id; try by id first, then by resolved route.
const entry =
SETTINGS_ROUTE_REGISTRY.find(e => e.id === currentRoute) ??
SETTINGS_ROUTE_REGISTRY.find(e => entryRoute(e) === currentRoute);
if (!entry) {
log('breadcrumbs: no registry entry for "%s"', currentRoute);
return [settingsCrumb];
}
// Home-level entries (section === 'home') are top-level section pages.
if (entry.section === 'home') {
return [settingsCrumb];
}
// Leaf panels: Settings → <section label>.
const sectionLabel = SECTION_LABEL[entry.section];
const sectionRoute = sectionRouteForSection(entry.section);
return [settingsCrumb, { label: sectionLabel, onClick: () => navigate(sectionRoute) }];
};
const breadcrumbs = getBreadcrumbs();
@@ -400,3 +310,30 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
breadcrumbs,
};
};
// ---------------------------------------------------------------------------
// Helper: canonical section-page route for a given section.
// ---------------------------------------------------------------------------
const sectionRouteForSection = (section: SettingsSection): string => {
switch (section) {
case 'account':
return '/settings/account';
case 'ai':
return '/settings/ai';
case 'agents':
return '/settings/agents-settings';
case 'features':
return '/settings/features';
case 'composio':
return '/settings/composio';
case 'crypto':
return '/settings/crypto';
case 'notifications':
return '/settings/notifications-hub';
case 'developer':
return '/settings/developer-options';
case 'home':
return '/settings';
}
};
File diff suppressed because it is too large Load Diff
+103 -105
View File
@@ -7,6 +7,7 @@
* here would race with that component's own state machine.
*/
import { invoke } from '@tauri-apps/api/core';
import debug from 'debug';
import { useEffect, useState } from 'react';
import { useAppUpdate } from '../../../hooks/useAppUpdate';
@@ -17,9 +18,13 @@ import { selectDeveloperMode, setDeveloperMode } from '../../../store/themeSlice
import { APP_VERSION, LATEST_APP_DOWNLOAD_URL } from '../../../utils/config';
import { isTauriEnvironment } from '../../../utils/configPersistence';
import { openUrl } from '../../../utils/openUrl';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('settings:developer-mode');
const AboutPanel = () => {
const { t } = useT();
const dispatch = useAppDispatch();
@@ -83,133 +88,126 @@ const AboutPanel = () => {
/>
<div className="p-4 space-y-4">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.about.version')}
{/* Version */}
<SettingsSection>
<div className="px-4 py-4">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.about.version')}
</div>
<div className="mt-1 text-lg font-semibold text-neutral-800 dark:text-neutral-100">
v{APP_VERSION}
</div>
{info?.available && info.available_version && (
<div className="mt-1 text-xs text-primary-500">
v{info.available_version} {t('settings.about.updateAvailable')}
</div>
)}
</div>
<div className="mt-1 text-lg font-semibold text-stone-900 dark:text-neutral-100">
v{APP_VERSION}
</div>
{info?.available && info.available_version && (
<div className="mt-1 text-xs text-primary-500">
v{info.available_version} {t('settings.about.updateAvailable')}
</SettingsSection>
{/* Software updates */}
<SettingsSection>
<SettingsRow
label={t('settings.about.softwareUpdates')}
description={summary}
control={
<Button
type="button"
variant="primary"
size="xs"
onClick={handleCheck}
disabled={isChecking}>
{isChecking ? t('settings.about.checking') : t('settings.about.checkForUpdates')}
</Button>
}
/>
{lastCheckedAt && (
<div className="px-4 pb-3 text-[11px] text-neutral-400 dark:text-neutral-500">
{t('settings.about.lastChecked')} {formatRelative(lastCheckedAt, t)}
</div>
)}
</div>
</SettingsSection>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.about.softwareUpdates')}
</div>
<div className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{summary}
</div>
{lastCheckedAt && (
<div className="mt-1 text-[11px] text-stone-400 dark:text-neutral-500">
{t('settings.about.lastChecked')} {formatRelative(lastCheckedAt, t)}
</div>
)}
</div>
<button
type="button"
onClick={handleCheck}
disabled={isChecking}
className="shrink-0 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors disabled:opacity-50">
{isChecking ? t('settings.about.checking') : t('settings.about.checkForUpdates')}
</button>
</div>
</div>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.about.connection')}
</div>
<div className="mt-2 space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.about.connectionMode')}
</span>
<span className="text-xs font-medium text-stone-900 dark:text-neutral-100">
{/* Connection */}
<SettingsSection title={t('settings.about.connection')}>
<SettingsRow
label={t('settings.about.connectionMode')}
control={
<span className="text-xs font-medium text-neutral-800 dark:text-neutral-100">
{coreMode.kind === 'local'
? t('settings.about.connectionModeLocal')
: coreMode.kind === 'cloud'
? t('settings.about.connectionModeCloud')
: t('settings.about.connectionModeUnset')}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-stone-500 dark:text-neutral-400 shrink-0">
{t('settings.about.serverUrl')}
</span>
}
/>
<SettingsRow
label={t('settings.about.serverUrl')}
control={
<span
className="text-xs font-mono text-stone-900 dark:text-neutral-100 truncate"
className="text-xs font-mono text-neutral-800 dark:text-neutral-100 truncate max-w-[200px]"
title={rpcUrl ?? undefined}>
{rpcUrl ?? t('settings.about.serverUrlUnavailable')}
</span>
</div>
}
/>
<div className="px-4 pb-3">
<p className="text-[11px] text-neutral-500 dark:text-neutral-400 leading-relaxed">
{coreMode.kind === 'cloud'
? t('settings.about.connectionHelperCloud')
: t('settings.about.connectionHelperLocal')}
</p>
</div>
<p className="mt-2 text-[11px] text-stone-500 dark:text-neutral-400 leading-relaxed">
{coreMode.kind === 'cloud'
? t('settings.about.connectionHelperCloud')
: t('settings.about.connectionHelperLocal')}
</p>
</div>
</SettingsSection>
{/* Developer Mode toggle — always visible so users can enable it
without needing it to be on first (chicken-and-egg avoidance). */}
<div
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"
data-testid="developer-mode-section">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.developerMode.title')}
</div>
<div className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{developerModeActive && !developerModePref
<div data-testid="developer-mode-section">
<SettingsSection>
<SettingsRow
htmlFor="switch-developer-mode"
label={t('settings.developerMode.title')}
description={
developerModeActive && !developerModePref
? t('settings.developerMode.enabledByBuild')
: t('settings.developerMode.description')}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={developerModePref}
aria-label={t('settings.developerMode.title')}
onClick={() => {
console.debug('[developer-mode] toggled to', !developerModePref);
dispatch(setDeveloperMode(!developerModePref));
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 ${
developerModePref ? 'bg-primary-600' : 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
developerModePref ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
: t('settings.developerMode.description')
}
control={
<SettingsSwitch
id="switch-developer-mode"
checked={developerModePref}
onCheckedChange={next => {
log('toggled to %s', String(next));
dispatch(setDeveloperMode(next));
}}
aria-label={t('settings.developerMode.title')}
/>
}
/>
</SettingsSection>
</div>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.about.releases')}
{/* Releases */}
<SettingsSection>
<div className="px-4 py-4 space-y-2">
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{t('settings.about.releases')}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
{t('settings.about.releasesDesc')}
</p>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => {
void openUrl(LATEST_APP_DOWNLOAD_URL);
}}>
{t('settings.about.openReleases')}
</Button>
</div>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('settings.about.releasesDesc')}
</p>
<button
type="button"
onClick={() => {
void openUrl(LATEST_APP_DOWNLOAD_URL);
}}
className="mt-3 px-3 py-1.5 rounded-lg border border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 text-xs transition-colors">
{t('settings.about.openReleases')}
</button>
</div>
</SettingsSection>
</div>
</div>
);
@@ -11,7 +11,20 @@ import {
type TrustedAccess,
type TrustedRoot,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsEmptyState,
SettingsListItem,
SettingsNumberField,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// Installs are always *available* but never silent: every `install_tool` call
@@ -228,7 +241,8 @@ const AgentAccessPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-6">
<div className="p-4 pt-2 space-y-5">
{/* Desktop-only notice */}
{!isTauri() && (
<p className="text-sm text-coral-600 dark:text-coral-300">
{t('settings.agentAccess.desktopOnly')}
@@ -236,232 +250,188 @@ const AgentAccessPanel = () => {
)}
{isLoading ? (
<p className="text-sm text-stone-600 dark:text-neutral-400">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.agentAccess.loading')}
</p>
) : (
<>
{/* Workspace confinement — orthogonal to the tier; applies in all
modes. Tier selection moved to PermissionsPanel. */}
<section className="space-y-1">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 cursor-pointer"
checked={workspaceOnly}
onChange={e => toggleWorkspaceOnly(e.target.checked)}
/>
<span>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.confine.label')}
</span>
<span className="block text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.confine.desc')}
</span>
</span>
</label>
</section>
{/* Workspace confinement + task plan approval */}
<SettingsSection>
<SettingsRow
htmlFor="switch-workspace-only"
label={t('settings.agentAccess.confine.label')}
description={t('settings.agentAccess.confine.desc')}
control={
<SettingsSwitch
id="switch-workspace-only"
checked={workspaceOnly}
onCheckedChange={toggleWorkspaceOnly}
aria-label={t('settings.agentAccess.confine.label')}
/>
}
/>
<SettingsRow
htmlFor="switch-task-plan-approval"
label={t('settings.agentAccess.requireTaskPlanApproval.label')}
description={t('settings.agentAccess.requireTaskPlanApproval.desc')}
control={
<SettingsSwitch
id="switch-task-plan-approval"
checked={requireTaskPlanApproval}
onCheckedChange={toggleTaskPlanApproval}
aria-label={t('settings.agentAccess.requireTaskPlanApproval.label')}
/>
}
/>
</SettingsSection>
<section className="space-y-1">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 cursor-pointer"
checked={requireTaskPlanApproval}
onChange={e => toggleTaskPlanApproval(e.target.checked)}
/>
<span>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.requireTaskPlanApproval.label')}
</span>
<span className="block text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.requireTaskPlanApproval.desc')}
</span>
</span>
</label>
</section>
{/* Action timeout */}
<SettingsSection
title={t('settings.agentAccess.timeout.label')}
description={t('settings.agentAccess.timeout.desc')}>
<SettingsRow
stacked
control={
<div className="space-y-2">
<SettingsNumberField
id="timeout-input"
value={timeoutInput}
onChange={setTimeoutInput}
onCommit={() => void commitTimeout()}
unit={t('settings.agentAccess.timeout.unit')}
min={timeoutMin}
max={timeoutMax}
disabled={timeoutEnvOverride}
invalid={!!timeoutError}
aria-label={t('settings.agentAccess.timeout.label')}
/>
{timeoutEnvOverride && (
<p className="rounded border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-300">
{t('settings.agentAccess.timeout.envOverride')}
</p>
)}
<SettingsStatusLine
saving={false}
savedNote={timeoutSavedNote}
error={timeoutError}
savingLabel={t('settings.agentAccess.saving')}
/>
</div>
}
/>
</SettingsSection>
{/* Action timeout — wall-clock limit for a single tool/action.
Extend it when large local models get cut off mid-response
(issue #3100). Persists independently of the autonomy block. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.timeout.label')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.timeout.desc')}
</p>
<div className="flex items-center gap-2">
<input
type="number"
inputMode="numeric"
min={timeoutMin}
max={timeoutMax}
step={1}
value={timeoutInput}
disabled={timeoutEnvOverride}
onChange={e => setTimeoutInput(e.target.value)}
onBlur={() => void commitTimeout()}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
void commitTimeout();
}
}}
aria-label={t('settings.agentAccess.timeout.label')}
className="w-28 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-sm disabled:opacity-60"
/>
<span className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.timeout.unit')} ({timeoutMin}{timeoutMax})
</span>
</div>
{timeoutEnvOverride && (
<p className="rounded border border-amber/40 bg-amber/5 dark:bg-amber/10 p-2 text-xs text-amber-700 dark:text-amber-300">
{t('settings.agentAccess.timeout.envOverride')}
</p>
)}
<div className="min-h-[1.25rem] text-xs" aria-live="polite">
{timeoutError ? (
<span className="text-coral-600 dark:text-coral-300">{timeoutError}</span>
) : timeoutSavedNote ? (
<span className="text-sage-700 dark:text-sage-300"> {timeoutSavedNote}</span>
) : null}
</div>
</section>
{/* Granted folders (trusted roots) — extra read/write reach. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.grantedFolders')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.grantedDesc')}
</p>
{/* Granted folders (trusted roots) */}
<SettingsSection
title={t('settings.agentAccess.grantedFolders')}
description={t('settings.agentAccess.grantedDesc')}>
{trustedRoots.length === 0 ? (
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.noneGranted')}
</p>
<SettingsEmptyState label={t('settings.agentAccess.noneGranted')} />
) : (
<ul className="space-y-1">
<ul>
{trustedRoots.map(r => (
<li
<SettingsListItem
key={r.path}
className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
<span className="font-mono text-xs text-stone-900 dark:text-neutral-100 truncate">
{r.path}
</span>
<span className="flex items-center gap-2">
<span className="text-xs text-stone-600 dark:text-neutral-400">
{r.access === 'readwrite'
? t('settings.agentAccess.readWrite')
: t('settings.agentAccess.readOnly')}
</span>
<button
type="button"
onClick={() => removeRoot(r.path)}
className="text-xs text-coral-600 dark:text-coral-300 hover:underline">
{t('settings.agentAccess.remove')}
</button>
</span>
</li>
label={r.path}
mono
badge={
r.access === 'readwrite' ? (
<SettingsBadge variant="success">
{t('settings.agentAccess.readWrite')}
</SettingsBadge>
) : (
<SettingsBadge variant="neutral">
{t('settings.agentAccess.readOnly')}
</SettingsBadge>
)
}
onRemove={() => removeRoot(r.path)}
removeLabel={t('settings.agentAccess.remove')}
/>
))}
</ul>
)}
<div className="flex items-center gap-2">
<input
type="text"
{/* Add-folder row */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-neutral-100 dark:border-neutral-800">
<SettingsTextField
mono
className="flex-1"
value={newRootPath}
onChange={e => setNewRootPath(e.target.value)}
placeholder={t('settings.agentAccess.pathPlaceholder')}
aria-label={t('settings.agentAccess.pathPlaceholder')}
className="flex-1 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-xs font-mono"
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
addRoot();
}
}}
inputSize="sm"
/>
<select
<SettingsSelect
value={newRootAccess}
onChange={e => setNewRootAccess(e.target.value as TrustedAccess)}
aria-label={t('settings.agentAccess.accessLevelLabel')}
className="rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-xs">
inputSize="sm"
className="w-32">
<option value="read">{t('settings.agentAccess.readOnly')}</option>
<option value="readwrite">{t('settings.agentAccess.readWrite')}</option>
</select>
<button
</SettingsSelect>
<Button
type="button"
variant="primary"
size="xs"
onClick={addRoot}
className="rounded bg-primary-500 px-3 py-1 text-xs text-white hover:bg-primary-600">
disabled={!newRootPath.trim()}>
{t('settings.agentAccess.add')}
</button>
</Button>
</div>
</section>
</SettingsSection>
{/* "Always allow" allowlist — tools the user chose to stop being
prompted for, via the in-chat approval card. Read-only here with
a Remove action to re-enable prompting for a tool. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.alwaysAllow')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.alwaysAllowDesc')}
</p>
{/* Always-allowed tools */}
<SettingsSection
title={t('settings.agentAccess.alwaysAllow')}
description={t('settings.agentAccess.alwaysAllowDesc')}>
{autoApprove.length === 0 ? (
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.alwaysAllowNone')}
</p>
<SettingsEmptyState label={t('settings.agentAccess.alwaysAllowNone')} />
) : (
<ul className="space-y-1">
<ul>
{autoApprove.map(tool => (
<li
<SettingsListItem
key={tool}
className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
<span className="font-mono text-xs text-stone-900 dark:text-neutral-100 truncate">
{tool}
</span>
<button
type="button"
onClick={() => removeAutoApprove(tool)}
className="text-xs text-coral-600 dark:text-coral-300 hover:underline">
{t('settings.agentAccess.remove')}
</button>
</li>
label={tool}
mono
onRemove={() => removeAutoApprove(tool)}
removeLabel={t('settings.agentAccess.remove')}
/>
))}
</ul>
)}
</section>
</SettingsSection>
{/* Approval history — read-only audit trail of past decisions,
backed by the gate's durable decided-rows store. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-ink">
{t('settings.agentAccess.approvalHistory')}
</h2>
<p className="text-xs text-ink-soft">
{t('settings.agentAccess.approvalHistoryDesc')}
</p>
<button
type="button"
onClick={() => navigateToSettings('approval-history')}
data-testid="agent-access-approval-history-link"
className="rounded border border-line px-3 py-1 text-xs text-ink hover:border-primary-300">
{t('settings.agentAccess.viewApprovalHistory')}
</button>
</section>
{/* Approval history */}
<SettingsSection
title={t('settings.agentAccess.approvalHistory')}
description={t('settings.agentAccess.approvalHistoryDesc')}>
<div className="px-4 py-3">
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => navigateToSettings('approval-history')}
data-testid="agent-access-approval-history-link">
{t('settings.agentAccess.viewApprovalHistory')}
</Button>
</div>
</SettingsSection>
{/* Auto-save status — changes persist on selection; no manual save. */}
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
{error ? (
<span className="text-coral-600 dark:text-coral-300">{error}</span>
) : isSaving ? (
<span className="text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.saving')}
</span>
) : savedNote ? (
<span className="text-sage-700 dark:text-sage-300"> {savedNote}</span>
) : (
<span className="text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.changesApply')}
</span>
)}
</div>
{/* Auto-save status */}
<SettingsStatusLine
saving={isSaving}
savedNote={savedNote}
error={error}
savingLabel={t('settings.agentAccess.saving')}
/>
</>
)}
</div>
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface ActivityLevelSettings {
@@ -100,7 +101,9 @@ export default function AgentActivityPanel() {
if (status === 'loading' && !settings) {
return (
<div className="p-4 text-sm text-stone-500 dark:text-neutral-400">{t('common.loading')}</div>
<div className="p-4 text-sm text-neutral-500 dark:text-neutral-400">
{t('common.loading')}
</div>
);
}
@@ -117,13 +120,13 @@ export default function AgentActivityPanel() {
breadcrumbs={breadcrumbs}
/>
<div className="flex flex-col gap-4 p-4">
<p className="text-xs text-stone-600 dark:text-neutral-400">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('activityLevel.description')}
</p>
{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">
<div className="px-3 py-2 rounded-md bg-neutral-100 dark:bg-neutral-800 text-sm">
<span className="font-medium text-neutral-800 dark:text-neutral-200">
{t('activityLevel.currentMonth').replace(
'{amount}',
monthlyCost.total_cost_usd.toFixed(2)
@@ -132,6 +135,7 @@ export default function AgentActivityPanel() {
</div>
)}
{/* Level selection cards — intentional bespoke card UI; kept as-is. */}
<div className="flex flex-col gap-2">
{LEVELS.map(({ key, value }) => {
const isSelected = settings?.level === value;
@@ -146,25 +150,25 @@ export default function AgentActivityPanel() {
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'
: 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:border-neutral-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">
<span className="text-sm font-semibold text-neutral-800 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">
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400">
{t('activityLevel.default')}
</span>
)}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
<p className="text-xs text-neutral-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">
<div className="text-xs font-mono text-neutral-500 dark:text-neutral-400 shrink-0 ml-4">
{costMin === 0 && costMax === 0
? t('activityLevel.costFree')
: t('activityLevel.costRange')
@@ -177,15 +181,12 @@ export default function AgentActivityPanel() {
})}
</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>
<SettingsStatusLine
saving={status === 'saving'}
savedNote={status === 'saved' ? t('activityLevel.saved') : null}
error={error}
savingLabel={t('autonomy.statusSaving')}
/>
</div>
</div>
);
@@ -2,7 +2,15 @@ import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { openhumanAgentChat } from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsEmptyState,
SettingsSection,
SettingsStatusLine,
SettingsTextArea,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
type ChatMessage = { role: 'user' | 'agent'; text: string };
@@ -74,7 +82,7 @@ const AgentChatPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('chat.agentChat')}
showBackButton={true}
@@ -83,76 +91,82 @@ const AgentChatPanel = () => {
/>
<div className="p-4 space-y-4">
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('chat.overrides')}
</h3>
<p className="text-sm text-stone-400 dark:text-neutral-500">{t('chat.agentChatDesc')}</p>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-2 text-sm text-stone-600 dark:text-neutral-300">
{t('chat.model')}
<input
className="input input-bordered w-full text-slate-900 bg-white dark:bg-neutral-900"
<SettingsSection title={t('chat.overrides')} description={t('chat.agentChatDesc')}>
<div className="px-4 py-3 grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label
htmlFor="agent-chat-model"
className="text-xs text-neutral-500 dark:text-neutral-400">
{t('chat.model')}
</label>
<SettingsTextField
id="agent-chat-model"
placeholder={t('chat.modelPlaceholder')}
value={modelOverride}
onChange={event => setModelOverride(event.target.value)}
aria-label={t('chat.model')}
/>
</label>
<label className="space-y-2 text-sm text-stone-600 dark:text-neutral-300">
{t('chat.temperature')}
<input
className="input input-bordered w-full text-slate-900 bg-white dark:bg-neutral-900"
</div>
<div className="space-y-1">
<label
htmlFor="agent-chat-temperature"
className="text-xs text-neutral-500 dark:text-neutral-400">
{t('chat.temperature')}
</label>
<SettingsTextField
id="agent-chat-temperature"
placeholder="0.7"
value={temperature}
onChange={event => setTemperature(event.target.value)}
aria-label={t('chat.temperature')}
/>
</label>
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('chat.conversation')}
</h3>
{error && (
<div className="rounded-lg border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-3">
{messages.length === 0 && (
<div className="text-sm text-stone-400 dark:text-neutral-500">
{t('chat.startAgentConversation')}
</div>
)}
{messages.map((message, index) => (
<div key={`${message.role}-${index}`} className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{message.role === 'user' ? t('chat.you') : t('chat.agent')}
</div>
<div
className={`text-sm whitespace-pre-wrap ${
message.role === 'user'
? 'text-stone-900 dark:text-neutral-100'
: 'text-emerald-700 dark:text-emerald-300'
}`}>
{message.text}
</div>
</div>
))}
</div>
<div className="space-y-2">
<textarea
className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white dark:bg-neutral-900"
placeholder={t('chat.askAgent')}
value={input}
onChange={event => setInput(event.target.value)}
/>
<button className="btn btn-primary" onClick={sendMessage} disabled={sending}>
{sending ? t('common.loading') : t('chat.sendMessage')}
</button>
</SettingsSection>
<SettingsSection title={t('chat.conversation')}>
<div className="px-4 py-3 space-y-3">
<SettingsStatusLine saving={false} error={error || null} savingLabel="" />
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-4 space-y-3 min-h-[6rem]">
{messages.length === 0 ? (
<SettingsEmptyState label={t('chat.startAgentConversation')} />
) : (
messages.map((message, index) => (
<div key={`${message.role}-${index}`} className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{message.role === 'user' ? t('chat.you') : t('chat.agent')}
</div>
<div
className={`text-sm whitespace-pre-wrap ${
message.role === 'user'
? 'text-neutral-800 dark:text-neutral-100'
: 'text-emerald-700 dark:text-emerald-300'
}`}>
{message.text}
</div>
</div>
))
)}
</div>
<div className="space-y-2">
<SettingsTextArea
placeholder={t('chat.askAgent')}
value={input}
onChange={event => setInput(event.target.value)}
rows={4}
aria-label={t('chat.askAgent')}
/>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void sendMessage()}
disabled={sending}>
{sending ? t('common.loading') : t('chat.sendMessage')}
</Button>
</div>
</div>
</section>
</SettingsSection>
</div>
</div>
);
@@ -131,4 +131,100 @@ describe('AgentEditorPage', () => {
expect(screen.queryByLabelText('Description')).toBeNull();
expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
});
it('shows source badge (custom/default) next to name in edit mode (line 259)', async () => {
mockGet.mockResolvedValue(agent({ source: 'custom' }));
renderAt('/settings/agents/edit/finance');
await waitFor(() => expect(mockGet).toHaveBeenCalled());
// Custom agents show their source badge
expect(await screen.findByText('Custom')).toBeInTheDocument();
});
it('auto-slugifies the ID field from the Name field on create (lines 280-282)', () => {
renderAt('/settings/agents/new');
// The Name input is present on create
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'My Cool Agent' } });
// ID is auto-derived from name via slugify — until user touches it
const idInput = screen.getByLabelText('ID') as HTMLInputElement;
expect(idInput.value).toBe('my-cool-agent');
});
it('allows manual ID override once the ID field is touched (lines 280-282)', () => {
renderAt('/settings/agents/new');
const idInput = screen.getByLabelText('ID');
fireEvent.change(idInput, { target: { value: 'custom-id' } });
// After touching, changing name should NOT overwrite the custom ID
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'New Name' } });
expect((screen.getByLabelText('ID') as HTMLInputElement).value).toBe('custom-id');
});
it('shows custom model text input when __custom__ model is selected (line 350)', () => {
renderAt('/settings/agents/new');
// Select the custom model option
fireEvent.change(screen.getByRole('combobox'), { target: { value: '__custom__' } });
// A custom model text input appears with the custom placeholder as aria-label
// The en.ts label is: 'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4'
const customInput = screen.getByLabelText(/e\.g\. anthropic/i);
expect(customInput).toBeInTheDocument();
});
it('updates system prompt textarea on change (line 373)', () => {
renderAt('/settings/agents/new');
// The label uses t('settings.agents.editor.systemPrompt') → 'System prompt (optional)'
const promptArea = screen.getByLabelText('System prompt (optional)') as HTMLTextAreaElement;
fireEvent.change(promptArea, { target: { value: 'Be helpful and precise.' } });
expect(promptArea.value).toBe('Be helpful and precise.');
});
it('shows All Tools chip when toolAllowlist contains "*" (line 390)', async () => {
renderAt('/settings/agents/new');
// Open tool picker via the Add tools button
fireEvent.click(screen.getByText('Add tools'));
await waitFor(() => expect(mockAvailableTools).toHaveBeenCalled());
await screen.findByText('Search the web for information.');
// Toggle all tools via the "Allow all tools (*)" button
fireEvent.click(screen.getByRole('button', { name: /Allow all tools/i }));
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
// The "All tools" chip should appear (toolsAllSelected branch)
await waitFor(() => {
expect(screen.getByText('All tools')).toBeInTheDocument();
});
});
it('removes a selected tool chip via its X button (line 410)', async () => {
renderAt('/settings/agents/new');
// Open picker and select a tool
fireEvent.click(screen.getByText('Add tools'));
await waitFor(() => expect(mockAvailableTools).toHaveBeenCalled());
await screen.findByText('Search the web for information.');
fireEvent.click(screen.getByText('web_search'));
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
// Chip for web_search appears
await waitFor(() => expect(screen.getAllByText('web_search').length).toBeGreaterThan(0));
// Click the X remove button on the chip — the aria-label is 'Remove web_search'
// since t('settings.agents.editor.removeToolAria') = 'Remove {tool}'
const removeBtn = screen.getByRole('button', { name: 'Remove web_search' });
fireEvent.click(removeBtn);
// The chip remove button should be gone
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Remove web_search' })).not.toBeInTheDocument();
});
});
});
@@ -24,7 +24,15 @@ import {
type AgentRegistryEntry,
type AgentToolInfo,
} from '../../../services/api/agentRegistryApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsTextArea,
SettingsTextField,
} from '../controls';
// Known model options — mirrors the Rust tier constants + route hints
// (src/openhuman/config/schema/types.rs, inference/provider/router.rs).
@@ -57,9 +65,6 @@ function slugify(name: string): string {
.replace(/^-+|-+$/g, '');
}
const inputClass =
'w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50';
const AgentEditorPage = () => {
const { t } = useT();
const navigate = useNavigate();
@@ -204,7 +209,7 @@ const AgentEditorPage = () => {
<div className="p-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
<div className="flex items-center justify-center py-12 text-neutral-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('common.loading')}</span>
</div>
@@ -216,163 +221,215 @@ const AgentEditorPage = () => {
// Built-in agents can't be edited; they may only be enabled/disabled
// or reset from the agents list.
<div className="space-y-3">
<div className="rounded-lg border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300">
<div className="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm text-neutral-600 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300">
{t('settings.agents.editor.builtInReadonly')}
</div>
<button
type="button"
onClick={backToList}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
<Button type="button" variant="secondary" size="sm" onClick={backToList}>
{t('common.back')}
</button>
</Button>
</div>
) : (
<div className="space-y-4 text-sm">
<div className="space-y-4">
{/* Name — editable only on create; read-only identity on edit. */}
{isCreate ? (
<Field label={t('settings.agents.editor.name')}>
<input
autoFocus
value={name}
onChange={e => handleName(e.target.value)}
className={inputClass}
<SettingsSection>
{isCreate ? (
<SettingsRow
htmlFor="agent-name"
label={t('settings.agents.editor.name')}
stacked
control={
<SettingsTextField
id="agent-name"
autoFocus
value={name}
onChange={e => handleName(e.target.value)}
aria-label={t('settings.agents.editor.name')}
/>
}
/>
</Field>
) : (
<Field label={t('settings.agents.editor.name')}>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{name}
</span>
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-[10px] font-medium text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
{isCustom
? t('settings.agents.sourceCustom')
: t('settings.agents.sourceDefault')}
</span>
</div>
</Field>
)}
{/* ID — editable only on create. */}
{isCreate ? (
<Field
label={t('settings.agents.editor.id')}
hint={t('settings.agents.editor.idHint')}>
<input
value={agentId}
onChange={e => {
setIdTouched(true);
setAgentId(e.target.value);
}}
className={`${inputClass} font-mono`}
/>
</Field>
) : (
<Field label={t('settings.agents.editor.id')}>
<code className="block font-mono text-xs text-stone-500 dark:text-neutral-400">
{agentId}
</code>
</Field>
)}
<Field label={t('settings.agents.editor.description')}>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
className={`${inputClass} resize-y`}
/>
</Field>
{/* Model — dropdown of known hints/tiers + custom escape hatch. */}
<Field label={t('settings.agents.editor.model')}>
<select
value={selectValue}
onChange={e => onModelSelect(e.target.value)}
className={inputClass}>
<option value="">{t('settings.agents.editor.modelInherit')}</option>
<optgroup label={t('settings.agents.editor.modelHints')}>
{MODEL_HINTS.map(h => (
<option key={h} value={h}>
{h}
</option>
))}
</optgroup>
<optgroup label={t('settings.agents.editor.modelTiers')}>
{MODEL_TIERS.map(m => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
<option value={CUSTOM_MODEL}>{t('settings.agents.editor.modelCustom')}</option>
</select>
{customModelMode && (
<input
value={model}
onChange={e => setModel(e.target.value)}
placeholder={t('settings.agents.editor.modelCustomPlaceholder')}
className={`${inputClass} mt-2 font-mono`}
) : (
<SettingsRow
label={t('settings.agents.editor.name')}
control={
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{name}
</span>
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400">
{isCustom
? t('settings.agents.sourceCustom')
: t('settings.agents.sourceDefault')}
</span>
</div>
}
/>
)}
</Field>
<Field label={t('settings.agents.editor.systemPrompt')}>
<textarea
value={systemPrompt}
onChange={e => setSystemPrompt(e.target.value)}
rows={4}
className={`${inputClass} resize-y`}
{/* ID — editable only on create. */}
{isCreate ? (
<SettingsRow
htmlFor="agent-id"
label={t('settings.agents.editor.id')}
description={t('settings.agents.editor.idHint')}
stacked
control={
<SettingsTextField
id="agent-id"
mono
value={agentId}
onChange={e => {
setIdTouched(true);
setAgentId(e.target.value);
}}
aria-label={t('settings.agents.editor.id')}
/>
}
/>
) : (
<SettingsRow
label={t('settings.agents.editor.id')}
control={
<code className="font-mono text-xs text-neutral-500 dark:text-neutral-400">
{agentId}
</code>
}
/>
)}
</SettingsSection>
<SettingsSection>
<SettingsRow
htmlFor="agent-description"
label={t('settings.agents.editor.description')}
stacked
control={
<SettingsTextArea
id="agent-description"
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
aria-label={t('settings.agents.editor.description')}
/>
}
/>
</Field>
</SettingsSection>
{/* Model — dropdown of known hints/tiers + custom escape hatch. */}
<SettingsSection>
<SettingsRow
htmlFor="agent-model"
label={t('settings.agents.editor.model')}
stacked
control={
<div className="space-y-2">
<SettingsSelect
id="agent-model"
value={selectValue}
onChange={e => onModelSelect(e.target.value)}
aria-label={t('settings.agents.editor.model')}
className="w-full">
<option value="">{t('settings.agents.editor.modelInherit')}</option>
<optgroup label={t('settings.agents.editor.modelHints')}>
{MODEL_HINTS.map(h => (
<option key={h} value={h}>
{h}
</option>
))}
</optgroup>
<optgroup label={t('settings.agents.editor.modelTiers')}>
{MODEL_TIERS.map(m => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
<option value={CUSTOM_MODEL}>
{t('settings.agents.editor.modelCustom')}
</option>
</SettingsSelect>
{customModelMode && (
<SettingsTextField
mono
value={model}
onChange={e => setModel(e.target.value)}
placeholder={t('settings.agents.editor.modelCustomPlaceholder')}
aria-label={t('settings.agents.editor.modelCustomPlaceholder')}
/>
)}
</div>
}
/>
</SettingsSection>
<SettingsSection>
<SettingsRow
htmlFor="agent-system-prompt"
label={t('settings.agents.editor.systemPrompt')}
stacked
control={
<SettingsTextArea
id="agent-system-prompt"
value={systemPrompt}
onChange={e => setSystemPrompt(e.target.value)}
rows={4}
aria-label={t('settings.agents.editor.systemPrompt')}
/>
}
/>
</SettingsSection>
{/* Allowed tools — chips + modal picker. */}
<Field
label={t('settings.agents.editor.tools')}
hint={t('settings.agents.editor.toolsHint')}>
<div className="rounded-md border border-stone-200 p-2 dark:border-neutral-700">
<div className="flex flex-wrap items-center gap-1.5">
{allToolsSelected ? (
<span className="inline-flex items-center gap-1 rounded-full bg-ocean-50 px-2.5 py-1 text-xs font-medium text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200">
{t('settings.agents.editor.toolsAllSelected')}
</span>
) : toolAllowlist.length === 0 ? (
<span className="px-1 py-1 text-xs text-stone-400 dark:text-neutral-500">
{t('settings.agents.editor.toolsNoneSelected')}
</span>
) : (
toolAllowlist.map(tool => (
<span
key={tool}
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 font-mono text-xs text-stone-700 dark:bg-neutral-800 dark:text-neutral-200">
{tool}
<button
type="button"
aria-label={t('settings.agents.editor.removeToolAria').replace(
'{tool}',
tool
)}
onClick={() => setToolAllowlist(prev => prev.filter(x => x !== tool))}
className="rounded-full text-stone-400 hover:text-coral-600 dark:text-neutral-500 dark:hover:text-coral-300">
<LuX className="h-3 w-3" />
</button>
</span>
))
)}
<button
type="button"
aria-label={t('settings.agents.editor.selectTools')}
onClick={() => setToolsOpen(true)}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-stone-300 px-2.5 py-1 text-xs font-medium text-stone-600 hover:border-ocean-400 hover:text-ocean-600 dark:border-neutral-700 dark:text-neutral-300 dark:hover:border-ocean-500 dark:hover:text-ocean-300">
<LuPlus className="h-3 w-3" />
{t('settings.agents.editor.selectTools')}
</button>
</div>
</div>
</Field>
<SettingsSection>
<SettingsRow
label={t('settings.agents.editor.tools')}
description={t('settings.agents.editor.toolsHint')}
stacked
control={
<div className="rounded-md border border-neutral-200 p-2 dark:border-neutral-700">
<div className="flex flex-wrap items-center gap-1.5">
{allToolsSelected ? (
<span className="inline-flex items-center gap-1 rounded-full bg-ocean-50 px-2.5 py-1 text-xs font-medium text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200">
{t('settings.agents.editor.toolsAllSelected')}
</span>
) : toolAllowlist.length === 0 ? (
<span className="px-1 py-1 text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.agents.editor.toolsNoneSelected')}
</span>
) : (
toolAllowlist.map(tool => (
<span
key={tool}
className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2.5 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200">
{tool}
<button
type="button"
aria-label={t('settings.agents.editor.removeToolAria').replace(
'{tool}',
tool
)}
onClick={() => setToolAllowlist(prev => prev.filter(x => x !== tool))}
className="rounded-full text-neutral-400 hover:text-coral-600 dark:text-neutral-500 dark:hover:text-coral-300">
<LuX className="h-3 w-3" />
</button>
</span>
))
)}
<button
type="button"
aria-label={t('settings.agents.editor.selectTools')}
onClick={() => setToolsOpen(true)}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-600 hover:border-ocean-400 hover:text-ocean-600 dark:border-neutral-700 dark:text-neutral-300 dark:hover:border-ocean-500 dark:hover:text-ocean-300">
<LuPlus className="h-3 w-3" />
{t('settings.agents.editor.selectTools')}
</button>
</div>
</div>
}
/>
</SettingsSection>
{!isCreate && !isCustom && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
<p className="text-[11px] text-neutral-400 dark:text-neutral-500">
{t('settings.agents.editor.defaultsNote')}
</p>
)}
@@ -384,23 +441,21 @@ const AgentEditorPage = () => {
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={backToList}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
<Button type="button" variant="secondary" size="sm" onClick={backToList}>
{t('common.cancel')}
</button>
<button
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
variant="primary"
size="sm"
onClick={() => void handleSubmit()}
disabled={!canSubmit}>
{submitting
? t('settings.agents.editor.saving')
: isCreate
? t('settings.agents.editor.create')
: t('settings.agents.editor.save')}
</button>
</Button>
</div>
</div>
)}
@@ -472,13 +527,13 @@ function ToolsPickerModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4 py-6">
<section className="flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-lg border border-stone-200 bg-white shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-stone-200 px-4 py-3 dark:border-neutral-800">
<section className="flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-50">
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-50">
{t('settings.agents.editor.toolsModalTitle')}
</h3>
<p className="text-xs text-stone-400 dark:text-neutral-500">
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.agents.editor.toolsSelectedCount').replace(
'{count}',
String(selectedCount)
@@ -489,21 +544,21 @@ function ToolsPickerModal({
type="button"
aria-label={t('common.close')}
onClick={onClose}
className="rounded-full p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
className="rounded-full p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
<LuX className="h-4 w-4" />
</button>
</div>
<div className="border-b border-stone-200 px-4 py-3 dark:border-neutral-800">
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="relative">
<LuSearch className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-stone-400 dark:text-neutral-500" />
<input
<LuSearch className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
<SettingsTextField
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('settings.agents.editor.toolsSearchPlaceholder')}
aria-label={t('settings.agents.editor.toolsSearchPlaceholder')}
className={`${inputClass} pl-8`}
className="pl-8"
/>
</div>
@@ -513,13 +568,13 @@ function ToolsPickerModal({
className={`mt-2 flex w-full items-start justify-between gap-2 rounded-md border px-3 py-2 text-left transition-colors ${
allToolsSelected
? 'border-ocean-400 bg-ocean-50 dark:border-ocean-500/40 dark:bg-ocean-500/10'
: 'border-stone-200 hover:bg-stone-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
: 'border-neutral-200 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
}`}>
<span>
<span className="block text-xs font-semibold text-stone-800 dark:text-neutral-100">
<span className="block text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.agents.editor.toolsAllowAll')}
</span>
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
<span className="block text-[11px] text-neutral-400 dark:text-neutral-500">
{t('settings.agents.editor.toolsAllowAllHint')}
</span>
</span>
@@ -529,7 +584,7 @@ function ToolsPickerModal({
<div className="min-h-[8rem] flex-1 overflow-y-auto px-2 py-2">
{loading ? (
<div className="flex items-center justify-center py-10 text-stone-400 dark:text-neutral-500">
<div className="flex items-center justify-center py-10 text-neutral-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('settings.agents.editor.toolsLoading')}</span>
</div>
@@ -538,7 +593,7 @@ function ToolsPickerModal({
{t('settings.agents.editor.toolsLoadError')}: {error}
</p>
) : filtered.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-stone-400 dark:text-neutral-500">
<p className="px-2 py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">
{t('settings.agents.editor.toolsEmpty')}
</p>
) : (
@@ -551,13 +606,13 @@ function ToolsPickerModal({
type="button"
disabled={allToolsSelected}
onClick={() => onToggleTool(tool.name)}
className="flex w-full items-start gap-3 rounded-md px-2 py-2 text-left hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800">
className="flex w-full items-start gap-3 rounded-md px-2 py-2 text-left hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800">
<Checkbox checked={checked} className="mt-0.5" />
<span className="min-w-0">
<span className="block font-mono text-xs font-medium text-stone-800 dark:text-neutral-100">
<span className="block font-mono text-xs font-medium text-neutral-800 dark:text-neutral-100">
{tool.name}
</span>
<span className="block break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
<span className="block break-words text-[11px] leading-snug text-neutral-500 dark:text-neutral-400">
{tool.description}
</span>
</span>
@@ -569,13 +624,10 @@ function ToolsPickerModal({
)}
</div>
<div className="flex justify-end border-t border-stone-200 px-4 py-3 dark:border-neutral-800">
<button
type="button"
onClick={onClose}
className="rounded-md bg-ocean-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
<div className="flex justify-end border-t border-neutral-200 px-4 py-3 dark:border-neutral-800">
<Button type="button" variant="primary" size="sm" onClick={onClose}>
{t('settings.agents.editor.toolsDone')}
</button>
</Button>
</div>
</section>
</div>
@@ -588,7 +640,7 @@ function Checkbox({ checked, className = '' }: { checked: boolean; className?: s
className={`flex h-4 w-4 flex-none items-center justify-center rounded border transition-colors ${
checked
? 'border-ocean-600 bg-ocean-600 text-white'
: 'border-stone-300 bg-white dark:border-neutral-600 dark:bg-neutral-950'
: 'border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-950'
} ${className}`}>
{checked && (
<svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
@@ -603,26 +655,4 @@ function Checkbox({ checked, className = '' }: { checked: boolean; className?: s
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{label}
</span>
{children}
{hint && (
<span className="mt-1 block text-[11px] text-stone-400 dark:text-neutral-500">{hint}</span>
)}
</label>
);
}
export default AgentEditorPage;
@@ -13,7 +13,9 @@ import { useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsBadge, SettingsEmptyState, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ORCHESTRATOR_ID = 'orchestrator';
@@ -100,16 +102,17 @@ const AgentsPanel = () => {
<div className="p-4">
<div className="mb-4 flex items-start justify-between gap-3">
<p className="text-sm text-stone-500 dark:text-neutral-400">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.agents.subtitle')}
</p>
<button
<Button
type="button"
onClick={() => navigate('/settings/agents/new')}
className="inline-flex flex-none items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
<LuPlus className="h-3.5 w-3.5" />
variant="primary"
size="xs"
onClick={() => navigate('/settings/agents/new')}>
<LuPlus className="h-3.5 w-3.5 mr-1" />
{t('settings.agents.newAgent')}
</button>
</Button>
</div>
{actionError && (
@@ -119,7 +122,7 @@ const AgentsPanel = () => {
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
<div className="flex items-center justify-center py-12 text-neutral-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('common.loading')}</span>
</div>
@@ -128,11 +131,9 @@ const AgentsPanel = () => {
{t('settings.agents.loadError')}: {error}
</div>
) : agents.length === 0 ? (
<p className="py-12 text-center text-sm text-stone-400 dark:text-neutral-500">
{t('settings.agents.empty')}
</p>
<SettingsEmptyState label={t('settings.agents.empty')} />
) : (
<ul className="divide-y divide-stone-200 overflow-hidden rounded-xl border border-stone-200 dark:divide-neutral-800 dark:border-neutral-800">
<ul className="divide-y divide-neutral-200 overflow-hidden rounded-xl border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800">
{agents.map(agent => (
<AgentRow
key={agent.id}
@@ -175,42 +176,27 @@ function AgentRow({
<li className={`bg-white px-4 py-3 dark:bg-neutral-900 ${agent.enabled ? '' : 'opacity-70'}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-stone-800 dark:text-neutral-100">
<h3 className="truncate text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{agent.name}
</h3>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
isCustom
? 'bg-ocean-50 text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200'
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
<SettingsBadge variant={isCustom ? 'primary' : 'neutral'}>
{isCustom ? t('settings.agents.sourceCustom') : t('settings.agents.sourceDefault')}
</span>
</SettingsBadge>
</div>
<button
type="button"
role="switch"
aria-checked={agent.enabled}
aria-label={agent.enabled ? t('settings.agents.disable') : t('settings.agents.enable')}
<SettingsSwitch
id={`agent-toggle-${agent.id}`}
checked={agent.enabled}
onCheckedChange={onToggle}
disabled={busy || isOrchestrator}
title={isOrchestrator ? t('settings.agents.orchestratorLocked') : undefined}
onClick={onToggle}
className={`relative h-5 w-9 flex-none rounded-full transition-colors disabled:opacity-40 ${
agent.enabled ? 'bg-ocean-600' : 'bg-stone-300 dark:bg-neutral-700'
}`}>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform dark:bg-neutral-900 ${
agent.enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
aria-label={agent.enabled ? t('settings.agents.disable') : t('settings.agents.enable')}
/>
</div>
<p className="mt-1 break-words text-xs leading-snug text-stone-500 dark:text-neutral-400">
<p className="mt-1 break-words text-xs leading-snug text-neutral-500 dark:text-neutral-400">
{agent.description}
</p>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-stone-400 dark:text-neutral-500">
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-neutral-400 dark:text-neutral-500">
<code className="font-mono">{agent.id}</code>
{agent.model && (
<span>
@@ -226,22 +212,19 @@ function AgentRow({
{/* Built-in agents can't be edited — only custom agents expose Edit.
Built-ins keep the toggle (enable/disable) and Reset (clear override). */}
{isCustom && (
<button
type="button"
onClick={onEdit}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuPencil className="h-3 w-3" />
<Button type="button" variant="ghost" size="xs" onClick={onEdit}>
<LuPencil className="h-3 w-3 mr-1" />
{t('settings.agents.edit')}
</button>
</Button>
)}
<button
type="button"
disabled={busy}
onClick={onRemove}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-coral-600 hover:bg-coral-50 disabled:opacity-40 dark:text-coral-300 dark:hover:bg-coral-500/10">
{isCustom ? <LuTrash2 className="h-3 w-3" /> : <LuRotateCcw className="h-3 w-3" />}
<Button type="button" variant="danger" size="xs" disabled={busy} onClick={onRemove}>
{isCustom ? (
<LuTrash2 className="h-3 w-3 mr-1" />
) : (
<LuRotateCcw className="h-3 w-3 mr-1" />
)}
{isCustom ? t('settings.agents.delete') : t('settings.agents.reset')}
</button>
</Button>
</div>
</li>
);
@@ -13,6 +13,7 @@ import {
type ThemeMode,
} from '../../../store/themeSlice';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface ModeOption {
@@ -136,7 +137,7 @@ const AppearancePanel = () => {
];
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.appearance.title')}
showBackButton
@@ -145,6 +146,7 @@ const AppearancePanel = () => {
/>
<div className="p-4 space-y-4">
{/* ── Theme picker — intentional bespoke tile UI ─────────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.appearance.themeHeading')}
@@ -209,6 +211,7 @@ const AppearancePanel = () => {
</p>
</div>
{/* ── Font size picker — intentional bespoke tile UI ─────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.appearance.fontSizeHeading')}
@@ -275,73 +278,39 @@ const AppearancePanel = () => {
</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.appearance.tabBarHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
<button
type="button"
role="switch"
aria-checked={labelsAlwaysVisible}
onClick={toggleTabBarLabels}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/60 focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30">
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.appearance.tabBarAlwaysShowLabels')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.appearance.tabBarAlwaysShowLabelsDesc')}
</span>
</span>
<span
aria-hidden
className={`relative inline-flex w-10 h-6 rounded-full transition-colors flex-shrink-0 ${
labelsAlwaysVisible ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-700'
}`}>
<span
className={`absolute top-0.5 inline-block w-5 h-5 rounded-full bg-white shadow transition-transform ${
labelsAlwaysVisible ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</span>
</button>
</div>
</div>
{/* ── Tab bar labels toggle ──────────────────────────────────── */}
<SettingsSection title={t('settings.appearance.tabBarHeading')}>
<SettingsRow
htmlFor="switch-tab-bar-labels"
label={t('settings.appearance.tabBarAlwaysShowLabels')}
description={t('settings.appearance.tabBarAlwaysShowLabelsDesc')}
control={
<SettingsSwitch
id="switch-tab-bar-labels"
checked={labelsAlwaysVisible}
onCheckedChange={toggleTabBarLabels}
aria-label={t('settings.appearance.tabBarAlwaysShowLabels')}
/>
}
/>
</SettingsSection>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.appearance.chatHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
<button
type="button"
role="switch"
aria-checked={assistantTextModeEnabled}
onClick={toggleAssistantTextMode}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/60 focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30">
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.appearance.assistantTextMode')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.appearance.assistantTextModeDesc')}
</span>
</span>
<span
aria-hidden
className={`relative inline-flex w-10 h-6 rounded-full transition-colors flex-shrink-0 ${
assistantTextModeEnabled ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-700'
}`}>
<span
className={`absolute top-0.5 inline-block w-5 h-5 rounded-full bg-white shadow transition-transform ${
assistantTextModeEnabled ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</span>
</button>
</div>
</div>
{/* ── Chat display toggle ────────────────────────────────────── */}
<SettingsSection title={t('settings.appearance.chatHeading')}>
<SettingsRow
htmlFor="switch-assistant-text-mode"
label={t('settings.appearance.assistantTextMode')}
description={t('settings.appearance.assistantTextModeDesc')}
control={
<SettingsSwitch
id="switch-assistant-text-mode"
checked={assistantTextModeEnabled}
onCheckedChange={toggleAssistantTextMode}
aria-label={t('settings.appearance.assistantTextMode')}
/>
}
/>
</SettingsSection>
</div>
</div>
);
@@ -7,7 +7,14 @@ import {
type ApprovalDecision,
fetchRecentApprovalDecisions,
} from '../../../services/api/approvalApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsEmptyState,
SettingsSection,
SettingsStatusLine,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('ui:approval-history');
@@ -18,12 +25,11 @@ const formatDateTime = (value: string): string => {
return Number.isNaN(ts) ? value : new Date(ts).toLocaleString();
};
/** Tailwind tone + i18n label key per decision variant. */
const DECISION_TONE: Record<ApprovalDecision, string> = {
approve_once: 'bg-sage-50 text-sage ring-sage-200',
approve_always_for_tool: 'bg-sage-50 text-sage ring-sage-200',
deny: 'bg-coral-50 text-coral ring-coral-200',
};
/** SettingsBadge variant per decision variant. */
const DECISION_BADGE_VARIANT: Record<
ApprovalDecision,
'success' | 'danger' | 'warning' | 'neutral' | 'primary'
> = { approve_once: 'success', approve_always_for_tool: 'success', deny: 'danger' };
const DECISION_LABEL_KEY: Record<ApprovalDecision, string> = {
approve_once: 'settings.approvalHistory.decision.approveOnce',
@@ -90,63 +96,80 @@ const ApprovalHistoryPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4" data-testid="approval-history-panel">
<div className="flex items-center justify-between">
<p className="text-xs text-ink-soft">{t('settings.approvalHistory.subtitle')}</p>
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
data-testid="approval-history-refresh"
className="rounded bg-primary-500 px-3 py-1 text-xs text-white hover:bg-primary-600 disabled:opacity-50">
{t('settings.approvalHistory.refresh')}
</button>
</div>
{isLoading ? (
<p className="text-sm text-ink-soft" data-testid="approval-history-loading">
{t('settings.approvalHistory.loading')}
</p>
) : error ? (
<div className="space-y-2" data-testid="approval-history-error">
<p className="text-sm text-coral">{error}</p>
<button
<div className="p-4 pt-2 space-y-5" data-testid="approval-history-panel">
<SettingsSection>
<div className="px-4 py-3 flex items-center justify-between gap-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.approvalHistory.subtitle')}
</p>
<Button
type="button"
variant="primary"
size="xs"
onClick={handleRefresh}
className="text-xs text-primary-600 hover:underline">
{t('settings.approvalHistory.retry')}
</button>
disabled={isLoading}
data-testid="approval-history-refresh">
{t('settings.approvalHistory.refresh')}
</Button>
</div>
) : entries.length === 0 ? (
<p className="text-sm text-ink-soft" data-testid="approval-history-empty">
{t('settings.approvalHistory.emptyState')}
</p>
) : (
<ul className="space-y-2" data-testid="approval-history-list">
{entries.map(entry => (
<li
key={entry.request_id}
className="rounded-lg border border-line p-3 space-y-1"
data-testid="approval-history-row">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-ink truncate">{entry.tool_name}</span>
<span
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ${DECISION_TONE[entry.decision]}`}
data-testid={`approval-history-decision-${entry.decision}`}>
{t(DECISION_LABEL_KEY[entry.decision])}
</span>
</div>
<p className="text-xs text-ink-soft">{entry.action_summary}</p>
<p className="text-[11px] text-ink-soft">
{t('settings.approvalHistory.decidedAt').replace(
'{date}',
formatDateTime(entry.decided_at)
)}
</p>
</li>
))}
</ul>
)}
{isLoading ? (
<div
className="px-4 py-4 text-sm text-neutral-500 dark:text-neutral-400"
data-testid="approval-history-loading">
{t('settings.approvalHistory.loading')}
</div>
) : error ? (
<div className="px-4 py-4 space-y-2" data-testid="approval-history-error">
<SettingsStatusLine saving={false} error={error} savingLabel="" />
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleRefresh}
className="text-primary-600 dark:text-primary-400">
{t('settings.approvalHistory.retry')}
</Button>
</div>
) : entries.length === 0 ? (
<div className="px-4 py-8 text-center" data-testid="approval-history-empty">
<SettingsEmptyState label={t('settings.approvalHistory.emptyState')} />
</div>
) : (
<ul
className="divide-y divide-neutral-100 dark:divide-neutral-800"
data-testid="approval-history-list">
{entries.map(entry => (
<li
key={entry.request_id}
className="px-4 py-3 space-y-1"
data-testid="approval-history-row">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-neutral-800 dark:text-neutral-100 truncate">
{entry.tool_name}
</span>
<span
data-testid={`approval-history-decision-${entry.decision}`}
className="flex-shrink-0">
<SettingsBadge variant={DECISION_BADGE_VARIANT[entry.decision]}>
{t(DECISION_LABEL_KEY[entry.decision])}
</SettingsBadge>
</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{entry.action_summary}
</p>
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.approvalHistory.decidedAt').replace(
'{date}',
formatDateTime(entry.decided_at)
)}
</p>
</li>
))}
</ul>
)}
</SettingsSection>
</div>
</div>
);
@@ -17,7 +17,10 @@ import {
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsSection, SettingsStatusLine, SettingsTextArea } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const DEFAULT_CONFIG: AutocompleteConfig = {
@@ -486,285 +489,288 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Runtime section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.appFilter.runtime')}
</h3>
<div className="text-sm text-stone-700 dark:text-neutral-200 space-y-1">
<div>
{t('settings.autocomplete.appFilter.platformSupported')}:{' '}
{status?.platform_supported ? t('common.yes') : t('common.no')}
<SettingsSection title={t('settings.autocomplete.appFilter.runtime')}>
<div className="px-4 py-3 space-y-3">
<div className="text-sm text-neutral-800 dark:text-neutral-200 space-y-1">
<div>
{t('settings.autocomplete.appFilter.platformSupported')}:{' '}
{status?.platform_supported ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.enabled')}:{' '}
{status?.enabled ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.running')}:{' '}
{status?.running ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.phase')}:{' '}
{status?.phase ?? t('settings.autocomplete.shared.unknown')}
</div>
<div>
{t('settings.autocomplete.appFilter.debounce')}:{' '}
{`${String(status?.debounce_ms ?? 0)}ms`}
</div>
<div>
{t('settings.autocomplete.appFilter.model')}:{' '}
{status?.model_id ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.app')}:{' '}
{status?.app_name ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.lastError')}:{' '}
{status?.last_error ?? t('settings.autocomplete.shared.none')}
</div>
<div>
{t('settings.autocomplete.appFilter.currentSuggestion')}:{' '}
{status?.suggestion?.value ?? t('settings.autocomplete.shared.none')}
</div>
</div>
<div>
{t('settings.autocomplete.appFilter.enabled')}:{' '}
{status?.enabled ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.running')}:{' '}
{status?.running ? t('common.yes') : t('common.no')}
</div>
<div>
{t('settings.autocomplete.appFilter.phase')}:{' '}
{status?.phase ?? t('settings.autocomplete.shared.unknown')}
</div>
<div>
{t('settings.autocomplete.appFilter.debounce')}:{' '}
{`${String(status?.debounce_ms ?? 0)}ms`}
</div>
<div>
{t('settings.autocomplete.appFilter.model')}:{' '}
{status?.model_id ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.app')}:{' '}
{status?.app_name ?? t('settings.autocomplete.shared.notApplicable')}
</div>
<div>
{t('settings.autocomplete.appFilter.lastError')}:{' '}
{status?.last_error ?? t('settings.autocomplete.shared.none')}
</div>
<div>
{t('settings.autocomplete.appFilter.currentSuggestion')}:{' '}
{status?.suggestion?.value ?? t('settings.autocomplete.shared.none')}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void refreshStatus(true)}
disabled={isLoading}>
{isLoading
? t('settings.autocomplete.appFilter.refreshing')
: t('settings.autocomplete.appFilter.refreshStatus')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void start()}
disabled={!status?.platform_supported || Boolean(status?.running)}>
{t('autocomplete.start')}
</Button>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => void stop()}
disabled={!status?.running}>
{t('autocomplete.stop')}
</Button>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void refreshStatus(true)}
disabled={isLoading}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 px-3 py-2 text-sm text-stone-700 dark:text-neutral-200 disabled:opacity-50">
{isLoading
? t('settings.autocomplete.appFilter.refreshing')
: t('settings.autocomplete.appFilter.refreshStatus')}
</button>
<button
type="button"
onClick={() => void start()}
disabled={!status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
{t('autocomplete.start')}
</button>
<button
type="button"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300 disabled:opacity-50">
{t('autocomplete.stop')}
</button>
</div>
</section>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Test section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.appFilter.test')}
</h3>
<div className="space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('settings.autocomplete.appFilter.contextOverride')}
<SettingsSection title={t('settings.autocomplete.appFilter.test')}>
<div className="px-4 py-3 space-y-3">
<div className="space-y-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.autocomplete.appFilter.contextOverride')}
</div>
<SettingsTextArea
value={contextOverride}
onChange={event => setContextOverride(event.target.value)}
rows={3}
aria-label={t('settings.autocomplete.appFilter.contextOverride')}
/>
</div>
<textarea
value={contextOverride}
onChange={event => setContextOverride(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void testCurrent()}>
{t('settings.autocomplete.appFilter.getSuggestion')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void acceptSuggestion()}>
{t('settings.autocomplete.appFilter.acceptSuggestion')}
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => void debugFocus()}>
{t('settings.autocomplete.appFilter.debugFocus')}
</Button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-2 text-xs text-neutral-800 dark:text-neutral-200">
{focusDebug}
</pre>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void testCurrent()}
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300">
{t('settings.autocomplete.appFilter.getSuggestion')}
</button>
<button
type="button"
onClick={() => void acceptSuggestion()}
className="rounded-lg border border-emerald-500/60 bg-emerald-50 dark:bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{t('settings.autocomplete.appFilter.acceptSuggestion')}
</button>
<button
type="button"
onClick={() => void debugFocus()}
className="rounded-lg border border-amber-500/60 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
{t('settings.autocomplete.appFilter.debugFocus')}
</button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200">
{focusDebug}
</pre>
)}
</section>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Live Logs section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.appFilter.liveLogs')}
</h3>
<button
type="button"
onClick={clearLogs}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200">
{t('common.clear')}
</button>
<SettingsSection title={t('settings.autocomplete.appFilter.liveLogs')}>
<div className="px-4 py-3 space-y-3">
<div className="flex justify-end">
<Button type="button" variant="secondary" size="xs" onClick={clearLogs}>
{t('common.clear')}
</Button>
</div>
{/* Bespoke log-stream display — kept intact */}
<pre className="max-h-56 overflow-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-2 text-xs text-neutral-800 dark:text-neutral-200">
{logs.length > 0 ? logs.join('\n') : t('settings.autocomplete.appFilter.noLogs')}
</pre>
</div>
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200">
{logs.length > 0 ? logs.join('\n') : t('settings.autocomplete.appFilter.noLogs')}
</pre>
</section>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Advanced settings */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autocomplete.advancedSettings')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.debounce')}
</span>
<input
type="number"
min={50}
max={2000}
step={10}
value={debounceMs}
onChange={event => setDebounceMs(event.target.value)}
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.maxChars')}
</span>
<input
type="number"
min={32}
max={1200}
step={8}
value={maxChars}
onChange={event => setMaxChars(event.target.value)}
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.overlayTtl')}
</span>
<input
type="number"
min={300}
max={10000}
step={100}
value={overlayTtlMs}
onChange={event => setOverlayTtlMs(event.target.value)}
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('settings.autocomplete.completionStyle.styleInstructions')}
<SettingsSection title={t('autocomplete.advancedSettings')}>
<div className="px-4 py-3 space-y-3">
<label className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-neutral-800 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.debounce')}
</span>
<Input
type="number"
inputSize="sm"
min={50}
max={2000}
step={10}
value={debounceMs}
onChange={event => setDebounceMs(event.target.value)}
className="w-28"
aria-label={t('settings.autocomplete.completionStyle.debounce')}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-neutral-800 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.maxChars')}
</span>
<Input
type="number"
inputSize="sm"
min={32}
max={1200}
step={8}
value={maxChars}
onChange={event => setMaxChars(event.target.value)}
className="w-28"
aria-label={t('settings.autocomplete.completionStyle.maxChars')}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-neutral-800 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.overlayTtl')}
</span>
<Input
type="number"
inputSize="sm"
min={300}
max={10000}
step={100}
value={overlayTtlMs}
onChange={event => setOverlayTtlMs(event.target.value)}
className="w-28"
aria-label={t('settings.autocomplete.completionStyle.overlayTtl')}
/>
</label>
<div className="space-y-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.autocomplete.completionStyle.styleInstructions')}
</div>
<SettingsTextArea
value={styleInstructions}
onChange={event => setStyleInstructions(event.target.value)}
rows={3}
aria-label={t('settings.autocomplete.completionStyle.styleInstructions')}
/>
</div>
<textarea
value={styleInstructions}
onChange={event => setStyleInstructions(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('settings.autocomplete.completionStyle.styleExamples')}
<div className="space-y-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.autocomplete.completionStyle.styleExamples')}
</div>
<SettingsTextArea
value={styleExamplesText}
onChange={event => setStyleExamplesText(event.target.value)}
rows={3}
aria-label={t('settings.autocomplete.completionStyle.styleExamples')}
/>
</div>
<textarea
value={styleExamplesText}
onChange={event => setStyleExamplesText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveAdvancedConfig()}
disabled={isSaving}>
{isSaving ? t('autocomplete.saving') : t('common.save')}
</Button>
</div>
<button
type="button"
onClick={() => void saveAdvancedConfig()}
disabled={isSaving}
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300 disabled:opacity-50">
{isSaving ? t('autocomplete.saving') : t('common.save')}
</button>
</section>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Personalization History */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.completionStyle.personalizationHistory')}
</h3>
<button
type="button"
onClick={() => void clearHistory()}
disabled={isClearingHistory || historyEntries.length === 0}
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-1.5 text-xs text-red-600 dark:text-red-300 disabled:opacity-40">
{isClearingHistory
? t('settings.autocomplete.completionStyle.clearing')
: t('settings.autocomplete.completionStyle.clearHistory')}
</button>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{isHistoryLoading
? t('common.loading')
: historyEntries.length === 0
? t('settings.autocomplete.completionStyle.noHistory')
: (historyEntries.length === 1
? t('settings.autocomplete.completionStyle.acceptedCompletion')
: t('settings.autocomplete.completionStyle.acceptedCompletions')
).replace('{count}', String(historyEntries.length))}
</p>
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs border border-stone-100 dark:border-neutral-800">
<div className="flex items-center gap-2 text-stone-500 dark:text-neutral-400">
<span className="shrink-0">
{new Date(entry.timestamp_ms).toLocaleString()}
</span>
{entry.app_name && (
<span className="rounded bg-stone-100 dark:bg-neutral-800 px-1 text-stone-600 dark:text-neutral-300">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-stone-700 dark:text-neutral-200 truncate">
<span className="shrink-0 text-stone-400 dark:text-neutral-500"></span>
<span className="truncate text-stone-500 dark:text-neutral-400">
{entry.context.slice(-40)}
</span>
<span className="shrink-0 text-stone-400 dark:text-neutral-500"></span>
<span className="font-medium text-primary-500 truncate">
{entry.suggestion}
</span>
</div>
</div>
))}
<SettingsSection title={t('settings.autocomplete.completionStyle.personalizationHistory')}>
<div className="px-4 py-3 space-y-3">
<div className="flex justify-end">
<Button
type="button"
variant="danger"
size="xs"
onClick={() => void clearHistory()}
disabled={isClearingHistory || historyEntries.length === 0}>
{isClearingHistory
? t('settings.autocomplete.completionStyle.clearing')
: t('settings.autocomplete.completionStyle.clearHistory')}
</Button>
</div>
)}
</section>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{isHistoryLoading
? t('common.loading')
: historyEntries.length === 0
? t('settings.autocomplete.completionStyle.noHistory')
: (historyEntries.length === 1
? t('settings.autocomplete.completionStyle.acceptedCompletion')
: t('settings.autocomplete.completionStyle.acceptedCompletions')
).replace('{count}', String(historyEntries.length))}
</p>
{/* Bespoke history list — kept intact */}
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center gap-2 text-neutral-500 dark:text-neutral-400">
<span className="shrink-0">
{new Date(entry.timestamp_ms).toLocaleString()}
</span>
{entry.app_name && (
<span className="rounded bg-neutral-100 dark:bg-neutral-800 px-1 text-neutral-500 dark:text-neutral-400">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-neutral-800 dark:text-neutral-200 truncate">
<span className="shrink-0 text-neutral-500 dark:text-neutral-400"></span>
<span className="truncate text-neutral-500 dark:text-neutral-400">
{entry.context.slice(-40)}
</span>
<span className="shrink-0 text-neutral-500 dark:text-neutral-400"></span>
<span className="font-medium text-primary-500 truncate">
{entry.suggestion}
</span>
</div>
</div>
))}
</div>
)}
</div>
</SettingsSection>
{/* ------------------------------------------------------------------ */}
{/* Feedback messages */}
{/* ------------------------------------------------------------------ */}
{message && <div className="text-xs text-green-700 dark:text-green-300">{message}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-300">{error}</div>}
<SettingsStatusLine saving={false} savedNote={message} error={error} savingLabel="" />
</div>
</div>
);
@@ -11,7 +11,17 @@ import {
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsNumberField,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
SettingsTextArea,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const DEFAULT_CONFIG: AutocompleteConfig = {
@@ -214,118 +224,130 @@ const AutocompletePanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autocomplete.settings')}
</h3>
<div className="p-4 pt-2 space-y-5">
{/* ── Settings ──────────────────────────────────────────────── */}
<SettingsSection title={t('autocomplete.settings')}>
<SettingsRow
label={t('common.enabled')}
control={
<SettingsSwitch
id="autocomplete-enabled"
checked={enabled}
onCheckedChange={setEnabled}
aria-label={t('common.enabled')}
/>
}
/>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('common.enabled')}
</span>
<input
type="checkbox"
checked={enabled}
onChange={event => setEnabled(event.target.checked)}
/>
</label>
<SettingsRow
label={t('autocomplete.acceptWithTab')}
control={
<SettingsSwitch
id="autocomplete-accept-with-tab"
checked={acceptWithTab}
onCheckedChange={setAcceptWithTab}
aria-label={t('autocomplete.acceptWithTab')}
/>
}
/>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.acceptWithTab')}
</span>
<input
type="checkbox"
checked={acceptWithTab}
onChange={event => setAcceptWithTab(event.target.checked)}
/>
</label>
<SettingsRow
htmlFor="autocomplete-style-preset"
label={t('autocomplete.stylePreset')}
control={
<SettingsSelect
id="autocomplete-style-preset"
value={stylePreset}
onChange={e => setStylePreset(e.target.value)}>
<option value="balanced">{t('autocomplete.style.balanced')}</option>
<option value="concise">{t('autocomplete.style.concise')}</option>
<option value="formal">{t('autocomplete.style.formal')}</option>
<option value="casual">{t('autocomplete.style.casual')}</option>
<option value="custom">{t('autocomplete.style.custom')}</option>
</SettingsSelect>
}
/>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.stylePreset')}
</span>
<select
value={stylePreset}
onChange={event => setStylePreset(event.target.value)}
className="rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200">
<option value="balanced">{t('autocomplete.style.balanced')}</option>
<option value="concise">{t('autocomplete.style.concise')}</option>
<option value="formal">{t('autocomplete.style.formal')}</option>
<option value="casual">{t('autocomplete.style.casual')}</option>
<option value="custom">{t('autocomplete.style.custom')}</option>
</select>
</label>
<SettingsRow
htmlFor="autocomplete-disabled-apps"
label={t('autocomplete.disabledApps')}
stacked
control={
<SettingsTextArea
id="autocomplete-disabled-apps"
value={disabledAppsText}
onChange={e => setDisabledAppsText(e.target.value)}
rows={3}
/>
}
/>
<div className="space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('autocomplete.disabledApps')}
</div>
<textarea
value={disabledAppsText}
onChange={event => setDisabledAppsText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
<SettingsRow
label={t('autocomplete.debounceMs')}
control={
<SettingsNumberField
id="autocomplete-debounce-ms-field"
value={debounceMs}
onChange={setDebounceMs}
onCommit={() => void saveConfig()}
unit="ms"
min={0}
max={5000}
aria-label={t('autocomplete.debounceMs')}
data-testid="autocomplete-debounce-ms"
/>
}
/>
<SettingsRow
label={t('autocomplete.maxChars')}
control={
<SettingsNumberField
id="autocomplete-max-chars-field"
value={maxChars}
onChange={setMaxChars}
onCommit={() => void saveConfig()}
unit={t('autocomplete.chars')}
min={1}
max={8192}
aria-label={t('autocomplete.maxChars')}
data-testid="autocomplete-max-chars"
/>
}
/>
<SettingsRow
label={t('autocomplete.overlayTtlMs')}
control={
<SettingsNumberField
id="autocomplete-overlay-ttl-ms-field"
value={overlayTtlMs}
onChange={setOverlayTtlMs}
onCommit={() => void saveConfig()}
unit="ms"
min={0}
max={30000}
aria-label={t('autocomplete.overlayTtlMs')}
data-testid="autocomplete-overlay-ttl-ms"
/>
}
/>
<div className="flex items-center gap-2 px-4 py-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveConfig()}
disabled={isSaving || !configLoaded}>
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</Button>
</div>
</SettingsSection>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.debounceMs')}
</span>
<input
type="number"
min={0}
value={debounceMs}
data-testid="autocomplete-debounce-ms"
onChange={event => setDebounceMs(event.target.value)}
className="w-24 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.maxChars')}
</span>
<input
type="number"
min={1}
value={maxChars}
data-testid="autocomplete-max-chars"
onChange={event => setMaxChars(event.target.value)}
className="w-24 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.overlayTtlMs')}
</span>
<input
type="number"
min={0}
value={overlayTtlMs}
data-testid="autocomplete-overlay-ttl-ms"
onChange={event => setOverlayTtlMs(event.target.value)}
className="w-24 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSaving || !configLoaded}
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300 disabled:opacity-50">
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</button>
</section>
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autocomplete.runtime')}
</h3>
<div className="text-sm text-stone-600 dark:text-neutral-300 space-y-1">
{/* ── Runtime ────────────────────────────────────────────────── */}
<SettingsSection title={t('autocomplete.runtime')}>
<div className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-300 space-y-1">
<div>
{t('autocomplete.running')}: {status?.running ? t('common.yes') : t('common.no')}
</div>
@@ -333,31 +355,39 @@ const AutocompletePanel = () => {
{t('common.enabled')}: {status?.enabled ? t('common.yes') : t('common.no')}
</div>
</div>
<div className="flex gap-2">
<button
<div className="flex gap-2 px-4 pb-3">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void start()}
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}>
{t('autocomplete.start')}
</button>
<button
</Button>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300 disabled:opacity-50">
disabled={!status?.running}>
{t('autocomplete.stop')}
</button>
</Button>
</div>
</section>
</SettingsSection>
{message && <div className="text-xs text-green-700 dark:text-green-300">{message}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-300">{error}</div>}
{/* ── Status messages ─────────────────────────────────────────── */}
<SettingsStatusLine
saving={isSaving}
savedNote={message}
error={error}
savingLabel={t('autocomplete.saving')}
/>
{/* ── Advanced link ────────────────────────────────────────────── */}
<button
type="button"
onClick={() => navigateToSettings('autocomplete-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
className="flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors">
{t('autocomplete.advancedSettings')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
@@ -5,7 +5,9 @@ import {
openhumanGetAutonomySettings,
openhumanUpdateAutonomySettings,
} from '../../../utils/tauriCommands/config';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsNumberField, SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// u32::MAX — the Rust default and our sentinel for "no limit". Inputs at or
@@ -94,6 +96,10 @@ const AutonomyPanel = () => {
}
};
const savedNote = status.kind === 'saved' ? t('autonomy.statusSaved') : null;
const errorMsg =
status.kind === 'error' ? `${t('autonomy.statusFailed')}: ${status.message}` : null;
return (
<div className="z-10 relative">
<SettingsHeader
@@ -102,78 +108,76 @@ const AutonomyPanel = () => {
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 flex flex-col gap-4">
<section className="px-4 py-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<label
htmlFor="autonomy-max-actions"
className="block text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autonomy.maxActionsLabel')}
</label>
<p className="text-xs text-stone-600 dark:text-neutral-400 mt-1">
{t('autonomy.maxActionsHelp')}
</p>
<div className="p-4 pt-2 space-y-5">
<SettingsSection
title={t('autonomy.maxActionsLabel')}
description={t('autonomy.maxActionsHelp')}>
<SettingsRow
stacked
control={
<div className="space-y-3">
<div className="flex items-center gap-2">
<SettingsNumberField
id="autonomy-max-actions"
value={draft}
onChange={v => {
setDraft(v);
if (status.kind === 'saved' || status.kind === 'error') {
setStatus({ kind: 'idle' });
}
}}
onCommit={() => {}}
unit={t('autonomy.maxActionsLabel')}
min={MIN}
max={MAX}
disabled={status.kind === 'loading' || status.kind === 'saving'}
invalid={!isValid && trimmed !== ''}
aria-label={t('autonomy.maxActionsLabel')}
/>
<Button
type="button"
variant="primary"
size="xs"
onClick={() => void onSave()}
disabled={!canSave}>
{status.kind === 'saving' ? t('autonomy.statusSaving') : t('common.save')}
</Button>
</div>
<div className="mt-3 flex items-center gap-2">
<input
id="autonomy-max-actions"
type="number"
min={MIN}
max={MAX}
step={1}
value={draft}
onChange={e => {
setDraft(e.target.value);
if (status.kind === 'saved' || status.kind === 'error') {
setStatus({ kind: 'idle' });
}
}}
disabled={status.kind === 'loading' || status.kind === 'saving'}
className="w-32 px-3 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm font-mono"
/>
<button
onClick={onSave}
disabled={!canSave}
className="px-3 py-1.5 rounded-md bg-primary-600 hover:bg-primary-500 disabled:opacity-50 text-white text-xs font-medium transition-colors">
{status.kind === 'saving' ? t('autonomy.statusSaving') : t('common.save')}
</button>
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map(p => (
<Button
key={p.value}
type="button"
variant="ghost"
size="xs"
onClick={() => applyPreset(p.value)}>
{p.labelKey ? t(p.labelKey) : p.label}
</Button>
))}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{PRESETS.map(p => (
<button
key={p.value}
onClick={() => applyPreset(p.value)}
className="px-2 py-1 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800">
{p.labelKey ? t(p.labelKey) : p.label}
</button>
))}
</div>
{!isValid && trimmed !== '' && (
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('autonomy.invalidIntegerMsg')}
</p>
)}
{isValid && parsed === UNLIMITED && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('autonomy.unlimitedNote')}
</p>
)}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="mt-3 text-xs min-h-[1rem]">
{!isValid && draft.trim() !== '' && (
<span className="text-coral-600 dark:text-coral-300">
{t('autonomy.invalidIntegerMsg')}
</span>
)}
{isValid && parsed === UNLIMITED && (
<span className="text-stone-500 dark:text-neutral-400">
{t('autonomy.unlimitedNote')}
</span>
)}
{status.kind === 'saved' && (
<span className="text-sage-700 dark:text-sage-300">{t('autonomy.statusSaved')}</span>
)}
{status.kind === 'error' && (
<span className="text-coral-600 dark:text-coral-300">
{t('autonomy.statusFailed')}: {status.message}
</span>
)}
</div>
</section>
<SettingsStatusLine
saving={status.kind === 'saving'}
savedNote={savedNote}
error={errorMsg}
savingLabel={t('autonomy.statusSaving')}
/>
</div>
}
/>
</SettingsSection>
</div>
</div>
);
@@ -4,6 +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 Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -51,41 +52,39 @@ const BillingPanel = () => {
<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">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-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">
<h1 className="mt-2 text-2xl font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.billing.openDashboard')}
</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-neutral-300">
<p className="mt-2 text-sm leading-6 text-neutral-600 dark:text-neutral-300">
{t('settings.billing.movedToWebDesc')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
<Button
type="button"
variant="primary"
size="md"
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">
</Button>
<Button type="button" variant="secondary" size="md" onClick={navigateBack}>
{t('settings.billing.backToSettings')}
</button>
</Button>
</div>
{status === 'opening' && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.billing.openingBrowser')}
</p>
)}
{status === 'idle' && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.billing.browserNotOpen')}
</p>
)}
@@ -9,7 +9,9 @@ import type {
StopCompanionSessionResult,
} from '../../../store/companionSlice';
import { useAppSelector } from '../../../store/hooks';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const CompanionPanel = () => {
@@ -97,7 +99,7 @@ const CompanionPanel = () => {
const sessionActive = status?.active ?? false;
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.companion.title')}
showBackButton
@@ -106,103 +108,119 @@ const CompanionPanel = () => {
/>
<div className="space-y-4 p-4">
{/* Status */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-stone-800">{t('settings.companion.session')}</p>
<p className="text-xs text-stone-500">
{isLoading
{/* Session status + controls */}
<SettingsSection>
<SettingsRow
label={t('settings.companion.session')}
description={
isLoading
? t('common.loading')
: sessionActive
? `${t('settings.companion.activeLabel')}${companionState}`
: t('settings.companion.inactiveStatus')}
</p>
</div>
<div>
{sessionActive ? (
<button
type="button"
onClick={handleStop}
disabled={isStopping}
className="rounded-lg bg-red-50 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-100 disabled:opacity-50">
{isStopping
? t('settings.companion.stopping')
: t('settings.companion.stopSession')}
</button>
) : (
<button
type="button"
onClick={handleStart}
disabled={isStarting || isLoading}
className="rounded-lg bg-blue-50 px-3 py-1.5 text-sm font-medium text-blue-700 hover:bg-blue-100 disabled:opacity-50">
{isStarting
? t('settings.companion.starting')
: t('settings.companion.startSession')}
</button>
)}
</div>
</div>
: t('settings.companion.inactiveStatus')
}
control={
sessionActive ? (
<Button
type="button"
variant="danger"
size="sm"
onClick={handleStop}
disabled={isStopping}>
{isStopping
? t('settings.companion.stopping')
: t('settings.companion.stopSession')}
</Button>
) : (
<Button
type="button"
variant="primary"
size="sm"
onClick={handleStart}
disabled={isStarting || isLoading}>
{isStarting
? t('settings.companion.starting')
: t('settings.companion.startSession')}
</Button>
)
}
/>
</SettingsSection>
{/* Session details */}
{sessionActive && status && (
<div className="rounded-lg bg-stone-50 p-3 text-xs text-stone-600 space-y-1">
<p>
{t('settings.companion.sessionId')}:{' '}
<span className="font-mono">{status.session_id?.slice(0, 8)}</span>
</p>
<p>
{t('settings.companion.turns')}: {status.turn_count}
</p>
{status.remaining_ms != null && (
<SettingsSection>
<div className="px-4 py-3 text-xs text-neutral-600 dark:text-neutral-300 space-y-1">
<p>
{t('settings.companion.remaining')}: {Math.floor(status.remaining_ms / 60000)}m{' '}
{Math.floor((status.remaining_ms % 60000) / 1000)}s
{t('settings.companion.sessionId')}:{' '}
<span className="font-mono">{status.session_id?.slice(0, 8)}</span>
</p>
)}
</div>
<p>
{t('settings.companion.turns')}: {status.turn_count}
</p>
{status.remaining_ms != null && (
<p>
{t('settings.companion.remaining')}: {Math.floor(status.remaining_ms / 60000)}m{' '}
{Math.floor((status.remaining_ms % 60000) / 1000)}s
</p>
)}
</div>
</SettingsSection>
)}
{/* Config */}
{config && (
<div className="space-y-3 border-t border-stone-100 pt-4">
<p className="text-xs font-medium uppercase tracking-wide text-stone-400">
{t('settings.companion.configuration')}
</p>
<div className="flex items-center justify-between">
<span className="text-sm text-stone-700">{t('settings.companion.hotkey')}</span>
<span className="rounded bg-stone-100 px-2 py-0.5 font-mono text-xs text-stone-600">
{config.hotkey}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-stone-700">
{t('settings.companion.activationMode')}
</span>
<span className="text-xs text-stone-500">{config.activation_mode}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-stone-700">{t('settings.companion.sessionTtl')}</span>
<span className="text-xs text-stone-500">{config.ttl_secs}s</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-stone-700">
{t('settings.companion.screenCapture')}
</span>
<span className="text-xs text-stone-500">
{config.capture_screen ? t('common.enabled') : t('common.disabled')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-stone-700">{t('settings.companion.appContext')}</span>
<span className="text-xs text-stone-500">
{config.include_app_context ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<SettingsSection title={t('settings.companion.configuration')}>
<SettingsRow
label={t('settings.companion.hotkey')}
control={
<span className="rounded bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 font-mono text-xs text-neutral-600 dark:text-neutral-300">
{config.hotkey}
</span>
}
/>
<SettingsRow
label={t('settings.companion.activationMode')}
control={
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{config.activation_mode}
</span>
}
/>
<SettingsRow
label={t('settings.companion.sessionTtl')}
control={
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{config.ttl_secs}s
</span>
}
/>
<SettingsRow
label={t('settings.companion.screenCapture')}
control={
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{config.capture_screen ? t('common.enabled') : t('common.disabled')}
</span>
}
/>
<SettingsRow
label={t('settings.companion.appContext')}
control={
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{config.include_app_context ? t('common.enabled') : t('common.disabled')}
</span>
}
/>
</SettingsSection>
)}
{/* Error */}
{error && <div className="rounded-lg bg-red-50 p-3 text-xs text-red-700">{error}</div>}
<SettingsStatusLine
saving={false}
savedNote={null}
error={error}
savingLabel={t('settings.agentAccess.saving')}
/>
</div>
</div>
);
@@ -23,7 +23,9 @@ import {
openhumanComposioGetMode,
openhumanComposioSetApiKey,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsStatusLine, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
type Mode = 'backend' | 'direct';
@@ -214,7 +216,7 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
/>
)}
<div className={embedded ? '' : 'p-4'}>
<p className="text-sm text-stone-500 dark:text-neutral-400">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.composio.loading')}
</p>
</div>
@@ -223,7 +225,7 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
}
return (
<div>
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.composio.title')}
@@ -233,21 +235,19 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
/>
)}
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
<p className="text-sm text-stone-500 dark:text-neutral-400">
<div className={embedded ? 'space-y-5' : 'p-4 pt-2 space-y-5'}>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.composio.intro')}
</p>
{allowManagedAuth ? (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-3">
<fieldset>
<legend className="text-sm font-medium text-stone-900 dark:text-neutral-100 mb-2">
<SettingsSection>
<fieldset className="px-4 py-3">
<legend className="text-sm font-medium text-neutral-800 dark:text-neutral-100 mb-2">
{t('settings.composio.routingMode')}
</legend>
<div className="space-y-2">
<label
className="flex items-start gap-3 cursor-pointer"
onClick={() => setMode('backend')}>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="composio-mode"
@@ -258,17 +258,15 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
className="mt-1"
/>
<div className="text-left">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{t('settings.composio.modeManaged')}
</span>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('settings.composio.modeManagedDesc')}
</p>
</div>
</label>
<label
className="flex items-start gap-3 cursor-pointer"
onClick={() => setMode('direct')}>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="composio-mode"
@@ -279,61 +277,65 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
className="mt-1"
/>
<div className="text-left">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{t('settings.composio.modeDirect')}
</span>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('settings.composio.modeDirectDesc')}
</p>
</div>
</label>
</div>
</fieldset>
</div>
</SettingsSection>
) : (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-2">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.composio.modeDirect')}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t(
'settings.composio.directOnlyDesc',
'Managed Composio auth is unavailable here. Enter your own Composio API key or skip this for now.'
)}
</p>
</div>
<SettingsSection>
<div className="px-4 py-3 space-y-2">
<p className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{t('settings.composio.modeDirect')}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.composio.directOnlyDesc',
'Managed Composio auth is unavailable here. Enter your own Composio API key or skip this for now.'
)}
</p>
</div>
</SettingsSection>
)}
{/* API key field — only when Direct is selected */}
{mode === 'direct' && (
<div className="space-y-2">
<label
className="block text-sm font-medium text-stone-800 dark:text-neutral-100"
htmlFor="composio-api-key">
{t('settings.composio.apiKeyLabel')}
</label>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.composio.apiKeyDesc')}
</p>
<input
id="composio-api-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder={
apiKeyStored
? t('settings.composio.apiKeyStoredPlaceholder')
: t('settings.composio.apiKeyExamplePlaceholder')
<SettingsSection
title={t('settings.composio.apiKeyLabel')}
description={t('settings.composio.apiKeyDesc')}>
<SettingsRow
stacked
control={
<div className="space-y-1">
<SettingsTextField
id="composio-api-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder={
apiKeyStored
? t('settings.composio.apiKeyStoredPlaceholder')
: t('settings.composio.apiKeyExamplePlaceholder')
}
aria-label={t('settings.composio.apiKeyLabel')}
mono
/>
{apiKeyStored && (
<p className="text-xs text-sage-700 dark:text-sage-300">
{t('settings.composio.apiKeyStored')}
</p>
)}
</div>
}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
{apiKeyStored && (
<p className="text-xs text-green-600 dark:text-green-300">
{t('settings.composio.apiKeyStored')}
</p>
)}
</div>
</SettingsSection>
)}
{confirmGate === 'awaiting' ? (
@@ -346,7 +348,7 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
<div
role="alertdialog"
aria-labelledby="composio-confirm-title"
className="rounded-2xl border border-amber-200 dark:border-amber-500/30 bg-amber-50/80 p-4 space-y-3">
className="rounded-xl border border-amber-200 dark:border-amber-500/30 bg-amber-50/80 p-4 space-y-3">
<p id="composio-confirm-title" className="text-sm font-medium text-amber-900">
{t('settings.composio.confirmTitle')}
</p>
@@ -360,46 +362,48 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
</ol>
</div>
<div className="flex gap-2 pt-1">
<button
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleCancelTransition}
disabled={saving}
className="flex-1 py-2 rounded-xl border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-stone-800 dark:text-neutral-100 text-sm font-medium hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 transition-colors disabled:opacity-50">
className="flex-1">
{t('common.cancel')}
</button>
<button
</Button>
<Button
type="button"
onClick={handleConfirmTransition}
size="sm"
onClick={() => void handleConfirmTransition()}
disabled={saving}
className="flex-1 py-2 rounded-xl bg-amber-600 text-white text-sm font-medium hover:bg-amber-500 transition-colors disabled:opacity-50">
className="flex-1 bg-amber-600 hover:bg-amber-500">
{saving ? t('settings.composio.switching') : t('settings.composio.confirmSwitch')}
</button>
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={handleSave}
disabled={saving}
className="w-full py-2 rounded-xl bg-primary-600 text-white text-sm font-medium hover:bg-primary-500 transition-colors disabled:opacity-50">
{saving ? t('settings.composio.saving') : t('common.save')}
</button>
)}
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600 dark:text-green-300">
{t('composio.settingsSaved')}
</p>
)}
{saveStatus === 'cleared' && (
<p className="text-xs text-center text-green-600 dark:text-green-300">
{t('settings.composio.clearedToBackend')}
</p>
)}
{saveStatus === 'error' && (
<p className="text-xs text-center text-red-500">
{t('settings.composio.saveErrorNoKey')}
</p>
<div className="flex items-center gap-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleSave()}
disabled={saving}>
{saving ? t('settings.composio.saving') : t('common.save')}
</Button>
<SettingsStatusLine
saving={false}
savedNote={
saveStatus === 'saved'
? t('composio.settingsSaved')
: saveStatus === 'cleared'
? t('settings.composio.clearedToBackend')
: null
}
error={saveStatus === 'error' ? t('settings.composio.saveErrorNoKey') : null}
savingLabel=""
/>
</div>
)}
</div>
</div>
@@ -5,7 +5,15 @@ import {
openhumanGetComposioTriggerSettings,
openhumanUpdateComposioTriggerSettings,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsRow,
SettingsSection,
SettingsStatusLine,
SettingsSwitch,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ComposioTriagePanel = () => {
@@ -83,7 +91,7 @@ const ComposioTriagePanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4">
<p className="text-sm text-stone-500 dark:text-neutral-400">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.composio.loading')}
</p>
</div>
@@ -100,80 +108,64 @@ const ComposioTriagePanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-5">
<p className="text-sm text-stone-500 dark:text-neutral-400">
<div className="p-4 pt-2 space-y-5">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('composio.triageDesc')}{' '}
<span className="font-mono">OPENHUMAN_TRIGGER_TRIAGE_DISABLED</span>{' '}
{t('composio.envVarOverrides')}
</p>
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-1">
<button
type="button"
role="switch"
aria-checked={triageDisabled}
aria-label={t('composio.disableAllTriage')}
onClick={() => setTriageDisabled(v => !v)}
className="w-full flex items-center justify-between">
<div className="text-left">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('composio.disableAllTriage')}
</span>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('composio.triggersStillRecorded')}
</p>
</div>
<div
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
triageDisabled ? 'bg-coral-400' : 'bg-stone-200 dark:bg-neutral-800'
}`}>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
triageDisabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
<SettingsSection>
<SettingsRow
htmlFor="switch-triage-disabled"
label={t('composio.disableAllTriage')}
description={t('composio.triggersStillRecorded')}
control={
<SettingsSwitch
id="switch-triage-disabled"
checked={triageDisabled}
onCheckedChange={next => setTriageDisabled(next)}
aria-label={t('composio.disableAllTriage')}
/>
</div>
</button>
</div>
}
/>
</SettingsSection>
<div className={`space-y-2 ${triageDisabled ? 'opacity-40 pointer-events-none' : ''}`}>
<label
className="block text-sm font-medium text-stone-800 dark:text-neutral-100"
htmlFor="disabled-toolkits">
{t('composio.disableSpecificIntegrations')}
</label>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('composio.integrationSlugsHelp')}{' '}
<span className="font-mono">{t('composio.integrationSlugsExample')}</span>.{' '}
{t('composio.integrationSlugsCaseInsensitive')}
</p>
<input
id="disabled-toolkits"
type="text"
value={disabledToolkits}
onChange={e => setDisabledToolkits(e.target.value)}
placeholder={t('composio.integrationSlugsPlaceholder')}
<SettingsSection
title={t('composio.disableSpecificIntegrations')}
description={`${t('composio.integrationSlugsHelp')} ${t('composio.integrationSlugsExample')}. ${t('composio.integrationSlugsCaseInsensitive')}`}>
<SettingsRow
stacked
disabled={triageDisabled}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed"
control={
<SettingsTextField
id="disabled-toolkits"
value={disabledToolkits}
onChange={e => setDisabledToolkits(e.target.value)}
placeholder={t('composio.integrationSlugsPlaceholder')}
disabled={triageDisabled}
aria-label={t('composio.disableSpecificIntegrations')}
/>
}
/>
</SettingsSection>
<div className="flex items-center gap-3">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleSave()}
disabled={saving}>
{saving ? t('common.loading') : t('common.save')}
</Button>
<SettingsStatusLine
saving={saving}
savedNote={saveStatus === 'saved' ? t('composio.settingsSaved') : null}
error={saveStatus === 'error' ? t('composio.saveFailed') : null}
savingLabel={t('common.loading')}
/>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="w-full py-2 rounded-xl bg-primary-600 text-white text-sm font-medium hover:bg-primary-500 transition-colors disabled:opacity-50">
{saving ? t('common.loading') : t('common.save')}
</button>
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600 dark:text-green-300">
{t('composio.settingsSaved')}
</p>
)}
{saveStatus === 'error' && (
<p className="text-xs text-center text-red-500">{t('composio.saveFailed')}</p>
)}
</div>
</div>
);
@@ -13,7 +13,9 @@ import {
openhumanCronRuns,
openhumanCronUpdate,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import CoreJobList from './cron/CoreJobList';
import CronJobFormModal from './cron/CronJobFormModal';
@@ -185,7 +187,7 @@ const CronJobsPanel = () => {
};
return (
<div data-testid="cron-jobs-panel">
<div className="z-10 relative" data-testid="cron-jobs-panel">
<SettingsHeader
title={t('cron.title')}
showBackButton={true}
@@ -193,54 +195,49 @@ const CronJobsPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<section className="space-y-1">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('cron.scheduledJobs')}
</h3>
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('cron.manageCronJobs')}</p>
</section>
<div className="p-4 pt-2 space-y-5">
<SettingsSection title={t('cron.scheduledJobs')} description={t('cron.manageCronJobs')}>
<div className="px-4 pb-4 space-y-4">
<div className="pt-2">
<Button
type="button"
variant="primary"
size="sm"
data-testid="cron-new-job"
onClick={() => {
setEditingJob(null);
setFormOpen(true);
}}>
{t('settings.cron.jobs.createJob')}
</Button>
</div>
{/* "+ New Scheduled Job" button */}
<div>
<button
type="button"
data-testid="cron-new-job"
className="inline-flex items-center rounded-xl border border-primary-700/30 bg-primary-600 px-3.5 py-2 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
onClick={() => {
setEditingJob(null);
setFormOpen(true);
}}>
{t('settings.cron.jobs.createJob')}
</button>
</div>
<SettingsStatusLine saving={false} error={coreError} savingLabel="" />
{coreError && (
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{coreError}
<CoreJobList
loading={loading}
coreJobs={coreJobs}
coreRunsByJob={coreRunsByJob}
coreBusyKey={coreBusyKey}
onToggleCoreJob={job => void toggleCoreJob(job)}
onRunCoreJob={jobId => void runCoreJob(jobId)}
onLoadCoreRuns={jobId => void loadCoreRuns(jobId)}
onRemoveCoreJob={jobId => void removeCoreJob(jobId)}
onEditCoreJob={job => setEditingJob(job)}
/>
<div>
<Button
type="button"
variant="secondary"
size="sm"
data-testid="cron-refresh"
onClick={() => void loadCoreCronJobsOnly()}>
{t('cron.refreshCronJobs')}
</Button>
</div>
</div>
)}
<CoreJobList
loading={loading}
coreJobs={coreJobs}
coreRunsByJob={coreRunsByJob}
coreBusyKey={coreBusyKey}
onToggleCoreJob={job => void toggleCoreJob(job)}
onRunCoreJob={jobId => void runCoreJob(jobId)}
onLoadCoreRuns={jobId => void loadCoreRuns(jobId)}
onRemoveCoreJob={jobId => void removeCoreJob(jobId)}
onEditCoreJob={job => setEditingJob(job)}
/>
<div>
<button
type="button"
data-testid="cron-refresh"
className="inline-flex items-center rounded-xl border border-stone-300 dark:border-stone-700 bg-white dark:bg-stone-900 px-3.5 py-2 text-sm font-medium text-stone-700 dark:text-stone-200 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
onClick={() => void loadCoreCronJobsOnly()}>
{t('cron.refreshCronJobs')}
</button>
</div>
</SettingsSection>
</div>
{/* Create modal */}
@@ -15,7 +15,15 @@ import {
openhumanCronRuns,
openhumanCronUpdate,
} from '../../../utils/tauriCommands/cron';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = createDebug('app:settings:DevWorkflowPanel');
@@ -514,69 +522,62 @@ const DevWorkflowPanel = () => {
</div>
)}
{existingJob && (
<div className="px-4 py-3 rounded-lg border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10">
<SettingsSection>
{/* Running indicator */}
{running && (
<div className="mb-3 px-3 py-2 rounded-md bg-primary-50 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/30 flex items-center gap-2">
<div className="mx-4 mt-4 px-3 py-2 rounded-md bg-primary-50 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/30 flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-primary-500 animate-pulse" />
<span className="text-xs font-medium text-primary-700 dark:text-primary-300">
{t('settings.devWorkflow.runningStatus')}
</span>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('settings.devWorkflow.activeConfiguration')}
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={existingJob.enabled}
onClick={() => void handleToggle()}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors ${
existingJob.enabled ? 'bg-sage-500' : 'bg-neutral-300 dark:bg-neutral-600'
}`}>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform mt-0.5 ${
existingJob.enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
<SettingsRow
label={t('settings.devWorkflow.activeConfiguration')}
htmlFor="dev-workflow-enabled"
control={
<div className="flex items-center gap-2">
<SettingsSwitch
id="dev-workflow-enabled"
checked={existingJob.enabled}
onCheckedChange={() => void handleToggle()}
aria-label={t('settings.devWorkflow.enabled')}
/>
</button>
<span className="text-xs text-sage-600 dark:text-sage-400">
{existingJob.enabled
? t('settings.devWorkflow.enabled')
: t('settings.devWorkflow.paused')}
</span>
</div>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-sage-600 dark:text-sage-400">
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{existingJob.enabled
? t('settings.devWorkflow.enabled')
: t('settings.devWorkflow.paused')}
</span>
</div>
}
/>
<dl className="px-4 pb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.activeConfigRepository')}
</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200">
<dd className="font-mono text-neutral-800 dark:text-neutral-100">
{existingJob.name?.replace(/^dev-workflow-/, '') ?? '—'}
</dd>
<dt className="text-sage-600 dark:text-sage-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.activeConfigSchedule')}
</dt>
<dd className="text-sage-900 dark:text-sage-200">
<dd className="text-neutral-800 dark:text-neutral-100">
{SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)
? t(SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)!.labelKey)
: existingJob.expression}
</dd>
<dt className="text-sage-600 dark:text-sage-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.nextRun')}
</dt>
<dd className="text-sage-900 dark:text-sage-200">
<dd className="text-neutral-800 dark:text-neutral-100">
{existingJob.next_run ? new Date(existingJob.next_run).toLocaleString() : '—'}
</dd>
{existingJob.last_run && (
<>
<dt className="text-sage-600 dark:text-sage-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('settings.devWorkflow.lastRun')}
</dt>
<dd className="text-sage-900 dark:text-sage-200">
<dd className="text-neutral-800 dark:text-neutral-100">
{new Date(existingJob.last_run).toLocaleString()}
{existingJob.last_status && (
<span
@@ -593,23 +594,23 @@ const DevWorkflowPanel = () => {
)}
</dl>
<div className="mt-3 flex items-center gap-2">
<button
<div className="px-4 pb-4 flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void handleRunNow()}
disabled={running}
className="px-3 py-1.5 rounded-md bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 text-xs font-medium hover:bg-primary-200 dark:hover:bg-primary-500/30 transition-colors disabled:opacity-50">
disabled={running}>
{running ? t('settings.devWorkflow.running') : t('settings.devWorkflow.runNow')}
</button>
<button
onClick={() => void handleRemove()}
className="px-3 py-1.5 rounded-md bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300 text-xs font-medium hover:bg-coral-200 dark:hover:bg-coral-500/30 transition-colors">
</Button>
<Button type="button" variant="danger" size="xs" onClick={() => void handleRemove()}>
{t('settings.devWorkflow.remove')}
</button>
</Button>
</div>
{existingJob.last_output && (
<div className="mt-3">
<div className="text-xs font-medium text-sage-600 dark:text-sage-400 mb-1">
<div className="px-4 pb-4">
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
{t('settings.devWorkflow.lastOutput')}
</div>
<pre className="px-3 py-2 rounded-md bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-[11px] text-neutral-700 dark:text-neutral-300 font-mono whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
@@ -619,10 +620,11 @@ const DevWorkflowPanel = () => {
)}
{runHistory.length > 0 && (
<div className="mt-3">
<div className="px-4 pb-4">
<button
type="button"
onClick={() => setHistoryExpanded(!historyExpanded)}
className="text-xs text-sage-600 dark:text-sage-400 hover:text-sage-800 dark:hover:text-sage-200 transition-colors">
className="text-xs text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors">
{historyExpanded ? '▾' : '▸'} {t('settings.devWorkflow.recentRuns')} (
{runHistory.length})
</button>
@@ -631,6 +633,7 @@ const DevWorkflowPanel = () => {
{runHistory.map(run => (
<div key={run.id} className="rounded bg-white dark:bg-neutral-800">
<button
type="button"
onClick={() => setExpandedRunId(expandedRunId === run.id ? null : run.id)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-750 rounded transition-colors">
<div className="flex items-center gap-2">
@@ -673,38 +676,40 @@ const DevWorkflowPanel = () => {
)}
</div>
)}
</div>
</SettingsSection>
)}
{/* Setup form — only shown when no active config exists */}
{!existingJob && (
<>
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.githubRepository')}
</label>
<SettingsSection title={t('settings.devWorkflow.githubRepository')}>
{reposError && (
<div className="mb-2 px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
<div className="mx-4 mt-3 px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
{reposError}
</div>
)}
<select
value={selectedRepo}
onChange={e => void onRepoSelect(e.target.value)}
disabled={reposLoading}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
<option value="">
{reposLoading
? t('settings.devWorkflow.loadingRepositories')
: t('settings.devWorkflow.selectRepository')}
</option>
{repos.map(r => (
<option key={r.fullName} value={r.fullName}>
{r.fullName} {r.private ? t('settings.devWorkflow.privateTag') : ''}
</option>
))}
</select>
</div>
<SettingsRow
stacked
control={
<SettingsSelect
value={selectedRepo}
onChange={e => void onRepoSelect(e.target.value)}
disabled={reposLoading}
className="w-full">
<option value="">
{reposLoading
? t('settings.devWorkflow.loadingRepositories')
: t('settings.devWorkflow.selectRepository')}
</option>
{repos.map(r => (
<option key={r.fullName} value={r.fullName}>
{r.fullName} {r.private ? t('settings.devWorkflow.privateTag') : ''}
</option>
))}
</SettingsSelect>
}
/>
</SettingsSection>
{/* Fork info */}
{forkLoading && (
@@ -736,29 +741,28 @@ const DevWorkflowPanel = () => {
{/* Branch selector */}
{branches.length > 0 && (
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.targetBranch')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">
{t('settings.devWorkflow.targetBranchNote')}
{forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}.
</p>
<select
value={targetBranch}
onChange={e => {
setTargetBranch(e.target.value);
setSaveStatus('idle');
}}
disabled={branchesLoading}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
{branches.map(b => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</select>
</div>
<SettingsSection title={t('settings.devWorkflow.targetBranch')}>
<SettingsRow
stacked
description={`${t('settings.devWorkflow.targetBranchNote')}${forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}.`}
control={
<SettingsSelect
value={targetBranch}
onChange={e => {
setTargetBranch(e.target.value);
setSaveStatus('idle');
}}
disabled={branchesLoading}
className="w-full">
{branches.map(b => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</SettingsSelect>
}
/>
</SettingsSection>
)}
{branchesLoading && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
@@ -768,48 +772,46 @@ const DevWorkflowPanel = () => {
{/* Schedule */}
{selectedRepo && (
<div>
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1.5">
{t('settings.devWorkflow.runFrequency')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">
{t('settings.devWorkflow.runFrequencyNote')}
</p>
<select
value={schedule}
onChange={e => {
setSchedule(e.target.value);
setSaveStatus('idle');
}}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
{SCHEDULE_PRESETS.map(p => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</select>
</div>
<SettingsSection title={t('settings.devWorkflow.runFrequency')}>
<SettingsRow
stacked
description={t('settings.devWorkflow.runFrequencyNote')}
control={
<SettingsSelect
value={schedule}
onChange={e => {
setSchedule(e.target.value);
setSaveStatus('idle');
}}
className="w-full">
{SCHEDULE_PRESETS.map(p => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</SettingsSelect>
}
/>
</SettingsSection>
)}
{/* Actions */}
{selectedRepo && (
<div className="flex items-center gap-3 pt-2">
<button
<div className="flex items-center gap-3 pb-4">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleSave()}
disabled={!canSave}
className="px-4 py-2 rounded-md bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
disabled={!canSave}>
{t('settings.devWorkflow.saveConfiguration')}
</button>
{saveStatus === 'saved' && (
<span className="text-xs text-sage-600 dark:text-sage-400 font-medium">
{t('settings.devWorkflow.saved')}
</span>
)}
{saveStatus === 'error' && (
<span className="text-xs text-coral-600 dark:text-coral-400 font-medium">
{t('settings.devWorkflow.cronSaveError')}
</span>
)}
</Button>
<SettingsStatusLine
saving={false}
savedNote={saveStatus === 'saved' ? t('settings.devWorkflow.saved') : null}
error={saveStatus === 'error' ? t('settings.devWorkflow.cronSaveError') : null}
savingLabel=""
/>
</div>
)}
</>
@@ -1,3 +1,9 @@
// [settings] Developer & Diagnostics panel — debug-only entries only.
// User-facing routes (agents, autonomy, agent-access, sandbox-settings,
// activity-level, tools, companion, screen-intelligence, voice, embeddings,
// heartbeat, ledger-usage, cost-dashboard, task-sources, composio-routing,
// webhooks-triggers, migration, security) have been moved to their canonical
// section pages. Only genuine diagnostics remain here.
import { type ReactNode, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -13,6 +19,7 @@ import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/comm
import { resetWalkthrough } from '../../walkthrough/AppWalkthrough';
import SettingsHeader from '../components/SettingsHeader';
import SettingsMenuItem from '../components/SettingsMenuItem';
import { SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// ---------------------------------------------------------------------------
@@ -34,13 +41,28 @@ interface DevGroup {
}
// ---------------------------------------------------------------------------
// 7 sub-sections per the IA redesign doc
// Debug-only groups — genuine diagnostics that belong ONLY here.
//
// Removed from all groups (moved to canonical section pages):
// agents, autonomy, agent-access, sandbox-settings, activity-level
// → Settings → Agents
// tools, companion, screen-intelligence
// → Settings → Features
// voice, embeddings, heartbeat, ledger-usage, cost-dashboard
// → Settings → AI & Models
// task-sources, composio-routing, webhooks-triggers
// → Settings → Integrations
// migration, security
// → Settings → Account
// persona
// → Settings home (Assistant group)
// ---------------------------------------------------------------------------
const knowledgeMemoryGroup: DevGroup = {
labelKey: 'settings.devGroups.knowledgeMemory',
items: [
{
// intelligence appears only once here (Council duplicate removed).
id: 'intelligence',
titleKey: 'settings.developerMenu.intelligence.title',
descriptionKey: 'settings.developerMenu.intelligence.desc',
@@ -104,109 +126,12 @@ const knowledgeMemoryGroup: DevGroup = {
</svg>
),
},
{
// Moved out of the layman Account group.
id: 'migration',
titleKey: 'settings.account.dataMigration',
descriptionKey: 'settings.account.dataMigrationDesc',
route: 'migration',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 7h11m0 0l-3-3m3 3l-3 3m8 7H9m0 0l3 3m-3-3l3-3"
/>
</svg>
),
},
],
};
const agentsAutonomyGroup: DevGroup = {
const agentDebugGroup: DevGroup = {
labelKey: 'settings.devGroups.agentsAutonomy',
items: [
{
id: 'agents',
titleKey: 'settings.agents.title',
descriptionKey: 'settings.agents.subtitle',
route: 'agents',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
},
{
id: 'autonomy',
titleKey: 'settings.developerMenu.autonomy.title',
descriptionKey: 'settings.developerMenu.autonomy.desc',
route: 'autonomy',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
),
},
{
id: 'agent-access',
titleKey: 'settings.agentAccess.title',
descriptionKey: 'settings.agentAccess.menuDesc',
route: 'agent-access',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
),
},
{
id: 'sandbox-settings',
titleKey: 'settings.sandbox.title',
descriptionKey: 'settings.sandbox.menuDesc',
route: 'sandbox-settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
),
},
{
id: 'heartbeat',
titleKey: 'settings.heartbeat.title',
descriptionKey: 'settings.heartbeat.desc',
route: 'heartbeat',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
),
},
{
id: 'tool-policy-diagnostics',
titleKey: 'devOptions.diagnostics',
@@ -240,73 +165,17 @@ const agentsAutonomyGroup: DevGroup = {
),
},
{
// Layman Permissions picker, moved out of the Assistant group.
id: 'permissions',
titleKey: 'settings.assistant.permissions',
descriptionKey: 'settings.assistant.permissionsDesc',
route: 'permissions',
id: 'agent-chat',
titleKey: 'settings.developerMenu.agentChat.title',
descriptionKey: 'settings.developerMenu.agentChat.desc',
route: 'agent-chat',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
),
},
{
// Subconscious (activity level), moved out of the Assistant group.
id: 'activity-level',
titleKey: 'settings.assistant.backgroundActivity',
descriptionKey: 'settings.assistant.backgroundActivityDesc',
route: 'activity-level',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
),
},
],
};
const modelsInferenceGroup: DevGroup = {
labelKey: 'settings.devGroups.modelsInference',
items: [
{
id: 'ai',
titleKey: 'settings.developerMenu.ai.title',
descriptionKey: 'settings.developerMenu.ai.desc',
route: 'ai',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
},
{
id: 'embeddings',
titleKey: 'pages.settings.ai.embeddings',
descriptionKey: 'pages.settings.ai.embeddingsDesc',
route: 'embeddings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
),
@@ -327,6 +196,34 @@ const modelsInferenceGroup: DevGroup = {
</svg>
),
},
{
id: 'skills-runner',
titleKey: 'settings.developerMenu.skillsRunner.title',
descriptionKey: 'settings.developerMenu.skillsRunner.desc',
route: 'skills-runner',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
],
};
const modelsDebugGroup: DevGroup = {
labelKey: 'settings.devGroups.modelsInference',
items: [
{
id: 'model-health',
titleKey: 'settings.modelHealth.title',
@@ -344,33 +241,49 @@ const modelsInferenceGroup: DevGroup = {
),
},
{
id: 'search',
titleKey: 'settings.search.title',
descriptionKey: 'settings.search.menuDesc',
route: 'search',
id: 'screen-awareness-debug',
titleKey: 'settings.developerMenu.screenAwareness.title',
descriptionKey: 'settings.developerMenu.screenAwareness.desc',
route: 'screen-awareness-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
),
},
{
id: 'agent-chat',
titleKey: 'settings.developerMenu.agentChat.title',
descriptionKey: 'settings.developerMenu.agentChat.desc',
route: 'agent-chat',
id: 'voice-debug',
titleKey: 'settings.developerMenu.voiceDebug.title',
descriptionKey: 'settings.developerMenu.voiceDebug.desc',
route: 'voice-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
),
},
{
id: 'autocomplete-debug',
titleKey: 'settings.developerMenu.autocomplete.title',
descriptionKey: 'settings.developerMenu.autocomplete.desc',
route: 'autocomplete-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h10M4 14h7m3 4h3m0 0l-2-2m2 2l-2 2"
/>
</svg>
),
@@ -378,7 +291,7 @@ const modelsInferenceGroup: DevGroup = {
],
};
const automationIntegrationsGroup: DevGroup = {
const automationDebugGroup: DevGroup = {
labelKey: 'settings.devGroups.automationIntegrations',
items: [
{
@@ -414,10 +327,10 @@ const automationIntegrationsGroup: DevGroup = {
),
},
{
id: 'webhooks-triggers',
titleKey: 'settings.developerMenu.composeioTriggers.title',
descriptionKey: 'settings.developerMenu.composeioTriggers.desc',
route: 'webhooks-triggers',
id: 'composio-triggers',
titleKey: 'settings.developerMenu.composio.title',
descriptionKey: 'settings.developerMenu.composio.desc',
route: 'composio-triggers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -445,38 +358,6 @@ const automationIntegrationsGroup: DevGroup = {
</svg>
),
},
{
id: 'task-sources',
titleKey: 'settings.taskSources.title',
descriptionKey: 'settings.taskSources.subtitle',
route: 'task-sources',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
),
},
{
id: 'composio',
titleKey: 'settings.developerMenu.composio.title',
descriptionKey: 'settings.developerMenu.composio.desc',
route: 'composio-routing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
),
},
{
id: 'mcp-server',
titleKey: 'settings.developerMenu.mcpServer.title',
@@ -512,157 +393,6 @@ const automationIntegrationsGroup: DevGroup = {
],
};
const toolsCapabilitiesGroup: DevGroup = {
labelKey: 'settings.devGroups.toolsCapabilities',
items: [
{
id: 'tools',
titleKey: 'settings.developerMenu.tools.title',
descriptionKey: 'settings.developerMenu.tools.desc',
route: 'tools',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
{
id: 'screen-awareness-debug',
titleKey: 'settings.developerMenu.screenAwareness.title',
descriptionKey: 'settings.developerMenu.screenAwareness.desc',
route: 'screen-awareness-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
),
},
{
id: 'autocomplete',
titleKey: 'settings.developerMenu.autocomplete.title',
descriptionKey: 'settings.developerMenu.autocomplete.desc',
route: 'autocomplete',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h10M4 14h7m3 4h3m0 0l-2-2m2 2l-2 2"
/>
</svg>
),
},
{
id: 'voice-debug',
titleKey: 'settings.developerMenu.voiceDebug.title',
descriptionKey: 'settings.developerMenu.voiceDebug.desc',
route: 'voice-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
),
},
{
// Voice (TTS/STT) settings, moved out of the Assistant group.
id: 'voice',
titleKey: 'settings.assistant.voice',
descriptionKey: 'settings.assistant.voiceDesc',
route: 'voice',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
),
},
{
// Screen awareness, moved out of the Assistant group.
id: 'screen-intelligence',
titleKey: 'settings.assistant.screenAwareness',
descriptionKey: 'settings.assistant.screenAwarenessDesc',
route: 'screen-intelligence',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
),
},
{
// Desktop companion, moved out of the Assistant group.
id: 'companion',
titleKey: 'settings.assistant.desktopCompanion',
descriptionKey: 'settings.assistant.desktopCompanionDesc',
route: 'companion',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
),
},
],
};
const councilGroup: DevGroup = {
labelKey: 'settings.devGroups.council',
items: [
// Council links to /intelligence?tab=council (the IS_DEV-gated tab).
// We route through the embedded Intelligence page in settings.
{
id: 'council',
titleKey: 'settings.devGroups.council',
descriptionKey: 'settings.developerMenu.intelligence.desc',
route: 'intelligence',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
),
},
],
};
const diagnosticsLogsGroup: DevGroup = {
labelKey: 'settings.devGroups.diagnosticsLogs',
items: [
@@ -682,38 +412,6 @@ const diagnosticsLogsGroup: DevGroup = {
</svg>
),
},
{
id: 'ledger-usage',
titleKey: 'settings.ledgerUsage.title',
descriptionKey: 'settings.ledgerUsage.desc',
route: 'ledger-usage',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
),
},
{
id: 'cost-dashboard',
titleKey: 'settings.costDashboard.title',
descriptionKey: 'settings.costDashboard.desc',
route: 'cost-dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V6m0 10c-1.11 0-2.08-.402-2.599-1M12 16v2m0-12a9 9 0 100 18 9 9 0 000-18z"
/>
</svg>
),
},
{
id: 'build-info',
titleKey: 'settings.buildInfo.title',
@@ -730,35 +428,15 @@ const diagnosticsLogsGroup: DevGroup = {
</svg>
),
},
{
// Security (secret storage / keychain), moved out of the layman
// Privacy & Security group.
id: 'security',
titleKey: 'settings.privacySecurity.security',
descriptionKey: 'settings.privacySecurity.securityDesc',
route: 'security',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
),
},
],
};
/** All 7 dev groups in display order */
/** All debug-only groups in display order */
const DEV_GROUPS: DevGroup[] = [
knowledgeMemoryGroup,
agentsAutonomyGroup,
modelsInferenceGroup,
automationIntegrationsGroup,
toolsCapabilitiesGroup,
councilGroup,
agentDebugGroup,
modelsDebugGroup,
automationDebugGroup,
diagnosticsLogsGroup,
];
@@ -772,7 +450,7 @@ const CoreModeBadge = () => {
if (mode.kind === 'unset') {
return (
<div className="px-4 py-3 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10 dark:border-coral-500/30">
<div className="px-4 py-3 rounded-xl border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10">
<div className="text-sm font-semibold text-coral-900 dark:text-coral-300">
{t('devOptions.coreModeNotSet')}
</div>
@@ -785,7 +463,7 @@ const CoreModeBadge = () => {
if (mode.kind === 'local') {
return (
<div className="px-4 py-3 rounded-lg border border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10 dark:border-primary-500/30">
<div className="px-4 py-3 rounded-xl border border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-primary-600 text-white text-[11px] font-medium">
{t('devOptions.local')}
@@ -802,7 +480,7 @@ const CoreModeBadge = () => {
}
return (
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10 dark:border-sage-500/30">
<div className="px-4 py-3 rounded-xl border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-sage-600 text-white text-[11px] font-medium">
{t('devOptions.cloud')}
@@ -852,7 +530,7 @@ const SentryTestRow = () => {
};
return (
<div className="px-4 py-3 rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30">
<div className="px-4 py-3 rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-900 dark:text-amber-300">
@@ -916,24 +594,24 @@ const LogsFolderRow = () => {
if (!isTauri()) return null;
return (
<div className="px-4 py-3 rounded-lg border border-slate-200 dark:border-neutral-800 bg-slate-50 dark:bg-neutral-800/60">
<div className="px-4 py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900 dark:text-neutral-100">
<div className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{t('devOptions.appLogs')}
</div>
<div className="text-xs text-slate-700 dark:text-neutral-300 mt-0.5">
<div className="text-xs text-neutral-700 dark:text-neutral-300 mt-0.5">
{t('devOptions.appLogsDesc')}
</div>
{path && (
<div className="text-[11px] text-slate-500 dark:text-neutral-400 mt-1 font-mono truncate">
<div className="text-[11px] text-neutral-500 dark:text-neutral-400 mt-1 font-mono truncate">
{path}
</div>
)}
</div>
<button
onClick={onClick}
className="shrink-0 px-3 py-1.5 rounded-md bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium transition-colors">
className="shrink-0 px-3 py-1.5 rounded-md bg-neutral-700 hover:bg-neutral-600 text-white text-xs font-medium transition-colors">
{t('devOptions.openLogsFolder')}
</button>
</div>
@@ -949,18 +627,6 @@ const LogsFolderRow = () => {
);
};
// ---------------------------------------------------------------------------
// Group section header
// ---------------------------------------------------------------------------
const DevGroupHeader = ({ label }: { label: string }) => (
<div className="px-1 pt-5 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{label}
</span>
</div>
);
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
@@ -1001,12 +667,11 @@ const DeveloperOptionsPanel = () => {
breadcrumbs={breadcrumbs}
/>
{/* 7 labeled sub-sections replacing the previous flat list */}
<div className="px-4 pb-5">
{/* Debug-only sub-sections */}
<div className="p-4 pt-2 space-y-3">
{DEV_GROUPS.map(group => (
<div key={group.labelKey} data-testid={`dev-group-${group.labelKey.split('.').pop()}`}>
<DevGroupHeader label={t(group.labelKey)} />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
<SettingsSection title={t(group.labelKey)}>
{group.items.map((item, index) => (
<SettingsMenuItem
key={item.id}
@@ -1019,30 +684,28 @@ const DeveloperOptionsPanel = () => {
isLast={index === group.items.length - 1}
/>
))}
</div>
</SettingsSection>
</div>
))}
{/* Restart Tour lives outside the 7 groups — utility action */}
<div className="pt-5">
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
<SettingsMenuItem
key={restartTourItem.id}
icon={restartTourItem.icon}
title={restartTourItem.title}
description={restartTourItem.description}
onClick={restartTourItem.onClick}
testId={`settings-nav-${restartTourItem.id}`}
isFirst={true}
isLast={true}
/>
</div>
</div>
{/* Restart Tour lives outside the groups — utility action */}
<SettingsSection>
<SettingsMenuItem
key={restartTourItem.id}
icon={restartTourItem.icon}
title={restartTourItem.title}
description={restartTourItem.description}
onClick={restartTourItem.onClick}
testId={`settings-nav-${restartTourItem.id}`}
isFirst={true}
isLast={true}
/>
</SettingsSection>
</div>
{/* Diagnostics callouts live outside the menu card so the spacing
and alignment don't clash with the SettingsMenuItem rows. */}
<div className="px-4 pt-6 flex flex-col gap-3">
<div className="px-4 pt-2 pb-5 flex flex-col gap-3">
<CoreModeBadge />
<LogsFolderRow />
{showSentryTest && <SentryTestRow />}
@@ -5,7 +5,15 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import type { ToastNotification } from '../../../types/intelligence';
import { ToastContainer } from '../../intelligence/Toast';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsEmptyState,
SettingsListItem,
SettingsSection,
SettingsStatusLine,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import PairPhoneModal from './devices/PairPhoneModal';
@@ -77,7 +85,8 @@ function PeerDot({ online }: { online: boolean | null }) {
return (
<span
title={isOnline ? t('devices.online') : t('devices.offline')}
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${isOnline ? 'bg-sage-500' : 'bg-stone-300'}`}
data-testid={isOnline ? 'peer-status-online' : 'peer-status-offline'}
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${isOnline ? 'bg-sage-500' : 'bg-neutral-300'}`}
/>
);
}
@@ -85,34 +94,28 @@ function PeerDot({ online }: { online: boolean | null }) {
function DeviceRow({
device,
onRevoke,
isFirst,
isLast,
}: {
device: PairedDevice;
onRevoke: (device: PairedDevice) => void;
isFirst: boolean;
isLast: boolean;
}) {
const { t } = useT();
const statusBadge = (
<div className="flex items-center gap-1.5">
<PeerDot online={device.peer_online} />
<span className="font-mono text-xs text-neutral-400">{truncateId(device.channel_id)}</span>
<span className="text-xs text-neutral-400">
{formatRelativeTime(relativeTime(device.last_seen_at), t)}
</span>
</div>
);
return (
<div
className={`flex items-center gap-3 px-4 py-3 bg-white border-b ${isLast ? 'border-b-0' : 'border-stone-100'} ${isFirst ? 'rounded-t-lg' : ''} ${isLast ? 'rounded-b-lg' : ''}`}>
<PeerDot online={device.peer_online} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stone-900 truncate">{device.label}</p>
<p className="text-xs text-stone-400 font-mono">{truncateId(device.channel_id)}</p>
<p className="text-xs text-stone-400">
{formatRelativeTime(relativeTime(device.last_seen_at), t)}
</p>
</div>
<button
onClick={() => onRevoke(device)}
className="text-xs text-coral-600 hover:text-coral-700 transition-colors flex-shrink-0 px-2 py-1 rounded hover:bg-coral-50"
aria-label={t('devices.revokeAria').replace('{label}', device.label)}>
{t('devices.revoke')}
</button>
</div>
<SettingsListItem
label={device.label}
badge={statusBadge}
onRemove={() => onRevoke(device)}
removeLabel={t('devices.revoke')}
/>
);
}
@@ -129,24 +132,26 @@ function ConfirmRevokeDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30">
<div className="bg-white rounded-2xl max-w-sm w-full p-6 border border-stone-200 shadow-large">
<h3 className="text-base font-semibold text-stone-900 mb-2">
<div className="bg-white dark:bg-neutral-900 rounded-2xl max-w-sm w-full p-6 border border-neutral-200 dark:border-neutral-800 shadow-large">
<h3 className="text-base font-semibold text-neutral-800 dark:text-neutral-100 mb-2">
{t('devices.confirmRevokeTitle')}
</h3>
<p className="text-sm text-stone-600 mb-5">
<p className="text-sm text-neutral-600 dark:text-neutral-300 mb-5">
{t('devices.confirmRevokeBody').replace('{label}', device.label)}
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-50 transition-colors text-sm">
<Button type="button" variant="secondary" size="md" onClick={onCancel} className="flex-1">
{t('common.cancel')}
</button>
<button
</Button>
<Button
type="button"
variant="danger"
size="md"
onClick={onConfirm}
className="flex-1 px-4 py-2 rounded-lg bg-coral-500 hover:bg-coral-600 text-white transition-colors text-sm">
className="flex-1"
data-testid="confirm-revoke-btn">
{t('devices.revoke')}
</button>
</Button>
</div>
</div>
</div>
@@ -283,25 +288,22 @@ const DevicesPanel = () => {
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">
<Button type="button" variant="primary" size="xs" onClick={handleOpenPairModal}>
{t('devices.pairIphone')}
</button>
</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">
{t('devices.betaBadge')}
</span>
<p className="text-xs text-stone-500 dark:text-neutral-400">{t('devices.betaText')}</p>
{/* Bespoke beta badge — intentional marketing chip */}
<SettingsBadge variant="warning">{t('devices.betaBadge')}</SettingsBadge>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('devices.betaText')}</p>
</div>
<div className="px-5 pb-5">
<div className="px-5 pb-5 space-y-3">
{loading && (
<div className="flex items-center justify-center py-12">
<svg className="w-5 h-5 animate-spin text-stone-400" fill="none" viewBox="0 0 24 24">
<svg className="w-5 h-5 animate-spin text-neutral-400" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -319,53 +321,51 @@ const DevicesPanel = () => {
</div>
)}
{!loading && error && (
<div className="rounded-lg bg-coral-50 border border-coral-200 px-4 py-3 text-sm text-coral-700">
{error}
</div>
)}
{!loading && error && <SettingsStatusLine saving={false} error={error} savingLabel="" />}
{!loading && !error && devices.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-xl bg-primary-50 flex items-center justify-center mb-3">
<svg
className="w-6 h-6 text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<SettingsSection>
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-xl bg-primary-50 dark:bg-primary-500/10 flex items-center justify-center mb-3">
<svg
className="w-6 h-6 text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<SettingsEmptyState label={t('devices.noPaired')} />
<p className="text-xs text-neutral-400 dark:text-neutral-500 mb-4 max-w-xs">
{t('devices.emptyState')}
</p>
<Button type="button" variant="primary" size="sm" onClick={handleOpenPairModal}>
{t('devices.pairIphone')}
</Button>
</div>
<p className="text-sm font-medium text-stone-700 mb-1">{t('devices.noPaired')}</p>
<p className="text-xs text-stone-400 mb-4 max-w-xs">{t('devices.emptyState')}</p>
<button
onClick={handleOpenPairModal}
className="px-4 py-2 text-sm font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors rounded-lg">
{t('devices.pairIphone')}
</button>
</div>
</SettingsSection>
)}
{!loading && !error && devices.length > 0 && (
<div className="rounded-xl border border-stone-200 overflow-hidden">
{devices.map((device, idx) => (
<DeviceRow
key={device.channel_id}
device={device}
onRevoke={d => {
log('[devices-ui] revoke requested channel_id=%s', d.channel_id);
setRevokeTarget(d);
}}
isFirst={idx === 0}
isLast={idx === devices.length - 1}
/>
))}
</div>
<SettingsSection>
<ul>
{devices.map(device => (
<DeviceRow
key={device.channel_id}
device={device}
onRevoke={d => {
log('[devices-ui] revoke requested channel_id=%s', d.channel_id);
setRevokeTarget(d);
}}
/>
))}
</ul>
</SettingsSection>
)}
</div>
@@ -18,7 +18,16 @@ import {
testEmbeddingsConnection,
updateEmbeddingsSettings,
} from '../../../services/api/embeddingsApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
type Status =
@@ -87,7 +96,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
/>
)}
<div className={embedded ? '' : 'p-4'}>
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-xs text-stone-500 dark:text-neutral-400">
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-xs text-neutral-500 dark:text-neutral-400">
{status.kind === 'loading'
? t('common.loading')
: status.kind === 'error'
@@ -334,72 +343,66 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
)}
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
{t('settings.embeddings.description')}
</p>
{/* Provider selection */}
<div
className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"
role="radiogroup"
aria-label={t('settings.embeddings.providerAria')}>
{settings.providers.map((entry, idx) => {
const selected = entry.slug === selectedProvider;
return (
<button
key={entry.slug}
type="button"
role="radio"
aria-checked={selected}
onClick={() => handleProviderClick(entry)}
className={`w-full flex items-start gap-3 px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 ${
idx !== 0 ? 'border-t border-neutral-100 dark:border-neutral-800' : ''
} ${
selected
? 'bg-primary-50 dark:bg-primary-500/10'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
}`}>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{entry.label}
</span>
{entry.requires_api_key && (
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider ${
entry.has_api_key
? 'bg-sage-100 text-sage-700 dark:bg-sage-900/40 dark:text-sage-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}>
{entry.has_api_key
? t('settings.embeddings.statusConfigured')
: t('settings.embeddings.statusNeedsKey')}
<SettingsSection>
<div role="radiogroup" aria-label={t('settings.embeddings.providerAria')}>
{settings.providers.map((entry, idx) => {
const selected = entry.slug === selectedProvider;
return (
<button
key={entry.slug}
type="button"
role="radio"
aria-checked={selected}
onClick={() => handleProviderClick(entry)}
className={`w-full flex items-start gap-3 px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 ${
idx !== 0 ? 'border-t border-neutral-100 dark:border-neutral-800' : ''
} ${
selected
? 'bg-primary-50 dark:bg-primary-500/10'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
}`}>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{entry.label}
</span>
)}
{entry.requires_api_key && (
<SettingsBadge variant={entry.has_api_key ? 'success' : 'warning'}>
{entry.has_api_key
? t('settings.embeddings.statusConfigured')
: t('settings.embeddings.statusNeedsKey')}
</SettingsBadge>
)}
</span>
<span className="block mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{entry.description}
</span>
</span>
<span className="block mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{entry.description}
</span>
</span>
{selected && (
<svg
className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
);
})}
</div>
{selected && (
<svg
className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
);
})}
</div>
</SettingsSection>
{/* Vector search disabled notice */}
{selectedProvider === 'none' && (
@@ -414,77 +417,83 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
{currentModels.length > 0 &&
selectedProvider !== 'custom' &&
selectedProvider !== 'none' && (
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 space-y-3">
<SettingsSection>
{currentModels.length > 1 && (
<div>
<label className="block text-xs font-semibold text-stone-700 dark:text-neutral-200 mb-1">
{t('settings.embeddings.model')}
</label>
<select
value={settings.model}
onChange={e => void handleModelChange(e.target.value)}
className="w-full px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{currentModels.map(m => (
<option key={m.id} value={m.id}>
{m.label} ({m.id})
</option>
))}
</select>
</div>
<SettingsRow
htmlFor="embeddings-model"
label={t('settings.embeddings.model')}
stacked
control={
<SettingsSelect
id="embeddings-model"
value={settings.model}
onChange={e => void handleModelChange(e.target.value)}
className="w-full">
{currentModels.map(m => (
<option key={m.id} value={m.id}>
{m.label} ({m.id})
</option>
))}
</SettingsSelect>
}
/>
)}
{allowedDims.length > 1 && (
<div>
<label className="block text-xs font-semibold text-stone-700 dark:text-neutral-200 mb-1">
{t('settings.embeddings.dimensions')}
</label>
<select
value={settings.dimensions}
onChange={e => void handleDimsChange(Number(e.target.value))}
className="w-full px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{allowedDims.map(d => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<SettingsRow
htmlFor="embeddings-dims"
label={t('settings.embeddings.dimensions')}
stacked
control={
<SettingsSelect
id="embeddings-dims"
value={settings.dimensions}
onChange={e => void handleDimsChange(Number(e.target.value))}
className="w-full">
{allowedDims.map(d => (
<option key={d} value={d}>
{d}
</option>
))}
</SettingsSelect>
}
/>
)}
{/* Active provider info + actions */}
<div className="flex items-center gap-2 pt-1">
<div className="flex items-center gap-2 px-4 py-3">
{currentEntry?.requires_api_key && currentEntry.has_api_key && (
<button
<Button
type="button"
onClick={() => void handleClearKey()}
className="px-2.5 py-1 rounded-md border border-coral-200 dark:border-coral-500/30 text-[11px] text-coral-600 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/10">
variant="danger"
size="xs"
onClick={() => void handleClearKey()}>
{t('settings.embeddings.clearKey')}
</button>
</Button>
)}
<button
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void handleTestConnection()}
disabled={selectedProvider === 'none'}
className="px-2.5 py-1 rounded-md border border-stone-200 dark:border-neutral-800 text-[11px] text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-50">
disabled={selectedProvider === 'none'}>
{t('settings.embeddings.testConnection')}
</button>
</Button>
</div>
</div>
</SettingsSection>
)}
{/* Status bar */}
<div
role="status"
aria-live="polite"
className="text-xs min-h-[1rem] text-stone-500 dark:text-neutral-400">
{status.kind === 'saving' && t('settings.embeddings.saving')}
{status.kind === 'saved' && t('settings.embeddings.saved')}
{status.kind === 'error' && (
<span className="text-coral-600 dark:text-coral-300">
{t('settings.embeddings.errorPrefix')}: {status.message}
</span>
)}
</div>
<SettingsStatusLine
saving={status.kind === 'saving'}
savedNote={status.kind === 'saved' ? t('settings.embeddings.saved') : null}
error={
status.kind === 'error'
? `${t('settings.embeddings.errorPrefix')}: ${status.message}`
: null
}
savingLabel={t('settings.embeddings.saving')}
/>
</div>
{/* ── Setup popup (API key entry + test + save) ── */}
@@ -505,55 +514,55 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
/* Custom endpoint form */
<div className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-stone-600 dark:text-neutral-300 mb-1">
<label className="block text-[11px] font-medium text-neutral-600 dark:text-neutral-300 mb-1">
{t('settings.embeddings.customEndpoint')}
</label>
<input
<SettingsTextField
type="text"
value={customEndpoint}
onChange={e => setCustomEndpoint(e.target.value)}
placeholder="https://your-endpoint.com/v1"
className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
mono
autoFocus
/>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] font-medium text-stone-600 dark:text-neutral-300 mb-1">
<label className="block text-[11px] font-medium text-neutral-600 dark:text-neutral-300 mb-1">
{t('settings.embeddings.customModelPlaceholder')}
</label>
<input
<SettingsTextField
type="text"
value={customModel}
onChange={e => setCustomModel(e.target.value)}
placeholder="text-embedding-3-small"
className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
mono
/>
</div>
<div className="w-24">
<label className="block text-[11px] font-medium text-stone-600 dark:text-neutral-300 mb-1">
<label className="block text-[11px] font-medium text-neutral-600 dark:text-neutral-300 mb-1">
{t('settings.embeddings.dimensions')}
</label>
<input
<SettingsTextField
type="number"
value={customDims}
onChange={e => setCustomDims(e.target.value)}
placeholder="1024"
className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
mono
/>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-stone-600 dark:text-neutral-300 mb-1">
<label className="block text-[11px] font-medium text-neutral-600 dark:text-neutral-300 mb-1">
{t('settings.embeddings.apiKeyLabel').replace('{provider}', 'API')} (
{t('settings.embeddings.optional')})
</label>
<input
<SettingsTextField
type={setupShowKey ? 'text' : 'password'}
value={setupKey}
onChange={e => setSetupKey(e.target.value)}
placeholder={t('settings.embeddings.placeholderKey')}
className="w-full px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
mono
/>
</div>
</div>
@@ -564,29 +573,31 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
{setupProvider.description}
</p>
<div>
<label className="block text-[11px] font-medium text-stone-600 dark:text-neutral-300 mb-1">
<label className="block text-[11px] font-medium text-neutral-600 dark:text-neutral-300 mb-1">
{t('settings.embeddings.apiKeyLabel').replace(
'{provider}',
setupProvider.label
)}
</label>
<div className="flex gap-2">
<input
<SettingsTextField
type={setupShowKey ? 'text' : 'password'}
value={setupKey}
onChange={e => setSetupKey(e.target.value)}
placeholder={t('settings.embeddings.placeholderKey')}
className="flex-1 px-2.5 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
mono
autoFocus
className="flex-1"
/>
<button
<Button
type="button"
onClick={() => setSetupShowKey(s => !s)}
className="px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-700 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
variant="secondary"
size="xs"
onClick={() => setSetupShowKey(s => !s)}>
{setupShowKey ? t('settings.embeddings.hide') : t('settings.embeddings.show')}
</button>
</Button>
</div>
<p className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
<p className="mt-1 text-[10px] text-neutral-400 dark:text-neutral-500">
{t('settings.embeddings.keyStoredEncrypted')}
</p>
</div>
@@ -621,8 +632,10 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
{/* Popup actions */}
<div className="flex justify-between pt-1">
<button
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => {
if (setupProvider.slug !== 'custom') {
void setupTest();
@@ -632,22 +645,24 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
setupTesting ||
setupSaving ||
(setupProvider.slug !== 'custom' && !setupKey.trim())
}
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-40">
}>
{setupTesting
? t('settings.embeddings.testing')
: t('settings.embeddings.testConnection')}
</button>
</Button>
<div className="flex gap-2">
<button
<Button
type="button"
onClick={() => setSetupProvider(null)}
className="px-4 py-1.5 rounded-lg text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800">
variant="ghost"
size="xs"
onClick={() => setSetupProvider(null)}>
{t('settings.embeddings.cancel')}
</button>
<button
</Button>
<Button
type="button"
variant="primary"
size="xs"
onClick={() => {
if (setupProvider.slug === 'custom') {
void setupSaveCustom();
@@ -661,12 +676,11 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
!setupKey.trim() &&
!setupProvider.has_api_key) ||
(setupProvider.slug === 'custom' && !customEndpoint.trim())
}
className="px-4 py-1.5 rounded-lg text-xs font-medium bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40">
}>
{setupSaving
? t('settings.embeddings.saving')
: t('settings.embeddings.saveAndSwitch')}
</button>
</Button>
</div>
</div>
</div>
@@ -684,18 +698,12 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
{t('settings.embeddings.wipeBody')}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingWipe(null)}
className="px-4 py-2 rounded-lg text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800">
<Button type="button" variant="ghost" size="xs" onClick={() => setPendingWipe(null)}>
{t('settings.embeddings.cancel')}
</button>
<button
type="button"
onClick={() => void confirmWipe()}
className="px-4 py-2 rounded-lg text-xs font-medium bg-coral-500 hover:bg-coral-600 text-white">
</Button>
<Button type="button" variant="danger" size="xs" onClick={() => void confirmWipe()}>
{t('settings.embeddings.confirmWipe')}
</button>
</Button>
</div>
</div>
</div>
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { getCoreHttpBaseUrl, getCoreRpcToken } from '../../../services/coreRpcClient';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsSelect, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface EventEntry {
@@ -209,7 +211,7 @@ const EventLogPanel = () => {
const domains = [...new Set(entries.map(e => e.domain))].sort();
return (
<div data-testid="event-log-panel">
<div className="z-10 relative" data-testid="event-log-panel">
<SettingsHeader
title={t('settings.developerMenu.eventLog.title')}
showBackButton={true}
@@ -219,37 +221,42 @@ const EventLogPanel = () => {
<div className="p-4 space-y-4">
{/* Status bar */}
<div className="flex flex-wrap items-center gap-2 text-xs">
<select
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200"
<div className="flex flex-wrap items-center gap-2">
<SettingsSelect
value={filterType}
onChange={e => setFilterType(e.target.value)}>
onChange={e => setFilterType(e.target.value)}
aria-label={t('settings.developerMenu.eventLog.allTypes')}
inputSize="sm">
<option value="">{t('settings.developerMenu.eventLog.allTypes')}</option>
{domains.map(d => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<input
type="text"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200 w-40"
</SettingsSelect>
<SettingsTextField
className="w-40"
placeholder={t('settings.developerMenu.eventLog.filterAgent')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
aria-label={t('settings.developerMenu.eventLog.filterAgent')}
inputSize="sm"
/>
<button
<Button
type="button"
variant="secondary"
size="xs"
onClick={exportLog}
disabled={filteredEntries.length === 0}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-50">
disabled={filteredEntries.length === 0}>
{t('settings.developerMenu.eventLog.download')}
</button>
<span className="text-stone-500 dark:text-neutral-400">
</Button>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{filteredEntries.length} {t('settings.developerMenu.eventLog.events')} &middot;{' '}
<span
className={
isLive ? 'text-sage-600 dark:text-sage-300' : 'text-stone-400 dark:text-neutral-500'
isLive
? 'text-sage-600 dark:text-sage-300'
: 'text-neutral-500 dark:text-neutral-400'
}>
{isLive
? t('settings.developerMenu.eventLog.live')
@@ -260,18 +267,19 @@ const EventLogPanel = () => {
{/* Jump to latest */}
{!autoScroll && (
<button
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
setAutoScroll(true);
const el = containerRef.current;
if (el) {
el.scrollTop = newEntriesRef.current === 'top' ? 0 : el.scrollHeight;
}
}}
className="text-xs rounded-lg border border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-primary-700 dark:text-primary-300">
}}>
{t('settings.developerMenu.eventLog.jumpToLatest')}
</button>
</Button>
)}
{/* Event stream */}
@@ -281,7 +289,7 @@ const EventLogPanel = () => {
onScroll={handleScroll}
className="max-h-[60vh] overflow-y-auto space-y-1">
{filteredEntries.length === 0 && (
<p className="text-xs text-stone-400 dark:text-neutral-500 py-4 text-center">
<p className="text-xs text-neutral-500 dark:text-neutral-400 py-4 text-center">
{isLive
? t('settings.developerMenu.eventLog.waiting')
: t('settings.developerMenu.eventLog.notConnected')}
@@ -289,14 +297,14 @@ const EventLogPanel = () => {
)}
{filteredEntries.map(entry => {
const colors = DOMAIN_BADGE_COLORS[entry.domain] || {
bg: 'bg-stone-500/20',
text: 'text-stone-400',
bg: 'bg-neutral-500/20',
text: 'text-neutral-400',
};
return (
<div
key={entry.id}
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 flex items-start gap-2">
<span className="text-[10px] text-stone-400 dark:text-neutral-500 font-mono shrink-0 pt-0.5">
className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 px-3 py-2 flex items-start gap-2">
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 font-mono shrink-0 pt-0.5">
{entry.timestamp}
</span>
<span
@@ -306,11 +314,11 @@ const EventLogPanel = () => {
: entry.domain.toUpperCase()}
</span>
{entry.agent && (
<span className="text-[10px] text-stone-500 dark:text-neutral-400 shrink-0 font-mono">
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 shrink-0 font-mono">
{entry.agent}
</span>
)}
<span className="text-xs text-stone-900 dark:text-neutral-100 truncate">
<span className="text-xs text-neutral-800 dark:text-neutral-100 truncate">
{entry.event}
</span>
</div>
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { BackgroundLoopControls } from './AIPanel';
@@ -34,12 +35,8 @@ const HeartbeatPanel = () => {
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4">
{loadError && (
<div className="mb-3 rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{loadError}
</div>
)}
<div className="p-4 space-y-3">
<SettingsStatusLine saving={false} error={loadError} savingLabel="" />
{snapshot ? (
<BackgroundLoopControls
view="heartbeat"
@@ -47,9 +44,11 @@ const HeartbeatPanel = () => {
routing={snapshot.routing}
cloudProviders={snapshot.cloudProviders}
/>
) : (
<div className="text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</div>
)}
) : !loadError ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { BackgroundLoopControls } from './AIPanel';
@@ -34,12 +35,8 @@ const LedgerUsagePanel = () => {
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4">
{loadError && (
<div className="mb-3 rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{loadError}
</div>
)}
<div className="p-4 space-y-3">
<SettingsStatusLine saving={false} error={loadError} savingLabel="" />
{snapshot ? (
<BackgroundLoopControls
view="ledger"
@@ -47,9 +44,11 @@ const LedgerUsagePanel = () => {
routing={snapshot.routing}
cloudProviders={snapshot.cloudProviders}
/>
) : (
<div className="text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</div>
)}
) : !loadError ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
@@ -50,9 +50,9 @@ const statusTone = (state: string): string => {
case 'degraded':
return 'text-amber-700 dark:text-amber-300';
case 'disabled':
return 'text-stone-500 dark:text-neutral-400';
return 'text-neutral-500 dark:text-neutral-400';
default:
return 'text-stone-700 dark:text-neutral-200';
return 'text-neutral-700 dark:text-neutral-200';
}
};
@@ -378,7 +378,7 @@ const LocalModelDebugPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('localModel.debugTitle')}
showBackButton={true}
@@ -35,7 +35,9 @@ import {
setSelectedMascotId,
SUPPORTED_MASCOT_COLORS,
} from '../../../store/mascotSlice';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import {
defaultVoiceIdForLocale,
@@ -308,7 +310,7 @@ const MascotPanel = () => {
);
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.mascot.title')}
showBackButton={true}
@@ -317,6 +319,7 @@ const MascotPanel = () => {
/>
<div className="p-4 space-y-4">
{/* ── Mascot preview (intentional bespoke visual) ───────────── */}
<div className="flex justify-center">
<div style={{ width: 180, height: 180 }}>
<RiveMascot
@@ -328,13 +331,14 @@ const MascotPanel = () => {
</div>
</div>
{/* ── Color picker — intentional bespoke swatch grid UI ────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.mascot.colorHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
{available.length === 0 ? (
<p className="p-4 text-sm text-stone-500 dark:text-neutral-400">
<p className="p-4 text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.mascot.noColorVariants')}
</p>
) : (
@@ -357,14 +361,14 @@ const MascotPanel = () => {
data-testid={`mascot-color-${opt.id}`}
className={`flex flex-col items-center gap-2 rounded-lg p-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 ${
selected
? 'bg-stone-100 dark:bg-neutral-800'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60'
? 'bg-neutral-100 dark:bg-neutral-800'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
}`}>
<span
className={`w-10 h-10 rounded-full border-2 transition-shadow ${
selected
? 'border-primary-500 shadow-soft'
: 'border-stone-200 dark:border-neutral-800'
: 'border-neutral-200 dark:border-neutral-800'
}`}
style={
opt.id === 'custom'
@@ -374,7 +378,9 @@ const MascotPanel = () => {
: { backgroundColor: palette.bodyFill }
}
/>
<span className="text-xs text-stone-700 dark:text-neutral-200">{label}</span>
<span className="text-xs text-neutral-700 dark:text-neutral-200">
{label}
</span>
</button>
);
})}
@@ -382,18 +388,18 @@ const MascotPanel = () => {
)}
</div>
{activeColor === 'custom' && (
<div className="mt-3 bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<div className="mt-3 bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-3">
<label className="flex items-center gap-3">
<input
type="color"
value={customPrimary}
onChange={e => dispatch(setCustomPrimaryColor(e.target.value))}
className="w-8 h-8 rounded-md border border-stone-200 dark:border-neutral-700 cursor-pointer p-0"
className="w-8 h-8 rounded-md border border-neutral-200 dark:border-neutral-700 cursor-pointer p-0"
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{t('settings.mascot.primaryColor')}
</span>
<code className="ml-auto text-[11px] font-mono text-stone-400 dark:text-neutral-500">
<code className="ml-auto text-[11px] font-mono text-neutral-400 dark:text-neutral-500">
{customPrimary}
</code>
</label>
@@ -402,32 +408,34 @@ const MascotPanel = () => {
type="color"
value={customSecondary}
onChange={e => dispatch(setCustomSecondaryColor(e.target.value))}
className="w-8 h-8 rounded-md border border-stone-200 dark:border-neutral-700 cursor-pointer p-0"
className="w-8 h-8 rounded-md border border-neutral-200 dark:border-neutral-700 cursor-pointer p-0"
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{t('settings.mascot.secondaryColor')}
</span>
<code className="ml-auto text-[11px] font-mono text-stone-400 dark:text-neutral-500">
<code className="ml-auto text-[11px] font-mono text-neutral-400 dark:text-neutral-500">
{customSecondary}
</code>
</label>
</div>
)}
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.colorDesc')}
</p>
</div>
{/* ── Voice picker section ──────────────────────────────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.mascot.voice.heading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-4">
{/* Gender radio buttons — intentional bespoke pill UI */}
<div
role="radiogroup"
aria-label={t('settings.mascot.voice.genderHeading')}
className="space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-300">
{t('settings.mascot.voice.genderHeading')}
</span>
<div className="flex gap-2 pt-1">
@@ -442,7 +450,7 @@ const MascotPanel = () => {
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
voiceGender === g
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/20 text-primary-700 dark:text-primary-200'
: 'border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:border-stone-300 dark:hover:border-neutral-700'
: 'border-neutral-200 dark:border-neutral-800 text-neutral-700 dark:text-neutral-200 hover:border-neutral-300 dark:hover:border-neutral-700'
}`}>
{t(
g === 'female'
@@ -454,17 +462,18 @@ const MascotPanel = () => {
</div>
</div>
<label className="flex items-start gap-2 text-sm text-stone-700 dark:text-neutral-200 cursor-pointer">
{/* Locale default checkbox — bespoke inline label layout */}
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-200 cursor-pointer">
<input
type="checkbox"
data-testid="mascot-voice-locale-default"
checked={useLocaleDefault}
onChange={e => onLocaleDefaultToggle(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-stone-300 dark:border-neutral-700 text-primary-600 focus:ring-primary-500"
className="mt-0.5 h-4 w-4 rounded border-neutral-300 dark:border-neutral-700 text-primary-600 focus:ring-primary-500"
/>
<span className="flex flex-col">
<span>{t('settings.mascot.voice.useLocaleDefault')}</span>
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.mascot.voice.useLocaleDefaultDesc')}{' '}
<code className="font-mono">{locale}</code> {' '}
<code className="font-mono">{localeDefaultVoiceId}</code>
@@ -472,8 +481,9 @@ const MascotPanel = () => {
</span>
</label>
{/* Preset dropdown — bespoke label + select combo */}
<label className={`block space-y-1 ${presetPickerDisabled ? 'opacity-50' : ''}`}>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-300">
{t('settings.mascot.voice.presetHeading')}
</span>
<select
@@ -482,7 +492,7 @@ const MascotPanel = () => {
disabled={presetPickerDisabled}
value={isCustomVoice ? '__custom__' : effectiveVoiceId}
onChange={e => onPresetChange(e.target.value)}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed">
className="w-full rounded-md border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-neutral-800 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed">
{visiblePresets.map(v => (
<option key={v.id} value={v.id}>
{v.label}
@@ -494,55 +504,59 @@ const MascotPanel = () => {
{isCustomVoice && (
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-300">
{t('settings.mascot.voice.customHeading')}
</span>
<div className="flex gap-2">
<input
<SettingsTextField
aria-label={t('settings.mascot.voice.customHeading')}
data-testid="mascot-voice-input"
value={voiceDraft}
placeholder={t('settings.mascot.voice.customPlaceholder')}
onChange={e => setVoiceDraft(e.target.value)}
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="flex-1"
/>
<button
<Button
type="button"
variant="primary"
size="xs"
data-testid="mascot-voice-save-paste"
onClick={onSavePaste}
disabled={voiceDraft.trim() === (storedVoiceId ?? '').trim()}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
disabled={voiceDraft.trim() === (storedVoiceId ?? '').trim()}>
{t('common.save')}
</button>
</Button>
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.mascot.voice.customDesc')}
</p>
</label>
)}
<div className="flex flex-wrap items-center gap-2">
<button
<Button
type="button"
variant="primary"
size="xs"
data-testid="mascot-voice-preview"
onClick={() => void onVoicePreview()}
disabled={isPreviewingVoice}
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
className="bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-500">
{isPreviewingVoice
? t('settings.mascot.voice.previewing')
: t('settings.mascot.voice.preview')}
</button>
<button
</Button>
<Button
type="button"
variant="secondary"
size="xs"
data-testid="mascot-voice-reset"
onClick={onVoiceReset}
disabled={storedVoiceId == null}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
disabled={storedVoiceId == null}>
{t('settings.mascot.voice.reset')}
</button>
</Button>
<span
data-testid="mascot-voice-current"
className="ml-1 text-[11px] text-stone-500 dark:text-neutral-400 truncate max-w-[18rem]"
className="ml-1 text-[11px] text-neutral-500 dark:text-neutral-400 truncate max-w-[18rem]"
title={effectiveVoiceId}>
{t('settings.mascot.voice.current')}:{' '}
<code className="font-mono">{effectiveVoiceId}</code>
@@ -557,22 +571,25 @@ const MascotPanel = () => {
</div>
)}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.voice.desc')}
</p>
</div>
{/* ── Character picker — intentional bespoke list UI ────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.mascot.characterHeading')}
</h3>
<div className="mb-3 bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
{/* Custom GIF input */}
<div className="mb-3 bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-300">
{t('settings.mascot.customGifHeading')}
</span>
<div className="flex gap-2">
<input
<SettingsTextField
aria-label={t('settings.mascot.customGifLabel')}
data-testid="mascot-custom-gif-input"
value={customGifDraft}
@@ -581,24 +598,26 @@ const MascotPanel = () => {
setCustomGifDraft(e.target.value);
setCustomGifError(null);
}}
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="flex-1"
/>
<button
<Button
type="button"
variant="primary"
size="xs"
data-testid="mascot-custom-gif-save"
onClick={onSaveCustomGif}
disabled={customGifDraft.trim() === (customMascotGifUrl ?? '').trim()}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
disabled={customGifDraft.trim() === (customMascotGifUrl ?? '').trim()}>
{t('common.save')}
</button>
<button
</Button>
<Button
type="button"
variant="secondary"
size="xs"
data-testid="mascot-custom-gif-reset"
onClick={onResetCustomGif}
disabled={customMascotGifUrl == null && customGifDraft.trim().length === 0}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
disabled={customMascotGifUrl == null && customGifDraft.trim().length === 0}>
{t('common.reset')}
</button>
</Button>
</div>
</label>
{customGifError && (
@@ -609,39 +628,41 @@ const MascotPanel = () => {
</p>
)}
{customMascotGifUrl && (
<div className="flex justify-center rounded-lg border border-stone-100 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex justify-center rounded-lg border border-neutral-100 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-3">
<div style={{ width: 128, height: 128 }}>
<CustomGifMascot src={customMascotGifUrl} />
</div>
</div>
)}
</div>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
{/* Backend mascot library */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
{backendListError && (
<p className="p-4 text-sm text-coral-700 dark:text-coral-300">
{t('settings.mascot.libraryUnavailable')}: {backendListError}
</p>
)}
{!backendListError && backendList === null && (
<p className="p-4 text-sm text-stone-500 dark:text-neutral-400">
<p className="p-4 text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.mascot.loadingLibrary')}
</p>
)}
{backendList && backendList.length === 0 && !backendListError && (
<p className="p-4 text-sm text-stone-500 dark:text-neutral-400">
<p className="p-4 text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.mascot.noCharacters')}
</p>
)}
{backendList && backendList.length > 0 && (
<ul className="divide-y divide-stone-100 dark:divide-neutral-800">
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
<li>
<button
type="button"
onClick={() => handleSelectBackend(null)}
aria-pressed={selectedMascotId == null && customMascotGifUrl == null}
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 ${
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800/60 ${
selectedMascotId == null && customMascotGifUrl == null
? 'bg-stone-50 dark:bg-neutral-800/60 font-medium'
? 'bg-neutral-50 dark:bg-neutral-800/60 font-medium'
: ''
}`}>
<span>{t('settings.mascot.localDefault')}</span>
@@ -661,12 +682,12 @@ const MascotPanel = () => {
onClick={() => handleSelectBackend(summary.id)}
aria-pressed={active}
data-testid={`backend-mascot-${summary.id}`}
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 ${
active ? 'bg-stone-50 dark:bg-neutral-800/60 font-medium' : ''
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800/60 ${
active ? 'bg-neutral-50 dark:bg-neutral-800/60 font-medium' : ''
}`}>
<span className="flex flex-col">
<span>{summary.name}</span>
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
v{summary.version} · {summary.states.length}{' '}
{t('settings.mascot.characterStates')}
{summary.hasVisemes
@@ -688,8 +709,8 @@ const MascotPanel = () => {
</div>
{visibleActiveDetail && (
<div className="mt-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<p className="text-[11px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400 mb-2">
<div className="mt-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-4">
<p className="text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400 mb-2">
{t('settings.mascot.characterPreview')} · {visibleActiveDetail.name}
</p>
<div className="flex justify-center">
@@ -704,7 +725,7 @@ const MascotPanel = () => {
{visibleDetailError}
</p>
)}
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.characterDesc')}
</p>
</div>
@@ -247,4 +247,29 @@ describe('McpServerPanel — open config file', () => {
// The button should not appear
expect(screen.queryByRole('button', { name: /Open Config File/i })).toBeNull();
});
test('shows openConfigError in role=status div when invoke rejects (line 280)', async () => {
hoisted.invoke.mockImplementation((cmd: string) => {
if (cmd === 'mcp_resolve_binary_path') return Promise.resolve(DEFAULT_BINARY_INFO);
if (cmd === 'mcp_open_client_config') return Promise.reject(new Error('permission denied'));
return Promise.resolve();
});
vi.resetModules();
const Panel = await importPanel();
renderWithProviders(<Panel />);
await waitFor(() => {
expect(hoisted.invoke).toHaveBeenCalledWith('mcp_resolve_binary_path');
});
fireEvent.click(screen.getByRole('button', { name: /Open Config File/i }));
// The error message should appear inside a role=status element (line 280-286)
await waitFor(() => {
const statusEl = document.querySelector('[role="status"]');
expect(statusEl).not.toBeNull();
expect(statusEl!.textContent).toContain('permission denied');
});
});
});
@@ -7,7 +7,9 @@ import { useT } from '../../../lib/i18n/I18nContext';
// TAURI-REACT-6 — into a rejected Promise that the existing try/catch sees
// as a regular IPC failure.
import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/common';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('mcp-server-panel');
@@ -180,13 +182,9 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
{/* Section 1 — Available Tools */}
{/* ----------------------------------------------------------------- */}
<div className="px-4 pt-4 pb-2">
<div className="text-sm font-semibold text-slate-900 dark:text-neutral-100 mb-0.5">
{t('settings.mcpServer.toolsSectionTitle')}
</div>
<div className="text-xs text-slate-500 dark:text-neutral-400 mb-3">
{t('settings.mcpServer.toolsSectionDesc')}
</div>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 divide-y divide-stone-100 dark:divide-neutral-800 overflow-hidden">
<SettingsSection
title={t('settings.mcpServer.toolsSectionTitle')}
description={t('settings.mcpServer.toolsSectionDesc')}>
{MCP_TOOLS.map(tool => (
<div
key={tool.name}
@@ -194,100 +192,100 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
<span className="font-mono text-xs text-primary-700 dark:text-primary-400 mt-0.5 shrink-0">
{tool.name}
</span>
<span className="text-xs text-slate-600 dark:text-neutral-400">
<span className="text-xs text-neutral-600 dark:text-neutral-400">
{tool.description}
</span>
</div>
))}
</div>
</SettingsSection>
</div>
{/* ----------------------------------------------------------------- */}
{/* Section 2 — Client Configuration */}
{/* ----------------------------------------------------------------- */}
<div className="px-4 pt-4 pb-6">
<div className="text-sm font-semibold text-slate-900 dark:text-neutral-100 mb-0.5">
{t('settings.mcpServer.configSectionTitle')}
</div>
<div className="text-xs text-slate-500 dark:text-neutral-400 mb-3">
{t('settings.mcpServer.configSectionDesc')}
</div>
{/* Client selector tabs */}
<div
className="flex gap-1 mb-4 flex-wrap"
role="tablist"
aria-label={t('settings.mcpServer.clientSelectorAriaLabel')}>
{clients.map(client => (
<button
key={client.id}
role="tab"
aria-selected={activeClient === client.id}
onClick={() => {
setActiveClient(client.id);
setOpenConfigError(null);
}}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
activeClient === client.id
? 'bg-primary-600 text-white'
: 'bg-stone-100 dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700',
].join(' ')}>
{client.label}
</button>
))}
</div>
{/* Binary path error banner */}
{binaryError && (
<div className="mb-3 px-3 py-2 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10 text-xs text-coral-900 dark:text-coral-300">
{t('settings.mcpServer.binaryPathNotFound')}
<SettingsSection
title={t('settings.mcpServer.configSectionTitle')}
description={t('settings.mcpServer.configSectionDesc')}>
{/* Client selector tabs */}
<div className="px-4 pt-3">
<div
className="flex gap-1 flex-wrap"
role="tablist"
aria-label={t('settings.mcpServer.clientSelectorAriaLabel')}>
{clients.map(client => (
<button
key={client.id}
type="button"
role="tab"
aria-selected={activeClient === client.id}
onClick={() => {
setActiveClient(client.id);
setOpenConfigError(null);
}}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
activeClient === client.id
? 'bg-primary-600 text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700',
].join(' ')}>
{client.label}
</button>
))}
</div>
</div>
)}
{/* Config file path */}
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-slate-500 dark:text-neutral-400 shrink-0">
{t('settings.mcpServer.configFilePath')}:
</span>
<span className="text-xs font-mono text-slate-700 dark:text-neutral-300 truncate">
{configPath}
</span>
</div>
{/* JSON snippet */}
<div className="rounded-xl overflow-hidden border border-stone-200 dark:border-neutral-800 mb-3">
<pre className="bg-stone-50 dark:bg-neutral-900/60 px-4 py-3 text-xs font-mono text-slate-800 dark:text-neutral-200 overflow-x-auto whitespace-pre leading-relaxed">
{snippet}
</pre>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleCopy}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors shrink-0">
{copied ? t('settings.mcpServer.copied') : t('settings.mcpServer.copySnippet')}
</button>
{isTauri() && (
<button
onClick={handleOpenConfig}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700 transition-colors shrink-0">
{t('settings.mcpServer.openConfigFile')}
</button>
{/* Binary path error banner */}
{binaryError && (
<div className="mx-4 mt-3 px-3 py-2 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10 text-xs text-coral-900 dark:text-coral-300">
{t('settings.mcpServer.binaryPathNotFound')}
</div>
)}
</div>
{/* Open config error */}
{openConfigError && (
<div
role="status"
aria-live="polite"
className="mt-2 text-xs text-coral-600 dark:text-coral-300">
{t('settings.mcpServer.openConfigError')}: {openConfigError}
{/* Config file path */}
<div className="px-4 mt-3 mb-2 flex items-center gap-2">
<span className="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">
{t('settings.mcpServer.configFilePath')}:
</span>
<span className="text-xs font-mono text-neutral-700 dark:text-neutral-300 truncate">
{configPath}
</span>
</div>
)}
{/* JSON snippet */}
<div className="mx-4 mb-3 rounded-xl overflow-hidden border border-neutral-200 dark:border-neutral-800">
<pre className="bg-neutral-50 dark:bg-neutral-900/60 px-4 py-3 text-xs font-mono text-neutral-800 dark:text-neutral-200 overflow-x-auto whitespace-pre leading-relaxed">
{snippet}
</pre>
</div>
{/* Action buttons */}
<div className="px-4 pb-4 flex items-center gap-2 flex-wrap">
<Button type="button" variant="secondary" size="xs" onClick={() => void handleCopy()}>
{copied ? t('settings.mcpServer.copied') : t('settings.mcpServer.copySnippet')}
</Button>
{isTauri() && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleOpenConfig()}>
{t('settings.mcpServer.openConfigFile')}
</Button>
)}
</div>
{/* Open config error */}
{openConfigError && (
<div
role="status"
aria-live="polite"
className="px-4 pb-3 text-xs text-coral-600 dark:text-coral-300">
{t('settings.mcpServer.openConfigError')}: {openConfigError}
</div>
)}
</SettingsSection>
</div>
</div>
);
@@ -58,32 +58,32 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
/>
)}
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<section className="rounded-xl border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
<section className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('memoryData.howItWorks')}
</h3>
<dl className="space-y-2.5">
<div>
<dt className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
<dt className="text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{t('memoryData.workspaceVault')}
</dt>
<dd className="text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
<dd className="text-xs leading-relaxed text-neutral-500 dark:text-neutral-400">
{t('memoryData.workspaceVaultDesc')}
</dd>
</div>
<div>
<dt className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
<dt className="text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{t('memoryData.connectedSources')}
</dt>
<dd className="text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
<dd className="text-xs leading-relaxed text-neutral-500 dark:text-neutral-400">
{t('memoryData.connectedSourcesDesc')}
</dd>
</div>
<div>
<dt className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
<dt className="text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{t('memoryData.internalFiles')}
</dt>
<dd className="text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
<dd className="text-xs leading-relaxed text-neutral-500 dark:text-neutral-400">
{t('memoryData.internalFilesDesc')}
</dd>
</div>
@@ -12,7 +12,16 @@ import {
memoryRecallNamespace,
} from '../../../utils/tauriCommands';
import { MemoryTextWithEntities } from '../../intelligence/MemoryTextWithEntities';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsEmptyState,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsTextArea,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { normalizeMemoryDocuments } from './memoryDebugUtils';
@@ -187,7 +196,7 @@ const MemoryDebugPanel = () => {
}, [clearNamespaceInput, refreshAll, t]);
return (
<div data-testid="memory-debug-panel">
<div className="z-10 relative" data-testid="memory-debug-panel">
<SettingsHeader
title={t('memory.debugTitle')}
showBackButton={true}
@@ -197,238 +206,237 @@ const MemoryDebugPanel = () => {
<div className="p-4 space-y-4">
{/* Documents */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('memory.documents')}
</h3>
<div className="flex gap-2">
<input
value={documentsNamespaceFilter}
onChange={e => setDocumentsNamespaceFilter(e.target.value)}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
placeholder={t('memory.filterByNamespace')}
/>
<button
type="button"
onClick={() => void loadDocuments()}
disabled={documentsLoading}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{documentsLoading ? '...' : t('memory.refresh')}
</button>
</div>
{documentsError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{documentsError}
<SettingsSection title={t('memory.documents')}>
<div className="px-4 py-3 space-y-3">
<div className="flex gap-2">
<SettingsTextField
className="flex-1"
value={documentsNamespaceFilter}
onChange={e => setDocumentsNamespaceFilter(e.target.value)}
placeholder={t('memory.filterByNamespace')}
aria-label={t('memory.filterByNamespace')}
inputSize="sm"
/>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void loadDocuments()}
disabled={documentsLoading}>
{documentsLoading ? '...' : t('memory.refresh')}
</Button>
</div>
)}
{documents.length === 0 && !documentsLoading ? (
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('memory.noDocumentsFound')}
</p>
) : (
<div className="space-y-1">
{documents.map(doc => (
<div
key={`${doc.namespace}:${doc.documentId}`}
className="flex items-start justify-between gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2">
<div className="min-w-0">
<div className="text-xs font-medium text-stone-900 dark:text-neutral-100 break-all">
{doc.documentId}
</div>
<div className="text-[11px] text-stone-500 dark:text-neutral-400 break-all">
{doc.namespace}
</div>
{doc.title && (
<div className="text-[11px] text-stone-400 dark:text-neutral-500">
{doc.title}
<SettingsStatusLine saving={false} error={documentsError} savingLabel="" />
{documents.length === 0 && !documentsLoading ? (
<SettingsEmptyState label={t('memory.noDocumentsFound')} />
) : (
<div className="space-y-1">
{documents.map(doc => (
<div
key={`${doc.namespace}:${doc.documentId}`}
className="flex items-start justify-between gap-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-2">
<div className="min-w-0">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-100 break-all">
{doc.documentId}
</div>
)}
<div className="text-[11px] text-neutral-500 dark:text-neutral-400 break-all">
{doc.namespace}
</div>
{doc.title && (
<div className="text-[11px] text-neutral-500 dark:text-neutral-400">
{doc.title}
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="xs"
disabled={Boolean(deleteLoadingId)}
onClick={() => void handleDelete(doc)}>
{deleteLoadingId === doc.documentId ? '...' : t('memory.delete')}
</Button>
</div>
<button
type="button"
disabled={Boolean(deleteLoadingId)}
onClick={() => void handleDelete(doc)}
className="shrink-0 rounded border border-stone-200 dark:border-neutral-800 px-2 py-1 text-[10px] text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{deleteLoadingId === doc.documentId ? '...' : t('memory.delete')}
</button>
</div>
))}
</div>
)}
<details className="text-xs">
<summary className="cursor-pointer text-stone-400 dark:text-neutral-500">
{t('memory.rawResponse')}
</summary>
<pre className="mt-1 max-h-32 overflow-auto rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-950 dark:bg-neutral-50 p-2 text-[11px] text-stone-100 whitespace-pre-wrap break-words">
{JSON.stringify(documentsRaw, null, 2)}
</pre>
</details>
</section>
))}
</div>
)}
<details className="text-xs">
<summary className="cursor-pointer text-neutral-500 dark:text-neutral-400">
{t('memory.rawResponse')}
</summary>
<pre className="mt-1 max-h-32 overflow-auto rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-950 dark:bg-neutral-50 p-2 text-[11px] text-neutral-100 whitespace-pre-wrap break-words">
{JSON.stringify(documentsRaw, null, 2)}
</pre>
</details>
</div>
</SettingsSection>
{/* Namespaces */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('memory.namespaces')}
</h3>
<button
type="button"
onClick={() => void loadNamespaces()}
disabled={namespacesLoading}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1 text-[11px] font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{namespacesLoading ? '...' : t('memory.refresh')}
</button>
<SettingsSection title={t('memory.namespaces')}>
<div className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void loadNamespaces()}
disabled={namespacesLoading}>
{namespacesLoading ? '...' : t('memory.refresh')}
</Button>
</div>
<SettingsStatusLine saving={false} error={namespacesError} savingLabel="" />
{namespaces.length > 0 ? (
<div className="flex flex-wrap gap-1">
{namespaces.map(ns => (
<span
key={ns}
className="rounded-full bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 text-[11px] text-neutral-500 dark:text-neutral-400">
{ns}
</span>
))}
</div>
) : (
<SettingsEmptyState label={t('memory.noNamespacesFound')} />
)}
</div>
{namespacesError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{namespacesError}
</div>
)}
{namespaces.length > 0 ? (
<div className="flex flex-wrap gap-1">
{namespaces.map(ns => (
<span
key={ns}
className="rounded-full bg-stone-100 dark:bg-neutral-800 px-2 py-0.5 text-[11px] text-stone-600 dark:text-neutral-300">
{ns}
</span>
))}
</div>
) : (
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('memory.noNamespacesFound')}
</p>
)}
</section>
</SettingsSection>
{/* Query & Recall */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('memory.queryRecall')}
</h3>
<input
value={namespaceInput}
onChange={e => setNamespaceInput(e.target.value)}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
placeholder={t('memory.namespace')}
/>
<textarea
value={queryInput}
onChange={e => setQueryInput(e.target.value)}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
rows={2}
placeholder={t('memory.queryText')}
/>
<div className="flex items-center gap-2">
<input
value={maxChunksInput}
onChange={e => setMaxChunksInput(e.target.value)}
className="w-16 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-2 py-1.5 text-xs text-stone-700 dark:text-neutral-200"
placeholder={t('memory.defaultMaxChunks')}
<SettingsSection title={t('memory.queryRecall')}>
<div className="px-4 py-3 space-y-2">
<SettingsTextField
value={namespaceInput}
onChange={e => setNamespaceInput(e.target.value)}
placeholder={t('memory.namespace')}
aria-label={t('memory.namespace')}
inputSize="sm"
/>
<span className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('memory.maxChunks')}
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => void handleQuery()}
disabled={queryLoading || !namespaceInput.trim() || !queryInput.trim()}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{queryLoading ? '...' : t('memory.query')}
</button>
<button
type="button"
onClick={() => void handleRecall()}
disabled={recallLoading || !namespaceInput.trim()}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{recallLoading ? '...' : t('memory.recall')}
</button>
<SettingsTextArea
value={queryInput}
onChange={e => setQueryInput(e.target.value)}
rows={2}
placeholder={t('memory.queryText')}
aria-label={t('memory.queryText')}
/>
<div className="flex items-center gap-2">
<SettingsTextField
className="w-16"
value={maxChunksInput}
onChange={e => setMaxChunksInput(e.target.value)}
placeholder={t('memory.defaultMaxChunks')}
aria-label={t('memory.maxChunks')}
inputSize="sm"
/>
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('memory.maxChunks')}
</span>
<div className="flex-1" />
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void handleQuery()}
disabled={queryLoading || !namespaceInput.trim() || !queryInput.trim()}>
{queryLoading ? '...' : t('memory.query')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void handleRecall()}
disabled={recallLoading || !namespaceInput.trim()}>
{recallLoading ? '...' : t('memory.recall')}
</Button>
</div>
<SettingsStatusLine
saving={false}
error={
queryError
? `${t('memory.queryLabel')}: ${queryError}`
: recallError
? `${t('memory.recallLabel')}: ${recallError}`
: null
}
savingLabel=""
/>
{(queryResult || recallResult) && (
<div className="space-y-2">
{queryResult && (
<div>
<div className="text-[11px] font-medium text-neutral-500 dark:text-neutral-400 mb-1">
{t('memory.queryResult')}
</div>
<MemoryTextWithEntities
text={queryResult.text ?? ''}
entities={queryResult.entities}
className="rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
/>
</div>
)}
{recallResult && (
<div>
<div className="text-[11px] font-medium text-neutral-500 dark:text-neutral-400 mb-1">
{t('memory.recallResult')}
</div>
<MemoryTextWithEntities
text={recallResult.text ?? ''}
entities={recallResult.entities}
className="rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
/>
</div>
)}
</div>
)}
</div>
{queryError && (
<div className="text-xs text-coral-600 dark:text-coral-300">
{t('memory.queryLabel')}: {queryError}
</div>
)}
{recallError && (
<div className="text-xs text-coral-600 dark:text-coral-300">
{t('memory.recallLabel')}: {recallError}
</div>
)}
{(queryResult || recallResult) && (
<div className="space-y-2">
{queryResult && (
<div>
<div className="text-[11px] font-medium text-stone-500 dark:text-neutral-400 mb-1">
{t('memory.queryResult')}
</div>
<MemoryTextWithEntities
text={queryResult.text ?? ''}
entities={queryResult.entities}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
/>
</div>
)}
{recallResult && (
<div>
<div className="text-[11px] font-medium text-stone-500 dark:text-neutral-400 mb-1">
{t('memory.recallResult')}
</div>
<MemoryTextWithEntities
text={recallResult.text ?? ''}
entities={recallResult.entities}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
/>
</div>
)}
</div>
)}
</section>
</SettingsSection>
{/* Clear Namespace */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('memory.clearNamespace')}
</h3>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('memory.clearNamespaceDescription')}
</p>
<div className="flex gap-2">
{namespaces.length > 0 ? (
<select
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200">
<option value="">{t('memory.selectNamespace')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
) : (
<input
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
placeholder={t('memory.exampleNamespace')}
/>
)}
<button
type="button"
onClick={() => void handleClearNamespace()}
disabled={clearLoading || !clearNamespaceInput.trim()}
className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-1.5 text-xs font-medium text-coral-600 dark:text-coral-300 hover:bg-coral-100 dark:bg-coral-500/20 disabled:opacity-50">
{clearLoading ? '...' : t('memory.clear')}
</button>
<SettingsSection
title={t('memory.clearNamespace')}
description={t('memory.clearNamespaceDescription')}>
<div className="px-4 py-3 space-y-2">
<div className="flex gap-2">
{namespaces.length > 0 ? (
<SettingsSelect
className="flex-1"
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
aria-label={t('memory.selectNamespace')}
inputSize="sm">
<option value="">{t('memory.selectNamespace')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</SettingsSelect>
) : (
<SettingsTextField
className="flex-1"
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
placeholder={t('memory.exampleNamespace')}
aria-label={t('memory.exampleNamespace')}
inputSize="sm"
/>
)}
<Button
type="button"
variant="danger"
size="xs"
onClick={() => void handleClearNamespace()}
disabled={clearLoading || !clearNamespaceInput.trim()}>
{clearLoading ? '...' : t('memory.clear')}
</Button>
</div>
<SettingsStatusLine
saving={false}
savedNote={clearSuccess}
error={clearError}
savingLabel=""
/>
</div>
{clearSuccess && (
<div className="text-xs text-sage-600 dark:text-sage-300">{clearSuccess}</div>
)}
{clearError && (
<div className="text-xs text-coral-600 dark:text-coral-300">{clearError}</div>
)}
</section>
</SettingsSection>
</div>
</div>
);
@@ -43,12 +43,12 @@ const MemorySyncPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<p className="text-sm text-stone-600 dark:text-neutral-300">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.dataSync.description')}
</p>
<MemorySourcesRegistry onToast={addToast} />
<div className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="mb-2 text-sm font-semibold text-stone-700 dark:text-neutral-200">
<div className="rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<h3 className="mb-2 text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('sync.auditTitle', 'Sync History')}
</h3>
<SyncAuditPanel />
@@ -7,7 +7,9 @@ import {
openhumanMigrateHermes,
openhumanMigrateOpenclaw,
} from '../../../utils/tauriCommands/core';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsSection, SettingsSelect, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('migration-panel');
@@ -143,70 +145,77 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
)}
<div className="max-w-3xl space-y-6 p-6">
<p className="text-sm text-stone-600 dark:text-neutral-300">{t('migration.description')}</p>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
{t('migration.description')}
</p>
<section
className="bg-stone-50 dark:bg-neutral-900/40 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4"
data-testid="migration-form">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('migration.vendorLabel')}
</span>
<select
aria-label={t('migration.vendorLabel')}
data-testid="migration-vendor-select"
value={vendor}
onChange={e => setVendor(e.target.value as Vendor)}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="openclaw">{t('migration.vendor.openclaw')}</option>
<option value="hermes">{t('migration.vendor.hermes')}</option>
</select>
</label>
<SettingsSection>
<div className="p-4 space-y-4" data-testid="migration-form">
<div className="space-y-1">
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-300">
{t('migration.vendorLabel')}
</label>
<SettingsSelect
aria-label={t('migration.vendorLabel')}
data-testid="migration-vendor-select"
value={vendor}
onChange={e => setVendor(e.target.value as Vendor)}
inputSize="sm"
className="w-full">
<option value="openclaw">{t('migration.vendor.openclaw')}</option>
<option value="hermes">{t('migration.vendor.hermes')}</option>
</SettingsSelect>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('migration.sourceLabel')}
</span>
<input
type="text"
data-testid="migration-source-input"
value={sourcePath}
onChange={e => setSourcePath(e.target.value)}
placeholder={
vendor === 'hermes'
? t('migration.sourcePlaceholderHermes')
: t('migration.sourcePlaceholder')
}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('migration.sourceHint')}
<div className="space-y-1">
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-300">
{t('migration.sourceLabel')}
</label>
<SettingsTextField
data-testid="migration-source-input"
value={sourcePath}
onChange={e => setSourcePath(e.target.value)}
placeholder={
vendor === 'hermes'
? t('migration.sourcePlaceholderHermes')
: t('migration.sourcePlaceholder')
}
aria-label={t('migration.sourceLabel')}
inputSize="sm"
className="w-full"
/>
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('migration.sourceHint')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="primary"
size="sm"
data-testid="migration-preview-button"
onClick={() => void runPreview()}
disabled={isPreviewing || isApplying}>
{isPreviewing ? t('migration.previewRunning') : t('migration.previewAction')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="migration-apply-button"
onClick={() => void runApply()}
disabled={!canApply}
className="bg-amber-600 hover:bg-amber-700 text-white disabled:bg-amber-600/50">
{isApplying ? t('migration.applyRunning') : t('migration.applyAction')}
</Button>
</div>
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('migration.applyDisclaimer')}
</p>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
data-testid="migration-preview-button"
onClick={runPreview}
disabled={isPreviewing || isApplying}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{isPreviewing ? t('migration.previewRunning') : t('migration.previewAction')}
</button>
<button
type="button"
data-testid="migration-apply-button"
onClick={runApply}
disabled={!canApply}
className="px-3 py-1.5 text-xs rounded-md bg-amber-600 hover:bg-amber-700 disabled:opacity-60 text-white">
{isApplying ? t('migration.applyRunning') : t('migration.applyAction')}
</button>
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('migration.applyDisclaimer')}
</p>
</section>
</SettingsSection>
{error != null && (
<div
@@ -221,71 +230,71 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
data-testid={
appliedReport != null ? 'migration-report-applied' : 'migration-report-preview'
}
className="bg-white dark:bg-neutral-900/40 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
className="bg-white dark:bg-neutral-900/40 rounded-lg border border-neutral-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{appliedReport != null
? t('migration.reportTitleApplied')
: t('migration.reportTitlePreview')}
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs">
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.source')}
</dt>
<dd
className="text-stone-900 dark:text-neutral-100 break-all"
className="text-neutral-800 dark:text-neutral-100 break-all"
data-testid="migration-report-source">
{reportToRender.source_workspace}
</dd>
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.target')}
</dt>
<dd
className="text-stone-900 dark:text-neutral-100 break-all"
className="text-neutral-800 dark:text-neutral-100 break-all"
data-testid="migration-report-target">
{reportToRender.target_workspace}
</dd>
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.fromSqlite')}
</dt>
<dd className="text-stone-900 dark:text-neutral-100">
<dd className="text-neutral-800 dark:text-neutral-100">
{reportToRender.stats.from_sqlite}
</dd>
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.fromMarkdown')}
</dt>
<dd className="text-stone-900 dark:text-neutral-100">
<dd className="text-neutral-800 dark:text-neutral-100">
{reportToRender.stats.from_markdown}
</dd>
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.imported')}
</dt>
<dd
className="text-stone-900 dark:text-neutral-100"
className="text-neutral-800 dark:text-neutral-100"
data-testid="migration-report-imported">
{reportToRender.stats.imported}
</dd>
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.skippedUnchanged')}
</dt>
<dd className="text-stone-900 dark:text-neutral-100">
<dd className="text-neutral-800 dark:text-neutral-100">
{reportToRender.stats.skipped_unchanged}
</dd>
<dt className="text-stone-500 dark:text-neutral-400">
<dt className="text-neutral-500 dark:text-neutral-400">
{t('migration.report.renamedConflicts')}
</dt>
<dd className="text-stone-900 dark:text-neutral-100">
<dd className="text-neutral-800 dark:text-neutral-100">
{reportToRender.stats.renamed_conflicts}
</dd>
</dl>
{reportToRender.warnings.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-stone-600 dark:text-neutral-300">
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{t('migration.report.warnings')}
</p>
<ul
data-testid="migration-report-warnings"
className="text-xs text-stone-700 dark:text-neutral-300 list-disc list-inside space-y-0.5">
className="text-xs text-neutral-700 dark:text-neutral-300 list-disc list-inside space-y-0.5">
{reportToRender.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
@@ -293,7 +302,7 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
</div>
)}
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
<p className="text-[11px] text-neutral-500 dark:text-neutral-400">
{appliedReport != null
? t('migration.report.appliedHint')
: t('migration.report.previewHint')}
@@ -3,7 +3,9 @@ import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsEmptyState, SettingsSelect } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('openhuman:model-health');
@@ -165,7 +167,7 @@ const ModelHealthPanel = () => {
const sortIcon = (col: SortCol) => (sortCol === col ? (sortAsc ? ' ↑' : ' ↓') : '');
return (
<div data-testid="model-health-panel">
<div className="z-10 relative" data-testid="model-health-panel">
<SettingsHeader
title={t('settings.modelHealth.title')}
showBackButton={true}
@@ -174,29 +176,28 @@ const ModelHealthPanel = () => {
/>
<div className="p-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
<select
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200"
<SettingsSelect
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}>
onChange={e => setFilterStatus(e.target.value)}
aria-label={t('settings.modelHealth.allStatuses')}
inputSize="sm">
<option value="">{t('settings.modelHealth.allStatuses')}</option>
<option value="keep">{t('settings.modelHealth.badge.keep')}</option>
<option value="replace">{t('settings.modelHealth.badge.replace')}</option>
<option value="staging">{t('settings.modelHealth.badge.staging')}</option>
<option value="vision">{t('settings.modelHealth.badge.vision')}</option>
</select>
<span className="text-stone-500 dark:text-neutral-400">
</SettingsSelect>
<span className="text-neutral-500 dark:text-neutral-400">
{filtered.length} {t('settings.modelHealth.models')}
</span>
</div>
{loading ? (
<p className="text-xs text-stone-400 py-4 text-center">
<p className="text-xs text-neutral-500 dark:text-neutral-400 py-4 text-center">
{t('settings.modelHealth.loading')}
</p>
) : filtered.length === 0 ? (
<p className="text-xs text-stone-400 py-4 text-center">
{t('settings.modelHealth.empty')}
</p>
<SettingsEmptyState label={t('settings.modelHealth.empty')} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
@@ -276,12 +277,14 @@ const ModelHealthPanel = () => {
{t(badge.label)}
</span>
{isReplace && candidates.length > 0 && (
<button
<Button
type="button"
className="ml-1 text-[10px] text-amber-400 hover:text-amber-300"
variant="ghost"
size="xs"
className="ml-1 text-amber-400 hover:text-amber-300"
onClick={() => setSwapTarget(m)}>
{t('settings.modelHealth.swap')}
</button>
</Button>
)}
</td>
</tr>
@@ -339,19 +342,23 @@ const ModelHealthPanel = () => {
})}
</div>
<div className="flex gap-2">
<button
<Button
type="button"
className="flex-1 py-2 rounded-lg border border-stone-200 dark:border-neutral-700 text-xs"
variant="secondary"
size="sm"
className="flex-1"
onClick={() => {
setSwapTarget(null);
setSelectedCandidate(null);
}}>
{t('settings.modelHealth.modal.cancel')}
</button>
<button
</Button>
<Button
type="button"
variant="primary"
size="sm"
className="flex-1"
disabled={!selectedCandidate}
className="flex-1 py-2 rounded-lg bg-blue-600 text-white text-xs font-semibold disabled:opacity-40"
onClick={() => {
if (selectedCandidate && swapTarget) {
// Apply is currently UI-only: the backend swap RPC is a
@@ -367,7 +374,7 @@ const ModelHealthPanel = () => {
setSelectedCandidate(null);
}}>
{t('settings.modelHealth.modal.apply')}
</button>
</Button>
</div>
</div>
</div>
@@ -8,6 +8,7 @@ import {
} from '../../../services/notificationService';
import type { NotificationStats } from '../../../types/notifications';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsCheckbox, SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp'];
@@ -121,27 +122,22 @@ const NotificationRoutingPanel = ({ embedded = false }: NotificationRoutingPanel
<div className="p-4 space-y-4">
{stats && (
<div className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('notifications.routing.pipelineStats')}
</p>
</div>
<div className="grid grid-cols-3 divide-x divide-stone-100 dark:divide-neutral-800">
<SettingsSection title={t('notifications.routing.pipelineStats')}>
<div className="grid grid-cols-3 divide-x divide-neutral-100 dark:divide-neutral-800">
{[
{ label: t('notifications.routing.total'), value: stats.total },
{ label: t('notifications.routing.unread'), value: stats.unread },
{ label: t('notifications.routing.unscored'), value: stats.unscored },
].map(({ label, value }) => (
<div key={label} className="px-4 py-3 text-center">
<p className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
<p className="text-lg font-semibold text-neutral-800 dark:text-neutral-100">
{value}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">{label}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{label}</p>
</div>
))}
</div>
</div>
</SettingsSection>
)}
{/* Info card */}
@@ -171,119 +167,110 @@ const NotificationRoutingPanel = ({ embedded = false }: NotificationRoutingPanel
</div>
{/* How it works */}
<div className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('notifications.routing.howItWorks')}
</p>
</div>
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{[
{
label: t('notifications.routing.level.drop'),
desc: t('notifications.routing.level.dropDesc'),
color: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300',
},
{
label: t('notifications.routing.level.acknowledge'),
desc: t('notifications.routing.level.acknowledgeDesc'),
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
},
{
label: t('notifications.routing.level.react'),
desc: t('notifications.routing.level.reactDesc'),
color: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
},
{
label: t('notifications.routing.level.escalate'),
desc: t('notifications.routing.level.escalateDesc'),
color: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300',
},
].map(row => (
<div key={row.label} className="flex items-center gap-3 px-4 py-3">
<span
className={`flex-shrink-0 px-2 py-0.5 rounded text-[11px] font-semibold ${row.color}`}>
{row.label}
</span>
<span className="text-xs text-stone-600 dark:text-neutral-300">{row.desc}</span>
</div>
))}
</div>
</div>
<SettingsSection title={t('notifications.routing.howItWorks')}>
{[
{
label: t('notifications.routing.level.drop'),
desc: t('notifications.routing.level.dropDesc'),
color: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300',
},
{
label: t('notifications.routing.level.acknowledge'),
desc: t('notifications.routing.level.acknowledgeDesc'),
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
},
{
label: t('notifications.routing.level.react'),
desc: t('notifications.routing.level.reactDesc'),
color: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
},
{
label: t('notifications.routing.level.escalate'),
desc: t('notifications.routing.level.escalateDesc'),
color: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300',
},
].map(row => (
<div key={row.label} className="flex items-center gap-3 px-4 py-3">
<span
className={`flex-shrink-0 px-2 py-0.5 rounded text-[11px] font-semibold ${row.color}`}>
{row.label}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-300">{row.desc}</span>
</div>
))}
</SettingsSection>
<div className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('notifications.routing.perProvider')}
</p>
</div>
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{providers.map(provider => {
const hasLoadError = Boolean(loadErrors[provider]);
const isLoaded = Boolean(loadedProviders[provider]);
const s = settings[provider] ?? {
enabled: true,
importance_threshold: 0,
route_to_orchestrator: true,
};
const controlsDisabled = !isLoaded || hasLoadError;
return (
<div key={provider} className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-stone-800 dark:text-neutral-100 capitalize">
{provider}
</p>
<label className="text-xs text-stone-600 dark:text-neutral-300 flex items-center gap-2">
{/* Per-provider routing */}
<SettingsSection title={t('notifications.routing.perProvider')}>
{providers.map(provider => {
const hasLoadError = Boolean(loadErrors[provider]);
const isLoaded = Boolean(loadedProviders[provider]);
const s = settings[provider] ?? {
enabled: true,
importance_threshold: 0,
route_to_orchestrator: true,
};
const controlsDisabled = !isLoaded || hasLoadError;
return (
<div key={provider} className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-neutral-800 dark:text-neutral-100 capitalize">
{provider}
</p>
<div className="flex items-center gap-2">
<label
htmlFor={`notification-enabled-${provider}`}
className="text-xs text-neutral-600 dark:text-neutral-300">
{t('common.enabled')}
<input
type="checkbox"
checked={s.enabled}
disabled={controlsDisabled}
onChange={e => {
void updateSetting(provider, { enabled: e.target.checked });
}}
/>
</label>
<SettingsCheckbox
id={`notification-enabled-${provider}`}
checked={s.enabled}
disabled={controlsDisabled}
onCheckedChange={next => {
void updateSetting(provider, { enabled: next });
}}
/>
</div>
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
{t('notifications.routing.threshold')}
<input
className="flex-1"
type="range"
min={0}
max={1}
step={0.05}
value={s.importance_threshold}
disabled={controlsDisabled}
onChange={e => {
void updateSetting(provider, {
importance_threshold: Number(e.target.value),
});
}}
/>
<span>{s.importance_threshold.toFixed(2)}</span>
</label>
<label className="text-xs text-stone-600 dark:text-neutral-300 flex items-center gap-2">
{t('notifications.routing.routeToOrchestrator')}
<input
type="checkbox"
checked={s.route_to_orchestrator}
disabled={controlsDisabled}
onChange={e => {
void updateSetting(provider, { route_to_orchestrator: e.target.checked });
}}
/>
</label>
{hasLoadError ? (
<p className="text-xs text-red-600 dark:text-red-300">
{t('notifications.routing.loadSettingsError')}
</p>
) : null}
</div>
);
})}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
{t('notifications.routing.threshold')}
<input
className="flex-1"
type="range"
min={0}
max={1}
step={0.05}
value={s.importance_threshold}
disabled={controlsDisabled}
onChange={e => {
void updateSetting(provider, {
importance_threshold: Number(e.target.value),
});
}}
/>
<span>{s.importance_threshold.toFixed(2)}</span>
</label>
<label className="text-xs text-neutral-600 dark:text-neutral-300 flex items-center gap-2">
{t('notifications.routing.routeToOrchestrator')}
<input
type="checkbox"
checked={s.route_to_orchestrator}
disabled={controlsDisabled}
onChange={e => {
void updateSetting(provider, { route_to_orchestrator: e.target.checked });
}}
/>
</label>
{hasLoadError ? (
<p className="text-xs text-red-600 dark:text-red-300">
{t('notifications.routing.loadSettingsError')}
</p>
) : null}
</div>
);
})}
</SettingsSection>
</div>
</div>
);
@@ -5,6 +5,7 @@ import { getBypassPrefs, setGlobalDnd } from '../../../services/webviewAccountSe
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { type NotificationCategory, setPreference } from '../../../store/notificationSlice';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface NotificationsPanelProps {
@@ -93,90 +94,58 @@ const NotificationsPanel = ({ embedded = false }: NotificationsPanelProps = {})
/>
)}
<div>
<div className="p-4 space-y-4">
{/* Do Not Disturb */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.notifications.doNotDisturb')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.notifications.suppressAll')}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('settings.notifications.suppressAllDesc')}
</p>
</div>
{dndLoading ? (
<div className="w-11 h-6 rounded-full bg-stone-200 dark:bg-neutral-800 animate-pulse" />
) : (
<button
onClick={() => {
void handleDndToggle();
}}
disabled={dndSaving}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 disabled:opacity-70 ${
dnd ? 'bg-primary-500' : 'bg-stone-400 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={dnd}
aria-label={t('settings.notifications.toggleDnd')}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
dnd ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
)}
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Do Not Disturb */}
<SettingsSection title={t('settings.notifications.doNotDisturb')}>
<SettingsRow
htmlFor="switch-dnd"
label={t('settings.notifications.suppressAll')}
description={t('settings.notifications.suppressAllDesc')}
control={
dndLoading ? (
<div className="w-[38px] h-[22px] rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
) : (
<SettingsSwitch
id="switch-dnd"
checked={dnd}
onCheckedChange={() => {
void handleDndToggle();
}}
disabled={dndSaving}
aria-label={t('settings.notifications.toggleDnd')}
/>
)
}
/>
</SettingsSection>
{/* Categories */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.notifications.categories')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden divide-y divide-stone-100 dark:divide-neutral-800">
{CATEGORIES.map(cat => {
const enabled = preferences[cat.id];
return (
<div key={cat.id} className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{cat.title}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{cat.description}
</p>
</div>
<button
onClick={() => handleToggle(cat.id)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 ${
enabled ? 'bg-primary-500' : 'bg-stone-400 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={enabled}
aria-label={`Toggle ${cat.title} notifications`}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
);
})}
</div>
{/* Categories */}
<SettingsSection title={t('settings.notifications.categories')}>
{CATEGORIES.map(cat => {
const enabled = preferences[cat.id];
const switchId = `switch-notif-${cat.id}`;
return (
<SettingsRow
key={cat.id}
htmlFor={switchId}
label={cat.title}
description={cat.description}
control={
<SettingsSwitch
id={switchId}
checked={enabled}
onCheckedChange={() => handleToggle(cat.id)}
aria-label={`Toggle ${cat.title} notifications`}
/>
}
/>
);
})}
</SettingsSection>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.notifications.categoryFooter')}
</p>
</div>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1">
{t('settings.notifications.categoryFooter')}
</p>
</div>
</div>
);
@@ -38,7 +38,7 @@ const NotificationsTabbedPanel = () => {
];
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.notifications')}
showBackButton={true}
@@ -49,7 +49,7 @@ const NotificationsTabbedPanel = () => {
<div
role="tablist"
aria-label={t('settings.notifications')}
className="flex gap-1 px-4 pt-3 border-b border-stone-200 dark:border-neutral-800">
className="flex gap-1 px-4 pt-3 border-b border-neutral-200 dark:border-neutral-800">
{tabs.map(({ id, label }) => {
const selected = tab === id;
return (
@@ -61,8 +61,8 @@ const NotificationsTabbedPanel = () => {
onClick={() => selectTab(id)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
selected
? 'border-primary-500 text-stone-900 dark:text-neutral-100'
: 'border-transparent text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200'
? 'border-primary-500 text-neutral-800 dark:text-neutral-100'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'
}`}>
{label}
</button>
@@ -10,7 +10,9 @@ import {
openhumanUpdateAgentPaths,
openhumanUpdateAutonomySettings,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsStatusLine, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// Installs are always *available* but never silent: every `install_tool` call
@@ -203,17 +205,17 @@ const PermissionsPanel = () => {
)}
{isLoading ? (
<p className="text-sm text-stone-600 dark:text-neutral-400">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.agentAccess.loading')}
</p>
) : (
<>
{/* Access mode presets — layman-friendly labels */}
{/* Access mode presets — intentional bespoke card UI; kept as-is. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.permissions.accessMode')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.permissions.accessModeDesc')}
</p>
<div className="grid gap-2">
@@ -226,26 +228,26 @@ const PermissionsPanel = () => {
className={`text-left rounded-lg border p-3 transition ${
level === p.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500'
: 'border-neutral-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500'
}`}>
<div className="flex items-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full border ${
level === p.id
? 'bg-primary-500 border-primary-500'
: 'border-stone-300 dark:border-neutral-700'
: 'border-neutral-300 dark:border-neutral-700'
}`}
/>
<span className="font-medium text-stone-900 dark:text-neutral-100">
<span className="font-medium text-neutral-800 dark:text-neutral-100">
{p.title}
</span>
{p.id === 'supervised' && (
<span className="text-xs text-stone-600 dark:text-neutral-400">
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.agentAccess.defaultTag')}
</span>
)}
</div>
<p className="mt-1 text-xs text-stone-600 dark:text-neutral-400">
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{p.description}
</p>
</button>
@@ -260,16 +262,16 @@ const PermissionsPanel = () => {
{/* Folders the assistant can use */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('settings.permissions.folders')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.permissions.foldersDesc')}
</p>
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2">
<div className="rounded-lg border border-neutral-200 dark:border-neutral-800 px-3 py-2">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-sage-500" />
<span className="text-xs font-medium text-stone-900 dark:text-neutral-100">
<span className="text-xs font-medium text-neutral-800 dark:text-neutral-100">
{t('settings.agentAccess.actionSandbox')}
</span>
<span className="text-xs text-sage-600 dark:text-sage-400">
@@ -279,31 +281,34 @@ const PermissionsPanel = () => {
{actionDirEditing ? (
<div className="mt-1 space-y-1">
<div className="flex items-center gap-2">
<input
type="text"
className="flex-1 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-mono text-stone-900 dark:text-neutral-100"
<SettingsTextField
mono
className="flex-1"
inputSize="sm"
value={actionDirInput}
onChange={e => setActionDirInput(e.target.value)}
placeholder={t('settings.agentAccess.actionDir.placeholder')}
disabled={actionDirSaving}
data-testid="permissions-action-dir-input"
/>
<button
<Button
type="button"
className="rounded bg-ocean px-2 py-1 text-xs font-medium text-white disabled:opacity-50"
variant="primary"
size="xs"
onClick={() => void saveActionDir()}
disabled={actionDirSaving}
data-testid="permissions-action-dir-save">
{t('settings.agentAccess.actionDir.save')}
</button>
<button
</Button>
<Button
type="button"
className="rounded border border-stone-300 dark:border-neutral-700 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-300 disabled:opacity-50"
variant="secondary"
size="xs"
onClick={cancelEditActionDir}
disabled={actionDirSaving}
data-testid="permissions-action-dir-cancel">
{t('settings.agentAccess.actionDir.cancel')}
</button>
</Button>
</div>
{actionDirError && (
<p
@@ -316,7 +321,7 @@ const PermissionsPanel = () => {
) : (
<div className="mt-0.5 flex items-center gap-2">
<p
className="text-xs text-stone-600 dark:text-neutral-400 font-mono"
className="text-xs text-neutral-500 dark:text-neutral-400 font-mono"
data-testid="permissions-action-dir">
{agentPaths?.action_dir ?? '~/OpenHuman/projects'}
</p>
@@ -341,28 +346,19 @@ const PermissionsPanel = () => {
{actionDirSaved && !actionDirEditing && (
<p className="text-xs text-sage-600 dark:text-sage-400">{actionDirSaved}</p>
)}
<p className="text-xs text-stone-500 dark:text-neutral-500 mt-0.5">
<p className="text-xs text-neutral-500 dark:text-neutral-500 mt-0.5">
{t('settings.agentAccess.actionSandboxDesc')}
</p>
</div>
</section>
{/* Auto-save status */}
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
{error ? (
<span className="text-coral-600 dark:text-coral-300">{error}</span>
) : isSaving ? (
<span className="text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.saving')}
</span>
) : savedNote ? (
<span className="text-sage-700 dark:text-sage-300"> {savedNote}</span>
) : (
<span className="text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.changesApply')}
</span>
)}
</div>
<SettingsStatusLine
saving={isSaving}
savedNote={savedNote}
error={error}
savingLabel={t('settings.agentAccess.saving')}
/>
</>
)}
</div>
@@ -17,7 +17,9 @@ import {
setPersonaDescription,
setPersonaDisplayName,
} from '../../../store/personaSlice';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsTextArea, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('persona:panel');
@@ -127,7 +129,7 @@ const PersonaPanel = () => {
};
return (
<div>
<div className="z-10 relative">
<SettingsHeader
title={t('settings.persona.title')}
showBackButton={true}
@@ -135,32 +137,32 @@ const PersonaPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<div className="p-4 pt-2 space-y-5">
{/* ── Identity ─────────────────────────────────────────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.persona.identityHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('settings.persona.displayNameLabel')}
</span>
<input
<SettingsSection title={t('settings.persona.identityHeading')}>
<SettingsRow
htmlFor="persona-display-name"
label={t('settings.persona.displayNameLabel')}
stacked
control={
<SettingsTextField
id="persona-display-name"
aria-label={t('settings.persona.displayNameLabel')}
data-testid="persona-display-name-input"
value={nameDraft}
maxLength={MAX_PERSONA_DISPLAY_NAME_LEN}
placeholder={t('settings.persona.displayNamePlaceholder')}
onChange={e => setNameDraft(e.target.value)}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('settings.persona.descriptionLabel')}
</span>
<textarea
}
/>
<SettingsRow
htmlFor="persona-description"
label={t('settings.persona.descriptionLabel')}
stacked
control={
<SettingsTextArea
id="persona-description"
aria-label={t('settings.persona.descriptionLabel')}
data-testid="persona-description-input"
value={descriptionDraft}
@@ -168,95 +170,95 @@ const PersonaPanel = () => {
rows={3}
placeholder={t('settings.persona.descriptionPlaceholder')}
onChange={e => setDescriptionDraft(e.target.value)}
className="w-full resize-y rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<div className="flex justify-end">
<button
type="button"
data-testid="persona-identity-save"
onClick={onSaveIdentity}
disabled={!identityDirty}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{t('common.save')}
</button>
</div>
}
/>
<div className="flex justify-end px-4 py-3">
<Button
type="button"
data-testid="persona-identity-save"
variant="primary"
size="xs"
onClick={onSaveIdentity}
disabled={!identityDirty}>
{t('common.save')}
</Button>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.persona.identityDesc')}
</p>
</div>
</SettingsSection>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1">
{t('settings.persona.identityDesc')}
</p>
{/* ── Personality (SOUL.md) ────────────────────────────────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.persona.soul.heading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
{soulLoading ? (
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('common.loading')}</p>
) : (
<>
<textarea
<SettingsSection title={t('settings.persona.soul.heading')}>
{soulLoading ? (
<div className="px-4 py-3">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('common.loading')}
</p>
</div>
) : (
<>
<div className="px-4 py-3">
<SettingsTextArea
aria-label={t('settings.persona.soul.editorLabel')}
data-testid="persona-soul-editor"
value={soulDraft}
rows={12}
spellCheck={false}
className="font-mono text-xs leading-relaxed"
onChange={e => setSoulDraft(e.target.value)}
className="w-full resize-y rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs leading-relaxed text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-testid="persona-soul-save"
onClick={() => void onSaveSoul()}
disabled={soulBusy || !soulDirty}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{t('common.save')}
</button>
<button
type="button"
data-testid="persona-soul-reset"
onClick={() => void onResetSoul()}
disabled={soulBusy || soulIsDefault}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
{t('settings.persona.soul.reset')}
</button>
{soulIsDefault && (
<span
data-testid="persona-soul-default-badge"
className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.persona.soul.usingDefault')}
</span>
)}
</div>
</>
)}
{soulError && (
<p
data-testid="persona-soul-error"
className="text-xs text-coral-700 dark:text-coral-300">
{soulError}
</p>
)}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.persona.soul.desc')}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<Button
type="button"
data-testid="persona-soul-save"
variant="primary"
size="xs"
onClick={() => void onSaveSoul()}
disabled={soulBusy || !soulDirty}>
{t('common.save')}
</Button>
<Button
type="button"
data-testid="persona-soul-reset"
variant="secondary"
size="xs"
onClick={() => void onResetSoul()}
disabled={soulBusy || soulIsDefault}>
{t('settings.persona.soul.reset')}
</Button>
{soulIsDefault && (
<span
data-testid="persona-soul-default-badge"
className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('settings.persona.soul.usingDefault')}
</span>
)}
</div>
</>
)}
{soulError && (
<p
data-testid="persona-soul-error"
className="px-4 pb-3 text-xs text-coral-700 dark:text-coral-300">
{soulError}
</p>
)}
</SettingsSection>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1">
{t('settings.persona.soul.desc')}
</p>
{/* ── Appearance & Voice (handled in Mascot settings) ──────── */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.persona.appearanceHeading')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4">
<SettingsSection title={t('settings.persona.appearanceHeading')}>
<div className="px-4 py-3">
<button
type="button"
data-testid="persona-open-mascot"
onClick={() => navigateToSettings('mascot')}
className="flex w-full items-center justify-between text-left text-sm text-stone-700 dark:text-neutral-200 hover:text-primary-700 dark:hover:text-primary-300">
className="flex w-full items-center justify-between text-left text-sm text-neutral-800 dark:text-neutral-200 hover:text-primary-700 dark:hover:text-primary-300">
<span>{t('settings.persona.openMascotSettings')}</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -268,10 +270,10 @@ const PersonaPanel = () => {
</svg>
</button>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.persona.appearanceDesc')}
</p>
</div>
</SettingsSection>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1">
{t('settings.persona.appearanceDesc')}
</p>
</div>
</div>
);
@@ -10,6 +10,13 @@ import {
type PrivacyDataKind,
} from '../../../utils/tauriCommands/aboutApp';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
type SettingsBadgeVariant,
SettingsRow,
SettingsSection,
SettingsSwitch,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('privacy-panel');
@@ -18,16 +25,12 @@ interface AnnotatedCapability extends Capability {
privacy: CapabilityPrivacy;
}
const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
raw: 'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30',
derived:
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
credentials:
'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800',
diagnostics:
'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-500/30',
metadata:
'bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 border-stone-200 dark:border-neutral-800',
const KIND_BADGE_VARIANT: Record<PrivacyDataKind, SettingsBadgeVariant> = {
raw: 'success',
derived: 'warning',
credentials: 'neutral',
diagnostics: 'primary',
metadata: 'neutral',
};
function kindLabel(kind: PrivacyDataKind, t: (key: string) => string): string {
@@ -105,156 +108,117 @@ const PrivacyPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div>
<div className="p-4 space-y-4">
{/* What leaves my computer */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-3 px-1">
{t('privacy.whatLeavesComputer')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
{loadState === 'loading' && (
<p className="p-4 text-xs text-stone-500 dark:text-neutral-400">
{t('privacy.loading')}
</p>
)}
{loadState === 'error' && (
<p
className="p-4 text-xs text-stone-500 dark:text-neutral-400"
data-testid="privacy-load-error">
{t('privacy.loadError')}
</p>
)}
{loadState === 'ready' && capabilities.length === 0 && (
<p className="p-4 text-xs text-stone-500 dark:text-neutral-400">
{t('privacy.noCapabilities')}
</p>
)}
{loadState === 'ready' && capabilities.length > 0 && (
<ul
className="divide-y divide-stone-100 dark:divide-neutral-800"
data-testid="privacy-capability-list">
{capabilities.map(cap => (
<li key={cap.id} className="p-4" data-testid={`privacy-row-${cap.id}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{cap.name}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{cap.description}
</p>
{cap.privacy.destinations.length > 0 && (
<p className="text-xs text-stone-400 dark:text-neutral-500 mt-1">
{t('privacy.sentTo')}: {cap.privacy.destinations.join(', ')}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<span
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border ${KIND_BADGE_CLASS[cap.privacy.data_kind]}`}>
{kindLabel(cap.privacy.data_kind, t)}
</span>
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{cap.privacy.leaves_device
? t('privacy.leavesDevice')
: t('privacy.staysLocal')}
</span>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<div className="p-4 space-y-4">
{/* What leaves my computer */}
<SettingsSection title={t('privacy.whatLeavesComputer')}>
{loadState === 'loading' && (
<p className="p-4 text-xs text-neutral-500 dark:text-neutral-400">
{t('privacy.loading')}
</p>
)}
{loadState === 'error' && (
<p
className="p-4 text-xs text-neutral-500 dark:text-neutral-400"
data-testid="privacy-load-error">
{t('privacy.loadError')}
</p>
)}
{loadState === 'ready' && capabilities.length === 0 && (
<p className="p-4 text-xs text-neutral-500 dark:text-neutral-400">
{t('privacy.noCapabilities')}
</p>
)}
{loadState === 'ready' && capabilities.length > 0 && (
<ul data-testid="privacy-capability-list">
{capabilities.map(cap => (
<li key={cap.id} className="p-4" data-testid={`privacy-row-${cap.id}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{cap.name}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 leading-relaxed">
{cap.description}
</p>
{cap.privacy.destinations.length > 0 && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{t('privacy.sentTo')}: {cap.privacy.destinations.join(', ')}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<SettingsBadge variant={KIND_BADGE_VARIANT[cap.privacy.data_kind]}>
{kindLabel(cap.privacy.data_kind, t)}
</SettingsBadge>
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
{cap.privacy.leaves_device
? t('privacy.leavesDevice')
: t('privacy.staysLocal')}
</span>
</div>
</div>
</li>
))}
</ul>
)}
</SettingsSection>
{/* Analytics Section */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-3 px-1">
{t('privacy.anonymizedAnalytics')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('privacy.shareAnonymizedData')}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('privacy.shareAnonymizedDataDesc')}
</p>
</div>
<button
type="button"
onClick={handleToggleAnalytics}
data-testid="privacy-analytics-toggle"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
analyticsEnabled ? 'bg-primary-500' : 'bg-stone-600 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={analyticsEnabled}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
analyticsEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
</div>
{/* Analytics Section */}
<SettingsSection title={t('privacy.anonymizedAnalytics')}>
<SettingsRow
htmlFor="switch-analytics"
label={t('privacy.shareAnonymizedData')}
description={t('privacy.shareAnonymizedDataDesc')}
control={
<SettingsSwitch
id="switch-analytics"
checked={analyticsEnabled}
onCheckedChange={() => {
void handleToggleAnalytics();
}}
data-testid="privacy-analytics-toggle"
/>
}
/>
</SettingsSection>
{/* Meeting Follow-ups Section (#1299) */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-3 px-1">
{t('privacy.meetingFollowUps')}
</h3>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('privacy.autoHandoffMeet')}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('privacy.autoHandoffMeetDesc')}
</p>
</div>
<button
data-testid="privacy-meet-handoff-toggle"
onClick={handleToggleMeetAutoHandoff}
aria-label={t('privacy.autoHandoffMeet')}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
meetAutoHandoff ? 'bg-primary-500' : 'bg-stone-600 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={meetAutoHandoff}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
meetAutoHandoff ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
</div>
{/* Meeting Follow-ups Section (#1299) */}
<SettingsSection title={t('privacy.meetingFollowUps')}>
<SettingsRow
htmlFor="switch-meet-handoff"
label={t('privacy.autoHandoffMeet')}
description={t('privacy.autoHandoffMeetDesc')}
control={
<SettingsSwitch
id="switch-meet-handoff"
checked={meetAutoHandoff}
onCheckedChange={() => {
void handleToggleMeetAutoHandoff();
}}
aria-label={t('privacy.autoHandoffMeet')}
data-testid="privacy-meet-handoff-toggle"
/>
}
/>
</SettingsSection>
{/* Info Box */}
<div className="p-4 bg-stone-50 dark:bg-neutral-800/60 rounded-xl border border-stone-200 dark:border-neutral-800">
<div className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-stone-400 dark:text-neutral-500 mt-0.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('privacy.analyticsDisclaimer')}
</p>
</div>
{/* Info Box */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-800/60 rounded-xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-neutral-400 dark:text-neutral-500 mt-0.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
{t('privacy.analyticsDisclaimer')}
</p>
</div>
</div>
</div>
@@ -8,7 +8,9 @@ import {
MNEMONIC_GENERATE_WORD_COUNT,
validateMnemonicPhrase,
} from '../../../utils/cryptoKeys';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsCheckbox } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
@@ -224,7 +226,7 @@ const RecoveryPhrasePanel = () => {
</svg>
</div>
<p className="text-sm font-medium text-sage-500">{t('mnemonic.phraseSaved')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('mnemonic.walletReady')}
</p>
</div>
@@ -233,7 +235,7 @@ const RecoveryPhrasePanel = () => {
{mode === 'generate' ? (
<>
<div className="mb-4 space-y-3">
<p className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed">
<p className="text-sm text-neutral-600 dark:text-neutral-300 leading-relaxed">
{t('mnemonic.writeDownWords')} {MNEMONIC_GENERATE_WORD_COUNT}{' '}
{t('mnemonic.wordsInOrder')}
</p>
@@ -256,7 +258,7 @@ const RecoveryPhrasePanel = () => {
</div>
</div>
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-2xl p-4 mb-4 border border-stone-200 dark:border-neutral-800 relative">
<div className="bg-neutral-50 dark:bg-neutral-800/60 rounded-2xl p-4 mb-4 border border-neutral-200 dark:border-neutral-800 relative">
<div
className="grid grid-cols-3 gap-2 transition-all duration-300"
style={{
@@ -267,8 +269,8 @@ const RecoveryPhrasePanel = () => {
{words.map((word, index) => (
<div
key={index}
className="flex items-center gap-2 bg-white dark:bg-neutral-900 rounded-lg px-3 py-2 text-sm border border-stone-200 dark:border-neutral-800">
<span className="text-stone-500 dark:text-neutral-400 font-mono text-xs w-5 text-right">
className="flex items-center gap-2 bg-white dark:bg-neutral-900 rounded-lg px-3 py-2 text-sm border border-neutral-200 dark:border-neutral-800">
<span className="text-neutral-500 dark:text-neutral-400 font-mono text-xs w-5 text-right">
{index + 1}.
</span>
<span className="font-mono font-medium">{word}</span>
@@ -282,7 +284,7 @@ const RecoveryPhrasePanel = () => {
aria-label={t('mnemonic.revealPhrase')}
className="absolute inset-0 flex items-center justify-center cursor-pointer bg-transparent">
<svg
className="w-7 h-7 text-stone-900 dark:text-white transition-opacity duration-200 hover:opacity-70"
className="w-7 h-7 text-neutral-800 dark:text-white transition-opacity duration-200 hover:opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -298,10 +300,13 @@ const RecoveryPhrasePanel = () => {
)}
</div>
<button
onClick={handleCopy}
<Button
type="button"
variant="secondary"
size="md"
onClick={() => void handleCopy()}
disabled={!revealed}
className="w-full flex items-center justify-center gap-2 border border-stone-200 dark:border-neutral-800 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700 font-medium py-2.5 text-sm rounded-xl text-stone-700 dark:text-neutral-200 transition-all duration-200 mb-3 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-stone-200 dark:disabled:hover:border-neutral-800">
className="w-full mb-3">
{copied ? (
<>
<svg
@@ -331,22 +336,22 @@ const RecoveryPhrasePanel = () => {
<span>{t('mnemonic.copyToClipboard')}</span>
</>
)}
</button>
</Button>
<button
type="button"
onClick={() => switchMode('import')}
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 dark:text-primary-300 transition-colors mb-3">
{t('mnemonic.alreadyHavePhrase')}
</button>
<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
type="checkbox"
<SettingsCheckbox
id="mnemonic-confirm-checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-stone-500 text-primary-500 focus:ring-primary-500"
onCheckedChange={setConfirmed}
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{t('mnemonic.consentSaved')}
</span>
</label>
@@ -354,13 +359,13 @@ const RecoveryPhrasePanel = () => {
) : (
<>
<div className="mb-4">
<p className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed">
<p className="text-sm text-neutral-600 dark:text-neutral-300 leading-relaxed">
{t('mnemonic.enterPhraseToRestore')}
</p>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-stone-500 dark:text-neutral-400">
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('mnemonic.words')}:
</span>
{BIP39_IMPORT_LENGTHS.map(len => (
@@ -371,18 +376,18 @@ const RecoveryPhrasePanel = () => {
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
selectedWordCount === len
? 'bg-primary-500/20 border-primary-500/40 text-primary-600 dark:text-primary-300 border'
: 'border border-stone-200 dark:border-neutral-800 text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:border-neutral-700'
: 'border border-neutral-200 dark:border-neutral-800 text-neutral-500 dark:text-neutral-400 hover:border-neutral-300 dark:border-neutral-700'
}`}>
{len}
</button>
))}
</div>
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-2xl p-4 mb-4 border border-stone-200 dark:border-neutral-800">
<div className="bg-neutral-50 dark:bg-neutral-800/60 rounded-2xl p-4 mb-4 border border-neutral-200 dark:border-neutral-800">
<div className="grid grid-cols-3 gap-2">
{importWords.map((word, index) => (
<div key={index} className="flex items-center gap-1.5">
<span className="text-stone-500 dark:text-neutral-400 font-mono text-xs w-5 text-right shrink-0">
<span className="text-neutral-500 dark:text-neutral-400 font-mono text-xs w-5 text-right shrink-0">
{index + 1}.
</span>
<input
@@ -396,12 +401,12 @@ const RecoveryPhrasePanel = () => {
onKeyDown={e => handleImportKeyDown(index, e)}
autoComplete="off"
spellCheck={false}
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 outline-none transition-colors ${
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white dark:bg-neutral-900 text-neutral-800 dark:text-neutral-100 outline-none transition-colors ${
importValid === false && word.trim()
? 'border-coral-400 focus:border-coral-300 dark:border-coral-500/40'
: importValid === true
? 'border-sage-400 focus:border-sage-300 dark:border-sage-500/40'
: 'border-stone-200 dark:border-neutral-800 focus:border-primary-400'
: 'border-neutral-200 dark:border-neutral-800 focus:border-primary-400'
}`}
/>
</div>
@@ -424,6 +429,7 @@ const RecoveryPhrasePanel = () => {
)}
<button
type="button"
onClick={() => switchMode('generate')}
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 dark:text-primary-300 transition-colors mb-3">
{t('mnemonic.generateNewPhrase')}
@@ -453,11 +459,13 @@ const RecoveryPhrasePanel = () => {
</div>
)}
<button
<Button
type="button"
variant="primary"
size="lg"
onClick={() => void handleSave()}
disabled={!canSave || loading}
className="btn-primary w-full py-3 text-sm font-medium rounded-xl disabled:opacity-60 flex items-center justify-center gap-2">
className="w-full">
{loading ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -480,7 +488,7 @@ const RecoveryPhrasePanel = () => {
) : (
t('mnemonic.saveRecoveryPhrase')
)}
</button>
</Button>
</>
)}
</div>
@@ -8,6 +8,16 @@ import {
type SandboxBackendId,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsEmptyState,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const BACKEND_OPTIONS: SandboxBackendId[] = [
@@ -126,235 +136,210 @@ const SandboxSettingsPanel = () => {
if (!isTauri()) {
return (
<div className="z-10 relative mx-auto max-w-2xl px-4 py-8">
<div className="z-10 relative">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<p className="text-sm text-stone-500 dark:text-stone-400">
{t('settings.sandbox.desktopOnly')}
</p>
<div className="p-4 pt-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.sandbox.desktopOnly')}
</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="z-10 relative mx-auto max-w-2xl px-4 py-8">
<div className="z-10 relative">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<p className="text-sm text-stone-500 dark:text-stone-400">
{t('settings.sandbox.loading')}
</p>
<div className="p-4 pt-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.sandbox.loading')}
</p>
</div>
</div>
);
}
return (
<div className="z-10 relative mx-auto max-w-2xl px-4 py-8">
<div className="z-10 relative">
<SettingsHeader
title={t('settings.sandbox.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{error && (
<div
className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
role="alert">
{error}
</div>
)}
{savedNote && (
<p className="mb-4 text-sm text-green-600 dark:text-green-400" aria-live="polite">
{savedNote}
</p>
)}
{/* Status */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold text-stone-700 dark:text-stone-200">
{t('settings.sandbox.status')}
</h2>
<div className="space-y-2 rounded-lg border border-stone-200 bg-stone-50 p-4 dark:border-stone-700 dark:bg-stone-800">
<div className="flex items-center justify-between">
<span className="text-sm text-stone-600 dark:text-stone-300">
{t('settings.sandbox.dockerStatus')}
</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
dockerAvailable
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-stone-200 text-stone-600 dark:bg-stone-700 dark:text-stone-400'
}`}>
{dockerAvailable
? t('settings.sandbox.available')
: t('settings.sandbox.unavailable')}
</span>
</div>
{detectedBackend && (
<div className="flex items-center justify-between">
<span className="text-sm text-stone-600 dark:text-stone-300">
{t('settings.sandbox.detectedBackend')}
</span>
<span className="text-sm font-mono text-stone-700 dark:text-stone-200">
{detectedBackend}
</span>
</div>
)}
</div>
</section>
{/* Enabled toggle */}
<section className="mb-6">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={enabled}
onChange={e => handleEnabledChange(e.target.checked)}
className="h-4 w-4 rounded border-stone-300 dark:border-stone-600"
aria-label={t('settings.sandbox.enableLabel')}
<div className="p-4 pt-2 space-y-5">
{/* Status section */}
<SettingsSection title={t('settings.sandbox.status')}>
<SettingsRow
label={t('settings.sandbox.dockerStatus')}
control={
<SettingsBadge variant={dockerAvailable ? 'success' : 'neutral'}>
{dockerAvailable
? t('settings.sandbox.available')
: t('settings.sandbox.unavailable')}
</SettingsBadge>
}
/>
<div>
<span className="text-sm font-medium text-stone-700 dark:text-stone-200">
{t('settings.sandbox.enableLabel')}
</span>
<p className="text-xs text-stone-500 dark:text-stone-400">
{t('settings.sandbox.enableDesc')}
</p>
</div>
</label>
</section>
{/* Backend selection */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold text-stone-700 dark:text-stone-200">
{t('settings.sandbox.backendLabel')}
</h2>
<p className="mb-2 text-xs text-stone-500 dark:text-stone-400">
{t('settings.sandbox.backendDesc')}
</p>
<select
value={backend}
onChange={e => handleBackendChange(e.target.value as SandboxBackendId)}
className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-700 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200"
aria-label={t('settings.sandbox.backendLabel')}>
{BACKEND_OPTIONS.map(opt => (
<option key={opt} value={opt}>
{t(`settings.sandbox.backend.${opt}`)}
</option>
))}
</select>
</section>
{/* Docker settings */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold text-stone-700 dark:text-stone-200">
{t('settings.sandbox.dockerSettings')}
</h2>
<div className="space-y-4 rounded-lg border border-stone-200 p-4 dark:border-stone-700">
{/* Docker image */}
<div>
<label className="mb-1 block text-xs font-medium text-stone-600 dark:text-stone-300">
{t('settings.sandbox.dockerImage')}
</label>
<input
type="text"
value={dockerImage}
onChange={e => setDockerImage(e.target.value)}
onBlur={handleDockerImageBlur}
onKeyDown={e => e.key === 'Enter' && handleDockerImageBlur()}
className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 font-mono text-sm text-stone-700 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200"
aria-label={t('settings.sandbox.dockerImage')}
placeholder={t('settings.sandbox.dockerImagePlaceholder')}
{detectedBackend && (
<SettingsRow
label={t('settings.sandbox.detectedBackend')}
control={
<span className="text-sm font-mono text-neutral-800 dark:text-neutral-100">
{detectedBackend}
</span>
}
/>
</div>
)}
</SettingsSection>
{/* Enabled toggle */}
<SettingsSection>
<SettingsRow
htmlFor="switch-sandbox-enabled"
label={t('settings.sandbox.enableLabel')}
description={t('settings.sandbox.enableDesc')}
control={
<SettingsSwitch
id="switch-sandbox-enabled"
checked={enabled}
onCheckedChange={handleEnabledChange}
aria-label={t('settings.sandbox.enableLabel')}
/>
}
/>
</SettingsSection>
{/* Backend selection */}
<SettingsSection
title={t('settings.sandbox.backendLabel')}
description={t('settings.sandbox.backendDesc')}>
<SettingsRow
stacked
control={
<SettingsSelect
value={backend}
onChange={e => handleBackendChange(e.target.value as SandboxBackendId)}
aria-label={t('settings.sandbox.backendLabel')}>
{BACKEND_OPTIONS.map(opt => (
<option key={opt} value={opt}>
{t(`settings.sandbox.backend.${opt}`)}
</option>
))}
</SettingsSelect>
}
/>
</SettingsSection>
{/* Docker settings */}
<SettingsSection title={t('settings.sandbox.dockerSettings')}>
{/* Docker image */}
<SettingsRow
htmlFor="sandbox-docker-image"
label={t('settings.sandbox.dockerImage')}
stacked
control={
<SettingsTextField
id="sandbox-docker-image"
mono
value={dockerImage}
onChange={e => setDockerImage(e.target.value)}
onBlur={handleDockerImageBlur}
onKeyDown={e => e.key === 'Enter' && handleDockerImageBlur()}
aria-label={t('settings.sandbox.dockerImage')}
placeholder={t('settings.sandbox.dockerImagePlaceholder')}
/>
}
/>
{/* Memory limit */}
<div>
<label className="mb-1 block text-xs font-medium text-stone-600 dark:text-stone-300">
{t('settings.sandbox.memoryLimit')}
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={memoryLimitMb}
onChange={e => setMemoryLimitMb(e.target.value)}
onBlur={handleMemoryBlur}
onKeyDown={e => e.key === 'Enter' && handleMemoryBlur()}
className="w-32 rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-700 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200"
aria-label={t('settings.sandbox.memoryLimit')}
min={64}
/>
<span className="text-xs text-stone-500 dark:text-stone-400">
{t('settings.sandbox.memoryUnit')}
</span>
</div>
</div>
<SettingsRow
htmlFor="sandbox-memory-limit"
label={t('settings.sandbox.memoryLimit')}
stacked
control={
<div className="flex items-center gap-2">
<SettingsTextField
id="sandbox-memory-limit"
type="number"
className="w-32"
inputSize="sm"
value={memoryLimitMb}
onChange={e => setMemoryLimitMb(e.target.value)}
onBlur={handleMemoryBlur}
onKeyDown={e => e.key === 'Enter' && handleMemoryBlur()}
aria-label={t('settings.sandbox.memoryLimit')}
min={64}
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sandbox.memoryUnit')}
</span>
</div>
}
/>
{/* CPU limit */}
<div>
<label className="mb-1 block text-xs font-medium text-stone-600 dark:text-stone-300">
{t('settings.sandbox.cpuLimit')}
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={cpuLimit}
onChange={e => setCpuLimit(e.target.value)}
onBlur={handleCpuBlur}
onKeyDown={e => e.key === 'Enter' && handleCpuBlur()}
className="w-32 rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-700 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200"
aria-label={t('settings.sandbox.cpuLimit')}
min={0.1}
step={0.1}
/>
<span className="text-xs text-stone-500 dark:text-stone-400">
{t('settings.sandbox.cpuUnit')}
</span>
</div>
</div>
</div>
</section>
{/* Environment passthrough */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold text-stone-700 dark:text-stone-200">
{t('settings.sandbox.envPassthrough')}
</h2>
<p className="mb-2 text-xs text-stone-500 dark:text-stone-400">
{t('settings.sandbox.envPassthroughDesc')}
</p>
<div className="rounded-lg border border-stone-200 bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-800">
{envPassthrough.length > 0 ? (
<div className="flex flex-wrap gap-2">
{envPassthrough.map(v => (
<span
key={v}
className="rounded-md bg-stone-200 px-2 py-0.5 font-mono text-xs text-stone-700 dark:bg-stone-700 dark:text-stone-300">
{v}
<SettingsRow
htmlFor="sandbox-cpu-limit"
label={t('settings.sandbox.cpuLimit')}
stacked
control={
<div className="flex items-center gap-2">
<SettingsTextField
id="sandbox-cpu-limit"
type="number"
className="w-32"
inputSize="sm"
value={cpuLimit}
onChange={e => setCpuLimit(e.target.value)}
onBlur={handleCpuBlur}
onKeyDown={e => e.key === 'Enter' && handleCpuBlur()}
aria-label={t('settings.sandbox.cpuLimit')}
min={0.1}
step={0.1}
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sandbox.cpuUnit')}
</span>
</div>
}
/>
</SettingsSection>
{/* Environment passthrough */}
<SettingsSection
title={t('settings.sandbox.envPassthrough')}
description={t('settings.sandbox.envPassthroughDesc')}>
{envPassthrough.length > 0 ? (
<div className="px-4 py-3 flex flex-wrap gap-2">
{envPassthrough.map(v => (
<SettingsBadge key={v} variant="neutral">
<span className="font-mono">{v}</span>
</SettingsBadge>
))}
</div>
) : (
<p className="text-xs text-stone-400 dark:text-stone-500">
{t('settings.sandbox.noEnvVars')}
</p>
<SettingsEmptyState label={t('settings.sandbox.noEnvVars')} />
)}
</div>
</section>
</SettingsSection>
{/* Saving indicator */}
{isSaving && (
<p className="text-xs text-stone-500 dark:text-stone-400" aria-live="polite">
{t('settings.sandbox.saving')}
</p>
)}
{/* Status line */}
<SettingsStatusLine
saving={isSaving}
savedNote={savedNote}
error={error}
savingLabel={t('settings.sandbox.saving')}
/>
</div>
</div>
);
};
@@ -4,7 +4,17 @@ import ScreenIntelligenceDebugPanel from '../../../components/intelligence/Scree
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsCheckbox,
SettingsEmptyState,
SettingsRow,
SettingsSection,
SettingsStatusLine,
SettingsTextArea,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const DebugSection = ({
@@ -17,18 +27,20 @@ const DebugSection = ({
const [isOpen, setIsOpen] = useState(false);
return (
<section className="space-y-3">
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900 dark:text-neutral-100">
<span>{t('screenAwareness.debug.debugAndDiagnostics')}</span>
<span className="text-xs text-stone-400 dark:text-neutral-500">
{isOpen ? t('screenAwareness.debug.collapse') : t('screenAwareness.debug.expand')}
</span>
</button>
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
</section>
<SettingsSection>
<div className="px-4 py-3 space-y-3">
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="flex w-full items-center justify-between text-sm font-semibold text-neutral-800 dark:text-neutral-100">
<span>{t('screenAwareness.debug.debugAndDiagnostics')}</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{isOpen ? t('screenAwareness.debug.collapse') : t('screenAwareness.debug.expand')}
</span>
</button>
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
</div>
</SettingsSection>
);
};
@@ -115,100 +127,90 @@ const ScreenAwarenessDebugPanel = () => {
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{/* Advanced policy settings */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('screenAwareness.debug.policyTitle')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('screenAwareness.debug.baselineFps')}
</span>
<input
type="number"
min={0.2}
max={30}
step={0.1}
value={baselineFps}
onChange={event => setBaselineFps(event.target.value)}
className="w-24 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('screenAwareness.debug.useVisionModel')}
<SettingsSection title={t('screenAwareness.debug.policyTitle')}>
<div className="px-4 py-3 space-y-3">
<label className="flex items-center justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-neutral-800 dark:text-neutral-200">
{t('screenAwareness.debug.baselineFps')}
</span>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('screenAwareness.debug.useVisionModelDesc')}
</p>
</div>
<input
type="checkbox"
checked={useVisionModel}
onChange={event => setUseVisionModel(event.target.checked)}
/>
</label>
<Input
type="number"
inputSize="sm"
min={0.2}
max={30}
step={0.1}
value={baselineFps}
onChange={event => setBaselineFps(event.target.value)}
className="w-24"
aria-label={t('screenAwareness.debug.baselineFps')}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('screenAwareness.debug.keepScreenshots')}
</span>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('screenAwareness.debug.keepScreenshotsDesc')}
</p>
</div>
<input
type="checkbox"
checked={keepScreenshots}
onChange={event => setKeepScreenshots(event.target.checked)}
<SettingsRow
htmlFor="screen-use-vision-model"
label={t('screenAwareness.debug.useVisionModel')}
description={t('screenAwareness.debug.useVisionModelDesc')}
control={
<SettingsCheckbox
id="screen-use-vision-model"
checked={useVisionModel}
onCheckedChange={setUseVisionModel}
/>
}
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('screenAwareness.debug.allowlist')}
</div>
<textarea
value={allowlistText}
onChange={event => setAllowlistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
<SettingsRow
htmlFor="screen-keep-screenshots"
label={t('screenAwareness.debug.keepScreenshots')}
description={t('screenAwareness.debug.keepScreenshotsDesc')}
control={
<SettingsCheckbox
id="screen-keep-screenshots"
checked={keepScreenshots}
onCheckedChange={setKeepScreenshots}
/>
}
/>
<div className="space-y-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('screenAwareness.debug.allowlist')}
</div>
<SettingsTextArea
value={allowlistText}
onChange={event => setAllowlistText(event.target.value)}
rows={3}
aria-label={t('screenAwareness.debug.allowlist')}
/>
</div>
<div className="space-y-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('screenAwareness.debug.denylist')}
</div>
<SettingsTextArea
value={denylistText}
onChange={event => setDenylistText(event.target.value)}
rows={3}
aria-label={t('screenAwareness.debug.denylist')}
/>
</div>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveConfig()}
disabled={isSavingConfig}>
{isSavingConfig ? t('common.loading') : t('screenAwareness.debug.saveSettings')}
</Button>
<SettingsStatusLine saving={false} error={configError} savingLabel="" />
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('screenAwareness.debug.denylist')}
</div>
<textarea
value={denylistText}
onChange={event => setDenylistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isSavingConfig ? t('common.loading') : t('screenAwareness.debug.saveSettings')}
</button>
{configError && (
<div className="text-xs text-red-600 dark:text-red-300">{configError}</div>
)}
</section>
</SettingsSection>
{/* Session stats */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('screenAwareness.debug.sessionStats')}
</h3>
<div className="text-sm text-stone-600 dark:text-neutral-300 space-y-1">
<SettingsSection title={t('screenAwareness.debug.sessionStats')}>
<div className="px-4 py-3 text-sm text-neutral-500 dark:text-neutral-400 space-y-1">
<div>
{t('screenAwareness.debug.framesEphemeral')}: {status?.session.frames_in_memory ?? 0}
</div>
@@ -230,46 +232,44 @@ const ScreenAwarenessDebugPanel = () => {
: t('screenAwareness.debug.notAvailable')}
</div>
</div>
</section>
</SettingsSection>
{/* Vision summaries */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('screenAwareness.debug.visionSummaries')}
</h3>
<button
type="button"
onClick={() => void refreshVision(10)}
disabled={isLoadingVision}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-600 dark:text-neutral-300 disabled:opacity-50">
{isLoadingVision ? t('screenAwareness.debug.refreshing') : t('common.refresh')}
</button>
</div>
<SettingsSection title={t('screenAwareness.debug.visionSummaries')}>
<div className="px-4 py-3 space-y-3">
<div className="flex justify-end">
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void refreshVision(10)}
disabled={isLoadingVision}>
{isLoadingVision ? t('screenAwareness.debug.refreshing') : t('common.refresh')}
</Button>
</div>
{recentVisionSummaries.length === 0 ? (
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('screenAwareness.debug.noSummaries')}
</div>
) : (
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 text-xs text-stone-200">
<div className="text-stone-500 dark:text-neutral-400">
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
{summary.app_name ?? t('screenAwareness.debug.unknownApp')}
{summary.window_title ? ` · ${summary.window_title}` : ''}
{recentVisionSummaries.length === 0 ? (
<SettingsEmptyState label={t('screenAwareness.debug.noSummaries')} />
) : (
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 text-xs">
<div className="text-neutral-500 dark:text-neutral-400">
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
{summary.app_name ?? t('screenAwareness.debug.unknownApp')}
{summary.window_title ? ` · ${summary.window_title}` : ''}
</div>
<div className="mt-1 text-neutral-800 dark:text-neutral-100">
{summary.actionable_notes}
</div>
</div>
<div className="mt-1 text-stone-800 dark:text-neutral-100">
{summary.actionable_notes}
</div>
</div>
))}
</div>
)}
</section>
))}
</div>
)}
</div>
</SettingsSection>
{/* Debug & Diagnostics (collapsible) */}
<DebugSection
@@ -294,11 +294,7 @@ const ScreenAwarenessDebugPanel = () => {
)}
{/* Error notice */}
{lastError && (
<div className="rounded-xl border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-300">
{lastError}
</div>
)}
{lastError && <SettingsStatusLine saving={false} error={lastError} savingLabel="" />}
</div>
</div>
);
@@ -3,7 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsSelect, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import PermissionsSection from './screen-intelligence/PermissionsSection';
@@ -135,13 +137,11 @@ const ScreenIntelligencePanel = () => {
/>
)}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.features.screenAwareness')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{/* Screen awareness config */}
<SettingsSection title={t('settings.features.screenAwareness')}>
{/* Enabled toggle */}
<label className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{t('common.enabled')}
</span>
<input
@@ -151,29 +151,35 @@ const ScreenIntelligencePanel = () => {
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.screenAwareness.mode')}
</span>
<select
value={policyMode}
onChange={event =>
setPolicyMode(
event.target.value === 'whitelist_only'
? 'whitelist_only'
: 'all_except_blacklist'
)
}
className="rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200">
<option value="all_except_blacklist">
{t('settings.screenAwareness.allExceptBlacklist')}
</option>
<option value="whitelist_only">{t('settings.screenAwareness.whitelistOnly')}</option>
</select>
</label>
{/* Policy mode */}
<SettingsRow
htmlFor="select-policy-mode"
label={t('settings.screenAwareness.mode')}
control={
<SettingsSelect
id="select-policy-mode"
value={policyMode}
onChange={event =>
setPolicyMode(
event.target.value === 'whitelist_only'
? 'whitelist_only'
: 'all_except_blacklist'
)
}
inputSize="sm">
<option value="all_except_blacklist">
{t('settings.screenAwareness.allExceptBlacklist')}
</option>
<option value="whitelist_only">
{t('settings.screenAwareness.whitelistOnly')}
</option>
</SettingsSelect>
}
/>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{/* Screen monitoring toggle */}
<label className="flex items-center justify-between px-4 py-3">
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{t('settings.screenAwareness.screenMonitoring')}
</span>
<input
@@ -188,67 +194,77 @@ const ScreenIntelligencePanel = () => {
/>
</label>
<button
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : t('settings.screenAwareness.saveSettings')}
</button>
{configError && (
<div className="text-xs text-red-600 dark:text-red-300">{configError}</div>
)}
</section>
{/* Save */}
<div className="px-4 py-3 space-y-2">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveConfig()}
disabled={isSavingConfig}>
{isSavingConfig ? 'Saving…' : t('settings.screenAwareness.saveSettings')}
</Button>
<SettingsStatusLine
saving={false}
savedNote={null}
error={configError}
savingLabel={t('settings.agentAccess.saving')}
/>
</div>
</SettingsSection>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.screenAwareness.session')}
</h3>
<div className="text-sm text-stone-600 dark:text-neutral-300 space-y-1">
<div>
{t('settings.screenAwareness.status')}:{' '}
{status?.session.active
? t('settings.screenAwareness.active')
: t('settings.screenAwareness.stopped')}
{/* Session controls */}
<SettingsSection title={t('settings.screenAwareness.session')}>
<div className="px-4 py-3 space-y-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300 space-y-1">
<div>
{t('settings.screenAwareness.status')}:{' '}
{status?.session.active
? t('settings.screenAwareness.active')
: t('settings.screenAwareness.stopped')}
</div>
<div>
{t('settings.screenAwareness.remaining')}: {remaining}
</div>
</div>
<div>
{t('settings.screenAwareness.remaining')}: {remaining}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
void startSession({
consent: true,
ttl_secs: status?.config.session_ttl_secs ?? 300,
screen_monitoring: screenMonitoring,
})
}
disabled={startDisabled}>
{isStartingSession ? 'Starting…' : t('settings.screenAwareness.startSession')}
</Button>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => void stopSession('manual_stop')}
disabled={stopDisabled}>
{isStoppingSession ? 'Stopping…' : t('settings.screenAwareness.stopSession')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void flushVision()}
disabled={isFlushingVision || !status?.session.active}>
{isFlushingVision ? 'Analyzing…' : t('settings.screenAwareness.analyzeNow')}
</Button>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
void startSession({
consent: true,
ttl_secs: status?.config.session_ttl_secs ?? 300,
screen_monitoring: screenMonitoring,
})
}
disabled={startDisabled}
className="rounded-lg border border-green-400 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
{isStartingSession ? 'Starting…' : t('settings.screenAwareness.startSession')}
</button>
<button
type="button"
onClick={() => void stopSession('manual_stop')}
disabled={stopDisabled}
className="rounded-lg border border-red-400 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300 disabled:opacity-50">
{isStoppingSession ? 'Stopping…' : t('settings.screenAwareness.stopSession')}
</button>
<button
type="button"
onClick={() => void flushVision()}
disabled={isFlushingVision || !status?.session.active}
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isFlushingVision ? 'Analyzing…' : t('settings.screenAwareness.analyzeNow')}
</button>
</div>
</section>
</SettingsSection>
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30 p-3 text-sm text-amber-700 dark:text-amber-300">
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.screenAwareness.macosOnly')}
</div>
)}
@@ -10,7 +10,10 @@ import {
type SearchSettings,
type SearchSettingsUpdate,
} from '../../../utils/tauriCommands/config';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsStatusLine, SettingsTextArea } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
type Status =
@@ -425,37 +428,37 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
</p>
{mode === 'custom' && (
<>
<textarea
<SettingsTextArea
value={allowedText}
onChange={e => setAllowedText(e.target.value)}
rows={4}
spellCheck={false}
placeholder={t('settings.search.allowedSitesPlaceholder')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-2 py-1.5 text-xs font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus-visible:border-primary-400"
className="font-mono text-xs"
aria-label={t('settings.search.allowedSitesLabel')}
/>
<button
<Button
type="button"
variant="primary"
size="xs"
onClick={() => persistAllowedDomains()}
disabled={status.kind === 'saving'}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-50">
disabled={status.kind === 'saving'}>
{t('settings.search.allowedSitesSave')}
</button>
</Button>
</>
)}
</div>
<div
role="status"
aria-live="polite"
className="text-xs min-h-[1rem] text-stone-500 dark:text-neutral-400">
{status.kind === 'saving' && t('settings.search.statusSaving')}
{status.kind === 'saved' && t('settings.search.statusSaved')}
{status.kind === 'error' && (
<span className="text-coral-600 dark:text-coral-300">
{t('settings.search.statusError')}: {status.message}
</span>
)}
</div>
<SettingsStatusLine
saving={status.kind === 'saving'}
savedNote={status.kind === 'saved' ? t('settings.search.statusSaved') : null}
error={
status.kind === 'error'
? `${t('settings.search.statusError')}: ${status.message}`
: null
}
savingLabel={t('settings.search.statusSaving')}
/>
</>
)}
</div>
@@ -496,12 +499,12 @@ const KeyEditor = ({
<div
role="group"
aria-labelledby={inputId}
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="flex items-center justify-between mb-2">
<label
id={inputId}
htmlFor={`${inputId}-input`}
className="text-xs font-semibold text-stone-700 dark:text-neutral-200">
className="text-xs font-semibold text-neutral-800 dark:text-neutral-200">
{label}
</label>
<a
@@ -513,34 +516,30 @@ const KeyEditor = ({
</a>
</div>
<div className="flex items-center gap-2">
<input
<Input
id={`${inputId}-input`}
type={show ? 'text' : 'password'}
inputSize="sm"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1 min-w-0 px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
className="flex-1 min-w-0 font-mono"
/>
<button
type="button"
onClick={onToggleShow}
className="px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
<Button type="button" variant="secondary" size="xs" onClick={onToggleShow}>
{show ? t('settings.search.hide') : t('settings.search.show')}
</button>
<button
</Button>
<Button
type="button"
variant="primary"
size="xs"
onClick={onSave}
disabled={value.trim().length === 0}
className="px-3 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium disabled:opacity-50">
disabled={value.trim().length === 0}>
{t('settings.search.save')}
</button>
</Button>
{configured && (
<button
type="button"
onClick={onClear}
className="px-2 py-1.5 rounded-md border border-coral-200 dark:border-coral-500/30 text-xs text-coral-600 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/10">
<Button type="button" variant="danger" size="xs" onClick={onClear}>
{t('settings.search.clear')}
</button>
</Button>
)}
</div>
</div>
@@ -3,30 +3,16 @@ import { useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { decideKeyringConsent, retryKeyringProbe } from '../../../services/keyringApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsBadge, SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const MODE_BADGE: Record<string, { label: string; className: string }> = {
os_keyring: {
label: 'keyring.settings.mode.osKeychain',
className:
'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30',
},
local_encrypted: {
label: 'keyring.settings.mode.encryptedFile',
className:
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
},
consent_pending: {
label: 'keyring.settings.mode.consentPending',
className:
'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800',
},
declined: {
label: 'keyring.settings.mode.declined',
className:
'bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300 border-coral-200 dark:border-coral-500/30',
},
const MODE_BADGE_VARIANT: Record<string, 'success' | 'warning' | 'neutral' | 'danger'> = {
os_keyring: 'success',
local_encrypted: 'warning',
consent_pending: 'neutral',
declined: 'danger',
};
const SecurityPanel = () => {
@@ -37,7 +23,8 @@ const SecurityPanel = () => {
const [error, setError] = useState<string | null>(null);
const keyringStatus = snapshot.keyringStatus;
const modeBadge = MODE_BADGE[keyringStatus.activeMode] ?? MODE_BADGE.consent_pending;
const modeBadgeVariant =
MODE_BADGE_VARIANT[keyringStatus.activeMode] ?? MODE_BADGE_VARIANT.consent_pending;
const handleRetryProbe = async () => {
setIsLoading(true);
@@ -71,87 +58,93 @@ const SecurityPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="space-y-6 p-4">
<div className="p-4 pt-2 space-y-5">
{/* Storage mode */}
<section>
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
{t('keyring.settings.storageMode')}
</h3>
<div className="flex items-center gap-3">
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${modeBadge.className}`}>
{t(modeBadge.label)}
</span>
<span className="text-xs text-stone-500 dark:text-stone-400">
{t('keyring.settings.backend')}: {keyringStatus.backendName}
</span>
</div>
</section>
<SettingsSection title={t('keyring.settings.storageMode')}>
<SettingsRow
label={t('keyring.settings.storageMode')}
control={
<div className="flex items-center gap-3">
<SettingsBadge variant={modeBadgeVariant}>
{t(
`keyring.settings.mode.${keyringStatus.activeMode}` as Parameters<typeof t>[0]
)}
</SettingsBadge>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('keyring.settings.backend')}: {keyringStatus.backendName}
</span>
</div>
}
/>
</SettingsSection>
{/* Availability */}
<section>
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
{t('keyring.settings.availability')}
</h3>
<div className="rounded-lg bg-stone-100 dark:bg-stone-800/60 p-4">
<div className="flex items-center gap-2 mb-2">
<SettingsSection title={t('keyring.settings.availability')}>
<div className="px-4 py-3 space-y-3">
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${keyringStatus.available ? 'bg-sage-500' : 'bg-amber-500'}`}
/>
<span className="text-sm text-stone-700 dark:text-stone-200">
<span className="text-sm text-neutral-700 dark:text-neutral-200">
{keyringStatus.available
? t('keyring.settings.available')
: t('keyring.settings.unavailable')}
</span>
</div>
{keyringStatus.failureReason && (
<p className="text-xs text-stone-500 dark:text-stone-400 ml-4">
<p className="text-xs text-neutral-500 dark:text-neutral-400 ml-4">
{keyringStatus.failureReason}
</p>
)}
<button
<Button
type="button"
onClick={handleRetryProbe}
disabled={isLoading}
className="mt-3 rounded-lg border border-stone-300 dark:border-stone-600 px-3 py-1.5 text-xs text-stone-700 dark:text-stone-200 hover:bg-stone-200 dark:hover:bg-stone-700 disabled:opacity-60">
variant="secondary"
size="sm"
onClick={() => void handleRetryProbe()}
disabled={isLoading}>
{isLoading ? t('keyring.consent.retrying') : t('keyring.settings.retryButton')}
</button>
</Button>
</div>
</section>
</SettingsSection>
{/* Consent management (only when keyring is unavailable) */}
{!keyringStatus.available && (
<section>
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
{t('keyring.settings.consentTitle')}
</h3>
<p className="text-xs text-stone-500 dark:text-stone-400 mb-3">
{t('keyring.settings.consentDescription')}
</p>
<div className="flex flex-wrap gap-2">
{keyringStatus.activeMode !== 'local_encrypted' && (
<button
type="button"
onClick={() => handleConsentChange('local_encrypted')}
disabled={isLoading}
className="rounded-lg bg-ocean-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{t('keyring.settings.grantConsent')}
</button>
)}
{keyringStatus.activeMode !== 'declined' && (
<button
type="button"
onClick={() => handleConsentChange('declined')}
disabled={isLoading}
className="rounded-lg border border-stone-300 dark:border-stone-600 px-3 py-1.5 text-xs text-stone-700 dark:text-stone-200 hover:bg-stone-200 dark:hover:bg-stone-700 disabled:opacity-60">
{t('keyring.settings.revokeConsent')}
</button>
)}
<SettingsSection title={t('keyring.settings.consentTitle')}>
<div className="px-4 py-3 space-y-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('keyring.settings.consentDescription')}
</p>
<div className="flex flex-wrap gap-2">
{keyringStatus.activeMode !== 'local_encrypted' && (
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleConsentChange('local_encrypted')}
disabled={isLoading}>
{t('keyring.settings.grantConsent')}
</Button>
)}
{keyringStatus.activeMode !== 'declined' && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void handleConsentChange('declined')}
disabled={isLoading}>
{t('keyring.settings.revokeConsent')}
</Button>
)}
</div>
</div>
</section>
</SettingsSection>
)}
{error && <p className="text-sm text-coral-400">{error}</p>}
<SettingsStatusLine
saving={isLoading}
error={error}
savingLabel={t('keyring.consent.retrying')}
/>
</div>
</div>
);
@@ -22,6 +22,7 @@ const removeMock = vi.fn();
const fetchMock = vi.fn();
const syncMock = vi.fn();
const previewMock = vi.fn();
const listDatabasesMock = vi.fn();
vi.mock('../../../utils/tauriCommands', () => ({
openhumanTaskSourcesList: () => listMock(),
@@ -32,6 +33,7 @@ vi.mock('../../../utils/tauriCommands', () => ({
openhumanTaskSourcesFetch: (id: string) => fetchMock(id),
openhumanTaskSourcesSync: () => syncMock(),
openhumanTaskSourcesPreviewFilter: (...args: unknown[]) => previewMock(...args),
openhumanTaskSourcesListDatabases: (provider: string) => listDatabasesMock(provider),
}));
function sampleSource(overrides: Record<string, unknown> = {}) {
@@ -84,6 +86,7 @@ describe('<TaskSourcesPanel />', () => {
{ sourceId: 's-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 },
]);
previewMock.mockResolvedValue([{ externalId: '1' }, { externalId: '2' }]);
listDatabasesMock.mockResolvedValue([]);
});
afterEach(() => vi.restoreAllMocks());
@@ -248,4 +251,49 @@ describe('<TaskSourcesPanel />', () => {
// GitHub-only labels field is gone for Notion.
expect(screen.queryByLabelText('Labels (comma-separated)')).not.toBeInTheDocument();
});
// ── New coverage for lines 417-418, 422, 425, 428-429, 464, 586 ──────────
it('shows Notion Browse Databases button when notion provider is selected (lines 417-418)', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
// Switch to notion provider
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'notion' } });
expect(screen.getByRole('button', { name: /Browse Databases/i })).toBeInTheDocument();
});
it('shows the database dropdown after Browse Databases returns results', async () => {
listDatabasesMock.mockResolvedValue([{ id: 'db-1', title: 'My Notion DB' }]);
renderPanel();
await screen.findByTestId('task-source-s-1');
// Switch the add-source provider to Notion so the Browse Databases control appears.
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'notion' } });
fireEvent.click(screen.getByRole('button', { name: /Browse Databases/i }));
// The returned database surfaces as a selectable option.
expect(await screen.findByRole('option', { name: 'My Notion DB' })).toBeInTheDocument();
expect(listDatabasesMock).toHaveBeenCalledWith('notion');
});
it('toggles "Assigned to me" checkbox and it affects state (line 464)', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
// The "Assigned to me" checkbox — it starts checked (assignedToMe defaults to true)
const checkbox = screen.getByRole('checkbox', { name: /Assigned to me/i });
expect(checkbox).toBeChecked();
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
});
it('clicking Refresh calls load() (line 586)', async () => {
renderPanel();
await screen.findByTestId('task-source-s-1');
// The first listMock call happens on mount; clicking Refresh triggers another
const beforeCount = listMock.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: /Refresh/i }));
await waitFor(() => {
expect(listMock.mock.calls.length).toBeGreaterThan(beforeCount);
});
});
});
@@ -17,7 +17,18 @@ import {
type TaskSourceProvider,
type TaskSourcesStatus,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsCheckbox,
SettingsEmptyState,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsStatusLine,
SettingsTextField,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const PROVIDERS: TaskSourceProvider[] = ['github', 'notion', 'linear', 'clickup'];
@@ -315,7 +326,7 @@ const TaskSourcesPanel = () => {
};
return (
<div data-testid="task-sources-panel">
<div className="z-10 relative" data-testid="task-sources-panel">
<SettingsHeader
title={t('settings.taskSources.title')}
showBackButton={true}
@@ -323,237 +334,260 @@ const TaskSourcesPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-5">
<section className="space-y-1">
<p className="text-xs text-stone-500 dark:text-neutral-400">
<div className="p-4 pt-2 space-y-5">
<div className="space-y-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.taskSources.description')}
</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.taskSources.connectHint')}
</p>
</section>
</div>
{status && !status.enabled && (
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.taskSources.disabledBanner')}
</div>
)}
{error && (
<div className="rounded-lg border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{notice && (
<div className="rounded-lg border border-sky-300 dark:border-sky-500/40 bg-sky-50 dark:bg-sky-500/10 px-4 py-3 text-sm text-sky-700 dark:text-sky-300">
{notice}
</div>
)}
<SettingsStatusLine saving={false} savedNote={notice} error={error} savingLabel="" />
{/* ── Add a source ─────────────────────────────────────────── */}
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.taskSources.addTitle')}
</h3>
<SettingsSection title={t('settings.taskSources.addTitle')}>
<SettingsRow
label={t('settings.taskSources.provider')}
htmlFor="task-source-provider"
stacked
control={
<SettingsSelect
id="task-source-provider"
value={provider}
onChange={e => setProvider(e.target.value as TaskSourceProvider)}
className="w-full">
{PROVIDERS.map(p => (
<option key={p} value={p}>
{providerLabel(p, t)}
</option>
))}
</SettingsSelect>
}
/>
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.provider')}
<select
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={provider}
onChange={e => setProvider(e.target.value as TaskSourceProvider)}>
{PROVIDERS.map(p => (
<option key={p} value={p}>
{providerLabel(p, t)}
</option>
))}
</select>
</label>
<SettingsRow
label={t('settings.taskSources.name')}
htmlFor="task-source-name"
stacked
control={
<SettingsTextField
id="task-source-name"
type="text"
placeholder={t('settings.taskSources.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
aria-label={t('settings.taskSources.name')}
/>
}
/>
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.name')}
<input
type="text"
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
placeholder={t('settings.taskSources.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{primaryLabel}
<input
type="text"
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={primary}
onChange={e => setPrimary(e.target.value)}
/>
</label>
<SettingsRow
label={primaryLabel}
htmlFor="task-source-primary"
stacked
control={
<SettingsTextField
id="task-source-primary"
type="text"
value={primary}
onChange={e => setPrimary(e.target.value)}
aria-label={primaryLabel}
/>
}
/>
{provider === 'notion' && (
<div className="space-y-1">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={busyKey !== null}
onClick={() => void browseDatabases()}>
{busyKey === 'databases'
? t('settings.taskSources.notion.loadingDatabases')
: t('settings.taskSources.notion.browseDatabases')}
</button>
{databases.length > 0 && (
<select
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={primary}
onChange={e => setPrimary(e.target.value)}>
<option value="">{t('settings.taskSources.notion.selectDatabase')}</option>
{databases.map(db => (
<option key={db.id} value={db.id}>
{db.title}
</option>
))}
</select>
)}
</div>
<SettingsRow
stacked
control={
<div className="space-y-1">
<Button
type="button"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void browseDatabases()}>
{busyKey === 'databases'
? t('settings.taskSources.notion.loadingDatabases')
: t('settings.taskSources.notion.browseDatabases')}
</Button>
{databases.length > 0 && (
<SettingsSelect
value={primary}
onChange={e => setPrimary(e.target.value)}
className="w-full mt-1">
<option value="">{t('settings.taskSources.notion.selectDatabase')}</option>
{databases.map(db => (
<option key={db.id} value={db.id}>
{db.title}
</option>
))}
</SettingsSelect>
)}
</div>
}
/>
)}
{provider === 'github' && (
<label className="block text-xs text-stone-500 dark:text-neutral-400">
{t('settings.taskSources.github.labels')}
<input
type="text"
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
value={labels}
onChange={e => setLabels(e.target.value)}
/>
</label>
<SettingsRow
label={t('settings.taskSources.github.labels')}
htmlFor="task-source-labels"
stacked
control={
<SettingsTextField
id="task-source-labels"
type="text"
value={labels}
onChange={e => setLabels(e.target.value)}
aria-label={t('settings.taskSources.github.labels')}
/>
}
/>
)}
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
<input
type="checkbox"
checked={assignedToMe}
onChange={e => setAssignedToMe(e.target.checked)}
/>
{t('settings.taskSources.assignedToMe')}
</label>
<SettingsRow
htmlFor="task-source-assigned"
label={t('settings.taskSources.assignedToMe')}
control={
<SettingsCheckbox
id="task-source-assigned"
checked={assignedToMe}
onCheckedChange={next => setAssignedToMe(next)}
aria-label={t('settings.taskSources.assignedToMe')}
/>
}
/>
<div className="flex gap-2 pt-1">
<button
<div className="flex gap-2 px-4 py-3 border-t border-neutral-100 dark:border-neutral-800">
<Button
type="button"
className="btn btn-primary btn-sm"
variant="primary"
size="xs"
disabled={busyKey !== null}
onClick={() => void addSource()}>
{busyKey === 'add' ? t('settings.taskSources.adding') : t('settings.taskSources.add')}
</button>
<button
</Button>
<Button
type="button"
className="btn btn-outline btn-sm"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void previewFilter()}>
{t('settings.taskSources.preview')}
</button>
</Button>
</div>
</section>
</SettingsSection>
{/* ── Configured sources ───────────────────────────────────── */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.taskSources.configured')}
</h3>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={loading || busyKey !== null || sources.length === 0}
onClick={() => void syncAll()}>
{busyKey === 'sync'
? t('settings.taskSources.syncing')
: t('settings.taskSources.syncAll')}
</button>
<SettingsSection title={t('settings.taskSources.configured')}>
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
<Button
type="button"
variant="secondary"
size="xs"
disabled={loading || busyKey !== null || sources.length === 0}
onClick={() => void syncAll()}>
{busyKey === 'sync'
? t('settings.taskSources.syncing')
: t('settings.taskSources.syncAll')}
</Button>
</div>
{loading ? (
<p className="text-sm text-stone-400 dark:text-neutral-500">{t('common.loading')}</p>
<div className="px-4 py-3">
<p className="text-sm text-neutral-400 dark:text-neutral-500">
{t('common.loading')}
</p>
</div>
) : sources.length === 0 ? (
<p className="text-sm text-stone-400 dark:text-neutral-500">
{t('settings.taskSources.empty')}
</p>
<SettingsEmptyState label={t('settings.taskSources.empty')} />
) : (
<ul className="space-y-2">
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
{sources.map(source => (
<li
key={source.id}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3 space-y-2"
className="p-3 space-y-2"
data-testid={`task-source-${source.id}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
<p className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{source.name || providerLabel(source.provider, t)}
</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{providerLabel(source.provider, t)}
{source.target === 'agent_todo_proactive'
? ` · ${t('settings.taskSources.proactive')}`
: ''}
</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.taskSources.lastFetch')}:{' '}
{source.lastFetchAt
? new Date(source.lastFetchAt).toLocaleString()
: t('settings.taskSources.never')}
</p>
</div>
<span
className={`text-xs rounded-full px-2 py-0.5 ${
source.enabled
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
}`}>
<SettingsBadge variant={source.enabled ? 'success' : 'neutral'}>
{source.enabled
? t('settings.taskSources.statusEnabled')
: t('settings.taskSources.statusDisabled')}
</span>
</SettingsBadge>
</div>
<div className="flex flex-wrap gap-2">
<button
<Button
type="button"
className="btn btn-outline btn-xs"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void toggleSource(source)}>
{source.enabled
? t('settings.taskSources.disable')
: t('settings.taskSources.enable')}
</button>
<button
</Button>
<Button
type="button"
className="btn btn-outline btn-xs"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void fetchNow(source)}>
{busyKey === `fetch:${source.id}`
? t('settings.taskSources.fetching')
: t('settings.taskSources.fetchNow')}
</button>
<button
</Button>
<Button
type="button"
className="btn btn-ghost btn-xs text-red-600 dark:text-red-400"
variant="danger"
size="xs"
disabled={busyKey !== null}
onClick={() => void removeSource(source)}>
{t('settings.taskSources.remove')}
</button>
</Button>
</div>
</li>
))}
</ul>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={loading || busyKey !== null}
onClick={() => void load()}>
{t('settings.taskSources.refresh')}
</button>
</section>
<div className="px-4 py-3 border-t border-neutral-100 dark:border-neutral-800">
<Button
type="button"
variant="ghost"
size="xs"
disabled={loading || busyKey !== null}
onClick={() => void load()}>
{t('settings.taskSources.refresh')}
</Button>
</div>
</SettingsSection>
</div>
</div>
);
@@ -17,7 +17,7 @@ const TasksPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
return (
<div data-testid="tasks-panel">
<div className="z-10 relative" data-testid="tasks-panel">
<SettingsHeader
title={t('memory.tab.tasks')}
showBackButton={true}
@@ -26,7 +26,7 @@ const TasksPanel = () => {
/>
<div className="p-4">
<p className="mb-4 text-xs text-stone-500 dark:text-neutral-400">
<p className="mb-4 text-xs text-neutral-500 dark:text-neutral-400">
{t('memory.tab.tasksDescription')}
</p>
<IntelligenceTasksTab />
@@ -7,7 +7,9 @@ import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { sanitizeError } from '../../../utils/sanitize';
import { CenteredLoadingState, ErrorBanner, InlineLoadingStatus, Spinner } from '../../ui';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsBadge, SettingsEmptyState, SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('core-rpc:error');
@@ -124,16 +126,19 @@ const TeamInvitesPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div>
<div className="p-4 space-y-4">
{error && <ErrorBanner message={error} />}
<div className="p-4 pt-2 space-y-5">
{error && <ErrorBanner message={error} />}
{/* Generate button */}
{isAdmin && (
<button
onClick={handleGenerate}
{/* Generate button */}
{isAdmin && (
<div className="px-1">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleGenerate()}
disabled={isGenerating}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50">
className="w-full">
{isGenerating ? (
<>
<Spinner className="w-4 h-4" />
@@ -152,30 +157,30 @@ const TeamInvitesPanel = () => {
{t('invites.generate')}
</>
)}
</button>
)}
</Button>
</div>
)}
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingInvites && invites.length > 0 && (
<InlineLoadingStatus label={t('invites.refreshing')} />
)}
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingInvites && invites.length > 0 && (
<InlineLoadingStatus label={t('invites.refreshing')} />
)}
{/* Invites list */}
{isLoadingInvites && invites.length === 0 ? (
<CenteredLoadingState label={t('invites.loading')} />
) : invites.length > 0 ? (
<div className="space-y-2">
{/* Invites list */}
{isLoadingInvites && invites.length === 0 ? (
<CenteredLoadingState label={t('invites.loading')} />
) : invites.length > 0 ? (
<SettingsSection>
<ul>
{invites.map(invite => {
const status = getInviteStatus(invite);
const isInactive = status !== 'active';
return (
<div
<li
key={invite._id}
className={`rounded-xl border p-3 ${
isInactive
? 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 opacity-60'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
className={`px-4 py-3 border-b border-neutral-100 dark:border-neutral-800 last:border-b-0 ${
isInactive ? 'opacity-60' : ''
}`}>
<div className="flex items-center justify-between mb-2">
{/* Code with status label */}
@@ -183,32 +188,28 @@ const TeamInvitesPanel = () => {
<code
className={`text-sm font-mono px-2 py-1 rounded-lg ${
isInactive
? 'text-stone-500 dark:text-neutral-400 bg-stone-100 dark:bg-neutral-800'
: 'text-stone-900 dark:text-neutral-100 bg-stone-200 dark:bg-neutral-800'
? 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800'
: 'text-neutral-800 dark:text-neutral-100 bg-neutral-200 dark:bg-neutral-800'
}`}>
{invite.code}
</code>
{status === 'expired' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-coral-500/20 text-coral-400 border border-coral-500/30">
<SettingsBadge variant="danger">
{t('rewards.referralSection.statusExpired')}
</span>
</SettingsBadge>
)}
{status === 'used' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
{t('invites.usedUp')}
</span>
<SettingsBadge variant="warning">{t('invites.usedUp')}</SettingsBadge>
)}
</div>
<div className="flex items-center gap-1.5">
{/* Copy */}
<button
onClick={() => handleCopy(invite.code, invite._id)}
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleCopy(invite.code, invite._id)}
disabled={status !== 'active'}
className={`p-1.5 rounded-lg transition-colors ${
status === 'active'
? 'text-stone-500 dark:text-neutral-400 hover:text-stone-900 dark:hover:text-neutral-100 hover:bg-stone-100 dark:hover:bg-neutral-800'
: 'text-stone-600 dark:text-neutral-300 cursor-not-allowed'
}`}
aria-label={t('invites.copyCodeAria')}>
{copiedId === invite._id ? (
<svg
@@ -237,14 +238,17 @@ const TeamInvitesPanel = () => {
/>
</svg>
)}
</button>
</Button>
{/* Revoke - only for active invites */}
{isAdmin && status === 'active' && (
<button
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => handleRevoke(invite._id, invite.code)}
disabled={revokingId === invite._id}
className="p-1.5 rounded-lg text-stone-500 dark:text-neutral-400 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
aria-label={t('invites.revokeAria')}>
aria-label={t('invites.revokeAria')}
className="text-neutral-500 dark:text-neutral-400 hover:text-coral-400 hover:bg-coral-500/10">
<svg
className="w-4 h-4"
fill="none"
@@ -257,11 +261,11 @@ const TeamInvitesPanel = () => {
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</Button>
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-stone-500 dark:text-neutral-400">
<div className="flex items-center gap-3 text-xs text-neutral-500 dark:text-neutral-400">
<span>
{t('invites.uses')
.replace('{current}', String(invite.currentUses))
@@ -276,78 +280,69 @@ const TeamInvitesPanel = () => {
)}
</span>
</div>
</div>
</li>
);
})}
</div>
) : (
<div className="text-center py-8">
<svg
className="w-10 h-10 mx-auto text-stone-600 dark:text-neutral-300 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('invites.empty')}</p>
<p className="text-xs text-stone-600 dark:text-neutral-300 mt-1">
{t('invites.emptyHint')}
</p>
</div>
)}
</ul>
</SettingsSection>
) : (
<SettingsSection>
<SettingsEmptyState label={t('invites.empty')} />
</SettingsSection>
)}
{/* Revoke Invite Confirmation Modal */}
{inviteToRevoke && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('invites.revokeTitle')}
</h3>
{/* Revoke Invite Confirmation Modal */}
{inviteToRevoke && (
<div className="fixed inset-0 bg-neutral-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-neutral-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-4">
{t('invites.revokeTitle')}
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
{t('invites.revokePromptPrefix')}{' '}
<code className="text-stone-900 dark:text-neutral-100 bg-stone-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-mono text-xs">
{inviteToRevoke.code}
</code>
?
</p>
<p className="mt-2 text-amber-400">{t('invites.revokeWarning')}</p>
</div>
<div className="space-y-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400">
<p>
{t('invites.revokePromptPrefix')}{' '}
<code className="text-neutral-800 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-mono text-xs">
{inviteToRevoke.code}
</code>
?
</p>
<p className="mt-2 text-amber-400">{t('invites.revokeWarning')}</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setInviteToRevoke(null)}
disabled={revokingId === inviteToRevoke.id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:hover:bg-neutral-700 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={confirmRevokeInvite}
disabled={revokingId === inviteToRevoke.id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-coral-500 hover:bg-coral-600 text-white transition-colors disabled:opacity-50">
{revokingId === inviteToRevoke.id
? t('invites.revoking')
: t('invites.revokeAction')}
</button>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="md"
className="flex-1"
onClick={() => setInviteToRevoke(null)}
disabled={revokingId === inviteToRevoke.id}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="danger"
size="md"
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white border-0 dark:bg-coral-500 dark:hover:bg-coral-600"
onClick={() => void confirmRevokeInvite()}
disabled={revokingId === inviteToRevoke.id}>
{revokingId === inviteToRevoke.id
? t('invites.revoking')
: t('invites.revokeAction')}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -4,7 +4,10 @@ import { useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsMenuItem from '../components/SettingsMenuItem';
import { SettingsSection, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamManagementPanel = () => {
@@ -98,7 +101,7 @@ const TeamManagementPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('team.notFound')}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('team.notFound')}</p>
</div>
</div>
);
@@ -114,7 +117,7 @@ const TeamManagementPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('team.accessDenied')}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('team.accessDenied')}</p>
</div>
</div>
);
@@ -131,283 +134,217 @@ const TeamManagementPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div>
<div className="p-4 space-y-4">
{/* Team Info */}
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-stone-200 dark:bg-neutral-800 flex items-center justify-center">
<span className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{team.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{team.name}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('team.planCreated')
.replace('{plan}', team.subscription.plan)
.replace('{date}', new Date(team.createdAt).toLocaleDateString())}
</p>
<div className="p-4 pt-2 space-y-5">
{/* Team Info */}
<SettingsSection>
<div className="flex items-center gap-3 px-4 py-3">
<div className="w-10 h-10 rounded-lg bg-neutral-200 dark:bg-neutral-800 flex items-center justify-center">
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-200">
{team.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{team.name}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('team.planCreated')
.replace('{plan}', team.subscription.plan)
.replace('{date}', new Date(team.createdAt).toLocaleDateString())}
</p>
</div>
</div>
</SettingsSection>
{/* Management Options */}
<SettingsSection title={t('team.management')}>
{/* Members */}
<SettingsMenuItem
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
/>
</svg>
}
title={t('team.members')}
description={t('team.membersDesc')}
onClick={() => navigateToSettings(`team/manage/${teamId}/members`)}
testId="settings-nav-team-members"
isFirst={true}
isLast={false}
/>
{/* Invites */}
<SettingsMenuItem
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
}
title={t('team.invites')}
description={t('team.invitesDesc')}
onClick={() => navigateToSettings(`team/manage/${teamId}/invites`)}
testId="settings-nav-team-invites"
isFirst={false}
isLast={false}
/>
{/* Edit Team Settings */}
<SettingsMenuItem
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
}
title={t('team.settings')}
description={t('team.settingsDesc')}
onClick={handleEditTeam}
testId="settings-nav-team-settings"
isFirst={false}
isLast={!teamEntry?.team.isPersonal ? false : true}
/>
{/* Delete Team */}
{!teamEntry?.team.isPersonal && (
<SettingsMenuItem
icon={
<svg
className="w-5 h-5 text-coral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
}
title={t('team.delete')}
description={t('team.deleteDesc')}
onClick={() => setIsDeleteModalOpen(true)}
testId="settings-nav-team-delete"
isFirst={false}
isLast={true}
dangerous
/>
)}
</SettingsSection>
{/* Edit Team Modal */}
{isEditModalOpen && (
<div className="fixed inset-0 bg-neutral-900/40 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-neutral-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-4">
{t('team.editSettings')}
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-600 dark:text-coral-300">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-2">
{t('team.teamName')}
</label>
<SettingsTextField
value={editTeamName}
onChange={e => setEditTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void handleUpdateTeam()}
placeholder={t('team.enterName')}
aria-label={t('team.teamName')}
inputSize="sm"
className="w-full"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="md"
className="flex-1"
onClick={() => setIsEditModalOpen(false)}
disabled={isUpdating}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="primary"
size="md"
className="flex-1"
onClick={() => void handleUpdateTeam()}
disabled={isUpdating || !editTeamName.trim()}>
{isUpdating ? t('team.saving') : t('team.saveChanges')}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Management Options */}
<div className="space-y-1">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1 mb-3">
{t('team.management')}
</h3>
{/* Delete Team Modal */}
{isDeleteModalOpen && (
<div className="fixed inset-0 bg-neutral-900/40 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-neutral-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-4">
{t('team.delete')}
</h3>
{/* Members */}
<button
onClick={() => navigateToSettings(`team/manage/${teamId}/members`)}
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
/>
</svg>
<div>
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100">
{t('team.members')}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('team.membersDesc')}
</p>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-600 dark:text-coral-300">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400">
<p>{t('team.confirmDelete').replace('{name}', teamEntry?.team.name ?? '')}</p>
<p className="mt-2 text-coral-400">{t('team.deleteWarning')}</p>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="md"
className="flex-1"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="danger"
size="md"
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white border-0 dark:bg-coral-500 dark:hover:bg-coral-600"
onClick={() => void handleDeleteTeam()}
disabled={isDeleting}>
{isDeleting ? t('team.deleting') : t('team.delete')}
</Button>
</div>
</div>
<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="M9 5l7 7-7 7"
/>
</svg>
</button>
{/* Invites */}
<button
onClick={() => navigateToSettings(`team/manage/${teamId}/invites`)}
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<div>
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100">
{t('team.invites')}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('team.invitesDesc')}
</p>
</div>
</div>
<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="M9 5l7 7-7 7"
/>
</svg>
</button>
{/* Edit Team Settings */}
<button
onClick={handleEditTeam}
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<div>
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100">
{t('team.settings')}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('team.settingsDesc')}
</p>
</div>
</div>
<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="M9 5l7 7-7 7"
/>
</svg>
</button>
{/* Delete Team */}
{!teamEntry?.team.isPersonal && (
<button
onClick={() => setIsDeleteModalOpen(true)}
className="w-full flex items-center justify-between p-3 rounded-xl border border-coral-500/30 bg-coral-500/5 hover:bg-coral-500/10 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-coral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<div>
<div className="font-medium text-sm text-coral-400">{t('team.delete')}</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('team.deleteDesc')}
</p>
</div>
</div>
<svg
className="w-4 h-4 text-coral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
)}
</div>
</div>
{/* Edit Team Modal */}
{isEditModalOpen && (
<div className="fixed inset-0 bg-stone-900/40 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('team.editSettings')}
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-600 dark:text-coral-300">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-neutral-200 mb-2">
{t('team.teamName')}
</label>
<input
type="text"
value={editTeamName}
onChange={e => setEditTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleUpdateTeam()}
className="w-full px-3 py-2 text-sm bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 rounded-xl text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:border-primary-500/50"
placeholder={t('team.enterName')}
/>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setIsEditModalOpen(false)}
disabled={isUpdating}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={handleUpdateTeam}
disabled={isUpdating || !editTeamName.trim()}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50">
{isUpdating ? t('team.saving') : t('team.saveChanges')}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Team Modal */}
{isDeleteModalOpen && (
<div className="fixed inset-0 bg-stone-900/40 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('team.delete')}
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-600 dark:text-coral-300">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>{t('team.confirmDelete').replace('{name}', teamEntry?.team.name ?? '')}</p>
<p className="mt-2 text-coral-400">{t('team.deleteWarning')}</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={handleDeleteTeam}
disabled={isDeleting}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-coral-500 hover:bg-coral-600 text-white transition-colors disabled:opacity-50">
{isDeleting ? t('team.deleting') : t('team.delete')}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
@@ -8,7 +8,9 @@ import { teamApi } from '../../../services/api/teamApi';
import type { TeamMember, TeamRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
import { CenteredLoadingState, ErrorBanner, InlineLoadingStatus } from '../../ui';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsBadge, SettingsEmptyState, SettingsSection, SettingsSelect } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('core-rpc:error');
@@ -121,11 +123,10 @@ const TeamMembersPanel = () => {
const isCurrentUser = (m: TeamMember) => m.user._id === user?._id;
const roleBadgeColor: Record<string, string> = {
ADMIN: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
BILLING_MANAGER: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
MEMBER:
'bg-stone-50 dark:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 border-stone-500/30',
const roleBadgeVariant: Record<string, 'primary' | 'warning' | 'neutral'> = {
ADMIN: 'primary',
BILLING_MANAGER: 'warning',
MEMBER: 'neutral',
};
return (
@@ -137,212 +138,225 @@ const TeamMembersPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div>
<div className="p-4 space-y-4">
{error && <ErrorBanner message={error} />}
<div className="p-4 pt-2 space-y-5">
{error && <ErrorBanner message={error} />}
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingMembers && members.length > 0 && (
<InlineLoadingStatus label={t('team.refreshingMembers')} />
{/* Refreshing indicator - only when loading and has existing data */}
{isLoadingMembers && members.length > 0 && (
<InlineLoadingStatus label={t('team.refreshingMembers')} />
)}
{/* Member count */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 px-1">
{t(members.length === 1 ? 'team.memberCount' : 'team.memberCountPlural').replace(
'{count}',
String(members.length)
)}
</p>
{/* Member count */}
<p className="text-xs text-stone-500 dark:text-neutral-400 px-1">
{t(members.length === 1 ? 'team.memberCount' : 'team.memberCountPlural').replace(
'{count}',
String(members.length)
)}
</p>
{/* Full loading state - only when loading and no existing data */}
{isLoadingMembers && members.length === 0 ? (
<CenteredLoadingState label={t('team.loadingMembers')} />
) : (
<div className="space-y-2">
{members.map(member => (
<div
key={member._id}
className="flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="flex items-center gap-3 min-w-0">
{/* Avatar */}
<div className="w-8 h-8 rounded-full bg-stone-700/60 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-semibold text-white">
{displayName(member).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{displayName(member)}
{/* Full loading state - only when loading and no existing data */}
{isLoadingMembers && members.length === 0 ? (
<CenteredLoadingState label={t('team.loadingMembers')} />
) : (
<SettingsSection>
{members.length === 0 && !isLoadingMembers ? (
<SettingsEmptyState label={t('team.noMembers')} />
) : (
<ul>
{members.map(member => (
<li
key={member._id}
className="flex items-center justify-between px-4 py-3 border-b border-neutral-100 dark:border-neutral-800 last:border-b-0">
<div className="flex items-center gap-3 min-w-0">
{/* Avatar */}
<div className="w-8 h-8 rounded-full bg-neutral-700/60 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-semibold text-white">
{displayName(member).charAt(0).toUpperCase()}
</span>
{isCurrentUser(member) && (
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{t('team.you')}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100 truncate">
{displayName(member)}
</span>
{isCurrentUser(member) && (
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
{t('team.you')}
</span>
)}
</div>
{member.user.username && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
@{member.user.username}
</p>
)}
</div>
{member.user.username && (
<p className="text-xs text-stone-500 dark:text-neutral-400 truncate">
@{member.user.username}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Role badge / dropdown */}
{isAdmin && !isCurrentUser(member) ? (
<SettingsSelect
value={member.role.toUpperCase()}
onChange={e => handleChangeRole(member, e.target.value as TeamRole)}
disabled={changingRoleId === member._id}
aria-label={t('team.roleSelectorAria')}
inputSize="sm"
className="w-36">
{ROLES.map(r => (
<option key={r} value={r}>
{r}
</option>
))}
</SettingsSelect>
) : (
<SettingsBadge
variant={roleBadgeVariant[member.role.toUpperCase()] ?? 'neutral'}>
{member.role.toUpperCase()}
</SettingsBadge>
)}
{/* Remove button (admin only, not self) */}
{isAdmin && !isCurrentUser(member) && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => handleRemoveMember(member)}
disabled={removingId === member._id}
aria-label={t('team.removeAria').replace('{name}', displayName(member))}
className="text-neutral-500 dark:text-neutral-400 hover:text-coral-400 hover:bg-coral-500/10">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</SettingsSection>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Role badge / dropdown */}
{isAdmin && !isCurrentUser(member) ? (
<select
value={member.role.toUpperCase()}
onChange={e => handleChangeRole(member, e.target.value as TeamRole)}
disabled={changingRoleId === member._id}
className="px-2 py-1 text-[10px] font-medium rounded-full border bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 border-stone-300 dark:border-neutral-700 focus:outline-none focus:border-primary-500/50 disabled:opacity-50">
{ROLES.map(r => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${roleBadgeColor[member.role.toUpperCase()] ?? roleBadgeColor.MEMBER}`}>
{member.role.toUpperCase()}
</span>
)}
{/* Remove Member Confirmation Modal */}
{memberToRemove && (
<div className="fixed inset-0 bg-neutral-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-neutral-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-4">
{t('team.removeTitle')}
</h3>
{/* Remove button (admin only, not self) */}
{isAdmin && !isCurrentUser(member) && (
<button
onClick={() => handleRemoveMember(member)}
disabled={removingId === member._id}
className="p-1 rounded-lg text-stone-500 dark:text-neutral-400 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
aria-label={t('team.removeAria').replace('{name}', displayName(member))}>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</div>
))}
{members.length === 0 && !isLoadingMembers && (
<div className="text-center py-8">
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('team.noMembers')}
</p>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
</div>
)}
{/* Remove Member Confirmation Modal */}
{memberToRemove && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('team.removeTitle')}
</h3>
<div className="space-y-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400">
<p>
{t('team.removePromptPrefix')}{' '}
<strong className="text-neutral-800 dark:text-neutral-100">
{displayName(memberToRemove)}
</strong>{' '}
{t('team.removePromptSuffix')}
</p>
<p className="mt-2 text-coral-400">{t('team.removeWarning')}</p>
</div>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
{t('team.removePromptPrefix')}{' '}
<strong className="text-stone-900 dark:text-neutral-100">
{displayName(memberToRemove)}
</strong>{' '}
{t('team.removePromptSuffix')}
</p>
<p className="mt-2 text-coral-400">{t('team.removeWarning')}</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setMemberToRemove(null)}
disabled={removingId === memberToRemove._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={confirmRemoveMember}
disabled={removingId === memberToRemove._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-coral-500 hover:bg-coral-600 text-white transition-colors disabled:opacity-50">
{removingId === memberToRemove._id
? t('team.removing')
: t('team.removeAction')}
</button>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="md"
className="flex-1"
onClick={() => setMemberToRemove(null)}
disabled={removingId === memberToRemove._id}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="danger"
size="md"
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white border-0 dark:bg-coral-500 dark:hover:bg-coral-600"
onClick={() => void confirmRemoveMember()}
disabled={removingId === memberToRemove._id}>
{removingId === memberToRemove._id
? t('team.removing')
: t('team.removeAction')}
</Button>
</div>
</div>
</div>
)}
</div>
)}
{/* Change Role Confirmation Modal */}
{roleChangeConfirmation && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('team.changeRoleTitle')}
</h3>
{/* Change Role Confirmation Modal */}
{roleChangeConfirmation && (
<div className="fixed inset-0 bg-neutral-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-neutral-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-4">
{t('team.changeRoleTitle')}
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
{t('team.changeRolePrompt')
.replace('{name}', displayName(roleChangeConfirmation.member))
.replace('{oldRole}', roleChangeConfirmation.oldRole)
.replace('{newRole}', roleChangeConfirmation.newRole)}
</p>
{roleChangeConfirmation.newRole === 'ADMIN' && (
<p className="mt-2 text-amber-400">{t('team.changeRoleAdminGrant')}</p>
)}
{roleChangeConfirmation.oldRole === 'ADMIN' && (
<p className="mt-2 text-coral-400">{t('team.changeRoleAdminRemove')}</p>
)}
</div>
<div className="space-y-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400">
<p>
{t('team.changeRolePrompt')
.replace('{name}', displayName(roleChangeConfirmation.member))
.replace('{oldRole}', roleChangeConfirmation.oldRole)
.replace('{newRole}', roleChangeConfirmation.newRole)}
</p>
{roleChangeConfirmation.newRole === 'ADMIN' && (
<p className="mt-2 text-amber-400">{t('team.changeRoleAdminGrant')}</p>
)}
{roleChangeConfirmation.oldRole === 'ADMIN' && (
<p className="mt-2 text-coral-400">{t('team.changeRoleAdminRemove')}</p>
)}
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setRoleChangeConfirmation(null)}
disabled={changingRoleId === roleChangeConfirmation.member._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 dark:text-neutral-600 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={confirmChangeRole}
disabled={changingRoleId === roleChangeConfirmation.member._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50">
{changingRoleId === roleChangeConfirmation.member._id
? t('team.changing')
: t('team.changeRoleAction')}
</button>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="md"
className="flex-1"
onClick={() => setRoleChangeConfirmation(null)}
disabled={changingRoleId === roleChangeConfirmation.member._id}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="primary"
size="md"
className="flex-1"
onClick={() => void confirmChangeRole()}
disabled={changingRoleId === roleChangeConfirmation.member._id}>
{changingRoleId === roleChangeConfirmation.member._id
? t('team.changing')
: t('team.changeRoleAction')}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
+133 -137
View File
@@ -8,7 +8,9 @@ import { CoreRpcError } from '../../../services/coreRpcClient';
import type { TeamWithRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
import { CenteredLoadingState, ErrorBanner } from '../../ui';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsBadge, SettingsSection, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('core-rpc:error');
@@ -153,33 +155,24 @@ const TeamPanel = () => {
? t('team.role.billingManager')
: t('team.role.member');
const colors: Record<string, string> = {
ADMIN: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
BILLING_MANAGER: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
MEMBER:
'bg-stone-50 dark:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 border-stone-500/30',
const variantMap: Record<string, 'primary' | 'warning' | 'neutral'> = {
ADMIN: 'primary',
BILLING_MANAGER: 'warning',
MEMBER: 'neutral',
};
return (
<span
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${colors[normalizedRole] ?? colors.MEMBER}`}>
{roleLabel}
</span>
<SettingsBadge variant={variantMap[normalizedRole] ?? 'neutral'}>{roleLabel}</SettingsBadge>
);
};
const planBadge = (plan: string) => {
const colors: Record<string, string> = {
PRO: 'bg-lavender-500/20 text-lavender-400 border-lavender-500/30',
BASIC: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
FREE: 'bg-stone-50 dark:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 border-stone-500/30',
const variantMap: Record<string, 'primary' | 'success' | 'neutral'> = {
PRO: 'primary',
BASIC: 'primary',
FREE: 'neutral',
};
return (
<span
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${colors[plan] ?? colors.FREE}`}>
{plan}
</span>
);
return <SettingsBadge variant={variantMap[plan] ?? 'neutral'}>{plan}</SettingsBadge>;
};
const TeamRow = ({ entry }: { entry: TeamWithRole }) => {
@@ -194,29 +187,25 @@ const TeamPanel = () => {
className={`flex items-center justify-between p-3 rounded-xl border transition-all ${
isActive
? 'border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60'
: 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
}`}>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-lg bg-stone-100 dark:bg-neutral-800 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-stone-600 dark:text-neutral-300">
<div className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-300">
{team.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100 truncate">
{team.name}
</span>
{roleBadge(role, team.createdBy)}
{planBadge(team.subscription.plan)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
{t('team.active')}
</span>
)}
{isActive && <SettingsBadge variant="success">{t('team.active')}</SettingsBadge>}
</div>
{team.isPersonal && (
<p className="text-xs text-stone-400 dark:text-neutral-500 mt-0.5">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('team.personalTeam')}
</p>
)}
@@ -225,27 +214,34 @@ const TeamPanel = () => {
<div className="flex items-center gap-2 flex-shrink-0">
{canManage && (
<button
onClick={() => navigateToTeamManagement(team._id)}
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-primary-50 dark:bg-primary-500/10 hover:bg-primary-100 dark:bg-primary-500/20 text-primary-600 dark:text-primary-300 transition-colors">
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => navigateToTeamManagement(team._id)}>
{t('team.manageTeam')}
</button>
</Button>
)}
{!isActive && (
<button
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => handleSwitchTeam(team._id)}
disabled={isSwitching === team._id}
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 transition-colors disabled:opacity-50">
disabled={isSwitching === team._id}>
{isSwitching === team._id ? t('team.switching') : t('team.switch')}
</button>
</Button>
)}
{canLeave && (
<button
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => handleLeaveTeam(entry)}
disabled={isLeaving === team._id}
className="px-2.5 py-1 text-xs font-medium rounded-lg text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:bg-amber-500/10 transition-colors disabled:opacity-50">
className="text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-500/10">
{isLeaving === team._id ? t('team.leaving') : t('team.leave')}
</button>
</Button>
)}
</div>
</div>
@@ -261,115 +257,115 @@ const TeamPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div>
<div className="p-4 space-y-4">
{error && <ErrorBanner message={error} />}
<div className="p-4 pt-2 space-y-5">
{error && <ErrorBanner message={error} />}
{isLoading && teams.length === 0 && <CenteredLoadingState />}
{isLoading && teams.length === 0 && <CenteredLoadingState />}
{teams.length > 0 && (
<div className="space-y-3">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1">
{t('team.yourTeams')} ({teams.length})
</h3>
<div className="space-y-2">
{teams.map(entry => (
<TeamRow key={entry.team._id} entry={entry} />
))}
</div>
{teams.length > 0 && (
<SettingsSection title={`${t('team.yourTeams')} (${teams.length})`}>
<div className="p-3 space-y-2">
{teams.map(entry => (
<TeamRow key={entry.team._id} entry={entry} />
))}
</div>
)}
</SettingsSection>
)}
<div className="space-y-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
<div className="space-y-2">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1">
{t('team.createNewTeam')}
</h3>
<div className="flex gap-2">
<input
type="text"
value={newTeamName}
onChange={e => setNewTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateTeam()}
placeholder={t('team.teamName')}
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:border-primary-500/50"
/>
<button
onClick={handleCreateTeam}
disabled={isCreating || !newTeamName.trim()}
className="px-4 py-2 text-xs font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{isCreating ? t('team.creating') : t('common.create')}
</button>
</div>
</div>
<div className="space-y-2">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1">
{t('team.joinExistingTeam')}
</h3>
<div className="flex gap-2">
<input
type="text"
value={joinCode}
onChange={e => setJoinCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleJoinTeam()}
placeholder={t('team.inviteCode')}
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:border-primary-500/50 font-mono"
/>
<button
onClick={handleJoinTeam}
disabled={isJoining || !joinCode.trim()}
className="px-4 py-2 text-xs font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{isJoining ? t('team.joining') : t('team.join')}
</button>
</div>
</div>
<SettingsSection title={t('team.createNewTeam')}>
<div className="flex gap-2 px-4 py-3">
<SettingsTextField
className="flex-1"
value={newTeamName}
onChange={e => setNewTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void handleCreateTeam()}
placeholder={t('team.teamName')}
aria-label={t('team.teamName')}
inputSize="sm"
/>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleCreateTeam()}
disabled={isCreating || !newTeamName.trim()}>
{isCreating ? t('team.creating') : t('common.create')}
</Button>
</div>
</SettingsSection>
{teamToLeave && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('team.leaveTeam')}
</h3>
<SettingsSection title={t('team.joinExistingTeam')}>
<div className="flex gap-2 px-4 py-3">
<SettingsTextField
mono
className="flex-1"
value={joinCode}
onChange={e => setJoinCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void handleJoinTeam()}
placeholder={t('team.inviteCode')}
aria-label={t('team.inviteCode')}
inputSize="sm"
/>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void handleJoinTeam()}
disabled={isJoining || !joinCode.trim()}>
{isJoining ? t('team.joining') : t('team.join')}
</Button>
</div>
</SettingsSection>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{teamToLeave && (
<div className="fixed inset-0 bg-neutral-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-neutral-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-4">
{t('team.leaveTeam')}
</h3>
<div className="space-y-4">
<div className="text-sm text-stone-500 dark:text-neutral-400">
<p>
{t('team.confirmLeave')}{' '}
<strong className="text-stone-900 dark:text-neutral-100">
{teamToLeave.team.name}
</strong>
?
</p>
<p className="mt-2 text-amber-400">{t('team.leaveWarning')}</p>
</div>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={() => setTeamToLeave(null)}
disabled={isLeaving === teamToLeave.team._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={confirmLeaveTeam}
disabled={isLeaving === teamToLeave.team._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-amber-500 hover:bg-amber-600 text-white transition-colors disabled:opacity-50">
{isLeaving === teamToLeave.team._id ? t('team.leaving') : t('team.leaveTeam')}
</button>
</div>
<div className="space-y-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400">
<p>
{t('team.confirmLeave')}{' '}
<strong className="text-neutral-800 dark:text-neutral-100">
{teamToLeave.team.name}
</strong>
?
</p>
<p className="mt-2 text-amber-400">{t('team.leaveWarning')}</p>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="secondary"
size="md"
className="flex-1"
onClick={() => setTeamToLeave(null)}
disabled={isLeaving === teamToLeave.team._id}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="ghost"
size="md"
className="flex-1 bg-amber-500 hover:bg-amber-600 text-white border-0 dark:bg-amber-500 dark:hover:bg-amber-600"
onClick={() => void confirmLeaveTeam()}
disabled={isLeaving === teamToLeave.team._id}>
{isLeaving === teamToLeave.team._id ? t('team.leaving') : t('team.leaveTeam')}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
type ToolPolicyDiagnostics = {
@@ -76,20 +77,18 @@ const ToolPolicyDiagnosticsPanel = () => {
const body = useMemo(() => {
if (status.kind === 'loading') {
return (
<div className="px-4 py-3 text-sm text-sage-700 dark:text-sage-200">
<div className="px-4 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('devOptions.toolPolicyDiagnostics.loading')}
</div>
);
}
if (status.kind === 'error') {
return (
<div className="px-4 py-3 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10">
<div className="text-sm font-semibold text-coral-900 dark:text-coral-200">
<div className="px-4 py-3">
<div className="text-sm font-semibold text-neutral-800 dark:text-neutral-100 mb-1">
{t('devOptions.toolPolicyDiagnostics.unavailable')}
</div>
<div className="text-xs text-coral-800 dark:text-coral-200 mt-1 font-mono break-words">
{status.message}
</div>
<SettingsStatusLine saving={false} error={status.message} savingLabel="" />
</div>
);
}
@@ -71,4 +71,31 @@ describe('<ToolsPanel />', () => {
)
);
});
it('renders SettingsHeader when embedded=false (line 110)', () => {
// Default embedded=false shows the header
render(<ToolsPanel embedded={false} />);
expect(screen.getByText('Tools')).toBeInTheDocument();
});
it('does not render SettingsHeader when embedded=true (line 101-108 skipped)', () => {
// When embedded, the header section is not rendered
render(<ToolsPanel embedded={true} />);
// The header mock outputs a <h1> with the title — embedded should NOT show it
expect(screen.queryByRole('heading', { name: 'Tools' })).not.toBeInTheDocument();
});
it('shows Save Changes button after toggling a tool (dirty state, line 145-155)', async () => {
render(<ToolsPanel />);
const shellToggle = screen.getByRole('switch', { name: /Shell Commands/ });
await waitFor(() => expect(shellToggle).toHaveAttribute('aria-checked', 'true'));
// Before toggle — no Save button
expect(screen.queryByRole('button', { name: 'Save Changes' })).not.toBeInTheDocument();
// After toggle — dirty=true → Save Changes appears (line 145-155)
fireEvent.click(shellToggle);
expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument();
});
});
@@ -10,7 +10,9 @@ import {
normalizeEnabledToolList,
TOOL_CATEGORIES,
} from '../../../utils/toolDefinitions';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsStatusLine, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface ToolsPanelProps {
@@ -95,7 +97,7 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
};
return (
<div>
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.features.tools')}
@@ -105,8 +107,8 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
/>
)}
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<p className="text-stone-500 dark:text-neutral-400 text-sm">
<div className={embedded ? 'space-y-4' : 'p-4 pt-2 space-y-4'}>
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
{t('settings.tools.chooseCapabilities')}
</p>
@@ -115,67 +117,49 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
const tools = toolsByCategory[category];
if (tools.length === 0) return null;
return (
<div key={category}>
<div className="mb-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{category}
</h2>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{CATEGORY_DESCRIPTIONS[category]}
</p>
</div>
<div className="space-y-1">
{tools.map(tool => (
<button
key={tool.id}
type="button"
role="switch"
aria-checked={Boolean(enabled[tool.id])}
onClick={() => toggle(tool.id)}
className="w-full flex items-center justify-between p-2.5 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700 transition-colors text-left">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{tool.displayName}
</span>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{tool.description}
</p>
</div>
<div
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
enabled[tool.id] ? 'bg-sage-500' : 'bg-stone-200 dark:bg-neutral-800'
}`}>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
enabled[tool.id] ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</div>
</button>
))}
</div>
</div>
<SettingsSection
key={category}
title={category}
description={CATEGORY_DESCRIPTIONS[category]}>
{tools.map(tool => (
<SettingsRow
key={tool.id}
htmlFor={`tool-switch-${tool.id}`}
label={tool.displayName}
description={tool.description}
control={
<SettingsSwitch
id={`tool-switch-${tool.id}`}
checked={Boolean(enabled[tool.id])}
onCheckedChange={() => toggle(tool.id)}
aria-label={tool.displayName}
/>
}
/>
))}
</SettingsSection>
);
})}
</div>
{dirty && (
<button
<Button
type="button"
onClick={handleSave}
disabled={saving}
className="mt-4 w-full py-2 rounded-xl bg-primary-600 text-white text-sm font-medium hover:bg-primary-500 transition-colors disabled:opacity-50">
{saving ? 'Saving...' : t('settings.tools.saveChanges')}
</button>
)}
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600 dark:text-green-300 mt-1">
{t('settings.tools.preferencesSaved')}
</p>
)}
{saveStatus === 'error' && (
<p className="text-xs text-center text-red-500 mt-1">{t('settings.tools.saveFailed')}</p>
variant="primary"
size="md"
className="w-full"
onClick={() => void handleSave()}
disabled={saving}>
{saving ? t('autonomy.statusSaving') : t('settings.tools.saveChanges')}
</Button>
)}
<SettingsStatusLine
saving={false}
savedNote={saveStatus === 'saved' ? t('settings.tools.preferencesSaved') : null}
error={saveStatus === 'error' ? t('settings.tools.saveFailed') : null}
savingLabel=""
/>
</div>
</div>
);
@@ -10,7 +10,15 @@ import {
type VoiceServerStatus,
type VoiceStatus,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsNumberField,
SettingsRow,
SettingsSection,
SettingsStatusLine,
SettingsSwitch,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const VoiceDebugPanel = () => {
@@ -123,176 +131,142 @@ const VoiceDebugPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<div className="p-4 pt-2 space-y-5">
{/* Runtime status section */}
<section className="space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('voice.debug.runtimeStatus')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('voice.debug.runtimeStatusDesc')}
</p>
</div>
<button
type="button"
onClick={() => void loadData()}
className="text-xs text-primary-600 dark:text-primary-300 hover:text-primary-700 dark:text-primary-300">
{t('common.refresh')}
</button>
</div>
<SettingsSection
title={t('voice.debug.runtimeStatus')}
description={t('voice.debug.runtimeStatusDesc')}>
<SettingsRow
stacked
control={
<div className="space-y-3">
<div className="flex items-center justify-end">
<Button type="button" variant="ghost" size="xs" onClick={() => void loadData()}>
{t('common.refresh')}
</Button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('voice.debug.server')}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-3">
<div className="text-[10px] uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t('voice.debug.server')}
</div>
<div className="mt-1 font-medium text-neutral-800 dark:text-neutral-100">
{serverStatus
? serverStatus.state
: isLoading
? t('common.loading')
: t('voice.debug.unavailable')}
</div>
</div>
<div className="rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-3">
<div className="text-[10px] uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
STT
</div>
<div className="mt-1 font-medium text-neutral-800 dark:text-neutral-100">
{voiceStatus?.stt_available
? t('voice.debug.ready')
: t('voice.debug.notReady')}
</div>
</div>
</div>
<div className="mt-1 font-medium text-stone-900 dark:text-neutral-100">
{serverStatus
? serverStatus.state
: isLoading
? t('common.loading')
: t('voice.debug.unavailable')}
</div>
</div>
<div className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
STT
</div>
<div className="mt-1 font-medium text-stone-900 dark:text-neutral-100">
{voiceStatus?.stt_available ? t('voice.debug.ready') : t('voice.debug.notReady')}
</div>
</div>
</div>
{serverStatus && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600 dark:text-neutral-300">
<div>
{t('voice.debug.hotkey')}: {serverStatus.hotkey || t('voice.debug.notAvailable')}
</div>
<div>
{t('voice.debug.mode')}: {serverStatus.activation_mode}
</div>
<div>
{t('voice.debug.transcriptions')}: {serverStatus.transcription_count}
</div>
</div>
)}
{serverStatus && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-neutral-500 dark:text-neutral-400">
<div>
{t('voice.debug.hotkey')}:{' '}
{serverStatus.hotkey || t('voice.debug.notAvailable')}
</div>
<div>
{t('voice.debug.mode')}: {serverStatus.activation_mode}
</div>
<div>
{t('voice.debug.transcriptions')}: {serverStatus.transcription_count}
</div>
</div>
)}
{serverStatus?.last_error && (
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-300">
<div className="font-medium mb-1">{t('voice.debug.serverError')}</div>
{serverStatus.last_error}
{serverStatus?.last_error && (
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-300">
<div className="font-medium mb-1">{t('voice.debug.serverError')}</div>
{serverStatus.last_error}
</div>
)}
</div>
)}
</div>
</section>
}
/>
</SettingsSection>
{/* Advanced settings section */}
<section className="space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('voice.debug.advancedSettings')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('voice.debug.advancedSettingsDesc')}
</p>
</div>
{settings && (
<>
{/* Always-on listening (Phase 2) — opt-in, privacy-sensitive. */}
<div className="flex items-start justify-between gap-3 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2.5">
<div className="min-w-0">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('voice.debug.alwaysOn')}
</span>
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mt-0.5">
{t('voice.debug.alwaysOnDesc')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.always_on_enabled}
<SettingsSection
title={t('voice.debug.advancedSettings')}
description={t('voice.debug.advancedSettingsDesc')}>
{settings && (
<>
<SettingsRow
htmlFor="switch-always-on"
label={t('voice.debug.alwaysOn')}
description={t('voice.debug.alwaysOnDesc')}
control={
<SettingsSwitch
id="switch-always-on"
checked={settings.always_on_enabled}
onCheckedChange={next => updateSetting('always_on_enabled', next)}
aria-label={t('voice.debug.alwaysOn')}
data-testid="voice-always-on-toggle"
onClick={() => updateSetting('always_on_enabled', !settings.always_on_enabled)}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors ${
settings.always_on_enabled
? 'bg-primary-500'
: 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
aria-hidden
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${
settings.always_on_enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.debug.minimumRecordingSeconds')}
</span>
<input
type="number"
min="0"
step="0.1"
value={settings.min_duration_secs}
onChange={e => updateSetting('min_duration_secs', Number(e.target.value) || 0)}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.debug.silenceThreshold')}
</span>
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('voice.debug.silenceThresholdDesc')}
</p>
<input
type="number"
min="0"
max="1"
step="0.001"
value={settings.silence_threshold}
onChange={e =>
updateSetting('silence_threshold', Number(e.target.value) || 0.002)
}
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
}
/>
<SettingsRow
stacked
label={t('voice.debug.minimumRecordingSeconds')}
control={
<SettingsNumberField
id="min-duration-input"
value={String(settings.min_duration_secs)}
onChange={val => updateSetting('min_duration_secs', Number(val) || 0)}
onCommit={() => {}}
min={0}
aria-label={t('voice.debug.minimumRecordingSeconds')}
/>
</label>
</>
)}
{error && (
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-300">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-300">
{notice}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void saveSettings()}
disabled={isSaving || !hasUnsavedChanges}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{isSaving ? t('common.loading') : t('common.save')}
</button>
</div>
}
/>
<SettingsRow
stacked
label={t('voice.debug.silenceThreshold')}
description={t('voice.debug.silenceThresholdDesc')}
control={
<SettingsNumberField
id="silence-threshold-input"
value={String(settings.silence_threshold)}
onChange={val => updateSetting('silence_threshold', Number(val) || 0.002)}
onCommit={() => {}}
min={0}
max={1}
step={0.001}
aria-label={t('voice.debug.silenceThreshold')}
/>
}
/>
</>
)}
<div className="px-4 py-3 space-y-3">
<SettingsStatusLine
saving={isSaving}
savedNote={notice}
error={error}
savingLabel={t('common.loading')}
/>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void saveSettings()}
disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? t('common.loading') : t('common.save')}
</Button>
</div>
</section>
</SettingsSection>
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,9 @@ import {
fetchWalletStatus,
type WalletChain,
} from '../../../services/walletApi';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsEmptyState, SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ReceiveModal from './wallet/ReceiveModal';
import SendCryptoModal from './wallet/SendCryptoModal';
@@ -34,7 +36,7 @@ const CHAIN_BADGE_CLASS: Record<string, string> = {
const badgeClassFor = (chain: WalletChain): string =>
CHAIN_BADGE_CLASS[chain] ??
'bg-stone-100 text-stone-700 dark:bg-neutral-800 dark:text-neutral-300';
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300';
// The rows rendered as placeholders before the wallet is set up, mirroring the
// configured layout (one EVM row per displayed network + BTC/Solana/Tron) so
@@ -113,7 +115,7 @@ const BalanceRow = ({ balance, onSend, onReceive }: BalanceRowProps) => {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200 truncate">
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-200 truncate">
{networkLabel}
</span>
{balance.providerStatus !== 'ready' && (
@@ -124,14 +126,14 @@ const BalanceRow = ({ balance, onSend, onReceive }: BalanceRowProps) => {
</div>
{/* Address + copy button */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono text-[11px] text-stone-500 dark:text-neutral-400 truncate">
<span className="font-mono text-[11px] text-neutral-500 dark:text-neutral-400 truncate">
{truncateAddress(balance.address)}
</span>
<button
type="button"
onClick={() => void handleCopyAddress()}
aria-label={t('walletBalances.copyAddress')}
className="shrink-0 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
className="shrink-0 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
{copied ? (
<svg
className="w-3.5 h-3.5 text-sage-500"
@@ -163,10 +165,10 @@ const BalanceRow = ({ balance, onSend, onReceive }: BalanceRowProps) => {
<div className="text-right shrink-0">
<span
title={t('walletBalances.rawBalance').replace('{raw}', balance.raw)}
className="text-sm font-medium text-stone-800 dark:text-neutral-100 font-mono">
className="text-sm font-medium text-neutral-800 dark:text-neutral-100 font-mono">
{balance.formatted}
</span>
<span className="ml-1 text-xs text-stone-500 dark:text-neutral-400">
<span className="ml-1 text-xs text-neutral-500 dark:text-neutral-400">
{balance.assetSymbol}
</span>
</div>
@@ -174,20 +176,24 @@ const BalanceRow = ({ balance, onSend, onReceive }: BalanceRowProps) => {
{/* Send / Receive actions */}
<div className="flex gap-2 mt-2.5">
<button
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onSend(balance)}
className="flex-1 py-1.5 text-xs font-medium rounded-lg border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors"
className="flex-1"
data-testid={`wallet-send-${balanceKey(balance)}`}>
{t('walletBalances.send')}
</button>
<button
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onReceive(balance)}
className="flex-1 py-1.5 text-xs font-medium rounded-lg border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors"
className="flex-1"
data-testid={`wallet-receive-${balanceKey(balance)}`}>
{t('walletBalances.receive')}
</button>
</Button>
</div>
</div>
);
@@ -218,20 +224,20 @@ const ChainPlaceholderRow = ({
{balanceBadge({ chain, evmNetwork })}
</span>
<div className="min-w-0">
<span className="block text-xs font-medium text-stone-400 dark:text-neutral-500 truncate">
<span className="block text-xs font-medium text-neutral-400 dark:text-neutral-500 truncate">
{balanceNetworkLabel({ chain, evmNetwork })}
</span>
<span className="font-mono text-[11px] text-stone-400 dark:text-neutral-500 truncate">
<span className="font-mono text-[11px] text-neutral-400 dark:text-neutral-500 truncate">
{t('walletBalances.notSetUp')}
</span>
</div>
<div className="flex-1" />
<div className="text-right shrink-0">
{/* Em dash placeholder — punctuation, not translatable copy. */}
<span className="text-sm font-medium text-stone-400 dark:text-neutral-500 font-mono">
<span className="text-sm font-medium text-neutral-400 dark:text-neutral-500 font-mono">
</span>
<span className="ml-1 text-xs text-stone-400 dark:text-neutral-500">{symbol}</span>
<span className="ml-1 text-xs text-neutral-400 dark:text-neutral-500">{symbol}</span>
</div>
</div>
);
@@ -302,7 +308,7 @@ const WalletBalancesPanel = () => {
const renderContent = () => {
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-stone-500 dark:text-neutral-400">
<div className="flex items-center justify-center gap-2 py-10 text-neutral-500 dark:text-neutral-400">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
@@ -345,12 +351,14 @@ const WalletBalancesPanel = () => {
{t('walletBalances.errorGeneric')}
</p>
</div>
<button
<Button
type="button"
variant="primary"
size="md"
onClick={() => void loadBalances()}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl">
className="w-full">
{t('walletBalances.retry')}
</button>
</Button>
</div>
);
}
@@ -389,7 +397,7 @@ const WalletBalancesPanel = () => {
</div>
</div>
</div>
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{PLACEHOLDER_ROWS.map(row => (
<ChainPlaceholderRow
key={`${row.chain}-${row.evmNetwork ?? 'native'}`}
@@ -406,9 +414,9 @@ const WalletBalancesPanel = () => {
if (balances !== null && balances.length === 0) {
return (
<div className="px-4 py-8 text-center">
<div className="w-12 h-12 rounded-full bg-stone-100 dark:bg-neutral-800 flex items-center justify-center mx-auto mb-3">
<div className="w-12 h-12 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center mx-auto mb-3">
<svg
className="w-6 h-6 text-stone-400 dark:text-neutral-500"
className="w-6 h-6 text-neutral-400 dark:text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -420,16 +428,14 @@ const WalletBalancesPanel = () => {
/>
</svg>
</div>
<p className="text-sm text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('walletBalances.emptyState')}
</p>
<SettingsEmptyState label={t('walletBalances.emptyState')} />
</div>
);
}
if (balances && balances.length > 0) {
return (
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{balances.map(balance => (
<BalanceRow
key={balanceKey(balance)}
@@ -453,12 +459,14 @@ const WalletBalancesPanel = () => {
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
<button
<Button
type="button"
variant="ghost"
size="sm"
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">
className="gap-1.5 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300">
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
@@ -472,12 +480,12 @@ const WalletBalancesPanel = () => {
/>
</svg>
{t('walletBalances.refresh')}
</button>
</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()}
<div className="mx-4 mb-4">
<SettingsSection>{renderContent()}</SettingsSection>
</div>
{sendTarget && (
@@ -16,7 +16,14 @@ import {
type WebhookDebugLogEntry,
type WebhookDebugRegistration,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import {
SettingsBadge,
SettingsEmptyState,
SettingsSection,
SettingsStatusLine,
} from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const LOG_LIMIT = 100;
@@ -155,7 +162,7 @@ const WebhooksDebugPanel = () => {
}, [loadData, t]);
return (
<div data-testid="webhooks-debug-panel">
<div className="z-10 relative" data-testid="webhooks-debug-panel">
<SettingsHeader
title={t('webhooks.debugTitle')}
showBackButton={true}
@@ -165,43 +172,43 @@ const WebhooksDebugPanel = () => {
<div className="p-4 space-y-4">
{/* Status bar */}
<div className="flex flex-wrap items-center gap-2 text-xs">
<button
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void loadData()}
disabled={loading}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
disabled={loading}>
{loading ? t('webhooks.loading') : t('webhooks.refresh')}
</button>
<button
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => void handleClearLogs()}
disabled={clearing || logs.length === 0}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
disabled={clearing || logs.length === 0}>
{clearing ? t('webhooks.clearing') : t('webhooks.clearLogs')}
</button>
<span className="text-stone-500 dark:text-neutral-400">
</Button>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{registrations.length} {t('webhooks.registered')} &middot; {logs.length}{' '}
{t('webhooks.captured')} &middot;{' '}
<span
className={
isLive ? 'text-sage-600 dark:text-sage-300' : 'text-stone-400 dark:text-neutral-500'
isLive
? 'text-sage-600 dark:text-sage-300'
: 'text-neutral-500 dark:text-neutral-400'
}>
{isLive ? t('webhooks.live') : t('webhooks.disconnected')}
</span>
</span>
</div>
{error && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
<SettingsStatusLine saving={false} error={error} savingLabel="" />
{lastEvent && (
<div className="text-xs text-stone-500 dark:text-neutral-400">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('webhooks.lastEvent')}:{' '}
<span className="font-medium text-stone-700 dark:text-neutral-200">
<span className="font-medium text-neutral-800 dark:text-neutral-200">
{lastEvent.event_type}
</span>{' '}
{t('webhooks.at')} {formatDateTime(lastEvent.timestamp)}
@@ -209,141 +216,131 @@ const WebhooksDebugPanel = () => {
)}
{/* Registrations */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('webhooks.registeredWebhooks')}
</h3>
{registrations.length === 0 ? (
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('webhooks.noActiveRegistrations')}
</p>
) : (
<div className="space-y-2">
{registrations.map(registration => (
<div
key={registration.tunnel_uuid}
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
{registration.tunnel_name || registration.tunnel_uuid}
</span>
<div className="flex gap-1 text-[10px]">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{registration.target_kind}
</span>
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{registration.skill_id}
<SettingsSection title={t('webhooks.registeredWebhooks')}>
<div className="px-4 py-3 space-y-2">
{registrations.length === 0 ? (
<SettingsEmptyState label={t('webhooks.noActiveRegistrations')} />
) : (
<div className="space-y-2">
{registrations.map(registration => (
<div
key={registration.tunnel_uuid}
className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{registration.tunnel_name || registration.tunnel_uuid}
</span>
<div className="flex gap-1">
<SettingsBadge variant="neutral">{registration.target_kind}</SettingsBadge>
<SettingsBadge variant="neutral">{registration.skill_id}</SettingsBadge>
</div>
</div>
<div className="mt-1 text-[11px] text-neutral-500 dark:text-neutral-400 font-mono break-all">
{backendUrl
? tunnelsApi.ingressUrl(backendUrl, registration.tunnel_uuid)
: t('webhooks.resolvingBackendUrl')}
</div>
</div>
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400 font-mono break-all">
{backendUrl
? tunnelsApi.ingressUrl(backendUrl, registration.tunnel_uuid)
: t('webhooks.resolvingBackendUrl')}
</div>
</div>
))}
</div>
)}
</section>
))}
</div>
)}
</div>
</SettingsSection>
{/* Captured Requests */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('webhooks.capturedRequests')}
</h3>
{logs.length === 0 ? (
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('webhooks.noRequestsCaptured')}
</p>
) : (
<div className="space-y-2">
{logs.map(entry => (
<button
key={entry.correlation_id}
type="button"
onClick={() => setSelectedCorrelationId(entry.correlation_id)}
className={`w-full rounded-xl border p-3 text-left transition-colors ${
selectedLog?.correlation_id === entry.correlation_id
? 'border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800'
}`}>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
{entry.method} {entry.path}
</span>
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{entry.status_code ?? '...'}
</span>
</div>
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{entry.tunnel_name}{' '}
{entry.skill_id ? `· ${entry.skill_id}` : `· ${t('webhooks.unrouted')}`} ·{' '}
{formatDateTime(entry.updated_at)}
</div>
</button>
))}
{selectedLog && (
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 space-y-3">
<div>
<div className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
{selectedLog.method} {selectedLog.path}
<SettingsSection title={t('webhooks.capturedRequests')}>
<div className="px-4 py-3 space-y-2">
{logs.length === 0 ? (
<SettingsEmptyState label={t('webhooks.noRequestsCaptured')} />
) : (
<div className="space-y-2">
{logs.map(entry => (
<button
key={entry.correlation_id}
type="button"
onClick={() => setSelectedCorrelationId(entry.correlation_id)}
className={`w-full rounded-xl border p-3 text-left transition-colors ${
selectedLog?.correlation_id === entry.correlation_id
? 'border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10'
: 'border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{entry.method} {entry.path}
</span>
<span className="text-[10px] text-neutral-500 dark:text-neutral-400">
{entry.status_code ?? '...'}
</span>
</div>
<div className="text-[10px] text-stone-400 dark:text-neutral-500 font-mono">
{selectedLog.correlation_id}
<div className="mt-1 text-[11px] text-neutral-500 dark:text-neutral-400">
{entry.tunnel_name}{' '}
{entry.skill_id ? `· ${entry.skill_id}` : `· ${t('webhooks.unrouted')}`} ·{' '}
{formatDateTime(entry.updated_at)}
</div>
</div>
</button>
))}
<div className="flex flex-wrap gap-1 text-[10px]">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{selectedLog.stage}
</span>
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{selectedLog.status_code ?? t('webhooks.pending')}
</span>
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{selectedLog.skill_id || t('webhooks.unrouted')}
</span>
</div>
{selectedLog.error_message && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{selectedLog.error_message}
{selectedLog && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/60 p-3 space-y-3">
<div>
<div className="text-xs font-semibold text-neutral-800 dark:text-neutral-100">
{selectedLog.method} {selectedLog.path}
</div>
<div className="text-[10px] text-neutral-500 dark:text-neutral-400 font-mono">
{selectedLog.correlation_id}
</div>
</div>
)}
<PayloadBlock
title={t('webhooks.requestHeaders')}
value={prettyJson(selectedLog.request_headers)}
/>
<PayloadBlock
title={t('webhooks.queryParams')}
value={prettyJson(selectedLog.request_query)}
/>
<PayloadBlock
title={t('webhooks.requestBody')}
value={decodeBase64Preview(selectedLog.request_body) || t('webhooks.empty')}
/>
<PayloadBlock
title={t('webhooks.responseHeaders')}
value={prettyJson(selectedLog.response_headers)}
/>
<PayloadBlock
title={t('webhooks.responseBody')}
value={decodeBase64Preview(selectedLog.response_body) || t('webhooks.empty')}
/>
{selectedLog.raw_payload != null && (
<div className="flex flex-wrap gap-1">
<SettingsBadge variant="neutral">{selectedLog.stage}</SettingsBadge>
<SettingsBadge variant="neutral">
{selectedLog.status_code ?? t('webhooks.pending')}
</SettingsBadge>
<SettingsBadge variant="neutral">
{selectedLog.skill_id || t('webhooks.unrouted')}
</SettingsBadge>
</div>
{selectedLog.error_message && (
<SettingsStatusLine
saving={false}
error={selectedLog.error_message}
savingLabel=""
/>
)}
<PayloadBlock
title={t('webhooks.rawPayload')}
value={prettyJson(selectedLog.raw_payload)}
title={t('webhooks.requestHeaders')}
value={prettyJson(selectedLog.request_headers)}
/>
)}
</div>
)}
</div>
)}
</section>
<PayloadBlock
title={t('webhooks.queryParams')}
value={prettyJson(selectedLog.request_query)}
/>
<PayloadBlock
title={t('webhooks.requestBody')}
value={decodeBase64Preview(selectedLog.request_body) || t('webhooks.empty')}
/>
<PayloadBlock
title={t('webhooks.responseHeaders')}
value={prettyJson(selectedLog.response_headers)}
/>
<PayloadBlock
title={t('webhooks.responseBody')}
value={decodeBase64Preview(selectedLog.response_body) || t('webhooks.empty')}
/>
{selectedLog.raw_payload != null && (
<PayloadBlock
title={t('webhooks.rawPayload')}
value={prettyJson(selectedLog.raw_payload)}
/>
)}
</div>
)}
</div>
)}
</div>
</SettingsSection>
</div>
</div>
);
@@ -14,7 +14,7 @@ const WorkflowRunnerPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
return (
<div className="flex flex-col h-full">
<div className="z-10 relative flex flex-col h-full">
<SettingsHeader
title={t('settings.developerMenu.skillsRunner.title')}
showBackButton={true}
@@ -97,7 +97,8 @@ describe('AgentAccessPanel (advanced)', () => {
it('toggling "confine to workspace" persists workspace_only', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Confine to workspace');
fireEvent.click(screen.getByRole('checkbox', { name: /confine to workspace/i }));
// Controls are now role="switch" (SettingsSwitch) instead of native checkboxes.
fireEvent.click(screen.getByRole('switch', { name: /confine to workspace/i }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ workspace_only: true }))
);
@@ -106,7 +107,7 @@ describe('AgentAccessPanel (advanced)', () => {
it('toggling task plan approval persists require_task_plan_approval', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Confine to workspace');
fireEvent.click(screen.getByRole('checkbox', { name: /require task plan approval/i }));
fireEvent.click(screen.getByRole('switch', { name: /require task plan approval/i }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ require_task_plan_approval: false })
@@ -146,10 +147,11 @@ describe('AgentAccessPanel (advanced)', () => {
renderWithProviders(<AgentAccessPanel />);
// Folder path is visible.
expect(await screen.findByText('/home/u/notes')).toBeInTheDocument();
// workspace_only checkbox is checked.
expect(
(screen.getByRole('checkbox', { name: /confine to workspace/i }) as HTMLInputElement).checked
).toBe(true);
// workspace_only switch has aria-checked="true".
expect(screen.getByRole('switch', { name: /confine to workspace/i })).toHaveAttribute(
'aria-checked',
'true'
);
// But the tier radio UI is NOT here (lives in PermissionsPanel).
expect(screen.queryByText('Read-only')).not.toBeInTheDocument();
});
@@ -236,4 +238,10 @@ describe('AgentAccessPanel (advanced)', () => {
expect(input.disabled).toBe(true);
expect(screen.getByText(/OPENHUMAN_TOOL_TIMEOUT_SECS/)).toBeInTheDocument();
});
it('approval history link button is present and has the correct data-testid', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Approval history');
expect(screen.getByTestId('agent-access-approval-history-link')).toBeInTheDocument();
});
});
@@ -0,0 +1,191 @@
/**
* AgentChatPanel unit tests covers changed lines:
* 129, 131, 134-135, 137, 141, 155, 163, 165
*
* Exercises: rendering the conversation area, sending a message, rendering
* user and agent chat messages, error display on RPC failure, model/temp
* overrides sent through, localStorage persistence, and the empty state.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import { openhumanAgentChat } from '../../../../utils/tauriCommands';
import AgentChatPanel from '../AgentChatPanel';
vi.mock('../../../../utils/tauriCommands', async () => {
const actual = await vi.importActual<typeof import('../../../../utils/tauriCommands')>(
'../../../../utils/tauriCommands'
);
return { ...actual, openhumanAgentChat: vi.fn() };
});
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: vi.fn(),
breadcrumbs: [],
}),
}));
const mockAgentChat = vi.mocked(openhumanAgentChat);
describe('AgentChatPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockAgentChat.mockResolvedValue({ result: 'Hello from agent', logs: [] });
});
it('renders the panel header and empty conversation area', async () => {
renderWithProviders(<AgentChatPanel />);
expect(await screen.findByText('Agent Chat')).toBeInTheDocument();
// Empty state label shown when no messages
expect(screen.getByText(/start a conversation/i)).toBeInTheDocument();
});
it('shows model and temperature input fields', async () => {
renderWithProviders(<AgentChatPanel />);
expect(await screen.findByRole('textbox', { name: /model/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /temperature/i })).toBeInTheDocument();
});
it('sends a message and renders the user and agent reply', async () => {
renderWithProviders(<AgentChatPanel />);
const textarea = await screen.findByRole('textbox', { name: /ask.*agent/i });
fireEvent.change(textarea, { target: { value: 'What is 2+2?' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
// User message appears
await waitFor(() => expect(screen.getByText('What is 2+2?')).toBeInTheDocument());
// Agent reply appears
await waitFor(() => expect(screen.getByText('Hello from agent')).toBeInTheDocument());
expect(screen.getByText('You')).toBeInTheDocument();
expect(screen.getByText('Agent')).toBeInTheDocument();
});
it('calls openhumanAgentChat with the message text', async () => {
renderWithProviders(<AgentChatPanel />);
const textarea = await screen.findByRole('textbox', { name: /ask.*agent/i });
fireEvent.change(textarea, { target: { value: 'Tell me a joke' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() =>
expect(mockAgentChat).toHaveBeenCalledWith('Tell me a joke', undefined, 0.7)
);
});
it('passes model override when filled in', async () => {
renderWithProviders(<AgentChatPanel />);
const modelInput = await screen.findByRole('textbox', { name: /model/i });
fireEvent.change(modelInput, { target: { value: 'claude-sonnet-4-5' } });
const textarea = screen.getByRole('textbox', { name: /ask.*agent/i });
fireEvent.change(textarea, { target: { value: 'Hello' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() =>
expect(mockAgentChat).toHaveBeenCalledWith('Hello', 'claude-sonnet-4-5', 0.7)
);
});
it('passes temperature override when filled in', async () => {
renderWithProviders(<AgentChatPanel />);
const tempInput = await screen.findByRole('textbox', { name: /temperature/i });
fireEvent.change(tempInput, { target: { value: '0.2' } });
const textarea = screen.getByRole('textbox', { name: /ask.*agent/i });
fireEvent.change(textarea, { target: { value: 'Hello' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() => expect(mockAgentChat).toHaveBeenCalledWith('Hello', undefined, 0.2));
});
it('shows an error banner when openhumanAgentChat rejects', async () => {
mockAgentChat.mockRejectedValueOnce(new Error('core offline'));
renderWithProviders(<AgentChatPanel />);
const textarea = await screen.findByRole('textbox', { name: /ask.*agent/i });
fireEvent.change(textarea, { target: { value: 'Test message' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() => expect(screen.getByText('core offline')).toBeInTheDocument());
});
it('clears the input field after sending', async () => {
renderWithProviders(<AgentChatPanel />);
const textarea = (await screen.findByRole('textbox', {
name: /ask.*agent/i,
})) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'Something to send' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() => expect(textarea.value).toBe(''));
});
it('disables the Send button while a request is in flight', async () => {
let resolveSend!: (v: { result: string; logs: never[] }) => void;
mockAgentChat.mockReturnValueOnce(
new Promise(r => {
resolveSend = r;
})
);
renderWithProviders(<AgentChatPanel />);
const textarea = await screen.findByRole('textbox', { name: /ask.*agent/i });
fireEvent.change(textarea, { target: { value: 'In flight?' } });
const sendBtn = screen.getByRole('button', { name: /send/i });
fireEvent.click(sendBtn);
// While in-flight the button is disabled (label changes to Loading)
await waitFor(() => expect(sendBtn).toBeDisabled());
resolveSend({ result: 'done', logs: [] });
await waitFor(() => expect(sendBtn).not.toBeDisabled());
});
it('does not call openhumanAgentChat when input is blank', async () => {
renderWithProviders(<AgentChatPanel />);
const sendBtn = await screen.findByRole('button', { name: /send/i });
fireEvent.click(sendBtn);
await new Promise(r => setTimeout(r, 50));
expect(mockAgentChat).not.toHaveBeenCalled();
});
it('renders multiple conversation turns in order', async () => {
mockAgentChat
.mockResolvedValueOnce({ result: 'Reply to first', logs: [] })
.mockResolvedValueOnce({ result: 'Reply to second', logs: [] });
renderWithProviders(<AgentChatPanel />);
const textarea = await screen.findByRole('textbox', { name: /ask.*agent/i });
// First turn
fireEvent.change(textarea, { target: { value: 'First question' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() => expect(screen.getByText('Reply to first')).toBeInTheDocument());
// Second turn
fireEvent.change(textarea, { target: { value: 'Second question' } });
fireEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() => expect(screen.getByText('Reply to second')).toBeInTheDocument());
expect(screen.getByText('First question')).toBeInTheDocument();
expect(screen.getByText('Second question')).toBeInTheDocument();
});
});
@@ -0,0 +1,566 @@
/**
* AutocompleteDebugPanel coverage tests.
*
* Target uncovered lines (from diff-cover report):
* 497,501,505,509,513,517,521,525,529,537,539,547-548,555,574,584,591,594,598,
* 618,639,655,671,682,693,702,704,719-721,727,729,731,737,739-740,747
*
* The i18n mock returns the full key as-is (key-passthrough).
* Status values are rendered inline with labels; use container queries or
* partial-text selectors for split-text-node elements.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const {
mockIsTauri,
mockAutocompleteStatus,
mockGetConfig,
mockAutocompleteStart,
mockAutocompleteStop,
mockAutocompleteCurrent,
mockAutocompleteAccept,
mockAutocompleteDebugFocus,
mockAutocompleteSetStyle,
mockAutocompleteHistory,
mockAutocompleteClearHistory,
} = vi.hoisted(() => ({
mockIsTauri: vi.fn(() => true),
mockAutocompleteStatus: vi.fn(),
mockGetConfig: vi.fn(),
mockAutocompleteStart: vi.fn(),
mockAutocompleteStop: vi.fn(),
mockAutocompleteCurrent: vi.fn(),
mockAutocompleteAccept: vi.fn(),
mockAutocompleteDebugFocus: vi.fn(),
mockAutocompleteSetStyle: vi.fn(),
mockAutocompleteHistory: vi.fn(),
mockAutocompleteClearHistory: vi.fn(),
}));
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../../../utils/tauriCommands', () => ({
isTauri: mockIsTauri,
openhumanAutocompleteStatus: mockAutocompleteStatus,
openhumanGetConfig: mockGetConfig,
openhumanAutocompleteStart: mockAutocompleteStart,
openhumanAutocompleteStop: mockAutocompleteStop,
openhumanAutocompleteCurrent: mockAutocompleteCurrent,
openhumanAutocompleteAccept: mockAutocompleteAccept,
openhumanAutocompleteDebugFocus: mockAutocompleteDebugFocus,
openhumanAutocompleteSetStyle: mockAutocompleteSetStyle,
openhumanAutocompleteHistory: mockAutocompleteHistory,
openhumanAutocompleteClearHistory: mockAutocompleteClearHistory,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Assert that text appears anywhere in the document body.
* Use for status-row values that are rendered as bare text nodes inside a div
* that also contains the label and colon getByText can't isolate those.
*/
const expectInBody = (text: string) => expect(document.body.textContent).toContain(text);
const baseStatus = () => ({
phase: 'idle',
running: false,
enabled: true,
platform_supported: true,
debounce_ms: 120,
model_id: 'claude-haiku',
app_name: 'TextEdit',
last_error: null,
suggestion: null,
});
const baseConfig = () => ({
result: {
config: {
autocomplete: {
enabled: true,
debounce_ms: 150,
max_chars: 400,
style_preset: 'balanced',
style_instructions: 'be concise',
style_examples: ['example one', 'example two'],
disabled_apps: [],
accept_with_tab: true,
overlay_ttl_ms: 1100,
},
},
},
logs: [],
});
const statusResponse = (overrides: Record<string, unknown> = {}) => ({
result: { ...baseStatus(), ...overrides },
logs: ['[runtime] phase=idle'],
});
async function renderPanel() {
const { default: AutocompleteDebugPanel } = await import('../AutocompleteDebugPanel');
return render(<AutocompleteDebugPanel />);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AutocompleteDebugPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
mockAutocompleteStatus.mockResolvedValue(statusResponse());
mockGetConfig.mockResolvedValue(baseConfig());
mockAutocompleteHistory.mockResolvedValue({ result: { entries: [] }, logs: [] });
mockAutocompleteStart.mockResolvedValue({ result: { started: true }, logs: [] });
mockAutocompleteStop.mockResolvedValue({ result: {}, logs: [] });
mockAutocompleteCurrent.mockResolvedValue({
result: { suggestion: { value: 'hello world' } },
logs: [],
});
mockAutocompleteAccept.mockResolvedValue({
result: { accepted: true, value: 'hello world' },
logs: [],
});
mockAutocompleteDebugFocus.mockResolvedValue({
result: { app_name: 'TestApp', role: 'textField', context: 'some text here' },
logs: [],
});
mockAutocompleteSetStyle.mockResolvedValue({
result: {
config: {
debounce_ms: 150,
max_chars: 400,
overlay_ttl_ms: 1100,
style_instructions: 'be concise',
style_examples: ['example one'],
},
},
logs: [],
});
mockAutocompleteClearHistory.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Runtime section: section renders (lines 492530) ──────────────────────
it('renders runtime section heading (line 492)', async () => {
await renderPanel();
// The SettingsSection title is a unique text node, not split
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.appFilter.runtime')).toBeInTheDocument()
);
});
it('renders platform_supported row with yes value (lines 496-498)', async () => {
await renderPanel();
// Status values are text nodes inside a div that also holds the label.
// Use body.textContent checks inside waitFor to avoid split-node matching issues.
await waitFor(() => {
expectInBody('settings.autocomplete.appFilter.platformSupported');
expectInBody('common.yes');
});
});
it('renders common.no for false values (lines 497, 501, 505)', async () => {
mockAutocompleteStatus.mockResolvedValue(
statusResponse({ platform_supported: false, running: false, enabled: false })
);
await renderPanel();
await waitFor(() => {
expectInBody('common.no');
});
});
it('renders phase value from status (line 509)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ phase: 'composing' }));
await renderPanel();
await waitFor(() => expectInBody('composing'));
});
it('renders model_id from status (line 517)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ model_id: 'gpt-4o' }));
await renderPanel();
await waitFor(() => expectInBody('gpt-4o'));
});
it('shows notApplicable when model_id is null (line 517)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ model_id: null }));
await renderPanel();
await waitFor(() => expectInBody('settings.autocomplete.shared.notApplicable'));
});
it('renders app_name from status (line 521)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ app_name: 'CodeEditor' }));
await renderPanel();
await waitFor(() => expectInBody('CodeEditor'));
});
it('renders last_error value from status (line 525)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ last_error: 'context_limit' }));
await renderPanel();
await waitFor(() => expectInBody('context_limit'));
});
it('shows none when last_error is null (line 525)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ last_error: null }));
await renderPanel();
// none appears for both last_error=null and suggestion=null
await waitFor(() => expectInBody('settings.autocomplete.shared.none'));
});
it('renders suggestion value from status (line 529)', async () => {
mockAutocompleteStatus.mockResolvedValue(
statusResponse({ suggestion: { value: 'suggested completion text' } })
);
await renderPanel();
await waitFor(() => expectInBody('suggested completion text'));
});
// ── Refresh status button (line 537) ──────────────────────────────────────
it('calls refreshStatus when Refresh Status button clicked (line 537)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.refreshStatus'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.refreshStatus'));
await waitFor(() => expect(mockAutocompleteStatus).toHaveBeenCalledTimes(2));
});
// ── Start / Stop buttons (lines 539, 547-548, 555) ────────────────────────
it('calls start when Start button clicked (line 547)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expect(mockAutocompleteStart).toHaveBeenCalled());
});
it('shows autocomplete.started after successful start', async () => {
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expect(screen.getByText('autocomplete.started')).toBeInTheDocument());
});
it('calls stop when Stop button clicked (line 555)', async () => {
mockAutocompleteStatus.mockResolvedValue(statusResponse({ running: true }));
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.stop'));
fireEvent.click(screen.getByText('autocomplete.stop'));
await waitFor(() => expect(mockAutocompleteStop).toHaveBeenCalled());
});
it('shows alreadyRunning when start returns not-started (line 263)', async () => {
mockAutocompleteStart.mockResolvedValue({ result: { started: false }, logs: [] });
// refreshStatus after start will show running=true
let callCount = 0;
mockAutocompleteStatus.mockImplementation(() => {
callCount++;
return Promise.resolve(statusResponse({ running: callCount > 1, enabled: true }));
});
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expectInBody('settings.autocomplete.debug.alreadyRunning'));
});
it('shows disabledInSettings when start fails and enabled=false (line 261)', async () => {
mockAutocompleteStart.mockResolvedValue({ result: { started: false }, logs: [] });
let callCount = 0;
mockAutocompleteStatus.mockImplementation(() => {
callCount++;
// After first load: enabled=true. After start attempt: enabled=false
return Promise.resolve(statusResponse({ running: false, enabled: callCount === 1 }));
});
await renderPanel();
await waitFor(() => screen.getByText('autocomplete.start'));
fireEvent.click(screen.getByText('autocomplete.start'));
await waitFor(() => expectInBody('settings.autocomplete.debug.disabledInSettings'));
});
// ── Test section: getSuggestion (lines 574, 584) ──────────────────────────
it('getSuggestion → shows suggestionPrefix (line 574)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.debug.suggestionPrefix')).toBeInTheDocument()
);
});
it('getSuggestion with null result → shows noSuggestionReturned (line 584)', async () => {
mockAutocompleteCurrent.mockResolvedValue({ result: { suggestion: null }, logs: [] });
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.debug.noSuggestionReturned')
).toBeInTheDocument()
);
});
// ── Test section: acceptSuggestion (lines 591, 594) ──────────────────────
it('acceptSuggestion accepted=true → shows acceptedPrefix (line 591)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.debug.acceptedPrefix')).toBeInTheDocument()
);
});
it('acceptSuggestion accepted=false with reason → shows reason (line 594)', async () => {
mockAutocompleteAccept.mockResolvedValue({
result: { accepted: false, reason: 'no suggestion pending', value: null },
logs: [],
});
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
await waitFor(() => expect(screen.getByText('no suggestion pending')).toBeInTheDocument());
});
it('acceptSuggestion accepted=false with no reason → noSuggestionApplied (line 594)', async () => {
mockAutocompleteAccept.mockResolvedValue({
result: { accepted: false, reason: null, value: null },
logs: [],
});
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.acceptSuggestion'));
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.debug.noSuggestionApplied')
).toBeInTheDocument()
);
});
// ── debugFocus (line 598) ──────────────────────────────────────────────────
it('debugFocus renders JSON pre with app_name content (line 598)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.debugFocus'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.debugFocus'));
// JSON.stringify result is rendered in a <pre>; check body textContent
await waitFor(() => expectInBody('"app_name"'));
});
it('debugFocus null result → focusDebug shows null JSON (line 598)', async () => {
mockAutocompleteDebugFocus.mockResolvedValue({ result: null, logs: [] });
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.debugFocus'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.debugFocus'));
await waitFor(() => expect(mockAutocompleteDebugFocus).toHaveBeenCalled());
// null result → focusDebug = "null", no app_name key in output
await waitFor(() => expect(document.body.textContent).not.toContain('"app_name"'));
});
// ── Live Logs section (line 618) ──────────────────────────────────────────
it('renders noLogs placeholder before any action (line 618)', async () => {
await renderPanel();
await waitFor(() =>
expect(screen.getByText('settings.autocomplete.appFilter.noLogs')).toBeInTheDocument()
);
});
it('clear logs button empties log display (line 618)', async () => {
await renderPanel();
// Perform an action to produce logs
await waitFor(() => screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
fireEvent.click(screen.getByText('settings.autocomplete.appFilter.getSuggestion'));
await waitFor(() => expect(mockAutocompleteCurrent).toHaveBeenCalled());
fireEvent.click(screen.getByText('common.clear'));
// After clear, noLogs placeholder appears
expect(screen.getByText('settings.autocomplete.appFilter.noLogs')).toBeInTheDocument();
});
// ── Advanced settings form (lines 639, 655, 671) ──────────────────────────
it('loads debounce_ms from config (line 639)', async () => {
await renderPanel();
await waitFor(() => {
const input = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.debounce',
}) as HTMLInputElement;
expect(input.value).toBe('150');
});
});
it('loads max_chars from config (line 655)', async () => {
await renderPanel();
await waitFor(() => {
const input = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.maxChars',
}) as HTMLInputElement;
expect(input.value).toBe('400');
});
});
it('loads overlay_ttl_ms from config (line 671)', async () => {
await renderPanel();
await waitFor(() => {
const input = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.overlayTtl',
}) as HTMLInputElement;
expect(input.value).toBe('1100');
});
});
it('updates debounce and calls setStyle on save (lines 682, 693)', async () => {
await renderPanel();
await waitFor(() =>
screen.getByRole('spinbutton', { name: 'settings.autocomplete.completionStyle.debounce' })
);
const debounceInput = screen.getByRole('spinbutton', {
name: 'settings.autocomplete.completionStyle.debounce',
});
fireEvent.change(debounceInput, { target: { value: '200' } });
fireEvent.click(screen.getByText('common.save'));
await waitFor(() => expect(mockAutocompleteSetStyle).toHaveBeenCalled());
expect(mockAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ debounce_ms: 200 })
);
});
it('shows autocomplete.settingsSaved after successful save (line 702)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('common.save'));
fireEvent.click(screen.getByText('common.save'));
await waitFor(() => expect(screen.getByText('autocomplete.settingsSaved')).toBeInTheDocument());
});
// ── History section (lines 719-721, 727, 729, 731, 737, 739-740, 747) ─────
it('renders noHistory when entries list empty (line 727)', async () => {
await renderPanel();
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.noHistory')
).toBeInTheDocument()
);
});
it('renders acceptedCompletion (singular) for 1 entry (line 729)', async () => {
mockAutocompleteHistory.mockResolvedValue({
result: {
entries: [
{
timestamp_ms: 1700000000000,
app_name: 'TextEdit',
context: 'context here',
suggestion: 'my suggestion',
},
],
},
logs: [],
});
await renderPanel();
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.acceptedCompletion')
).toBeInTheDocument()
);
});
it('renders acceptedCompletions (plural) and entry rows for 2 entries (lines 731, 737, 739-740)', async () => {
mockAutocompleteHistory.mockResolvedValue({
result: {
entries: [
{
timestamp_ms: 1700000000000,
app_name: 'TestEditor',
context: 'some context text ending here',
suggestion: 'suggested completion',
},
{
timestamp_ms: 1700000001000,
app_name: null,
context: 'another context',
suggestion: 'another suggestion',
},
],
},
logs: [],
});
await renderPanel();
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.acceptedCompletions')
).toBeInTheDocument()
);
expect(screen.getByText('suggested completion')).toBeInTheDocument();
expect(screen.getByText('another suggestion')).toBeInTheDocument();
expect(screen.getByText('TestEditor')).toBeInTheDocument();
});
it('clear history button disabled when entries empty (line 719)', async () => {
await renderPanel();
await waitFor(() => screen.getByText('settings.autocomplete.completionStyle.clearHistory'));
const btn = screen
.getByText('settings.autocomplete.completionStyle.clearHistory')
.closest('button') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('clears history entries on button click (lines 719-721, 747)', async () => {
mockAutocompleteHistory.mockResolvedValue({
result: {
entries: [
{
timestamp_ms: 1700000000000,
app_name: 'TestApp',
context: 'some context',
suggestion: 'a suggestion',
},
],
},
logs: [],
});
await renderPanel();
await waitFor(() => expect(screen.getByText('a suggestion')).toBeInTheDocument());
const clearBtn = screen
.getByText('settings.autocomplete.completionStyle.clearHistory')
.closest('button') as HTMLButtonElement;
expect(clearBtn.disabled).toBe(false);
fireEvent.click(clearBtn);
await waitFor(() => expect(mockAutocompleteClearHistory).toHaveBeenCalled());
await waitFor(() =>
expect(
screen.getByText('settings.autocomplete.completionStyle.noHistory')
).toBeInTheDocument()
);
});
});
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
@@ -132,9 +132,8 @@ describe('AutocompletePanel (simplified)', () => {
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Change style preset and save
const presetRow = screen.getByText('Style Preset').closest('label');
const presetSelect = presetRow?.querySelector('select') as HTMLSelectElement;
// Change style preset and save using the labeled select
const presetSelect = screen.getByRole('combobox', { name: 'Style Preset' });
fireEvent.change(presetSelect, { target: { value: 'concise' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
@@ -191,12 +190,9 @@ describe('AutocompletePanel (simplified)', () => {
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Toggle enabled off and save
const enabledLabel = screen.getByText('Enabled').closest('label');
const enabledCheckbox = enabledLabel?.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
fireEvent.click(enabledCheckbox);
// Toggle enabled off via SettingsSwitch (role="switch")
const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
fireEvent.click(enabledSwitch);
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
@@ -228,9 +224,14 @@ describe('AutocompletePanel (simplified)', () => {
await screen.findByText('Autocomplete');
const debounce = (await screen.findByTestId('autocomplete-debounce-ms')) as HTMLInputElement;
const maxChars = screen.getByTestId('autocomplete-max-chars') as HTMLInputElement;
const overlayTtl = screen.getByTestId('autocomplete-overlay-ttl-ms') as HTMLInputElement;
// SettingsNumberField wraps the input in a div; find the inner spinbutton
const debounceWrapper = await screen.findByTestId('autocomplete-debounce-ms');
const maxCharsWrapper = screen.getByTestId('autocomplete-max-chars');
const overlayTtlWrapper = screen.getByTestId('autocomplete-overlay-ttl-ms');
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
const overlayTtl = within(overlayTtlWrapper).getByRole('spinbutton') as HTMLInputElement;
// Seeded from loaded config.
await waitFor(() => expect(debounce.value).toBe('500'));
@@ -255,8 +256,11 @@ describe('AutocompletePanel (simplified)', () => {
await screen.findByText('Autocomplete');
const maxChars = (await screen.findByTestId('autocomplete-max-chars')) as HTMLInputElement;
const debounce = screen.getByTestId('autocomplete-debounce-ms') as HTMLInputElement;
const maxCharsWrapper = await screen.findByTestId('autocomplete-max-chars');
const debounceWrapper = screen.getByTestId('autocomplete-debounce-ms');
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
// Intermediate empty / zero states are preserved while typing (no snap).
fireEvent.change(maxChars, { target: { value: '' } });
@@ -274,4 +278,108 @@ describe('AutocompletePanel (simplified)', () => {
);
});
});
// ─── Disabled apps textarea ───────────────────────────────────────────────
it('seeds the disabled-apps textarea from config and saves changes', async () => {
runtime.config.disabled_apps = ['Slack', 'Zoom'];
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
const textarea = (await screen.findByRole('textbox', {
name: /disabled apps/i,
})) as HTMLTextAreaElement;
await waitFor(() => expect(textarea.value).toContain('Slack'));
expect(textarea.value).toContain('Zoom');
// Edit the textarea
fireEvent.change(textarea, { target: { value: 'Teams\nDiscord' } });
expect(textarea.value).toBe('Teams\nDiscord');
// Save includes updated disabled_apps
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ disabled_apps: ['Teams', 'Discord'] })
);
});
});
// ─── onCommit callbacks on number fields ──────────────────────────────────
it('committing a debounce value via Enter triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
const debounceWrapper = await screen.findByTestId('autocomplete-debounce-ms');
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
fireEvent.change(debounce, { target: { value: '300' } });
// onCommit fires when SettingsNumberField calls its onCommit prop; simulate by
// pressing Enter which SettingsNumberField forwards to onCommit.
fireEvent.keyDown(debounce, { key: 'Enter' });
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ debounce_ms: 300 })
);
});
});
it('committing a max-chars value triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
const maxCharsWrapper = await screen.findByTestId('autocomplete-max-chars');
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
fireEvent.change(maxChars, { target: { value: '512' } });
fireEvent.keyDown(maxChars, { key: 'Enter' });
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ max_chars: 512 })
);
});
});
it('committing an overlay-ttl value triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
const overlayWrapper = await screen.findByTestId('autocomplete-overlay-ttl-ms');
const overlayTtl = within(overlayWrapper).getByRole('spinbutton') as HTMLInputElement;
fireEvent.change(overlayTtl, { target: { value: '2500' } });
fireEvent.keyDown(overlayTtl, { key: 'Enter' });
await waitFor(() => {
expect(openhumanAutocompleteSetStyle).toHaveBeenCalledWith(
expect.objectContaining({ overlay_ttl_ms: 2500 })
);
});
});
// ─── didNotStart branch ───────────────────────────────────────────────────
it('shows "did not start" message when autocomplete start returns started=false', async () => {
vi.mocked(openhumanAutocompleteStart).mockResolvedValueOnce({
result: { started: false },
logs: [],
});
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await waitFor(() => expect(screen.getByText('Running: No')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
await waitFor(() => expect(openhumanAutocompleteStart).toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByText(/did not start|autocomplete did not/i)).toBeInTheDocument()
);
});
});
@@ -115,4 +115,86 @@ describe('AutonomyPanel', () => {
// Reverted to last committed value.
expect(input).toHaveValue(50);
});
// ─── Preset buttons ───────────────────────────────────────────────────────
test('clicking the 100 preset sets the draft to 100', async () => {
mockGet.mockResolvedValue({ result: autonomy(20), logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = (await screen.findByDisplayValue('20')) as HTMLInputElement;
fireEvent.click(screen.getByRole('button', { name: '100' }));
await waitFor(() => expect(input).toHaveValue(100));
expect(screen.getByRole('button', { name: /^Save$/ })).not.toBeDisabled();
});
test('clicking the 500 preset sets the draft to 500', async () => {
mockGet.mockResolvedValue({ result: autonomy(20), logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = (await screen.findByDisplayValue('20')) as HTMLInputElement;
fireEvent.click(screen.getByRole('button', { name: '500' }));
await waitFor(() => expect(input).toHaveValue(500));
});
test('clicking the 1000 preset sets the draft to 1000', async () => {
mockGet.mockResolvedValue({ result: autonomy(20), logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = (await screen.findByDisplayValue('20')) as HTMLInputElement;
fireEvent.click(screen.getByRole('button', { name: '1000' }));
await waitFor(() => expect(input).toHaveValue(1000));
});
test('clicking Unlimited preset sets draft to UNLIMITED sentinel and shows note', async () => {
mockGet.mockResolvedValue({ result: autonomy(20), logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
await screen.findByDisplayValue('20');
// The Unlimited preset button uses i18n key autonomy.presetUnlimited
const unlimitedBtn = screen.getByRole('button', { name: /unlimited/i });
fireEvent.click(unlimitedBtn);
// The input value should be set to the UNLIMITED sentinel (4294967295)
const input = screen.getByRole('spinbutton') as HTMLInputElement;
await waitFor(() => expect(Number(input.value)).toBe(4_294_967_295));
// Save button enabled because value changed
expect(screen.getByRole('button', { name: /^Save$/ })).not.toBeDisabled();
});
// ─── Status transitions on re-edit ───────────────────────────────────────
test('editing the field after save clears the saved status', async () => {
mockGet.mockResolvedValue({ result: autonomy(20), logs: [] });
mockUpdate.mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
});
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = await screen.findByDisplayValue('20');
fireEvent.change(input, { target: { value: '300' } });
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await screen.findByText(/Saved\./i);
// Now edit again — saved note disappears and Save re-enables
fireEvent.change(input, { target: { value: '400' } });
await waitFor(() => expect(screen.queryByText(/Saved\./i)).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /^Save$/ })).not.toBeDisabled();
});
test('editing the field after error clears the error status', async () => {
mockGet.mockResolvedValue({ result: autonomy(50), logs: [] });
mockUpdate.mockRejectedValue(new Error('disk full'));
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = await screen.findByDisplayValue('50');
fireEvent.change(input, { target: { value: '500' } });
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await screen.findByText(/Failed: disk full/);
// Re-edit clears the error
fireEvent.change(input, { target: { value: '200' } });
await waitFor(() => expect(screen.queryByText(/Failed: disk full/)).not.toBeInTheDocument());
});
});
@@ -123,7 +123,9 @@ describe('DeveloperOptionsPanel — CoreModeBadge', () => {
{ preloadedState: { locale: { current: 'zh-CN' } } }
);
expect(screen.getByText('AI 配置')).toBeInTheDocument();
// 'AI 配置' was removed from Developer Options in Pass A (moved to the AI
// section page). Assert a destination that IS present: 智能 (Intelligence).
expect(screen.getByText('智能')).toBeInTheDocument();
// Two screen-awareness rows now exist (the moved settings row + the debug
// panel), which collapse to the same zh-CN label — assert at least one.
expect(screen.getAllByText('屏幕感知').length).toBeGreaterThan(0);
@@ -88,11 +88,13 @@ describe('DevicesPanel', () => {
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText("Charlie's iPhone");
fireEvent.click(screen.getByRole('button', { name: /Revoke/i }));
// Click the per-row revoke button (may be many; pick the first one)
fireEvent.click(screen.getAllByRole('button', { name: /Revoke/i })[0]);
// Confirmation dialog
expect(await screen.findByText('Revoke device?')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Revoke$/i }));
// Use testid to unambiguously target the dialog confirm button
fireEvent.click(screen.getByTestId('confirm-revoke-btn'));
await waitFor(() => {
expect(mockCall).toHaveBeenCalledWith(
@@ -154,4 +156,26 @@ describe('DevicesPanel', () => {
expect(await screen.findByText(/Failed to load devices/)).toBeInTheDocument();
});
it('shows the online status indicator when a peer is online', async () => {
const device = makeDevice({ label: 'Online iPhone', peer_online: true });
mockCall.mockResolvedValue(listResponse([device]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText('Online iPhone');
expect(screen.getByTestId('peer-status-online')).toBeInTheDocument();
expect(screen.queryByTestId('peer-status-offline')).not.toBeInTheDocument();
});
it('shows the offline status indicator when a peer is offline', async () => {
const device = makeDevice({ label: 'Offline iPhone', peer_online: false });
mockCall.mockResolvedValue(listResponse([device]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText('Offline iPhone');
expect(screen.getByTestId('peer-status-offline')).toBeInTheDocument();
expect(screen.queryByTestId('peer-status-online')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,709 @@
/**
* EmbeddingsPanel unit tests covers the uncovered changed lines:
* 353-355, 361, 363, 365, 374-376, 386, 430, 432-433, 451, 453-454,
* 470, 489, 491, 596, 659, 701, 704
*
* Exercises: provider radio selection, setup popup (API key entry, test,
* save), confirm-wipe dialog, clear-key flow, test-connection flow, and
* model / dimensions selects.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearEmbeddingsApiKey,
type EmbeddingProviderEntry,
type EmbeddingsSettings,
loadEmbeddingsSettings,
setEmbeddingsApiKey,
testEmbeddingsConnection,
updateEmbeddingsSettings,
} from '../../../../services/api/embeddingsApi';
import { renderWithProviders } from '../../../../test/test-utils';
import EmbeddingsPanel from '../EmbeddingsPanel';
vi.mock('../../../../services/api/embeddingsApi', () => ({
loadEmbeddingsSettings: vi.fn(),
updateEmbeddingsSettings: vi.fn(),
setEmbeddingsApiKey: vi.fn(),
clearEmbeddingsApiKey: vi.fn(),
testEmbeddingsConnection: vi.fn(),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: vi.fn(),
breadcrumbs: [],
}),
}));
const makeProvider = (
slug: string,
overrides: Partial<EmbeddingProviderEntry> = {}
): EmbeddingProviderEntry => ({
slug,
label: slug.charAt(0).toUpperCase() + slug.slice(1),
description: `${slug} embeddings provider`,
requires_api_key: false,
requires_endpoint: false,
has_api_key: false,
models: [
{
id: `${slug}-model-v1`,
label: `${slug} Model v1`,
default_dimensions: 1536,
allowed_dimensions: [768, 1536],
},
],
...overrides,
});
const makeSettings = (overrides: Partial<EmbeddingsSettings> = {}): EmbeddingsSettings => ({
provider: 'managed',
model: 'managed-model-v1',
dimensions: 1536,
rate_limit_per_min: 60,
vector_search_enabled: true,
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true }),
makeProvider('custom', { requires_api_key: false, requires_endpoint: true }),
],
...overrides,
});
describe('EmbeddingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(makeSettings());
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({
provider: 'managed',
model: 'managed-model-v1',
dimensions: 1536,
});
vi.mocked(setEmbeddingsApiKey).mockResolvedValue(undefined);
vi.mocked(clearEmbeddingsApiKey).mockResolvedValue(undefined);
vi.mocked(testEmbeddingsConnection).mockResolvedValue({
success: true,
provider: 'managed',
model: 'managed-model-v1',
actual_dimensions: 1536,
});
});
// ─── Initial load ─────────────────────────────────────────────────────────
it('loads and renders provider options', async () => {
renderWithProviders(<EmbeddingsPanel />);
expect(await screen.findByText('Managed')).toBeInTheDocument();
expect(screen.getByText('Openai')).toBeInTheDocument();
expect(screen.getByText('Custom')).toBeInTheDocument();
});
it('shows loading state then settings', async () => {
let resolveLoad!: (s: EmbeddingsSettings) => void;
vi.mocked(loadEmbeddingsSettings).mockReturnValue(
new Promise(r => {
resolveLoad = r;
})
);
renderWithProviders(<EmbeddingsPanel />);
// loading placeholder visible
expect(screen.getByText(/loading/i)).toBeInTheDocument();
resolveLoad(makeSettings());
expect(await screen.findByText('Managed')).toBeInTheDocument();
});
it('shows error state when loadEmbeddingsSettings rejects', async () => {
vi.mocked(loadEmbeddingsSettings).mockRejectedValueOnce(new Error('network error'));
renderWithProviders(<EmbeddingsPanel />);
expect(await screen.findByText('network error')).toBeInTheDocument();
});
// ─── Provider selection — no API key needed ───────────────────────────────
it('clicking a provider that needs no API key calls updateEmbeddingsSettings', async () => {
// Start with openai selected so managed is a valid switch target
const settings = makeSettings({
provider: 'openai',
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: true }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
// Click managed (no API key required, different from current)
const managedBtn = screen.getByRole('radio', { name: /managed/i });
fireEvent.click(managedBtn);
await waitFor(() =>
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'managed', confirm_wipe: false })
)
);
});
it('clicking the already-selected provider is a no-op', async () => {
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
const managedBtn = screen.getByRole('radio', { name: /managed/i });
fireEvent.click(managedBtn);
// No RPC — already selected
await new Promise(r => setTimeout(r, 50));
expect(vi.mocked(updateEmbeddingsSettings)).not.toHaveBeenCalled();
});
// ─── Setup popup — API key entry ──────────────────────────────────────────
it('clicking a provider that requires an API key opens the setup popup', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: false }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Openai');
fireEvent.click(screen.getByRole('radio', { name: /openai/i }));
// Setup popup appears with "Set up Openai" heading
await waitFor(() =>
expect(screen.getByRole('heading', { name: /set up openai/i })).toBeInTheDocument()
);
});
it('can enter an API key and click Save & Switch to persist and switch provider', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: false }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({ provider: 'openai' });
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Openai');
fireEvent.click(screen.getByRole('radio', { name: /openai/i }));
await screen.findByRole('heading', { name: /set up openai/i });
// Enter key
const keyInput = screen.getByPlaceholderText(/paste your api key/i);
fireEvent.change(keyInput, { target: { value: 'sk-test-openai-key-12345' } });
// Click Save & Switch
fireEvent.click(screen.getByRole('button', { name: /save.*switch/i }));
await waitFor(() => expect(vi.mocked(setEmbeddingsApiKey)).toHaveBeenCalled());
await waitFor(() =>
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'openai' })
)
);
});
it('can click Test Connection inside the setup popup', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: false }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(testEmbeddingsConnection).mockResolvedValue({
success: true,
provider: 'openai',
model: 'openai-model-v1',
actual_dimensions: 1536,
});
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Openai');
fireEvent.click(screen.getByRole('radio', { name: /openai/i }));
await screen.findByRole('heading', { name: /set up openai/i });
const keyInput = screen.getByPlaceholderText(/paste your api key/i);
fireEvent.change(keyInput, { target: { value: 'sk-test-key-abcdefgh' } });
// There are two "Test connection" buttons (popup + outside panel); click the
// first one which belongs to the setup popup (popup renders at end of DOM
// but buttons are ordered — take first match which is the popup one).
const testBtns = screen.getAllByRole('button', { name: /test connection/i });
// The popup's Test Connection is disabled when key is empty (but we filled it),
// and the outside one is enabled too — click the popup footer btn which is last
fireEvent.click(testBtns[testBtns.length - 1]);
await waitFor(() => expect(vi.mocked(testEmbeddingsConnection)).toHaveBeenCalled());
});
it('the setup popup Cancel button closes the popup without persisting', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: false }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Openai');
fireEvent.click(screen.getByRole('radio', { name: /openai/i }));
await screen.findByRole('heading', { name: /set up openai/i });
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
await waitFor(() =>
expect(screen.queryByRole('heading', { name: /set up openai/i })).not.toBeInTheDocument()
);
expect(vi.mocked(updateEmbeddingsSettings)).not.toHaveBeenCalled();
});
it('the show/hide key toggle inside the popup toggles input type', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: false }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Openai');
fireEvent.click(screen.getByRole('radio', { name: /openai/i }));
await screen.findByRole('heading', { name: /set up openai/i });
const keyInput = screen.getByPlaceholderText(/paste your api key/i) as HTMLInputElement;
expect(keyInput.type).toBe('password');
// Toggle show
fireEvent.click(screen.getByRole('button', { name: /show/i }));
expect(keyInput.type).toBe('text');
// Toggle hide
fireEvent.click(screen.getByRole('button', { name: /hide/i }));
expect(keyInput.type).toBe('password');
});
// ─── Custom provider popup ─────────────────────────────────────────────────
it('clicking the custom provider opens the custom endpoint form', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('custom', { requires_api_key: false, requires_endpoint: true }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Custom');
fireEvent.click(screen.getByRole('radio', { name: /custom/i }));
// Custom popup has an endpoint input
await waitFor(() =>
expect(screen.getByPlaceholderText(/https:\/\/your-endpoint/i)).toBeInTheDocument()
);
});
it('can fill and save the custom endpoint form', async () => {
const settings = makeSettings({
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('custom', { requires_api_key: false, requires_endpoint: true }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({ provider: 'custom' });
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Custom');
fireEvent.click(screen.getByRole('radio', { name: /custom/i }));
await screen.findByPlaceholderText(/https:\/\/your-endpoint/i);
const endpointInput = screen.getByPlaceholderText(/https:\/\/your-endpoint/i);
fireEvent.change(endpointInput, { target: { value: 'https://my-embeddings.example.com/v1' } });
const saveBtn = screen.getByRole('button', { name: /save.*switch/i });
fireEvent.click(saveBtn);
await waitFor(() =>
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'custom',
custom_endpoint: 'https://my-embeddings.example.com/v1',
})
)
);
});
// ─── Confirm wipe dialog ──────────────────────────────────────────────────
it('shows confirm-wipe dialog when updateEmbeddingsSettings returns DIMENSION_CHANGE error', async () => {
const settings = makeSettings({
provider: 'openai',
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: true }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({
error: 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE',
});
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
fireEvent.click(screen.getByRole('radio', { name: /managed/i }));
// Wipe confirmation dialog appears
await waitFor(() =>
expect(screen.getByRole('heading', { name: /reset memory vectors/i })).toBeInTheDocument()
);
});
it('confirm wipe calls updateEmbeddingsSettings with confirm_wipe: true', async () => {
const settings = makeSettings({
provider: 'openai',
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: true }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
// First call: wipe required; second: success
vi.mocked(updateEmbeddingsSettings)
.mockResolvedValueOnce({ error: 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE' })
.mockResolvedValueOnce({ provider: 'managed' });
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
fireEvent.click(screen.getByRole('radio', { name: /managed/i }));
await waitFor(() =>
expect(screen.getByRole('heading', { name: /reset memory vectors/i })).toBeInTheDocument()
);
// Click the confirm/wipe button (i18n key: settings.embeddings.confirmWipe = 'Wipe & apply')
const confirmBtn = screen.getByRole('button', { name: /wipe.*apply|wipe & apply/i });
fireEvent.click(confirmBtn);
await waitFor(() =>
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledWith(
expect.objectContaining({ confirm_wipe: true })
)
);
});
it('cancel wipe dialog closes it without a second RPC call', async () => {
const settings = makeSettings({
provider: 'openai',
providers: [
makeProvider('managed', { requires_api_key: false }),
makeProvider('openai', { requires_api_key: true, has_api_key: true }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({
error: 'EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE',
});
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
fireEvent.click(screen.getByRole('radio', { name: /managed/i }));
await screen.findByRole('heading', { name: /reset memory vectors/i });
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
await waitFor(() =>
expect(
screen.queryByRole('heading', { name: /reset memory vectors/i })
).not.toBeInTheDocument()
);
// Only 1 RPC call — the one that returned wipe-required
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledTimes(1);
});
// ─── Clear API key button ─────────────────────────────────────────────────
it('clicking Clear API key calls clearEmbeddingsApiKey and reloads', async () => {
const settings = makeSettings({
provider: 'openai',
providers: [
makeProvider('openai', {
requires_api_key: true,
has_api_key: true,
models: [
{
id: 'openai-model-v1',
label: 'OpenAI Model v1',
default_dimensions: 1536,
allowed_dimensions: [1536],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Openai');
// The "Clear API key" button is rendered only when currentEntry has_api_key=true
const clearBtn = await screen.findByRole('button', { name: /clear api key/i });
fireEvent.click(clearBtn);
await waitFor(() => expect(vi.mocked(clearEmbeddingsApiKey)).toHaveBeenCalledWith('openai'));
});
// ─── Test Connection button (outside popup) ───────────────────────────────
it('clicking Test Connection (outside popup) calls testEmbeddingsConnection', async () => {
const settings = makeSettings({
provider: 'managed',
providers: [
makeProvider('managed', {
requires_api_key: false,
models: [
{
id: 'managed-model-v1',
label: 'Managed Model v1',
default_dimensions: 1536,
allowed_dimensions: [1536],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
const testBtn = await screen.findByRole('button', { name: /test connection/i });
fireEvent.click(testBtn);
await waitFor(() => expect(vi.mocked(testEmbeddingsConnection)).toHaveBeenCalled());
});
it('shows error when testEmbeddingsConnection returns failure', async () => {
const settings = makeSettings({
provider: 'managed',
providers: [
makeProvider('managed', {
requires_api_key: false,
models: [
{
id: 'managed-model-v1',
label: 'Managed Model v1',
default_dimensions: 1536,
allowed_dimensions: [1536],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(testEmbeddingsConnection).mockResolvedValueOnce({
success: false,
provider: 'managed',
model: 'managed-model-v1',
error: 'Connection refused',
});
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('Managed');
const testBtn = await screen.findByRole('button', { name: /test connection/i });
fireEvent.click(testBtn);
await waitFor(() => expect(screen.getByText(/connection refused/i)).toBeInTheDocument());
});
// ─── Model select (multiple catalog models) ───────────────────────────────
it('shows model select when provider has multiple models', async () => {
const settings = makeSettings({
provider: 'openai',
model: 'openai-model-v1',
providers: [
makeProvider('openai', {
requires_api_key: true,
has_api_key: true,
models: [
{
id: 'openai-model-v1',
label: 'Model v1',
default_dimensions: 1536,
allowed_dimensions: [768, 1536],
},
{
id: 'openai-model-v2',
label: 'Model v2',
default_dimensions: 3072,
allowed_dimensions: [3072],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
const modelSelect = await screen.findByRole('combobox', { name: /model/i });
expect(modelSelect).toHaveValue('openai-model-v1');
expect(screen.getByText(/model v2/i)).toBeInTheDocument();
});
it('changing model select calls handleModelChange', async () => {
const settings = makeSettings({
provider: 'openai',
model: 'openai-model-v1',
providers: [
makeProvider('openai', {
requires_api_key: true,
has_api_key: true,
models: [
{
id: 'openai-model-v1',
label: 'Model v1',
default_dimensions: 1536,
allowed_dimensions: [768, 1536],
},
{
id: 'openai-model-v2',
label: 'Model v2',
default_dimensions: 3072,
allowed_dimensions: [3072],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({ model: 'openai-model-v2' });
renderWithProviders(<EmbeddingsPanel />);
const modelSelect = await screen.findByRole('combobox', { name: /model/i });
fireEvent.change(modelSelect, { target: { value: 'openai-model-v2' } });
await waitFor(() =>
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledWith(
expect.objectContaining({ model: 'openai-model-v2', confirm_wipe: false })
)
);
});
// ─── Dimensions select ────────────────────────────────────────────────────
it('shows dimensions select when provider model allows multiple dimensions', async () => {
const settings = makeSettings({
provider: 'openai',
model: 'openai-model-v1',
dimensions: 1536,
providers: [
makeProvider('openai', {
requires_api_key: true,
has_api_key: true,
models: [
{
id: 'openai-model-v1',
label: 'Model v1',
default_dimensions: 1536,
allowed_dimensions: [768, 1536],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
const dimsSelect = await screen.findByRole('combobox', { name: /dimensions/i });
expect(dimsSelect).toHaveValue('1536');
});
it('changing dimensions select calls handleDimsChange', async () => {
const settings = makeSettings({
provider: 'openai',
model: 'openai-model-v1',
dimensions: 1536,
providers: [
makeProvider('openai', {
requires_api_key: true,
has_api_key: true,
models: [
{
id: 'openai-model-v1',
label: 'Model v1',
default_dimensions: 1536,
allowed_dimensions: [768, 1536],
},
],
}),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
vi.mocked(updateEmbeddingsSettings).mockResolvedValue({ dimensions: 768 });
renderWithProviders(<EmbeddingsPanel />);
const dimsSelect = await screen.findByRole('combobox', { name: /dimensions/i });
fireEvent.change(dimsSelect, { target: { value: '768' } });
await waitFor(() =>
expect(vi.mocked(updateEmbeddingsSettings)).toHaveBeenCalledWith(
expect.objectContaining({ dimensions: 768, confirm_wipe: false })
)
);
});
// ─── "none" provider (vector search disabled banner) ─────────────────────
it('shows vector search disabled banner when selected provider is none', async () => {
const settings = makeSettings({
provider: 'none',
providers: [
makeProvider('none', { requires_api_key: false }),
makeProvider('managed', { requires_api_key: false }),
],
});
vi.mocked(loadEmbeddingsSettings).mockResolvedValue(settings);
renderWithProviders(<EmbeddingsPanel />);
await screen.findByText('None');
// Vector search disabled warning should appear
await waitFor(() => expect(screen.getByText(/vector search.*disabled/i)).toBeInTheDocument());
});
// ─── Embedded mode ────────────────────────────────────────────────────────
it('does not render the SettingsHeader in embedded mode', async () => {
renderWithProviders(<EmbeddingsPanel embedded />);
await screen.findByText('Managed');
// No header rendered in embedded mode
expect(screen.queryByRole('heading', { name: /embeddings/i })).not.toBeInTheDocument();
});
});
@@ -10,6 +10,12 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: mockNavigateBack, breadcrumbs: [] }),
}));
// No i18n mock: the context default resolves real English translations via resolveEn(),
// which is needed by existing tests that assert on actual English text (e.g. 'Test Connection',
// 'Reachable', 'http://localhost:11434' placeholder). New tests must also use real strings.
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
const mockGetConfig = vi.fn();
vi.mock('../../../../utils/tauriCommands/config', () => ({
openhumanGetConfig: (...args: unknown[]) => mockGetConfig(...args),
@@ -46,6 +52,25 @@ function renderPanel() {
);
}
const makeDiagnostics = (overrides: Record<string, unknown> = {}) => ({
ok: true,
ollama_running: false,
ollama_base_url: null,
ollama_binary_path: null,
installed_models: [],
expected: {
chat_model: 'llama3',
chat_found: false,
embedding_model: 'nomic-embed-text',
embedding_found: false,
vision_model: 'llava',
vision_found: false,
},
issues: [],
repair_actions: [],
...overrides,
});
describe('LocalModelDebugPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -53,23 +78,7 @@ describe('LocalModelDebugPanel', () => {
mockLocalAiAssetsStatus.mockResolvedValue({ result: null });
mockLocalAiDownloadsProgress.mockResolvedValue({ result: null });
mockGetConfig.mockResolvedValue({ result: { config: {} } });
mockLocalAiDiagnostics.mockResolvedValue({
ok: true,
ollama_running: false,
ollama_base_url: null,
ollama_binary_path: null,
installed_models: [],
expected: {
chat_model: '',
chat_found: false,
embedding_model: '',
embedding_found: false,
vision_model: '',
vision_found: false,
},
issues: [],
repair_actions: [],
});
mockLocalAiDiagnostics.mockResolvedValue(makeDiagnostics());
});
afterEach(() => {
@@ -154,4 +163,145 @@ describe('LocalModelDebugPanel', () => {
const urlInput = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement;
expect(urlInput.value).toBe('http://localhost:11434');
});
// ── statusTone function (line 53) — exercised via ModelStatusSection rendering
it('renders ready state tone (line 53 — statusTone "ready")', async () => {
mockLocalAiStatus.mockResolvedValue({
result: {
state: 'ready',
provider: 'ollama',
model_id: 'llama3',
active_backend: 'cpu',
last_latency_ms: 120,
gen_toks_per_sec: 15.5,
download_progress: null,
downloaded_bytes: null,
total_bytes: null,
download_speed_bps: null,
eta_seconds: null,
error_category: null,
error_detail: null,
warning: null,
backend_reason: null,
model_path: null,
},
});
renderPanel();
// 'Runtime Status' is the English translation of 'settings.localModel.status.runtimeStatus'
await waitFor(() => expect(screen.getByText('Runtime Status')).toBeInTheDocument());
// statusTone('ready') → 'text-green-600 dark:text-green-300' (just confirm no crash)
});
it('renders degraded state (line 53 — statusTone "degraded")', async () => {
mockLocalAiStatus.mockResolvedValue({
result: {
state: 'degraded',
provider: null,
model_id: null,
active_backend: 'cpu',
last_latency_ms: null,
gen_toks_per_sec: null,
download_progress: null,
downloaded_bytes: null,
total_bytes: null,
download_speed_bps: null,
eta_seconds: null,
error_category: 'install',
error_detail: 'checksum mismatch',
warning: null,
backend_reason: null,
model_path: null,
},
});
renderPanel();
await waitFor(() => expect(screen.getByText('Runtime Status')).toBeInTheDocument());
});
it('renders disabled state (line 53 — statusTone "disabled")', async () => {
mockLocalAiStatus.mockResolvedValue({
result: {
state: 'disabled',
provider: null,
model_id: null,
active_backend: null,
last_latency_ms: null,
gen_toks_per_sec: null,
download_progress: null,
downloaded_bytes: null,
total_bytes: null,
download_speed_bps: null,
eta_seconds: null,
error_category: null,
error_detail: null,
warning: null,
backend_reason: null,
model_path: null,
},
});
renderPanel();
await waitFor(() => expect(screen.getByText('Runtime Status')).toBeInTheDocument());
});
// ── ModelStatusSection: statusError display (line 405) ────────────────────
it('runs diagnostics via Run Diagnostics button', async () => {
const diagnostics = makeDiagnostics({
ok: true,
ollama_running: true,
ollama_base_url: 'http://localhost:11434',
ollama_binary_path: '/usr/local/bin/ollama',
installed_models: [
{
name: 'llama3:8b',
size: 4815162342,
eligibility: { status: 'ok', context_length: 8192, required: 2048 },
},
],
});
mockLocalAiDiagnostics.mockResolvedValue(diagnostics);
renderPanel();
// 'Run Diagnostics' is the English translation of 'settings.localModel.status.runDiagnostics'
fireEvent.click(screen.getByText('Run Diagnostics'));
await waitFor(() => expect(mockLocalAiDiagnostics).toHaveBeenCalled());
// After diagnostics run: 'All checks passed' (translation of 'settings.localModel.status.allChecksPassed')
await waitFor(() => expect(screen.getByText('All checks passed')).toBeInTheDocument());
});
it('renders installed models with eligibility badge (line 446 — diagnostics flow)', async () => {
mockLocalAiDiagnostics.mockResolvedValue(
makeDiagnostics({
ok: false,
ollama_running: true,
installed_models: [
{
name: 'tiny-model',
size: 100000,
eligibility: { status: 'below_minimum', context_length: 512, required: 2048 },
},
],
issues: ['Chat model not installed'],
})
);
renderPanel();
fireEvent.click(screen.getByText('Run Diagnostics'));
await waitFor(() => expect(screen.getByText('tiny-model')).toBeInTheDocument());
expect(screen.getByText(/512/)).toBeInTheDocument();
});
it('shows issues found when diagnostics has issues (line 446)', async () => {
mockLocalAiDiagnostics.mockResolvedValue(
makeDiagnostics({ ok: false, issues: ['Chat model not found', 'Embedding model not found'] })
);
renderPanel();
fireEvent.click(screen.getByText('Run Diagnostics'));
await waitFor(() => expect(screen.getByText('Chat model not found')).toBeInTheDocument());
expect(screen.getByText('Embedding model not found')).toBeInTheDocument();
});
});
@@ -9,21 +9,28 @@ import mascotReducer, {
DEFAULT_MASCOT_COLOR,
setCustomMascotGifUrl,
setMascotColor,
setMascotVoiceId,
setSelectedMascotId,
} from '../../../../store/mascotSlice';
import MascotPanel from '../MascotPanel';
const { mockNavigateBack, fetchMascotListMock, getCachedMascotDetailMock } = vi.hoisted(() => ({
mockNavigateBack: vi.fn(),
fetchMascotListMock: vi.fn(),
getCachedMascotDetailMock: vi.fn(),
}));
const { mockNavigateBack, fetchMascotListMock, getCachedMascotDetailMock, mockSynthesizeSpeech } =
vi.hoisted(() => ({
mockNavigateBack: vi.fn(),
fetchMascotListMock: vi.fn(),
getCachedMascotDetailMock: vi.fn(),
mockSynthesizeSpeech: vi.fn(),
}));
vi.mock('../../../../services/mascotService', () => ({
fetchMascotList: (...args: unknown[]) => fetchMascotListMock(...args),
getCachedMascotDetail: (...args: unknown[]) => getCachedMascotDetailMock(...args),
}));
vi.mock('../../../../features/human/voice/ttsClient', () => ({
synthesizeSpeech: (...args: unknown[]) => mockSynthesizeSpeech(...args),
}));
vi.mock('../../../../features/human/Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('../../../../features/human/Mascot')>();
return {
@@ -70,6 +77,7 @@ describe('MascotPanel', () => {
vi.clearAllMocks();
fetchMascotListMock.mockResolvedValue([]);
getCachedMascotDetailMock.mockResolvedValue(null);
mockSynthesizeSpeech.mockResolvedValue(new Uint8Array(0));
});
it('renders a radio swatch for each supported color', () => {
@@ -272,3 +280,61 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => {
});
});
});
// ── Voice picker: save-paste button disabled state (line 525) ────────────────
describe('MascotPanel — voice picker custom voice input (line 525)', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMascotListMock.mockResolvedValue([]);
getCachedMascotDetailMock.mockResolvedValue(null);
mockSynthesizeSpeech.mockResolvedValue(new Uint8Array(0));
});
it('shows save-paste button when a non-curated (custom) voice id is stored', () => {
// A non-curated voice id triggers isCustomVoice=true automatically
// without needing to select __custom__ in the picker.
const store = buildStore();
store.dispatch(setMascotVoiceId('custom-voice-id-xyz'));
renderPanel(store);
// The custom voice input section is visible
const saveBtn = screen.getByTestId('mascot-voice-save-paste');
expect(saveBtn).toBeInTheDocument();
});
it('save-paste button is disabled when draft matches stored voice id (line 525)', () => {
const store = buildStore();
store.dispatch(setMascotVoiceId('custom-voice-id-xyz'));
renderPanel(store);
// Draft defaults to storedVoiceId — so draft === storedVoiceId → disabled
const saveBtn = screen.getByTestId('mascot-voice-save-paste');
expect(saveBtn).toBeDisabled();
});
it('save-paste button is enabled when draft differs from stored voice id (line 525)', () => {
const store = buildStore();
store.dispatch(setMascotVoiceId('custom-voice-id-xyz'));
renderPanel(store);
const input = screen.getByTestId('mascot-voice-input');
fireEvent.change(input, { target: { value: 'different-voice-id' } });
const saveBtn = screen.getByTestId('mascot-voice-save-paste');
expect(saveBtn).not.toBeDisabled();
});
it('clicking save-paste button dispatches new voice id to store', () => {
const store = buildStore();
store.dispatch(setMascotVoiceId('custom-voice-id-xyz'));
renderPanel(store);
const input = screen.getByTestId('mascot-voice-input');
fireEvent.change(input, { target: { value: 'new-voice-id' } });
const saveBtn = screen.getByTestId('mascot-voice-save-paste');
fireEvent.click(saveBtn);
expect(store.getState().mascot.voiceId).toBe('new-voice-id');
});
});
@@ -1,5 +1,19 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
/**
* MemoryDebugPanel coverage tests.
*
* Target uncovered lines (from diff-cover report):
* 215,224,234-235,245,256-257,282,288,290-291,309,316,325,338-340,346,348,354,
* 356,362,364,370,376,382,399,403,407-408,417,427,429
*
* These cover:
* - Documents section: list rendering, doc row (with/without title), delete btn
* - Namespaces section: list rendering, empty state
* - Query & Recall: query button, recall button, queryResult/recallResult rendering
* - Clear Namespace section: select when namespaces exist, text field fallback,
* clear success/failure paths
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
@@ -10,25 +24,336 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../../intelligence/MemoryTextWithEntities', () => ({
MemoryTextWithEntities: ({ text }: { text: string }) => <span>{text}</span>,
MemoryTextWithEntities: ({ text }: { text: string }) => (
<span data-testid="mem-text">{text}</span>
),
}));
const {
mockListDocuments,
mockListNamespaces,
mockDeleteDocument,
mockQueryNamespace,
mockRecallNamespace,
mockClearNamespace,
} = vi.hoisted(() => ({
mockListDocuments: vi.fn(),
mockListNamespaces: vi.fn(),
mockDeleteDocument: vi.fn(),
mockQueryNamespace: vi.fn(),
mockRecallNamespace: vi.fn(),
mockClearNamespace: vi.fn(),
}));
vi.mock('../../../../utils/tauriCommands', () => ({
memoryClearNamespace: vi.fn().mockResolvedValue({}),
memoryDeleteDocument: vi.fn().mockResolvedValue({}),
memoryListDocuments: vi.fn().mockResolvedValue({ data: { documents: [] } }),
memoryListNamespaces: vi.fn().mockResolvedValue([]),
memoryQueryNamespace: vi.fn().mockResolvedValue({ matches: [] }),
memoryRecallNamespace: vi.fn().mockResolvedValue({ matches: [] }),
memoryClearNamespace: (...args: unknown[]) => mockClearNamespace(...args),
memoryDeleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
memoryListDocuments: (...args: unknown[]) => mockListDocuments(...args),
memoryListNamespaces: (...args: unknown[]) => mockListNamespaces(...args),
memoryQueryNamespace: (...args: unknown[]) => mockQueryNamespace(...args),
memoryRecallNamespace: (...args: unknown[]) => mockRecallNamespace(...args),
}));
describe('MemoryDebugPanel stable test hooks', () => {
it('renders the panel-level test id', async () => {
// normalizeMemoryDocuments returns MemoryDebugDocument[] from raw response.
// Mock it to pass through the data we supply.
vi.mock('../memoryDebugUtils', () => ({
normalizeMemoryDocuments: (raw: unknown) => {
if (!raw || typeof raw !== 'object') return [];
const r = raw as { documents?: unknown[] };
if (!Array.isArray(r.documents)) return [];
return r.documents;
},
}));
// ------------------------------------------------------------------
// Factory helpers
// ------------------------------------------------------------------
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
documentId: 'doc-alpha',
namespace: 'ns-test',
title: null,
...overrides,
});
const makeRawPayload = (docs: unknown[] = []) => ({ documents: docs });
const queryResult = { text: 'query result text here', entities: [] };
const recallResult = { text: 'recall result text here', entities: [] };
describe('MemoryDebugPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: empty state
mockListDocuments.mockResolvedValue(makeRawPayload());
mockListNamespaces.mockResolvedValue([]);
mockDeleteDocument.mockResolvedValue({});
mockQueryNamespace.mockResolvedValue(queryResult);
mockRecallNamespace.mockResolvedValue(recallResult);
mockClearNamespace.mockResolvedValue({ cleared: true, namespace: 'ns-test' });
});
async function renderPanel() {
const { default: MemoryDebugPanel } = await import('../MemoryDebugPanel');
return render(<MemoryDebugPanel />);
}
render(<MemoryDebugPanel />);
// ── Documents section ──────────────────────────────────────────────────────
it('renders the panel root test-id (smoke test)', async () => {
await renderPanel();
expect(screen.getByTestId('memory-debug-panel')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('memory.documents')).toBeInTheDocument());
});
it('renders empty state when no documents (line 234)', async () => {
mockListDocuments.mockResolvedValue(makeRawPayload([]));
await renderPanel();
await waitFor(() => expect(screen.getByText('memory.noDocumentsFound')).toBeInTheDocument());
});
it('renders document rows with id and namespace (lines 234-235, 239, 242)', async () => {
const doc = makeDoc({ documentId: 'doc-beta', namespace: 'ns-alpha', title: null });
mockListDocuments.mockResolvedValue(makeRawPayload([doc]));
await renderPanel();
await waitFor(() => expect(screen.getByText('doc-beta')).toBeInTheDocument());
expect(screen.getByText('ns-alpha')).toBeInTheDocument();
});
it('renders optional title when doc has one (line 245-248)', async () => {
const doc = makeDoc({
documentId: 'doc-gamma',
namespace: 'ns-beta',
title: 'My document title',
});
mockListDocuments.mockResolvedValue(makeRawPayload([doc]));
await renderPanel();
await waitFor(() => expect(screen.getByText('My document title')).toBeInTheDocument());
});
it('calls memoryDeleteDocument after confirm dialog (lines 256-257, 282)', async () => {
const doc = makeDoc({ documentId: 'doc-delta', namespace: 'ns-gamma' });
mockListDocuments.mockResolvedValue(makeRawPayload([doc]));
// Mock window.confirm to auto-accept
vi.spyOn(window, 'confirm').mockReturnValue(true);
await renderPanel();
await waitFor(() => expect(screen.getByText('doc-delta')).toBeInTheDocument());
fireEvent.click(screen.getByText('memory.delete'));
await waitFor(() => expect(mockDeleteDocument).toHaveBeenCalledWith('doc-delta', 'ns-gamma'));
});
it('does not delete when confirm is cancelled (line 282)', async () => {
const doc = makeDoc({ documentId: 'doc-epsilon', namespace: 'ns-delta' });
mockListDocuments.mockResolvedValue(makeRawPayload([doc]));
vi.spyOn(window, 'confirm').mockReturnValue(false);
await renderPanel();
await waitFor(() => screen.getByText('memory.delete'));
fireEvent.click(screen.getByText('memory.delete'));
expect(mockDeleteDocument).not.toHaveBeenCalled();
});
// ── Namespaces section ─────────────────────────────────────────────────────
it('renders empty state when no namespaces (line 299)', async () => {
mockListNamespaces.mockResolvedValue([]);
await renderPanel();
await waitFor(() => expect(screen.getByText('memory.noNamespacesFound')).toBeInTheDocument());
});
it('renders namespace chips when namespaces exist (lines 288, 290-291)', async () => {
mockListNamespaces.mockResolvedValue(['ns-workspace', 'ns-channel']);
await renderPanel();
// Namespace chips in the namespaces section (may also appear in Clear Namespace select)
await waitFor(() =>
expect(screen.getAllByText('ns-workspace').length).toBeGreaterThanOrEqual(1)
);
expect(screen.getAllByText('ns-channel').length).toBeGreaterThanOrEqual(1);
});
// ── Query & Recall section ─────────────────────────────────────────────────
it('calls memoryQueryNamespace and renders result (lines 309, 316, 325, 362, 364)', async () => {
mockListNamespaces.mockResolvedValue(['ns-workspace']);
mockQueryNamespace.mockResolvedValue(queryResult);
await renderPanel();
// Fill namespace input
const nsInput = screen.getByPlaceholderText('memory.namespace');
fireEvent.change(nsInput, { target: { value: 'ns-workspace' } });
// Fill query text
const queryTextarea = screen.getByPlaceholderText('memory.queryText');
fireEvent.change(queryTextarea, { target: { value: 'what did I learn?' } });
// Click Query button
fireEvent.click(screen.getByText('memory.query'));
await waitFor(() =>
expect(mockQueryNamespace).toHaveBeenCalledWith(
'ns-workspace',
'what did I learn?',
expect.any(Number)
)
);
// Result label and text rendered (lines 362, 364, 370)
await waitFor(() => expect(screen.getByText('memory.queryResult')).toBeInTheDocument());
expect(screen.getByText('query result text here')).toBeInTheDocument();
});
it('calls memoryRecallNamespace and renders result (lines 338-340, 346, 348, 376, 382)', async () => {
mockRecallNamespace.mockResolvedValue(recallResult);
await renderPanel();
const nsInput = screen.getByPlaceholderText('memory.namespace');
fireEvent.change(nsInput, { target: { value: 'ns-workspace' } });
// Query textarea can remain empty for recall
fireEvent.click(screen.getByText('memory.recall'));
await waitFor(() =>
expect(mockRecallNamespace).toHaveBeenCalledWith('ns-workspace', expect.any(Number))
);
await waitFor(() => expect(screen.getByText('memory.recallResult')).toBeInTheDocument());
expect(screen.getByText('recall result text here')).toBeInTheDocument();
});
it('shows query error when queryNamespace rejects (lines 354, 356)', async () => {
mockQueryNamespace.mockRejectedValue(new Error('query failed due to timeout'));
await renderPanel();
const nsInput = screen.getByPlaceholderText('memory.namespace');
fireEvent.change(nsInput, { target: { value: 'ns-test' } });
const queryTextarea = screen.getByPlaceholderText('memory.queryText');
fireEvent.change(queryTextarea, { target: { value: 'search term' } });
fireEvent.click(screen.getByText('memory.query'));
await waitFor(() =>
expect(screen.getByText(/query failed due to timeout/)).toBeInTheDocument()
);
});
it('shows recall error when recallNamespace rejects (lines 346, 348)', async () => {
mockRecallNamespace.mockRejectedValue(new Error('recall timed out'));
await renderPanel();
const nsInput = screen.getByPlaceholderText('memory.namespace');
fireEvent.change(nsInput, { target: { value: 'ns-test' } });
fireEvent.click(screen.getByText('memory.recall'));
await waitFor(() => expect(screen.getByText(/recall timed out/)).toBeInTheDocument());
});
// ── Clear Namespace section ────────────────────────────────────────────────
it('renders SettingsSelect when namespaces exist (lines 399, 403)', async () => {
mockListNamespaces.mockResolvedValue(['ns-workspace', 'ns-channel']);
await renderPanel();
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
// Options should include each namespace (lines 407-408)
const select = screen.getByRole('combobox') as HTMLSelectElement;
const optionValues = Array.from(select.options).map(o => o.value);
expect(optionValues).toContain('ns-workspace');
expect(optionValues).toContain('ns-channel');
});
it('renders text input fallback when no namespaces exist (line 417)', async () => {
mockListNamespaces.mockResolvedValue([]);
await renderPanel();
await waitFor(() => expect(screen.queryByRole('combobox')).not.toBeInTheDocument());
// The namespace text field has aria-label from t()
expect(screen.getByPlaceholderText('memory.exampleNamespace')).toBeInTheDocument();
});
it('shows success message after clear (line 427)', async () => {
mockListNamespaces.mockResolvedValue(['ns-workspace']);
mockClearNamespace.mockResolvedValue({ cleared: true, namespace: 'ns-workspace' });
vi.spyOn(window, 'confirm').mockReturnValue(true);
await renderPanel();
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'ns-workspace' } });
fireEvent.click(screen.getByText('memory.clear'));
await waitFor(() => expect(mockClearNamespace).toHaveBeenCalledWith('ns-workspace'));
await waitFor(() =>
expect(screen.getByText('memory.clearNamespaceSuccess')).toBeInTheDocument()
);
});
it('shows empty/nothing message when cleared=false (line 429)', async () => {
mockListNamespaces.mockResolvedValue(['ns-empty']);
mockClearNamespace.mockResolvedValue({ cleared: false, namespace: 'ns-empty' });
vi.spyOn(window, 'confirm').mockReturnValue(true);
await renderPanel();
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'ns-empty' } });
fireEvent.click(screen.getByText('memory.clear'));
await waitFor(() => expect(screen.getByText('memory.clearNamespaceEmpty')).toBeInTheDocument());
});
it('shows error when clearNamespace rejects', async () => {
mockListNamespaces.mockResolvedValue(['ns-bad']);
mockClearNamespace.mockRejectedValue(new Error('clear failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
await renderPanel();
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'ns-bad' } });
fireEvent.click(screen.getByText('memory.clear'));
await waitFor(() => expect(screen.getByText(/clear failed/)).toBeInTheDocument());
});
it('does not call clearNamespace when confirm is cancelled', async () => {
mockListNamespaces.mockResolvedValue(['ns-test']);
vi.spyOn(window, 'confirm').mockReturnValue(false);
await renderPanel();
await waitFor(() => expect(screen.getByRole('combobox')).toBeInTheDocument());
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'ns-test' } });
fireEvent.click(screen.getByText('memory.clear'));
expect(mockClearNamespace).not.toHaveBeenCalled();
});
it('refresh documents button re-loads (line 215, 224)', async () => {
await renderPanel();
// Two refresh buttons: documents section + namespaces section
await waitFor(() =>
expect(screen.getAllByText('memory.refresh').length).toBeGreaterThanOrEqual(1)
);
const refreshBtns = screen.getAllByText('memory.refresh');
fireEvent.click(refreshBtns[0]);
await waitFor(() => expect(mockListDocuments).toHaveBeenCalledTimes(2));
});
});
@@ -39,10 +39,10 @@ describe('RecoveryPhrasePanel — trust-surface polish', () => {
expect(screen.getByText(/Enter your recovery phrase below/i)).toBeTruthy();
});
it('uses palette token text-stone-700 on the confirm-checkbox label (not opacity)', () => {
it('uses palette token text-neutral-700 on the confirm-checkbox label (not opacity)', () => {
const { container } = renderWithProviders(<RecoveryPhrasePanel />);
const label = screen.getByText(/consent to using it for local wallet setup/i);
expect(label.className).toContain('text-stone-700');
expect(label.className).toContain('text-neutral-700');
// Sanity: the old opacity hack is gone from this label.
expect(label.className).not.toContain('opacity-80');
expect(container).toBeTruthy();
@@ -82,11 +82,11 @@ describe('SandboxSettingsPanel', () => {
);
});
it('toggling the enabled checkbox persists the change', async () => {
it('toggling the enabled switch persists the change', async () => {
renderWithProviders(<SandboxSettingsPanel />);
await waitFor(() => expect(mockGet).toHaveBeenCalled());
const checkbox = await screen.findByRole('checkbox', { name: /enable sandbox/i });
fireEvent.click(checkbox);
const toggle = await screen.findByRole('switch', { name: /enable sandbox/i });
fireEvent.click(toggle);
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ enabled: false }))
);
@@ -144,4 +144,96 @@ describe('SandboxSettingsPanel', () => {
fireEvent.change(select, { target: { value: 'docker' } });
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
// ─── Memory limit field ───────────────────────────────────────────────────
it('renders memory limit input with current value', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /memory limit/i });
expect(input).toHaveValue(512);
});
it('blurring the memory limit input persists the value', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /memory limit/i });
fireEvent.change(input, { target: { value: '1024' } });
fireEvent.blur(input);
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ docker_memory_limit_mb: 1024 })
)
);
});
it('pressing Enter in the memory limit input persists the value', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /memory limit/i });
fireEvent.change(input, { target: { value: '256' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ docker_memory_limit_mb: 256 })
)
);
});
it('clearing the memory limit and blurring persists null', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /memory limit/i });
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ docker_memory_limit_mb: null })
)
);
});
// ─── CPU limit field ──────────────────────────────────────────────────────
it('renders CPU limit input with current value', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /cpu limit/i });
expect(input).toHaveValue(1.0);
});
it('blurring the CPU limit input persists the value', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /cpu limit/i });
fireEvent.change(input, { target: { value: '2' } });
fireEvent.blur(input);
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ docker_cpu_limit: 2 }))
);
});
it('pressing Enter in the CPU limit input persists the value', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /cpu limit/i });
fireEvent.change(input, { target: { value: '0.5' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ docker_cpu_limit: 0.5 }))
);
});
it('clearing the CPU limit and blurring persists null', async () => {
renderWithProviders(<SandboxSettingsPanel />);
const input = await screen.findByRole('spinbutton', { name: /cpu limit/i });
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ docker_cpu_limit: null }))
);
});
// ─── Empty env passthrough ────────────────────────────────────────────────
it('shows empty state when no env passthrough variables are configured', async () => {
mockGet.mockResolvedValue({ result: sandboxSettings({ env_passthrough: [] }), logs: [] });
renderWithProviders(<SandboxSettingsPanel />);
await waitFor(() => expect(mockGet).toHaveBeenCalled());
// Empty state text appears when env_passthrough is empty
expect(await screen.findByText(/no environment variables configured/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,312 @@
/**
* ScreenAwarenessDebugPanel coverage tests.
*
* Target uncovered lines (from diff-cover report):
* 34,38,41,143,181,193,203,205,245,247,251,255-256,261-262,297
*
* These cover:
* - DebugSection toggle (line 34,38,41): expand/collapse
* - Config form inputs: baselineFps, use-vision-model checkbox, keep-screenshots (143,181,193)
* - saveConfig handler (line 203,205)
* - Vision summaries: list rendering, refresh, empty state (245,247,251,255-256,261-262)
* - lastError display (297)
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// ------------------------------------------------------------------
// Module mocks
// ------------------------------------------------------------------
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../../../components/intelligence/ScreenIntelligenceDebugPanel', () => ({
default: () => <div data-testid="screen-debug-inner">debug-content</div>,
}));
const { mockIsTauri, mockUpdateScreenIntelligenceSettings, mockUseScreenIntelligenceState } =
vi.hoisted(() => ({
mockIsTauri: vi.fn(() => true),
mockUpdateScreenIntelligenceSettings: vi.fn(),
mockUseScreenIntelligenceState: vi.fn(),
}));
vi.mock('../../../../utils/tauriCommands', () => ({
isTauri: mockIsTauri,
openhumanUpdateScreenIntelligenceSettings: mockUpdateScreenIntelligenceSettings,
}));
vi.mock('../../../../features/screen-intelligence/useScreenIntelligenceState', () => ({
useScreenIntelligenceState: mockUseScreenIntelligenceState,
}));
// ------------------------------------------------------------------
// Fixture builders
// ------------------------------------------------------------------
const makeStatus = (overrides: Record<string, unknown> = {}) => ({
platform_supported: true,
config: {
enabled: true,
baseline_fps: 1,
use_vision_model: true,
keep_screenshots: false,
allowlist: ['TextEdit', 'Xcode'],
denylist: ['Safari'],
policy_mode: 'all_except_blacklist',
...((overrides.config as Record<string, unknown>) ?? {}),
},
session: {
frames_in_memory: 5,
panic_hotkey: 'Ctrl+Shift+P',
vision_state: 'running',
vision_queue_depth: 2,
last_vision_at_ms: Date.now() - 3000,
},
...overrides,
});
const makeVisionSummary = (overrides: Record<string, unknown> = {}) => ({
id: 'vs-001',
captured_at_ms: Date.now() - 10000,
app_name: 'TextEdit',
window_title: 'Untitled',
actionable_notes: 'User is editing a document.',
...overrides,
});
const makeDefaultState = (overrides: Record<string, unknown> = {}) => ({
status: makeStatus(),
lastError: null,
isLoadingVision: false,
recentVisionSummaries: [],
refreshStatus: vi.fn().mockResolvedValue(null),
refreshVision: vi.fn().mockResolvedValue([]),
runCaptureTest: vi.fn().mockResolvedValue(undefined),
captureTestResult: null,
isCaptureTestRunning: false,
...overrides,
});
async function renderPanel(stateOverrides: Record<string, unknown> = {}) {
mockUseScreenIntelligenceState.mockReturnValue(makeDefaultState(stateOverrides));
const { default: ScreenAwarenessDebugPanel } = await import('../ScreenAwarenessDebugPanel');
return render(<ScreenAwarenessDebugPanel />);
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
describe('ScreenAwarenessDebugPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
mockUpdateScreenIntelligenceSettings.mockResolvedValue({});
});
// ── DebugSection expand/collapse (lines 34, 38, 41) ───────────────────────
it('collapses debug section by default and shows expand label (line 34, 38)', async () => {
await renderPanel();
expect(screen.getByText('screenAwareness.debug.expand')).toBeInTheDocument();
expect(screen.queryByTestId('screen-debug-inner')).not.toBeInTheDocument();
});
it('expands debug section when toggle is clicked (line 41)', async () => {
await renderPanel();
fireEvent.click(screen.getByText('screenAwareness.debug.debugAndDiagnostics'));
expect(screen.getByText('screenAwareness.debug.collapse')).toBeInTheDocument();
expect(screen.getByTestId('screen-debug-inner')).toBeInTheDocument();
});
it('collapses debug section after second click', async () => {
await renderPanel();
const toggleBtn = screen.getByText('screenAwareness.debug.debugAndDiagnostics');
fireEvent.click(toggleBtn);
fireEvent.click(toggleBtn);
expect(screen.getByText('screenAwareness.debug.expand')).toBeInTheDocument();
expect(screen.queryByTestId('screen-debug-inner')).not.toBeInTheDocument();
});
// ── Policy settings form: FPS input (line 143) ───────────────────────────
it('renders baseline FPS input with config value (line 143)', async () => {
await renderPanel({
status: makeStatus({
config: {
baseline_fps: 2,
use_vision_model: true,
keep_screenshots: false,
allowlist: [],
denylist: [],
policy_mode: 'all_except_blacklist',
enabled: true,
},
}),
});
const fpsInput = screen.getByRole('spinbutton') as HTMLInputElement;
expect(fpsInput.value).toBe('2');
});
it('updates baseline FPS on change', async () => {
await renderPanel();
const fpsInput = screen.getByRole('spinbutton');
fireEvent.change(fpsInput, { target: { value: '5' } });
expect((fpsInput as HTMLInputElement).value).toBe('5');
});
// ── Use vision model checkbox (line 181) ──────────────────────────────────
it('renders use-vision-model checkbox checked (line 181)', async () => {
await renderPanel();
// SettingsCheckbox renders a native <input type="checkbox" id="screen-use-vision-model">.
// The panel also has a keep-screenshots checkbox, so query by id.
const checkbox = document.getElementById('screen-use-vision-model') as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
// config.use_vision_model=true → checked=true
expect(checkbox.checked).toBe(true);
expect(screen.getByText('screenAwareness.debug.useVisionModel')).toBeInTheDocument();
});
// ── saveConfig handler (lines 203, 205) ───────────────────────────────────
it('calls updateScreenIntelligenceSettings when save is clicked (line 203)', async () => {
const refreshStatus = vi.fn().mockResolvedValue(null);
mockUseScreenIntelligenceState.mockReturnValue(makeDefaultState({ refreshStatus }));
const { default: ScreenAwarenessDebugPanel } = await import('../ScreenAwarenessDebugPanel');
render(<ScreenAwarenessDebugPanel />);
fireEvent.click(screen.getByText('screenAwareness.debug.saveSettings'));
await waitFor(() => expect(mockUpdateScreenIntelligenceSettings).toHaveBeenCalled());
expect(mockUpdateScreenIntelligenceSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, use_vision_model: true, keep_screenshots: false })
);
});
it('shows error when saveConfig throws (line 205)', async () => {
mockUpdateScreenIntelligenceSettings.mockRejectedValue(new Error('permission denied'));
await renderPanel();
fireEvent.click(screen.getByText('screenAwareness.debug.saveSettings'));
await waitFor(() => expect(screen.getByText('permission denied')).toBeInTheDocument());
});
it('skips saveConfig when not in tauri env (line 203)', async () => {
mockIsTauri.mockReturnValue(false);
await renderPanel();
fireEvent.click(screen.getByText('screenAwareness.debug.saveSettings'));
// Should not call update
expect(mockUpdateScreenIntelligenceSettings).not.toHaveBeenCalled();
});
// ── Vision summaries: empty state (line 251) ──────────────────────────────
it('shows empty state when no vision summaries (line 251)', async () => {
await renderPanel({ recentVisionSummaries: [] });
expect(screen.getByText('screenAwareness.debug.noSummaries')).toBeInTheDocument();
});
// ── Vision summaries: list rendering (lines 245, 247, 255-256, 261-262) ───
it('renders vision summary rows with app name and notes (lines 255-256, 261-262)', async () => {
const summary = makeVisionSummary();
await renderPanel({ recentVisionSummaries: [summary] });
// app_name and window_title are text nodes inside a div that also contains the
// timestamp and bullet separators — use body.textContent to avoid split-node issues.
expect(document.body.textContent).toContain('TextEdit');
expect(document.body.textContent).toContain('User is editing a document.');
expect(document.body.textContent).toContain('Untitled');
});
it('renders unknownApp when app_name is null (line 261)', async () => {
const summary = makeVisionSummary({ app_name: null, window_title: null });
await renderPanel({ recentVisionSummaries: [summary] });
expect(document.body.textContent).toContain('screenAwareness.debug.unknownApp');
});
it('calls refreshVision when Refresh button is clicked (line 247)', async () => {
const refreshVision = vi.fn().mockResolvedValue([]);
await renderPanel({ refreshVision });
fireEvent.click(screen.getByText('common.refresh'));
await waitFor(() => expect(refreshVision).toHaveBeenCalledWith(10));
});
it('shows refreshing label while vision is loading (line 245)', async () => {
await renderPanel({ isLoadingVision: true });
expect(screen.getByText('screenAwareness.debug.refreshing')).toBeInTheDocument();
});
// ── Platform unsupported notice and lastError (line 297) ─────────────────
it('shows macosOnly notice when platform not supported (line 290-292)', async () => {
const status = makeStatus();
status.platform_supported = false;
await renderPanel({ status });
expect(screen.getByText('screenAwareness.debug.macosOnly')).toBeInTheDocument();
});
it('shows lastError status line when lastError is non-null (line 297)', async () => {
await renderPanel({ lastError: 'screen recording permission denied' });
expect(screen.getByText('screen recording permission denied')).toBeInTheDocument();
});
// ── Session stats section (lines 215-235) ────────────────────────────────
it('renders session stats with live values', async () => {
await renderPanel();
// These keys appear as text nodes inside divs that also hold ': <value>' —
// use body.textContent to avoid split-text-node matching failures.
expect(document.body.textContent).toContain('screenAwareness.debug.framesEphemeral');
expect(document.body.textContent).toContain('screenAwareness.debug.panicStop');
expect(document.body.textContent).toContain('screenAwareness.debug.vision');
});
it('shows defaultPanicHotkey when panic_hotkey is null', async () => {
const status = makeStatus();
// At runtime the JSON from core may return null even though the TypeScript type says string.
// Force-cast to test the nullish-coalescing fallback branch.
(status.session as unknown as Record<string, unknown>).panic_hotkey = null;
await renderPanel({ status });
expect(document.body.textContent).toContain('screenAwareness.debug.defaultPanicHotkey');
});
it('shows notAvailable for last_vision_at_ms when null', async () => {
const status = makeStatus();
// last_vision_at_ms is number | null in the real type, but inferred as number in the fixture.
// Force-cast to assign null and test the notAvailable branch.
(status.session as unknown as Record<string, unknown>).last_vision_at_ms = null;
await renderPanel({ status });
expect(document.body.textContent).toContain('screenAwareness.debug.notAvailable');
});
});
@@ -123,11 +123,9 @@ describe('ScreenIntelligencePanel', () => {
renderPanel();
// Both the header h2 and section h3 say "Screen Awareness" — wait for either.
// The SettingsHeader renders "Screen Awareness" as the panel heading; wait for it.
await waitFor(() => {
expect(screen.getAllByRole('heading', { name: 'Screen Awareness' }).length).toBeGreaterThan(
0
);
expect(screen.getAllByText('Screen Awareness').length).toBeGreaterThan(0);
});
const enabledLabel = screen.getByText('Enabled').closest('label');
@@ -191,4 +189,155 @@ describe('ScreenIntelligencePanel', () => {
expect(await screen.findByText(/Core restarted: PID 4000/i)).toBeInTheDocument();
});
// ─── Session controls ─────────────────────────────────────────────────────
it('Start Session button calls startSession with consent=true', async () => {
const startSession = vi.fn().mockResolvedValue(null);
renderPanel({
...baseState,
status: {
...baseState.status!,
// accessibility granted so Start is not disabled
permissions: { ...baseState.status!.permissions, accessibility: 'granted' },
session: { ...baseState.status!.session, active: false },
},
startSession,
});
await waitFor(() => expect(screen.getAllByText(/start session/i).length).toBeGreaterThan(0));
fireEvent.click(screen.getByRole('button', { name: /start session/i }));
await waitFor(() =>
expect(startSession).toHaveBeenCalledWith(expect.objectContaining({ consent: true }))
);
});
it('Stop Session button calls stopSession', async () => {
const stopSession = vi.fn().mockResolvedValue(null);
renderPanel({
...baseState,
status: { ...baseState.status!, session: { ...baseState.status!.session, active: true } },
stopSession,
});
await waitFor(() =>
expect(screen.getByRole('button', { name: /stop session/i })).not.toBeDisabled()
);
fireEvent.click(screen.getByRole('button', { name: /stop session/i }));
await waitFor(() => expect(stopSession).toHaveBeenCalledWith('manual_stop'));
});
it('Analyze Now button calls flushVision when session is active', async () => {
const flushVision = vi.fn().mockResolvedValue(undefined);
renderPanel({
...baseState,
status: { ...baseState.status!, session: { ...baseState.status!.session, active: true } },
flushVision,
});
await waitFor(() =>
expect(screen.getByRole('button', { name: /analyze now/i })).not.toBeDisabled()
);
fireEvent.click(screen.getByRole('button', { name: /analyze now/i }));
await waitFor(() => expect(flushVision).toHaveBeenCalled());
});
it('Analyze Now is disabled when session is not active', async () => {
renderPanel({
...baseState,
status: { ...baseState.status!, session: { ...baseState.status!.session, active: false } },
});
await waitFor(() =>
expect(screen.getByRole('button', { name: /analyze now/i })).toBeDisabled()
);
});
// ─── Policy mode select ───────────────────────────────────────────────────
it('changing policy mode select updates the local state', async () => {
renderPanel();
await waitFor(() => expect(screen.getAllByText(/screen awareness/i).length).toBeGreaterThan(0));
const policySelect = screen.getByRole('combobox', { name: /mode/i }) as HTMLSelectElement;
fireEvent.change(policySelect, { target: { value: 'whitelist_only' } });
expect(policySelect.value).toBe('whitelist_only');
});
it('saves with whitelist_only policy when mode is changed', async () => {
const deferred = createDeferred<{ result: ConfigSnapshot; logs: [] }>();
vi.mocked(openhumanUpdateScreenIntelligenceSettings).mockReturnValueOnce(deferred.promise);
renderPanel();
await waitFor(() => expect(screen.getAllByText(/screen awareness/i).length).toBeGreaterThan(0));
const policySelect = screen.getByRole('combobox', { name: /mode/i });
fireEvent.change(policySelect, { target: { value: 'whitelist_only' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
expect(vi.mocked(openhumanUpdateScreenIntelligenceSettings)).toHaveBeenCalledWith(
expect.objectContaining({ policy_mode: 'whitelist_only' })
);
deferred.resolve({
result: { config: {}, workspace_dir: '/tmp/workspace', config_path: '/tmp/config.toml' },
logs: [],
});
});
// ─── Error display ────────────────────────────────────────────────────────
it('shows lastError when state has an error', async () => {
renderPanel({ ...baseState, lastError: 'Permission denied by OS' });
expect(await screen.findByText('Permission denied by OS')).toBeInTheDocument();
});
// ─── Screen monitoring toggle ─────────────────────────────────────────────
it('toggling screen monitoring checkbox updates the override', async () => {
renderPanel();
await waitFor(() => expect(screen.getAllByText(/screen awareness/i).length).toBeGreaterThan(0));
const monitoringLabel = screen.getByText('Screen Monitoring').closest('label');
const checkbox = monitoringLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
// Initially reflects status value (true in baseState.features)
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
});
// ─── Session status display ───────────────────────────────────────────────
it('shows Active session status when session is active', async () => {
renderPanel({
...baseState,
status: { ...baseState.status!, session: { ...baseState.status!.session, active: true } },
});
await waitFor(() => expect(screen.getByText(/active/i)).toBeInTheDocument());
});
it('shows Stopped session status when session is inactive', async () => {
renderPanel({
...baseState,
status: { ...baseState.status!, session: { ...baseState.status!.session, active: false } },
});
await waitFor(() => expect(screen.getByText(/stopped/i)).toBeInTheDocument());
});
});
@@ -0,0 +1,237 @@
/**
* SecurityPanel coverage for keyring status display and consent actions.
*
* Target lines: 11, 27, 103, 118, 123, 128, 133
*
* 11 MODE_BADGE_VARIANT constant drives modeBadgeVariant
* 27 modeBadgeVariant calculated from keyringStatus.activeMode
* 103 retry probe button click handleRetryProbe
* 118 consent section visible when keyring unavailable
* 123 grantConsent button shown when activeMode !== 'local_encrypted'
* 128 revokeConsent button shown when activeMode !== 'declined'
* 133 consent button calls handleConsentChange
*
* NOTE: useT is NOT mocked here the real I18nContext default (en.ts) is used,
* so all assertions use the actual English strings from en.ts.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import SecurityPanel from '../SecurityPanel';
// ── Mocks ─────────────────────────────────────────────────────────────────────
const mockRetryKeyringProbe = vi.fn();
const mockDecideKeyringConsent = vi.fn();
vi.mock('../../../../services/keyringApi', () => ({
retryKeyringProbe: (...args: unknown[]) => mockRetryKeyringProbe(...args),
decideKeyringConsent: (...args: unknown[]) => mockDecideKeyringConsent(...args),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <h1>{title}</h1>,
}));
const mockUseCoreState = vi.fn();
vi.mock('../../../../providers/CoreStateProvider', () => ({
useCoreState: () => mockUseCoreState(),
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeKeyringStatus(overrides: Record<string, unknown> = {}) {
return {
activeMode: 'os_keyring',
available: true,
backendName: 'macOS Keychain',
failureReason: null,
...overrides,
};
}
function setupState(keyringStatus: ReturnType<typeof makeKeyringStatus>) {
mockUseCoreState.mockReturnValue({ snapshot: { keyringStatus } });
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('SecurityPanel — storage mode badge (lines 11, 27)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRetryKeyringProbe.mockResolvedValue(undefined);
});
it('renders os_keyring mode badge — key not in en.ts so t() returns key', () => {
setupState(makeKeyringStatus({ activeMode: 'os_keyring' }));
renderWithProviders(<SecurityPanel />);
// 'keyring.settings.mode.os_keyring' not in en.ts → rendered as key
expect(screen.getByText('keyring.settings.mode.os_keyring')).toBeInTheDocument();
});
it('renders local_encrypted mode badge', () => {
setupState(makeKeyringStatus({ activeMode: 'local_encrypted' }));
renderWithProviders(<SecurityPanel />);
expect(screen.getByText('keyring.settings.mode.local_encrypted')).toBeInTheDocument();
});
it('renders declined mode badge (line 27 — MODE_BADGE_VARIANT.declined = danger)', () => {
setupState(makeKeyringStatus({ activeMode: 'declined' }));
renderWithProviders(<SecurityPanel />);
// en.ts has keyring.settings.mode.declined = 'Declined'
expect(screen.getByText('Declined')).toBeInTheDocument();
});
it('renders consent_pending mode badge', () => {
setupState(makeKeyringStatus({ activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
expect(screen.getByText('keyring.settings.mode.consent_pending')).toBeInTheDocument();
});
});
describe('SecurityPanel — availability section', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRetryKeyringProbe.mockResolvedValue(undefined);
});
it('shows available indicator when keyring is available (line 90)', () => {
setupState(makeKeyringStatus({ available: true }));
renderWithProviders(<SecurityPanel />);
// en.ts: 'keyring.settings.available': 'OS keychain is available'
expect(screen.getByText('OS keychain is available')).toBeInTheDocument();
});
it('shows unavailable indicator when keyring is not available (line 91)', () => {
setupState(makeKeyringStatus({ available: false }));
renderWithProviders(<SecurityPanel />);
// en.ts: 'keyring.settings.unavailable': 'OS keychain is unavailable'
expect(screen.getByText('OS keychain is unavailable')).toBeInTheDocument();
});
it('shows failure reason when present', () => {
setupState(makeKeyringStatus({ available: false, failureReason: 'Keychain locked' }));
renderWithProviders(<SecurityPanel />);
expect(screen.getByText('Keychain locked')).toBeInTheDocument();
});
});
describe('SecurityPanel — retry probe (line 103)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRetryKeyringProbe.mockResolvedValue(undefined);
});
it('calls retryKeyringProbe when retry button is clicked (line 103)', async () => {
setupState(makeKeyringStatus());
renderWithProviders(<SecurityPanel />);
// en.ts: 'keyring.settings.retryButton': 'Retry keychain detection'
fireEvent.click(screen.getByText('Retry keychain detection'));
await waitFor(() => {
expect(mockRetryKeyringProbe).toHaveBeenCalledTimes(1);
});
});
it('shows error when retryKeyringProbe fails', async () => {
mockRetryKeyringProbe.mockRejectedValue(new Error('probe failed'));
setupState(makeKeyringStatus());
renderWithProviders(<SecurityPanel />);
fireEvent.click(screen.getByText('Retry keychain detection'));
await waitFor(() => {
// en.ts: 'keyring.settings.retryFailed': 'Retry failed. Keychain is still unavailable.'
expect(screen.getByText('Retry failed. Keychain is still unavailable.')).toBeInTheDocument();
});
});
});
describe('SecurityPanel — consent management (lines 118, 123, 128, 133)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDecideKeyringConsent.mockResolvedValue(undefined);
mockRetryKeyringProbe.mockResolvedValue(undefined);
});
it('renders consent section when keyring is unavailable (line 118)', () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
// en.ts: 'keyring.settings.consentTitle': 'Storage consent'
expect(screen.getByText('Storage consent')).toBeInTheDocument();
});
it('does NOT render consent section when keyring is available', () => {
setupState(makeKeyringStatus({ available: true }));
renderWithProviders(<SecurityPanel />);
expect(screen.queryByText('Storage consent')).not.toBeInTheDocument();
});
it('shows grantConsent button when mode is not local_encrypted (line 123)', () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
// en.ts: 'keyring.settings.grantConsent': 'Allow local encrypted storage'
expect(screen.getByText('Allow local encrypted storage')).toBeInTheDocument();
});
it('hides grantConsent button when mode is already local_encrypted', () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'local_encrypted' }));
renderWithProviders(<SecurityPanel />);
expect(screen.queryByText('Allow local encrypted storage')).not.toBeInTheDocument();
});
it('shows revokeConsent button when mode is not declined (line 128)', () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
// en.ts: 'keyring.settings.revokeConsent': 'Decline local storage'
expect(screen.getByText('Decline local storage')).toBeInTheDocument();
});
it('hides revokeConsent button when mode is already declined', () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'declined' }));
renderWithProviders(<SecurityPanel />);
expect(screen.queryByText('Decline local storage')).not.toBeInTheDocument();
});
it('calls decideKeyringConsent with local_encrypted when grantConsent clicked (line 133)', async () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
fireEvent.click(screen.getByText('Allow local encrypted storage'));
await waitFor(() => {
expect(mockDecideKeyringConsent).toHaveBeenCalledWith('local_encrypted');
});
});
it('calls decideKeyringConsent with declined when revokeConsent clicked (line 133)', async () => {
setupState(makeKeyringStatus({ available: false, activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
fireEvent.click(screen.getByText('Decline local storage'));
await waitFor(() => {
expect(mockDecideKeyringConsent).toHaveBeenCalledWith('declined');
});
});
it('shows error when decideKeyringConsent fails', async () => {
mockDecideKeyringConsent.mockRejectedValue(new Error('consent rejected'));
setupState(makeKeyringStatus({ available: false, activeMode: 'consent_pending' }));
renderWithProviders(<SecurityPanel />);
fireEvent.click(screen.getByText('Allow local encrypted storage'));
await waitFor(() => {
// en.ts: 'keyring.consent.error': 'Failed to save preference. Please try again.'
expect(screen.getByText('Failed to save preference. Please try again.')).toBeInTheDocument();
});
});
});
@@ -1,31 +1,228 @@
/**
* TeamInvitesPanel unhandled-rejection guard test.
* TeamInvitesPanel coverage for generate, revoke modal, error states.
*
* Regression coverage for OPENHUMAN-REACT-12: bootstrap-time
* `team_list_invites` failures leaked through the
* `void refreshTeamInvites(...).finally(...)` pattern. The new explicit
* `.catch()` chained before `.finally()` must absorb the rejection
* silently.
* Target lines: 130, 302, 326
*
* 130 ErrorBanner rendered when error is set
* 302 error banner inside revoke-invite modal
* 326 cancel button inside revoke modal
*
* The existing test file covers only the unhandled-rejection regression.
* This extended version adds behaviour tests for the UI flows.
*/
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
import { useCoreState } from '../../../../providers/CoreStateProvider';
import { CoreRpcError } from '../../../../services/coreRpcClient';
import TeamInvitesPanel from '../TeamInvitesPanel';
vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../../../lib/i18n/I18nContext', () => ({
useT: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'invites.title': 'Invites',
'invites.generate': 'Generate Invite',
'invites.generating': 'Generating...',
'invites.empty': 'No invites yet',
'invites.loading': 'Loading...',
'invites.refreshing': 'Refreshing...',
'invites.revokeTitle': 'Revoke invite',
'invites.revokePromptPrefix': 'Revoke invite code',
'invites.revokeWarning': 'Existing users with this code cannot use it.',
'invites.revokeAction': 'Revoke',
'invites.revoking': 'Revoking...',
'invites.revokeAria': 'Revoke invite',
'invites.copyCodeAria': 'Copy invite code',
'invites.uses': '{current}/{max} uses',
'invites.expiresOn': 'Expires {date}',
'invites.usedUp': 'Used up',
'invites.failedGenerate': 'Failed to generate invite',
'invites.failedRevoke': 'Failed to revoke invite',
'rewards.referralSection.statusExpired': 'Expired',
'common.cancel': 'Cancel',
};
return (map[key] ?? key)
.replace('{current}', '0')
.replace('{max}', '')
.replace('{date}', '1/1/2030');
},
}),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('react-router-dom', () => ({
useParams: () => ({ teamId: 'team-u1' }),
useLocation: () => ({ pathname: '/team/manage/team-u1' }),
}));
describe('TeamInvitesPanel — unhandled-rejection guard (#REACT-12)', () => {
const mockCreateInvite = vi.fn();
const mockRevokeInvite = vi.fn();
vi.mock('../../../../services/api/teamApi', () => ({
teamApi: {
createInvite: (...args: unknown[]) => mockCreateInvite(...args),
revokeInvite: (...args: unknown[]) => mockRevokeInvite(...args),
},
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeFutureDate() {
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
}
function makeInvite(overrides: Record<string, unknown> = {}) {
return {
_id: 'inv-1',
code: 'ABC-123',
createdBy: 'user-1',
expiresAt: makeFutureDate(),
maxUses: 10,
currentUses: 2,
usageHistory: [],
...overrides,
};
}
function setupState(
opts: {
invites?: ReturnType<typeof makeInvite>[];
isAdmin?: boolean;
refreshInvites?: ReturnType<typeof vi.fn>;
} = {}
) {
const {
invites = [],
isAdmin = true,
refreshInvites = vi.fn().mockResolvedValue(undefined),
} = opts;
vi.mocked(useCoreState).mockReturnValue({
snapshot: { currentUser: { _id: 'user-1', activeTeamId: 'team-u1' } },
teams: [{ team: { _id: 'team-u1', name: 'Test Team' }, role: isAdmin ? 'ADMIN' : 'MEMBER' }],
teamInvitesById: { 'team-u1': invites },
refreshTeamInvites: refreshInvites,
} as never);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('TeamInvitesPanel — error banner (line 130)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateInvite.mockRejectedValue({ error: 'Invite limit reached' });
});
it('shows an error banner when generate invite fails (line 130)', async () => {
setupState({ invites: [] });
render(<TeamInvitesPanel />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Generate Invite/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Generate Invite/i }));
await waitFor(() => {
expect(screen.getByText('Invite limit reached')).toBeInTheDocument();
});
});
});
describe('TeamInvitesPanel — revoke modal (lines 302, 326)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRevokeInvite.mockResolvedValue({});
});
it('opens the revoke confirmation modal when revoke is clicked', async () => {
setupState({ invites: [makeInvite()] });
render(<TeamInvitesPanel />);
await waitFor(() => {
expect(screen.getByText('ABC-123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Revoke invite/i }));
await waitFor(() => {
expect(screen.getByText('Revoke invite')).toBeInTheDocument();
expect(screen.getByText('Existing users with this code cannot use it.')).toBeInTheDocument();
});
});
it('cancel button closes the revoke modal (line 326)', async () => {
setupState({ invites: [makeInvite()] });
render(<TeamInvitesPanel />);
await waitFor(() => {
expect(screen.getByText('ABC-123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Revoke invite/i }));
await screen.findByText('Revoke invite');
fireEvent.click(screen.getByText('Cancel'));
expect(screen.queryByText('Revoke invite')).not.toBeInTheDocument();
});
it('shows error banner inside revoke modal on API failure (line 302)', async () => {
mockRevokeInvite.mockRejectedValue({ error: 'Already revoked' });
setupState({ invites: [makeInvite()] });
render(<TeamInvitesPanel />);
await waitFor(() => {
expect(screen.getByText('ABC-123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Revoke invite/i }));
await screen.findByText('Revoke invite');
fireEvent.click(screen.getByText('Revoke'));
await waitFor(() => {
expect(screen.getAllByText('Already revoked').length).toBeGreaterThan(0);
});
});
it('calls revokeInvite on confirm', async () => {
const refreshInvites = vi.fn().mockResolvedValue(undefined);
setupState({ invites: [makeInvite()], refreshInvites });
render(<TeamInvitesPanel />);
await waitFor(() => {
expect(screen.getByText('ABC-123')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Revoke invite/i }));
await screen.findByText('Revoke invite');
fireEvent.click(screen.getByText('Revoke'));
await waitFor(() => {
expect(mockRevokeInvite).toHaveBeenCalledWith('team-u1', 'inv-1');
});
});
it('shows expired badge for past-expiry invites', async () => {
const expired = makeInvite({ expiresAt: '2020-01-01T00:00:00.000Z' });
setupState({ invites: [expired] });
render(<TeamInvitesPanel />);
await waitFor(() => {
expect(screen.getAllByText('Expired').length).toBeGreaterThan(0);
});
});
});
describe('TeamInvitesPanel — unhandled-rejection guard (existing regression)', () => {
let urEvents: PromiseRejectionEvent[];
const urHandler = (e: PromiseRejectionEvent) => {
urEvents.push(e);
@@ -57,7 +254,6 @@ describe('TeamInvitesPanel — unhandled-rejection guard (#REACT-12)', () => {
render(<TeamInvitesPanel />);
await waitFor(() => expect(refreshTeamInvites).toHaveBeenCalledWith('team-u1'));
await new Promise(r => setTimeout(r, 20));
expect(urEvents).toHaveLength(0);
});
});

Some files were not shown because too many files have changed in this diff Show More