mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(settings): unify settings navigation + enforce panel UI contract (#3599)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}–{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 min–max range hint', () => {
|
||||
render(<SettingsNumberField {...defaultProps} />);
|
||||
expect(screen.getByText('1–3600')).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
@@ -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')} ·{' '}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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')} · {logs.length}{' '}
|
||||
{t('webhooks.captured')} ·{' '}
|
||||
<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 492–530) ──────────────────────
|
||||
|
||||
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
Reference in New Issue
Block a user