feat(i18n): Added support for Chinese language pack

This commit is contained in:
lidong committed 2026-03-02 18:39:34 +08:00
1 parent bd389fda44
commit 7ffb8a4234
84 files changed
+4122 -1281

No files matched your search

+12
View File
@@ -28,12 +28,14 @@
"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",
"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",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.68.0", "react-hook-form": "^7.68.0",
"react-i18next": "^16.5.4",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
@@ -761,10 +763,14 @@
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"i18next": ["i18next@25.8.13", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -931,6 +937,8 @@
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="], "react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
@@ -1069,12 +1077,16 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
"vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="],
+2
View File
@@ -42,12 +42,14 @@
"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",
"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",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.68.0", "react-hook-form": "^7.68.0",
"react-i18next": "^16.5.4",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
+1
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client'; import { queryClient } from '@/lib/query-client';
import '@/lib/i18n';
import { ThemeProvider } from '@/components/layout/theme-provider'; import { ThemeProvider } from '@/components/layout/theme-provider';
import { PrivacyProvider } from '@/contexts/privacy-context'; import { PrivacyProvider } from '@/contexts/privacy-context';
import { AuthProvider } from '@/contexts/auth-context'; import { AuthProvider } from '@/contexts/auth-context';
@@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
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 { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
interface AccountSafetyWarningCardProps { interface AccountSafetyWarningCardProps {
@@ -24,21 +25,14 @@ export function AccountSafetyWarningCard({
disabled = false, disabled = false,
showProxySettingsLink = false, showProxySettingsLink = false,
}: AccountSafetyWarningCardProps) { }: AccountSafetyWarningCardProps) {
const title = 'OAuth Account Safety Warning'; const { t } = useTranslation();
const subtitle = 'Issue #509 · Gemini + AGY OAuth risk'; const title = t('accountSafetyWarning.title');
const firstLine = ( const subtitle = t('accountSafetyWarning.subtitle');
<> const firstLine = t('accountSafetyWarning.firstLine');
Issue #509 documents suspension/ban reports tied to <code className="font-mono">ccs agy</code>{' '} const secondLine = t('accountSafetyWarning.secondLine');
and shared-account usage between <code className="font-mono">ccs gemini</code> and{' '}
<code className="font-mono">ccs agy</code>.
</>
);
const secondLine = (
<>Continue only if you accept full responsibility for OAuth and account-access risk.</>
);
const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509'; const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509';
const issueLabel = 'Read issue #509'; const issueLabel = t('accountSafetyWarning.issueLabel');
const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy'; const proxySettingsLabel = t('accountSafetyWarning.proxySettingsLabel');
return ( return (
<section <section
+40 -28
View File
@@ -25,6 +25,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react'; import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog'; import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
import { import {
useSetDefaultAccount, useSetDefaultAccount,
@@ -40,6 +41,7 @@ interface AccountsTableProps {
} }
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const { t } = useTranslation();
const setDefaultMutation = useSetDefaultAccount(); const setDefaultMutation = useSetDefaultAccount();
const deleteMutation = useDeleteAccount(); const deleteMutation = useDeleteAccount();
const resetDefaultMutation = useResetDefaultAccount(); const resetDefaultMutation = useResetDefaultAccount();
@@ -50,14 +52,14 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const columns: ColumnDef<Account>[] = [ const columns: ColumnDef<Account>[] = [
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Name', header: t('accountsTable.name'),
size: 200, size: 200,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span> <span className="font-medium">{row.original.name}</span>
{row.original.name === defaultAccount && ( {row.original.name === defaultAccount && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20"> <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">
default {t('accountsTable.defaultBadge')}
</span> </span>
)} )}
</div> </div>
@@ -65,7 +67,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
}, },
{ {
accessorKey: 'type', accessorKey: 'type',
header: 'Type', header: t('accountsTable.type'),
size: 100, size: 100,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span> <span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
@@ -73,7 +75,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
}, },
{ {
accessorKey: 'created', accessorKey: 'created',
header: 'Created', header: t('accountsTable.created'),
size: 150, size: 150,
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.created); const date = new Date(row.original.created);
@@ -82,7 +84,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
}, },
{ {
accessorKey: 'last_used', accessorKey: 'last_used',
header: 'Last Used', header: t('accountsTable.lastUsed'),
size: 150, size: 150,
cell: ({ row }) => { cell: ({ row }) => {
if (!row.original.last_used) return <span className="text-muted-foreground/50">-</span>; if (!row.original.last_used) return <span className="text-muted-foreground/50">-</span>;
@@ -92,7 +94,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
}, },
{ {
id: 'context', id: 'context',
header: 'History Sync', header: t('accountsTable.historySync'),
size: 170, size: 170,
cell: ({ row }) => { cell: ({ row }) => {
if (row.original.type === 'cliproxy') { if (row.original.type === 'cliproxy') {
@@ -103,32 +105,42 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (mode === 'shared') { if (mode === 'shared') {
const group = row.original.context_group || 'default'; const group = row.original.context_group || 'default';
if (row.original.continuity_mode === 'deeper') { if (row.original.continuity_mode === 'deeper') {
return <span className="text-muted-foreground">shared ({group}, deeper)</span>; return (
<span className="text-muted-foreground">
{t('accountsTable.sharedGroupDeeper', { group })}
</span>
);
} }
if (row.original.continuity_inferred) { if (row.original.continuity_inferred) {
return ( return (
<span className="text-amber-700 dark:text-amber-400"> <span className="text-amber-700 dark:text-amber-400">
shared ({group}, standard legacy) {t('accountsTable.sharedGroupLegacy', { group })}
</span> </span>
); );
} }
return <span className="text-muted-foreground">shared ({group}, standard)</span>; return (
<span className="text-muted-foreground">
{t('accountsTable.sharedGroupStandard', { group })}
</span>
);
} }
if (row.original.context_inferred) { if (row.original.context_inferred) {
return ( return (
<span className="text-amber-700 dark:text-amber-400">isolated (legacy default)</span> <span className="text-amber-700 dark:text-amber-400">
{t('accountsTable.isolatedLegacy')}
</span>
); );
} }
return <span className="text-muted-foreground">isolated</span>; return <span className="text-muted-foreground">{t('accountsTable.isolated')}</span>;
}, },
}, },
{ {
id: 'actions', id: 'actions',
header: 'Actions', header: t('accountsTable.actions'),
size: 220, size: 220,
cell: ({ row }) => { cell: ({ row }) => {
const isDefault = row.original.name === defaultAccount; const isDefault = row.original.name === defaultAccount;
@@ -149,10 +161,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
className="h-8 px-2" className="h-8 px-2"
disabled={isPending} disabled={isPending}
onClick={() => setContextTarget(row.original)} onClick={() => setContextTarget(row.original)}
title="Edit sync mode, group, and continuity depth" title={t('accountsTable.syncTitle')}
> >
<Pencil className="w-3.5 h-3.5 mr-1" /> <Pencil className="w-3.5 h-3.5 mr-1" />
Sync {t('accountsTable.sync')}
</Button> </Button>
)} )}
{!isCliproxy && hasLegacyInference && ( {!isCliproxy && hasLegacyInference && (
@@ -177,10 +189,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
: undefined, : undefined,
}) })
} }
title="Confirm this legacy account's current mode as explicit" title={t('accountsTable.confirmLegacyTitle')}
> >
<CheckCheck className="w-3 h-3 mr-1" /> <CheckCheck className="w-3 h-3 mr-1" />
Confirm {t('accountsTable.confirm')}
</Button> </Button>
)} )}
<Button <Button
@@ -191,7 +203,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
onClick={() => setDefaultMutation.mutate(row.original.name)} onClick={() => setDefaultMutation.mutate(row.original.name)}
> >
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} /> <Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
{isDefault ? 'Active' : 'Set Default'} {isDefault ? t('accountsTable.active') : t('accountsTable.setDefault')}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -199,7 +211,11 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10" className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={isDefault || isPending} disabled={isDefault || isPending}
onClick={() => setDeleteTarget(row.original.name)} onClick={() => setDeleteTarget(row.original.name)}
title={isDefault ? 'Cannot delete default account' : 'Delete account'} title={
isDefault
? t('accountsTable.cannotDeleteDefault')
: t('accountsTable.deleteAccount')
}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
@@ -218,10 +234,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">{t('accountsTable.noAccounts')}</div>
No CCS auth accounts found. Use{' '}
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
</div>
); );
} }
@@ -279,7 +292,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
disabled={resetDefaultMutation.isPending} disabled={resetDefaultMutation.isPending}
> >
<RotateCcw className="w-4 h-4 mr-2" /> <RotateCcw className="w-4 h-4 mr-2" />
Reset to CCS Default {t('accountsTable.resetToDefault')}
</Button> </Button>
</div> </div>
)} )}
@@ -293,14 +306,13 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}> <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle> <AlertDialogTitle>{t('accountsTable.deleteDialogTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the account &quot;{deleteTarget}&quot;? This will {t('accountsTable.deleteDialogDesc', { name: deleteTarget ?? '' })}
remove the profile and all its session data. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t('accountsTable.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { onClick={() => {
@@ -310,7 +322,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
} }
}} }}
> >
Delete {t('accountsTable.delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -45,6 +45,7 @@ import {
KIRO_AUTH_METHOD_OPTIONS, KIRO_AUTH_METHOD_OPTIONS,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
import type { KiroAuthMethod } from '@/lib/provider-config'; import type { KiroAuthMethod } from '@/lib/provider-config';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface AddAccountDialogProps { interface AddAccountDialogProps {
@@ -76,6 +77,7 @@ export function AddAccountDialog({
const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false);
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false);
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD); const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
const { t } = useTranslation();
const wasAuthenticatingRef = useRef(false); const wasAuthenticatingRef = useRef(false);
const authFlow = useCliproxyAuthFlow(); const authFlow = useCliproxyAuthFlow();
const kiroImportMutation = useKiroImport(); const kiroImportMutation = useKiroImport();
@@ -318,13 +320,13 @@ export function AddAccountDialog({
}} }}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle> <DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
<DialogDescription> <DialogDescription>
{isKiro {isKiro
? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.' ? t('addAccountDialog.descKiro')
: isDeviceCode : isDeviceCode
? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' ? t('addAccountDialog.descDeviceCode')
: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} : t('addAccountDialog.descOauth')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -344,10 +346,9 @@ export function AddAccountDialog({
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100"> <div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
<div className="mb-1.5 flex items-center gap-1.5 font-semibold"> <div className="mb-1.5 flex items-center gap-1.5 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" /> <ShieldAlert className="h-3.5 w-3.5" />
Power user mode enabled {t('addAccountDialog.powerUserEnabled')}
</div> </div>
AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full {t('addAccountDialog.powerUserSkipped')}
responsibility for OAuth/account risk.
</div> </div>
)} )}
@@ -367,7 +368,7 @@ export function AddAccountDialog({
{/* Kiro auth method */} {/* Kiro auth method */}
{isKiro && !showAuthUI && ( {isKiro && !showAuthUI && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="kiro-auth-method">Auth Method</Label> <Label htmlFor="kiro-auth-method">{t('addAccountDialog.authMethod')}</Label>
<Select <Select
value={kiroAuthMethod} value={kiroAuthMethod}
onValueChange={(value) => { onValueChange={(value) => {
@@ -376,7 +377,7 @@ export function AddAccountDialog({
}} }}
> >
<SelectTrigger id="kiro-auth-method"> <SelectTrigger id="kiro-auth-method">
<SelectValue placeholder="Select Kiro auth method" /> <SelectValue placeholder={t('addAccountDialog.selectKiroAuthMethod')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{KIRO_AUTH_METHOD_OPTIONS.map((option) => ( {KIRO_AUTH_METHOD_OPTIONS.map((option) => (
@@ -394,7 +395,9 @@ export function AddAccountDialog({
{!showAuthUI && ( {!showAuthUI && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nickname"> <Label htmlFor="nickname">
{requiresNickname ? 'Nickname (required)' : 'Nickname (optional)'} {requiresNickname
? t('addAccountDialog.nicknameRequired')
: t('addAccountDialog.nicknameOptional')}
</Label> </Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
@@ -405,15 +408,15 @@ export function AddAccountDialog({
setNickname(e.target.value); setNickname(e.target.value);
setLocalError(null); setLocalError(null);
}} }}
placeholder="e.g., work, personal" placeholder={t('addAccountDialog.nicknamePlaceholder')}
disabled={isPending} disabled={isPending}
className="flex-1" className="flex-1"
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{requiresNickname {requiresNickname
? 'Required for this provider. Use a unique friendly name (e.g., work, personal).' ? t('addAccountDialog.nicknameRequiredHint')
: 'A friendly name to identify this account. Auto-generated from email if left empty.'} : t('addAccountDialog.nicknameOptionalHint')}
</p> </p>
</div> </div>
)} )}
@@ -425,12 +428,12 @@ export function AddAccountDialog({
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" /> <Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Waiting for authentication... {t('addAccountDialog.waitingForAuth')}
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{authFlow.isDeviceCodeFlow {authFlow.isDeviceCodeFlow
? 'A verification code dialog will appear shortly. Enter the code on the provider website.' ? t('addAccountDialog.deviceCodeHint')
: 'Complete the authentication in your browser. This dialog closes automatically.'} : t('addAccountDialog.browserHint')}
</p> </p>
</div> </div>
@@ -438,7 +441,7 @@ export function AddAccountDialog({
{authFlow.authUrl && !authFlow.isDeviceCodeFlow && ( {authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">Open this URL in any browser to sign in:</Label> <Label className="text-xs">{t('addAccountDialog.openUrlLabel')}</Label>
<div className="p-3 bg-muted rounded-md"> <div className="p-3 bg-muted rounded-md">
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3"> <p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
{authFlow.authUrl} {authFlow.authUrl}
@@ -448,12 +451,12 @@ export function AddAccountDialog({
{copied ? ( {copied ? (
<> <>
<Check className="w-3 h-3 mr-1" /> <Check className="w-3 h-3 mr-1" />
Copied {t('addAccountDialog.copied')}
</> </>
) : ( ) : (
<> <>
<Copy className="w-3 h-3 mr-1" /> <Copy className="w-3 h-3 mr-1" />
Copy {t('addAccountDialog.copy')}
</> </>
)} )}
</Button> </Button>
@@ -464,15 +467,12 @@ export function AddAccountDialog({
if (!authFlow.authUrl) return; if (!authFlow.authUrl) return;
const popup = window.open(authFlow.authUrl, '_blank'); const popup = window.open(authFlow.authUrl, '_blank');
if (!popup || popup.closed || typeof popup.closed === 'undefined') { if (!popup || popup.closed || typeof popup.closed === 'undefined') {
toast.warning( toast.warning(t('addAccountDialog.popupBlocked'), { duration: 5000 });
'Popup blocked. Copy the URL above and open it manually in your browser.',
{ duration: 5000 }
);
} }
}} }}
> >
<ExternalLink className="w-3 h-3 mr-1" /> <ExternalLink className="w-3 h-3 mr-1" />
Open {t('addAccountDialog.open')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -481,13 +481,13 @@ export function AddAccountDialog({
{/* Callback paste field */} {/* Callback paste field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="callback-url" className="text-xs"> <Label htmlFor="callback-url" className="text-xs">
Redirect didn&apos;t work? Paste the callback URL: {t('addAccountDialog.redirectPasteLabel')}
</Label> </Label>
<Input <Input
id="callback-url" id="callback-url"
value={callbackUrl} value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)} onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="Paste the redirect URL here..." placeholder={t('addAccountDialog.callbackPlaceholder')}
className="font-mono text-xs" className="font-mono text-xs"
/> />
<Button <Button
@@ -499,10 +499,10 @@ export function AddAccountDialog({
{authFlow.isSubmittingCallback ? ( {authFlow.isSubmittingCallback ? (
<> <>
<Loader2 className="w-3 h-3 mr-1 animate-spin" /> <Loader2 className="w-3 h-3 mr-1 animate-spin" />
Submitting... {t('addAccountDialog.submitting')}
</> </>
) : ( ) : (
'Submit Callback' t('addAccountDialog.submitCallback')
)} )}
</Button> </Button>
</div> </div>
@@ -511,7 +511,7 @@ export function AddAccountDialog({
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && ( {!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<p className="text-xs text-center text-muted-foreground"> <p className="text-xs text-center text-muted-foreground">
Preparing sign-in URL... {t('addAccountDialog.preparingUrl')}
</p> </p>
)} )}
</div> </div>
@@ -524,26 +524,26 @@ export function AddAccountDialog({
{kiroImportMutation.isPending && ( {kiroImportMutation.isPending && (
<p className="text-sm text-center text-muted-foreground"> <p className="text-sm text-center text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" /> <Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Importing token from Kiro IDE... {t('addAccountDialog.importingToken')}
</p> </p>
)} )}
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2"> <div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleCancel}> <Button variant="ghost" onClick={handleCancel}>
Cancel {t('addAccountDialog.cancel')}
</Button> </Button>
{isKiro && !showAuthUI && ( {isKiro && !showAuthUI && (
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}> <Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
{kiroImportMutation.isPending ? ( {kiroImportMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Importing... {t('addAccountDialog.importing')}
</> </>
) : ( ) : (
<> <>
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Import from IDE {t('addAccountDialog.importFromIde')}
</> </>
)} )}
</Button> </Button>
@@ -560,7 +560,7 @@ export function AddAccountDialog({
} }
> >
<ExternalLink className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
Authenticate {t('addAccountDialog.authenticate')}
</Button> </Button>
)} )}
</div> </div>
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants'; import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants';
@@ -21,6 +22,7 @@ export function AntigravityResponsibilityChecklist({
onChange, onChange,
disabled = false, disabled = false,
}: AntigravityResponsibilityChecklistProps) { }: AntigravityResponsibilityChecklistProps) {
const { t } = useTranslation();
const completedSteps = [ const completedSteps = [
value.reviewedIssue509, value.reviewedIssue509,
value.understandsBanRisk, value.understandsBanRisk,
@@ -50,21 +52,19 @@ export function AntigravityResponsibilityChecklist({
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-semibold leading-5">Antigravity OAuth Responsibility</p> <p className="text-sm font-semibold leading-5">{t('antigravityChecklist.title')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('antigravityChecklist.subtitle')}</p>
Complete all 4 steps before you can authenticate.
</p>
</div> </div>
</div> </div>
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300"> <Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
Mandatory {t('antigravityChecklist.mandatory')}
</Badge> </Badge>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Completion</span> <span>{t('antigravityChecklist.completion')}</span>
<span>{completedSteps}/4 steps</span> <span>{t('antigravityChecklist.stepsCount', { current: completedSteps })}</span>
</div> </div>
<Progress value={progressValue} className="h-2" /> <Progress value={progressValue} className="h-2" />
</div> </div>
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Copy, Check, Terminal } from 'lucide-react'; import { Copy, Check, Terminal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface CreateAuthProfileDialogProps { interface CreateAuthProfileDialogProps {
open: boolean; open: boolean;
@@ -25,6 +26,7 @@ interface CreateAuthProfileDialogProps {
const MAX_CONTEXT_GROUP_LENGTH = 64; const MAX_CONTEXT_GROUP_LENGTH = 64;
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
const { t } = useTranslation();
const [profileName, setProfileName] = useState(''); const [profileName, setProfileName] = useState('');
const [shareContext, setShareContext] = useState(false); const [shareContext, setShareContext] = useState(false);
const [contextGroup, setContextGroup] = useState(''); const [contextGroup, setContextGroup] = useState('');
@@ -52,7 +54,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
] ]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
: 'ccs auth create <name>'; : t('createAuthProfileDialog.commandFallback');
const handleCopy = async () => { const handleCopy = async () => {
if (!isValidName || (shareContext && !isValidContextGroup)) return; if (!isValidName || (shareContext && !isValidContextGroup)) return;
@@ -74,27 +76,23 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Create New Account</DialogTitle> <DialogTitle>{t('createAuthProfileDialog.title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('createAuthProfileDialog.description')}</DialogDescription>
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
edit sync mode, group, and continuity depth later from the Accounts table.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label> <Label htmlFor="profile-name">{t('createAuthProfileDialog.profileName')}</Label>
<Input <Input
id="profile-name" id="profile-name"
value={profileName} value={profileName}
onChange={(e) => setProfileName(e.target.value)} onChange={(e) => setProfileName(e.target.value)}
placeholder="e.g., work, personal, client" placeholder={t('createAuthProfileDialog.profileNamePlaceholder')}
autoComplete="off" autoComplete="off"
/> />
{profileName && !isValidName && ( {profileName && !isValidName && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
Name must start with a letter and contain only letters, numbers, dashes, or {t('createAuthProfileDialog.invalidProfileName')}
underscores.
</p> </p>
)} )}
</div> </div>
@@ -107,22 +105,24 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setShareContext(checked === true)} onCheckedChange={(checked) => setShareContext(checked === true)}
/> />
<Label htmlFor="share-context" className="cursor-pointer"> <Label htmlFor="share-context" className="cursor-pointer">
Enable shared history sync with other ccs auth accounts {t('createAuthProfileDialog.enableSharedHistory')}
</Label> </Label>
</div> </div>
{shareContext && ( {shareContext && (
<div className="space-y-2 pl-6"> <div className="space-y-2 pl-6">
<Label htmlFor="context-group">History Sync Group (optional)</Label> <Label htmlFor="context-group">
{t('createAuthProfileDialog.historySyncGroupOptional')}
</Label>
<Input <Input
id="context-group" id="context-group"
value={contextGroup} value={contextGroup}
onChange={(e) => setContextGroup(e.target.value)} onChange={(e) => setContextGroup(e.target.value)}
placeholder="default, sprint-a, client-x" placeholder={t('createAuthProfileDialog.historySyncGroupPlaceholder')}
autoComplete="off" autoComplete="off"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Leave empty to use the default shared group. Spaces are normalized to dashes. {t('createAuthProfileDialog.historySyncGroupHint')}
</p> </p>
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<Checkbox <Checkbox
@@ -131,17 +131,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setDeeperContinuity(checked === true)} onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
/> />
<Label htmlFor="deeper-continuity" className="cursor-pointer"> <Label htmlFor="deeper-continuity" className="cursor-pointer">
Advanced: deeper continuity mode {t('createAuthProfileDialog.deeperContinuity')}
</Label> </Label>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '} {t('createAuthProfileDialog.deeperContinuityHint')}
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
</p> </p>
{contextGroup.trim().length > 0 && !isValidContextGroup && ( {contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
Group must start with a letter and use only letters, numbers, dashes, or {t('createAuthProfileDialog.invalidContextGroup', {
underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars). max: MAX_CONTEXT_GROUP_LENGTH,
})}
</p> </p>
)} )}
</div> </div>
@@ -149,7 +149,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Command</Label> <Label>{t('createAuthProfileDialog.command')}</Label>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm"> <div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" /> <Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
<code className="flex-1 break-all">{command}</code> <code className="flex-1 break-all">{command}</code>
@@ -170,20 +170,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
</div> </div>
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
<p>After running the command:</p> <p>{t('createAuthProfileDialog.afterRunPrefix')}</p>
<ol className="list-decimal list-inside pl-2 space-y-0.5"> <ol className="list-decimal list-inside pl-2 space-y-0.5">
<li>Complete the Claude login in your browser</li> <li>{t('createAuthProfileDialog.afterRunStep1')}</li>
<li>Return here and refresh to see the new account</li> <li>{t('createAuthProfileDialog.afterRunStep2')}</li>
</ol> </ol>
<p className="pt-1"> <p className="pt-1">{t('createAuthProfileDialog.poolingHint')}</p>
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
page action button.
</p>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleClose}> <Button variant="ghost" onClick={handleClose}>
Close {t('createAuthProfileDialog.close')}
</Button> </Button>
<Button <Button
onClick={handleCopy} onClick={handleCopy}
@@ -192,12 +189,12 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
{copied ? ( {copied ? (
<> <>
<Check className="w-4 h-4 mr-2" /> <Check className="w-4 h-4 mr-2" />
Copied! {t('createAuthProfileDialog.copied')}
</> </>
) : ( ) : (
<> <>
<Copy className="w-4 h-4 mr-2" /> <Copy className="w-4 h-4 mr-2" />
Copy Command {t('createAuthProfileDialog.copyCommand')}
</> </>
)} )}
</Button> </Button>
@@ -17,6 +17,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslation } from 'react-i18next';
import type { Account } from '@/lib/api-client'; import type { Account } from '@/lib/api-client';
import { useUpdateAccountContext } from '@/hooks/use-accounts'; import { useUpdateAccountContext } from '@/hooks/use-accounts';
@@ -32,6 +33,7 @@ interface EditAccountContextDialogProps {
} }
export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) { export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
const { t } = useTranslation();
const updateContextMutation = useUpdateAccountContext(); const updateContextMutation = useUpdateAccountContext();
const [mode, setMode] = useState<ContextMode>( const [mode, setMode] = useState<ContextMode>(
account.context_mode === 'shared' ? 'shared' : 'isolated' account.context_mode === 'shared' ? 'shared' : 'isolated'
@@ -78,89 +80,86 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
<Dialog open onOpenChange={handleOpenChange}> <Dialog open onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit History Sync</DialogTitle> <DialogTitle>{t('editAccountContext.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Configure how "{account.name}" shares history and continuity with other {t('editAccountContext.description', { name: account.name })}
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="context-mode">Sync Mode</Label> <Label htmlFor="context-mode">{t('editAccountContext.syncMode')}</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}> <Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode"> <SelectTrigger id="context-mode">
<SelectValue placeholder="Select context mode" /> <SelectValue placeholder={t('editAccountContext.selectContextMode')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="isolated">isolated (no sync)</SelectItem> <SelectItem value="isolated">{t('editAccountContext.isolatedOption')}</SelectItem>
<SelectItem value="shared">shared (sync enabled)</SelectItem> <SelectItem value="shared">{t('editAccountContext.sharedOption')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{mode === 'shared' {mode === 'shared'
? 'Shared mode reuses workspace context for accounts in the same history sync group.' ? t('editAccountContext.sharedModeHint')
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'} : t('editAccountContext.isolatedModeHint')}
</p> </p>
</div> </div>
{mode === 'shared' && ( {mode === 'shared' && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="context-group">History Sync Group</Label> <Label htmlFor="context-group">{t('editAccountContext.historySyncGroup')}</Label>
<Input <Input
id="context-group" id="context-group"
value={group} value={group}
onChange={(event) => setGroup(event.target.value)} onChange={(event) => setGroup(event.target.value)}
placeholder="default" placeholder={t('editAccountContext.groupPlaceholder')}
autoComplete="off" autoComplete="off"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-` {t('editAccountContext.groupHint', { max: MAX_CONTEXT_GROUP_LENGTH })}
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
</p> </p>
{!isSharedGroupValid && ( {!isSharedGroupValid && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">{t('editAccountContext.invalidGroup')}</p>
Enter a valid group name that starts with a letter.
</p>
)} )}
</div> </div>
)} )}
{mode === 'shared' && ( {mode === 'shared' && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="continuity-mode">Continuity Depth</Label> <Label htmlFor="continuity-mode">{t('editAccountContext.continuityDepth')}</Label>
<Select <Select
value={continuityMode} value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)} onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
> >
<SelectTrigger id="continuity-mode"> <SelectTrigger id="continuity-mode">
<SelectValue placeholder="Select continuity depth" /> <SelectValue placeholder={t('editAccountContext.selectContinuityDepth')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="standard">standard (projects only)</SelectItem> <SelectItem value="standard">{t('editAccountContext.standardOption')}</SelectItem>
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem> <SelectItem value="deeper">{t('editAccountContext.deeperOption')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{continuityMode === 'deeper' {continuityMode === 'deeper'
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.' ? t('editAccountContext.deeperHint')
: 'Standard mode syncs project workspace context only.'} : t('editAccountContext.standardHint')}
</p> </p>
</div> </div>
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Credentials and `.anthropic` remain isolated per account in all modes. {t('editAccountContext.credentialsIsolated')}
</p> </p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}> <Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
Cancel {t('editAccountContext.cancel')}
</Button> </Button>
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}> <Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
{updateContextMutation.isPending ? 'Saving...' : 'Save'} {updateContextMutation.isPending
? t('editAccountContext.saving')
: t('editAccountContext.save')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -17,6 +17,7 @@ import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { AccountData, DragOffset } from './types'; import type { AccountData, DragOffset } from './types';
@@ -94,6 +95,7 @@ export function AccountCard({
onPauseToggle, onPauseToggle,
isPausingAccount, isPausingAccount,
}: AccountCardProps) { }: AccountCardProps) {
const { t } = useTranslation();
const borderSide = BORDER_SIDE_MAP[zone]; const borderSide = BORDER_SIDE_MAP[zone];
const borderColor = getBorderColorStyle(zone, account.color); const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone]; const connectorPosition = CONNECTOR_POSITION_MAP[zone];
@@ -245,7 +247,7 @@ export function AccountCard({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="text-xs"> <TooltipContent side="top" className="text-xs">
{account.paused ? 'Resume account' : 'Pause account'} {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -266,7 +268,7 @@ export function AccountCard({
{quotaLoading ? ( {quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground"> <div className="flex items-center gap-1 text-[8px] text-muted-foreground">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> <Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>Quota...</span> <span>{t('accountCard.quotaLoading')}</span>
</div> </div>
) : minQuotaValue !== null ? ( ) : minQuotaValue !== null ? (
<TooltipProvider> <TooltipProvider>
@@ -275,7 +277,7 @@ export function AccountCard({
<div className="space-y-0.5 cursor-help"> <div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight"> <span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Quota {t('accountCard.quota')}
</span> </span>
<span <span
className={cn( className={cn(
@@ -320,21 +322,23 @@ export function AccountCard({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : quota?.success ? ( ) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div> <div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : quota?.needsReauth ? ( ) : quota?.needsReauth ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400"> <div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
<KeyRound className="w-2.5 h-2.5" /> <KeyRound className="w-2.5 h-2.5" />
<span>Reauth needed</span> <span>{t('accountCard.reauthNeeded')}</span>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]"> <TooltipContent side="top" className="max-w-[200px]">
<p className="text-xs"> <p className="text-xs">
{quota.error?.includes('No refresh token') {quota.error?.includes('No refresh token')
? 'Remove and re-add account' ? t('accountCard.removeAndReadd')
: quota.error || 'Auto-refresh failed'} : quota.error || t('accountCard.autoRefreshFailed')}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -2,6 +2,7 @@
* Connection Timeline Component - right sidebar panel * Connection Timeline Component - right sidebar panel
*/ */
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { STATUS_COLORS } from '@/lib/utils'; import { STATUS_COLORS } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -16,10 +17,13 @@ interface ConnectionTimelineProps {
} }
export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) { export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) {
const { t } = useTranslation();
if (events.length === 0) { if (events.length === 0) {
return ( return (
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]"> <div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
<div className="text-xs text-muted-foreground font-mono">No recent connections</div> <div className="text-xs text-muted-foreground font-mono">
{t('flowViz.noRecentConnections')}
</div>
</div> </div>
); );
} }
@@ -30,7 +34,7 @@ export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelinePr
<div className="flex items-center gap-2 mb-3 px-2"> <div className="flex items-center gap-2 mb-3 px-2">
<Activity className="w-3.5 h-3.5 text-muted-foreground" /> <Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider"> <span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
Connection Timeline {t('flowViz.connectionTimeline')}
</span> </span>
</div> </div>
@@ -2,6 +2,7 @@
* Flow Visualization Header Component * Flow Visualization Header Component
*/ */
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
@@ -20,6 +21,7 @@ export function FlowVizHeader({
hasCustomPositions, hasCustomPositions,
onResetPositions, onResetPositions,
}: FlowVizHeaderProps) { }: FlowVizHeaderProps) {
const { t } = useTranslation();
return ( return (
<div className="flex items-center justify-between px-3 py-1.5"> <div className="flex items-center justify-between px-3 py-1.5">
{onBack ? ( {onBack ? (
@@ -28,7 +30,7 @@ export function FlowVizHeader({
className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50" className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50"
> >
<ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" /> <ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" />
<span>Back to providers</span> <span>{t('flowViz.backToProviders')}</span>
</button> </button>
) : ( ) : (
<div /> <div />
@@ -44,7 +46,7 @@ export function FlowVizHeader({
)} )}
> >
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />} {showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
<span>{showDetails ? 'Hide Details' : 'Show Details'}</span> <span>{showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')}</span>
</button> </button>
{hasCustomPositions && ( {hasCustomPositions && (
<button <button
@@ -52,7 +54,7 @@ export function FlowVizHeader({
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm" className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
<span>Reset layout</span> <span>{t('flowViz.resetLayout')}</span>
</button> </button>
)} )}
</div> </div>
@@ -2,6 +2,7 @@
* Provider Card Component for Flow Visualization * Provider Card Component for Flow Visualization
*/ */
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/shared/provider-icon'; import { ProviderIcon } from '@/components/shared/provider-icon';
import { GripVertical } from 'lucide-react'; import { GripVertical } from 'lucide-react';
@@ -39,6 +40,7 @@ export function ProviderCard({
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation();
const { accounts } = providerData; const { accounts } = providerData;
return ( return (
@@ -115,17 +117,19 @@ export function ProviderCard({
<h3 className="text-sm font-semibold text-foreground tracking-tight"> <h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName} {providerData.displayName}
</h3> </h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">Provider</p> <p className="text-[10px] text-muted-foreground font-medium uppercase">
{t('flowViz.provider')}
</p>
</div> </div>
</div> </div>
<div className="space-y-2 relative z-10"> <div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span> <span className="text-muted-foreground">{t('flowViz.totalRequests')}</span>
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span> <span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span>
</div> </div>
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span> <span className="text-muted-foreground">{t('flowViz.accounts')}</span>
<span className="text-foreground font-mono">{accounts.length}</span> <span className="text-foreground font-mono">{accounts.length}</span>
</div> </div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden"> <div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
@@ -13,6 +13,7 @@ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface HistorySyncLearningMapProps { interface HistorySyncLearningMapProps {
@@ -76,6 +77,7 @@ export function HistorySyncLearningMap({
legacyTargetCount, legacyTargetCount,
cliproxyCount, cliproxyCount,
}: HistorySyncLearningMapProps) { }: HistorySyncLearningMapProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default']; const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
@@ -84,33 +86,47 @@ export function HistorySyncLearningMap({
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<CardTitle className="text-base">How History Sync Works</CardTitle> <CardTitle className="text-base">{t('historySyncLearningMap.title')}</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Isolated -&gt; Shared -&gt; Deeper. Use <code>Sync</code> per row for all changes. {t('historySyncLearningMap.description')}
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="outline">Learning Map</Badge> <Badge variant="outline">{t('historySyncLearningMap.learningMap')}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{cliproxyCount > 0 && ( {cliproxyCount > 0 && (
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300"> <div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
{cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'} {t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
managed in Action Center / CLIProxy page.
</div> </div>
)} )}
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center"> <div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
<StageTile title="Isolated" count={isolatedCount} icon={Unlink} tone="isolated" /> <StageTile
title={t('historySyncLearningMap.isolated')}
count={isolatedCount}
icon={Unlink}
tone="isolated"
/>
<div className="hidden sm:flex justify-center text-muted-foreground"> <div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</div> </div>
<StageTile title="Shared" count={sharedStandardCount} icon={Link2} tone="shared" /> <StageTile
title={t('historySyncLearningMap.shared')}
count={sharedStandardCount}
icon={Link2}
tone="shared"
/>
<div className="hidden sm:flex justify-center text-muted-foreground"> <div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</div> </div>
<StageTile title="Deeper" count={deeperSharedCount} icon={Waves} tone="deeper" /> <StageTile
title={t('historySyncLearningMap.deeper')}
count={deeperSharedCount}
icon={Waves}
tone="deeper"
/>
</div> </div>
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
@@ -119,7 +135,7 @@ export function HistorySyncLearningMap({
variant="ghost" variant="ghost"
className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground" className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
> >
<span>Show details: groups, switching, and legacy policy</span> <span>{t('historySyncLearningMap.showDetails')}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} /> <ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
@@ -128,21 +144,20 @@ export function HistorySyncLearningMap({
<div className="rounded-md border bg-muted/20 p-2.5 text-xs"> <div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" /> <ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">Mode Switch</p> <p className="font-semibold">{t('historySyncLearningMap.modeSwitch')}</p>
</div> </div>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
Sync dialog lets users move between isolated/shared and choose deeper continuity. {t('historySyncLearningMap.modeSwitchDesc')}
</p> </p>
</div> </div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs"> <div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" /> <Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">History Sync Group</p> <p className="font-semibold">{t('historySyncLearningMap.historySyncGroup')}</p>
</div> </div>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
Same group means shared project context lane. Default fallback is{' '} {t('historySyncLearningMap.historySyncGroupDesc')}
<code>default</code>.
</p> </p>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{groupsToShow.map((group) => ( {groupsToShow.map((group) => (
@@ -12,6 +12,7 @@ import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lu
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { UsageSummary } from '@/hooks/use-usage'; import type { UsageSummary } from '@/hooks/use-usage';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
interface UsageSummaryCardsProps { interface UsageSummaryCardsProps {
data?: UsageSummary; data?: UsageSummary;
@@ -20,6 +21,7 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const { privacyMode } = usePrivacy(); const { privacyMode } = usePrivacy();
const { t } = useTranslation();
if (isLoading) { if (isLoading) {
return ( return (
@@ -48,49 +50,61 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const cards = [ const cards = [
{ {
title: 'Total Tokens', title: t('analyticsSummary.totalTokens'),
value: data?.totalTokens ?? 0, value: data?.totalTokens ?? 0,
icon: FileText, icon: FileText,
format: (v: number) => formatNumber(v), format: (v: number) => formatNumber(v),
color: 'text-blue-600', color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20', bgColor: 'bg-blue-100 dark:bg-blue-900/20',
subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`, subtitle: t('analyticsSummary.totalTokensSubtitle', {
input: formatNumber(data?.totalInputTokens ?? 0),
output: formatNumber(data?.totalOutputTokens ?? 0),
}),
}, },
{ {
title: 'Total Cost', title: t('analyticsSummary.totalCost'),
value: data?.totalCost ?? 0, value: data?.totalCost ?? 0,
icon: DollarSign, icon: DollarSign,
format: (v: number) => `$${v.toFixed(2)}`, format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-green-600', color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20', bgColor: 'bg-green-100 dark:bg-green-900/20',
subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`, subtitle: t('analyticsSummary.totalCostSubtitle', {
value: data?.averageCostPerDay?.toFixed(2) ?? '0.00',
}),
}, },
{ {
title: 'Cache Tokens', title: t('analyticsSummary.cacheTokens'),
value: data?.totalCacheTokens ?? 0, value: data?.totalCacheTokens ?? 0,
icon: Database, icon: Database,
format: (v: number) => formatNumber(v), format: (v: number) => formatNumber(v),
color: 'text-cyan-600', color: 'text-cyan-600',
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20', bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`, subtitle: t('analyticsSummary.cacheTokensSubtitle', {
cost: cacheCost.toFixed(2),
percent: cacheCostPercent,
}),
}, },
{ {
title: 'Input Cost', title: t('analyticsSummary.inputCost'),
value: data?.tokenBreakdown?.input?.cost ?? 0, value: data?.tokenBreakdown?.input?.cost ?? 0,
icon: ArrowDownRight, icon: ArrowDownRight,
format: (v: number) => `$${v.toFixed(2)}`, format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-purple-600', color: 'text-purple-600',
bgColor: 'bg-purple-100 dark:bg-purple-900/20', bgColor: 'bg-purple-100 dark:bg-purple-900/20',
subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`, subtitle: t('analyticsSummary.tokensSubtitle', {
value: formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0),
}),
}, },
{ {
title: 'Output Cost', title: t('analyticsSummary.outputCost'),
value: data?.tokenBreakdown?.output?.cost ?? 0, value: data?.tokenBreakdown?.output?.cost ?? 0,
icon: ArrowUpRight, icon: ArrowUpRight,
format: (v: number) => `$${v.toFixed(2)}`, format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-orange-600', color: 'text-orange-600',
bgColor: 'bg-orange-100 dark:bg-orange-900/20', bgColor: 'bg-orange-100 dark:bg-orange-900/20',
subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`, subtitle: t('analyticsSummary.tokensSubtitle', {
value: formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0),
}),
}, },
]; ];
@@ -18,21 +18,22 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Cpu } from 'lucide-react'; import { Cpu } from 'lucide-react';
import type { CliproxyModelsResponse } from '@/lib/api-client'; import type { CliproxyModelsResponse } from '@/lib/api-client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** Provider display configuration */ /** Provider display configuration */
const CATEGORY_CONFIG: Record<string, { name: string; color: string }> = { const CATEGORY_CONFIG: Record<string, { key: string; color: string }> = {
google: { name: 'Google (Gemini)', color: 'text-blue-600' }, google: { key: 'google', color: 'text-blue-600' },
openai: { name: 'OpenAI (GPT)', color: 'text-green-600' }, openai: { key: 'openai', color: 'text-green-600' },
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' }, anthropic: { key: 'anthropic', color: 'text-orange-600' },
antigravity: { name: 'Antigravity', color: 'text-purple-600' }, antigravity: { key: 'antigravity', color: 'text-purple-600' },
other: { name: 'Other', color: 'text-gray-600' }, other: { key: 'other', color: 'text-gray-600' },
}; };
/** Get display name for category */ /** Get display name for category */
function getCategoryDisplay(category: string) { function getCategoryDisplay(category: string) {
return ( return (
CATEGORY_CONFIG[category.toLowerCase()] || { CATEGORY_CONFIG[category.toLowerCase()] || {
name: category.charAt(0).toUpperCase() + category.slice(1), key: 'other',
color: 'text-gray-600', color: 'text-gray-600',
} }
); );
@@ -61,9 +62,12 @@ export function CategorizedModelSelector({
value, value,
onChange, onChange,
disabled, disabled,
placeholder = 'Select a model', placeholder,
className, className,
}: CategorizedModelSelectorProps) { }: CategorizedModelSelectorProps) {
const { t } = useTranslation();
const resolvedPlaceholder = placeholder ?? t('categorizedModelSelector.selectModel');
// Sort categories by model count (descending) // Sort categories by model count (descending)
const sortedCategories = useMemo(() => { const sortedCategories = useMemo(() => {
if (!modelsData?.byCategory) return []; if (!modelsData?.byCategory) return [];
@@ -84,7 +88,7 @@ export function CategorizedModelSelector({
return ( return (
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}> <div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
<Cpu className="w-4 h-4" /> <Cpu className="w-4 h-4" />
<span>No models available</span> <span>{t('categorizedModelSelector.noModelsAvailable')}</span>
</div> </div>
); );
} }
@@ -92,7 +96,7 @@ export function CategorizedModelSelector({
return ( return (
<Select value={value || ''} onValueChange={onChange} disabled={disabled}> <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className={cn('w-[320px]', className)}> <SelectTrigger className={cn('w-[320px]', className)}>
<SelectValue placeholder={placeholder}> <SelectValue placeholder={resolvedPlaceholder}>
{value && ( {value && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate">{value}</span> <span className="truncate">{value}</span>
@@ -104,7 +108,9 @@ export function CategorizedModelSelector({
{sortedCategories.map(({ category, display, models }) => ( {sortedCategories.map(({ category, display, models }) => (
<SelectGroup key={category}> <SelectGroup key={category}>
<SelectLabel className="flex items-center justify-between px-2 py-1.5"> <SelectLabel className="flex items-center justify-between px-2 py-1.5">
<span className={cn('font-semibold', display.color)}>{display.name}</span> <span className={cn('font-semibold', display.color)}>
{t(`cliproxyModelCategory.${display.key}`)}
</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2"> <Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
{models.length} {models.length}
</Badge> </Badge>
@@ -129,6 +135,8 @@ export function CategorizedModelSelectorCompact({
onChange, onChange,
disabled, disabled,
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) { }: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
const { t } = useTranslation();
return ( return (
<CategorizedModelSelector <CategorizedModelSelector
modelsData={modelsData} modelsData={modelsData}
@@ -136,7 +144,7 @@ export function CategorizedModelSelectorCompact({
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
placeholder="Model..." placeholder={t('categorizedModelSelector.modelPlaceholder')}
className="w-[200px]" className="w-[200px]"
/> />
); );
+50 -34
View File
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context'; import { usePrivacy } from '@/contexts/privacy-context';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
@@ -69,6 +70,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
})); }));
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateVariant(); const createMutation = useCreateVariant();
const { data: authData } = useCliproxyAuth(); const { data: authData } = useCliproxyAuth();
const { privacyMode } = usePrivacy(); const { privacyMode } = usePrivacy();
@@ -151,8 +153,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{mode === 'single' ? ( {mode === 'single' ? (
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4"> <form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
<div> <div>
<Label htmlFor="name">Name</Label> <Label htmlFor="name">{t('cliproxyDialog.name')}</Label>
<Input id="name" {...singleForm.register('name')} placeholder="my-gemini" /> <Input
id="name"
{...singleForm.register('name')}
placeholder={t('cliproxyDialog.placeholderName')}
/>
{singleForm.formState.errors.name && ( {singleForm.formState.errors.name && (
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">
{singleForm.formState.errors.name.message} {singleForm.formState.errors.name.message}
@@ -161,13 +167,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div> </div>
<div> <div>
<Label htmlFor="provider">Provider</Label> <Label htmlFor="provider">{t('cliproxyDialog.provider')}</Label>
<select <select
id="provider" id="provider"
{...singleForm.register('provider')} {...singleForm.register('provider')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="">Select provider...</option> <option value="">{t('cliproxyDialog.selectProvider')}</option>
{providerOptions.map((opt) => ( {providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
@@ -183,17 +189,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{selectedProvider && providerAccounts.length > 0 && ( {selectedProvider && providerAccounts.length > 0 && (
<div> <div>
<Label htmlFor="account">Account</Label> <Label htmlFor="account">{t('cliproxyDialog.account')}</Label>
<select <select
id="account" id="account"
{...singleForm.register('account')} {...singleForm.register('account')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="">Use default account</option> <option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
{providerAccounts.map((acc) => ( {providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}> <option key={acc.id} value={acc.id}>
{privacyMode ? '••••••' : acc.email || acc.id} {privacyMode ? '••••••' : acc.email || acc.id}
{acc.isDefault ? ' (default)' : ''} {acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option> </option>
))} ))}
</select> </select>
@@ -201,39 +207,45 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)} )}
<div> <div>
<Label htmlFor="model">Model (optional)</Label> <Label htmlFor="model">{t('cliproxyDialog.modelOptional')}</Label>
<Input id="model" {...singleForm.register('model')} placeholder="gemini-2.5-pro" /> <Input
id="model"
{...singleForm.register('model')}
placeholder={t('cliproxyDialog.placeholderModel')}
/>
</div> </div>
<div> <div>
<Label htmlFor="target">Default Target</Label> <Label htmlFor="target">{t('cliproxyDialog.defaultTarget')}</Label>
<select <select
id="target" id="target"
{...singleForm.register('target')} {...singleForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="claude">Claude Code</option> <option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">Factory Droid</option> <option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select> </select>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel {t('cliproxyDialog.cancel')}
</Button> </Button>
<Button type="submit" disabled={createMutation.isPending}> <Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'} {createMutation.isPending
? t('cliproxyDialog.creating')
: t('cliproxyDialog.create')}
</Button> </Button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4"> <form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
<div> <div>
<Label htmlFor="comp-name">Name</Label> <Label htmlFor="comp-name">{t('cliproxyDialog.name')}</Label>
<Input <Input
id="comp-name" id="comp-name"
{...compositeForm.register('name')} {...compositeForm.register('name')}
placeholder="my-composite" placeholder={t('cliproxyDialog.placeholderComposite')}
/> />
{compositeForm.formState.errors.name && ( {compositeForm.formState.errors.name && (
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">
@@ -243,17 +255,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div> </div>
<div> <div>
<Label>Tier Configuration</Label> <Label>{t('cliproxyDialog.tierConfig')}</Label>
<Tabs defaultValue="opus" className="w-full"> <Tabs defaultValue="opus" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="opus">Opus</TabsTrigger> <TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
<TabsTrigger value="sonnet">Sonnet</TabsTrigger> <TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
<TabsTrigger value="haiku">Haiku</TabsTrigger> <TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
</TabsList> </TabsList>
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( {(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<TabsContent key={tier} value={tier} className="space-y-3"> <TabsContent key={tier} value={tier} className="space-y-3">
<div> <div>
<Label htmlFor={`${tier}-provider`}>Provider</Label> <Label htmlFor={`${tier}-provider`}>{t('cliproxyDialog.provider')}</Label>
<select <select
id={`${tier}-provider`} id={`${tier}-provider`}
{...compositeForm.register(`tiers.${tier}.provider`)} {...compositeForm.register(`tiers.${tier}.provider`)}
@@ -267,11 +279,11 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</select> </select>
</div> </div>
<div> <div>
<Label htmlFor={`${tier}-model`}>Model</Label> <Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
<Input <Input
id={`${tier}-model`} id={`${tier}-model`}
{...compositeForm.register(`tiers.${tier}.model`)} {...compositeForm.register(`tiers.${tier}.model`)}
placeholder="model-id" placeholder={t('cliproxyDialog.placeholderModelId')}
/> />
{compositeForm.formState.errors.tiers?.[tier]?.model && ( {compositeForm.formState.errors.tiers?.[tier]?.model && (
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">
@@ -280,11 +292,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)} )}
</div> </div>
<div> <div>
<Label htmlFor={`${tier}-account`}>Account (optional)</Label> <Label htmlFor={`${tier}-account`}>
{t('cliproxyDialog.accountOptional')}
</Label>
<Input <Input
id={`${tier}-account`} id={`${tier}-account`}
{...compositeForm.register(`tiers.${tier}.account`)} {...compositeForm.register(`tiers.${tier}.account`)}
placeholder="account-id" placeholder={t('cliproxyDialog.placeholderAccountId')}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -293,36 +307,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div> </div>
<div> <div>
<Label htmlFor="default-tier">Default Tier</Label> <Label htmlFor="default-tier">{t('cliproxyDialog.defaultTier')}</Label>
<select <select
id="default-tier" id="default-tier"
{...compositeForm.register('default_tier')} {...compositeForm.register('default_tier')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="opus">Opus</option> <option value="opus">{t('cliproxyDialog.opus')}</option>
<option value="sonnet">Sonnet</option> <option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
<option value="haiku">Haiku</option> <option value="haiku">{t('cliproxyDialog.haiku')}</option>
</select> </select>
</div> </div>
<div> <div>
<Label htmlFor="composite-target">Default Target</Label> <Label htmlFor="composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select <select
id="composite-target" id="composite-target"
{...compositeForm.register('target')} {...compositeForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="claude">Claude Code</option> <option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">Factory Droid</option> <option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select> </select>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel {t('cliproxyDialog.cancel')}
</Button> </Button>
<Button type="submit" disabled={createMutation.isPending}> <Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'} {createMutation.isPending
? t('cliproxyDialog.creating')
: t('cliproxyDialog.create')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { useUpdateVariant } from '@/hooks/use-cliproxy'; import { useUpdateVariant } from '@/hooks/use-cliproxy';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import type { UpdateVariant, Variant } from '@/lib/api-client'; import type { UpdateVariant, Variant } from '@/lib/api-client';
@@ -117,6 +118,7 @@ function isCompositeVariantOnlyTargetChange(variant: Variant, data: CompositeFor
} }
export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) {
const { t } = useTranslation();
const updateMutation = useUpdateVariant(); const updateMutation = useUpdateVariant();
const isComposite = variant?.type === 'composite'; const isComposite = variant?.type === 'composite';
@@ -264,24 +266,28 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name} {isComposite
? t('cliproxyDialog.editCompositeTitle', { name: variant.name })
: t('cliproxyDialog.editSingleTitle', { name: variant.name })}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{isComposite ? ( {isComposite ? (
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4"> <form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
<div> <div>
<Label>Tier Configuration</Label> <Label>{t('cliproxyDialog.tierConfig')}</Label>
<Tabs defaultValue="opus" className="w-full"> <Tabs defaultValue="opus" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="opus">Opus</TabsTrigger> <TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
<TabsTrigger value="sonnet">Sonnet</TabsTrigger> <TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
<TabsTrigger value="haiku">Haiku</TabsTrigger> <TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
</TabsList> </TabsList>
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( {(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<TabsContent key={tier} value={tier} className="space-y-3"> <TabsContent key={tier} value={tier} className="space-y-3">
<div> <div>
<Label htmlFor={`edit-${tier}-provider`}>Provider</Label> <Label htmlFor={`edit-${tier}-provider`}>
{t('cliproxyDialog.provider')}
</Label>
<select <select
id={`edit-${tier}-provider`} id={`edit-${tier}-provider`}
{...compositeForm.register(`tiers.${tier}.provider`)} {...compositeForm.register(`tiers.${tier}.provider`)}
@@ -295,11 +301,11 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</select> </select>
</div> </div>
<div> <div>
<Label htmlFor={`edit-${tier}-model`}>Model</Label> <Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
<Input <Input
id={`edit-${tier}-model`} id={`edit-${tier}-model`}
{...compositeForm.register(`tiers.${tier}.model`)} {...compositeForm.register(`tiers.${tier}.model`)}
placeholder="model-id" placeholder={t('cliproxyDialog.placeholderModelId')}
/> />
{compositeForm.formState.errors.tiers?.[tier]?.model && ( {compositeForm.formState.errors.tiers?.[tier]?.model && (
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">
@@ -308,11 +314,13 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
)} )}
</div> </div>
<div> <div>
<Label htmlFor={`edit-${tier}-account`}>Account (optional)</Label> <Label htmlFor={`edit-${tier}-account`}>
{t('cliproxyDialog.accountOptional')}
</Label>
<Input <Input
id={`edit-${tier}-account`} id={`edit-${tier}-account`}
{...compositeForm.register(`tiers.${tier}.account`)} {...compositeForm.register(`tiers.${tier}.account`)}
placeholder="account-id" placeholder={t('cliproxyDialog.placeholderAccountId')}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -321,43 +329,45 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</div> </div>
<div> <div>
<Label htmlFor="edit-default-tier">Default Tier</Label> <Label htmlFor="edit-default-tier">{t('cliproxyDialog.defaultTier')}</Label>
<select <select
id="edit-default-tier" id="edit-default-tier"
{...compositeForm.register('default_tier')} {...compositeForm.register('default_tier')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="opus">Opus</option> <option value="opus">{t('cliproxyDialog.opus')}</option>
<option value="sonnet">Sonnet</option> <option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
<option value="haiku">Haiku</option> <option value="haiku">{t('cliproxyDialog.haiku')}</option>
</select> </select>
</div> </div>
<div> <div>
<Label htmlFor="edit-composite-target">Default Target</Label> <Label htmlFor="edit-composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select <select
id="edit-composite-target" id="edit-composite-target"
{...compositeForm.register('target')} {...compositeForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="claude">Claude Code</option> <option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">Factory Droid</option> <option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select> </select>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel {t('cliproxyDialog.cancel')}
</Button> </Button>
<Button type="submit" disabled={updateMutation.isPending}> <Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending
? t('cliproxyDialog.saving')
: t('cliproxyDialog.saveChanges')}
</Button> </Button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4"> <form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
<div> <div>
<Label htmlFor="edit-provider">Provider</Label> <Label htmlFor="edit-provider">{t('cliproxyDialog.provider')}</Label>
<select <select
id="edit-provider" id="edit-provider"
{...singleForm.register('provider')} {...singleForm.register('provider')}
@@ -372,37 +382,43 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</div> </div>
<div> <div>
<Label htmlFor="edit-model">Model</Label> <Label htmlFor="edit-model">{t('cliproxyDialog.model')}</Label>
<Input id="edit-model" {...singleForm.register('model')} placeholder="model-id" />
</div>
<div>
<Label htmlFor="edit-account">Account (optional)</Label>
<Input <Input
id="edit-account" id="edit-model"
{...singleForm.register('account')} {...singleForm.register('model')}
placeholder="account-id" placeholder={t('cliproxyDialog.placeholderModelId')}
/> />
</div> </div>
<div> <div>
<Label htmlFor="edit-target">Default Target</Label> <Label htmlFor="edit-account">{t('cliproxyDialog.accountOptional')}</Label>
<Input
id="edit-account"
{...singleForm.register('account')}
placeholder={t('cliproxyDialog.placeholderAccountId')}
/>
</div>
<div>
<Label htmlFor="edit-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select <select
id="edit-target" id="edit-target"
{...singleForm.register('target')} {...singleForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<option value="claude">Claude Code</option> <option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">Factory Droid</option> <option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select> </select>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel {t('cliproxyDialog.cancel')}
</Button> </Button>
<Button type="submit" disabled={updateMutation.isPending}> <Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending
? t('cliproxyDialog.saving')
: t('cliproxyDialog.saveChanges')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled'; type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
@@ -35,6 +36,8 @@ function CredentialRow({
onRefresh, onRefresh,
privacyMode, privacyMode,
}: CredentialRowProps) { }: CredentialRowProps) {
const { t } = useTranslation();
const statusConfig = { const statusConfig = {
ready: { ready: {
icon: CheckCircle2, icon: CheckCircle2,
@@ -62,21 +65,21 @@ function CredentialRow({
const Icon = config.icon; const Icon = config.icon;
const formatLastUsed = (date?: string) => { const formatLastUsed = (date?: string) => {
if (!date) return 'Never used'; if (!date) return t('credentialHealth.neverUsed');
try { try {
const lastUsed = new Date(date); const lastUsed = new Date(date);
const now = new Date(); const now = new Date();
const diff = now.getTime() - lastUsed.getTime(); const diff = now.getTime() - lastUsed.getTime();
if (diff < 0) return 'Just now'; if (diff < 0) return t('credentialHealth.justNow');
const minutes = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000); const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000); const days = Math.floor(diff / 86400000);
if (days > 0) return `${days}d ago`; if (days > 0) return t('credentialHealth.daysAgo', { count: days });
if (hours > 0) return `${hours}h ago`; if (hours > 0) return t('credentialHealth.hoursAgo', { count: hours });
if (minutes > 0) return `${minutes}m ago`; if (minutes > 0) return t('credentialHealth.minutesAgo', { count: minutes });
return 'Just now'; return t('credentialHealth.justNow');
} catch { } catch {
return 'Unknown'; return t('credentialHealth.unknown');
} }
}; };
@@ -120,10 +123,12 @@ function CredentialRow({
} }
function CredentialHealthSkeleton() { function CredentialHealthSkeleton() {
const { t } = useTranslation();
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle> <CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
@@ -137,6 +142,7 @@ function CredentialHealthSkeleton() {
} }
export function CredentialHealthList() { export function CredentialHealthList() {
const { t } = useTranslation();
const { data: authData, isLoading } = useCliproxyAuth(); const { data: authData, isLoading } = useCliproxyAuth();
const { data: stats } = useCliproxyStats(true); const { data: stats } = useCliproxyStats(true);
const { privacyMode } = usePrivacy(); const { privacyMode } = usePrivacy();
@@ -155,7 +161,8 @@ export function CredentialHealthList() {
name: account.id, name: account.id,
provider: status.provider, provider: status.provider,
status: (account as { status?: CredentialStatus }).status ?? 'ready', status: (account as { status?: CredentialStatus }).status ?? 'ready',
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready', statusMessage:
(account as { statusMessage?: string }).statusMessage ?? t('credentialHealth.ready'),
email: account.email, email: account.email,
lastUsedAt: runtimeLastUsed || account.lastUsedAt, lastUsedAt: runtimeLastUsed || account.lastUsedAt,
}; };
@@ -166,11 +173,11 @@ export function CredentialHealthList() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle> <CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">
No credentials configured. Use the login buttons above to authenticate. {t('credentialHealth.noCredentialsConfigured')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -180,7 +187,7 @@ export function CredentialHealthList() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle> <CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{credentials.map((cred, i) => ( {credentials.map((cred, i) => (
@@ -10,31 +10,34 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Cpu, AlertCircle } from 'lucide-react'; import { Cpu, AlertCircle } from 'lucide-react';
import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** Category display configuration */ /** Category display configuration */
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = { const CATEGORY_CONFIG: Record<string, { key: string; color: string; bgColor: string }> = {
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' }, google: { key: 'google', color: 'text-blue-600', bgColor: 'bg-blue-50' },
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' }, openai: { key: 'openai', color: 'text-green-600', bgColor: 'bg-green-50' },
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' }, anthropic: { key: 'anthropic', color: 'text-orange-600', bgColor: 'bg-orange-50' },
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' }, antigravity: { key: 'antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' }, other: { key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
}; };
function getCategoryDisplay(category: string) { function getCategoryDisplay(category: string) {
const normalized = category.toLowerCase();
const configured = CATEGORY_CONFIG[normalized];
return ( return (
CATEGORY_CONFIG[category.toLowerCase()] || { configured || {
name: category.charAt(0).toUpperCase() + category.slice(1), key: 'other',
color: 'text-gray-600', color: 'text-gray-600',
bgColor: 'bg-gray-50', bgColor: 'bg-gray-50',
} }
); );
} }
function ModelPreferencesSkeleton() { function ModelPreferencesSkeleton({ title }: { title: string }) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Available Models</CardTitle> <CardTitle className="text-base">{title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -47,21 +50,27 @@ function ModelPreferencesSkeleton() {
); );
} }
function EmptyModelsState() { function EmptyModelsState({
title,
noModels,
hint,
}: {
title: string;
noModels: string;
hint: string;
}) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Available Models</CardTitle> <CardTitle className="text-base">{title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3"> <div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<AlertCircle className="w-6 h-6 text-muted-foreground" /> <AlertCircle className="w-6 h-6 text-muted-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground mb-1">No models available</p> <p className="text-sm text-muted-foreground mb-1">{noModels}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{hint}</p>
Start a CLIProxy session to fetch available models
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -69,6 +78,7 @@ function EmptyModelsState() {
} }
export function ModelPreferencesGrid() { export function ModelPreferencesGrid() {
const { t } = useTranslation();
const { data: modelsData, isLoading, isError } = useCliproxyModels(); const { data: modelsData, isLoading, isError } = useCliproxyModels();
const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: updateCheck } = useCliproxyUpdateCheck();
const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy'; const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy';
@@ -86,11 +96,17 @@ export function ModelPreferencesGrid() {
}, [modelsData]); }, [modelsData]);
if (isLoading) { if (isLoading) {
return <ModelPreferencesSkeleton />; return <ModelPreferencesSkeleton title={t('cliproxyOverviewComponents.availableModels')} />;
} }
if (isError || !modelsData || modelsData.totalCount === 0) { if (isError || !modelsData || modelsData.totalCount === 0) {
return <EmptyModelsState />; return (
<EmptyModelsState
title={t('cliproxyOverviewComponents.availableModels')}
noModels={t('cliproxyOverviewComponents.noModelsAvailable')}
hint={t('cliproxyOverviewComponents.startSessionHint')}
/>
);
} }
return ( return (
@@ -98,13 +114,13 @@ export function ModelPreferencesGrid() {
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Cpu className="w-4 h-4" /> <Cpu className="w-4 h-4" />
Available Models {t('cliproxyOverviewComponents.availableModels')}
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{modelsData.totalCount} total {t('cliproxyOverviewComponents.totalCount', { count: modelsData.totalCount })}
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Models available through {backendLabel}, grouped by provider {t('cliproxyOverviewComponents.availableThroughGroupedByProvider', { backendLabel })}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -115,7 +131,9 @@ export function ModelPreferencesGrid() {
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')} className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className={cn('font-medium text-sm', display.color)}>{display.name}</h3> <h3 className={cn('font-medium text-sm', display.color)}>
{t(`cliproxyModelCategory.${display.key}`)}
</h3>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{models.length} {models.length}
</Badge> </Badge>
@@ -132,7 +150,7 @@ export function ModelPreferencesGrid() {
))} ))}
{models.length > 5 && ( {models.length > 5 && (
<div className="text-xs text-muted-foreground italic"> <div className="text-xs text-muted-foreground italic">
+{models.length - 5} more {t('cliproxyOverviewComponents.moreCount', { count: models.length - 5 })}
</div> </div>
)} )}
</div> </div>
@@ -4,6 +4,7 @@
*/ */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react'; import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react';
import { useCliproxyStats } from '@/hooks/use-cliproxy'; import { useCliproxyStats } from '@/hooks/use-cliproxy';
@@ -45,6 +46,7 @@ function StatsSkeleton() {
} }
export function QuickStatsRow() { export function QuickStatsRow() {
const { t } = useTranslation();
const { data: stats, isLoading } = useCliproxyStats(); const { data: stats, isLoading } = useCliproxyStats();
if (isLoading) { if (isLoading) {
@@ -71,16 +73,24 @@ export function QuickStatsRow() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard <StatCard
icon={<Activity className="w-4 h-4" />} icon={<Activity className="w-4 h-4" />}
label="Total Requests" label={t('quickStats.totalRequests')}
value={totalRequests} value={totalRequests}
/> />
<StatCard <StatCard
icon={<CheckCircle2 className="w-4 h-4" />} icon={<CheckCircle2 className="w-4 h-4" />}
label="Success Rate" label={t('quickStats.successRate')}
value={`${successRate}%`} value={`${successRate}%`}
/> />
<StatCard icon={<Coins className="w-4 h-4" />} label="Total Tokens" value={tokenDisplay} /> <StatCard
<StatCard icon={<Cpu className="w-4 h-4" />} label="Active Models" value={modelCount} /> icon={<Coins className="w-4 h-4" />}
label={t('quickStats.totalTokens')}
value={tokenDisplay}
/>
<StatCard
icon={<Cpu className="w-4 h-4" />}
label={t('quickStats.activeModels')}
value={modelCount}
/>
</div> </div>
); );
} }
@@ -18,6 +18,7 @@ import {
import { Sparkles, Loader2, Star, Zap } from 'lucide-react'; import { Sparkles, Loader2, Star, Zap } from 'lucide-react';
import { FlexibleModelSelector } from '../provider-model-selector'; import { FlexibleModelSelector } from '../provider-model-selector';
import type { CustomPresetDialogProps, ModelMappingValues } from './types'; import type { CustomPresetDialogProps, ModelMappingValues } from './types';
import { useTranslation } from 'react-i18next';
export function CustomPresetDialog({ export function CustomPresetDialog({
open, open,
@@ -29,6 +30,7 @@ export function CustomPresetDialog({
catalog, catalog,
allModels, allModels,
}: CustomPresetDialogProps) { }: CustomPresetDialogProps) {
const { t } = useTranslation();
const [values, setValues] = useState<ModelMappingValues>(currentValues); const [values, setValues] = useState<ModelMappingValues>(currentValues);
const [presetName, setPresetName] = useState(''); const [presetName, setPresetName] = useState('');
@@ -48,54 +50,54 @@ export function CustomPresetDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Custom Preset {t('customPresetDialog.title')}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="preset-name">Preset Name (optional)</Label> <Label htmlFor="preset-name">{t('customPresetDialog.presetNameOptional')}</Label>
<Input <Input
id="preset-name" id="preset-name"
value={presetName} value={presetName}
onChange={(e) => setPresetName(e.target.value)} onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g., My Custom Config" placeholder={t('customPresetDialog.presetNamePlaceholder')}
className="text-sm" className="text-sm"
/> />
</div> </div>
<Separator /> <Separator />
{catalog?.provider === 'codex' && ( {catalog?.provider === 'codex' && (
<p className="text-[11px] text-muted-foreground rounded-md border bg-muted/30 px-2.5 py-2"> <p className="text-[11px] text-muted-foreground rounded-md border bg-muted/30 px-2.5 py-2">
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '} {t('customPresetDialog.codexTipPrefix')} <code>-medium</code>, <code>-high</code>, and{' '}
pin effort. Unsuffixed models use Thinking settings. <code>-xhigh</code> {t('customPresetDialog.codexTipSuffix')}
</p> </p>
)} )}
<FlexibleModelSelector <FlexibleModelSelector
label="Default Model" label={t('customPresetDialog.defaultModel')}
description="Used when no specific tier is requested" description={t('customPresetDialog.defaultModelDesc')}
value={values.default} value={values.default}
onChange={(model) => setValues({ ...values, default: model })} onChange={(model) => setValues({ ...values, default: model })}
catalog={catalog} catalog={catalog}
allModels={allModels} allModels={allModels}
/> />
<FlexibleModelSelector <FlexibleModelSelector
label="Opus (Most capable)" label={t('customPresetDialog.opusModel')}
description="For complex reasoning tasks" description={t('customPresetDialog.opusModelDesc')}
value={values.opus} value={values.opus}
onChange={(model) => setValues({ ...values, opus: model })} onChange={(model) => setValues({ ...values, opus: model })}
catalog={catalog} catalog={catalog}
allModels={allModels} allModels={allModels}
/> />
<FlexibleModelSelector <FlexibleModelSelector
label="Sonnet (Balanced)" label={t('customPresetDialog.sonnetModel')}
description="Balance of speed and capability" description={t('customPresetDialog.sonnetModelDesc')}
value={values.sonnet} value={values.sonnet}
onChange={(model) => setValues({ ...values, sonnet: model })} onChange={(model) => setValues({ ...values, sonnet: model })}
catalog={catalog} catalog={catalog}
allModels={allModels} allModels={allModels}
/> />
<FlexibleModelSelector <FlexibleModelSelector
label="Haiku (Fast)" label={t('customPresetDialog.haikuModel')}
description="Quick responses for simple tasks" description={t('customPresetDialog.haikuModelDesc')}
value={values.haiku} value={values.haiku}
onChange={(model) => setValues({ ...values, haiku: model })} onChange={(model) => setValues({ ...values, haiku: model })}
catalog={catalog} catalog={catalog}
@@ -104,7 +106,7 @@ export function CustomPresetDialog({
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Cancel {t('customPresetDialog.cancel')}
</Button> </Button>
{onSave && ( {onSave && (
<Button <Button
@@ -117,12 +119,12 @@ export function CustomPresetDialog({
) : ( ) : (
<Star className="w-4 h-4 mr-1" /> <Star className="w-4 h-4 mr-1" />
)} )}
Save Preset {t('customPresetDialog.savePreset')}
</Button> </Button>
)} )}
<Button onClick={() => onApply(values, presetName || undefined)}> <Button onClick={() => onApply(values, presetName || undefined)}>
<Zap className="w-4 h-4 mr-1" /> <Zap className="w-4 h-4 mr-1" />
Apply Preset {t('customPresetDialog.applyPreset')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -17,6 +17,7 @@ import {
useDeletePreset, useDeletePreset,
} from '@/hooks/use-cliproxy'; } from '@/hooks/use-cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import i18n from '@/lib/i18n';
import { usePrivacy } from '@/contexts/privacy-context'; import { usePrivacy } from '@/contexts/privacy-context';
import { useProviderEditor } from './use-provider-editor'; import { useProviderEditor } from './use-provider-editor';
import { CustomPresetDialog } from './custom-preset-dialog'; import { CustomPresetDialog } from './custom-preset-dialog';
@@ -155,7 +156,7 @@ export function ProviderEditor({
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => { const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
if (!presetName) { if (!presetName) {
toast.error('Please enter a preset name to save'); toast.error(i18n.t('commonToast.enterPresetName'));
return; return;
} }
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } }); createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
@@ -19,6 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { AlertTriangle, AlertCircle, Check } from 'lucide-react'; import { AlertTriangle, AlertCircle, Check } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getCodexEffortDisplay } from '@/lib/codex-effort'; import { getCodexEffortDisplay } from '@/lib/codex-effort';
import { useTranslation } from 'react-i18next';
/** Model entry from catalog */ /** Model entry from catalog */
export interface ModelEntry { export interface ModelEntry {
@@ -72,9 +73,12 @@ export function ProviderModelSelector({
value, value,
onChange, onChange,
disabled, disabled,
placeholder = 'Select a model', placeholder,
className, className,
}: ProviderModelSelectorProps) { }: ProviderModelSelectorProps) {
const { t } = useTranslation();
const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel');
// Group models by tier // Group models by tier
const groupedModels = useMemo(() => { const groupedModels = useMemo(() => {
if (!catalog?.models) return { free: [], paid: [] }; if (!catalog?.models) return { free: [], paid: [] };
@@ -95,7 +99,7 @@ export function ProviderModelSelector({
if (!catalog || catalog.models.length === 0) { if (!catalog || catalog.models.length === 0) {
return ( return (
<div className={cn('text-sm text-muted-foreground py-2', className)}> <div className={cn('text-sm text-muted-foreground py-2', className)}>
No models available for this provider {t('providerModelSelector.noModelsForProvider')}
</div> </div>
); );
} }
@@ -110,12 +114,12 @@ export function ProviderModelSelector({
<span className="truncate">{model.name}</span> <span className="truncate">{model.name}</span>
{model.broken && ( {model.broken && (
<Badge variant="destructive" className="text-[9px] h-4 px-1"> <Badge variant="destructive" className="text-[9px] h-4 px-1">
BROKEN {t('providerModelSelector.broken')}
</Badge> </Badge>
)} )}
{model.deprecated && ( {model.deprecated && (
<Badge variant="secondary" className="text-[9px] h-4 px-1"> <Badge variant="secondary" className="text-[9px] h-4 px-1">
DEPRECATED {t('providerModelSelector.deprecated')}
</Badge> </Badge>
)} )}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />} {value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
@@ -127,13 +131,13 @@ export function ProviderModelSelector({
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2', className)}>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}> <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder={placeholder}> <SelectValue placeholder={resolvedPlaceholder}>
{selectedModel && ( {selectedModel && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate">{selectedModel.name}</span> <span className="truncate">{selectedModel.name}</span>
{selectedModel.tier === 'paid' && ( {selectedModel.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1"> <Badge variant="outline" className="text-[9px] h-4 px-1">
PAID {t('providerModelSelector.paid')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -143,13 +147,17 @@ export function ProviderModelSelector({
<SelectContent> <SelectContent>
{groupedModels.free.length > 0 && ( {groupedModels.free.length > 0 && (
<SelectGroup> <SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">Free Tier</SelectLabel> <SelectLabel className="text-xs text-muted-foreground">
{t('providerModelSelector.freeTier')}
</SelectLabel>
{groupedModels.free.map(renderModelItem)} {groupedModels.free.map(renderModelItem)}
</SelectGroup> </SelectGroup>
)} )}
{groupedModels.paid.length > 0 && ( {groupedModels.paid.length > 0 && (
<SelectGroup> <SelectGroup>
<SelectLabel className="text-xs text-amber-600">Paid Tier</SelectLabel> <SelectLabel className="text-xs text-amber-600">
{t('providerModelSelector.paidTier')}
</SelectLabel>
{groupedModels.paid.map(renderModelItem)} {groupedModels.paid.map(renderModelItem)}
</SelectGroup> </SelectGroup>
)} )}
@@ -161,7 +169,7 @@ export function ProviderModelSelector({
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive"> <div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" /> <AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div> <div>
<p className="font-medium">This model has known issues</p> <p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
{selectedModel.issueUrl && ( {selectedModel.issueUrl && (
<a <a
href={selectedModel.issueUrl} href={selectedModel.issueUrl}
@@ -169,7 +177,7 @@ export function ProviderModelSelector({
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline hover:no-underline" className="underline hover:no-underline"
> >
View issue details {t('providerModelSelector.viewIssueDetails')}
</a> </a>
)} )}
</div> </div>
@@ -180,7 +188,7 @@ export function ProviderModelSelector({
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700"> <div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" /> <AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div> <div>
<p className="font-medium">This model is deprecated</p> <p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
{selectedModel.deprecationReason && ( {selectedModel.deprecationReason && (
<p className="opacity-80">{selectedModel.deprecationReason}</p> <p className="opacity-80">{selectedModel.deprecationReason}</p>
)} )}
@@ -212,6 +220,7 @@ export function ModelMappingSelector({
onChange, onChange,
disabled, disabled,
}: ModelMappingSelectorProps) { }: ModelMappingSelectorProps) {
const { t } = useTranslation();
if (!catalog) return null; if (!catalog) return null;
return ( return (
@@ -219,7 +228,7 @@ export function ModelMappingSelector({
<label className="text-xs font-medium text-muted-foreground">{label}</label> <label className="text-xs font-medium text-muted-foreground">{label}</label>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}> <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-8 text-sm"> <SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select model"> <SelectValue placeholder={t('providerModelSelector.selectModel')}>
{value && <span className="truncate font-mono text-xs">{value}</span>} {value && <span className="truncate font-mono text-xs">{value}</span>}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -230,7 +239,7 @@ export function ModelMappingSelector({
<span className="truncate text-sm">{model.name}</span> <span className="truncate text-sm">{model.name}</span>
{model.tier === 'paid' && ( {model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1"> <Badge variant="outline" className="text-[9px] h-4 px-1">
PAID {t('providerModelSelector.paid')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -262,6 +271,7 @@ export function FlexibleModelSelector({
allModels, allModels,
disabled, disabled,
}: FlexibleModelSelectorProps) { }: FlexibleModelSelectorProps) {
const { t } = useTranslation();
// Combine catalog models (recommended) with all available models // Combine catalog models (recommended) with all available models
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []); const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
const isCodexProvider = catalog?.provider === 'codex'; const isCodexProvider = catalog?.provider === 'codex';
@@ -275,7 +285,7 @@ export function FlexibleModelSelector({
</div> </div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}> <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-9">
<SelectValue placeholder="Select model"> <SelectValue placeholder={t('providerModelSelector.selectModel')}>
{value && ( {value && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span> <span className="truncate font-mono text-xs">{value}</span>
@@ -295,7 +305,9 @@ export function FlexibleModelSelector({
{/* Recommended models from catalog */} {/* Recommended models from catalog */}
{catalog && catalog.models.length > 0 && ( {catalog && catalog.models.length > 0 && (
<SelectGroup> <SelectGroup>
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel> <SelectLabel className="text-xs text-primary">
{t('providerModelSelector.recommended')}
</SelectLabel>
{catalog.models.map((model) => { {catalog.models.map((model) => {
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null; const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
return ( return (
@@ -304,7 +316,7 @@ export function FlexibleModelSelector({
<span className="truncate font-mono text-xs">{model.id}</span> <span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && ( {model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1"> <Badge variant="outline" className="text-[9px] h-4 px-1">
PAID {t('providerModelSelector.paid')}
</Badge> </Badge>
)} )}
{codexEffort && ( {codexEffort && (
@@ -327,7 +339,7 @@ export function FlexibleModelSelector({
{allModels.length > 0 && ( {allModels.length > 0 && (
<SelectGroup> <SelectGroup>
<SelectLabel className="text-xs text-muted-foreground"> <SelectLabel className="text-xs text-muted-foreground">
All Models ({allModels.length}) {t('providerModelSelector.allModelsCount', { count: allModels.length })}
</SelectLabel> </SelectLabel>
{allModels {allModels
.filter((m) => !catalogModelIds.has(m.id)) .filter((m) => !catalogModelIds.has(m.id))
@@ -355,7 +367,9 @@ export function FlexibleModelSelector({
{/* Fallback if no models available */} {/* Fallback if no models available */}
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && ( {(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
<div className="py-2 px-3 text-xs text-muted-foreground">No models available</div> <div className="py-2 px-3 text-xs text-muted-foreground">
{t('providerModelSelector.noModelsAvailable')}
</div>
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
+13 -13
View File
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Upload, CheckCircle } from 'lucide-react'; import { Loader2, Upload, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync'; import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync';
@@ -23,6 +24,7 @@ interface SyncDialogProps {
} }
export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
const { t } = useTranslation();
const { data: preview, isLoading: previewLoading } = useSyncPreview(); const { data: preview, isLoading: previewLoading } = useSyncPreview();
const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync(); const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync();
@@ -45,11 +47,9 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" /> <Upload className="w-5 h-5" />
Sync Profiles to Local CLIProxy {t('syncDialog.title')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{t('syncDialog.description')}</DialogDescription>
Sync your CCS API profiles to the local CLIProxy config.yaml.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="mt-4"> <div className="mt-4">
@@ -59,8 +59,8 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
</div> </div>
) : preview?.count === 0 ? ( ) : preview?.count === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<p>No profiles configured to sync.</p> <p>{t('syncDialog.noProfiles')}</p>
<p className="text-sm mt-2">Create API profiles first using the Profiles tab.</p> <p className="text-sm mt-2">{t('syncDialog.createProfilesFirst')}</p>
</div> </div>
) : ( ) : (
<ScrollArea className="h-[300px] pr-4"> <ScrollArea className="h-[300px] pr-4">
@@ -74,7 +74,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
<div className="font-medium">{profile.name}</div> <div className="font-medium">{profile.name}</div>
{profile.modelName && ( {profile.modelName && (
<div className="text-xs text-muted-foreground truncate max-w-[300px]"> <div className="text-xs text-muted-foreground truncate max-w-[300px]">
Model: {profile.modelName} {t('syncDialog.modelLabel')} {profile.modelName}
</div> </div>
)} )}
{profile.baseUrl && ( {profile.baseUrl && (
@@ -84,7 +84,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
)} )}
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
Ready {t('syncDialog.ready')}
</Badge> </Badge>
</div> </div>
))} ))}
@@ -94,11 +94,11 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
<div className="flex items-center justify-between mt-6 pt-4 border-t"> <div className="flex items-center justify-between mt-6 pt-4 border-t">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync {t('syncDialog.profilesToSync', { count: preview?.count ?? 0 })}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
Cancel {t('syncDialog.cancel')}
</Button> </Button>
<Button <Button
onClick={handleSync} onClick={handleSync}
@@ -108,17 +108,17 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
{isSyncing ? ( {isSyncing ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Syncing... {t('syncDialog.syncing')}
</> </>
) : isSuccess ? ( ) : isSuccess ? (
<> <>
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
Synced! {t('syncDialog.synced')}
</> </>
) : ( ) : (
<> <>
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Sync Now {t('syncDialog.syncNow')}
</> </>
)} )}
</Button> </Button>
@@ -9,10 +9,12 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react'; import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { SyncDialog } from './sync-dialog'; import { SyncDialog } from './sync-dialog';
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
export function SyncStatusCard() { export function SyncStatusCard() {
const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const { data: status, isLoading, refetch } = useSyncStatus(); const { data: status, isLoading, refetch } = useSyncStatus();
const { mutate: executeSync, isPending: isSyncing } = useExecuteSync(); const { mutate: executeSync, isPending: isSyncing } = useExecuteSync();
@@ -31,7 +33,7 @@ export function SyncStatusCard() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <CardTitle className="text-sm font-medium flex items-center gap-2">
<FileDown className="w-4 h-4" /> <FileDown className="w-4 h-4" />
Profile Sync {t('syncStatusCard.profileSync')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex items-center justify-center py-6"> <CardContent className="flex items-center justify-center py-6">
@@ -50,7 +52,7 @@ export function SyncStatusCard() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <CardTitle className="text-sm font-medium flex items-center gap-2">
<FileDown className="w-4 h-4" /> <FileDown className="w-4 h-4" />
Profile Sync {t('syncStatusCard.profileSync')}
</CardTitle> </CardTitle>
<Badge <Badge
variant={isConfigured ? 'default' : 'secondary'} variant={isConfigured ? 'default' : 'secondary'}
@@ -62,22 +64,24 @@ export function SyncStatusCard() {
)} )}
> >
{isConfigured ? <Check className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />} {isConfigured ? <Check className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
{isConfigured ? 'Ready' : 'No Config'} {isConfigured ? t('syncStatusCard.ready') : t('syncStatusCard.noConfig')}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{isConfigured && ( {isConfigured && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Syncs API profiles to local CLIProxy config {t('syncStatusCard.syncsProfilesDesc')}
</div> </div>
)} )}
{!isConfigured && ( {!isConfigured && (
<p className="text-xs text-muted-foreground"> <p
Run <code className="bg-muted px-1 rounded">ccs doctor --fix</code> to generate className="text-xs text-muted-foreground"
config. dangerouslySetInnerHTML={{
</p> __html: t('syncStatusCard.runDoctorHint'),
}}
/>
)} )}
{status?.error && <p className="text-xs text-red-500">{status.error}</p>} {status?.error && <p className="text-xs text-red-500">{status.error}</p>}
@@ -89,7 +93,7 @@ export function SyncStatusCard() {
className="flex-1" className="flex-1"
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
> >
Details {t('syncStatusCard.details')}
</Button> </Button>
<Button <Button
variant="default" variant="default"
@@ -103,7 +107,7 @@ export function SyncStatusCard() {
) : ( ) : (
<RefreshCw className="w-3 h-3" /> <RefreshCw className="w-3 h-3" />
)} )}
Sync Now {t('syncStatusCard.syncNow')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -4,6 +4,7 @@ import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CodeEditor } from '@/components/shared/code-editor'; import { CodeEditor } from '@/components/shared/code-editor';
import i18n from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface RawJsonSettingsEditorPanelProps { interface RawJsonSettingsEditorPanelProps {
@@ -39,7 +40,7 @@ export function RawJsonSettingsEditorPanel({
if (!value) return; if (!value) return;
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setCopied(true); setCopied(true);
toast.success('Settings copied to clipboard'); toast.success(i18n.t('commonToast.settingsCopied'));
window.setTimeout(() => setCopied(false), 1500); window.setTimeout(() => setCopied(false), 1500);
}; };
@@ -16,6 +16,7 @@ import {
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import type { FlexibleModelSelectorProps } from './types'; import type { FlexibleModelSelectorProps } from './types';
import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; import { getPlanBadgeStyle, getMultiplierDisplay } from './utils';
import { useTranslation } from 'react-i18next';
export function FlexibleModelSelector({ export function FlexibleModelSelector({
label, label,
@@ -25,6 +26,7 @@ export function FlexibleModelSelector({
models, models,
disabled, disabled,
}: FlexibleModelSelectorProps) { }: FlexibleModelSelectorProps) {
const { t } = useTranslation();
// Find current model for display // Find current model for display
const currentModel = models.find((m) => m.id === value); const currentModel = models.find((m) => m.id === value);
@@ -36,7 +38,7 @@ export function FlexibleModelSelector({
</div> </div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}> <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-9">
<SelectValue placeholder="Select model"> <SelectValue placeholder={t('componentModelSelector.selectModel')}>
{value && ( {value && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span> <span className="truncate font-mono text-xs">{value}</span>
@@ -55,7 +57,7 @@ export function FlexibleModelSelector({
<SelectContent className="max-h-[300px]"> <SelectContent className="max-h-[300px]">
<SelectGroup> <SelectGroup>
<SelectLabel className="text-xs text-muted-foreground"> <SelectLabel className="text-xs text-muted-foreground">
Available Models ({models.length}) {t('componentModelSelector.availableModelsCount', { count: models.length })}
</SelectLabel> </SelectLabel>
{models.map((model) => ( {models.map((model) => (
<SelectItem key={model.id} value={model.id}> <SelectItem key={model.id} value={model.id}>
@@ -76,7 +78,7 @@ export function FlexibleModelSelector({
)} )}
{model.preview && ( {model.preview && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4"> <Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
Preview {t('componentModelSelector.preview')}
</Badge> </Badge>
)} )}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />} {value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
@@ -16,6 +16,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslation } from 'react-i18next';
interface SettingsTabProps { interface SettingsTabProps {
enabled: boolean; enabled: boolean;
@@ -46,6 +47,8 @@ export function SettingsTab({
onUpdateRateLimit, onUpdateRateLimit,
onUpdateWaitOnLimit, onUpdateWaitOnLimit,
}: SettingsTabProps) { }: SettingsTabProps) {
const { t } = useTranslation();
return ( return (
<TabsContent <TabsContent
value="settings" value="settings"
@@ -57,10 +60,10 @@ export function SettingsTab({
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium"> <Label htmlFor="enabled" className="text-sm font-medium">
Enable Copilot {t('copilotSettings.enableCopilot')}
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow using GitHub Copilot subscription {t('copilotSettings.enableCopilotDesc')}
</p> </p>
</div> </div>
<Switch id="enabled" checked={enabled} onCheckedChange={onUpdateEnabled} /> <Switch id="enabled" checked={enabled} onCheckedChange={onUpdateEnabled} />
@@ -68,12 +71,12 @@ export function SettingsTab({
{/* Basic Settings */} {/* Basic Settings */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium">Basic Settings</h3> <h3 className="text-sm font-medium">{t('copilotSettings.basicSettings')}</h3>
{/* Port */} {/* Port */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="port" className="text-xs"> <Label htmlFor="port" className="text-xs">
Port {t('copilotPage.port')}
</Label> </Label>
<Input <Input
id="port" id="port"
@@ -89,16 +92,22 @@ export function SettingsTab({
{/* Account Type */} {/* Account Type */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="account-type" className="text-xs"> <Label htmlFor="account-type" className="text-xs">
Account Type {t('copilotSettings.accountType')}
</Label> </Label>
<Select value={accountType} onValueChange={onUpdateAccountType}> <Select value={accountType} onValueChange={onUpdateAccountType}>
<SelectTrigger id="account-type" className="max-w-[150px] h-8"> <SelectTrigger id="account-type" className="max-w-[150px] h-8">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="individual">Individual</SelectItem> <SelectItem value="individual">
<SelectItem value="business">Business</SelectItem> {t('copilotSettings.accountTypeIndividual')}
<SelectItem value="enterprise">Enterprise</SelectItem> </SelectItem>
<SelectItem value="business">
{t('copilotSettings.accountTypeBusiness')}
</SelectItem>
<SelectItem value="enterprise">
{t('copilotSettings.accountTypeEnterprise')}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -108,18 +117,18 @@ export function SettingsTab({
{/* Rate Limiting */} {/* Rate Limiting */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium">Rate Limiting</h3> <h3 className="text-sm font-medium">{t('copilotSettings.rateLimiting')}</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="rate-limit" className="text-xs"> <Label htmlFor="rate-limit" className="text-xs">
Rate Limit (seconds) {t('copilotSettings.rateLimitSeconds')}
</Label> </Label>
<Input <Input
id="rate-limit" id="rate-limit"
type="number" type="number"
value={rateLimit} value={rateLimit}
onChange={(e) => onUpdateRateLimit(e.target.value)} onChange={(e) => onUpdateRateLimit(e.target.value)}
placeholder="No limit" placeholder={t('copilotSettings.noLimit')}
min={0} min={0}
className="max-w-[150px] h-8" className="max-w-[150px] h-8"
/> />
@@ -128,10 +137,10 @@ export function SettingsTab({
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="wait-on-limit" className="text-xs"> <Label htmlFor="wait-on-limit" className="text-xs">
Wait on Rate Limit {t('copilotSettings.waitOnRateLimit')}
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
Wait instead of error when limit hit {t('copilotSettings.waitOnRateLimitDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -146,15 +155,15 @@ export function SettingsTab({
{/* Daemon Settings */} {/* Daemon Settings */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium">Daemon Settings</h3> <h3 className="text-sm font-medium">{t('copilotSettings.daemonSettings')}</h3>
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="auto-start" className="text-xs"> <Label htmlFor="auto-start" className="text-xs">
Auto-start Daemon {t('copilotSettings.autoStartDaemon')}
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
Start copilot-api when using profile {t('copilotSettings.autoStartDaemonDesc')}
</p> </p>
</div> </div>
<Switch id="auto-start" checked={autoStart} onCheckedChange={onUpdateAutoStart} /> <Switch id="auto-start" checked={autoStart} onCheckedChange={onUpdateAutoStart} />
@@ -5,6 +5,7 @@ import { useFixHealth, type HealthCheck } from '@/hooks/use-health';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
const statusConfig = { const statusConfig = {
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' }, ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
@@ -14,6 +15,7 @@ const statusConfig = {
}; };
export function HealthCheckItem({ check }: { check: HealthCheck }) { export function HealthCheckItem({ check }: { check: HealthCheck }) {
const { t } = useTranslation();
const fixMutation = useFixHealth(); const fixMutation = useFixHealth();
const config = statusConfig[check.status]; const config = statusConfig[check.status];
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -21,7 +23,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
toast.success('Copied to clipboard'); toast.success(t('health.copied'));
}; };
// Compact single-line display for items without expandable content // Compact single-line display for items without expandable content
@@ -62,7 +64,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity" className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
> >
<Wrench className="w-3 h-3 mr-1" /> <Wrench className="w-3 h-3 mr-1" />
Fix {t('health.fix')}
</Button> </Button>
)} )}
</div> </div>
@@ -149,7 +151,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
className="h-7 px-3 text-xs" className="h-7 px-3 text-xs"
> >
<Wrench className="w-3 h-3 mr-1" /> <Wrench className="w-3 h-3 mr-1" />
Apply Fix {t('health.applyFix')}
</Button> </Button>
)} )}
</div> </div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface HealthGaugeProps { interface HealthGaugeProps {
passed: number; passed: number;
@@ -20,6 +21,7 @@ const statusColors = {
}; };
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) { export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
const { t } = useTranslation();
const config = sizeConfig[size]; const config = sizeConfig[size];
const colors = statusColors[status]; const colors = statusColors[status];
const percentage = total > 0 ? Math.round((passed / total) * 100) : 0; const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
@@ -83,7 +85,7 @@ export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeP
config.labelSize config.labelSize
)} )}
> >
health {t('health.healthLabel')}
</span> </span>
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface HealthStatsBarProps { interface HealthStatsBarProps {
total: number; total: number;
@@ -28,6 +29,7 @@ function StatItem({ label, value, color, bgColor }: StatItemProps) {
} }
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) { export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
const { t } = useTranslation();
// Calculate percentages for the progress bar // Calculate percentages for the progress bar
const passedPct = (passed / total) * 100; const passedPct = (passed / total) * 100;
const warningPct = (warnings / total) * 100; const warningPct = (warnings / total) * 100;
@@ -68,7 +70,7 @@ export function HealthStatsBar({ total, passed, warnings, errors, info }: Health
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Checks {t('health.checks')}
</span> </span>
<span className="font-mono font-bold text-lg">{total}</span> <span className="font-mono font-bold text-lg">{total}</span>
</div> </div>
+59 -55
View File
@@ -35,6 +35,7 @@ import { useSidebar } from '@/hooks/use-sidebar';
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTranslation } from 'react-i18next';
interface SidebarBadge { interface SidebarBadge {
text: string; text: string;
@@ -62,66 +63,69 @@ interface SidebarGroupDef {
items: SidebarItem[]; items: SidebarItem[];
} }
// Define navigation groups function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
const navGroups: SidebarGroupDef[] = [ return [
{ {
title: 'General', title: t('nav.general'),
items: [ items: [
{ path: '/', icon: Home, label: 'Home' }, { path: '/', icon: Home, label: t('nav.home') },
{ path: '/analytics', icon: BarChart3, label: 'Analytics' }, { path: '/analytics', icon: BarChart3, label: t('nav.analytics') },
], ],
}, },
{ {
title: 'Identity & Access', title: t('nav.identityAccess'),
items: [ items: [
{ {
path: '/providers', path: '/providers',
icon: Key, icon: Key,
label: 'API Profiles', label: t('nav.apiProfiles'),
badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' }, badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' },
}, },
{ {
path: '/cliproxy', path: '/cliproxy',
icon: Zap, icon: Zap,
label: 'CLIProxy Plus', label: t('nav.cliproxyPlus'),
isCollapsible: true, isCollapsible: true,
children: [ children: [
{ path: '/cliproxy', label: 'Overview' }, { path: '/cliproxy', label: t('nav.cliproxyOverview') },
{ path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, { path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') },
], ],
}, },
{ path: '/copilot', icon: Github, label: 'GitHub Copilot' }, { path: '/copilot', icon: Github, label: t('nav.githubCopilot') },
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: 'Cursor IDE' }, { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') },
{ {
path: '/accounts', path: '/accounts',
icon: Users, icon: Users,
label: 'Accounts', label: t('nav.accounts'),
isCollapsible: true, isCollapsible: true,
children: [ children: [
{ path: '/accounts', label: 'All Accounts' }, { path: '/accounts', label: t('nav.allAccounts') },
{ path: '/shared', icon: FolderOpen, label: 'Shared Data' }, { path: '/shared', icon: FolderOpen, label: t('nav.sharedData') },
], ],
}, },
], ],
}, },
{ {
title: 'Compatible CLIs', title: t('nav.compatibleClis'),
items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }], items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }],
}, },
{ {
title: 'System', title: t('nav.system'),
items: [ items: [
{ path: '/health', icon: Activity, label: 'Health' }, { path: '/health', icon: Activity, label: t('nav.health') },
{ path: '/settings', icon: Settings, label: 'Settings' }, { path: '/settings', icon: Settings, label: t('nav.settings') },
], ],
}, },
]; ];
}
export function AppSidebar() { export function AppSidebar() {
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { state } = useSidebar(); const { state } = useSidebar();
const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: updateCheck } = useCliproxyUpdateCheck();
const navGroups = buildNavGroups(t);
// Dynamic label for CLIProxy based on backend // Dynamic label for CLIProxy based on backend
const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy'; const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy';
@@ -236,7 +240,7 @@ export function AppSidebar() {
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>349+ models via OpenRouter</p> <p>{t('nav.openrouterTooltip')}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@@ -0,0 +1,38 @@
import { Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { normalizeLocale, persistLocale, SUPPORTED_LOCALES } from '@/lib/locales';
export function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const currentLocale = normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? 'en');
const handleLocaleChange = (nextLocale: string) => {
const normalized = persistLocale(nextLocale);
void i18n.changeLanguage(normalized);
};
return (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<Select value={currentLocale} onValueChange={handleLocaleChange}>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue placeholder={t('layout.languageSwitcher')} />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LOCALES.map((locale) => (
<SelectItem key={locale} value={locale}>
{t(`locale.${locale}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+2
View File
@@ -14,6 +14,7 @@ import { SponsorButton } from '@/components/shared/sponsor-button';
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
import { DeviceCodeDialog } from '@/components/shared/device-code-dialog'; import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
import { UserMenu } from '@/components/auth/user-menu'; import { UserMenu } from '@/components/auth/user-menu';
import { LanguageSwitcher } from './language-switcher';
import { useProjectSelection } from '@/hooks/use-project-selection'; import { useProjectSelection } from '@/hooks/use-project-selection';
import { useDeviceCode } from '@/hooks/use-device-code'; import { useDeviceCode } from '@/hooks/use-device-code';
import { storeLastRoute } from '@/lib/last-route'; import { storeLastRoute } from '@/lib/last-route';
@@ -47,6 +48,7 @@ export function Layout() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ConnectionIndicator /> <ConnectionIndicator />
<LanguageSwitcher />
<DocsLink /> <DocsLink />
<GitHubLink /> <GitHubLink />
<PrivacyToggle /> <PrivacyToggle />
@@ -5,6 +5,7 @@
*/ */
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { STATUS_COLORS } from '@/lib/utils'; import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz'; import { AccountFlowViz } from '@/components/account-flow-viz';
@@ -20,6 +21,7 @@ import { SummaryCard } from './components/summary-card';
const STORAGE_KEY = 'auth-monitor-selected-provider'; const STORAGE_KEY = 'auth-monitor-selected-provider';
export function AuthMonitor() { export function AuthMonitor() {
const { t } = useTranslation();
const { const {
accounts, accounts,
totalSuccess, totalSuccess,
@@ -117,7 +119,7 @@ export function AuthMonitor() {
<span>Updated {timeSinceUpdate || 'now'}</span> <span>Updated {timeSinceUpdate || 'now'}</span>
</div> </div>
<span className="text-muted-foreground/50">|</span> <span className="text-muted-foreground/50">|</span>
<span>{accounts.length} accounts</span> <span>{t('authMonitor.accountsCount', { count: accounts.length })}</span>
<span className="font-mono">{totalRequests.toLocaleString()} req</span> <span className="font-mono">{totalRequests.toLocaleString()} req</span>
</div> </div>
</div> </div>
@@ -126,25 +128,25 @@ export function AuthMonitor() {
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30"> <div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
<SummaryCard <SummaryCard
icon={<Activity className="w-4 h-4" />} icon={<Activity className="w-4 h-4" />}
label="Accounts" label={t('authMonitor.accounts')}
value={accounts.length} value={accounts.length}
color="var(--accent)" color="var(--accent)"
/> />
<SummaryCard <SummaryCard
icon={<CheckCircle2 className="w-4 h-4" />} icon={<CheckCircle2 className="w-4 h-4" />}
label="Success" label={t('authMonitor.success')}
value={totalSuccess.toLocaleString()} value={totalSuccess.toLocaleString()}
color={STATUS_COLORS.success} color={STATUS_COLORS.success}
/> />
<SummaryCard <SummaryCard
icon={<XCircle className="w-4 h-4" />} icon={<XCircle className="w-4 h-4" />}
label="Failed" label={t('authMonitor.failed')}
value={totalFailure.toLocaleString()} value={totalFailure.toLocaleString()}
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
/> />
<SummaryCard <SummaryCard
icon={<Activity className="w-4 h-4" />} icon={<Activity className="w-4 h-4" />}
label="Success Rate" label={t('authMonitor.successRate')}
value={`${overallSuccessRate}%`} value={`${overallSuccessRate}%`}
color={ color={
overallSuccessRate === 100 overallSuccessRate === 100
@@ -14,10 +14,12 @@ import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react'; import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ErrorLogItem } from './error-log-item'; import { ErrorLogItem } from './error-log-item';
import { LogContentPanel } from './log-content-panel'; import { LogContentPanel } from './log-content-panel';
export function ErrorLogsMonitor() { export function ErrorLogsMonitor() {
const { t } = useTranslation();
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
@@ -142,14 +144,14 @@ export function ErrorLogsMonitor() {
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-500" /> <AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-sm font-semibold tracking-tight">Error Logs</span> <span className="text-sm font-semibold tracking-tight">{t('errorLogs.title')}</span>
<span className="text-xs text-muted-foreground ml-1"> <span className="text-xs text-muted-foreground ml-1">
{errorCount} failed request{errorCount !== 1 ? 's' : ''} {t('errorLogs.failedRequests', { count: errorCount })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileWarning className="w-3.5 h-3.5" /> <FileWarning className="w-3.5 h-3.5" />
<span>CLIProxy Diagnostics</span> <span>{t('errorLogs.cliproxyDiagnostics')}</span>
</div> </div>
</div> </div>
@@ -175,7 +177,7 @@ export function ErrorLogsMonitor() {
</div> </div>
{logs.length > 50 && ( {logs.length > 50 && (
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50"> <div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
Showing 50 of {logs.length} logs {t('errorLogs.showingCount', { total: logs.length })}
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
@@ -8,12 +8,14 @@ import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react'; import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { parseErrorLog } from '@/lib/error-log-parser'; import { parseErrorLog } from '@/lib/error-log-parser';
import type { TabType, LogContentPanelProps } from './types'; import type { TabType, LogContentPanelProps } from './types';
import { TabButton, StatusBadge } from './ui-primitives'; import { TabButton, StatusBadge } from './ui-primitives';
import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components'; import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
@@ -29,7 +31,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<Terminal className="w-10 h-10 mx-auto opacity-40" /> <Terminal className="w-10 h-10 mx-auto opacity-40" />
<p className="text-sm">Select a log to view details</p> <p className="text-sm">{t('errorLogs.selectLog')}</p>
</div> </div>
</div> </div>
); );
@@ -51,7 +53,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
if (error || !content || !parsed) { if (error || !content || !parsed) {
return ( return (
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-sm">Failed to load log content</p> <p className="text-sm">{t('errorLogs.failedLoadContent')}</p>
</div> </div>
); );
} }
@@ -69,7 +71,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
{name && ( {name && (
<CopyButton <CopyButton
value={absolutePath || name} value={absolutePath || name}
label="Copy absolute path" label={t('errorLogs.copyAbsolutePath')}
size="icon-sm" size="icon-sm"
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity" className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
/> />
@@ -80,7 +82,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
{content && ( {content && (
<CopyButton <CopyButton
value={content} value={content}
label="Copy raw log content" label={t('errorLogs.copyRawContent')}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
@@ -99,31 +101,31 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
icon={Info} icon={Info}
> >
Overview {t('errorLogs.tabOverview')}
</TabButton> </TabButton>
<TabButton <TabButton
active={activeTab === 'headers'} active={activeTab === 'headers'}
onClick={() => setActiveTab('headers')} onClick={() => setActiveTab('headers')}
icon={Code} icon={Code}
> >
Headers {t('errorLogs.tabHeaders')}
</TabButton> </TabButton>
<TabButton <TabButton
active={activeTab === 'request'} active={activeTab === 'request'}
onClick={() => setActiveTab('request')} onClick={() => setActiveTab('request')}
icon={ArrowUpRight} icon={ArrowUpRight}
> >
Request {t('errorLogs.tabRequest')}
</TabButton> </TabButton>
<TabButton <TabButton
active={activeTab === 'response'} active={activeTab === 'response'}
onClick={() => setActiveTab('response')} onClick={() => setActiveTab('response')}
icon={ArrowDownLeft} icon={ArrowDownLeft}
> >
Response {t('errorLogs.tabResponse')}
</TabButton> </TabButton>
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}> <TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
Raw {t('errorLogs.tabRaw')}
</TabButton> </TabButton>
</div> </div>
@@ -5,6 +5,7 @@
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react'; import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
getErrorTypeLabel, getErrorTypeLabel,
@@ -16,9 +17,11 @@ import { StatusBadge } from './ui-primitives';
/** Overview tab content */ /** Overview tab content */
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
const { t } = useTranslation();
const quotaResetDisplay = const quotaResetDisplay =
formatQuotaResetDelay(parsed.quotaResetDelay) || formatQuotaResetDelay(parsed.quotaResetDelay) ||
formatQuotaResetTimestamp(parsed.quotaResetTimestamp); formatQuotaResetTimestamp(parsed.quotaResetTimestamp);
const na = t('errorLogs.na');
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
@@ -37,7 +40,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-violet-500/10 border border-violet-500/20"> <div className="flex items-center gap-2.5 p-3 rounded-lg bg-violet-500/10 border border-violet-500/20">
<Cpu className="w-4 h-4 text-violet-500 shrink-0" /> <Cpu className="w-4 h-4 text-violet-500 shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="text-muted-foreground">Model: </span> <span className="text-muted-foreground">{t('errorLogs.modelLabel')}</span>
<span className="font-semibold text-violet-600 dark:text-violet-400"> <span className="font-semibold text-violet-600 dark:text-violet-400">
{parsed.model} {parsed.model}
</span> </span>
@@ -50,7 +53,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20"> <div className="flex items-center gap-2.5 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<Clock className="w-4 h-4 text-amber-500 shrink-0" /> <Clock className="w-4 h-4 text-amber-500 shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="text-muted-foreground">Quota resets in </span> <span className="text-muted-foreground">{t('errorLogs.quotaResetsIn')}</span>
<span className="font-semibold text-amber-600 dark:text-amber-400"> <span className="font-semibold text-amber-600 dark:text-amber-400">
{quotaResetDisplay} {quotaResetDisplay}
</span> </span>
@@ -61,37 +64,37 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
{/* Key metrics grid */} {/* Key metrics grid */}
<div className="grid grid-cols-4 gap-3 text-xs"> <div className="grid grid-cols-4 gap-3 text-xs">
<div className="p-2.5 rounded bg-muted/30 border border-border/50"> <div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Method</div> <div className="text-muted-foreground mb-1">{t('errorLogs.method')}</div>
<div className="font-medium">{parsed.method || 'N/A'}</div> <div className="font-medium">{parsed.method || na}</div>
</div> </div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50"> <div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div> <div className="text-muted-foreground mb-1">{t('errorLogs.provider')}</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div> <div className="font-medium">{parsed.provider || na}</div>
</div> </div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50"> <div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div> <div className="text-muted-foreground mb-1">{t('errorLogs.version')}</div>
<div className="font-medium">{parsed.version || 'N/A'}</div> <div className="font-medium">{parsed.version || na}</div>
</div> </div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50"> <div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Endpoint</div> <div className="text-muted-foreground mb-1">{t('errorLogs.endpoint')}</div>
<div className="font-medium truncate" title={parsed.endpoint}> <div className="font-medium truncate" title={parsed.endpoint}>
{parsed.endpoint || 'N/A'} {parsed.endpoint || na}
</div> </div>
</div> </div>
</div> </div>
{/* URL */} {/* URL */}
<div className="text-xs"> <div className="text-xs">
<div className="text-muted-foreground mb-1.5">URL</div> <div className="text-muted-foreground mb-1.5">{t('errorLogs.url')}</div>
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed"> <div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
{parsed.url || 'N/A'} {parsed.url || na}
</div> </div>
</div> </div>
{/* Timestamp */} {/* Timestamp */}
<div className="text-xs"> <div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div> <div className="text-muted-foreground mb-1.5">{t('errorLogs.timestamp')}</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div> <div className="font-mono">{parsed.timestamp || na}</div>
</div> </div>
{/* Actionable suggestion based on error type */} {/* Actionable suggestion based on error type */}
@@ -123,27 +126,14 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
: 'text-blue-600 dark:text-blue-400' : 'text-blue-600 dark:text-blue-400'
)} )}
> >
{parsed.errorType === 'rate_limit' && ( {parsed.errorType === 'rate_limit' &&
<> (parsed.model
<strong>Rate Limited.</strong> Switch to a different account or wait for quota ? t('errorLogs.rateLimitedMsg', { model: parsed.model })
reset. : t('errorLogs.rateLimitedMsgNoModel'))}
{parsed.model && ( {parsed.errorType === 'auth' && t('errorLogs.authFailedMsg')}
<> {parsed.errorType === 'not_found' && t('errorLogs.notFoundMsg')}
{' '} {parsed.errorType === 'server' && t('errorLogs.serverErrorMsg')}
Model <code className="font-mono text-[11px]">{parsed.model}</code> has {parsed.errorType === 'timeout' && t('errorLogs.timeoutMsg')}
exhausted quota.
</>
)}
</>
)}
{parsed.errorType === 'auth' &&
'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'}
{parsed.errorType === 'not_found' &&
'Endpoint not found. This endpoint may not exist on this provider.'}
{parsed.errorType === 'server' &&
'Server error from upstream. Retry later or check provider status page.'}
{parsed.errorType === 'timeout' &&
'Request timed out. Check network connection or increase timeout settings.'}
</div> </div>
</div> </div>
)} )}
@@ -178,8 +168,10 @@ export function HeadersTab({ headers }: { headers: Record<string, string> }) {
/** JSON/Body tab content */ /** JSON/Body tab content */
export function BodyTab({ content, label }: { content: string; label: string }) { export function BodyTab({ content, label }: { content: string; label: string }) {
const { t } = useTranslation();
if (!content || content.trim() === '') { if (!content || content.trim() === '') {
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>; const key = label === 'Request' ? 'errorLogs.noRequestBody' : 'errorLogs.noResponseBody';
return <div className="p-4 text-xs text-muted-foreground">{t(key)}</div>;
} }
// Try to format as JSON // Try to format as JSON
@@ -51,6 +51,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useQuery, useIsMutating } from '@tanstack/react-query'; import { useQuery, useIsMutating } from '@tanstack/react-query';
import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { api, type CliproxyServerConfig } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
import { import {
useProxyStatus, useProxyStatus,
useStartProxy, useStartProxy,
@@ -89,15 +90,17 @@ function formatUptime(startedAt?: string): string {
return `${minutes}m`; return `${minutes}m`;
} }
function formatTimeAgo(timestamp?: number): string { function formatTimeAgo(
timestamp: number | undefined,
t: (key: string, opts?: { count?: number }) => string
): string {
if (!timestamp) return ''; if (!timestamp) return '';
const diff = Date.now() - timestamp; const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / (1000 * 60)); const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60));
if (minutes < 1) return t('proxyStatusWidget.justNow');
if (minutes < 1) return 'just now'; if (minutes < 60) return t('proxyStatusWidget.minutesAgo', { count: minutes });
if (minutes < 60) return `${minutes}m ago`; return t('proxyStatusWidget.hoursAgo', { count: hours });
return `${hours}h ago`;
} }
/** Icon button with tooltip wrapper */ /** Icon button with tooltip wrapper */
@@ -148,6 +151,7 @@ function IconButton({
} }
export function ProxyStatusWidget() { export function ProxyStatusWidget() {
const { t } = useTranslation();
const { data: status, isLoading } = useProxyStatus(); const { data: status, isLoading } = useProxyStatus();
const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: updateCheck } = useCliproxyUpdateCheck();
const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions(); const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions();
@@ -196,7 +200,9 @@ export function ProxyStatusWidget() {
// Sync status // Sync status
const isSyncConfigured = syncStatus?.configured ?? false; const isSyncConfigured = syncStatus?.configured ?? false;
const syncStatusText = isSyncConfigured ? 'Sync Ready' : 'No Config'; const syncStatusText = isSyncConfigured
? t('proxyStatusWidget.syncReady')
: t('proxyStatusWidget.noConfig');
// Target version for update/downgrade badge // Target version for update/downgrade badge
const targetVersion = isUnstable const targetVersion = isUnstable
@@ -257,12 +263,12 @@ export function ProxyStatusWidget() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-blue-500" /> <Globe className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium">Remote Proxy</span> <span className="text-sm font-medium">{t('proxyStatusWidget.remoteProxy')}</span>
<Badge <Badge
variant="secondary" variant="secondary"
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
> >
Active {t('proxyStatusWidget.active')}
</Badge> </Badge>
</div> </div>
<Activity className="w-3 h-3 text-blue-600" /> <Activity className="w-3 h-3 text-blue-600" />
@@ -273,7 +279,7 @@ export function ProxyStatusWidget() {
<span className="font-mono">{remoteDisplayHost}</span> <span className="font-mono">{remoteDisplayHost}</span>
</div> </div>
<p className="text-[10px] text-muted-foreground/70 leading-tight"> <p className="text-[10px] text-muted-foreground/70 leading-tight">
Traffic auto-routed to remote server {t('proxyStatusWidget.trafficAutoRouted')}
</p> </p>
</div> </div>
</div> </div>
@@ -310,21 +316,21 @@ export function ProxyStatusWidget() {
<> <>
<IconButton <IconButton
icon={isSyncing ? RefreshCw : FileDown} icon={isSyncing ? RefreshCw : FileDown}
tooltip="Sync profiles to CLIProxy" tooltip={t('proxyStatusWidget.tooltipSync')}
onClick={() => executeSync()} onClick={() => executeSync()}
disabled={!isSyncConfigured || isActioning} disabled={!isSyncConfigured || isActioning}
isPending={isSyncing} isPending={isSyncing}
/> />
<IconButton <IconButton
icon={RotateCw} icon={RotateCw}
tooltip="Restart" tooltip={t('proxyStatusWidget.tooltipRestart')}
onClick={() => restartProxy.mutate()} onClick={() => restartProxy.mutate()}
disabled={isActioning} disabled={isActioning}
isPending={restartProxy.isPending} isPending={restartProxy.isPending}
/> />
<IconButton <IconButton
icon={Square} icon={Square}
tooltip="Stop" tooltip={t('proxyStatusWidget.tooltipStop')}
onClick={() => stopProxy.mutate()} onClick={() => stopProxy.mutate()}
disabled={isActioning} disabled={isActioning}
isPending={stopProxy.isPending} isPending={stopProxy.isPending}
@@ -335,7 +341,11 @@ export function ProxyStatusWidget() {
{/* Settings button always visible */} {/* Settings button always visible */}
<IconButton <IconButton
icon={isExpanded ? X : Settings} icon={isExpanded ? X : Settings}
tooltip={isExpanded ? 'Close' : 'Version settings'} tooltip={
isExpanded
? t('proxyStatusWidget.tooltipClose')
: t('proxyStatusWidget.tooltipVersionSettings')
}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className={isExpanded ? 'bg-muted' : undefined} className={isExpanded ? 'bg-muted' : undefined}
/> />
@@ -363,7 +373,11 @@ export function ProxyStatusWidget() {
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50' : 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
)} )}
onClick={() => handleInstallVersion(targetVersion)} onClick={() => handleInstallVersion(targetVersion)}
title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} title={
isUnstable
? t('proxyStatusWidget.clickToDowngrade')
: t('proxyStatusWidget.clickToUpdate')
}
> >
{isUnstable ? ( {isUnstable ? (
<ArrowDown className="w-2.5 h-2.5" /> <ArrowDown className="w-2.5 h-2.5" />
@@ -379,11 +393,13 @@ export function ProxyStatusWidget() {
{/* Stats row when running */} {/* Stats row when running */}
{isRunning && status && ( {isRunning && status && (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground"> <div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span> <span className="flex items-center gap-1">
{t('proxyStatusWidget.port', { port: status.port })}
</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && ( {status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users className="w-3 h-3" /> <Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} {t('proxyStatusWidget.sessionCount', { count: status.sessionCount })}
</span> </span>
)} )}
{status.startedAt && ( {status.startedAt && (
@@ -416,7 +432,9 @@ export function ProxyStatusWidget() {
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}> <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted"> <CollapsibleContent className="mt-3 pt-3 border-t border-muted">
{/* Section header */} {/* Section header */}
<h4 className="text-xs font-medium text-muted-foreground mb-3">Version Management</h4> <h4 className="text-xs font-medium text-muted-foreground mb-3">
{t('proxyStatusWidget.versionManagement')}
</h4>
{/* Version picker row */} {/* Version picker row */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -427,7 +445,7 @@ export function ProxyStatusWidget() {
disabled={versionsLoading} disabled={versionsLoading}
> >
<SelectTrigger className="h-8 text-xs flex-1"> <SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select version to install..." /> <SelectValue placeholder={t('proxyStatusWidget.selectVersionPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{versionsData?.versions.slice(0, 20).map((v) => { {versionsData?.versions.slice(0, 20).map((v) => {
@@ -439,7 +457,9 @@ export function ProxyStatusWidget() {
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
v{v} v{v}
{v === versionsData.latestStable && ( {v === versionsData.latestStable && (
<span className="text-green-600 dark:text-green-400">(stable)</span> <span className="text-green-600 dark:text-green-400">
{t('proxyStatusWidget.stable')}
</span>
)} )}
{vIsUnstable && ( {vIsUnstable && (
<span className="text-amber-600 dark:text-amber-400">⚠</span> <span className="text-amber-600 dark:text-amber-400">⚠</span>
@@ -464,7 +484,7 @@ export function ProxyStatusWidget() {
) : ( ) : (
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
)} )}
Install {t('proxyStatusWidget.install')}
</Button> </Button>
</div> </div>
@@ -474,14 +494,20 @@ export function ProxyStatusWidget() {
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && ( isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400"> <div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" /> <AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
<span>Versions above {versionsData.maxStableVersion} have known issues</span> <span>
{t('proxyStatusWidget.versionsAboveUnstable', {
version: versionsData.maxStableVersion,
})}
</span>
</div> </div>
)} )}
{/* Sync time */} {/* Sync time */}
{updateCheck?.checkedAt && ( {updateCheck?.checkedAt && (
<div className="mt-2 text-[10px] text-muted-foreground/60"> <div className="mt-2 text-[10px] text-muted-foreground/60">
Last checked {formatTimeAgo(updateCheck.checkedAt)} {t('proxyStatusWidget.lastChecked', {
time: formatTimeAgo(updateCheck.checkedAt, t),
})}
</div> </div>
)} )}
</CollapsibleContent> </CollapsibleContent>
@@ -490,7 +516,9 @@ export function ProxyStatusWidget() {
{/* Not running state */} {/* Not running state */}
{!isRunning && ( {!isRunning && (
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span> <span className="text-xs text-muted-foreground">
{t('proxyStatusWidget.notRunning')}
</span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -503,7 +531,7 @@ export function ProxyStatusWidget() {
) : ( ) : (
<Power className="w-3 h-3" /> <Power className="w-3 h-3" />
)} )}
Start {t('proxyStatusWidget.start')}
</Button> </Button>
</div> </div>
)} )}
@@ -514,27 +542,32 @@ export function ProxyStatusWidget() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" /> <AlertTriangle className="w-5 h-5 text-amber-500" />
Install Unstable Version? {t('proxyStatusWidget.installUnstableTitle')}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="space-y-2"> <AlertDialogDescription className="space-y-2">
<p> <p
You are about to install <strong>v{pendingInstallVersion}</strong>, which is above dangerouslySetInnerHTML={{
the maximum stable version{' '} __html: t('proxyStatusWidget.installUnstableDesc', {
<strong>v{versionsData?.maxStableVersion || '6.6.80'}</strong>. version: pendingInstallVersion ?? '',
</p> maxStable: versionsData?.maxStableVersion || '6.6.80',
}),
}}
/>
<p className="text-amber-600 dark:text-amber-400"> <p className="text-amber-600 dark:text-amber-400">
This version has known stability issues and may cause unexpected behavior. {t('proxyStatusWidget.installUnstableWarning')}
</p> </p>
<p>Are you sure you want to proceed?</p> <p>{t('proxyStatusWidget.installUnstableConfirm')}</p>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelUnstableInstall}>Cancel</AlertDialogCancel> <AlertDialogCancel onClick={handleCancelUnstableInstall}>
{t('proxyStatusWidget.cancel')}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmUnstableInstall} onClick={handleConfirmUnstableInstall}
className="bg-amber-500 hover:bg-amber-600 text-white" className="bg-amber-500 hover:bg-amber-600 text-white"
> >
Install Anyway {t('proxyStatusWidget.installAnyway')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -12,6 +12,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { isSensitiveKey } from './utils'; import { isSensitiveKey } from './utils';
import type { Settings } from './types'; import type { Settings } from './types';
import { useTranslation } from 'react-i18next';
interface EnvEditorSectionProps { interface EnvEditorSectionProps {
currentSettings: Settings | undefined; currentSettings: Settings | undefined;
@@ -32,6 +33,7 @@ export function EnvEditorSection({
onEnvValueChange, onEnvValueChange,
onAddEnvVar, onAddEnvVar,
}: EnvEditorSectionProps) { }: EnvEditorSectionProps) {
const { t } = useTranslation();
return ( return (
<> <>
{/* Scrollable Environment Variables List */} {/* Scrollable Environment Variables List */}
@@ -45,7 +47,7 @@ export function EnvEditorSection({
{key} {key}
{isSensitiveKey(key) && ( {isSensitiveKey(key) && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4"> <Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
sensitive {t('envEditor.sensitive')}
</Badge> </Badge>
)} )}
</Label> </Label>
@@ -67,10 +69,8 @@ export function EnvEditorSection({
</> </>
) : ( ) : (
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm"> <div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
<p>No environment variables configured.</p> <p>{t('envEditor.none')}</p>
<p className="text-xs mt-1 opacity-70"> <p className="text-xs mt-1 opacity-70">{t('envEditor.noneHint')}</p>
Add variables using the input below or edit the JSON directly.
</p>
</div> </div>
)} )}
</div> </div>
@@ -79,18 +79,18 @@ export function EnvEditorSection({
{/* Fixed Add Input at Bottom */} {/* Fixed Add Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0"> <div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground"> <Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable {t('envEditor.addVariable')}
</Label> </Label>
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<Input <Input
placeholder="VARIABLE_NAME" placeholder={t('envEditor.keyPlaceholder')}
value={newEnvKey} value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())} onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8 w-2/5" className="font-mono text-sm h-8 w-2/5"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()} onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/> />
<Input <Input
placeholder="value" placeholder={t('envEditor.valuePlaceholder')}
value={newEnvValue} value={newEnvValue}
onChange={(e) => onNewEnvValueChange(e.target.value)} onChange={(e) => onNewEnvValueChange(e.target.value)}
className="font-mono text-sm h-8 flex-1" className="font-mono text-sm h-8 flex-1"
@@ -19,6 +19,7 @@ import { ChevronRight, Settings2, Plus } from 'lucide-react';
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils'; import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import i18n from '@/lib/i18n';
import type { Settings, SettingsResponse } from './types'; import type { Settings, SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client'; import type { CliTarget } from '@/lib/api-client';
@@ -81,7 +82,7 @@ export function FriendlyUISection({
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId); onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
} }
// Show feedback toast // Show feedback toast
toast.success('Applied model to all tiers', { duration: 2000 }); toast.success(i18n.t('commonToast.appliedModelAllTiers'), { duration: 2000 });
}; };
// Handle tier mapping change // Handle tier mapping change
+4 -3
View File
@@ -16,6 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section';
import { RawEditorSection } from './raw-editor-section'; import { RawEditorSection } from './raw-editor-section';
import type { ProfileEditorProps, Settings, SettingsResponse } from './types'; import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
import { api, type CliTarget } from '@/lib/api-client'; import { api, type CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n';
export function ProfileEditor({ export function ProfileEditor({
profileName, profileName,
@@ -143,7 +144,7 @@ export function ProfileEditor({
queryClient.invalidateQueries({ queryKey: ['profiles'] }); queryClient.invalidateQueries({ queryKey: ['profiles'] });
setLocalEdits({}); setLocalEdits({});
setRawJsonEdits(null); setRawJsonEdits(null);
toast.success('Settings saved'); toast.success(i18n.t('commonToast.settingsSaved'));
}, },
onError: (error: Error) => { onError: (error: Error) => {
if (error.message === 'CONFLICT') setConflictDialog(true); if (error.message === 'CONFLICT') setConflictDialog(true);
@@ -158,12 +159,12 @@ export function ProfileEditor({
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] }); queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Default target updated'); toast.success(i18n.t('commonToast.defaultTargetUpdated'));
}, },
onError: (error: Error, target: CliTarget) => { onError: (error: Error, target: CliTarget) => {
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code'; const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
const suffix = error.message.trim() ? `: ${error.message}` : ''; const suffix = error.message.trim() ? `: ${error.message}` : '';
toast.error(`Failed to update default target to ${targetLabel}${suffix}`); toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix }));
}, },
}); });
@@ -3,6 +3,7 @@
* Displays profile information and usage commands * Displays profile information and usage commands
*/ */
import { useTranslation } from 'react-i18next';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
@@ -17,6 +18,7 @@ interface InfoSectionProps {
} }
export function InfoSection({ profileName, target, data }: InfoSectionProps) { export function InfoSection({ profileName, target, data }: InfoSectionProps) {
const { t } = useTranslation();
const isDroidTarget = target === 'droid'; const isDroidTarget = target === 'droid';
return ( return (
@@ -26,17 +28,21 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
<div> <div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3"> <h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
Profile Information {t('profileEditor.profileInfo')}
</h3> </h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm"> <div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
{data && ( {data && (
<> <>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Profile Name</span> <span className="font-medium text-muted-foreground">
{t('profileEditor.profileName')}
</span>
<span className="font-mono">{data.profile}</span> <span className="font-mono">{data.profile}</span>
</div> </div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span> <span className="font-medium text-muted-foreground">
{t('profileEditor.filePath')}
</span>
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all"> <code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path} {data.path}
@@ -45,11 +51,15 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div> </div>
</div> </div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Last Modified</span> <span className="font-medium text-muted-foreground">
{t('profileEditor.lastModified')}
</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span> <span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div> </div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Default Target</span> <span className="font-medium text-muted-foreground">
{t('profileEditor.defaultTarget')}
</span>
<span className="font-mono">{target}</span> <span className="font-mono">{target}</span>
</div> </div>
</> </>
@@ -59,10 +69,12 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
{/* Usage */} {/* Usage */}
<div> <div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3> <h3 className="text-sm font-medium mb-3">{t('profileEditor.quickUsage')}</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm"> <div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div> <div>
<Label className="text-xs text-muted-foreground">Run with profile</Label> <Label className="text-xs text-muted-foreground">
{t('profileEditor.runWithProfile')}
</Label>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate"> <code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} "prompt" ccs {profileName} "prompt"
@@ -72,7 +84,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div> </div>
<div> <div>
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'} {isDroidTarget
? t('profileEditor.droidAliasExplicit')
: t('profileEditor.runOnDroid')}
</Label> </Label>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate"> <code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
@@ -93,7 +107,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div> </div>
<div> <div>
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Override to Claude' : 'Override to Claude (explicit)'} {isDroidTarget
? t('profileEditor.overrideToClaude')
: t('profileEditor.overrideToClaudeExplicit')}
</Label> </Label>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate"> <code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
@@ -107,7 +123,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div> </div>
</div> </div>
<div> <div>
<Label className="text-xs text-muted-foreground">Set as default</Label> <Label className="text-xs text-muted-foreground">
{t('profileEditor.setAsDefault')}
</Label>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate"> <code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs default {profileName} ccs default {profileName}
@@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { suggestTierMappings } from '@/lib/openrouter-utils'; import { suggestTierMappings } from '@/lib/openrouter-utils';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export interface TierMapping { export interface TierMapping {
opus?: string; opus?: string;
@@ -32,6 +33,7 @@ export function ModelTierMapping({
onChange, onChange,
className, className,
}: ModelTierMappingProps) { }: ModelTierMappingProps) {
const { t } = useTranslation();
const { models } = useOpenRouterCatalog(); const { models } = useOpenRouterCatalog();
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
@@ -53,18 +55,18 @@ export function ModelTierMapping({
<Collapsible className={cn('group', className)}> <Collapsible className={cn('group', className)}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline"> <CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" /> <ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
Model Tier Mapping {t('modelTierMapping.title')}
<span className="text-muted-foreground font-normal">(Advanced)</span> <span className="text-muted-foreground font-normal">
({t('modelTierMapping.advanced')})
</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3"> <CollapsibleContent className="space-y-3 pt-3">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">{t('modelTierMapping.description')}</p>
Configure different models for Claude Code&apos;s opus/sonnet/haiku tiers.
</p>
{hasSuggestions && ( {hasSuggestions && (
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}> <Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
<Wand2 className="mr-1 h-4 w-4" /> <Wand2 className="mr-1 h-4 w-4" />
Auto-suggest based on {selectedModel?.split('/')[0]} {t('modelTierMapping.autoSuggest', { provider: selectedModel?.split('/')[0] })}
</Button> </Button>
)} )}
@@ -77,7 +79,7 @@ export function ModelTierMapping({
id="tier-opus" id="tier-opus"
value={value.opus ?? ''} value={value.opus ?? ''}
onChange={(e) => updateTier('opus', e.target.value)} onChange={(e) => updateTier('opus', e.target.value)}
placeholder="e.g., anthropic/claude-opus-4" placeholder={t('modelTierMapping.opusPlaceholder')}
/> />
</div> </div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2"> <div className="grid grid-cols-[80px_1fr] items-center gap-2">
@@ -88,7 +90,7 @@ export function ModelTierMapping({
id="tier-sonnet" id="tier-sonnet"
value={value.sonnet ?? ''} value={value.sonnet ?? ''}
onChange={(e) => updateTier('sonnet', e.target.value)} onChange={(e) => updateTier('sonnet', e.target.value)}
placeholder="e.g., anthropic/claude-sonnet-4" placeholder={t('modelTierMapping.sonnetPlaceholder')}
/> />
</div> </div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2"> <div className="grid grid-cols-[80px_1fr] items-center gap-2">
@@ -99,15 +101,12 @@ export function ModelTierMapping({
id="tier-haiku" id="tier-haiku"
value={value.haiku ?? ''} value={value.haiku ?? ''}
onChange={(e) => updateTier('haiku', e.target.value)} onChange={(e) => updateTier('haiku', e.target.value)}
placeholder="e.g., anthropic/claude-3.5-haiku" placeholder={t('modelTierMapping.haikuPlaceholder')}
/> />
</div> </div>
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">{t('modelTierMapping.footer')}</p>
These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL.
</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models'; import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react'; import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface OpenRouterQuickStartProps { interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void; onOpenRouterClick: () => void;
@@ -19,6 +20,7 @@ export function OpenRouterQuickStart({
onOpenRouterClick, onOpenRouterClick,
onCustomClick, onCustomClick,
}: OpenRouterQuickStartProps) { }: OpenRouterQuickStartProps) {
const { t } = useTranslation();
const { modelCount, isLoading } = useOpenRouterReady(); const { modelCount, isLoading } = useOpenRouterReady();
return ( return (
@@ -35,13 +37,14 @@ export function OpenRouterQuickStart({
variant="secondary" variant="secondary"
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground" className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
> >
Recommended {t('openrouterQuickStart.recommended')}
</Badge> </Badge>
</div> </div>
<CardTitle className="text-xl">Start with OpenRouter</CardTitle> <CardTitle className="text-xl">{t('openrouterQuickStart.title')}</CardTitle>
<CardDescription className="text-base"> <CardDescription className="text-base">
Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google, {t('openrouterQuickStart.description', {
Meta and more - all through one API. modelCountLabel: isLoading ? '300+' : `${modelCount}+`,
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -49,11 +52,11 @@ export function OpenRouterQuickStart({
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Zap className="w-4 h-4 text-accent" /> <Zap className="w-4 h-4 text-accent" />
<span>One API, all providers</span> <span>{t('openrouterQuickStart.featureOneApi')}</span>
</div> </div>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="w-4 h-4 text-accent" /> <Sparkles className="w-4 h-4 text-accent" />
<span>Model tier mapping</span> <span>{t('openrouterQuickStart.featureTierMapping')}</span>
</div> </div>
</div> </div>
@@ -62,12 +65,12 @@ export function OpenRouterQuickStart({
className="w-full bg-accent hover:bg-accent/90 text-white" className="w-full bg-accent hover:bg-accent/90 text-white"
size="lg" size="lg"
> >
Create OpenRouter Profile {t('openrouterQuickStart.createOpenRouterProfile')}
<ArrowRight className="w-4 h-4 ml-2" /> <ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
<p className="text-xs text-center text-muted-foreground"> <p className="text-xs text-center text-muted-foreground">
Get your API key at{' '} {t('openrouterQuickStart.getApiKeyAt')}{' '}
<a <a
href="https://openrouter.ai/keys" href="https://openrouter.ai/keys"
target="_blank" target="_blank"
@@ -84,13 +87,13 @@ export function OpenRouterQuickStart({
{/* Divider */} {/* Divider */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Separator className="flex-1" /> <Separator className="flex-1" />
<span className="text-xs text-muted-foreground">or</span> <span className="text-xs text-muted-foreground">{t('openrouterQuickStart.or')}</span>
<Separator className="flex-1" /> <Separator className="flex-1" />
</div> </div>
{/* Custom Option */} {/* Custom Option */}
<Button variant="outline" onClick={onCustomClick} className="w-full"> <Button variant="outline" onClick={onCustomClick} className="w-full">
Create Custom API Profile {t('openrouterQuickStart.createCustomProfile')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -32,6 +32,7 @@ import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles'; import { useCreateProfile } from '@/hooks/use-profiles';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react'; import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -49,6 +50,7 @@ import {
} from '@/lib/openrouter-utils'; } from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types'; import type { CategorizedModel } from '@/lib/openrouter-types';
import type { CliTarget } from '@/lib/api-client'; import type { CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n';
const schema = z.object({ const schema = z.object({
name: z name: z
@@ -104,6 +106,7 @@ export function ProfileCreateDialog({
onSuccess, onSuccess,
initialMode = 'openrouter', initialMode = 'openrouter',
}: ProfileCreateDialogProps) { }: ProfileCreateDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile(); const createMutation = useCreateProfile();
const [activeTab, setActiveTab] = useState('basic'); const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null); const [urlWarning, setUrlWarning] = useState<string | null>(null);
@@ -242,7 +245,7 @@ export function ProfileCreateDialog({
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
// Validate API key - required unless preset has requiresApiKey: false // Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) { if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error('API key is required'); toast.error(i18n.t('commonToast.apiKeyRequired'));
return; return;
} }
// Use user-provided baseUrl (allows customization of preset URLs) // Use user-provided baseUrl (allows customization of preset URLs)
@@ -290,7 +293,9 @@ export function ProfileCreateDialog({
<div className="px-6 py-3 border-b bg-muted/30 space-y-2"> <div className="px-6 py-3 border-b bg-muted/30 space-y-2">
{/* Main Options: OpenRouter + Custom */} {/* Main Options: OpenRouter + Custom */}
<div> <div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label> <Label className="text-xs text-muted-foreground mb-1.5 block">
{t('profileEditor.provider')}
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
{RECOMMENDED_PRESETS.map((preset) => ( {RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard <CompactPresetCard
@@ -312,7 +317,7 @@ export function ProfileCreateDialog({
)} )}
> >
<Settings2 className="w-4 h-4" /> <Settings2 className="w-4 h-4" />
<span>Custom</span> <span>{t('profileEditor.custom')}</span>
</button> </button>
</div> </div>
</div> </div>
+30 -18
View File
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label';
import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles';
import type { Profile } from '@/lib/api-client'; import type { Profile } from '@/lib/api-client';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
@@ -41,6 +42,7 @@ interface ProfileDialogProps {
} }
export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile(); const createMutation = useCreateProfile();
const updateMutation = useUpdateProfile(); const updateMutation = useUpdateProfile();
const [showModelMapping, setShowModelMapping] = useState(false); const [showModelMapping, setShowModelMapping] = useState(false);
@@ -115,34 +117,45 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{profile ? 'Edit Profile' : 'Create API Profile'}</DialogTitle> <DialogTitle>
{profile ? t('profileDialog.editTitle') : t('profileDialog.createTitle')}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div> <div>
<Label htmlFor="name">Name</Label> <Label htmlFor="name">{t('profileDialog.name')}</Label>
<Input id="name" {...register('name')} placeholder="my-api" disabled={!!profile} /> <Input
id="name"
{...register('name')}
placeholder={t('profileDialog.namePlaceholder')}
disabled={!!profile}
/>
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>} {errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
</div> </div>
<div> <div>
<Label htmlFor="baseUrl">Base URL</Label> <Label htmlFor="baseUrl">{t('profileDialog.baseUrl')}</Label>
<Input id="baseUrl" {...register('baseUrl')} placeholder="https://api.example.com" /> <Input
id="baseUrl"
{...register('baseUrl')}
placeholder={t('profileDialog.baseUrlPlaceholder')}
/>
{errors.baseUrl && ( {errors.baseUrl && (
<span className="text-xs text-red-500">{errors.baseUrl.message}</span> <span className="text-xs text-red-500">{errors.baseUrl.message}</span>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="apiKey">API Key</Label> <Label htmlFor="apiKey">{t('profileDialog.apiKey')}</Label>
<Input id="apiKey" type="password" {...register('apiKey')} /> <Input id="apiKey" type="password" {...register('apiKey')} />
{errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>} {errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
</div> </div>
<div> <div>
<Label htmlFor="model">Default Model (ANTHROPIC_MODEL)</Label> <Label htmlFor="model">{t('profileDialog.defaultModel')}</Label>
<Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} /> <Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Leave blank to use: {DEFAULT_MODEL} {t('profileDialog.defaultModelHint', { model: DEFAULT_MODEL })}
</p> </p>
</div> </div>
@@ -153,7 +166,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors" className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
onClick={() => setShowModelMapping(!showModelMapping)} onClick={() => setShowModelMapping(!showModelMapping)}
> >
<span>Model Mapping (Opus/Sonnet/Haiku)</span> <span>{t('profileDialog.modelMappingTitle')}</span>
{showModelMapping ? ( {showModelMapping ? (
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
) : ( ) : (
@@ -164,13 +177,12 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
{showModelMapping && ( {showModelMapping && (
<div className="p-3 pt-0 space-y-3 border-t"> <div className="p-3 pt-0 space-y-3 border-t">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Configure different model IDs for each tier. Useful for API proxies that route {t('profileDialog.modelMappingDesc')}
different model types to different backends.
</p> </p>
<div> <div>
<Label htmlFor="opusModel" className="text-xs"> <Label htmlFor="opusModel" className="text-xs">
Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL) {t('profileDialog.opusModel')}
</Label> </Label>
<Input <Input
id="opusModel" id="opusModel"
@@ -182,7 +194,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div> <div>
<Label htmlFor="sonnetModel" className="text-xs"> <Label htmlFor="sonnetModel" className="text-xs">
Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL) {t('profileDialog.sonnetModel')}
</Label> </Label>
<Input <Input
id="sonnetModel" id="sonnetModel"
@@ -194,7 +206,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div> <div>
<Label htmlFor="haikuModel" className="text-xs"> <Label htmlFor="haikuModel" className="text-xs">
Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL) {t('profileDialog.haikuModel')}
</Label> </Label>
<Input <Input
id="haikuModel" id="haikuModel"
@@ -209,14 +221,14 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel {t('profileDialog.cancel')}
</Button> </Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}> <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending {createMutation.isPending || updateMutation.isPending
? 'Saving...' ? t('profileDialog.saving')
: profile : profile
? 'Update' ? t('profileDialog.update')
: 'Create'} : t('profileDialog.create')}
</Button> </Button>
</div> </div>
</form> </form>
+2 -1
View File
@@ -24,6 +24,7 @@ import {
import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import type { CLIProxyProvider } from '@/lib/provider-config'; import type { CLIProxyProvider } from '@/lib/provider-config';
import { applyDefaultPreset } from '@/lib/preset-utils'; import { applyDefaultPreset } from '@/lib/preset-utils';
import i18n from '@/lib/i18n';
import { usePrivacy } from '@/contexts/privacy-context'; import { usePrivacy } from '@/contexts/privacy-context';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -100,7 +101,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
if (result.success && result.presetName) { if (result.success && result.presetName) {
toast.success(`Applied "${result.presetName}" preset`); toast.success(`Applied "${result.presetName}" preset`);
} else if (!result.success) { } else if (!result.success) {
toast.warning('Account added, but failed to apply default preset'); toast.warning(i18n.t('commonToast.accountAddedPresetFailed'));
} }
} }
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import type { VariantStepProps } from '../types'; import type { VariantStepProps } from '../types';
import { useTranslation } from 'react-i18next';
const CUSTOM_MODEL_VALUE = '__custom__'; const CUSTOM_MODEL_VALUE = '__custom__';
@@ -34,6 +35,7 @@ export function VariantStep({
onSkip, onSkip,
onCreate, onCreate,
}: VariantStepProps) { }: VariantStepProps) {
const { t } = useTranslation();
// Track if user selected custom model option // Track if user selected custom model option
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || []; const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName); const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
@@ -55,7 +57,7 @@ export function VariantStep({
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm"> <div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" /> <User className="w-4 h-4" />
<span> <span>
Using:{' '} {t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}> <span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id} {selectedAccount.email || selectedAccount.id}
</span> </span>
@@ -64,33 +66,33 @@ export function VariantStep({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="variant-name">Variant Name *</Label> <Label htmlFor="variant-name">{t('setupVariant.variantNameRequired')}</Label>
<Input <Input
id="variant-name" id="variant-name"
value={variantName} value={variantName}
onChange={(e) => onVariantNameChange(e.target.value)} onChange={(e) => onVariantNameChange(e.target.value)}
placeholder="e.g., my-gemini, g3, flash" placeholder={t('setupVariant.variantNamePlaceholder')}
/> />
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Use this name to invoke: ccs {variantName || '<name>'} "prompt" {t('setupVariant.invokeHintPrefix')} ccs {variantName || '<name>'} "prompt"
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Model</Label> <Label>{t('setupVariant.model')}</Label>
{showCustomInput ? ( {showCustomInput ? (
<div className="space-y-2"> <div className="space-y-2">
<Input <Input
value={modelName} value={modelName}
onChange={(e) => onModelChange(e.target.value)} onChange={(e) => onModelChange(e.target.value)}
placeholder="e.g., gemini-2.5-pro, claude-opus-4-5" placeholder={t('setupVariant.modelPlaceholder')}
/> />
<button <button
type="button" type="button"
className="text-xs text-primary hover:underline" className="text-xs text-primary hover:underline"
onClick={() => setShowCustomInput(false)} onClick={() => setShowCustomInput(false)}
> >
Choose from presets instead {t('setupVariant.choosePresetInstead')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -99,7 +101,7 @@ export function VariantStep({
onValueChange={handleModelSelect} onValueChange={handleModelSelect}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a model" /> <SelectValue placeholder={t('setupVariant.selectModel')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{catalogModels.map((m) => ( {catalogModels.map((m) => (
@@ -113,35 +115,37 @@ export function VariantStep({
</SelectItem> </SelectItem>
))} ))}
<SelectItem value={CUSTOM_MODEL_VALUE}> <SelectItem value={CUSTOM_MODEL_VALUE}>
<span className="text-primary">Custom model name...</span> <span className="text-primary">{t('setupVariant.customModelName')}</span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{showCustomInput {showCustomInput
? 'Enter any model name supported by CLIProxy' ? t('setupVariant.enterAnyModel')
: `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`} : t('setupVariant.defaultModel', {
model:
MODEL_CATALOGS[selectedProvider]?.defaultModel ||
t('setupVariant.providerDefault'),
})}
</div> </div>
</div> </div>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={onBack}> <Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Back {t('setupVariant.back')}
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" onClick={onSkip}> <Button variant="ghost" onClick={onSkip}>
Skip {t('setupVariant.skip')}
</Button> </Button>
<Button onClick={onCreate} disabled={!variantName || isPending}> <Button onClick={onCreate} disabled={!variantName || isPending}>
{isPending ? 'Creating...' : 'Create Variant'} {isPending ? t('setupVariant.creating') : t('setupVariant.createVariant')}
</Button> </Button>
</div> </div>
</div> </div>
<p className="text-xs text-center text-muted-foreground"> <p className="text-xs text-center text-muted-foreground">{t('setupVariant.skipHint')}</p>
Skip if you just wanted to add an account without creating a variant
</p>
</div> </div>
); );
} }
@@ -21,6 +21,7 @@ import {
getDeviceCodeProviderDisplayName, getDeviceCodeProviderDisplayName,
getDeviceCodeProviderInstruction, getDeviceCodeProviderInstruction,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
import i18n from '@/lib/i18n';
interface DeviceCodeDialogProps { interface DeviceCodeDialogProps {
open: boolean; open: boolean;
@@ -72,10 +73,10 @@ export function DeviceCodeDialog({
try { try {
await navigator.clipboard.writeText(userCode); await navigator.clipboard.writeText(userCode);
setHasCopied(true); setHasCopied(true);
toast.success('Code copied to clipboard'); toast.success(i18n.t('commonToast.codeCopied'));
setTimeout(() => setHasCopied(false), 2000); setTimeout(() => setHasCopied(false), 2000);
} catch { } catch {
toast.error('Failed to copy code'); toast.error(i18n.t('commonToast.copyCodeFailed'));
} }
}, [userCode]); }, [userCode]);
+2 -1
View File
@@ -20,6 +20,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
import { ConfirmDialog } from './confirm-dialog'; import { ConfirmDialog } from './confirm-dialog';
import { Save, X, Loader2, Code2 } from 'lucide-react'; import { Save, X, Loader2, Code2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import i18n from '@/lib/i18n';
// Lazy load CodeEditor to reduce initial bundle size // Lazy load CodeEditor to reduce initial bundle size
const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor }))); const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor })));
@@ -154,7 +155,7 @@ function SettingsDialogContent({
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
queryClient.invalidateQueries({ queryKey: ['profiles'] }); queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Settings saved'); toast.success(i18n.t('commonToast.settingsSaved'));
onClose(); onClose();
}, },
onError: (error: Error) => { onError: (error: Error) => {
@@ -1,33 +1,7 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { type NoticeProgressState } from '@/lib/updates-notice-state'; import { type NoticeProgressState } from '@/lib/updates-notice-state';
import { useTranslation } from 'react-i18next';
const NOTICE_PROGRESS_META: Record<
NoticeProgressState,
{ label: string; className: string; showDot?: boolean }
> = {
new: {
label: 'Needs Action',
className:
'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300',
showDot: true,
},
seen: {
label: 'In Review',
className:
'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300',
},
done: {
label: 'Done',
className:
'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300',
},
dismissed: {
label: 'Dismissed',
className:
'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30',
},
};
export function NoticeProgressBadge({ export function NoticeProgressBadge({
state, state,
@@ -36,7 +10,30 @@ export function NoticeProgressBadge({
state: NoticeProgressState; state: NoticeProgressState;
className?: string; className?: string;
}) { }) {
const meta = NOTICE_PROGRESS_META[state]; const { t } = useTranslation();
const meta = {
new: {
label: t('updates.progressNeedsAction'),
className:
'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300',
showDot: true,
},
seen: {
label: t('updates.progressInReview'),
className:
'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300',
},
done: {
label: t('updates.progressDone'),
className:
'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300',
},
dismissed: {
label: t('updates.progressDismissed'),
className:
'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30',
},
}[state] as { label: string; className: string; showDot?: boolean };
return ( return (
<Badge <Badge
@@ -8,12 +8,7 @@ import {
type SupportScope, type SupportScope,
} from '@/lib/support-updates-catalog'; } from '@/lib/support-updates-catalog';
import { SupportStatusBadge } from './support-status-badge'; import { SupportStatusBadge } from './support-status-badge';
import { useTranslation } from 'react-i18next';
const PILLAR_LABELS: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'auth', label: 'Auth' },
{ key: 'model', label: 'Model' },
];
const SCOPE_STYLES: Record<SupportScope, string> = { const SCOPE_STYLES: Record<SupportScope, string> = {
target: target:
@@ -27,6 +22,13 @@ const SCOPE_STYLES: Record<SupportScope, string> = {
}; };
export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) { export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
const { t } = useTranslation();
const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'auth', label: 'Auth' },
{ key: 'model', label: 'Model' },
];
return ( return (
<Card className="h-full gap-4"> <Card className="h-full gap-4">
<CardHeader className="gap-3"> <CardHeader className="gap-3">
@@ -45,7 +47,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
{PILLAR_LABELS.map((pillar) => ( {pillarLabels.map((pillar) => (
<div key={pillar.key} className="rounded-md border bg-muted/30 p-2"> <div key={pillar.key} className="rounded-md border bg-muted/30 p-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground"> <p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{pillar.label} {pillar.label}
@@ -57,7 +59,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Dashboard {t('updates.dashboard')}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{entry.routes.map((route) => ( {entry.routes.map((route) => (
@@ -75,7 +77,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
CLI Usage {t('updates.cliUsage')}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{entry.commands.slice(0, 2).map((command) => ( {entry.commands.slice(0, 2).map((command) => (
@@ -1,12 +1,7 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { SupportStatus } from '@/lib/support-updates-catalog'; import type { SupportStatus } from '@/lib/support-updates-catalog';
import { useTranslation } from 'react-i18next';
const STATUS_LABELS: Record<SupportStatus, string> = {
new: 'New',
stable: 'Stable',
planned: 'Planned',
};
const STATUS_STYLES: Record<SupportStatus, string> = { const STATUS_STYLES: Record<SupportStatus, string> = {
new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300', new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300',
@@ -23,9 +18,16 @@ export function SupportStatusBadge({
status: SupportStatus; status: SupportStatus;
className?: string; className?: string;
}) { }) {
const { t } = useTranslation();
const labels: Record<SupportStatus, string> = {
new: t('updates.statusNew'),
stable: t('updates.statusStable'),
planned: t('updates.statusPlanned'),
};
return ( return (
<Badge variant="outline" className={cn('font-medium', STATUS_STYLES[status], className)}> <Badge variant="outline" className={cn('font-medium', STATUS_STYLES[status], className)}>
{STATUS_LABELS[status]} {labels[status]}
</Badge> </Badge>
); );
} }
@@ -15,6 +15,7 @@ import {
type SupportNotice, type SupportNotice,
} from '@/lib/support-updates-catalog'; } from '@/lib/support-updates-catalog';
import { type NoticeProgressState } from '@/lib/updates-notice-state'; import { type NoticeProgressState } from '@/lib/updates-notice-state';
import { useTranslation } from 'react-i18next';
type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed'; type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed';
@@ -29,10 +30,11 @@ export function UpdatesDetailsPanel({
relatedEntries: CliSupportEntry[]; relatedEntries: CliSupportEntry[];
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
}) { }) {
const { t } = useTranslation();
if (!notice) { if (!notice) {
return ( return (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6"> <div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
<p className="text-sm text-muted-foreground">No updates available.</p> <p className="text-sm text-muted-foreground">{t('updates.noUpdates')}</p>
</div> </div>
); );
} }
@@ -53,21 +55,21 @@ export function UpdatesDetailsPanel({
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" /> <CalendarClock className="h-3.5 w-3.5" />
<span>Published {formatCatalogDate(notice.publishedAt)}</span> <span>{t('updates.published', { date: formatCatalogDate(notice.publishedAt) })}</span>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => onUpdateProgress('done')}> <Button size="sm" onClick={() => onUpdateProgress('done')}>
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
Mark done {t('updates.markDone')}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}> <Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
<EyeOff className="h-4 w-4" /> <EyeOff className="h-4 w-4" />
Dismiss {t('updates.dismiss')}
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}> <Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Reopen {t('updates.reopen')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -78,7 +80,7 @@ export function UpdatesDetailsPanel({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> <Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Do Next</CardTitle> <CardTitle className="text-base">{t('updates.doNext')}</CardTitle>
</div> </div>
<CardDescription>{notice.primaryAction}</CardDescription> <CardDescription>{notice.primaryAction}</CardDescription>
</CardHeader> </CardHeader>
@@ -96,8 +98,8 @@ export function UpdatesDetailsPanel({
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden"> <div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Impacted Integrations</CardTitle> <CardTitle className="text-base">{t('updates.impactedIntegrations')}</CardTitle>
<CardDescription>Related areas based on update scope and routing.</CardDescription> <CardDescription>{t('updates.impactedDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="min-h-0"> <CardContent className="min-h-0">
<ScrollArea className="h-full pr-2"> <ScrollArea className="h-full pr-2">
@@ -134,10 +136,8 @@ export function UpdatesDetailsPanel({
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Why It Matters</CardTitle> <CardTitle className="text-base">{t('updates.whyMatters')}</CardTitle>
<CardDescription> <CardDescription>{t('updates.whyMattersDesc')}</CardDescription>
Short context only, no wall-of-text release notes.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="min-h-0"> <CardContent className="min-h-0">
<ScrollArea className="h-full pr-2"> <ScrollArea className="h-full pr-2">
@@ -3,8 +3,10 @@ import { ArrowUpRight, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; import { type SupportNoticeAction } from '@/lib/support-updates-catalog';
import { useTranslation } from 'react-i18next';
export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) {
const { t } = useTranslation();
return ( return (
<div className="rounded-md border bg-muted/20 p-3"> <div className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -16,7 +18,7 @@ export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction
{action.type === 'route' && action.path && ( {action.type === 'route' && action.path && (
<Button size="sm" asChild> <Button size="sm" asChild>
<Link to={action.path}> <Link to={action.path}>
Open {t('updates.open')}
<ArrowUpRight className="h-3.5 w-3.5" /> <ArrowUpRight className="h-3.5 w-3.5" />
</Link> </Link>
</Button> </Button>
+39 -20
View File
@@ -1,3 +1,5 @@
import { getFormattingLocale } from '@/lib/locales';
/** /**
* Error Log Parser Utility * Error Log Parser Utility
* *
@@ -394,7 +396,9 @@ function getStatusText(code: number): string {
/** /**
* Format Unix timestamp (seconds) to relative time string * Format Unix timestamp (seconds) to relative time string
*/ */
export function formatRelativeTime(modifiedSeconds: number): string { export function formatRelativeTime(modifiedSeconds: number, locale?: string): string {
const formatLocale = getFormattingLocale(locale);
const isZh = formatLocale === 'zh-CN';
const now = Date.now(); const now = Date.now();
const modified = modifiedSeconds * 1000; // Convert to milliseconds const modified = modifiedSeconds * 1000; // Convert to milliseconds
const diff = now - modified; const diff = now - modified;
@@ -404,14 +408,14 @@ export function formatRelativeTime(modifiedSeconds: number): string {
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (seconds < 60) return 'just now'; if (seconds < 60) return isZh ? '刚刚' : 'just now';
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`; if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
if (days < 7) return `${days}d ago`; if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
// Format as date for older logs // Format as date for older logs
const date = new Date(modified); const date = new Date(modified);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return date.toLocaleDateString(formatLocale, { month: 'short', day: 'numeric' });
} }
/** /**
@@ -436,16 +440,26 @@ export function getStatusColor(code: number): string {
/** /**
* Get error type label * Get error type label
*/ */
export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string { export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
const labels: Record<string, string> = { const isZh = getFormattingLocale(locale) === 'zh-CN';
rate_limit: 'Rate Limited', const labels: Record<string, string> = isZh
auth: 'Auth Error', ? {
not_found: 'Not Found', rate_limit: '限流',
server: 'Server Error', auth: '认证错误',
timeout: 'Timeout', not_found: '未找到',
unknown: 'Error', server: '服务错误',
}; timeout: '超时',
return labels[type] || 'Error'; unknown: '错误',
}
: {
rate_limit: 'Rate Limited',
auth: 'Auth Error',
not_found: 'Not Found',
server: 'Server Error',
timeout: 'Timeout',
unknown: 'Error',
};
return labels[type] || (isZh ? '错误' : 'Error');
} }
/** /**
@@ -467,22 +481,27 @@ export function formatQuotaResetDelay(seconds: number | null): string | null {
/** /**
* Format quota reset timestamp as relative time * Format quota reset timestamp as relative time
*/ */
export function formatQuotaResetTimestamp(timestamp: string | null): string | null { export function formatQuotaResetTimestamp(
timestamp: string | null,
locale?: string
): string | null {
if (!timestamp) return null; if (!timestamp) return null;
const isZh = getFormattingLocale(locale) === 'zh-CN';
try { try {
const resetDate = new Date(timestamp); const resetDate = new Date(timestamp);
const now = new Date(); const now = new Date();
const diff = resetDate.getTime() - now.getTime(); const diff = resetDate.getTime() - now.getTime();
if (diff <= 0) return 'now'; if (diff <= 0) return isZh ? '现在' : 'now';
const seconds = Math.floor(diff / 1000); const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s`; if (seconds < 60) return isZh ? `${seconds}秒` : `${seconds}s`;
if (seconds < 3600) { if (seconds < 3600) {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
return `${mins}m`; return isZh ? `${mins}分钟` : `${mins}m`;
} }
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60); const mins = Math.floor((seconds % 3600) / 60);
if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} catch { } catch {
return null; return null;
+2237
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
export const LOCALE_STORAGE_KEY = 'ccs-ui-locale';
export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export function isSupportedLocale(locale: string): locale is AppLocale {
return SUPPORTED_LOCALES.includes(locale as AppLocale);
}
export function normalizeLocale(locale: string | null | undefined): AppLocale {
if (!locale) return 'en';
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
return 'en';
}
export function getStoredLocale(): AppLocale | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
return raw ? normalizeLocale(raw) : null;
} catch {
return null;
}
}
export function getInitialLocale(): AppLocale {
const stored = getStoredLocale();
if (stored) return stored;
if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language);
return 'en';
}
export function getFormattingLocale(locale?: string): string {
if (locale) return normalizeLocale(locale);
const stored = getStoredLocale();
if (stored) return stored;
if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language);
return 'en';
}
export function persistLocale(locale: string): AppLocale {
const normalized = normalizeLocale(locale);
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, normalized);
} catch {
// Ignore storage errors and keep runtime locale only.
}
}
return normalized;
}
+59 -51
View File
@@ -17,8 +17,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts'; import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export function AccountsPage() { export function AccountsPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isLoading } = useAccounts(); const { data, isLoading } = useAccounts();
const confirmLegacyMutation = useConfirmLegacyAccountPolicies(); const confirmLegacyMutation = useConfirmLegacyAccountPolicies();
@@ -59,12 +61,12 @@ export function AccountsPage() {
<div className="p-4 border-b bg-background space-y-2"> <div className="p-4 border-b bg-background space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" /> <Users className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Accounts</h1> <h1 className="font-semibold">{t('accountsPage.title')}</h1>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Manage {t('accountsPage.managePrefix')}
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code> <code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts and pool onboarding from one panel. {t('accountsPage.manageSuffix')}
</p> </p>
</div> </div>
@@ -72,7 +74,7 @@ export function AccountsPage() {
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Primary Actions {t('accountsPage.primaryActions')}
</p> </p>
<Button <Button
size="sm" size="sm"
@@ -80,7 +82,7 @@ export function AccountsPage() {
onClick={() => setCreateDialogOpen(true)} onClick={() => setCreateDialogOpen(true)}
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Create Account {t('accountsPage.createAccount')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -88,7 +90,7 @@ export function AccountsPage() {
onClick={handleOpenClaudePoolAuth} onClick={handleOpenClaudePoolAuth}
> >
<Zap className="w-4 h-4 mr-2" /> <Zap className="w-4 h-4 mr-2" />
Authenticate Claude in Pool {t('accountsPage.authClaudeInPool')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -96,7 +98,7 @@ export function AccountsPage() {
className="w-full justify-start" className="w-full justify-start"
onClick={handleOpenClaudePool} onClick={handleOpenClaudePool}
> >
Open Claude Pool Settings {t('accountsPage.openClaudePoolSettings')}
<ArrowRight className="w-4 h-4 ml-auto" /> <ArrowRight className="w-4 h-4 ml-auto" />
</Button> </Button>
</div> </div>
@@ -104,7 +106,7 @@ export function AccountsPage() {
{hasLegacyFollowUp ? ( {hasLegacyFollowUp ? (
<section className="space-y-2"> <section className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Migration Follow-up {t('accountsPage.migrationFollowup')}
</p> </p>
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3"> <div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -112,16 +114,14 @@ export function AccountsPage() {
<div className="space-y-1 text-xs"> <div className="space-y-1 text-xs">
{legacyContextCount > 0 && ( {legacyContextCount > 0 && (
<p className="text-amber-800 dark:text-amber-300"> <p className="text-amber-800 dark:text-amber-300">
{legacyContextCount} account {t('accountsPage.legacyContextPending', { count: legacyContextCount })}
{legacyContextCount > 1 ? 's still need' : ' still needs'} first-time
mode confirmation.
</p> </p>
)} )}
{legacyContinuityCount > 0 && ( {legacyContinuityCount > 0 && (
<p className="text-amber-800 dark:text-amber-300"> <p className="text-amber-800 dark:text-amber-300">
{legacyContinuityCount} shared account {t('accountsPage.legacyContinuityPending', {
{legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy count: legacyContinuityCount,
continuity depth. })}
</p> </p>
)} )}
</div> </div>
@@ -134,14 +134,14 @@ export function AccountsPage() {
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0} disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
> >
{confirmLegacyMutation.isPending {confirmLegacyMutation.isPending
? 'Confirming Legacy Policies...' ? t('accountsPage.confirmingLegacy')
: `Confirm Legacy Policies (${legacyTargetCount})`} : t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
</Button> </Button>
</div> </div>
</section> </section>
) : ( ) : (
<div className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground">
No legacy follow-up pending. {t('accountsPage.noLegacyFollowup')}
</div> </div>
)} )}
@@ -151,9 +151,11 @@ export function AccountsPage() {
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0"> <Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
<div className="text-left"> <div className="text-left">
<CardTitle className="text-sm">Continuity Guide</CardTitle> <CardTitle className="text-sm">
{t('accountsPage.continuityGuide')}
</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Expand only when needed. {t('accountsPage.expandWhenNeeded')}
</CardDescription> </CardDescription>
</div> </div>
<ChevronDown <ChevronDown
@@ -165,21 +167,26 @@ export function AccountsPage() {
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-3 text-xs text-muted-foreground"> <CardContent className="space-y-3 text-xs text-muted-foreground">
<div className="rounded-md border p-2.5"> <div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Shared Standard</p> <p className="font-semibold text-foreground">
{t('accountsPage.sharedStandard')}
</p>
<p className="mt-1">{t('accountsPage.sharedStandardDesc')}</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.sharedDeeper')}
</p>
<p className="mt-1"> <p className="mt-1">
Project workspace sync only. Best default for most teams. {t('accountsPage.sharedDeeperPrefix')} <code>session-env</code>,{' '}
<code>file-history</code>, <code>shell-snapshots</code>,{' '}
<code>todos</code>.
</p> </p>
</div> </div>
<div className="rounded-md border p-2.5"> <div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Shared Deeper</p> <p className="font-semibold text-foreground">
<p className="mt-1"> {t('accountsPage.isolated')}
Adds <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, <code>todos</code>.
</p> </p>
</div> <p className="mt-1">{t('accountsPage.isolatedDesc')}</p>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Isolated</p>
<p className="mt-1">No link. Best for strict separation.</p>
</div> </div>
</CardContent> </CardContent>
</CollapsibleContent> </CollapsibleContent>
@@ -188,8 +195,8 @@ export function AccountsPage() {
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">Quick Commands</CardTitle> <CardTitle className="text-sm">{t('accountsPage.quickCommands')}</CardTitle>
<CardDescription>Copy and run in terminal.</CardDescription> <CardDescription>{t('accountsPage.quickCommandsDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2"> <div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
@@ -215,16 +222,18 @@ export function AccountsPage() {
<div className="flex-1 min-w-0 flex flex-col bg-background"> <div className="flex-1 min-w-0 flex flex-col bg-background">
<div className="px-5 py-4 border-b bg-background"> <div className="px-5 py-4 border-b bg-background">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">ccs auth Workspace</Badge> <Badge variant="outline">{t('accountsPage.workspaceBadge')}</Badge>
<Badge variant="secondary">History Sync Controls</Badge> <Badge variant="secondary">{t('accountsPage.historySyncBadge')}</Badge>
</div> </div>
<h2 className="mt-2 text-xl font-semibold tracking-tight">Auth Accounts</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">
{t('accountsPage.authAccounts')}
</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
This table is intentionally scoped to {t('accountsPage.tableScopePrefix')}
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code> <code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts. Use {t('accountsPage.tableScopeMiddle')}
<code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code> <code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code>
for mode/group/depth changes. {t('accountsPage.tableScopeSuffix')}
</p> </p>
</div> </div>
@@ -240,15 +249,14 @@ export function AccountsPage() {
<Card className="flex flex-col"> <Card className="flex flex-col">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg">Account Matrix</CardTitle> <CardTitle className="text-lg">{t('accountsPage.accountMatrix')}</CardTitle>
<CardDescription> <CardDescription>
Shared total: {sharedCount}. Actions include Sync settings and legacy {t('accountsPage.sharedTotalDesc', { count: sharedCount })}
confirmation.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div> <div className="text-muted-foreground">{t('accountsPage.loadingAccounts')}</div>
) : ( ) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} /> <AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
)} )}
@@ -262,24 +270,24 @@ export function AccountsPage() {
<div className="p-4 space-y-4 lg:hidden"> <div className="p-4 space-y-4 lg:hidden">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Accounts</CardTitle> <CardTitle className="text-lg">{t('accountsPage.title')}</CardTitle>
<CardDescription> <CardDescription>
Manage {t('accountsPage.managePrefix')}
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code> <code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
continuity per account. {t('accountsPage.mobileManageSuffix')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<Button className="w-full" onClick={() => setCreateDialogOpen(true)}> <Button className="w-full" onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Create Account {t('accountsPage.createAccount')}
</Button> </Button>
<Button variant="outline" className="w-full" onClick={handleOpenClaudePool}> <Button variant="outline" className="w-full" onClick={handleOpenClaudePool}>
Open CLIProxy Claude Pool {t('accountsPage.openCliProxyClaudePool')}
<ArrowRight className="w-4 h-4 ml-2" /> <ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
<Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}> <Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}>
Authenticate Claude in Pool {t('accountsPage.authClaudeInPool')}
<Zap className="w-4 h-4 ml-2" /> <Zap className="w-4 h-4 ml-2" />
</Button> </Button>
<Button <Button
@@ -289,8 +297,8 @@ export function AccountsPage() {
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0} disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
> >
{confirmLegacyMutation.isPending {confirmLegacyMutation.isPending
? 'Confirming Legacy Policies...' ? t('accountsPage.confirmingLegacy')
: `Confirm Legacy Policies (${legacyTargetCount})`} : t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -306,11 +314,11 @@ export function AccountsPage() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Account Matrix</CardTitle> <CardTitle className="text-base">{t('accountsPage.accountMatrix')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div> <div className="text-muted-foreground">{t('accountsPage.loadingAccounts')}</div>
) : ( ) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} /> <AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
)} )}
@@ -9,6 +9,7 @@ import { subDays, startOfMonth } from 'date-fns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DateRangeFilter } from '@/components/analytics/date-range-filter'; import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AnalyticsHeaderProps { interface AnalyticsHeaderProps {
dateRange: DateRange | undefined; dateRange: DateRange | undefined;
@@ -29,11 +30,13 @@ export function AnalyticsHeader({
lastUpdatedText, lastUpdatedText,
viewMode, viewMode,
}: AnalyticsHeaderProps) { }: AnalyticsHeaderProps) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col gap-3 shrink-0 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-3 shrink-0 xl:flex-row xl:items-center xl:justify-between">
<div> <div>
<h1 className="text-xl font-semibold">Analytics</h1> <h1 className="text-xl font-semibold">{t('analytics.title')}</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p> <p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end"> <div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Button <Button
@@ -51,14 +54,17 @@ export function AnalyticsHeader({
presets={[ presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } }, { label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } }, { label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } }, {
{ label: 'All Time', range: { from: undefined, to: new Date() } }, label: t('analytics.month'),
range: { from: startOfMonth(new Date()), to: new Date() },
},
{ label: t('analytics.allTime'), range: { from: undefined, to: new Date() } },
]} ]}
/> />
{lastUpdatedText && ( {lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText} {t('analytics.updated', { value: lastUpdatedText })}
</span> </span>
)} )}
<Button <Button
+28 -21
View File
@@ -29,8 +29,10 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import type { Profile } from '@/lib/api-client'; import type { Profile } from '@/lib/api-client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next';
export function ApiPage() { export function ApiPage() {
const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useProfiles(); const { data, isLoading, isError, refetch } = useProfiles();
const deleteMutation = useDeleteProfile(); const deleteMutation = useDeleteProfile();
const [selectedProfile, setSelectedProfile] = useState<string | null>(null); const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
@@ -105,7 +107,7 @@ export function ApiPage() {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" /> <Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">API Profiles</h1> <h1 className="font-semibold">{t('apiProfiles.title')}</h1>
</div> </div>
<Button <Button
size="sm" size="sm"
@@ -114,7 +116,7 @@ export function ApiPage() {
}} }}
> >
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
New {t('apiProfiles.new')}
</Button> </Button>
</div> </div>
@@ -122,7 +124,7 @@ export function ApiPage() {
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search profiles..." placeholder={t('apiProfiles.searchPlaceholder')}
className="pl-8 h-9" className="pl-8 h-9"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@@ -133,20 +135,22 @@ export function ApiPage() {
{/* Profile List */} {/* Profile List */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{isLoading ? ( {isLoading ? (
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div> <div className="p-4 text-sm text-muted-foreground">
{t('apiProfiles.loadingProfiles')}
</div>
) : isError ? ( ) : isError ? (
<div className="p-4 text-center"> <div className="p-4 text-center">
<div className="space-y-3 py-8"> <div className="space-y-3 py-8">
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" /> <AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
<div> <div>
<p className="text-sm font-medium">Failed to load profiles</p> <p className="text-sm font-medium">{t('apiProfiles.failedLoadTitle')}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Unable to fetch API profiles. Please try again. {t('apiProfiles.failedLoadDesc')}
</p> </p>
</div> </div>
<Button size="sm" variant="outline" onClick={() => refetch()}> <Button size="sm" variant="outline" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" /> <RefreshCw className="w-4 h-4 mr-1" />
Retry {t('apiProfiles.retry')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -156,9 +160,9 @@ export function ApiPage() {
<div className="space-y-3 py-8"> <div className="space-y-3 py-8">
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" /> <FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
<div> <div>
<p className="text-sm font-medium">No API profiles yet</p> <p className="text-sm font-medium">{t('apiProfiles.noProfilesYet')}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Create your first profile to connect to custom API endpoints {t('apiProfiles.noProfilesDesc')}
</p> </p>
</div> </div>
<Button <Button
@@ -169,12 +173,12 @@ export function ApiPage() {
}} }}
> >
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
Create Profile {t('apiProfiles.createProfile')}
</Button> </Button>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground py-4"> <p className="text-sm text-muted-foreground py-4">
No profiles match "{searchQuery}" {t('apiProfiles.noProfileMatch', { query: searchQuery })}
</p> </p>
)} )}
</div> </div>
@@ -197,12 +201,12 @@ export function ApiPage() {
{profiles.length > 0 && ( {profiles.length > 0 && (
<div className="p-3 border-t bg-background text-xs text-muted-foreground"> <div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span> <span>{t('apiProfiles.profileCount', { count: profiles.length })}</span>
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-600" /> <CheckCircle2 className="w-3 h-3 text-green-600" />
{profiles.filter((p) => p.configured).length} configured {t('apiProfiles.configuredCount', {
count: profiles.filter((p) => p.configured).length,
})}
</span> </span>
</div> </div>
</div> </div>
@@ -253,9 +257,9 @@ export function ApiPage() {
{/* Delete Confirmation */} {/* Delete Confirmation */}
<ConfirmDialog <ConfirmDialog
open={!!deleteConfirm} open={!!deleteConfirm}
title="Delete Profile" title={t('apiProfiles.deleteProfileTitle')}
description={`Are you sure you want to delete "${deleteConfirm}"? This will remove the settings file and cannot be undone.`} description={t('apiProfiles.deleteProfileDesc', { name: deleteConfirm ?? '' })}
confirmText="Delete" confirmText={t('apiProfiles.delete')}
variant="destructive" variant="destructive"
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)} onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)} onCancel={() => setDeleteConfirm(null)}
@@ -264,9 +268,12 @@ export function ApiPage() {
{/* Unsaved Changes Confirmation */} {/* Unsaved Changes Confirmation */}
<ConfirmDialog <ConfirmDialog
open={!!pendingSwitch} open={!!pendingSwitch}
title="Unsaved Changes" title={t('apiProfiles.unsavedChangesTitle')}
description={`You have unsaved changes in "${selectedProfile}". Discard and switch to "${pendingSwitch}"?`} description={t('apiProfiles.unsavedChangesDesc', {
confirmText="Discard & Switch" current: selectedProfile ?? '',
next: pendingSwitch ?? '',
})}
confirmText={t('apiProfiles.discardSwitch')}
variant="destructive" variant="destructive"
onConfirm={() => { onConfirm={() => {
setEditorHasChanges(false); setEditorHasChanges(false);
+35 -22
View File
@@ -34,6 +34,7 @@ import type { AuthStatus, Variant } from '@/lib/api-client';
import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
// Sidebar provider item // Sidebar provider item
function ProviderSidebarItem({ function ProviderSidebarItem({
@@ -45,6 +46,7 @@ function ProviderSidebarItem({
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
const { t } = useTranslation();
const accountCount = status.accounts?.length || 0; const accountCount = status.accounts?.length || 0;
return ( return (
@@ -71,12 +73,14 @@ function ProviderSidebarItem({
{status.authenticated ? ( {status.authenticated ? (
<> <>
<Check className="w-3 h-3 text-green-600" /> <Check className="w-3 h-3 text-green-600" />
<span className="text-xs text-green-600">Connected</span> <span className="text-xs text-green-600">{t('cliproxyPage.connected')}</span>
</> </>
) : ( ) : (
<> <>
<X className="w-3 h-3 text-muted-foreground" /> <X className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Not connected</span> <span className="text-xs text-muted-foreground">
{t('cliproxyPage.notConnected')}
</span>
</> </>
)} )}
</div> </div>
@@ -101,6 +105,7 @@ function VariantSidebarItem({
onDelete: () => void; onDelete: () => void;
isDeleting?: boolean; isDeleting?: boolean;
}) { }) {
const { t } = useTranslation();
return ( return (
<button <button
className={cn( className={cn(
@@ -119,7 +124,7 @@ function VariantSidebarItem({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{variant.name}</span> <span className="font-medium text-sm truncate">{variant.name}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1"> <Badge variant="outline" className="text-[9px] h-4 px-1">
variant {t('cliproxyPage.variant')}
</Badge> </Badge>
<Badge variant="outline" className="text-[9px] h-4 px-1 uppercase"> <Badge variant="outline" className="text-[9px] h-4 px-1 uppercase">
{variant.target || 'claude'} {variant.target || 'claude'}
@@ -129,12 +134,16 @@ function VariantSidebarItem({
{parentAuth?.authenticated ? ( {parentAuth?.authenticated ? (
<> <>
<Check className="w-3 h-3 text-green-600" /> <Check className="w-3 h-3 text-green-600" />
<span className="text-xs text-muted-foreground truncate">via {variant.provider}</span> <span className="text-xs text-muted-foreground truncate">
{t('cliproxyPage.viaProvider', { provider: variant.provider })}
</span>
</> </>
) : ( ) : (
<> <>
<X className="w-3 h-3 text-muted-foreground" /> <X className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Parent not connected</span> <span className="text-xs text-muted-foreground">
{t('cliproxyPage.parentNotConnected')}
</span>
</> </>
)} )}
</div> </div>
@@ -157,27 +166,25 @@ function VariantSidebarItem({
// Empty state for right panel // Empty state for right panel
function EmptyProviderState({ onSetup }: { onSetup: () => void }) { function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
const { t } = useTranslation();
return ( return (
<div className="flex-1 flex items-center justify-center bg-muted/20"> <div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8"> <div className="text-center max-w-md px-8">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
<Zap className="w-8 h-8 text-muted-foreground" /> <Zap className="w-8 h-8 text-muted-foreground" />
</div> </div>
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2> <h2 className="text-xl font-semibold mb-2">{t('cliproxyPage.emptyTitle')}</h2>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">{t('cliproxyPage.emptyDesc')}</p>
Manage OAuth authentication, account preferences, and model selection for CLIProxy
providers. Configure how CCS routes requests to different AI backends.
</p>
<p className="text-xs text-muted-foreground mb-6"> <p className="text-xs text-muted-foreground mb-6">
For live usage stats and real-time monitoring, visit the{' '} {t('cliproxyPage.emptyControlPanelPrefix')}{' '}
<a href="/cliproxy/control-panel" className="text-primary hover:underline"> <a href="/cliproxy/control-panel" className="text-primary hover:underline">
Control Panel {t('cliproxyPage.controlPanel')}
</a> </a>
. .
</p> </p>
<Button onClick={onSetup} className="gap-2"> <Button onClick={onSetup} className="gap-2">
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Quick Setup {t('cliproxyPage.quickSetup')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -185,6 +192,7 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
} }
export function CliproxyPage() { export function CliproxyPage() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const { data: authData, isLoading: authLoading } = useCliproxyAuth();
const { data: variantsData, isFetching } = useCliproxy(); const { data: variantsData, isFetching } = useCliproxy();
@@ -337,7 +345,9 @@ export function CliproxyPage() {
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p> <p className="text-xs text-muted-foreground mb-3">
{t('cliproxyPage.accountManagement')}
</p>
<Button <Button
variant="default" variant="default"
@@ -346,7 +356,7 @@ export function CliproxyPage() {
onClick={() => setWizardOpen(true)} onClick={() => setWizardOpen(true)}
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Quick Setup {t('cliproxyPage.quickSetup')}
</Button> </Button>
</div> </div>
@@ -354,7 +364,7 @@ export function CliproxyPage() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2"> <div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
Providers {t('cliproxyPage.providers')}
</div> </div>
{authLoading ? ( {authLoading ? (
<div className="space-y-2 px-2"> <div className="space-y-2 px-2">
@@ -380,7 +390,7 @@ export function CliproxyPage() {
<> <>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
<GitBranch className="w-3 h-3" /> <GitBranch className="w-3 h-3" />
Variants {t('cliproxyPage.variants')}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{variants.map((variant) => ( {variants.map((variant) => (
@@ -408,12 +418,12 @@ export function CliproxyPage() {
{/* Footer Stats */} {/* Footer Stats */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground"> <div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span> <span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
{providers.length} provider{providers.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-600" /> <Check className="w-3 h-3 text-green-600" />
{providers.filter((p) => p.authenticated).length} connected {t('cliproxyPage.connectedCount', {
count: providers.filter((p) => p.authenticated).length,
})}
</span> </span>
</div> </div>
</div> </div>
@@ -429,7 +439,10 @@ export function CliproxyPage() {
// Variant selected - show ProviderEditor with variant profile name // Variant selected - show ProviderEditor with variant profile name
<ProviderEditor <ProviderEditor
provider={selectedVariantData.name} provider={selectedVariantData.name}
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`} displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name,
provider: selectedVariantData.provider,
})}
authStatus={parentAuthForVariant} authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]} catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider} logoProvider={selectedVariantData.provider}
+38 -26
View File
@@ -24,6 +24,7 @@ import {
import { useCopilot } from '@/hooks/use-copilot'; import { useCopilot } from '@/hooks/use-copilot';
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form'; import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
// Status section component // Status section component
function StatusSection({ title, children }: { title: string; children: React.ReactNode }) { function StatusSection({ title, children }: { title: string; children: React.ReactNode }) {
@@ -99,6 +100,7 @@ function LoadingSidebar() {
} }
export function CopilotPage() { export function CopilotPage() {
const { t } = useTranslation();
const { const {
status, status,
statusLoading, statusLoading,
@@ -122,7 +124,7 @@ export function CopilotPage() {
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Github className="w-5 h-5 text-primary" /> <Github className="w-5 h-5 text-primary" />
<h1 className="font-semibold">Copilot</h1> <h1 className="font-semibold">{t('copilotPage.title')}</h1>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -134,7 +136,7 @@ export function CopilotPage() {
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">GitHub Copilot proxy</p> <p className="text-xs text-muted-foreground">{t('copilotPage.subtitle')}</p>
</div> </div>
{/* Status Overview */} {/* Status Overview */}
@@ -148,18 +150,18 @@ export function CopilotPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" /> <AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200"> <span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
Unofficial API - Use at Your Own Risk {t('copilotPage.unofficialTitle')}
</span> </span>
</div> </div>
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc"> <ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
<li>Reverse-engineered API - may break anytime</li> <li>{t('copilotPage.unofficialItem1')}</li>
<li>Excessive use may trigger account restrictions</li> <li>{t('copilotPage.unofficialItem2')}</li>
<li>No warranty, no responsibility from CCS</li> <li>{t('copilotPage.unofficialItem3')}</li>
</ul> </ul>
</div> </div>
{/* Setup - Binary first, then enabled status */} {/* Setup - Binary first, then enabled status */}
<StatusSection title="Setup"> <StatusSection title={t('copilotPage.setup')}>
<StatusItem <StatusItem
icon={Server} icon={Server}
label="copilot-api" label="copilot-api"
@@ -168,8 +170,8 @@ export function CopilotPage() {
status?.installed status?.installed
? status.version ? status.version
? `v${status.version}` ? `v${status.version}`
: 'Installed' : t('copilotPage.installed')
: 'Missing' : t('copilotPage.missing')
} }
/> />
{!status?.installed && ( {!status?.installed && (
@@ -182,12 +184,12 @@ export function CopilotPage() {
{isInstalling ? ( {isInstalling ? (
<> <>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
Installing... {t('copilotPage.installing')}
</> </>
) : ( ) : (
<> <>
<Download className="w-3.5 h-3.5 mr-1.5" /> <Download className="w-3.5 h-3.5 mr-1.5" />
Install copilot-api {t('copilotPage.installCopilotApi')}
</> </>
)} )}
</Button> </Button>
@@ -195,21 +197,27 @@ export function CopilotPage() {
{status?.installed && ( {status?.installed && (
<StatusItem <StatusItem
icon={Power} icon={Power}
label="Integration" label={t('copilotPage.integration')}
status={status?.enabled ?? false} status={status?.enabled ?? false}
statusText={status?.enabled ? 'Enabled' : 'Disabled'} statusText={
status?.enabled ? t('copilotPage.enabled') : t('copilotPage.disabled')
}
/> />
)} )}
</StatusSection> </StatusSection>
{/* Authentication - only show after binary installed */} {/* Authentication - only show after binary installed */}
{status?.installed && ( {status?.installed && (
<StatusSection title="Auth"> <StatusSection title={t('copilotPage.auth')}>
<StatusItem <StatusItem
icon={Key} icon={Key}
label="GitHub" label={t('copilotPage.github')}
status={status?.authenticated ?? false} status={status?.authenticated ?? false}
statusText={status?.authenticated ? 'Connected' : 'Not Connected'} statusText={
status?.authenticated
? t('copilotPage.connected')
: t('copilotPage.notConnected')
}
/> />
{!status?.authenticated && ( {!status?.authenticated && (
<Button <Button
@@ -218,7 +226,9 @@ export function CopilotPage() {
onClick={() => startAuth()} onClick={() => startAuth()}
disabled={isAuthenticating} disabled={isAuthenticating}
> >
{isAuthenticating ? 'Authenticating...' : 'Authenticate'} {isAuthenticating
? t('copilotPage.authenticating')
: t('copilotPage.authenticate')}
</Button> </Button>
)} )}
</StatusSection> </StatusSection>
@@ -226,15 +236,17 @@ export function CopilotPage() {
{/* Daemon - only show after authenticated */} {/* Daemon - only show after authenticated */}
{status?.authenticated && ( {status?.authenticated && (
<StatusSection title="Daemon"> <StatusSection title={t('copilotPage.daemon')}>
<StatusItem <StatusItem
icon={Cpu} icon={Cpu}
label="Status" label={t('copilotPage.status')}
status={status?.daemon_running ?? false} status={status?.daemon_running ?? false}
statusText={status?.daemon_running ? 'Running' : 'Stopped'} statusText={
status?.daemon_running ? t('copilotPage.running') : t('copilotPage.stopped')
}
/> />
<div className="px-3 py-1 text-xs text-muted-foreground"> <div className="px-3 py-1 text-xs text-muted-foreground">
Port: {status?.port ?? 4141} {t('copilotPage.port')}: {status?.port ?? 4141}
</div> </div>
<div className="px-1"> <div className="px-1">
{status?.daemon_running ? ( {status?.daemon_running ? (
@@ -246,7 +258,7 @@ export function CopilotPage() {
disabled={isStoppingDaemon} disabled={isStoppingDaemon}
> >
<PowerOff className="w-3.5 h-3.5 mr-1.5" /> <PowerOff className="w-3.5 h-3.5 mr-1.5" />
{isStoppingDaemon ? 'Stopping...' : 'Stop'} {isStoppingDaemon ? t('copilotPage.stopping') : t('copilotPage.stop')}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -257,7 +269,7 @@ export function CopilotPage() {
disabled={isStartingDaemon} disabled={isStartingDaemon}
> >
<Power className="w-3.5 h-3.5 mr-1.5" /> <Power className="w-3.5 h-3.5 mr-1.5" />
{isStartingDaemon ? 'Starting...' : 'Start'} {isStartingDaemon ? t('copilotPage.starting') : t('copilotPage.start')}
</Button> </Button>
)} )}
</div> </div>
@@ -270,16 +282,16 @@ export function CopilotPage() {
{/* Footer */} {/* Footer */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground"> <div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Proxy</span> <span>{t('copilotPage.proxy')}</span>
{status?.daemon_running ? ( {status?.daemon_running ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" /> <CheckCircle2 className="w-3 h-3 text-green-500" />
Active {t('copilotPage.active')}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<XCircle className="w-3 h-3 text-muted-foreground" /> <XCircle className="w-3 h-3 text-muted-foreground" />
Inactive {t('copilotPage.inactive')}
</span> </span>
)} )}
</div> </div>
+127 -99
View File
@@ -52,6 +52,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useTranslation } from 'react-i18next';
interface CursorConfigDraft { interface CursorConfigDraft {
port: string; port: string;
@@ -156,6 +157,7 @@ function CursorModelSelector({
allowDefaultFallback?: boolean; allowDefaultFallback?: boolean;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
const { t } = useTranslation();
const selectorValue = value || (allowDefaultFallback ? '__default' : ''); const selectorValue = value || (allowDefaultFallback ? '__default' : '');
const selected = models.find((model) => model.id === value); const selected = models.find((model) => model.id === value);
@@ -177,12 +179,12 @@ function CursorModelSelector({
disabled={disabled} disabled={disabled}
> >
<SelectTrigger className="h-9"> <SelectTrigger className="h-9">
<SelectValue placeholder="Select model"> <SelectValue placeholder={t('cursorPage.selectModel')}>
{selectorValue && ( {selectorValue && (
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="truncate font-mono text-xs"> <span className="truncate font-mono text-xs">
{allowDefaultFallback && selectorValue === '__default' {allowDefaultFallback && selectorValue === '__default'
? 'Use Default Model' ? t('cursorPage.useDefaultModel')
: selected?.name || selectorValue} : selected?.name || selectorValue}
</span> </span>
{selected?.provider && ( {selected?.provider && (
@@ -197,9 +199,11 @@ function CursorModelSelector({
<SelectContent className="max-h-[300px]"> <SelectContent className="max-h-[300px]">
<SelectGroup> <SelectGroup>
<SelectLabel className="text-xs text-muted-foreground"> <SelectLabel className="text-xs text-muted-foreground">
Available Models ({models.length}) {t('cursorPage.availableModelCount', { count: models.length })}
</SelectLabel> </SelectLabel>
{allowDefaultFallback && <SelectItem value="__default">Use Default Model</SelectItem>} {allowDefaultFallback && (
<SelectItem value="__default">{t('cursorPage.useDefaultModel')}</SelectItem>
)}
{models.map((model) => ( {models.map((model) => (
<SelectItem key={model.id} value={model.id}> <SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -252,6 +256,7 @@ function StatusItem({
} }
export function CursorPage() { export function CursorPage() {
const { t } = useTranslation();
const { const {
status, status,
statusLoading, statusLoading,
@@ -342,8 +347,13 @@ export function CursorPage() {
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
const integrationBadge = useMemo( const integrationBadge = useMemo(
() => (status?.enabled ? <Badge>Enabled</Badge> : <Badge variant="secondary">Disabled</Badge>), () =>
[status?.enabled] status?.enabled ? (
<Badge>{t('cursorPage.enabled')}</Badge>
) : (
<Badge variant="secondary">{t('cursorPage.disabled')}</Badge>
),
[status?.enabled, t]
); );
const handleSaveConfig = async ({ const handleSaveConfig = async ({
@@ -351,11 +361,11 @@ export function CursorPage() {
}: { suppressSuccessToast?: boolean } = {}) => { }: { suppressSuccessToast?: boolean } = {}) => {
const parsedPort = Number(effectivePort); const parsedPort = Number(effectivePort);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
toast.error('Port must be an integer between 1 and 65535'); toast.error(t('cursorPage.invalidPort'));
return false; return false;
} }
if (!effectiveModel.trim()) { if (!effectiveModel.trim()) {
toast.error('Default model is required'); toast.error(t('cursorPage.defaultModelRequired'));
return false; return false;
} }
@@ -382,23 +392,23 @@ export function CursorPage() {
}) })
); );
if (!suppressSuccessToast) { if (!suppressSuccessToast) {
toast.success('Cursor config and model mapping saved'); toast.success(t('cursorPage.savedConfig'));
} }
return true; return true;
} catch (error) { } catch (error) {
toast.error((error as Error).message || 'Failed to save config'); toast.error((error as Error).message || t('cursorPage.failedSaveConfig'));
return false; return false;
} }
}; };
const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => { const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => {
if (modelsLoading) { if (modelsLoading) {
toast.error('Models are still loading. Please wait before applying a preset.'); toast.error(t('cursorPage.modelsLoadingWait'));
return; return;
} }
if (models.length === 0) { if (models.length === 0) {
toast.error('No models available yet. Start the daemon and refresh first.'); toast.error(t('cursorPage.noModelsAvailable'));
return; return;
} }
@@ -457,7 +467,7 @@ export function CursorPage() {
sonnet_model: codex53, sonnet_model: codex53,
haiku_model: codexMini, haiku_model: codexMini,
})); }));
toast.success('Applied GPT-5.3 Codex preset'); toast.success(t('cursorPage.appliedCodexPreset'));
return; return;
} }
@@ -469,7 +479,7 @@ export function CursorPage() {
sonnet_model: sonnet45, sonnet_model: sonnet45,
haiku_model: haiku45, haiku_model: haiku45,
})); }));
toast.success('Applied Claude 4.6 preset'); toast.success(t('cursorPage.appliedClaudePreset'));
return; return;
} }
@@ -480,30 +490,32 @@ export function CursorPage() {
sonnet_model: gemini3Pro, sonnet_model: gemini3Pro,
haiku_model: gemini3Flash, haiku_model: gemini3Flash,
})); }));
toast.success('Applied Gemini 3 preset'); toast.success(t('cursorPage.appliedGeminiPreset'));
}; };
const handleToggleEnabled = async (enabled: boolean) => { const handleToggleEnabled = async (enabled: boolean) => {
try { try {
await updateConfigAsync({ enabled }); await updateConfigAsync({ enabled });
toast.success(enabled ? 'Cursor integration enabled' : 'Cursor integration disabled'); toast.success(
enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled')
);
} catch (error) { } catch (error) {
toast.error((error as Error).message || 'Failed to update integration state'); toast.error((error as Error).message || t('cursorPage.failedUpdateIntegration'));
} }
}; };
const handleAutoDetectAuth = async () => { const handleAutoDetectAuth = async () => {
try { try {
await autoDetectAuthAsync(); await autoDetectAuthAsync();
toast.success('Cursor credentials imported'); toast.success(t('cursorPage.credentialsImported'));
} catch (error) { } catch (error) {
toast.error((error as Error).message || 'Auto-detect failed'); toast.error((error as Error).message || t('cursorPage.autoDetectFailed'));
} }
}; };
const handleManualAuthImport = async () => { const handleManualAuthImport = async () => {
if (!manualToken.trim() || !manualMachineId.trim()) { if (!manualToken.trim() || !manualMachineId.trim()) {
toast.error('Token and machine ID are required'); toast.error(t('cursorPage.manualRequired'));
return; return;
} }
@@ -512,12 +524,12 @@ export function CursorPage() {
accessToken: manualToken.trim(), accessToken: manualToken.trim(),
machineId: manualMachineId.trim(), machineId: manualMachineId.trim(),
}); });
toast.success('Cursor credentials imported'); toast.success(t('cursorPage.credentialsImported'));
setManualAuthOpen(false); setManualAuthOpen(false);
setManualToken(''); setManualToken('');
setManualMachineId(''); setManualMachineId('');
} catch (error) { } catch (error) {
toast.error((error as Error).message || 'Manual import failed'); toast.error((error as Error).message || t('cursorPage.manualImportFailed'));
} }
}; };
@@ -525,12 +537,16 @@ export function CursorPage() {
try { try {
const result = await startDaemonAsync(); const result = await startDaemonAsync();
if (!result.success) { if (!result.success) {
toast.error(result.error || 'Failed to start daemon'); toast.error(result.error || t('cursorPage.failedStartDaemon'));
return; return;
} }
toast.success(`Daemon started${result.pid ? ` (PID: ${result.pid})` : ''}`); toast.success(
result.pid
? t('cursorPage.daemonStartedWithPid', { pid: result.pid })
: t('cursorPage.daemonStarted')
);
} catch (error) { } catch (error) {
toast.error((error as Error).message || 'Failed to start daemon'); toast.error((error as Error).message || t('cursorPage.failedStartDaemon'));
} }
}; };
@@ -538,12 +554,12 @@ export function CursorPage() {
try { try {
const result = await stopDaemonAsync(); const result = await stopDaemonAsync();
if (!result.success) { if (!result.success) {
toast.error(result.error || 'Failed to stop daemon'); toast.error(result.error || t('cursorPage.failedStopDaemon'));
return; return;
} }
toast.success('Daemon stopped'); toast.success(t('cursorPage.daemonStopped'));
} catch (error) { } catch (error) {
toast.error((error as Error).message || 'Failed to stop daemon'); toast.error((error as Error).message || t('cursorPage.failedStopDaemon'));
} }
}; };
@@ -551,12 +567,12 @@ export function CursorPage() {
suppressSuccessToast = false, suppressSuccessToast = false,
}: { suppressSuccessToast?: boolean } = {}) => { }: { suppressSuccessToast?: boolean } = {}) => {
if (!rawSettingsReady) { if (!rawSettingsReady) {
toast.error('Raw settings are still loading. Please wait and try again.'); toast.error(t('cursorPage.rawLoading'));
return false; return false;
} }
if (!rawParseResult.isValid || !rawParseResult.settings) { if (!rawParseResult.isValid || !rawParseResult.settings) {
toast.error(rawParseResult.error || 'Invalid JSON'); toast.error(rawParseResult.error || t('cursorPage.invalidJson'));
return false; return false;
} }
@@ -567,14 +583,14 @@ export function CursorPage() {
}); });
setRawConfigDirty(false); setRawConfigDirty(false);
if (!suppressSuccessToast) { if (!suppressSuccessToast) {
toast.success('Raw settings saved'); toast.success(t('cursorPage.rawSaved'));
} }
return true; return true;
} catch (error) { } catch (error) {
if (isApiConflictError(error)) { if (isApiConflictError(error)) {
toast.error('Raw settings changed externally. Refresh and retry.'); toast.error(t('cursorPage.rawChanged'));
} else { } else {
toast.error((error as Error).message || 'Failed to save raw settings'); toast.error((error as Error).message || t('cursorPage.failedSaveRaw'));
} }
return false; return false;
} }
@@ -601,7 +617,7 @@ export function CursorPage() {
} }
if (saveConfig && saveRawSettings) { if (saveConfig && saveRawSettings) {
toast.success('Cursor configuration saved'); toast.success(t('cursorPage.savedAll'));
} }
}; };
@@ -623,12 +639,12 @@ export function CursorPage() {
alt="" alt=""
className="w-5 h-5 object-contain shrink-0" className="w-5 h-5 object-contain shrink-0"
/> />
<h1 className="font-semibold">Cursor</h1> <h1 className="font-semibold">{t('cursorPage.title')}</h1>
<Badge <Badge
variant="outline" variant="outline"
className="h-5 border-amber-500/60 bg-amber-500/10 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-300" className="h-5 border-amber-500/60 bg-amber-500/10 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-300"
> >
Beta {t('cursorPage.beta')}
</Badge> </Badge>
{integrationBadge} {integrationBadge}
</div> </div>
@@ -642,7 +658,7 @@ export function CursorPage() {
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Dedicated Cursor integration controls</p> <p className="text-xs text-muted-foreground">{t('cursorPage.subtitle')}</p>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
@@ -651,46 +667,48 @@ export function CursorPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" /> <AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200"> <span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
Unofficial API - Use at Your Own Risk {t('cursorPage.unofficialTitle')}
</span> </span>
</div> </div>
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc"> <ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
<li>Reverse-engineered integration may break anytime</li> <li>{t('cursorPage.unofficialItem1')}</li>
<li>Abuse or excessive usage may risk account restrictions</li> <li>{t('cursorPage.unofficialItem2')}</li>
<li>No warranty, no responsibility from CCS</li> <li>{t('cursorPage.unofficialItem3')}</li>
</ul> </ul>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<StatusItem <StatusItem
icon={ShieldCheck} icon={ShieldCheck}
label="Integration" label={t('cursorPage.integration')}
ok={Boolean(status?.enabled)} ok={Boolean(status?.enabled)}
detail={status?.enabled ? 'Enabled' : 'Disabled'} detail={status?.enabled ? t('cursorPage.enabled') : t('cursorPage.disabled')}
/> />
<StatusItem <StatusItem
icon={Key} icon={Key}
label="Authentication" label={t('cursorPage.authentication')}
ok={Boolean(status?.authenticated && !status?.token_expired)} ok={Boolean(status?.authenticated && !status?.token_expired)}
detail={ detail={
status?.authenticated status?.authenticated
? status?.token_expired ? status?.token_expired
? 'Expired' ? t('cursorPage.expired')
: (status.auth_method ?? 'Connected') : (status.auth_method ?? t('cursorPage.connected'))
: 'Not connected' : t('cursorPage.notConnected')
} }
/> />
<StatusItem <StatusItem
icon={Server} icon={Server}
label="Daemon" label={t('cursorPage.daemon')}
ok={Boolean(status?.daemon_running)} ok={Boolean(status?.daemon_running)}
detail={status?.daemon_running ? 'Running' : 'Stopped'} detail={
status?.daemon_running ? t('cursorPage.running') : t('cursorPage.stopped')
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Actions {t('cursorPage.actions')}
</p> </p>
{status?.enabled ? ( {status?.enabled ? (
@@ -702,7 +720,7 @@ export function CursorPage() {
disabled={isUpdatingConfig} disabled={isUpdatingConfig}
> >
<PowerOff className="w-3.5 h-3.5 mr-1.5" /> <PowerOff className="w-3.5 h-3.5 mr-1.5" />
Disable Integration {t('cursorPage.disableIntegration')}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -712,7 +730,7 @@ export function CursorPage() {
disabled={isUpdatingConfig} disabled={isUpdatingConfig}
> >
<Power className="w-3.5 h-3.5 mr-1.5" /> <Power className="w-3.5 h-3.5 mr-1.5" />
Enable Integration {t('cursorPage.enableIntegration')}
</Button> </Button>
)} )}
@@ -728,7 +746,7 @@ export function CursorPage() {
) : ( ) : (
<Key className="w-3.5 h-3.5 mr-1.5" /> <Key className="w-3.5 h-3.5 mr-1.5" />
)} )}
Auto-detect Auth {t('cursorPage.autoDetectAuth')}
</Button> </Button>
<Button <Button
@@ -738,7 +756,7 @@ export function CursorPage() {
onClick={() => setManualAuthOpen(true)} onClick={() => setManualAuthOpen(true)}
> >
<Key className="w-3.5 h-3.5 mr-1.5" /> <Key className="w-3.5 h-3.5 mr-1.5" />
Manual Auth Import {t('cursorPage.manualAuthImport')}
</Button> </Button>
{status?.daemon_running ? ( {status?.daemon_running ? (
@@ -754,7 +772,7 @@ export function CursorPage() {
) : ( ) : (
<PowerOff className="w-3.5 h-3.5 mr-1.5" /> <PowerOff className="w-3.5 h-3.5 mr-1.5" />
)} )}
Stop Daemon {t('cursorPage.stopDaemon')}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -768,7 +786,7 @@ export function CursorPage() {
) : ( ) : (
<Play className="w-3.5 h-3.5 mr-1.5" /> <Play className="w-3.5 h-3.5 mr-1.5" />
)} )}
Start Daemon {t('cursorPage.startDaemon')}
</Button> </Button>
)} )}
</div> </div>
@@ -777,7 +795,7 @@ export function CursorPage() {
<div className="p-3 border-t bg-background text-xs text-muted-foreground"> <div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Port</span> <span>{t('cursorPage.port')}</span>
<span>{status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}</span> <span>{status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}</span>
</div> </div>
</div> </div>
@@ -789,7 +807,7 @@ export function CursorPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Cursor Configuration</h2> <h2 className="text-lg font-semibold">{t('cursorPage.configuration')}</h2>
{rawSettings && ( {rawSettings && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
cursor.settings.json cursor.settings.json
@@ -798,10 +816,10 @@ export function CursorPage() {
</div> </div>
{rawSettings && ( {rawSettings && (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Last modified:{' '} {t('cursorPage.lastModified')}{' '}
{rawSettings.exists {rawSettings.exists
? new Date(rawSettings.mtime).toLocaleString() ? new Date(rawSettings.mtime).toLocaleString()
: 'Never saved'} : t('cursorPage.neverSaved')}
</p> </p>
)} )}
</div> </div>
@@ -828,12 +846,12 @@ export function CursorPage() {
{isUpdatingConfig || isSavingRawSettings ? ( {isUpdatingConfig || isSavingRawSettings ? (
<> <>
<Loader2 className="w-4 h-4 mr-1 animate-spin" /> <Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving... {t('cursorPage.saving')}
</> </>
) : ( ) : (
<> <>
<Save className="w-4 h-4 mr-1" /> <Save className="w-4 h-4 mr-1" />
Save {t('cursorPage.save')}
</> </>
)} )}
</Button> </Button>
@@ -846,13 +864,13 @@ export function CursorPage() {
<div className="px-4 pt-4 shrink-0"> <div className="px-4 pt-4 shrink-0">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="config" className="flex-1"> <TabsTrigger value="config" className="flex-1">
Model Config {t('cursorPage.modelConfig')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="settings" className="flex-1"> <TabsTrigger value="settings" className="flex-1">
Settings {t('cursorPage.settings')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="info" className="flex-1"> <TabsTrigger value="info" className="flex-1">
Info {t('cursorPage.info')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -867,10 +885,10 @@ export function CursorPage() {
<div> <div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2"> <h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
Presets {t('cursorPage.presets')}
</h3> </h3>
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
Apply pre-configured model mappings {t('cursorPage.presetsDesc')}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
@@ -912,14 +930,16 @@ export function CursorPage() {
<Separator /> <Separator />
<div> <div>
<h3 className="text-sm font-medium mb-2">Model Mapping</h3> <h3 className="text-sm font-medium mb-2">
{t('cursorPage.modelMapping')}
</h3>
<p className="text-xs text-muted-foreground mb-4"> <p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier {t('cursorPage.modelMappingDesc')}
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<CursorModelSelector <CursorModelSelector
label="Default Model" label={t('cursorPage.defaultModel')}
description="Used when no specific tier is requested." description={t('cursorPage.defaultModelDesc')}
value={effectiveModel} value={effectiveModel}
models={orderedModels} models={orderedModels}
disabled={modelsLoading} disabled={modelsLoading}
@@ -928,8 +948,8 @@ export function CursorPage() {
}} }}
/> />
<CursorModelSelector <CursorModelSelector
label="Opus (Most capable)" label={t('cursorPage.opusModel')}
description="Complex reasoning and highest quality responses." description={t('cursorPage.opusModelDesc')}
value={effectiveOpusModel} value={effectiveOpusModel}
models={orderedModels} models={orderedModels}
disabled={modelsLoading} disabled={modelsLoading}
@@ -939,8 +959,8 @@ export function CursorPage() {
}} }}
/> />
<CursorModelSelector <CursorModelSelector
label="Sonnet (Balanced)" label={t('cursorPage.sonnetModel')}
description="General coding and day-to-day chat workloads." description={t('cursorPage.sonnetModelDesc')}
value={effectiveSonnetModel} value={effectiveSonnetModel}
models={orderedModels} models={orderedModels}
disabled={modelsLoading} disabled={modelsLoading}
@@ -950,8 +970,8 @@ export function CursorPage() {
}} }}
/> />
<CursorModelSelector <CursorModelSelector
label="Haiku (Fast)" label={t('cursorPage.haikuModel')}
description="Low-latency and lightweight request paths." description={t('cursorPage.haikuModelDesc')}
value={effectiveHaikuModel} value={effectiveHaikuModel}
models={orderedModels} models={orderedModels}
disabled={modelsLoading} disabled={modelsLoading}
@@ -973,7 +993,9 @@ export function CursorPage() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-4 space-y-6"> <div className="p-4 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium">Runtime Settings</h3> <h3 className="text-sm font-medium">
{t('cursorPage.runtimeSettings')}
</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cursor-port" className="text-xs"> <Label htmlFor="cursor-port" className="text-xs">
@@ -998,10 +1020,10 @@ export function CursorPage() {
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="cursor-auto-start" className="text-xs"> <Label htmlFor="cursor-auto-start" className="text-xs">
Auto-start Daemon {t('cursorPage.autoStartDaemon')}
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
Start Cursor daemon automatically when integration is used. {t('cursorPage.autoStartDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -1016,10 +1038,10 @@ export function CursorPage() {
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="cursor-ghost-mode" className="text-xs"> <Label htmlFor="cursor-ghost-mode" className="text-xs">
Ghost Mode {t('cursorPage.ghostMode')}
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
Request `x-ghost-mode` to reduce telemetry. {t('cursorPage.ghostModeDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -1043,11 +1065,15 @@ export function CursorPage() {
<div className="p-4 space-y-6"> <div className="p-4 space-y-6">
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm"> <div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Provider</span> <span className="font-medium text-muted-foreground">
{t('cursorPage.provider')}
</span>
<span className="font-mono">Cursor IDE</span> <span className="font-mono">Cursor IDE</span>
</div> </div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span> <span className="font-medium text-muted-foreground">
{t('cursorPage.filePath')}
</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all"> <code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{rawSettings?.path ?? '~/.ccs/cursor.settings.json'} {rawSettings?.path ?? '~/.ccs/cursor.settings.json'}
</code> </code>
@@ -1060,15 +1086,17 @@ export function CursorPage() {
</div> </div>
<div> <div>
<h3 className="text-sm font-medium mb-3">Available Models</h3> <h3 className="text-sm font-medium mb-3">
{t('cursorPage.availableModels')}
</h3>
{modelsLoading ? ( {modelsLoading ? (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Loading models... {t('cursorPage.loadingModels')}
</div> </div>
) : models.length === 0 ? ( ) : models.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No model metadata available yet. Start daemon and refresh. {t('cursorPage.noModels')}
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -1083,7 +1111,9 @@ export function CursorPage() {
{model.name} • {model.provider} {model.name} • {model.provider}
</p> </p>
</div> </div>
{model.id === currentModel && <Badge>Default</Badge>} {model.id === currentModel && (
<Badge>{t('cursorPage.default')}</Badge>
)}
</div> </div>
))} ))}
</div> </div>
@@ -1100,7 +1130,7 @@ export function CursorPage() {
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]"> <div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" /> <Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON) {t('cursorPage.rawConfiguration')}
</span> </span>
</div> </div>
<RawEditorSection <RawEditorSection
@@ -1122,36 +1152,34 @@ export function CursorPage() {
<Dialog open={manualAuthOpen} onOpenChange={setManualAuthOpen}> <Dialog open={manualAuthOpen} onOpenChange={setManualAuthOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Manual Cursor Auth Import</DialogTitle> <DialogTitle>{t('cursorPage.manualImportTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('cursorPage.manualImportDesc')}</DialogDescription>
Provide Cursor access token and machine ID if auto-detect is unavailable.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cursor-manual-token">Access Token</Label> <Label htmlFor="cursor-manual-token">{t('cursorPage.accessToken')}</Label>
<Input <Input
id="cursor-manual-token" id="cursor-manual-token"
value={manualToken} value={manualToken}
onChange={(e) => setManualToken(e.target.value)} onChange={(e) => setManualToken(e.target.value)}
placeholder="Paste Cursor access token" placeholder={t('cursorPage.accessTokenPlaceholder')}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cursor-manual-machine-id">Machine ID</Label> <Label htmlFor="cursor-manual-machine-id">{t('cursorPage.machineId')}</Label>
<Input <Input
id="cursor-manual-machine-id" id="cursor-manual-machine-id"
value={manualMachineId} value={manualMachineId}
onChange={(e) => setManualMachineId(e.target.value)} onChange={(e) => setManualMachineId(e.target.value)}
placeholder="32-char hex (UUID without hyphens)" placeholder={t('cursorPage.machineIdPlaceholder')}
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setManualAuthOpen(false)}> <Button variant="outline" onClick={() => setManualAuthOpen(false)}>
Cancel {t('cursorPage.cancel')}
</Button> </Button>
<Button onClick={handleManualAuthImport} disabled={isImportingManualAuth}> <Button onClick={handleManualAuthImport} disabled={isImportingManualAuth}>
{isImportingManualAuth ? ( {isImportingManualAuth ? (
@@ -1159,7 +1187,7 @@ export function CursorPage() {
) : ( ) : (
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
)} )}
Import {t('cursorPage.import')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+69 -51
View File
@@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { import {
applyAnthropicBudgetTokensToDroidByokModel, applyAnthropicBudgetTokensToDroidByokModel,
applyReasoningEffortToDroidByokModel, applyReasoningEffortToDroidByokModel,
@@ -166,6 +167,7 @@ function DetailRow({
} }
export function DroidPage() { export function DroidPage() {
const { t } = useTranslation();
const { const {
diagnostics, diagnostics,
diagnosticsLoading, diagnosticsLoading,
@@ -201,7 +203,7 @@ export function DroidPage() {
const updateSettingsField = (key: string, value: unknown | null) => { const updateSettingsField = (key: string, value: unknown | null) => {
if (!rawEditorParsed.valid) { if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before using quick settings controls.'); toast.error(t('droidPage.fixJsonBeforeQuickSettings'));
return; return;
} }
@@ -248,7 +250,7 @@ export function DroidPage() {
const handleSaveRawSettings = async () => { const handleSaveRawSettings = async () => {
if (!rawEditorValidation.valid) { if (!rawEditorValidation.valid) {
toast.error(`Invalid JSON: ${rawEditorValidation.error}`); toast.error(t('droidPage.invalidJson', { value: rawEditorValidation.error }));
return; return;
} }
@@ -258,12 +260,12 @@ export function DroidPage() {
expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined,
}); });
setRawDraftText(null); setRawDraftText(null);
toast.success('Droid settings saved'); toast.success(t('droidPage.saved'));
} catch (error) { } catch (error) {
if (isApiConflictError(error)) { if (isApiConflictError(error)) {
toast.error('Droid settings changed externally. Refresh and retry.'); toast.error(t('droidPage.changedExternally'));
} else { } else {
toast.error((error as Error).message || 'Failed to save Droid settings'); toast.error((error as Error).message || t('droidPage.failedSave'));
} }
} }
}; };
@@ -279,7 +281,7 @@ export function DroidPage() {
return ( return (
<div className="flex h-full items-center justify-center text-muted-foreground"> <div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> <Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading Droid diagnostics... {t('droidPage.loadingDiagnostics')}
</div> </div>
); );
} }
@@ -287,7 +289,7 @@ export function DroidPage() {
if (diagnosticsError || !diagnostics) { if (diagnosticsError || !diagnostics) {
return ( return (
<div className="flex h-full items-center justify-center text-destructive px-6 text-center"> <div className="flex h-full items-center justify-center text-destructive px-6 text-center">
Failed to load Droid diagnostics. {t('droidPage.failedDiagnostics')}
</div> </div>
); );
} }
@@ -314,9 +316,9 @@ export function DroidPage() {
<Tabs defaultValue="byok" className="h-full flex flex-col"> <Tabs defaultValue="byok" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0"> <div className="px-4 pt-4 shrink-0">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">{t('droidPage.overview')}</TabsTrigger>
<TabsTrigger value="byok">BYOK</TabsTrigger> <TabsTrigger value="byok">{t('droidPage.byok')}</TabsTrigger>
<TabsTrigger value="docs">Docs</TabsTrigger> <TabsTrigger value="docs">{t('droidPage.docs')}</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -328,34 +330,40 @@ export function DroidPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<TerminalSquare className="h-4 w-4" /> <TerminalSquare className="h-4 w-4" />
Runtime & Installation {t('droidPage.runtimeInstall')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span> <span className="text-sm text-muted-foreground">{t('droidPage.status')}</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}> <Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{diagnostics.binary.installed ? 'Detected' : 'Not Found'} {diagnostics.binary.installed
? t('droidPage.detected')
: t('droidPage.notFound')}
</Badge> </Badge>
</div> </div>
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow <DetailRow
label="Binary path" label={t('droidPage.detectionSource')}
value={diagnostics.binary.path || 'Not detected'} value={diagnostics.binary.source}
mono mono
/> />
<DetailRow <DetailRow
label="Install directory" label={t('droidPage.binaryPath')}
value={diagnostics.binary.path || t('droidPage.notFound')}
mono
/>
<DetailRow
label={t('droidPage.installDirectory')}
value={diagnostics.binary.installDir || 'N/A'} value={diagnostics.binary.installDir || 'N/A'}
mono mono
/> />
<DetailRow <DetailRow
label="Version" label={t('droidPage.version')}
value={diagnostics.binary.version || 'Unknown'} value={diagnostics.binary.version || 'Unknown'}
mono mono
/> />
<DetailRow <DetailRow
label="Override (CCS_DROID_PATH)" label={t('droidPage.overridePath')}
value={diagnostics.binary.overridePath || 'Not set'} value={diagnostics.binary.overridePath || 'Not set'}
mono mono
/> />
@@ -366,7 +374,7 @@ export function DroidPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
Config Files {t('droidPage.configFiles')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -380,15 +388,25 @@ export function DroidPage() {
<XCircle className="h-4 w-4 text-muted-foreground" /> <XCircle className="h-4 w-4 text-muted-foreground" />
)} )}
</div> </div>
<DetailRow label="Path" value={file.path} mono /> <DetailRow label={t('droidPage.path')} value={file.path} mono />
<DetailRow label="Resolved" value={file.resolvedPath} mono /> <DetailRow label={t('droidPage.resolved')} value={file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} /> <DetailRow
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} /> label={t('droidPage.size')}
value={formatBytes(file.sizeBytes)}
/>
<DetailRow
label={t('droidPage.lastModified')}
value={formatTimestamp(file.mtimeMs)}
/>
{file.parseError && ( {file.parseError && (
<p className="text-xs text-amber-600">Parse warning: {file.parseError}</p> <p className="text-xs text-amber-600">
{t('droidPage.parseWarning', { value: file.parseError })}
</p>
)} )}
{file.readError && ( {file.readError && (
<p className="text-xs text-destructive">Read warning: {file.readError}</p> <p className="text-xs text-destructive">
{t('droidPage.readWarning', { value: file.readError })}
</p>
)} )}
</div> </div>
))} ))}
@@ -400,7 +418,7 @@ export function DroidPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" /> <AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings {t('droidPage.warnings')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-1.5"> <CardContent className="space-y-1.5">
@@ -444,11 +462,11 @@ export function DroidPage() {
disabledReason={ disabledReason={
rawEditorParsed.valid rawEditorParsed.valid
? null ? null
: `BYOK reasoning controls disabled: ${rawEditorParsed.error}` : `${t('droidPage.byok')}: ${rawEditorParsed.error}`
} }
onEffortChange={(modelId, effort) => { onEffortChange={(modelId, effort) => {
if (!rawEditorParsed.valid) { if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); toast.error(t('droidPage.fixJsonBeforeReasoning'));
return; return;
} }
@@ -458,7 +476,7 @@ export function DroidPage() {
effort effort
); );
if (!nextSettings) { if (!nextSettings) {
toast.error('Unable to update selected BYOK model reasoning setting.'); toast.error(t('droidPage.unableUpdateReasoning'));
return; return;
} }
@@ -466,7 +484,7 @@ export function DroidPage() {
}} }}
onAnthropicBudgetChange={(modelId, budgetTokens) => { onAnthropicBudgetChange={(modelId, budgetTokens) => {
if (!rawEditorParsed.valid) { if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating thinking budget.'); toast.error(t('droidPage.fixJsonBeforeBudget'));
return; return;
} }
@@ -476,7 +494,7 @@ export function DroidPage() {
budgetTokens budgetTokens
); );
if (!nextSettings) { if (!nextSettings) {
toast.error('Thinking budget is only available for Anthropic BYOK models.'); toast.error(t('droidPage.anthropicOnlyBudget'));
return; return;
} }
@@ -488,38 +506,38 @@ export function DroidPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
BYOK Summary {t('droidPage.byokSummary')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<DetailRow <DetailRow
label="Active model selector" label={t('droidPage.activeModelSelector')}
value={diagnostics.byok.activeModelSelector || 'Not set'} value={diagnostics.byok.activeModelSelector || 'Not set'}
mono mono
/> />
<DetailRow <DetailRow
label="Custom models" label={t('droidPage.customModels')}
value={String(diagnostics.byok.customModelCount)} value={String(diagnostics.byok.customModelCount)}
/> />
<DetailRow <DetailRow
label="CCS-managed" label={t('droidPage.ccsManaged')}
value={String(diagnostics.byok.ccsManagedCount)} value={String(diagnostics.byok.ccsManagedCount)}
/> />
<DetailRow <DetailRow
label="User-managed" label={t('droidPage.userManaged')}
value={String(diagnostics.byok.userManagedCount)} value={String(diagnostics.byok.userManagedCount)}
/> />
<DetailRow <DetailRow
label="Malformed entries" label={t('droidPage.malformedEntries')}
value={String(diagnostics.byok.invalidModelEntryCount)} value={String(diagnostics.byok.invalidModelEntryCount)}
/> />
<Separator /> <Separator />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Providers</p> <p className="text-xs text-muted-foreground">{t('droidPage.providers')}</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{providerRows.length === 0 && ( {providerRows.length === 0 && (
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
none {t('droidPage.none')}
</Badge> </Badge>
)} )}
{providerRows.map(([provider, count]) => ( {providerRows.map(([provider, count]) => (
@@ -534,20 +552,20 @@ export function DroidPage() {
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">Custom Models</CardTitle> <CardTitle className="text-base">{t('droidPage.customModelsTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border overflow-hidden"> <div className="rounded-md border overflow-hidden">
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium"> <div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
<span>Name / Model</span> <span>{t('droidPage.modelName')}</span>
<span>Provider</span> <span>{t('droidPage.provider')}</span>
<span>Base URL</span> <span>{t('droidPage.baseUrl')}</span>
</div> </div>
<ScrollArea className="h-52"> <ScrollArea className="h-52">
<div className="divide-y"> <div className="divide-y">
{customModels.length === 0 && ( {customModels.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground"> <div className="px-3 py-4 text-xs text-muted-foreground">
No custom models {t('droidPage.noCustomModels')}
</div> </div>
)} )}
{customModels.map((model) => ( {customModels.map((model) => (
@@ -593,7 +611,7 @@ export function DroidPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
Docs-Aligned Notes {t('droidPage.docsAlignedNotes')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2 text-sm">
@@ -605,7 +623,7 @@ export function DroidPage() {
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide"> <p className="text-xs text-muted-foreground uppercase tracking-wide">
Factory Docs {t('droidPage.factoryDocs')}
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{docsLinks.map((link) => ( {docsLinks.map((link) => (
@@ -633,7 +651,7 @@ export function DroidPage() {
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide"> <p className="text-xs text-muted-foreground uppercase tracking-wide">
Provider Fact-Check Docs {t('droidPage.providerFactCheckDocs')}
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{providerDocs.map((providerDoc) => ( {providerDocs.map((providerDoc) => (
@@ -660,10 +678,10 @@ export function DroidPage() {
</div> </div>
<Separator /> <Separator />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Provider values: {providerValues.join(', ')} {t('droidPage.providerValues', { value: providerValues.join(', ') })}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Settings hierarchy: {settingsHierarchy.join(' -> ')} {t('droidPage.settingsHierarchy', { value: settingsHierarchy.join(' -> ') })}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -686,7 +704,7 @@ export function DroidPage() {
</PanelResizeHandle> </PanelResizeHandle>
<Panel defaultSize={55} minSize={35}> <Panel defaultSize={55} minSize={35}>
<RawJsonSettingsEditorPanel <RawJsonSettingsEditorPanel
title="Droid BYOK Settings" title={t('droidPage.settingsTitle')}
pathLabel={rawSettings?.path || '~/.factory/settings.json'} pathLabel={rawSettings?.path || '~/.factory/settings.json'}
loading={rawSettingsLoading} loading={rawSettingsLoading}
parseWarning={rawSettings?.parseError} parseWarning={rawSettings?.parseError}
+23 -16
View File
@@ -9,6 +9,7 @@ import { useHealth, type HealthGroup } from '@/hooks/use-health';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) { function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
if (summary.errors > 0) return 'error'; if (summary.errors > 0) return 'error';
@@ -16,14 +17,17 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n
return 'ok'; return 'ok';
} }
function formatRelativeTime(timestamp: number): string { function formatRelativeTime(
timestamp: number,
t: (key: string, options?: Record<string, unknown>) => string
): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000); const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now'; if (seconds < 5) return t('health.justNow');
if (seconds < 60) return `${seconds}s ago`; if (seconds < 60) return t('health.secondsAgo', { count: seconds });
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return t('health.minutesAgo', { count: minutes });
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
return `${hours}h ago`; return t('health.hoursAgo', { count: hours });
} }
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] { function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
@@ -75,6 +79,7 @@ function LoadingSkeleton() {
} }
export function HealthPage() { export function HealthPage() {
const { t } = useTranslation();
const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
// Use dataUpdatedAt directly instead of storing in state // Use dataUpdatedAt directly instead of storing in state
@@ -91,12 +96,12 @@ export function HealthPage() {
const copyDoctorCommand = () => { const copyDoctorCommand = () => {
navigator.clipboard.writeText('ccs doctor'); navigator.clipboard.writeText('ccs doctor');
toast.success('Copied to clipboard'); toast.success(t('health.copied'));
}; };
const handleRefresh = () => { const handleRefresh = () => {
refetch(); refetch();
toast.info('Refreshing health checks...'); toast.info(t('health.refreshing'));
}; };
if (isLoading && !data) { if (isLoading && !data) {
@@ -154,10 +159,12 @@ export function HealthPage() {
{/* Main title */} {/* Main title */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold font-mono tracking-tight">System Health</h1> <h1 className="text-2xl font-bold font-mono tracking-tight">
{t('health.systemHealth')}
</h1>
{data?.version && ( {data?.version && (
<Badge variant="outline" className="font-mono text-xs bg-muted/50"> <Badge variant="outline" className="font-mono text-xs bg-muted/50">
build {data.version} {t('health.build', { version: data.version })}
</Badge> </Badge>
)} )}
</div> </div>
@@ -165,12 +172,12 @@ export function HealthPage() {
{/* Status message */} {/* Status message */}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Cpu className="w-4 h-4 text-muted-foreground" /> <Cpu className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground">Last scan:</span> <span className="text-muted-foreground">{t('health.lastScan')}</span>
<span className="font-mono"> <span className="font-mono">
{lastRefresh ? formatRelativeTime(lastRefresh) : '--'} {lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'}
</span> </span>
<span className="text-muted-foreground">|</span> <span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">Auto-refresh:</span> <span className="text-muted-foreground">{t('health.autoRefresh')}</span>
<span className="font-mono text-green-500">30s</span> <span className="font-mono text-green-500">30s</span>
</div> </div>
</div> </div>
@@ -195,7 +202,7 @@ export function HealthPage() {
className="gap-2" className="gap-2"
> >
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
<span className="hidden sm:inline">Refresh</span> <span className="hidden sm:inline">{t('health.refresh')}</span>
<kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground"> <kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
R R
</kbd> </kbd>
@@ -237,10 +244,10 @@ export function HealthPage() {
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4"> <div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span> <span>
Version <span className="font-mono">{data?.version ?? '--'}</span> {t('health.version')} <span className="font-mono">{data?.version ?? '--'}</span>
</span> </span>
<span> <span>
Platform{' '} {t('health.platform')}{' '}
<span className="font-mono"> <span className="font-mono">
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'} {typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
</span> </span>
@@ -248,7 +255,7 @@ export function HealthPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span>Live monitoring active</span> <span>{t('health.liveMonitoring')}</span>
</div> </div>
</div> </div>
</div> </div>
+7 -5
View File
@@ -9,6 +9,7 @@ import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared'; import { useSharedSummary } from '@/hooks/use-shared';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const HEALTH_VARIANTS = { const HEALTH_VARIANTS = {
ok: 'success', ok: 'success',
@@ -62,6 +63,7 @@ function InlineStat({
} }
export function HomePage() { export function HomePage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: overview, isLoading: isOverviewLoading } = useOverview(); const { data: overview, isLoading: isOverviewLoading } = useOverview();
const { data: shared, isLoading: isSharedLoading } = useSharedSummary(); const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
@@ -134,28 +136,28 @@ export function HomePage() {
{/* Right: Inline Stats */} {/* Right: Inline Stats */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<InlineStat <InlineStat
title="Profiles" title={t('home.profiles')}
value={overview?.profiles ?? 0} value={overview?.profiles ?? 0}
icon={Key} icon={Key}
variant="accent" variant="accent"
onClick={() => navigate('/providers')} onClick={() => navigate('/providers')}
/> />
<InlineStat <InlineStat
title="CLIProxy" title={t('home.cliproxy')}
value={overview?.cliproxy ?? 0} value={overview?.cliproxy ?? 0}
icon={Zap} icon={Zap}
variant="accent" variant="accent"
onClick={() => navigate('/cliproxy')} onClick={() => navigate('/cliproxy')}
/> />
<InlineStat <InlineStat
title="Accounts" title={t('home.accounts')}
value={overview?.accounts ?? 0} value={overview?.accounts ?? 0}
icon={Users} icon={Users}
variant="default" variant="default"
onClick={() => navigate('/accounts')} onClick={() => navigate('/accounts')}
/> />
<InlineStat <InlineStat
title="Health" title={t('home.health')}
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'} value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
icon={Activity} icon={Activity}
variant={healthVariant} variant={healthVariant}
@@ -169,7 +171,7 @@ export function HomePage() {
{shared?.symlinkStatus && !shared.symlinkStatus.valid && ( {shared?.symlinkStatus && !shared.symlinkStatus.valid && (
<Alert variant="warning"> <Alert variant="warning">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle> <AlertTitle>{t('home.configurationRequired')}</AlertTitle>
<AlertDescription>{shared.symlinkStatus.message}</AlertDescription> <AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
</Alert> </Alert>
)} )}
+11 -9
View File
@@ -12,8 +12,10 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertCircle, Loader2, Lock } from 'lucide-react'; import { AlertCircle, Loader2, Lock } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { useTranslation } from 'react-i18next';
export function LoginPage() { export function LoginPage() {
const { t } = useTranslation();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -47,7 +49,7 @@ export function LoginPage() {
await login(username, password); await login(username, password);
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed'); setError(err instanceof Error ? err.message : t('auth.loginFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -60,8 +62,8 @@ export function LoginPage() {
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10"> <div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Lock className="h-6 w-6 text-primary" /> <Lock className="h-6 w-6 text-primary" />
</div> </div>
<CardTitle className="text-2xl">CCS Dashboard</CardTitle> <CardTitle className="text-2xl">{t('auth.dashboardTitle')}</CardTitle>
<CardDescription>Enter your credentials to access the dashboard</CardDescription> <CardDescription>{t('auth.loginDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -72,26 +74,26 @@ export function LoginPage() {
</Alert> </Alert>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">{t('auth.username')}</Label>
<Input <Input
id="username" id="username"
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username" placeholder={t('auth.usernamePlaceholder')}
autoComplete="username" autoComplete="username"
disabled={loading} disabled={loading}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t('auth.password')}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password" placeholder={t('auth.passwordPlaceholder')}
autoComplete="current-password" autoComplete="current-password"
disabled={loading} disabled={loading}
required required
@@ -101,10 +103,10 @@ export function LoginPage() {
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in... {t('auth.signingIn')}
</> </>
) : ( ) : (
'Sign In' t('auth.signIn')
)} )}
</Button> </Button>
</form> </form>
@@ -6,22 +6,24 @@
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react'; import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react';
import type { SettingsTab } from '../types'; import type { SettingsTab } from '../types';
import { useTranslation } from 'react-i18next';
interface TabNavigationProps { interface TabNavigationProps {
activeTab: SettingsTab; activeTab: SettingsTab;
onTabChange: (tab: SettingsTab) => void; onTabChange: (tab: SettingsTab) => void;
} }
const tabs = [
{ value: 'websearch' as const, label: 'Web', icon: Globe },
{ value: 'globalenv' as const, label: 'Env', icon: Settings2 },
{ value: 'thinking' as const, label: 'Think', icon: Brain },
{ value: 'proxy' as const, label: 'Proxy', icon: Server },
{ value: 'auth' as const, label: 'Auth', icon: KeyRound },
{ value: 'backups' as const, label: 'Backup', icon: Archive },
] as const;
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
const { t } = useTranslation();
const tabs = [
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
{ value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain },
{ value: 'proxy' as const, label: t('settingsTabs.proxy'), icon: Server },
{ value: 'auth' as const, label: t('settingsTabs.auth'), icon: KeyRound },
{ value: 'backups' as const, label: t('settingsTabs.backup'), icon: Archive },
] as const;
return ( return (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}> <Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="grid w-full grid-cols-6"> <TabsList className="grid w-full grid-cols-6">
+14 -7
View File
@@ -21,6 +21,8 @@ import { useSettingsTab, useRawConfig } from './hooks';
import { TabNavigation } from './components/tab-navigation'; import { TabNavigation } from './components/tab-navigation';
import { SectionSkeleton } from './components/section-skeleton'; import { SectionSkeleton } from './components/section-skeleton';
import type { SettingsTab } from './types'; import type { SettingsTab } from './types';
import { useTranslation } from 'react-i18next';
import i18n from '@/lib/i18n';
/** /**
* Retry wrapper for dynamic imports with exponential backoff * Retry wrapper for dynamic imports with exponential backoff
@@ -72,15 +74,19 @@ class SectionErrorBoundary extends Component<
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center p-6 max-w-md"> <div className="text-center p-6 max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" /> <AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<p className="font-medium text-foreground mb-2">Failed to load section</p> <p className="font-medium text-foreground mb-2">
<p className="text-sm mb-4">{this.state.error?.message || 'Unknown error occurred'}</p> {i18n.t('settings.sectionLoadFailed')}
</p>
<p className="text-sm mb-4">
{this.state.error?.message || i18n.t('settings.unknownError')}
</p>
<Button <Button
variant="outline" variant="outline"
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="inline-flex items-center gap-2" className="inline-flex items-center gap-2"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
Reload page {i18n.t('settings.reloadPage')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -93,6 +99,7 @@ class SectionErrorBoundary extends Component<
// Inner component that uses context // Inner component that uses context
function SettingsPageInner() { function SettingsPageInner() {
const { t } = useTranslation();
const { activeTab, setActiveTab } = useSettingsTab(); const { activeTab, setActiveTab } = useSettingsTab();
const { const {
rawConfig, rawConfig,
@@ -178,12 +185,12 @@ function SettingsPageInner() {
{copied ? ( {copied ? (
<> <>
<Check className="w-4 h-4 mr-1" /> <Check className="w-4 h-4 mr-1" />
Copied {t('settings.copied')}
</> </>
) : ( ) : (
<> <>
<Copy className="w-4 h-4 mr-1" /> <Copy className="w-4 h-4 mr-1" />
Copy {t('settings.copy')}
</> </>
)} )}
</Button> </Button>
@@ -203,7 +210,7 @@ function SettingsPageInner() {
{rawConfigLoading ? ( {rawConfigLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> <RefreshCw className="w-5 h-5 animate-spin mr-2" />
Loading... {t('settings.loading')}
</div> </div>
) : rawConfig ? ( ) : rawConfig ? (
<CodeEditor <CodeEditor
@@ -218,7 +225,7 @@ function SettingsPageInner() {
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center"> <div className="text-center">
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" /> <FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>Config file not found</p> <p>{t('settings.configFileNotFound')}</p>
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block"> <code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
ccs migrate ccs migrate
</code> </code>
+29 -33
View File
@@ -23,6 +23,7 @@ import {
Save, Save,
} from 'lucide-react'; } from 'lucide-react';
import { useRawConfig } from '../hooks'; import { useRawConfig } from '../hooks';
import { useTranslation } from 'react-i18next';
interface TokenInfo { interface TokenInfo {
value: string; value: string;
@@ -35,6 +36,7 @@ interface AuthTokens {
} }
export default function AuthSection() { export default function AuthSection() {
const { t } = useTranslation();
const { fetchRawConfig } = useRawConfig(); const { fetchRawConfig } = useRawConfig();
// State // State
@@ -60,16 +62,16 @@ export default function AuthSection() {
// Use /raw to get unmasked values for editing // Use /raw to get unmasked values for editing
const response = await fetch('/api/settings/auth/tokens/raw'); const response = await fetch('/api/settings/auth/tokens/raw');
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch auth tokens'); throw new Error(t('settingsAuth.failedFetchTokens'));
} }
const data = await response.json(); const data = await response.json();
setTokens(data); setTokens(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
// Load on mount // Load on mount
useEffect(() => { useEffect(() => {
@@ -117,16 +119,16 @@ export default function AuthSection() {
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to save tokens'); throw new Error(data.error || t('settingsAuth.failedSaveTokens'));
} }
setSuccess('Tokens updated. Restart CLIProxy to apply.'); setSuccess(t('settingsAuth.tokensUpdated'));
setEditedApiKey(null); setEditedApiKey(null);
setEditedSecret(null); setEditedSecret(null);
await fetchTokens(); await fetchTokens();
await fetchRawConfig(); await fetchRawConfig();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -143,14 +145,14 @@ export default function AuthSection() {
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to regenerate secret'); throw new Error(data.error || t('settingsAuth.failedRegenerate'));
} }
setSuccess('New management secret generated. Restart CLIProxy to apply.'); setSuccess(t('settingsAuth.secretRegenerated'));
await fetchTokens(); await fetchTokens();
await fetchRawConfig(); await fetchRawConfig();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -167,16 +169,16 @@ export default function AuthSection() {
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to reset tokens'); throw new Error(data.error || t('settingsAuth.failedReset'));
} }
setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.'); setSuccess(t('settingsAuth.tokensReset'));
setEditedApiKey(null); setEditedApiKey(null);
setEditedSecret(null); setEditedSecret(null);
await fetchTokens(); await fetchTokens();
await fetchRawConfig(); await fetchRawConfig();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -209,7 +211,7 @@ export default function AuthSection() {
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span> <span>{t('settings.loading')}</span>
</div> </div>
</div> </div>
); );
@@ -251,31 +253,27 @@ export default function AuthSection() {
{/* Scrollable Content */} {/* Scrollable Content */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-5 space-y-6"> <div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t('settingsAuth.description')}</p>
Configure CLIProxy authentication tokens. Changes require CLIProxy restart.
</p>
{/* API Key Section */} {/* API Key Section */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-primary" /> <KeyRound className="w-4 h-4 text-primary" />
<h3 className="text-base font-medium">API Key</h3> <h3 className="text-base font-medium">{t('settingsAuth.apiKey')}</h3>
{tokens.apiKey.isCustom && ( {tokens.apiKey.isCustom && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"> <span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
Custom {t('settingsAuth.custom')}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsAuth.apiKeyDesc')}</p>
Used by Claude Code to authenticate with CLIProxy
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
value={displayApiKey} value={displayApiKey}
onChange={(e) => setEditedApiKey(e.target.value)} onChange={(e) => setEditedApiKey(e.target.value)}
placeholder="API key" placeholder={t('settingsAuth.apiKeyPlaceholder')}
disabled={saving} disabled={saving}
className="pr-20 font-mono text-sm" className="pr-20 font-mono text-sm"
/> />
@@ -310,15 +308,15 @@ export default function AuthSection() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-primary" /> <ShieldCheck className="w-4 h-4 text-primary" />
<h3 className="text-base font-medium">Management Secret</h3> <h3 className="text-base font-medium">{t('settingsAuth.managementSecret')}</h3>
{tokens.managementSecret.isCustom && ( {tokens.managementSecret.isCustom && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"> <span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
Custom {t('settingsAuth.custom')}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Used by CCS dashboard to access CLIProxy management APIs {t('settingsAuth.managementSecretDesc')}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -326,7 +324,7 @@ export default function AuthSection() {
type={showSecret ? 'text' : 'password'} type={showSecret ? 'text' : 'password'}
value={displaySecret} value={displaySecret}
onChange={(e) => setEditedSecret(e.target.value)} onChange={(e) => setEditedSecret(e.target.value)}
placeholder="Management secret" placeholder={t('settingsAuth.managementSecretPlaceholder')}
disabled={saving} disabled={saving}
className="pr-20 font-mono text-sm" className="pr-20 font-mono text-sm"
/> />
@@ -359,7 +357,7 @@ export default function AuthSection() {
size="sm" size="sm"
onClick={regenerateSecret} onClick={regenerateSecret}
disabled={saving} disabled={saving}
title="Generate new secure secret" title={t('settingsAuth.generateSecret')}
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
</Button> </Button>
@@ -375,11 +373,9 @@ export default function AuthSection() {
className="gap-2" className="gap-2"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
Reset to Defaults {t('settingsAuth.resetDefaults')}
</Button> </Button>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">{t('settingsAuth.resetDesc')}</p>
Resets both API key and management secret to their default values.
</p>
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
@@ -394,7 +390,7 @@ export default function AuthSection() {
className="flex-1" className="flex-1"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh {t('settings.refresh')}
</Button> </Button>
<Button <Button
variant="default" variant="default"
@@ -404,7 +400,7 @@ export default function AuthSection() {
className="flex-1" className="flex-1"
> >
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} /> <Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save'} {saving ? t('settingsAuth.saving') : t('settingsAuth.save')}
</Button> </Button>
</div> </div>
</> </>
@@ -22,6 +22,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react'; import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react';
import { useRawConfig } from '../hooks'; import { useRawConfig } from '../hooks';
import { useTranslation } from 'react-i18next';
/** Duration in ms before success toast auto-dismisses */ /** Duration in ms before success toast auto-dismisses */
const SUCCESS_DISPLAY_DURATION_MS = 3000; const SUCCESS_DISPLAY_DURATION_MS = 3000;
@@ -39,6 +40,7 @@ interface BackupsResponse {
} }
export default function BackupsSection() { export default function BackupsSection() {
const { t } = useTranslation();
const { fetchRawConfig } = useRawConfig(); const { fetchRawConfig } = useRawConfig();
// AbortController refs for cleanup // AbortController refs for cleanup
@@ -66,18 +68,18 @@ export default function BackupsSection() {
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch backups'); throw new Error(t('settingsBackups.failedFetch'));
} }
const data: BackupsResponse = await response.json(); const data: BackupsResponse = await response.json();
setBackups(data.backups || []); setBackups(data.backups || []);
} catch (err) { } catch (err) {
// Ignore abort errors // Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') return; if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
// Restore backup (wrapped in useCallback for callback stability) // Restore backup (wrapped in useCallback for callback stability)
const restoreBackup = useCallback( const restoreBackup = useCallback(
@@ -98,21 +100,21 @@ export default function BackupsSection() {
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to restore backup'); throw new Error(data.error || t('settingsBackups.failedRestore'));
} }
setSuccess('Backup restored successfully'); setSuccess(t('settingsBackups.restoreSuccess'));
await fetchBackups(); await fetchBackups();
await fetchRawConfig(); await fetchRawConfig();
} catch (err) { } catch (err) {
// Ignore abort errors // Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') return; if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally { } finally {
setRestoring(null); setRestoring(null);
} }
}, },
[fetchBackups, fetchRawConfig] [fetchBackups, fetchRawConfig, t]
); );
// Load on mount // Load on mount
@@ -205,12 +207,9 @@ export default function BackupsSection() {
<div> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Archive className="w-5 h-5 text-primary" /> <Archive className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">Settings Backups</h2> <h2 className="text-lg font-semibold">{t('settingsBackups.title')}</h2>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t('settingsBackups.description')}</p>
Restore previous versions of your settings.json file. Backups are created
automatically when settings are modified.
</p>
</div> </div>
{/* Backups List */} {/* Backups List */}
@@ -218,9 +217,9 @@ export default function BackupsSection() {
<Card className="p-8"> <Card className="p-8">
<div className="text-center"> <div className="text-center">
<Archive className="w-12 h-12 mx-auto mb-3 opacity-30 text-muted-foreground" /> <Archive className="w-12 h-12 mx-auto mb-3 opacity-30 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No backups available</p> <p className="text-sm text-muted-foreground">{t('settingsBackups.none')}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Backups will appear here when you modify settings {t('settingsBackups.noneHint')}
</p> </p>
</div> </div>
</Card> </Card>
@@ -236,7 +235,7 @@ export default function BackupsSection() {
<p className="text-sm font-medium font-mono">{backup.timestamp}</p> <p className="text-sm font-medium font-mono">{backup.timestamp}</p>
{index === 0 && ( {index === 0 && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Latest {t('settingsBackups.latest')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -253,7 +252,9 @@ export default function BackupsSection() {
<RotateCcw <RotateCcw
className={`w-4 h-4 ${restoring === backup.timestamp ? 'animate-spin' : ''}`} className={`w-4 h-4 ${restoring === backup.timestamp ? 'animate-spin' : ''}`}
/> />
{restoring === backup.timestamp ? 'Restoring...' : 'Restore'} {restoring === backup.timestamp
? t('settingsBackups.restoring')
: t('settingsBackups.restore')}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -276,7 +277,7 @@ export default function BackupsSection() {
className="w-full" className="w-full"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh {t('settings.refresh')}
</Button> </Button>
</div> </div>
@@ -284,17 +285,17 @@ export default function BackupsSection() {
<AlertDialog open={!!confirmRestore} onOpenChange={() => setConfirmRestore(null)}> <AlertDialog open={!!confirmRestore} onOpenChange={() => setConfirmRestore(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Restore Backup?</AlertDialogTitle> <AlertDialogTitle>{t('settingsBackups.restoreConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will replace your current settings with backup from{' '} {t('settingsBackups.restoreConfirmPrefix')}{' '}
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground"> <code className="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground">
{confirmRestore} {confirmRestore}
</code> </code>
. This action cannot be undone. . {t('settingsBackups.restoreConfirmSuffix')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t('settingsBackups.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={() => {
if (confirmRestore) { if (confirmRestore) {
@@ -303,7 +304,7 @@ export default function BackupsSection() {
setConfirmRestore(null); setConfirmRestore(null);
}} }}
> >
Restore {t('settingsBackups.restore')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -11,8 +11,10 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react'; import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { useGlobalEnvConfig, useRawConfig } from '../hooks'; import { useGlobalEnvConfig, useRawConfig } from '../hooks';
import { useTranslation } from 'react-i18next';
export default function GlobalEnvSection() { export default function GlobalEnvSection() {
const { t } = useTranslation();
const { const {
config, config,
loading, loading,
@@ -46,7 +48,7 @@ export default function GlobalEnvSection() {
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span> <span>{t('settings.loading')}</span>
</div> </div>
</div> </div>
); );
@@ -71,7 +73,7 @@ export default function GlobalEnvSection() {
{success && ( {success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300"> <div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span> <span className="text-sm font-medium">{t('settings.saved')}</span>
</div> </div>
)} )}
</div> </div>
@@ -79,21 +81,18 @@ export default function GlobalEnvSection() {
{/* Scrollable Content */} {/* Scrollable Content */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-5 space-y-6"> <div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t('settingsGlobalEnv.description')}</p>
Environment variables injected into all non-Claude subscription profiles (gemini, codex,
agy, ghcp, etc.)
</p>
{/* Enable/Disable Toggle */} {/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div> <div>
<p className="font-medium"> <p className="font-medium">
{config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} {config?.enabled ? t('settingsGlobalEnv.enabled') : t('settingsGlobalEnv.disabled')}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{config?.enabled {config?.enabled
? 'Env vars will be injected into third-party profiles' ? t('settingsGlobalEnv.enabledDesc')
: 'Env vars will not be injected'} : t('settingsGlobalEnv.disabledDesc')}
</p> </p>
</div> </div>
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} /> <Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
@@ -101,7 +100,7 @@ export default function GlobalEnvSection() {
{/* Current Environment Variables */} {/* Current Environment Variables */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Environment Variables</h3> <h3 className="text-base font-medium">{t('settingsGlobalEnv.envVars')}</h3>
{config?.env && Object.keys(config.env).length > 0 ? ( {config?.env && Object.keys(config.env).length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -127,18 +126,18 @@ export default function GlobalEnvSection() {
</div> </div>
) : ( ) : (
<div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground"> <div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground">
<p>No environment variables configured</p> <p>{t('settingsGlobalEnv.noneConfigured')}</p>
</div> </div>
)} )}
{/* Add New Variable */} {/* Add New Variable */}
<div className="p-4 rounded-lg border bg-muted/30"> <div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-3">Add New Variable</h4> <h4 className="text-sm font-medium mb-3">{t('settingsGlobalEnv.addNew')}</h4>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={newEnvKey} value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())} onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder="KEY_NAME" placeholder={t('settingsGlobalEnv.keyName')}
className="flex-1 font-mono text-sm h-9" className="flex-1 font-mono text-sm h-9"
disabled={saving} disabled={saving}
/> />
@@ -146,7 +145,7 @@ export default function GlobalEnvSection() {
<Input <Input
value={newEnvValue} value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)} onChange={(e) => setNewEnvValue(e.target.value)}
placeholder="value" placeholder={t('settingsGlobalEnv.value')}
className="flex-1 font-mono text-sm h-9" className="flex-1 font-mono text-sm h-9"
disabled={saving} disabled={saving}
/> />
@@ -157,14 +156,14 @@ export default function GlobalEnvSection() {
className="h-9" className="h-9"
> >
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
Add {t('settingsGlobalEnv.add')}
</Button> </Button>
</div> </div>
</div> </div>
{/* Common Variables Quick Add */} {/* Common Variables Quick Add */}
<div className="p-4 rounded-lg border bg-muted/30"> <div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-3">Quick Add Common Variables</h4> <h4 className="text-sm font-medium mb-3">{t('settingsGlobalEnv.quickAdd')}</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{[ {[
{ key: 'DISABLE_BUG_COMMAND', value: '1' }, { key: 'DISABLE_BUG_COMMAND', value: '1' },
@@ -192,7 +191,7 @@ export default function GlobalEnvSection() {
(k) => config.env[k] (k) => config.env[k]
) && ( ) && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
All common variables are configured {t('settingsGlobalEnv.allConfigured')}
</span> </span>
)} )}
</div> </div>
@@ -214,7 +213,7 @@ export default function GlobalEnvSection() {
className="w-full" className="w-full"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh {t('settings.refresh')}
</Button> </Button>
</div> </div>
</> </>
+73 -70
View File
@@ -30,6 +30,7 @@ import { api } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
/** LocalStorage key for debug mode preference */ /** LocalStorage key for debug mode preference */
const DEBUG_MODE_KEY = 'ccs_debug_mode'; const DEBUG_MODE_KEY = 'ccs_debug_mode';
@@ -42,6 +43,7 @@ function normalizeRiskAckPhrase(value: string): string {
} }
export default function ProxySection() { export default function ProxySection() {
const { t } = useTranslation();
const { const {
config, config,
loading, loading,
@@ -98,17 +100,17 @@ export default function ProxySection() {
setAgyAckBypassLoading(true); setAgyAckBypassLoading(true);
const response = await fetch('/api/settings/auth/antigravity-risk'); const response = await fetch('/api/settings/auth/antigravity-risk');
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to load AGY power user mode'); throw new Error(t('settingsProxy.failedLoadAgyMode'));
} }
const data = (await response.json()) as { antigravityAckBypass?: boolean }; const data = (await response.json()) as { antigravityAckBypass?: boolean };
setAgyAckBypass(data.antigravityAckBypass === true); setAgyAckBypass(data.antigravityAckBypass === true);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load AGY power user mode'); toast.error(err instanceof Error ? err.message : t('settingsProxy.failedLoadAgyMode'));
setAgyAckBypass(false); setAgyAckBypass(false);
} finally { } finally {
setAgyAckBypassLoading(false); setAgyAckBypassLoading(false);
} }
}, []); }, [t]);
const persistAgyAckBypass = useCallback( const persistAgyAckBypass = useCallback(
async (nextValue: boolean) => { async (nextValue: boolean) => {
@@ -130,7 +132,7 @@ export default function ProxySection() {
}; };
if (!response.ok) { if (!response.ok) {
throw new Error(payload.error || 'Failed to update AGY power user mode'); throw new Error(payload.error || t('settingsProxy.failedUpdateAgyMode'));
} }
const persistedValue = payload.antigravityAckBypass === true; const persistedValue = payload.antigravityAckBypass === true;
@@ -139,29 +141,29 @@ export default function ProxySection() {
cache: 'no-store', cache: 'no-store',
}); });
if (!verifyResponse.ok) { if (!verifyResponse.ok) {
throw new Error('Failed to verify AGY power user mode persistence'); throw new Error(t('settingsProxy.failedVerifyAgyMode'));
} }
const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean }; const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean };
const verifiedValue = verifyData.antigravityAckBypass === true; const verifiedValue = verifyData.antigravityAckBypass === true;
if (verifiedValue !== nextValue) { if (verifiedValue !== nextValue) {
throw new Error( throw new Error(t('settingsProxy.notPersistedAgyMode'));
'AGY power user mode was not persisted. Config may have been modified by another process.'
);
} }
setAgyAckBypass(verifiedValue && persistedValue); setAgyAckBypass(verifiedValue && persistedValue);
setShowAgyEnableConfirm(false); setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase(''); setAgyEnableConfirmPhrase('');
toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.'); toast.success(
nextValue ? t('settingsProxy.agyModeEnabled') : t('settingsProxy.agyModeDisabled')
);
await fetchRawConfig(); await fetchRawConfig();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update AGY power user mode'); toast.error(err instanceof Error ? err.message : t('settingsProxy.failedUpdateAgyMode'));
} finally { } finally {
agyAckBypassSavingRef.current = false; agyAckBypassSavingRef.current = false;
setAgyAckBypassSaving(false); setAgyAckBypassSaving(false);
} }
}, },
[agyAckBypassSaving, fetchRawConfig, saving] [agyAckBypassSaving, fetchRawConfig, saving, t]
); );
const handleAgyAckBypassChange = useCallback( const handleAgyAckBypassChange = useCallback(
@@ -182,11 +184,11 @@ export default function ProxySection() {
const confirmAgyEnable = useCallback(() => { const confirmAgyEnable = useCallback(() => {
if (!isAgyConfirmPhraseValid) { if (!isAgyConfirmPhraseValid) {
toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`); toast.error(t('settingsProxy.typePhraseToContinue', { value: RISK_ACK_PHRASE }));
return; return;
} }
void persistAgyAckBypass(true); void persistAgyAckBypass(true);
}, [isAgyConfirmPhraseValid, persistAgyAckBypass]); }, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
// Backend state (loaded from API) + mutation hook for proper query invalidation // Backend state (loaded from API) + mutation hook for proper query invalidation
const [backend, setBackend] = useState<'original' | 'plus'>('plus'); const [backend, setBackend] = useState<'original' | 'plus'>('plus');
@@ -262,7 +264,7 @@ export default function ProxySection() {
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span> <span>{t('settings.loading')}</span>
</div> </div>
</div> </div>
); );
@@ -308,7 +310,7 @@ export default function ProxySection() {
const parsedPort = Number(portStr); const parsedPort = Number(portStr);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
toast.error('Port must be an integer between 1 and 65535, or empty for default'); toast.error(t('settingsProxy.invalidPortOrEmpty'));
setEditedPort(null); setEditedPort(null);
return; return;
} }
@@ -340,7 +342,7 @@ export default function ProxySection() {
const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr); const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
toast.error('Local port must be an integer between 1 and 65535'); toast.error(t('settingsProxy.invalidLocalPort'));
setEditedLocalPort(null); setEditedLocalPort(null);
return; return;
} }
@@ -379,7 +381,7 @@ export default function ProxySection() {
{success && ( {success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300"> <div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span> <span className="text-sm font-medium">{t('settings.saved')}</span>
</div> </div>
)} )}
</div> </div>
@@ -388,21 +390,23 @@ export default function ProxySection() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-5 space-y-6"> <div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Configure local or remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} connection {t('settingsProxy.description', {
for proxy-based profiles backend:
backend === 'plus' ? t('settingsProxy.backendPlus') : t('settingsProxy.backend'),
})}
</p> </p>
{/* Proxy Status Widget - Quick access to start/stop controls */} {/* Proxy Status Widget - Quick access to start/stop controls */}
{!isRemoteMode && ( {!isRemoteMode && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Instance Status</h3> <h3 className="text-base font-medium">{t('settingsProxy.instanceStatus')}</h3>
<ProxyStatusWidget /> <ProxyStatusWidget />
</div> </div>
)} )}
{/* Mode Toggle - Card based selection */} {/* Mode Toggle - Card based selection */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Connection Mode</h3> <h3 className="text-base font-medium">{t('settingsProxy.connectionMode')}</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* Local Mode Card */} {/* Local Mode Card */}
<button <button
@@ -418,10 +422,15 @@ export default function ProxySection() {
<Laptop <Laptop
className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`} className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
/> />
<span className="font-medium">Local</span> <span className="font-medium">{t('settingsProxy.local')}</span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Run {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} binary on this machine {t('settingsProxy.localDesc', {
backend:
backend === 'plus'
? t('settingsProxy.backendPlus')
: t('settingsProxy.backend'),
})}
</p> </p>
</button> </button>
@@ -439,10 +448,15 @@ export default function ProxySection() {
<Cloud <Cloud
className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`} className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
/> />
<span className="font-medium">Remote</span> <span className="font-medium">{t('settingsProxy.remote')}</span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Connect to a remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} server {t('settingsProxy.remoteDesc', {
backend:
backend === 'plus'
? t('settingsProxy.backendPlus')
: t('settingsProxy.backend'),
})}
</p> </p>
</button> </button>
</div> </div>
@@ -452,14 +466,14 @@ export default function ProxySection() {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2"> <h3 className="text-base font-medium flex items-center gap-2">
<Box className="w-4 h-4" /> <Box className="w-4 h-4" />
Backend Binary {t('settingsProxy.backendBinary')}
</h3> </h3>
{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */} {/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */}
{!isRemoteMode && isProxyRunning && ( {!isRemoteMode && isProxyRunning && (
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5"> <Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600" /> <AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400"> <AlertDescription className="text-amber-700 dark:text-amber-400">
Stop the running proxy in Instance Status to switch backend. {t('settingsProxy.stopProxyToSwitch')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -475,14 +489,12 @@ export default function ProxySection() {
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`} } ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
> >
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPIPlus</span> <span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
Default {t('settingsProxy.default')}
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
Full provider support including Kiro and GitHub Copilot
</p>
</button> </button>
{/* Original Backend Card */} {/* Original Backend Card */}
@@ -496,21 +508,16 @@ export default function ProxySection() {
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`} } ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
> >
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPI</span> <span className="font-medium">{t('settingsProxy.backendApi')}</span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
Original binary (Gemini, Codex, Antigravity only)
</p>
</button> </button>
</div> </div>
{/* Warning when original backend selected with Kiro/ghcp variants */} {/* Warning when original backend selected with Kiro/ghcp variants */}
{backend === 'original' && hasKiroGhcpVariants && ( {backend === 'original' && hasKiroGhcpVariants && (
<Alert variant="destructive" className="py-2"> <Alert variant="destructive" className="py-2">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription> <AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to
CLIProxyAPIPlus or remove those variants.
</AlertDescription>
</Alert> </Alert>
)} )}
</div> </div>
@@ -519,15 +526,13 @@ export default function ProxySection() {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2"> <h3 className="text-base font-medium flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" /> <ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" />
Safety {t('settingsProxy.safety')}
</h3> </h3>
<div className="space-y-3 rounded-lg border border-amber-400/35 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-950/25"> <div className="space-y-3 rounded-lg border border-amber-400/35 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-950/25">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-sm">Antigravity Power User Mode</p> <p className="font-medium text-sm">{t('settingsProxy.agyModeTitle')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsProxy.agyModeDesc')}</p>
Skip AGY responsibility checklist in Add Account and `ccs agy` flows.
</p>
</div> </div>
<Switch <Switch
aria-labelledby="agy-power-user-mode-label" aria-labelledby="agy-power-user-mode-label"
@@ -541,24 +546,22 @@ export default function ProxySection() {
id="agy-power-user-mode-description" id="agy-power-user-mode-description"
className="text-xs text-amber-800/90 dark:text-amber-200/90" className="text-xs text-amber-800/90 dark:text-amber-200/90"
> >
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS {t('settingsProxy.agyWarning')}
cannot assume responsibility for account loss.
</p> </p>
{showAgyEnableConfirm && ( {showAgyEnableConfirm && (
<div className="space-y-3 rounded-lg border border-rose-500/40 bg-rose-500/[0.08] p-3.5"> <div className="space-y-3 rounded-lg border border-rose-500/40 bg-rose-500/[0.08] p-3.5">
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200"> <p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200">
Final confirmation required {t('settingsProxy.finalConfirm')}
</p> </p>
<p className="text-xs leading-relaxed text-rose-800/95 dark:text-rose-200/90"> <p className="text-xs leading-relaxed text-rose-800/95 dark:text-rose-200/90">
Enabling this will skip AGY safety checkpoints in both dashboard and CLI. {t('settingsProxy.finalConfirmDesc')}
Review issue #509 and type the exact phrase to proceed.
</p> </p>
</div> </div>
<div className="grid gap-2 md:grid-cols-2"> <div className="grid gap-2 md:grid-cols-2">
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5"> <div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200"> <p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 1 {t('settingsProxy.step1')}
</p> </p>
<a <a
href="https://github.com/kaitranntt/ccs/issues/509" href="https://github.com/kaitranntt/ccs/issues/509"
@@ -566,20 +569,20 @@ export default function ProxySection() {
rel="noreferrer" rel="noreferrer"
className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-rose-800 underline decoration-rose-500/60 underline-offset-2 transition-colors hover:text-rose-700 dark:text-rose-200" className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-rose-800 underline decoration-rose-500/60 underline-offset-2 transition-colors hover:text-rose-700 dark:text-rose-200"
> >
Read issue #509 {t('settingsProxy.readIssue')}
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
</div> </div>
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5"> <div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200"> <p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 2 {t('settingsProxy.step2')}
</p> </p>
<p className="mt-1 text-xs text-rose-800/95 dark:text-rose-200/90"> <p className="mt-1 text-xs text-rose-800/95 dark:text-rose-200/90">
Type{' '} {t('settingsProxy.typePrefix')}{' '}
<code className="rounded bg-background/80 px-1 py-0.5 font-mono"> <code className="rounded bg-background/80 px-1 py-0.5 font-mono">
{RISK_ACK_PHRASE} {RISK_ACK_PHRASE}
</code>{' '} </code>{' '}
to enable. {t('settingsProxy.typeSuffix')}
</p> </p>
</div> </div>
</div> </div>
@@ -590,10 +593,10 @@ export default function ProxySection() {
placeholder={RISK_ACK_PHRASE} placeholder={RISK_ACK_PHRASE}
disabled={agyAckBypassSaving || saving} disabled={agyAckBypassSaving || saving}
className="font-mono text-xs" className="font-mono text-xs"
aria-label="Type I ACCEPT RISK to enable Antigravity power user mode" aria-label={t('settingsProxy.typePhraseAria')}
/> />
<p className="text-[11px] text-rose-800/90 dark:text-rose-200/80"> <p className="text-[11px] text-rose-800/90 dark:text-rose-200/80">
Exact phrase required. {t('settingsProxy.exactPhrase')}
</p> </p>
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -606,7 +609,7 @@ export default function ProxySection() {
}} }}
disabled={agyAckBypassSaving || saving} disabled={agyAckBypassSaving || saving}
> >
Cancel {t('settingsBackups.cancel')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -614,13 +617,13 @@ export default function ProxySection() {
onClick={confirmAgyEnable} onClick={confirmAgyEnable}
disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving} disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving}
> >
Enable Power User Mode {t('settingsProxy.enableAgyMode')}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
<span id="agy-power-user-mode-label" className="sr-only"> <span id="agy-power-user-mode-label" className="sr-only">
Toggle AGY power user mode {t('settingsProxy.toggleAgyMode')}
</span> </span>
</div> </div>
</div> </div>
@@ -651,14 +654,14 @@ export default function ProxySection() {
{/* Fallback Settings */} {/* Fallback Settings */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Fallback Settings</h3> <h3 className="text-base font-medium">{t('settingsProxy.fallbackSettings')}</h3>
<div className="space-y-3 p-4 rounded-lg border bg-muted/30"> <div className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* Enable Fallback */} {/* Enable Fallback */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-sm">Enable fallback to local</p> <p className="font-medium text-sm">{t('settingsProxy.enableFallback')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Use local proxy if remote is unreachable {t('settingsProxy.enableFallbackDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -673,9 +676,9 @@ export default function ProxySection() {
{/* Auto-start on fallback */} {/* Auto-start on fallback */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-sm">Auto-start local proxy</p> <p className="font-medium text-sm">{t('settingsProxy.autoStartLocal')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Automatically start local proxy on fallback {t('settingsProxy.autoStartLocalDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -693,15 +696,15 @@ export default function ProxySection() {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2"> <h3 className="text-base font-medium flex items-center gap-2">
<Bug className="w-4 h-4" /> <Bug className="w-4 h-4" />
Advanced {t('settingsProxy.advanced')}
</h3> </h3>
<div className="space-y-3 p-4 rounded-lg border bg-muted/30"> <div className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* Debug Mode Toggle */} {/* Debug Mode Toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-sm">Debug Mode</p> <p className="font-medium text-sm">{t('settingsProxy.debugMode')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enable developer diagnostics in browser console {t('settingsProxy.debugModeDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -712,7 +715,7 @@ export default function ProxySection() {
</div> </div>
{debugMode && ( {debugMode && (
<p className="text-xs text-amber-600 dark:text-amber-400 pl-0.5"> <p className="text-xs text-amber-600 dark:text-amber-400 pl-0.5">
Debug mode enabled. Check browser console for detailed logs. {t('settingsProxy.debugModeEnabled')}
</p> </p>
)} )}
</div> </div>
@@ -748,7 +751,7 @@ export default function ProxySection() {
className="w-full" className="w-full"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh {t('settings.refresh')}
</Button> </Button>
</div> </div>
</> </>
@@ -7,6 +7,7 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import type { CliproxyServerConfig } from '../../types'; import type { CliproxyServerConfig } from '../../types';
import { useTranslation } from 'react-i18next';
interface LocalProxyCardProps { interface LocalProxyCardProps {
config: CliproxyServerConfig; config: CliproxyServerConfig;
@@ -25,15 +26,16 @@ export function LocalProxyCard({
onSaveLocalPort, onSaveLocalPort,
onSaveConfig, onSaveConfig,
}: LocalProxyCardProps) { }: LocalProxyCardProps) {
const { t } = useTranslation();
const localConfig = config.local; const localConfig = config.local;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Local Proxy</h3> <h3 className="text-base font-medium">{t('settingsProxy.localProxy')}</h3>
<div className="space-y-3 p-4 rounded-lg border bg-muted/30"> <div className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* Port */} {/* Port */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Port</label> <label className="text-sm text-muted-foreground">{t('settingsProxy.port')}</label>
<Input <Input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -49,10 +51,8 @@ export function LocalProxyCard({
{/* Auto-start */} {/* Auto-start */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-sm">Auto-start</p> <p className="font-medium text-sm">{t('settingsProxy.autoStart')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsProxy.localAutoStartDesc')}</p>
Start local proxy automatically when needed
</p>
</div> </div>
<Switch <Switch
checked={localConfig.auto_start ?? true} checked={localConfig.auto_start ?? true}
@@ -15,6 +15,7 @@ import {
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react'; import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types'; import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
import { useTranslation } from 'react-i18next';
interface RemoteProxyCardProps { interface RemoteProxyCardProps {
config: CliproxyServerConfig; config: CliproxyServerConfig;
@@ -57,6 +58,7 @@ export function RemoteProxyCard({
onSaveConfig, onSaveConfig,
onTestConnection, onTestConnection,
}: RemoteProxyCardProps) { }: RemoteProxyCardProps) {
const { t } = useTranslation();
const remoteConfig = config.remote; const remoteConfig = config.remote;
// HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL) // HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL)
@@ -67,17 +69,17 @@ export function RemoteProxyCard({
<div className="space-y-4 p-4 rounded-lg border bg-muted/30"> <div className="space-y-4 p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium flex items-center gap-2"> <h4 className="text-sm font-medium flex items-center gap-2">
<Cloud className="w-4 h-4" /> <Cloud className="w-4 h-4" />
Remote Server Configuration {t('settingsProxy.remoteConfigTitle')}
</h4> </h4>
{/* Host */} {/* Host */}
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm text-muted-foreground">Host</label> <label className="text-sm text-muted-foreground">{t('settingsProxy.host')}</label>
<Input <Input
value={displayHost} value={displayHost}
onChange={(e) => setEditedHost(e.target.value)} onChange={(e) => setEditedHost(e.target.value)}
onBlur={onSaveHost} onBlur={onSaveHost}
placeholder="192.168.1.100 or proxy.example.com" placeholder={t('settingsProxy.hostPlaceholder')}
className="font-mono" className="font-mono"
disabled={saving} disabled={saving}
/> />
@@ -87,9 +89,13 @@ export function RemoteProxyCard({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm text-muted-foreground"> <label className="text-sm text-muted-foreground">
Port{' '} {t('settingsProxy.port')}{' '}
<span className="text-xs opacity-70"> <span className="text-xs opacity-70">
(default: {getDefaultPort(config.remote.protocol || 'http')}) (
{t('settingsProxy.defaultPort', {
value: getDefaultPort(config.remote.protocol || 'http'),
})}
)
</span> </span>
</label> </label>
<Input <Input
@@ -98,13 +104,15 @@ export function RemoteProxyCard({
value={displayPort} value={displayPort}
onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))} onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))}
onBlur={onSavePort} onBlur={onSavePort}
placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`} placeholder={t('settingsProxy.portPlaceholder', {
value: getDefaultPort(config.remote.protocol || 'http'),
})}
className="font-mono" className="font-mono"
disabled={saving} disabled={saving}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm text-muted-foreground">Protocol</label> <label className="text-sm text-muted-foreground">{t('settingsProxy.protocol')}</label>
<Select <Select
value={config.remote.protocol || 'http'} value={config.remote.protocol || 'http'}
onValueChange={(value: 'http' | 'https') => onValueChange={(value: 'http' | 'https') =>
@@ -116,8 +124,8 @@ export function RemoteProxyCard({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http">HTTP</SelectItem> <SelectItem value="http">{t('settingsProxy.http')}</SelectItem>
<SelectItem value="https">HTTPS</SelectItem> <SelectItem value="https">{t('settingsProxy.https')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -125,36 +133,34 @@ export function RemoteProxyCard({
{/* Auth Token (API Key) */} {/* Auth Token (API Key) */}
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm text-muted-foreground">API Key (optional)</label> <label className="text-sm text-muted-foreground">{t('settingsProxy.apiKeyOptional')}</label>
<Input <Input
type="password" type="password"
value={displayAuthToken} value={displayAuthToken}
onChange={(e) => setEditedAuthToken(e.target.value)} onChange={(e) => setEditedAuthToken(e.target.value)}
onBlur={onSaveAuthToken} onBlur={onSaveAuthToken}
placeholder="For /v1/* API endpoints" placeholder={t('settingsProxy.apiKeyPlaceholder')}
className="font-mono" className="font-mono"
disabled={saving} disabled={saving}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsProxy.apiKeyDesc')}</p>
Used for API requests to /v1/chat/completions
</p>
</div> </div>
{/* Management Key */} {/* Management Key */}
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm text-muted-foreground">Management Key (optional)</label> <label className="text-sm text-muted-foreground">
{t('settingsProxy.managementKeyOptional')}
</label>
<Input <Input
type="password" type="password"
value={displayManagementKey} value={displayManagementKey}
onChange={(e) => setEditedManagementKey(e.target.value)} onChange={(e) => setEditedManagementKey(e.target.value)}
onBlur={onSaveManagementKey} onBlur={onSaveManagementKey}
placeholder="For /v0/management/* endpoints" placeholder={t('settingsProxy.managementKeyPlaceholder')}
className="font-mono" className="font-mono"
disabled={saving} disabled={saving}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settingsProxy.managementKeyDesc')}</p>
Used for dashboard management APIs. Falls back to API Key if not set.
</p>
</div> </div>
{/* Test Connection */} {/* Test Connection */}
@@ -168,12 +174,12 @@ export function RemoteProxyCard({
{testing ? ( {testing ? (
<> <>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" /> <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Testing... {t('settingsProxy.testing')}
</> </>
) : ( ) : (
<> <>
<Wifi className="w-4 h-4 mr-2" /> <Wifi className="w-4 h-4 mr-2" />
Test Connection {t('settingsProxy.testConnection')}
</> </>
)} )}
</Button> </Button>
@@ -192,14 +198,14 @@ export function RemoteProxyCard({
<> <>
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" /> <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-300"> <span className="text-sm font-medium text-green-700 dark:text-green-300">
Connected ({testResult.latencyMs}ms) {t('settingsProxy.connectedLatency', { value: testResult.latencyMs })}
</span> </span>
</> </>
) : ( ) : (
<> <>
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" /> <WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-sm font-medium text-red-700 dark:text-red-300"> <span className="text-sm font-medium text-red-700 dark:text-red-300">
{testResult.error || 'Connection failed'} {testResult.error || t('settingsProxy.connectionFailed')}
</span> </span>
</> </>
)} )}
@@ -20,6 +20,7 @@ import {
import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react'; import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react';
import { useThinkingConfig } from '../../hooks'; import { useThinkingConfig } from '../../hooks';
import type { ThinkingMode } from '../../types'; import type { ThinkingMode } from '../../types';
import { useTranslation } from 'react-i18next';
const THINKING_LEVELS = [ const THINKING_LEVELS = [
{ value: 'minimal', label: 'Minimal (512 tokens)' }, { value: 'minimal', label: 'Minimal (512 tokens)' },
@@ -42,6 +43,7 @@ const THINKING_BUDGET_MIN = 0;
const THINKING_BUDGET_MAX = 100000; const THINKING_BUDGET_MAX = 100000;
export default function ThinkingSection() { export default function ThinkingSection() {
const { t } = useTranslation();
const { const {
config, config,
loading, loading,
@@ -125,7 +127,7 @@ export default function ThinkingSection() {
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span> <span>{t('settings.loading')}</span>
</div> </div>
</div> </div>
); );
@@ -156,7 +158,7 @@ export default function ThinkingSection() {
className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10" className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10"
> >
<RefreshCw className="w-3 h-3 mr-1" /> <RefreshCw className="w-3 h-3 mr-1" />
Retry {t('sharedPage.retry')}
</Button> </Button>
</div> </div>
</Alert> </Alert>
@@ -164,7 +166,7 @@ export default function ThinkingSection() {
{success && ( {success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300"> <div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span> <span className="text-sm font-medium">{t('settings.saved')}</span>
</div> </div>
)} )}
</div> </div>
@@ -174,27 +176,28 @@ export default function ThinkingSection() {
<div className="p-5 space-y-6"> <div className="p-5 space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Brain className="w-5 h-5 text-primary" /> <Brain className="w-5 h-5 text-primary" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t('settingsThinking.description')}</p>
Configure extended thinking/reasoning for supported models.
</p>
</div> </div>
{/* U4: Provider support indicator */} {/* U4: Provider support indicator */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"> <div className="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" /> <Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="text-sm text-blue-700 dark:text-blue-300"> <div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium">Supported Providers</p> <p className="font-medium">{t('settingsThinking.supportedProviders')}</p>
<ul className="mt-1 space-y-0.5 text-blue-600 dark:text-blue-400"> <ul className="mt-1 space-y-0.5 text-blue-600 dark:text-blue-400">
<li> <li>
Thinking budget: <strong>agy</strong>, <strong>gemini</strong> (token-based) {t('settingsThinking.supportLine1Prefix')} <strong>agy</strong>,{' '}
<strong>gemini</strong> {t('settingsThinking.supportLine1Suffix')}
</li> </li>
<li> <li>
Reasoning effort: <strong>codex</strong> (suffix or <code>--effort</code>: {t('settingsThinking.supportLine2Prefix')} <strong>codex</strong>{' '}
medium/high/xhigh) {t('settingsThinking.supportLine2SuffixPrefix')}
<code>--effort</code>
{t('settingsThinking.supportLine2SuffixPostfix')}
</li> </li>
<li> <li>
Codex suffixes pin effort (for example <code>-high</code>); unsuffixed models use {t('settingsThinking.supportLine3Prefix')} <code>-high</code>
Thinking mode. {t('settingsThinking.supportLine3Suffix')}
</li> </li>
</ul> </ul>
</div> </div>
@@ -202,7 +205,7 @@ export default function ThinkingSection() {
{/* Mode Selection */} {/* Mode Selection */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Thinking Mode</h3> <h3 className="text-base font-medium">{t('settingsThinking.modeTitle')}</h3>
<div className="space-y-2"> <div className="space-y-2">
{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => ( {(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (
<div <div
@@ -221,10 +224,9 @@ export default function ThinkingSection() {
<div> <div>
<p className="font-medium capitalize">{mode}</p> <p className="font-medium capitalize">{mode}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{mode === 'auto' && 'Automatically set thinking based on model tier'} {mode === 'auto' && t('settingsThinking.modeAutoDesc')}
{mode === 'off' && 'Disable extended thinking'} {mode === 'off' && t('settingsThinking.modeOffDesc')}
{mode === 'manual' && {mode === 'manual' && t('settingsThinking.modeManualDesc')}
'Set a persistent override level or use CLI flags per run'}
</p> </p>
</div> </div>
<div <div
@@ -242,9 +244,9 @@ export default function ThinkingSection() {
{/* Tier Defaults (only shown when mode is auto) */} {/* Tier Defaults (only shown when mode is auto) */}
{config.mode === 'auto' && ( {config.mode === 'auto' && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Tier Defaults</h3> <h3 className="text-base font-medium">{t('settingsThinking.tierDefaults')}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Default thinking level for each model tier when in auto mode. {t('settingsThinking.tierDefaultsDesc')}
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
@@ -276,9 +278,9 @@ export default function ThinkingSection() {
{/* Manual Override (shown when mode is manual) */} {/* Manual Override (shown when mode is manual) */}
{config.mode === 'manual' && ( {config.mode === 'manual' && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Persistent Override</h3> <h3 className="text-base font-medium">{t('settingsThinking.persistentOverride')}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Applied to all sessions. CLI flags still take priority. {t('settingsThinking.persistentOverrideDesc')}
</p> </p>
<Select <Select
value={overrideSelectValue} value={overrideSelectValue}
@@ -320,7 +322,7 @@ export default function ThinkingSection() {
onChange={(event) => setCustomOverrideBudgetInput(event.target.value)} onChange={(event) => setCustomOverrideBudgetInput(event.target.value)}
onBlur={handleApplyCustomBudget} onBlur={handleApplyCustomBudget}
disabled={saving} disabled={saving}
placeholder="Enter custom budget" placeholder={t('settingsThinking.enterCustomBudget')}
/> />
<Button <Button
type="button" type="button"
@@ -329,7 +331,7 @@ export default function ThinkingSection() {
onClick={handleApplyCustomBudget} onClick={handleApplyCustomBudget}
disabled={saving} disabled={saving}
> >
Apply {t('settingsThinking.apply')}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -353,19 +355,21 @@ export default function ThinkingSection() {
<ChevronDown <ChevronDown
className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`} className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`}
/> />
Provider Overrides ({Object.keys(config.provider_overrides ?? {}).length}) {t('settingsThinking.providerOverrides', {
count: Object.keys(config.provider_overrides ?? {}).length,
})}
</button> </button>
{providerOverridesOpen && ( {providerOverridesOpen && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Override tier defaults for specific providers. Add custom provider keys as needed. {t('settingsThinking.providerOverridesDesc')}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
value={customProviderInput} value={customProviderInput}
onChange={(event) => setCustomProviderInput(event.target.value)} onChange={(event) => setCustomProviderInput(event.target.value)}
disabled={saving} disabled={saving}
placeholder="Add provider key (e.g. qwen)" placeholder={t('settingsThinking.addProviderPlaceholder')}
/> />
<Button <Button
type="button" type="button"
@@ -374,7 +378,7 @@ export default function ThinkingSection() {
onClick={handleAddProvider} onClick={handleAddProvider}
disabled={saving} disabled={saving}
> >
Add {t('settingsGlobalEnv.add')}
</Button> </Button>
</div> </div>
{providerKeys.map((provider) => ( {providerKeys.map((provider) => (
@@ -404,7 +408,9 @@ export default function ThinkingSection() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__default__">Default</SelectItem> <SelectItem value="__default__">
{t('cursorPage.default')}
</SelectItem>
{THINKING_LEVELS.map((level) => ( {THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}> <SelectItem key={level.value} value={level.value}>
{level.label} {level.label}
@@ -425,9 +431,9 @@ export default function ThinkingSection() {
{/* Show Warnings Toggle */} {/* Show Warnings Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div> <div>
<p className="font-medium">Show Warnings</p> <p className="font-medium">{t('settingsThinking.showWarnings')}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Display warnings when thinking values are clamped or adjusted {t('settingsThinking.showWarningsDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -439,7 +445,7 @@ export default function ThinkingSection() {
{/* Info Box */} {/* Info Box */}
<div className="p-4 rounded-lg border bg-muted/30"> <div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-2">CLI &amp; Env Override</h4> <h4 className="text-sm font-medium mb-2">{t('settingsThinking.cliEnvOverride')}</h4>
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
Override per session with flags or{' '} Override per session with flags or{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">CCS_THINKING</code> env var. <code className="px-1.5 py-0.5 rounded bg-muted">CCS_THINKING</code> env var.
@@ -465,7 +471,7 @@ export default function ThinkingSection() {
className="w-full" className="w-full"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh {t('settings.refresh')}
</Button> </Button>
</div> </div>
</> </>
@@ -10,8 +10,10 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react'; import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
import { useWebSearchConfig, useRawConfig } from '../../hooks'; import { useWebSearchConfig, useRawConfig } from '../../hooks';
import { ProviderCard } from './provider-card'; import { ProviderCard } from './provider-card';
import { useTranslation } from 'react-i18next';
export default function WebSearchSection() { export default function WebSearchSection() {
const { t } = useTranslation();
const { const {
config, config,
status, status,
@@ -83,7 +85,7 @@ export default function WebSearchSection() {
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span> <span>{t('settings.loading')}</span>
</div> </div>
</div> </div>
); );
@@ -108,7 +110,7 @@ export default function WebSearchSection() {
{success && ( {success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300"> <div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span> <span className="text-sm font-medium">{t('settings.saved')}</span>
</div> </div>
)} )}
</div> </div>
@@ -116,18 +118,16 @@ export default function WebSearchSection() {
{/* Scrollable Content */} {/* Scrollable Content */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-5 space-y-6"> <div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t('settingsWebsearch.description')}</p>
CLI-based web search for third-party profiles (gemini, codex, agy, etc.)
</p>
{/* Status Summary */} {/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div> <div>
<p className="font-medium"> <p className="font-medium">
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} {isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}
</p> </p>
{statusLoading ? ( {statusLoading ? (
<p className="text-sm text-muted-foreground">Checking status...</p> <p className="text-sm text-muted-foreground">{t('settingsWebsearch.checking')}</p>
) : status?.readiness ? ( ) : status?.readiness ? (
<p className="text-sm text-muted-foreground">{status.readiness.message}</p> <p className="text-sm text-muted-foreground">{status.readiness.message}</p>
) : null} ) : null}
@@ -139,19 +139,21 @@ export default function WebSearchSection() {
{/* CLI Providers */} {/* CLI Providers */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Providers</h3> <h3 className="text-base font-medium">{t('settingsWebsearch.providers')}</h3>
{/* Empty state when no providers available */} {/* Empty state when no providers available */}
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && ( {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30"> <div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" /> <Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
<p className="font-medium text-foreground mb-1">No providers configured</p> <p className="font-medium text-foreground mb-1">
{t('settingsWebsearch.noneConfigured')}
</p>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Install CLI tools to enable web search providers {t('settingsWebsearch.noneConfiguredHint')}
</p> </p>
<Button variant="outline" size="sm" onClick={fetchStatus}> <Button variant="outline" size="sm" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
Check for providers {t('settingsWebsearch.checkProviders')}
</Button> </Button>
</div> </div>
)} )}
@@ -233,7 +235,7 @@ export default function WebSearchSection() {
className="w-full" className="w-full"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh {t('settings.refresh')}
</Button> </Button>
</div> </div>
</> </>
@@ -6,6 +6,7 @@
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react'; import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface ProviderCardProps { export interface ProviderCardProps {
name: string; name: string;
@@ -50,6 +51,7 @@ export function ProviderCard({
docsUrl, docsUrl,
hintColor, hintColor,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation();
const badgeClass = const badgeClass =
badgeColor === 'green' badgeColor === 'green'
? 'bg-green-500/10 text-green-600' ? 'bg-green-500/10 text-green-600'
@@ -98,11 +100,11 @@ export function ProviderCard({
</span> </span>
{installed ? ( {installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed {t('settingsWebsearch.installed')}
</span> </span>
) : ( ) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed {t('settingsWebsearch.notInstalled')}
</span> </span>
)} )}
</div> </div>
@@ -116,7 +118,9 @@ export function ProviderCard({
{enabled && modelInput !== undefined && setModelInput && onModelBlur && ( {enabled && modelInput !== undefined && setModelInput && onModelBlur && (
<div className="px-4 pb-4 pt-0"> <div className="px-4 pb-4 pt-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">Model:</label> <label className="text-sm text-muted-foreground whitespace-nowrap">
{t('settingsWebsearch.model')}
</label>
<Input <Input
value={modelInput} value={modelInput}
onChange={(e) => setModelInput(e.target.value)} onChange={(e) => setModelInput(e.target.value)}
@@ -128,7 +132,7 @@ export function ProviderCard({
{modelSaved && ( {modelSaved && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200"> <span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
<Check className="w-3.5 h-3.5" /> <Check className="w-3.5 h-3.5" />
Saved {t('settings.saved')}
</span> </span>
)} )}
</div> </div>
@@ -143,13 +147,16 @@ export function ProviderCard({
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`} className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
> >
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
How to install {displayName} CLI {t('settingsWebsearch.howInstall', { name: displayName })}
</button> </button>
{showHint && ( {showHint && (
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}> <div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
<p className="mb-2"> <p className="mb-2">
Install globally{' '} {t('settingsWebsearch.installGlobally')}{' '}
{badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}: {badge === 'GROK_API_KEY'
? t('settingsWebsearch.requiresKey')
: t('settingsWebsearch.freeTier')}
:
</p> </p>
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}> <code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
{installCmd} {installCmd}
@@ -161,7 +168,7 @@ export function ProviderCard({
className="hover:underline inline-flex items-center gap-1" className="hover:underline inline-flex items-center gap-1"
> >
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
View documentation {t('settingsWebsearch.viewDocs')}
</a> </a>
</div> </div>
)} )}
+52 -34
View File
@@ -8,6 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import '@/lib/i18n';
import { useTranslation } from 'react-i18next';
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -21,14 +23,15 @@ import {
type TabType = 'commands' | 'skills' | 'agents'; type TabType = 'commands' | 'skills' | 'agents';
const tabLabels: Record<TabType, string> = {
commands: 'Commands',
skills: 'Skills',
agents: 'Agents',
};
export function SharedPage() { export function SharedPage() {
const { t } = useTranslation();
const [tab, setTab] = useState<TabType>('commands'); const [tab, setTab] = useState<TabType>('commands');
const tabLabels: Record<TabType, string> = {
commands: t('sharedPage.commands'),
skills: t('sharedPage.skills'),
agents: t('sharedPage.agents'),
};
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null); const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null);
@@ -105,10 +108,8 @@ export function SharedPage() {
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<h1 className="text-2xl font-bold">Shared Data</h1> <h1 className="text-2xl font-bold">{t('sharedPage.title')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t('sharedPage.subtitle')}</p>
Commands, skills, and agents shared across Claude instances
</p>
</div> </div>
<Tabs <Tabs
@@ -133,14 +134,18 @@ export function SharedPage() {
<div className="flex flex-col gap-2 lg:items-end"> <div className="flex flex-col gap-2 lg:items-end">
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3"> <div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
<HeaderMetricCard label="Total Shared" value={totalSharedItems} /> <HeaderMetricCard label={t('sharedPage.totalShared')} value={totalSharedItems} />
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} /> <HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
<HeaderMetricCard label="Visible" value={filteredItems.length} /> <HeaderMetricCard label={t('sharedPage.visible')} value={filteredItems.length} />
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<Badge variant="secondary">Markdown detail view</Badge> <Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
{activeQuery ? <Badge variant="outline">Filter: {activeQuery}</Badge> : null} {activeQuery ? (
<Badge variant="outline">
{t('sharedPage.filterPrefix')} {activeQuery}
</Badge>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -148,7 +153,7 @@ export function SharedPage() {
{summary && !summary.symlinkStatus.valid && ( {summary && !summary.symlinkStatus.valid && (
<Alert variant="warning"> <Alert variant="warning">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle> <AlertTitle>{t('sharedPage.configurationRequired')}</AlertTitle>
<AlertDescription> <AlertDescription>
{summary.symlinkStatus.message}. Run `ccs sync` to configure. {summary.symlinkStatus.message}. Run `ccs sync` to configure.
</AlertDescription> </AlertDescription>
@@ -158,7 +163,7 @@ export function SharedPage() {
{isSummaryError && ( {isSummaryError && (
<Alert variant="info"> <Alert variant="info">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Counts unavailable</AlertTitle> <AlertTitle>{t('sharedPage.countsUnavailable')}</AlertTitle>
<AlertDescription> <AlertDescription>
<p>{summaryErrorMessage}</p> <p>{summaryErrorMessage}</p>
<div className="mt-3"> <div className="mt-3">
@@ -170,7 +175,7 @@ export function SharedPage() {
}} }}
> >
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
Retry counts {t('sharedPage.retryCounts')}
</Button> </Button>
</div> </div>
</AlertDescription> </AlertDescription>
@@ -200,30 +205,38 @@ export function SharedPage() {
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={`Filter ${tab} by name, description, or path`} placeholder={t('sharedPage.filterPlaceholder', { tab })}
aria-label={`Filter ${tab} by name, description, or path`} aria-label={t('sharedPage.filterPlaceholder', { tab })}
className="pl-8 h-9" className="pl-8 h-9"
/> />
</div> </div>
{!isLoading && !isError && ( {!isLoading && !isError && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Showing {filteredItems.length} of {allItems.length} {tab} {t('sharedPage.showing', {
{activeQuery ? ` for "${activeQuery}"` : ''} visible: filteredItems.length,
{isFetching ? ' (refreshing...)' : ''} total: allItems.length,
tab,
})}
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
{isFetching ? t('sharedPage.refreshing') : ''}
</p> </p>
)} )}
</div> </div>
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
{isLoading ? ( {isLoading ? (
<div className="p-4 text-sm text-muted-foreground">Loading shared {tab}...</div> <div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.loadingShared', { tab })}
</div>
) : isError ? ( ) : isError ? (
<div className="p-4 text-center"> <div className="p-4 text-center">
<div className="space-y-3 py-8"> <div className="space-y-3 py-8">
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" /> <AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
<div> <div>
<p className="text-sm font-medium">Failed to load shared {tab}</p> <p className="text-sm font-medium">
{t('sharedPage.failedLoadShared', { tab })}
</p>
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p> <p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
</div> </div>
<Button <Button
@@ -234,17 +247,17 @@ export function SharedPage() {
}} }}
> >
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
Retry {t('sharedPage.retry')}
</Button> </Button>
</div> </div>
</div> </div>
) : hasNoItems ? ( ) : hasNoItems ? (
<div className="p-4 text-sm text-muted-foreground"> <div className="p-4 text-sm text-muted-foreground">
No shared {tab} found. Run `ccs sync` or add items in your shared directory. {t('sharedPage.noSharedFound', { tab })}
</div> </div>
) : hasNoMatches ? ( ) : hasNoMatches ? (
<div className="p-4 text-sm text-muted-foreground"> <div className="p-4 text-sm text-muted-foreground">
No {tab} match "{activeQuery}". {t('sharedPage.noMatch', { tab, query: activeQuery })}
</div> </div>
) : ( ) : (
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
@@ -277,7 +290,7 @@ export function SharedPage() {
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20"> <div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
{!selectedItem ? ( {!selectedItem ? (
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground"> <div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
Select a {tab.slice(0, -1)} to view full content. {t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
</div> </div>
) : ( ) : (
<> <>
@@ -293,11 +306,15 @@ export function SharedPage() {
<div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col"> <div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col">
<div className="rounded-md border bg-muted/35 p-3"> <div className="rounded-md border bg-muted/35 p-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetadataField label="Path" value={selectedItem.path} mono /> <MetadataField
label={t('sharedPage.pathLabel')}
value={selectedItem.path}
mono
/>
{selectedItemContent?.contentPath && {selectedItemContent?.contentPath &&
selectedItemContent.contentPath !== selectedItem.path && ( selectedItemContent.contentPath !== selectedItem.path && (
<MetadataField <MetadataField
label="Resolved Source" label={t('sharedPage.resolvedSource')}
value={selectedItemContent.contentPath} value={selectedItemContent.contentPath}
mono mono
/> />
@@ -310,12 +327,12 @@ export function SharedPage() {
<ScrollArea className="h-full px-5 py-4"> <ScrollArea className="h-full px-5 py-4">
{isContentLoading ? ( {isContentLoading ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Loading markdown content... {t('sharedPage.loadingMarkdown')}
</p> </p>
) : isContentError ? ( ) : isContentError ? (
<Alert variant="destructive" className="max-w-2xl"> <Alert variant="destructive" className="max-w-2xl">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Failed to load content</AlertTitle> <AlertTitle>{t('sharedPage.failedLoadContent')}</AlertTitle>
<AlertDescription> <AlertDescription>
<p>{contentErrorMessage}</p> <p>{contentErrorMessage}</p>
<div className="mt-3"> <div className="mt-3">
@@ -327,7 +344,7 @@ export function SharedPage() {
}} }}
> >
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
Retry content {t('sharedPage.retryContent')}
</Button> </Button>
</div> </div>
</AlertDescription> </AlertDescription>
@@ -433,10 +450,11 @@ interface ParsedMarkdownDocument {
} }
function MarkdownViewer({ content }: { content: string }) { function MarkdownViewer({ content }: { content: string }) {
const { t } = useTranslation();
const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]); const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]);
if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) { if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) {
return <p className="text-sm text-muted-foreground">No markdown content available.</p>; return <p className="text-sm text-muted-foreground">{t('sharedPage.noMarkdown')}</p>;
} }
return ( return (
+13 -15
View File
@@ -17,15 +17,10 @@ import {
writeNoticeProgressMap, writeNoticeProgressMap,
type NoticeProgressMap, type NoticeProgressMap,
} from '@/lib/updates-notice-state'; } from '@/lib/updates-notice-state';
import { useTranslation } from 'react-i18next';
type NoticeViewMode = 'inbox' | 'done' | 'all'; type NoticeViewMode = 'inbox' | 'done' | 'all';
const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [
{ id: 'inbox', label: 'Action Required' },
{ id: 'done', label: 'Done' },
{ id: 'all', label: 'All' },
];
function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean { function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean {
if (!queryValue) { if (!queryValue) {
return true; return true;
@@ -49,6 +44,7 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean
} }
export function UpdatesPage() { export function UpdatesPage() {
const { t } = useTranslation();
const notices = useMemo( const notices = useMemo(
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[] []
@@ -112,20 +108,18 @@ export function UpdatesPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-primary" /> <Megaphone className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Updates Inbox</h1> <h1 className="font-semibold">{t('updates.inboxTitle')}</h1>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('updates.inboxSubtitle')}</p>
Focus on actions, then mark updates done or dismissed.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-xs"> <div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border bg-background px-2 py-1.5"> <div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Needs Action</p> <p className="text-muted-foreground">{t('updates.needsAction')}</p>
<p className="text-base font-semibold">{pendingCount}</p> <p className="text-base font-semibold">{pendingCount}</p>
</div> </div>
<div className="rounded-md border bg-background px-2 py-1.5"> <div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Done</p> <p className="text-muted-foreground">{t('updates.doneCount')}</p>
<p className="text-base font-semibold">{doneCount}</p> <p className="text-base font-semibold">{doneCount}</p>
</div> </div>
</div> </div>
@@ -135,13 +129,17 @@ export function UpdatesPage() {
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Search actions or commands" placeholder={t('updates.searchPlaceholder')}
className="h-9 pl-8" className="h-9 pl-8"
/> />
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{NOTICE_VIEW_MODES.map((mode) => ( {[
{ id: 'inbox' as const, label: t('updates.actionRequired') },
{ id: 'done' as const, label: t('updates.done') },
{ id: 'all' as const, label: t('updates.all') },
].map((mode) => (
<Button <Button
key={mode.id} key={mode.id}
size="sm" size="sm"
@@ -158,7 +156,7 @@ export function UpdatesPage() {
<div className="space-y-2 p-2"> <div className="space-y-2 p-2">
{visibleNotices.length === 0 ? ( {visibleNotices.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground"> <div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
No notices match this view. {t('updates.noNotices')}
</div> </div>
) : ( ) : (
visibleNotices.map((notice) => ( visibleNotices.map((notice) => (
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
import i18n from '@/lib/i18n';
import { LanguageSwitcher } from '@/components/layout/language-switcher';
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
import { getInitialLocale, LOCALE_STORAGE_KEY, persistLocale } from '@/lib/locales';
describe('Dashboard i18n', () => {
const storage = new Map<string, string>();
const localStorageMock = window.localStorage as unknown as {
getItem: ReturnType<typeof vi.fn>;
setItem: ReturnType<typeof vi.fn>;
removeItem: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
storage.clear();
localStorageMock.getItem.mockImplementation((key: string) => storage.get(key) ?? null);
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
storage.set(key, value);
});
localStorageMock.removeItem.mockImplementation((key: string) => {
storage.delete(key);
});
localStorageMock.clear.mockImplementation(() => {
storage.clear();
});
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
await i18n.changeLanguage('en');
});
it(
'renders language switcher and changes locale',
async () => {
render(<LanguageSwitcher />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByText('Simplified Chinese'));
await waitFor(() => {
expect(i18n.language).toBe('zh-CN');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
},
10000
);
it('restores locale from persisted storage', () => {
persistLocale('zh-CN');
expect(getInitialLocale()).toBe('zh-CN');
});
it('shows Chinese labels on translated settings tabs', async () => {
await i18n.changeLanguage('zh-CN');
render(<TabNavigation activeTab="websearch" onTabChange={() => {}} />);
expect(screen.getByText('网页')).toBeInTheDocument();
expect(screen.getByText('环境')).toBeInTheDocument();
expect(screen.getByText('认证')).toBeInTheDocument();
});
});