mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
feat(settings): refine browser automation tab
This commit is contained in:
1 parent
4e30c9b080
commit
06f6f5485f
6 files changed
+584
-306
No files matched your search
+10
@@ -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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 設定を保存',
|
||||||
|
|||||||
@@ -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 || '');
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user