feat(settings): refine browser automation tab

This commit is contained in:
Tam Nhu Tran committed 2026-04-16 18:49:24 -04:00
1 parent 4e30c9b080
commit 06f6f5485f
6 files changed
+584 -306

No files matched your search

+10
View File
@@ -8,6 +8,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@nivo/core": "^0.99.0", "@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0", "@nivo/sankey": "^0.99.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -28,6 +29,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.38.0",
"i18next": "^25.8.13", "i18next": "^25.8.13",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
@@ -273,6 +275,8 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -735,6 +739,8 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -877,6 +883,10 @@
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.12.4", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg=="], "msw": ["msw@2.12.4", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg=="],
+2
View File
@@ -22,6 +22,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@nivo/core": "^0.99.0", "@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0", "@nivo/sankey": "^0.99.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -42,6 +43,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.38.0",
"i18next": "^25.8.13", "i18next": "^25.8.13",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
+8
View File
@@ -2433,6 +2433,8 @@ const resources = {
'Configure Claude Browser Attach and Codex Browser Tools here, then use the guidance below to launch or verify each lane.', 'Configure Claude Browser Attach and Codex Browser Tools here, then use the guidance below to launch or verify each lane.',
readiness: 'Readiness', readiness: 'Readiness',
nextStep: 'Next step', nextStep: 'Next step',
technicalDetails: 'Technical Details',
diagnostics: 'Diagnostics',
actions: { actions: {
saveClaude: 'Save Claude settings', saveClaude: 'Save Claude settings',
saveCodex: 'Save Codex settings', saveCodex: 'Save Codex settings',
@@ -4846,6 +4848,8 @@ const resources = {
'在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。', '在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。',
readiness: '就绪状态', readiness: '就绪状态',
nextStep: '下一步', nextStep: '下一步',
technicalDetails: '技术细节',
diagnostics: '诊断信息',
actions: { actions: {
saveClaude: '保存 Claude 设置', saveClaude: '保存 Claude 设置',
saveCodex: '保存 Codex 设置', saveCodex: '保存 Codex 设置',
@@ -7361,6 +7365,8 @@ const resources = {
'Cấu hình Claude Browser Attach và Codex Browser Tools tại đây, rồi dùng hướng dẫn bên dưới để khởi chạy hoặc kiểm tra từng luồng.', 'Cấu hình Claude Browser Attach và Codex Browser Tools tại đây, rồi dùng hướng dẫn bên dưới để khởi chạy hoặc kiểm tra từng luồng.',
readiness: 'Trạng thái sẵn sàng', readiness: 'Trạng thái sẵn sàng',
nextStep: 'Bước tiếp theo', nextStep: 'Bước tiếp theo',
technicalDetails: 'Chi tiết kỹ thuật',
diagnostics: 'Chẩn đoán',
actions: { actions: {
saveClaude: 'Lưu cấu hình Claude', saveClaude: 'Lưu cấu hình Claude',
saveCodex: 'Lưu cấu hình Codex', saveCodex: 'Lưu cấu hình Codex',
@@ -9783,6 +9789,8 @@ const resources = {
'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。', 'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。',
readiness: '準備状況', readiness: '準備状況',
nextStep: '次の手順', nextStep: '次の手順',
technicalDetails: '技術的な詳細',
diagnostics: '診断情報',
actions: { actions: {
saveClaude: 'Claude 設定を保存', saveClaude: 'Claude 設定を保存',
saveCodex: 'Codex 設定を保存', saveCodex: 'Codex 設定を保存',
+25
View File
@@ -0,0 +1,25 @@
export type ClientPlatformKey = 'darwin' | 'linux' | 'win32';
type NavigatorWithUserAgentData = Pick<Navigator, 'platform' | 'userAgent'> & {
userAgentData?: {
platform?: string | null;
};
};
function normalizeClientPlatform(value: string): ClientPlatformKey {
const platform = value.toLowerCase();
if (platform.includes('mac')) return 'darwin';
if (platform.includes('win')) return 'win32';
return 'linux';
}
export function getClientPlatformKey(
nav: NavigatorWithUserAgentData = navigator as NavigatorWithUserAgentData
): ClientPlatformKey {
const userAgentDataPlatform =
typeof nav.userAgentData?.platform === 'string' ? nav.userAgentData.platform : '';
const fallbackPlatform =
typeof nav.platform === 'string' && nav.platform.trim() ? nav.platform : nav.userAgent;
return normalizeClientPlatform(userAgentDataPlatform || fallbackPlatform || '');
}
+507 -306
View File
@@ -1,60 +1,40 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import {
Browser,
Gear,
CheckCircle,
WarningCircle,
XCircle,
ArrowRight,
ClipboardText,
ArrowsClockwise,
TerminalWindow,
CaretDown,
Info,
} from '@phosphor-icons/react';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
AlertCircle, import { getClientPlatformKey } from '@/lib/platform';
CheckCircle2,
Copy,
Monitor,
RefreshCw,
SearchCheck,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useBrowserConfig, useRawConfig } from '../../hooks'; import { useBrowserConfig, useRawConfig } from '../../hooks';
import type { BrowserConfig } from '../../types'; import type { BrowserConfig } from '../../types';
function getPlatformKey(): 'darwin' | 'linux' | 'win32' { // --- Constants & Helpers ---
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'darwin';
if (platform.includes('win')) return 'win32';
return 'linux';
}
function parsePortDraft(value: string): number | null { function parsePortDraft(value: string): number | null {
if (!/^\d+$/.test(value.trim())) { if (!/^\d+$/.test(value.trim())) return null;
return null;
}
const port = Number.parseInt(value.trim(), 10); const port = Number.parseInt(value.trim(), 10);
if (port < 1 || port > 65535) { if (port < 1 || port > 65535) return null;
return null;
}
return port; return port;
} }
function statusTone(state: string) {
if (state === 'ready' || state === 'enabled') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
}
if (state === 'disabled') {
return 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300';
}
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
}
function stateLabel(state: string) {
return state.replaceAll('_', ' ');
}
function buildLaunchCommand( function buildLaunchCommand(
userDataDir: string, userDataDir: string,
devtoolsPort: number, devtoolsPort: number,
@@ -70,32 +50,181 @@ function buildLaunchCommand(
return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
} }
function updateClaudeDraft( // --- High-End Components ---
source: BrowserConfig,
updates: Partial<BrowserConfig['claude']> /**
): BrowserConfig { * Double-Bezel Card Pattern
return { * Machined hardware look with nested enclosures
...source, */
claude: { function DoubleBezelCard({
...source.claude, children,
...updates, className,
}, title,
}; description,
badge,
action,
}: {
children: React.ReactNode;
className?: string;
title?: string;
description?: string;
badge?: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div
className={cn(
'group relative overflow-hidden rounded-[2.5rem] border border-border/60 bg-muted/20 p-1.5 transition-all duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] dark:border-white/[0.08] dark:bg-zinc-900/35',
className
)}
>
<div className="relative overflow-hidden rounded-[calc(2.5rem-0.375rem)] border border-border/70 bg-card/90 shadow-[0_12px_36px_-18px_rgba(15,23,42,0.18)] dark:border-white/[0.08] dark:bg-zinc-900/70 dark:shadow-[0_18px_44px_-24px_rgba(0,0,0,0.55)]">
{/* Subtle highlight inner shadow */}
<div className="pointer-events-none absolute inset-0 shadow-[inset_0_1px_0_rgba(255,255,255,0.28)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]" />
{(title || action) && (
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-border/60 px-8 py-6 dark:border-white/[0.06]">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold tracking-tight">{title}</h3>
{badge}
</div>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{action && <div className="flex items-center gap-2">{action}</div>}
</div>
)}
<div className="px-8 py-8">{children}</div>
</div>
</div>
);
} }
function updateCodexDraft( /**
source: BrowserConfig, * Animated Status Strip
updates: Partial<BrowserConfig['codex']> * Highlights readiness with cinematic dynamics
): BrowserConfig { */
return { function StatusStrip({
...source, state,
codex: { title,
...source.codex, detail,
...updates, nextStep,
}, }: {
}; state: string;
title: string;
detail: string;
nextStep: string;
}) {
const { t } = useTranslation();
const isReady = state === 'ready' || state === 'enabled';
const isError = !isReady && state !== 'disabled';
return (
<div
className={cn(
'relative overflow-hidden rounded-2xl border p-4 transition-all duration-500',
isReady
? 'border-emerald-500/20 bg-emerald-500/[0.03] dark:bg-emerald-500/[0.02]'
: isError
? 'border-amber-500/20 bg-amber-500/[0.03] dark:bg-amber-500/[0.02]'
: 'border-border/60 bg-muted/30 dark:border-white/[0.06] dark:bg-zinc-900/45'
)}
>
<div className="flex items-start gap-4">
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-500 group-hover:scale-110',
isReady
? 'bg-emerald-500/10 text-emerald-600'
: isError
? 'bg-amber-500/10 text-amber-600'
: 'bg-slate-500/10 text-slate-500'
)}
>
{isReady ? (
<CheckCircle weight="duotone" size={24} />
) : isError ? (
<WarningCircle weight="duotone" size={24} />
) : (
<XCircle weight="duotone" size={24} />
)}
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground/60">
{t('settingsPage.browserSection.readiness')}
</span>
{isReady && (
<motion.span
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 2, repeat: Infinity }}
className="h-1.5 w-1.5 rounded-full bg-emerald-500"
/>
)}
</div>
<h4 className="font-medium text-foreground">{title}</h4>
<p className="text-sm leading-relaxed text-muted-foreground">{detail}</p>
{nextStep && (
<div className="mt-3 flex items-start gap-2 rounded-lg border border-border/50 bg-muted/35 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
<ArrowRight size={14} className="mt-0.5 shrink-0 text-primary" />
<p className="text-xs leading-normal">
<span className="font-semibold text-primary">
{t('settingsPage.browserSection.nextStep')}:
</span>{' '}
{nextStep}
</p>
</div>
)}
</div>
</div>
</div>
);
} }
/**
* Diagnostics Accordion
* Pushes secondary details lower in the hierarchy
*/
function DiagnosticsSection({
title,
children,
defaultOpen = false,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="overflow-hidden rounded-2xl border border-border/60 bg-muted/20 dark:border-white/[0.06] dark:bg-zinc-900/30"
>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between px-6 py-4 text-sm font-medium transition-colors hover:bg-muted/40 dark:hover:bg-zinc-900/50">
<div className="flex items-center gap-2">
<Gear size={16} weight="bold" className="text-muted-foreground" />
{title}
</div>
<CaretDown
size={16}
className={cn('transition-transform duration-300', isOpen && 'rotate-180')}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/60 px-6 py-6 dark:border-white/[0.06]">
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// --- Main Section ---
export default function BrowserSection() { export default function BrowserSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { fetchRawConfig } = useRawConfig(); const { fetchRawConfig } = useRawConfig();
@@ -132,7 +261,7 @@ export default function BrowserSection() {
return buildLaunchCommand( return buildLaunchCommand(
effectiveConfig.claude.userDataDir, effectiveConfig.claude.userDataDir,
effectiveConfig.claude.devtoolsPort, effectiveConfig.claude.devtoolsPort,
getPlatformKey() getClientPlatformKey()
); );
}, [effectiveConfig]); }, [effectiveConfig]);
@@ -168,7 +297,6 @@ export default function BrowserSection() {
const saveClaudeSettings = useCallback(async () => { const saveClaudeSettings = useCallback(async () => {
if (!effectiveConfig || claudePort === null) return; if (!effectiveConfig || claudePort === null) return;
const saved = await saveConfig({ const saved = await saveConfig({
claude: { claude: {
enabled: effectiveConfig.claude.enabled, enabled: effectiveConfig.claude.enabled,
@@ -176,7 +304,6 @@ export default function BrowserSection() {
devtoolsPort: claudePort, devtoolsPort: claudePort,
}, },
}); });
if (saved) { if (saved) {
await fetchRawConfig(); await fetchRawConfig();
setActionMessage(null); setActionMessage(null);
@@ -187,13 +314,11 @@ export default function BrowserSection() {
const saveCodexSettings = useCallback(async () => { const saveCodexSettings = useCallback(async () => {
if (!effectiveConfig) return; if (!effectiveConfig) return;
const saved = await saveConfig({ const saved = await saveConfig({
codex: { codex: {
enabled: effectiveConfig.codex.enabled, enabled: effectiveConfig.codex.enabled,
}, },
}); });
if (saved) { if (saved) {
await fetchRawConfig(); await fetchRawConfig();
setActionMessage(null); setActionMessage(null);
@@ -208,12 +333,21 @@ export default function BrowserSection() {
setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied')); setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied'));
}, [preferredLaunchCommand, t]); }, [preferredLaunchCommand, t]);
// -- Render Helpers --
if (loading) { if (loading) {
return ( return (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex flex-col items-center gap-4 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin" /> <motion.div
<span>{t('settings.loading')}</span> animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}
>
<ArrowsClockwise size={32} />
</motion.div>
<span className="text-sm font-medium uppercase tracking-widest opacity-60">
{t('settings.loading')}
</span>
</div> </div>
</div> </div>
); );
@@ -221,99 +355,121 @@ export default function BrowserSection() {
if (!config || !status || !effectiveConfig) { if (!config || !status || !effectiveConfig) {
return ( return (
<div className="p-5"> <div className="p-8">
<Alert variant="destructive"> <Alert variant="destructive" className="rounded-[2rem] p-8 shadow-xl">
<AlertCircle className="h-4 w-4" /> <XCircle weight="duotone" size={24} />
<AlertDescription> <AlertDescription className="mt-2 text-md leading-relaxed">
{error ?? t('settingsPage.browserSection.description')} {error ?? t('settingsPage.browserSection.description')}
</AlertDescription> </AlertDescription>
</Alert> <div className="mt-6">
<div className="mt-4"> <Button
<Button variant="outline" size="sm" onClick={refreshAll}> variant="outline"
<RefreshCw className="mr-2 h-4 w-4" /> className="rounded-full px-6 transition-transform active:scale-95"
onClick={refreshAll}
>
<ArrowsClockwise className="mr-2 h-4 w-4" />
{t('sharedPage.retry')} {t('sharedPage.retry')}
</Button> </Button>
</div> </div>
</Alert>
</div> </div>
); );
} }
return ( return (
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div {/* Toast Notification Surface */}
className={cn( <AnimatePresence>
'absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out', {(error || success || actionMessage) && (
error || success || actionMessage <motion.div
? 'translate-y-0 opacity-100' initial={{ opacity: 0, y: -20, scale: 0.95 }}
: 'pointer-events-none -translate-y-2 opacity-0' animate={{ opacity: 1, y: 0, scale: 1 }}
)} exit={{ opacity: 0, y: -20, scale: 0.95 }}
className="absolute left-1/2 top-6 z-50 -translate-x-1/2"
> >
{error && ( {error ? (
<Alert variant="destructive" className="py-2 shadow-lg"> <div className="flex items-center gap-3 rounded-full border border-rose-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-rose-600 shadow-2xl dark:bg-zinc-900/85">
<AlertCircle className="h-4 w-4" /> <XCircle size={18} weight="bold" />
<AlertDescription>{error}</AlertDescription> {error}
</Alert> </div>
)} ) : (
{!error && (success || actionMessage) && ( <div className="flex items-center gap-3 rounded-full border border-emerald-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-emerald-600 shadow-2xl dark:bg-zinc-900/85">
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-emerald-700 shadow-lg dark:border-emerald-900/50 dark:bg-emerald-950/80 dark:text-emerald-300"> <CheckCircle size={18} weight="bold" />
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">
{actionMessage ?? t('commonToast.settingsSaved')} {actionMessage ?? t('commonToast.settingsSaved')}
</span>
</div> </div>
)} )}
</div> </motion.div>
)}
</AnimatePresence>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-5 p-5"> <div className="mx-auto max-w-[1240px] space-y-12 p-8 py-16">
<div className="flex flex-wrap items-start justify-between gap-3"> {/* Header Section */}
<div className="space-y-1"> <motion.div
<div className="flex items-center gap-2"> initial={{ opacity: 0, y: 20 }}
<Monitor className="h-5 w-5 text-primary" /> animate={{ opacity: 1, y: 0 }}
<h2 className="text-lg font-semibold">{t('settingsPage.browserSection.title')}</h2> className="flex flex-wrap items-end justify-between gap-6"
>
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Browser weight="duotone" size={28} />
</div> </div>
<p className="text-sm text-muted-foreground"> <div>
<h1 className="text-3xl font-bold tracking-tight">
{t('settingsPage.browserSection.title')}
</h1>
<p className="mt-1 text-muted-foreground">
{t('settingsPage.browserSection.description')} {t('settingsPage.browserSection.description')}
</p> </p>
</div> </div>
<Button variant="outline" size="sm" onClick={refreshAll} disabled={saving || loading}> </div>
<RefreshCw className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} /> </div>
<Button
variant="outline"
className="rounded-full border-border/70 bg-background/85 shadow-sm transition-all hover:bg-muted/50 active:scale-95 dark:border-white/[0.08] dark:bg-zinc-900/70 dark:hover:bg-zinc-900"
onClick={refreshAll}
disabled={saving || loading}
>
<ArrowsClockwise className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} />
{t('settings.refresh')} {t('settings.refresh')}
</Button> </Button>
</div> </motion.div>
<Alert> <motion.div
<Wrench className="h-4 w-4" /> initial={{ opacity: 0, y: 20 }}
<AlertDescription> animate={{ opacity: 1, y: 0 }}
<span className="font-medium">{t('settingsPage.browserSection.primaryTitle')}</span>{' '} transition={{ delay: 0.1 }}
>
<Alert className="items-center rounded-3xl border-primary/10 bg-primary/[0.04] p-6 py-4 dark:border-primary/15 dark:bg-primary/[0.05]">
<Info className="h-5 w-5 text-primary" weight="duotone" />
<AlertDescription className="ml-2 text-sm">
<span className="font-semibold text-primary/80">
{t('settingsPage.browserSection.primaryTitle')}
</span>{' '}
{t('settingsPage.browserSection.primaryDescription')} {t('settingsPage.browserSection.primaryDescription')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div>
<Card> {/* Claude Lane Card */}
<CardHeader className="gap-3"> <motion.div
<div className="flex items-start justify-between gap-3"> initial={{ opacity: 0, y: 20 }}
<div> animate={{ opacity: 1, y: 0 }}
<CardTitle>{t('settingsPage.browserSection.claude.title')}</CardTitle> transition={{ delay: 0.2 }}
<CardDescription> >
{t('settingsPage.browserSection.claude.description')} <DoubleBezelCard
</CardDescription> title={t('settingsPage.browserSection.claude.title')}
</div> description={t('settingsPage.browserSection.claude.description')}
<Badge variant="outline" className={statusTone(status.claude.state)}> action={
{stateLabel(status.claude.state)} <div className="flex items-center gap-3">
</Badge> <div className="flex items-center gap-2 rounded-full border border-border/60 bg-muted/30 px-4 py-2 dark:border-white/[0.06] dark:bg-zinc-900/45">
</div> <Label
</CardHeader> htmlFor="browser-claude-enabled"
<CardContent className="space-y-4"> className="cursor-pointer text-xs font-semibold leading-none"
<div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-center"> >
<div> {effectiveConfig.claude.enabled ? 'ENABLED' : 'DISABLED'}
<Label htmlFor="browser-claude-enabled">
{t('settingsPage.browserSection.claude.enabledLabel')}
</Label> </Label>
<p className="mt-1 text-sm text-muted-foreground">
{t('settingsPage.browserSection.claude.enabledDescription')}
</p>
</div>
<Switch <Switch
id="browser-claude-enabled" id="browser-claude-enabled"
checked={effectiveConfig.claude.enabled} checked={effectiveConfig.claude.enabled}
@@ -322,13 +478,47 @@ export default function BrowserSection() {
updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) updateClaudeDraft(current ?? effectiveConfig, { enabled: next })
) )
} }
aria-label={t('settingsPage.browserSection.claude.enabledLabel')}
/> />
</div> </div>
<Button
onClick={saveClaudeSettings}
disabled={saving || claudePortInvalid || !hasClaudeChanges}
className="rounded-full px-6 shadow-md transition-transform active:scale-95"
>
{saving ? (
<ArrowsClockwise className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle weight="bold" size={16} className="mr-2" />
)}
{t('settingsPage.browserSection.actions.saveClaude')}
</Button>
<Button
variant="outline"
className="rounded-full border-border/70 bg-background/85 shadow-sm transition-all hover:bg-muted/50 active:scale-95 dark:border-white/[0.08] dark:bg-zinc-900/70 dark:hover:bg-zinc-900"
onClick={refreshStatus}
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
>
<TerminalWindow weight="bold" size={16} className="mr-2" />
{t('settingsPage.browserSection.actions.testConnection')}
</Button>
</div>
}
>
<div className="grid gap-12">
<StatusStrip
state={status.claude.state}
title={status.claude.title}
detail={status.claude.detail}
nextStep={status.claude.nextStep}
/>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-2"> <div className="space-y-6">
<Label htmlFor="browser-claude-user-data-dir"> <div className="space-y-3">
<Label
htmlFor="browser-claude-user-data-dir"
className="text-sm font-semibold tracking-wide text-muted-foreground/80"
>
{t('settingsPage.browserSection.claude.userDataDir')} {t('settingsPage.browserSection.claude.userDataDir')}
</Label> </Label>
<Input <Input
@@ -341,15 +531,19 @@ export default function BrowserSection() {
}) })
) )
} }
className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70"
placeholder={status.claude.recommendedUserDataDir} placeholder={status.claude.recommendedUserDataDir}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-[11px] leading-relaxed text-muted-foreground/70">
{t('settingsPage.browserSection.claude.userDataDirHint')} {t('settingsPage.browserSection.claude.userDataDirHint')}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-3">
<Label htmlFor="browser-claude-devtools-port"> <Label
htmlFor="browser-claude-devtools-port"
className="text-sm font-semibold tracking-wide text-muted-foreground/80"
>
{t('settingsPage.browserSection.claude.devtoolsPort')} {t('settingsPage.browserSection.claude.devtoolsPort')}
</Label> </Label>
<Input <Input
@@ -357,11 +551,12 @@ export default function BrowserSection() {
value={displayedClaudePort} value={displayedClaudePort}
onChange={(event) => setClaudePortDraft(event.target.value)} onChange={(event) => setClaudePortDraft(event.target.value)}
inputMode="numeric" inputMode="numeric"
className="max-w-[120px] rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70"
/> />
<p <p
className={cn( className={cn(
'text-xs text-muted-foreground', 'text-[11px] leading-relaxed text-muted-foreground/70',
claudePortInvalid && 'text-destructive' claudePortInvalid && 'text-rose-500'
)} )}
> >
{claudePortInvalid {claudePortInvalid
@@ -371,61 +566,11 @@ export default function BrowserSection() {
</div> </div>
</div> </div>
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 md:grid-cols-2"> <div className="space-y-6">
<div> <div className="group/launch relative overflow-hidden rounded-2xl border border-border/60 bg-muted/20 p-5 transition-all hover:bg-muted/35 dark:border-white/[0.06] dark:bg-zinc-900/35 dark:hover:bg-zinc-900/50">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="flex items-start justify-between gap-4">
{t('settingsPage.browserSection.readiness')} <div className="space-y-1">
</p> <p className="text-[11px] font-bold uppercase tracking-[0.15em] text-primary/70">
<p className="mt-1 font-medium">{status.claude.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{status.claude.detail}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.nextStep')}
</p>
<p className="mt-1 text-sm">{status.claude.nextStep}</p>
</div>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.claude.effectivePath')}
</p>
<p className="mt-1 break-all font-mono text-xs">
{status.claude.effectiveUserDataDir}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t('settingsPage.browserSection.claude.recommendedPath')}:{' '}
{status.claude.recommendedUserDataDir}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.claude.managedRuntime')}
</p>
<p className="mt-1">{status.claude.managedMcpServerName}</p>
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">
{status.claude.managedMcpServerPath}
</p>
</div>
</div>
{status.claude.overrideActive ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('settingsPage.browserSection.claude.overrideMessage', {
source: status.claude.source,
})}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">
{t('settingsPage.browserSection.claude.launchGuidance')} {t('settingsPage.browserSection.claude.launchGuidance')}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -433,73 +578,89 @@ export default function BrowserSection() {
</p> </p>
</div> </div>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 rounded-full p-0 transition-transform active:scale-90"
onClick={copyLaunchCommand} onClick={copyLaunchCommand}
disabled={!preferredLaunchCommand} disabled={!preferredLaunchCommand}
> >
<Copy className="mr-2 h-4 w-4" /> <ClipboardText size={18} />
{t('settingsPage.browserSection.actions.copyLaunchCommand')}
</Button> </Button>
</div> </div>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs"> <code className="mt-4 block break-all rounded-lg border border-border/50 bg-background/80 p-3 font-mono text-[10px] leading-relaxed dark:border-white/[0.05] dark:bg-zinc-900/60">
<code>{preferredLaunchCommand}</code> {preferredLaunchCommand}
</pre> </code>
{status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? (
<p className="text-xs text-muted-foreground">
DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}
</p>
) : null}
</div> </div>
<div className="flex flex-wrap gap-2"> {status.claude.overrideActive && (
<Button <div className="flex items-center gap-3 rounded-2xl border border-amber-500/10 bg-amber-500/[0.05] p-4 text-xs font-medium text-amber-700 dark:text-amber-300">
onClick={saveClaudeSettings} <WarningCircle size={18} weight="duotone" />
disabled={saving || claudePortInvalid || !hasClaudeChanges} {t('settingsPage.browserSection.claude.overrideMessage', {
> source: status.claude.source,
{saving ? ( })}
<RefreshCw className="mr-2 h-4 w-4 animate-spin" /> </div>
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)} )}
{t('settingsPage.browserSection.actions.saveClaude')}
</Button>
<Button
variant="outline"
onClick={refreshStatus}
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
>
<SearchCheck className="mr-2 h-4 w-4" />
{t('settingsPage.browserSection.actions.testConnection')}
</Button>
</div> </div>
</CardContent> </div>
</Card>
<Card> <DiagnosticsSection
<CardHeader className="gap-3"> title={t('settingsPage.browserSection.technicalDetails')}
<div className="flex items-start justify-between gap-3"> defaultOpen={status.claude.state !== 'ready'}
<div> >
<CardTitle>{t('settingsPage.browserSection.codex.title')}</CardTitle> <div className="grid gap-6 sm:grid-cols-2">
<CardDescription> <div className="space-y-2">
{t('settingsPage.browserSection.codex.description')} <p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
</CardDescription> {t('settingsPage.browserSection.claude.effectivePath')}
</div> </p>
<Badge variant="outline" className={statusTone(status.codex.state)}> <div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
{stateLabel(status.codex.state)} <p className="break-all font-mono text-[11px] leading-relaxed">
</Badge> {status.claude.effectiveUserDataDir}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-center">
<div>
<Label htmlFor="browser-codex-enabled">
{t('settingsPage.browserSection.codex.enabledLabel')}
</Label>
<p className="mt-1 text-sm text-muted-foreground">
{t('settingsPage.browserSection.codex.enabledDescription')}
</p> </p>
</div> </div>
<p className="text-[10px] font-medium text-muted-foreground/60">
<span className="font-bold">
{t('settingsPage.browserSection.claude.recommendedPath')}:
</span>{' '}
{status.claude.recommendedUserDataDir}
</p>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
{t('settingsPage.browserSection.claude.managedRuntime')}
</p>
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
<p className="font-semibold text-primary/80">
{status.claude.managedMcpServerName}
</p>
<p className="mt-1 break-all font-mono text-[11px] leading-relaxed text-muted-foreground/80">
{status.claude.managedMcpServerPath}
</p>
</div>
</div>
</div>
</DiagnosticsSection>
</div>
</DoubleBezelCard>
</motion.div>
{/* Codex Lane Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<DoubleBezelCard
title={t('settingsPage.browserSection.codex.title')}
description={t('settingsPage.browserSection.codex.description')}
action={
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-muted/30 px-4 py-2 dark:border-white/[0.06] dark:bg-zinc-900/45">
<Label
htmlFor="browser-codex-enabled"
className="cursor-pointer text-xs font-semibold leading-none"
>
{effectiveConfig.codex.enabled ? 'ENABLED' : 'DISABLED'}
</Label>
<Switch <Switch
id="browser-codex-enabled" id="browser-codex-enabled"
checked={effectiveConfig.codex.enabled} checked={effectiveConfig.codex.enabled}
@@ -508,70 +669,110 @@ export default function BrowserSection() {
updateCodexDraft(current ?? effectiveConfig, { enabled: next }) updateCodexDraft(current ?? effectiveConfig, { enabled: next })
) )
} }
aria-label={t('settingsPage.browserSection.codex.enabledLabel')}
/> />
</div> </div>
<Button
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 md:grid-cols-2"> onClick={saveCodexSettings}
<div> disabled={saving || !hasCodexChanges}
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> className="rounded-full px-6 shadow-md transition-transform active:scale-95"
{t('settingsPage.browserSection.readiness')} >
</p>
<p className="mt-1 font-medium">{status.codex.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{status.codex.detail}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.nextStep')}
</p>
<p className="mt-1 text-sm">{status.codex.nextStep}</p>
</div>
</div>
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.codex.serverName')}
</p>
<p className="mt-1 font-mono text-xs">{status.codex.serverName}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.codex.overrideSupport')}
</p>
<p className="mt-1">
{status.codex.supportsConfigOverrides
? t('settingsPage.browserSection.codex.overrideSupported')
: t('settingsPage.browserSection.codex.overrideUnsupported')}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.codex.binary')}
</p>
<p className="mt-1 break-all font-mono text-xs">
{status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')}
</p>
{status.codex.version ? (
<p className="mt-2 text-xs text-muted-foreground">{status.codex.version}</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={saveCodexSettings} disabled={saving || !hasCodexChanges}>
{saving ? ( {saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" /> <ArrowsClockwise className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle weight="bold" size={16} className="mr-2" />
)} )}
{t('settingsPage.browserSection.actions.saveCodex')} {t('settingsPage.browserSection.actions.saveCodex')}
</Button> </Button>
</div> </div>
</CardContent> }
</Card> >
<div className="grid gap-12">
<StatusStrip
state={status.codex.state}
title={status.codex.title}
detail={status.codex.detail}
nextStep={status.codex.nextStep}
/>
<DiagnosticsSection
title={t('settingsPage.browserSection.technicalDetails')}
defaultOpen={status.codex.state === 'unsupported_build'}
>
<div className="grid gap-6 sm:grid-cols-3">
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
{t('settingsPage.browserSection.codex.serverName')}
</p>
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
<p className="font-mono text-[11px] font-semibold">
{status.codex.serverName}
</p>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
{t('settingsPage.browserSection.codex.overrideSupport')}
</p>
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 p-3 font-medium text-xs dark:border-white/[0.05] dark:bg-zinc-900/45">
{status.codex.supportsConfigOverrides ? (
<CheckCircle className="text-emerald-500" size={16} weight="bold" />
) : (
<XCircle className="text-rose-500" size={16} weight="bold" />
)}
{status.codex.supportsConfigOverrides
? t('settingsPage.browserSection.codex.overrideSupported')
: t('settingsPage.browserSection.codex.overrideUnsupported')}
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
{t('settingsPage.browserSection.codex.binary')}
</p>
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
<p className="break-all font-mono text-[11px] leading-relaxed">
{status.codex.binaryPath ??
t('settingsPage.browserSection.codex.notDetected')}
</p>
{status.codex.version && (
<div className="mt-2 inline-flex rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-bold text-primary">
{status.codex.version}
</div>
)}
</div>
</div>
</div>
</DiagnosticsSection>
</div>
</DoubleBezelCard>
</motion.div>
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
); );
} }
function updateClaudeDraft(
source: BrowserConfig,
updates: Partial<BrowserConfig['claude']>
): BrowserConfig {
return {
...source,
claude: {
...source.claude,
...updates,
},
};
}
function updateCodexDraft(
source: BrowserConfig,
updates: Partial<BrowserConfig['codex']>
): BrowserConfig {
return {
...source,
codex: {
...source.codex,
...updates,
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getClientPlatformKey } from '@/lib/platform';
describe('getClientPlatformKey', () => {
it('prefers navigator.userAgentData.platform when available', () => {
expect(
getClientPlatformKey({
userAgentData: { platform: 'macOS' },
platform: 'Win32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
})
).toBe('darwin');
});
it('falls back to navigator.platform when userAgentData is unavailable', () => {
expect(
getClientPlatformKey({
platform: 'Win32',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
})
).toBe('win32');
});
it('falls back to user-agent parsing when platform is unavailable', () => {
expect(
getClientPlatformKey({
platform: '',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
})
).toBe('win32');
});
});