diff --git a/ui/bun.lock b/ui/bun.lock index d79f76ca..cd5c1000 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -8,6 +8,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^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-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -28,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", @@ -273,6 +275,8 @@ "@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/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=="], + "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=="], "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=="], + "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=="], "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=="], diff --git a/ui/package.json b/ui/package.json index a5a7ad53..872ab80e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,6 +22,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^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-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -42,6 +43,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 502ea578..5329df3a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -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.', readiness: 'Readiness', nextStep: 'Next step', + technicalDetails: 'Technical Details', + diagnostics: 'Diagnostics', actions: { saveClaude: 'Save Claude settings', saveCodex: 'Save Codex settings', @@ -4846,6 +4848,8 @@ const resources = { '在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。', readiness: '就绪状态', nextStep: '下一步', + technicalDetails: '技术细节', + diagnostics: '诊断信息', actions: { saveClaude: '保存 Claude 设置', 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.', readiness: 'Trạng thái sẵn sàng', nextStep: 'Bước tiếp theo', + technicalDetails: 'Chi tiết kỹ thuật', + diagnostics: 'Chẩn đoán', actions: { saveClaude: 'Lưu cấu hình Claude', saveCodex: 'Lưu cấu hình Codex', @@ -9783,6 +9789,8 @@ const resources = { 'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。', readiness: '準備状況', nextStep: '次の手順', + technicalDetails: '技術的な詳細', + diagnostics: '診断情報', actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', diff --git a/ui/src/lib/platform.ts b/ui/src/lib/platform.ts new file mode 100644 index 00000000..535abdb7 --- /dev/null +++ b/ui/src/lib/platform.ts @@ -0,0 +1,25 @@ +export type ClientPlatformKey = 'darwin' | 'linux' | 'win32'; + +type NavigatorWithUserAgentData = Pick & { + 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 || ''); +} diff --git a/ui/src/pages/settings/sections/browser/index.tsx b/ui/src/pages/settings/sections/browser/index.tsx index 49dc8301..6c997257 100644 --- a/ui/src/pages/settings/sections/browser/index.tsx +++ b/ui/src/pages/settings/sections/browser/index.tsx @@ -1,60 +1,40 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; -import { - AlertCircle, - CheckCircle2, - Copy, - Monitor, - RefreshCw, - SearchCheck, - Wrench, -} from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { getClientPlatformKey } from '@/lib/platform'; import { cn } from '@/lib/utils'; import { useBrowserConfig, useRawConfig } from '../../hooks'; import type { BrowserConfig } from '../../types'; -function getPlatformKey(): 'darwin' | 'linux' | 'win32' { - const platform = navigator.platform.toLowerCase(); - if (platform.includes('mac')) return 'darwin'; - if (platform.includes('win')) return 'win32'; - return 'linux'; -} +// --- Constants & Helpers --- function parsePortDraft(value: string): number | null { - if (!/^\d+$/.test(value.trim())) { - return null; - } - + if (!/^\d+$/.test(value.trim())) return null; const port = Number.parseInt(value.trim(), 10); - if (port < 1 || port > 65535) { - return null; - } - + if (port < 1 || port > 65535) return null; 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( userDataDir: string, devtoolsPort: number, @@ -70,32 +50,181 @@ function buildLaunchCommand( return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; } -function updateClaudeDraft( - source: BrowserConfig, - updates: Partial -): BrowserConfig { - return { - ...source, - claude: { - ...source.claude, - ...updates, - }, - }; +// --- High-End Components --- + +/** + * Double-Bezel Card Pattern + * Machined hardware look with nested enclosures + */ +function DoubleBezelCard({ + children, + className, + title, + description, + badge, + action, +}: { + children: React.ReactNode; + className?: string; + title?: string; + description?: string; + badge?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+ {/* Subtle highlight inner shadow */} +
+ + {(title || action) && ( +
+
+
+

{title}

+ {badge} +
+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ )} +
{children}
+
+
+ ); } -function updateCodexDraft( - source: BrowserConfig, - updates: Partial -): BrowserConfig { - return { - ...source, - codex: { - ...source.codex, - ...updates, - }, - }; +/** + * Animated Status Strip + * Highlights readiness with cinematic dynamics + */ +function StatusStrip({ + state, + title, + detail, + nextStep, +}: { + state: string; + title: string; + detail: string; + nextStep: string; +}) { + const { t } = useTranslation(); + const isReady = state === 'ready' || state === 'enabled'; + const isError = !isReady && state !== 'disabled'; + + return ( +
+
+
+ {isReady ? ( + + ) : isError ? ( + + ) : ( + + )} +
+
+
+ + {t('settingsPage.browserSection.readiness')} + + {isReady && ( + + )} +
+

{title}

+

{detail}

+ {nextStep && ( +
+ +

+ + {t('settingsPage.browserSection.nextStep')}: + {' '} + {nextStep} +

+
+ )} +
+
+
+ ); } +/** + * 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 ( + + + + + +
+ {children} +
+
+
+ ); +} + +// --- Main Section --- + export default function BrowserSection() { const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); @@ -132,7 +261,7 @@ export default function BrowserSection() { return buildLaunchCommand( effectiveConfig.claude.userDataDir, effectiveConfig.claude.devtoolsPort, - getPlatformKey() + getClientPlatformKey() ); }, [effectiveConfig]); @@ -168,7 +297,6 @@ export default function BrowserSection() { const saveClaudeSettings = useCallback(async () => { if (!effectiveConfig || claudePort === null) return; - const saved = await saveConfig({ claude: { enabled: effectiveConfig.claude.enabled, @@ -176,7 +304,6 @@ export default function BrowserSection() { devtoolsPort: claudePort, }, }); - if (saved) { await fetchRawConfig(); setActionMessage(null); @@ -187,13 +314,11 @@ export default function BrowserSection() { const saveCodexSettings = useCallback(async () => { if (!effectiveConfig) return; - const saved = await saveConfig({ codex: { enabled: effectiveConfig.codex.enabled, }, }); - if (saved) { await fetchRawConfig(); setActionMessage(null); @@ -208,12 +333,21 @@ export default function BrowserSection() { setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied')); }, [preferredLaunchCommand, t]); + // -- Render Helpers -- + if (loading) { return (
-
- - {t('settings.loading')} +
+ + + + + {t('settings.loading')} +
); @@ -221,357 +355,424 @@ export default function BrowserSection() { if (!config || !status || !effectiveConfig) { return ( -
- - - +
+ + + {error ?? t('settingsPage.browserSection.description')} +
+ +
-
- -
); } return ( -
-
+ {/* Toast Notification Surface */} + + {(error || success || actionMessage) && ( + + {error ? ( +
+ + {error} +
+ ) : ( +
+ + {actionMessage ?? t('commonToast.settingsSaved')} +
+ )} +
)} - > - {error && ( - - - {error} - - )} - {!error && (success || actionMessage) && ( -
- - - {actionMessage ?? t('commonToast.settingsSaved')} - -
- )} -
+ -
-
-
-
- -

{t('settingsPage.browserSection.title')}

+
+ {/* Header Section */} + +
+
+
+ +
+
+

+ {t('settingsPage.browserSection.title')} +

+

+ {t('settingsPage.browserSection.description')} +

+
-

- {t('settingsPage.browserSection.description')} -

- -
+ - - - - {t('settingsPage.browserSection.primaryTitle')}{' '} - {t('settingsPage.browserSection.primaryDescription')} - - + + + + + + {t('settingsPage.browserSection.primaryTitle')} + {' '} + {t('settingsPage.browserSection.primaryDescription')} + + + - - -
-
- {t('settingsPage.browserSection.claude.title')} - - {t('settingsPage.browserSection.claude.description')} - -
- - {stateLabel(status.claude.state)} - -
-
- -
-
- -

- {t('settingsPage.browserSection.claude.enabledDescription')} -

-
- - setDraft((current) => - updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) - ) - } - aria-label={t('settingsPage.browserSection.claude.enabledLabel')} - /> -
- -
-
- - - setDraft((current) => - updateClaudeDraft(current ?? effectiveConfig, { - userDataDir: event.target.value, - }) - ) - } - placeholder={status.claude.recommendedUserDataDir} - /> -

- {t('settingsPage.browserSection.claude.userDataDirHint')} -

-
- -
- - setClaudePortDraft(event.target.value)} - inputMode="numeric" - /> -

- {claudePortInvalid - ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') - : t('settingsPage.browserSection.claude.devtoolsPortHint')} -

-
-
- -
-
-

- {t('settingsPage.browserSection.readiness')} -

-

{status.claude.title}

-

{status.claude.detail}

-
-
-

- {t('settingsPage.browserSection.nextStep')} -

-

{status.claude.nextStep}

-
-
- -
-
-

- {t('settingsPage.browserSection.claude.effectivePath')} -

-

- {status.claude.effectiveUserDataDir} -

-

- {t('settingsPage.browserSection.claude.recommendedPath')}:{' '} - {status.claude.recommendedUserDataDir} -

-
-
-

- {t('settingsPage.browserSection.claude.managedRuntime')} -

-

{status.claude.managedMcpServerName}

-

- {status.claude.managedMcpServerPath} -

-
-
- - {status.claude.overrideActive ? ( - - - - {t('settingsPage.browserSection.claude.overrideMessage', { - source: status.claude.source, - })} - - - ) : null} - -
-
-
-

- {t('settingsPage.browserSection.claude.launchGuidance')} -

-

- {t('settingsPage.browserSection.claude.launchGuidanceHint')} -

+ {/* Claude Lane Card */} + + +
+ + + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + />
+
-
-                  {preferredLaunchCommand}
-                
- {status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? ( -

- DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL} -

- ) : null} -
- -
- - -
- - - - - -
-
- {t('settingsPage.browserSection.codex.title')} - - {t('settingsPage.browserSection.codex.description')} - -
- - {stateLabel(status.codex.state)} - -
-
- -
-
- -

- {t('settingsPage.browserSection.codex.enabledDescription')} -

-
- - setDraft((current) => - updateCodexDraft(current ?? effectiveConfig, { enabled: next }) - ) - } - aria-label={t('settingsPage.browserSection.codex.enabledLabel')} + } + > +
+ -
-
-
-

- {t('settingsPage.browserSection.readiness')} -

-

{status.codex.title}

-

{status.codex.detail}

-
-
-

- {t('settingsPage.browserSection.nextStep')} -

-

{status.codex.nextStep}

-
-
+
+
+
+ + + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { + userDataDir: event.target.value, + }) + ) + } + className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + placeholder={status.claude.recommendedUserDataDir} + /> +

+ {t('settingsPage.browserSection.claude.userDataDirHint')} +

+
-
-
-

- {t('settingsPage.browserSection.codex.serverName')} -

-

{status.codex.serverName}

-
-
-

- {t('settingsPage.browserSection.codex.overrideSupport')} -

-

- {status.codex.supportsConfigOverrides - ? t('settingsPage.browserSection.codex.overrideSupported') - : t('settingsPage.browserSection.codex.overrideUnsupported')} -

-
-
-

- {t('settingsPage.browserSection.codex.binary')} -

-

- {status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')} -

- {status.codex.version ? ( -

{status.codex.version}

- ) : null} -
-
+
+ + setClaudePortDraft(event.target.value)} + inputMode="numeric" + className="max-w-[120px] rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + /> +

+ {claudePortInvalid + ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') + : t('settingsPage.browserSection.claude.devtoolsPortHint')} +

+
+
-
- +
+
+
+
+

+ {t('settingsPage.browserSection.claude.launchGuidance')} +

+

+ {t('settingsPage.browserSection.claude.launchGuidanceHint')} +

+
+ +
+ + {preferredLaunchCommand} + +
+ + {status.claude.overrideActive && ( +
+ + {t('settingsPage.browserSection.claude.overrideMessage', { + source: status.claude.source, + })} +
+ )} +
+
+ + +
+
+

+ {t('settingsPage.browserSection.claude.effectivePath')} +

+
+

+ {status.claude.effectiveUserDataDir} +

+
+

+ + {t('settingsPage.browserSection.claude.recommendedPath')}: + {' '} + {status.claude.recommendedUserDataDir} +

+
+
+

+ {t('settingsPage.browserSection.claude.managedRuntime')} +

+
+

+ {status.claude.managedMcpServerName} +

+

+ {status.claude.managedMcpServerPath} +

+
+
+
+
- - + + + + {/* Codex Lane Card */} + + +
+ + + setDraft((current) => + updateCodexDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + /> +
+ +
+ } + > +
+ + + +
+
+

+ {t('settingsPage.browserSection.codex.serverName')} +

+
+

+ {status.codex.serverName} +

+
+
+
+

+ {t('settingsPage.browserSection.codex.overrideSupport')} +

+
+ {status.codex.supportsConfigOverrides ? ( + + ) : ( + + )} + {status.codex.supportsConfigOverrides + ? t('settingsPage.browserSection.codex.overrideSupported') + : t('settingsPage.browserSection.codex.overrideUnsupported')} +
+
+
+

+ {t('settingsPage.browserSection.codex.binary')} +

+
+

+ {status.codex.binaryPath ?? + t('settingsPage.browserSection.codex.notDetected')} +

+ {status.codex.version && ( +
+ {status.codex.version} +
+ )} +
+
+
+
+
+ +
); } + +function updateClaudeDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + claude: { + ...source.claude, + ...updates, + }, + }; +} + +function updateCodexDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + codex: { + ...source.codex, + ...updates, + }, + }; +} diff --git a/ui/tests/unit/ui/lib/platform.test.ts b/ui/tests/unit/ui/lib/platform.test.ts new file mode 100644 index 00000000..b97f923a --- /dev/null +++ b/ui/tests/unit/ui/lib/platform.test.ts @@ -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'); + }); +});