feat(i18n): Added support for Chinese language pack

This commit is contained in:
lidong
2026-03-02 18:39:34 +08:00
parent bd389fda44
commit 7ffb8a4234
84 changed files with 4122 additions and 1281 deletions
+12
View File
@@ -28,12 +28,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^25.8.13",
"lucide-react": "^0.556.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.4",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.10.1",
"react-simple-code-editor": "^0.14.1",
@@ -761,10 +763,14 @@
"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=="],
"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=="],
"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-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-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-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=="],
"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=="],
"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=="],
"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",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^25.8.13",
"lucide-react": "^0.556.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.4",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.10.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 { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client';
import '@/lib/i18n';
import { ThemeProvider } from '@/components/layout/theme-provider';
import { PrivacyProvider } from '@/contexts/privacy-context';
import { AuthProvider } from '@/contexts/auth-context';
@@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
interface AccountSafetyWarningCardProps {
@@ -24,21 +25,14 @@ export function AccountSafetyWarningCard({
disabled = false,
showProxySettingsLink = false,
}: AccountSafetyWarningCardProps) {
const title = 'OAuth Account Safety Warning';
const subtitle = 'Issue #509 · Gemini + AGY OAuth risk';
const firstLine = (
<>
Issue #509 documents suspension/ban reports tied to <code className="font-mono">ccs agy</code>{' '}
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 { t } = useTranslation();
const title = t('accountSafetyWarning.title');
const subtitle = t('accountSafetyWarning.subtitle');
const firstLine = t('accountSafetyWarning.firstLine');
const secondLine = t('accountSafetyWarning.secondLine');
const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509';
const issueLabel = 'Read issue #509';
const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy';
const issueLabel = t('accountSafetyWarning.issueLabel');
const proxySettingsLabel = t('accountSafetyWarning.proxySettingsLabel');
return (
<section
+40 -28
View File
@@ -25,6 +25,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
import {
useSetDefaultAccount,
@@ -40,6 +41,7 @@ interface AccountsTableProps {
}
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const { t } = useTranslation();
const setDefaultMutation = useSetDefaultAccount();
const deleteMutation = useDeleteAccount();
const resetDefaultMutation = useResetDefaultAccount();
@@ -50,14 +52,14 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const columns: ColumnDef<Account>[] = [
{
accessorKey: 'name',
header: 'Name',
header: t('accountsTable.name'),
size: 200,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.name === defaultAccount && (
<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>
)}
</div>
@@ -65,7 +67,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
},
{
accessorKey: 'type',
header: 'Type',
header: t('accountsTable.type'),
size: 100,
cell: ({ row }) => (
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
@@ -73,7 +75,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
},
{
accessorKey: 'created',
header: 'Created',
header: t('accountsTable.created'),
size: 150,
cell: ({ row }) => {
const date = new Date(row.original.created);
@@ -82,7 +84,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
},
{
accessorKey: 'last_used',
header: 'Last Used',
header: t('accountsTable.lastUsed'),
size: 150,
cell: ({ row }) => {
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',
header: 'History Sync',
header: t('accountsTable.historySync'),
size: 170,
cell: ({ row }) => {
if (row.original.type === 'cliproxy') {
@@ -103,32 +105,42 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (mode === 'shared') {
const group = row.original.context_group || 'default';
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) {
return (
<span className="text-amber-700 dark:text-amber-400">
shared ({group}, standard legacy)
{t('accountsTable.sharedGroupLegacy', { group })}
</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) {
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',
header: 'Actions',
header: t('accountsTable.actions'),
size: 220,
cell: ({ row }) => {
const isDefault = row.original.name === defaultAccount;
@@ -149,10 +161,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
className="h-8 px-2"
disabled={isPending}
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" />
Sync
{t('accountsTable.sync')}
</Button>
)}
{!isCliproxy && hasLegacyInference && (
@@ -177,10 +189,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
: undefined,
})
}
title="Confirm this legacy account's current mode as explicit"
title={t('accountsTable.confirmLegacyTitle')}
>
<CheckCheck className="w-3 h-3 mr-1" />
Confirm
{t('accountsTable.confirm')}
</Button>
)}
<Button
@@ -191,7 +203,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
onClick={() => setDefaultMutation.mutate(row.original.name)}
>
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
{isDefault ? 'Active' : 'Set Default'}
{isDefault ? t('accountsTable.active') : t('accountsTable.setDefault')}
</Button>
<Button
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"
disabled={isDefault || isPending}
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" />
</Button>
@@ -218,10 +234,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No CCS auth accounts found. Use{' '}
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
</div>
<div className="text-center py-8 text-muted-foreground">{t('accountsTable.noAccounts')}</div>
);
}
@@ -279,7 +292,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
disabled={resetDefaultMutation.isPending}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset to CCS Default
{t('accountsTable.resetToDefault')}
</Button>
</div>
)}
@@ -293,14 +306,13 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogTitle>{t('accountsTable.deleteDialogTitle')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the account &quot;{deleteTarget}&quot;? This will
remove the profile and all its session data. This action cannot be undone.
{t('accountsTable.deleteDialogDesc', { name: deleteTarget ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('accountsTable.cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
@@ -310,7 +322,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
}
}}
>
Delete
{t('accountsTable.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -45,6 +45,7 @@ import {
KIRO_AUTH_METHOD_OPTIONS,
} from '@/lib/provider-config';
import type { KiroAuthMethod } from '@/lib/provider-config';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface AddAccountDialogProps {
@@ -76,6 +77,7 @@ export function AddAccountDialog({
const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false);
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false);
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
const { t } = useTranslation();
const wasAuthenticatingRef = useRef(false);
const authFlow = useCliproxyAuthFlow();
const kiroImportMutation = useKiroImport();
@@ -318,13 +320,13 @@ export function AddAccountDialog({
}}
>
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
<DialogDescription>
{isKiro
? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.'
? t('addAccountDialog.descKiro')
: isDeviceCode
? 'Click Authenticate. A verification code will appear for you to enter on the provider website.'
: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
? t('addAccountDialog.descDeviceCode')
: t('addAccountDialog.descOauth')}
</DialogDescription>
</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="mb-1.5 flex items-center gap-1.5 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" />
Power user mode enabled
{t('addAccountDialog.powerUserEnabled')}
</div>
AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full
responsibility for OAuth/account risk.
{t('addAccountDialog.powerUserSkipped')}
</div>
)}
@@ -367,7 +368,7 @@ export function AddAccountDialog({
{/* Kiro auth method */}
{isKiro && !showAuthUI && (
<div className="space-y-2">
<Label htmlFor="kiro-auth-method">Auth Method</Label>
<Label htmlFor="kiro-auth-method">{t('addAccountDialog.authMethod')}</Label>
<Select
value={kiroAuthMethod}
onValueChange={(value) => {
@@ -376,7 +377,7 @@ export function AddAccountDialog({
}}
>
<SelectTrigger id="kiro-auth-method">
<SelectValue placeholder="Select Kiro auth method" />
<SelectValue placeholder={t('addAccountDialog.selectKiroAuthMethod')} />
</SelectTrigger>
<SelectContent>
{KIRO_AUTH_METHOD_OPTIONS.map((option) => (
@@ -394,7 +395,9 @@ export function AddAccountDialog({
{!showAuthUI && (
<div className="space-y-2">
<Label htmlFor="nickname">
{requiresNickname ? 'Nickname (required)' : 'Nickname (optional)'}
{requiresNickname
? t('addAccountDialog.nicknameRequired')
: t('addAccountDialog.nicknameOptional')}
</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
@@ -405,15 +408,15 @@ export function AddAccountDialog({
setNickname(e.target.value);
setLocalError(null);
}}
placeholder="e.g., work, personal"
placeholder={t('addAccountDialog.nicknamePlaceholder')}
disabled={isPending}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{requiresNickname
? 'Required for this provider. Use a unique friendly name (e.g., work, personal).'
: 'A friendly name to identify this account. Auto-generated from email if left empty.'}
? t('addAccountDialog.nicknameRequiredHint')
: t('addAccountDialog.nicknameOptionalHint')}
</p>
</div>
)}
@@ -425,12 +428,12 @@ export function AddAccountDialog({
<div className="text-center">
<p className="text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Waiting for authentication...
{t('addAccountDialog.waitingForAuth')}
</p>
<p className="text-xs text-muted-foreground mt-1">
{authFlow.isDeviceCodeFlow
? 'A verification code dialog will appear shortly. Enter the code on the provider website.'
: 'Complete the authentication in your browser. This dialog closes automatically.'}
? t('addAccountDialog.deviceCodeHint')
: t('addAccountDialog.browserHint')}
</p>
</div>
@@ -438,7 +441,7 @@ export function AddAccountDialog({
{authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<div className="space-y-3">
<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">
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
{authFlow.authUrl}
@@ -448,12 +451,12 @@ export function AddAccountDialog({
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied
{t('addAccountDialog.copied')}
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
{t('addAccountDialog.copy')}
</>
)}
</Button>
@@ -464,15 +467,12 @@ export function AddAccountDialog({
if (!authFlow.authUrl) return;
const popup = window.open(authFlow.authUrl, '_blank');
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
toast.warning(
'Popup blocked. Copy the URL above and open it manually in your browser.',
{ duration: 5000 }
);
toast.warning(t('addAccountDialog.popupBlocked'), { duration: 5000 });
}
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
Open
{t('addAccountDialog.open')}
</Button>
</div>
</div>
@@ -481,13 +481,13 @@ export function AddAccountDialog({
{/* Callback paste field */}
<div className="space-y-2">
<Label htmlFor="callback-url" className="text-xs">
Redirect didn&apos;t work? Paste the callback URL:
{t('addAccountDialog.redirectPasteLabel')}
</Label>
<Input
id="callback-url"
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="Paste the redirect URL here..."
placeholder={t('addAccountDialog.callbackPlaceholder')}
className="font-mono text-xs"
/>
<Button
@@ -499,10 +499,10 @@ export function AddAccountDialog({
{authFlow.isSubmittingCallback ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Submitting...
{t('addAccountDialog.submitting')}
</>
) : (
'Submit Callback'
t('addAccountDialog.submitCallback')
)}
</Button>
</div>
@@ -511,7 +511,7 @@ export function AddAccountDialog({
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<p className="text-xs text-center text-muted-foreground">
Preparing sign-in URL...
{t('addAccountDialog.preparingUrl')}
</p>
)}
</div>
@@ -524,26 +524,26 @@ export function AddAccountDialog({
{kiroImportMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Importing token from Kiro IDE...
{t('addAccountDialog.importingToken')}
</p>
)}
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel
{t('addAccountDialog.cancel')}
</Button>
{isKiro && !showAuthUI && (
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
{kiroImportMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Importing...
{t('addAccountDialog.importing')}
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Import from IDE
{t('addAccountDialog.importFromIde')}
</>
)}
</Button>
@@ -560,7 +560,7 @@ export function AddAccountDialog({
}
>
<ExternalLink className="w-4 h-4 mr-2" />
Authenticate
{t('addAccountDialog.authenticate')}
</Button>
)}
</div>
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants';
@@ -21,6 +22,7 @@ export function AntigravityResponsibilityChecklist({
onChange,
disabled = false,
}: AntigravityResponsibilityChecklistProps) {
const { t } = useTranslation();
const completedSteps = [
value.reviewedIssue509,
value.understandsBanRisk,
@@ -50,21 +52,19 @@ export function AntigravityResponsibilityChecklist({
<AlertTriangle className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-5">Antigravity OAuth Responsibility</p>
<p className="text-xs text-muted-foreground">
Complete all 4 steps before you can authenticate.
</p>
<p className="text-sm font-semibold leading-5">{t('antigravityChecklist.title')}</p>
<p className="text-xs text-muted-foreground">{t('antigravityChecklist.subtitle')}</p>
</div>
</div>
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
Mandatory
{t('antigravityChecklist.mandatory')}
</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Completion</span>
<span>{completedSteps}/4 steps</span>
<span>{t('antigravityChecklist.completion')}</span>
<span>{t('antigravityChecklist.stepsCount', { current: completedSteps })}</span>
</div>
<Progress value={progressValue} className="h-2" />
</div>
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Copy, Check, Terminal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface CreateAuthProfileDialogProps {
open: boolean;
@@ -25,6 +26,7 @@ interface CreateAuthProfileDialogProps {
const MAX_CONTEXT_GROUP_LENGTH = 64;
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
const { t } = useTranslation();
const [profileName, setProfileName] = useState('');
const [shareContext, setShareContext] = useState(false);
const [contextGroup, setContextGroup] = useState('');
@@ -52,7 +54,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
]
.filter(Boolean)
.join(' ')
: 'ccs auth create <name>';
: t('createAuthProfileDialog.commandFallback');
const handleCopy = async () => {
if (!isValidName || (shareContext && !isValidContextGroup)) return;
@@ -74,27 +76,23 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Account</DialogTitle>
<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>
<DialogTitle>{t('createAuthProfileDialog.title')}</DialogTitle>
<DialogDescription>{t('createAuthProfileDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Label htmlFor="profile-name">{t('createAuthProfileDialog.profileName')}</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="e.g., work, personal, client"
placeholder={t('createAuthProfileDialog.profileNamePlaceholder')}
autoComplete="off"
/>
{profileName && !isValidName && (
<p className="text-xs text-destructive">
Name must start with a letter and contain only letters, numbers, dashes, or
underscores.
{t('createAuthProfileDialog.invalidProfileName')}
</p>
)}
</div>
@@ -107,22 +105,24 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setShareContext(checked === true)}
/>
<Label htmlFor="share-context" className="cursor-pointer">
Enable shared history sync with other ccs auth accounts
{t('createAuthProfileDialog.enableSharedHistory')}
</Label>
</div>
{shareContext && (
<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
id="context-group"
value={contextGroup}
onChange={(e) => setContextGroup(e.target.value)}
placeholder="default, sprint-a, client-x"
placeholder={t('createAuthProfileDialog.historySyncGroupPlaceholder')}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group. Spaces are normalized to dashes.
{t('createAuthProfileDialog.historySyncGroupHint')}
</p>
<div className="flex items-center gap-2 pt-1">
<Checkbox
@@ -131,17 +131,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
/>
<Label htmlFor="deeper-continuity" className="cursor-pointer">
Advanced: deeper continuity mode
{t('createAuthProfileDialog.deeperContinuity')}
</Label>
</div>
<p className="text-xs text-muted-foreground">
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
{t('createAuthProfileDialog.deeperContinuityHint')}
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
Group must start with a letter and use only letters, numbers, dashes, or
underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars).
{t('createAuthProfileDialog.invalidContextGroup', {
max: MAX_CONTEXT_GROUP_LENGTH,
})}
</p>
)}
</div>
@@ -149,7 +149,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
</div>
<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">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
<code className="flex-1 break-all">{command}</code>
@@ -170,20 +170,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
</div>
<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">
<li>Complete the Claude login in your browser</li>
<li>Return here and refresh to see the new account</li>
<li>{t('createAuthProfileDialog.afterRunStep1')}</li>
<li>{t('createAuthProfileDialog.afterRunStep2')}</li>
</ol>
<p className="pt-1">
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
page action button.
</p>
<p className="pt-1">{t('createAuthProfileDialog.poolingHint')}</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleClose}>
Close
{t('createAuthProfileDialog.close')}
</Button>
<Button
onClick={handleCopy}
@@ -192,12 +189,12 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Copied!
{t('createAuthProfileDialog.copied')}
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy Command
{t('createAuthProfileDialog.copyCommand')}
</>
)}
</Button>
@@ -17,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTranslation } from 'react-i18next';
import type { Account } from '@/lib/api-client';
import { useUpdateAccountContext } from '@/hooks/use-accounts';
@@ -32,6 +33,7 @@ interface EditAccountContextDialogProps {
}
export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
const { t } = useTranslation();
const updateContextMutation = useUpdateAccountContext();
const [mode, setMode] = useState<ContextMode>(
account.context_mode === 'shared' ? 'shared' : 'isolated'
@@ -78,89 +80,86 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit History Sync</DialogTitle>
<DialogTitle>{t('editAccountContext.title')}</DialogTitle>
<DialogDescription>
Configure how "{account.name}" shares history and continuity with other
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts.
{t('editAccountContext.description', { name: account.name })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-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)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder="Select context mode" />
<SelectValue placeholder={t('editAccountContext.selectContextMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">isolated (no sync)</SelectItem>
<SelectItem value="shared">shared (sync enabled)</SelectItem>
<SelectItem value="isolated">{t('editAccountContext.isolatedOption')}</SelectItem>
<SelectItem value="shared">{t('editAccountContext.sharedOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{mode === 'shared'
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
? t('editAccountContext.sharedModeHint')
: t('editAccountContext.isolatedModeHint')}
</p>
</div>
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="context-group">History Sync Group</Label>
<Label htmlFor="context-group">{t('editAccountContext.historySyncGroup')}</Label>
<Input
id="context-group"
value={group}
onChange={(event) => setGroup(event.target.value)}
placeholder="default"
placeholder={t('editAccountContext.groupPlaceholder')}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-`
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
{t('editAccountContext.groupHint', { max: MAX_CONTEXT_GROUP_LENGTH })}
</p>
{!isSharedGroupValid && (
<p className="text-xs text-destructive">
Enter a valid group name that starts with a letter.
</p>
<p className="text-xs text-destructive">{t('editAccountContext.invalidGroup')}</p>
)}
</div>
)}
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="continuity-mode">Continuity Depth</Label>
<Label htmlFor="continuity-mode">{t('editAccountContext.continuityDepth')}</Label>
<Select
value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
>
<SelectTrigger id="continuity-mode">
<SelectValue placeholder="Select continuity depth" />
<SelectValue placeholder={t('editAccountContext.selectContinuityDepth')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">standard (projects only)</SelectItem>
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
<SelectItem value="standard">{t('editAccountContext.standardOption')}</SelectItem>
<SelectItem value="deeper">{t('editAccountContext.deeperOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{continuityMode === 'deeper'
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.'
: 'Standard mode syncs project workspace context only.'}
? t('editAccountContext.deeperHint')
: t('editAccountContext.standardHint')}
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
Credentials and `.anthropic` remain isolated per account in all modes.
{t('editAccountContext.credentialsIsolated')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
Cancel
{t('editAccountContext.cancel')}
</Button>
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
{updateContextMutation.isPending ? 'Saving...' : 'Save'}
{updateContextMutation.isPending
? t('editAccountContext.saving')
: t('editAccountContext.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -17,6 +17,7 @@ import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { AccountData, DragOffset } from './types';
@@ -94,6 +95,7 @@ export function AccountCard({
onPauseToggle,
isPausingAccount,
}: AccountCardProps) {
const { t } = useTranslation();
const borderSide = BORDER_SIDE_MAP[zone];
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
@@ -245,7 +247,7 @@ export function AccountCard({
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? 'Resume account' : 'Pause account'}
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -266,7 +268,7 @@ export function AccountCard({
{quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>Quota...</span>
<span>{t('accountCard.quotaLoading')}</span>
</div>
) : minQuotaValue !== null ? (
<TooltipProvider>
@@ -275,7 +277,7 @@ export function AccountCard({
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
Quota
{t('accountCard.quota')}
</span>
<span
className={cn(
@@ -320,21 +322,23 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
) : 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 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
<KeyRound className="w-2.5 h-2.5" />
<span>Reauth needed</span>
<span>{t('accountCard.reauthNeeded')}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? 'Remove and re-add account'
: quota.error || 'Auto-refresh failed'}
? t('accountCard.removeAndReadd')
: quota.error || t('accountCard.autoRefreshFailed')}
</p>
</TooltipContent>
</Tooltip>
@@ -2,6 +2,7 @@
* Connection Timeline Component - right sidebar panel
*/
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { STATUS_COLORS } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -16,10 +17,13 @@ interface ConnectionTimelineProps {
}
export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) {
const { t } = useTranslation();
if (events.length === 0) {
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="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>
);
}
@@ -30,7 +34,7 @@ export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelinePr
<div className="flex items-center gap-2 mb-3 px-2">
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
Connection Timeline
{t('flowViz.connectionTimeline')}
</span>
</div>
@@ -2,6 +2,7 @@
* Flow Visualization Header Component
*/
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
@@ -20,6 +21,7 @@ export function FlowVizHeader({
hasCustomPositions,
onResetPositions,
}: FlowVizHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between px-3 py-1.5">
{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"
>
<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>
) : (
<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" />}
<span>{showDetails ? 'Hide Details' : 'Show Details'}</span>
<span>{showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')}</span>
</button>
{hasCustomPositions && (
<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"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset layout</span>
<span>{t('flowViz.resetLayout')}</span>
</button>
)}
</div>
@@ -2,6 +2,7 @@
* Provider Card Component for Flow Visualization
*/
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { GripVertical } from 'lucide-react';
@@ -39,6 +40,7 @@ export function ProviderCard({
onPointerMove,
onPointerUp,
}: ProviderCardProps) {
const { t } = useTranslation();
const { accounts } = providerData;
return (
@@ -115,17 +117,19 @@ export function ProviderCard({
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName}
</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 className="space-y-2 relative z-10">
<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>
</div>
<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>
</div>
<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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
interface HistorySyncLearningMapProps {
@@ -76,6 +77,7 @@ export function HistorySyncLearningMap({
legacyTargetCount,
cliproxyCount,
}: HistorySyncLearningMapProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
@@ -84,33 +86,47 @@ export function HistorySyncLearningMap({
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base">How History Sync Works</CardTitle>
<CardTitle className="text-base">{t('historySyncLearningMap.title')}</CardTitle>
<CardDescription className="mt-1">
Isolated -&gt; Shared -&gt; Deeper. Use <code>Sync</code> per row for all changes.
{t('historySyncLearningMap.description')}
</CardDescription>
</div>
<Badge variant="outline">Learning Map</Badge>
<Badge variant="outline">{t('historySyncLearningMap.learningMap')}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{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">
{cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'}
managed in Action Center / CLIProxy page.
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
</div>
)}
<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">
<ArrowRight className="h-4 w-4" />
</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">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile title="Deeper" count={deeperSharedCount} icon={Waves} tone="deeper" />
<StageTile
title={t('historySyncLearningMap.deeper')}
count={deeperSharedCount}
icon={Waves}
tone="deeper"
/>
</div>
<Collapsible open={open} onOpenChange={setOpen}>
@@ -119,7 +135,7 @@ export function HistorySyncLearningMap({
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"
>
<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')} />
</Button>
</CollapsibleTrigger>
@@ -128,21 +144,20 @@ export function HistorySyncLearningMap({
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<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>
<p className="mt-1 text-muted-foreground">
Sync dialog lets users move between isolated/shared and choose deeper continuity.
{t('historySyncLearningMap.modeSwitchDesc')}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<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>
<p className="mt-1 text-muted-foreground">
Same group means shared project context lane. Default fallback is{' '}
<code>default</code>.
{t('historySyncLearningMap.historySyncGroupDesc')}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{groupsToShow.map((group) => (
@@ -12,6 +12,7 @@ import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lu
import { cn } from '@/lib/utils';
import type { UsageSummary } from '@/hooks/use-usage';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
interface UsageSummaryCardsProps {
data?: UsageSummary;
@@ -20,6 +21,7 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
if (isLoading) {
return (
@@ -48,49 +50,61 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const cards = [
{
title: 'Total Tokens',
title: t('analyticsSummary.totalTokens'),
value: data?.totalTokens ?? 0,
icon: FileText,
format: (v: number) => formatNumber(v),
color: 'text-blue-600',
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,
icon: DollarSign,
format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-green-600',
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,
icon: Database,
format: (v: number) => formatNumber(v),
color: 'text-cyan-600',
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,
icon: ArrowDownRight,
format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-purple-600',
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,
icon: ArrowUpRight,
format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-orange-600',
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 type { CliproxyModelsResponse } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** Provider display configuration */
const CATEGORY_CONFIG: Record<string, { name: string; color: string }> = {
google: { name: 'Google (Gemini)', color: 'text-blue-600' },
openai: { name: 'OpenAI (GPT)', color: 'text-green-600' },
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' },
antigravity: { name: 'Antigravity', color: 'text-purple-600' },
other: { name: 'Other', color: 'text-gray-600' },
const CATEGORY_CONFIG: Record<string, { key: string; color: string }> = {
google: { key: 'google', color: 'text-blue-600' },
openai: { key: 'openai', color: 'text-green-600' },
anthropic: { key: 'anthropic', color: 'text-orange-600' },
antigravity: { key: 'antigravity', color: 'text-purple-600' },
other: { key: 'other', color: 'text-gray-600' },
};
/** Get display name for category */
function getCategoryDisplay(category: string) {
return (
CATEGORY_CONFIG[category.toLowerCase()] || {
name: category.charAt(0).toUpperCase() + category.slice(1),
key: 'other',
color: 'text-gray-600',
}
);
@@ -61,9 +62,12 @@ export function CategorizedModelSelector({
value,
onChange,
disabled,
placeholder = 'Select a model',
placeholder,
className,
}: CategorizedModelSelectorProps) {
const { t } = useTranslation();
const resolvedPlaceholder = placeholder ?? t('categorizedModelSelector.selectModel');
// Sort categories by model count (descending)
const sortedCategories = useMemo(() => {
if (!modelsData?.byCategory) return [];
@@ -84,7 +88,7 @@ export function CategorizedModelSelector({
return (
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
<Cpu className="w-4 h-4" />
<span>No models available</span>
<span>{t('categorizedModelSelector.noModelsAvailable')}</span>
</div>
);
}
@@ -92,7 +96,7 @@ export function CategorizedModelSelector({
return (
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className={cn('w-[320px]', className)}>
<SelectValue placeholder={placeholder}>
<SelectValue placeholder={resolvedPlaceholder}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate">{value}</span>
@@ -104,7 +108,9 @@ export function CategorizedModelSelector({
{sortedCategories.map(({ category, display, models }) => (
<SelectGroup key={category}>
<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">
{models.length}
</Badge>
@@ -129,6 +135,8 @@ export function CategorizedModelSelectorCompact({
onChange,
disabled,
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
const { t } = useTranslation();
return (
<CategorizedModelSelector
modelsData={modelsData}
@@ -136,7 +144,7 @@ export function CategorizedModelSelectorCompact({
value={value}
onChange={onChange}
disabled={disabled}
placeholder="Model..."
placeholder={t('categorizedModelSelector.modelPlaceholder')}
className="w-[200px]"
/>
);
+50 -34
View File
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
@@ -69,6 +70,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
}));
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateVariant();
const { data: authData } = useCliproxyAuth();
const { privacyMode } = usePrivacy();
@@ -151,8 +153,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{mode === 'single' ? (
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...singleForm.register('name')} placeholder="my-gemini" />
<Label htmlFor="name">{t('cliproxyDialog.name')}</Label>
<Input
id="name"
{...singleForm.register('name')}
placeholder={t('cliproxyDialog.placeholderName')}
/>
{singleForm.formState.errors.name && (
<span className="text-xs text-red-500">
{singleForm.formState.errors.name.message}
@@ -161,13 +167,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div>
<div>
<Label htmlFor="provider">Provider</Label>
<Label htmlFor="provider">{t('cliproxyDialog.provider')}</Label>
<select
id="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"
>
<option value="">Select provider...</option>
<option value="">{t('cliproxyDialog.selectProvider')}</option>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
@@ -183,17 +189,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{selectedProvider && providerAccounts.length > 0 && (
<div>
<Label htmlFor="account">Account</Label>
<Label htmlFor="account">{t('cliproxyDialog.account')}</Label>
<select
id="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"
>
<option value="">Use default account</option>
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
{providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{privacyMode ? '••••••' : acc.email || acc.id}
{acc.isDefault ? ' (default)' : ''}
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option>
))}
</select>
@@ -201,39 +207,45 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)}
<div>
<Label htmlFor="model">Model (optional)</Label>
<Input id="model" {...singleForm.register('model')} placeholder="gemini-2.5-pro" />
<Label htmlFor="model">{t('cliproxyDialog.modelOptional')}</Label>
<Input
id="model"
{...singleForm.register('model')}
placeholder={t('cliproxyDialog.placeholderModel')}
/>
</div>
<div>
<Label htmlFor="target">Default Target</Label>
<Label htmlFor="target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="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"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
{createMutation.isPending
? t('cliproxyDialog.creating')
: t('cliproxyDialog.create')}
</Button>
</div>
</form>
) : (
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
<div>
<Label htmlFor="comp-name">Name</Label>
<Label htmlFor="comp-name">{t('cliproxyDialog.name')}</Label>
<Input
id="comp-name"
{...compositeForm.register('name')}
placeholder="my-composite"
placeholder={t('cliproxyDialog.placeholderComposite')}
/>
{compositeForm.formState.errors.name && (
<span className="text-xs text-red-500">
@@ -243,17 +255,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div>
<div>
<Label>Tier Configuration</Label>
<Label>{t('cliproxyDialog.tierConfig')}</Label>
<Tabs defaultValue="opus" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="opus">Opus</TabsTrigger>
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
<TabsTrigger value="haiku">Haiku</TabsTrigger>
<TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
<TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
<TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
</TabsList>
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<TabsContent key={tier} value={tier} className="space-y-3">
<div>
<Label htmlFor={`${tier}-provider`}>Provider</Label>
<Label htmlFor={`${tier}-provider`}>{t('cliproxyDialog.provider')}</Label>
<select
id={`${tier}-provider`}
{...compositeForm.register(`tiers.${tier}.provider`)}
@@ -267,11 +279,11 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</select>
</div>
<div>
<Label htmlFor={`${tier}-model`}>Model</Label>
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
<Input
id={`${tier}-model`}
{...compositeForm.register(`tiers.${tier}.model`)}
placeholder="model-id"
placeholder={t('cliproxyDialog.placeholderModelId')}
/>
{compositeForm.formState.errors.tiers?.[tier]?.model && (
<span className="text-xs text-red-500">
@@ -280,11 +292,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
)}
</div>
<div>
<Label htmlFor={`${tier}-account`}>Account (optional)</Label>
<Label htmlFor={`${tier}-account`}>
{t('cliproxyDialog.accountOptional')}
</Label>
<Input
id={`${tier}-account`}
{...compositeForm.register(`tiers.${tier}.account`)}
placeholder="account-id"
placeholder={t('cliproxyDialog.placeholderAccountId')}
/>
</div>
</TabsContent>
@@ -293,36 +307,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</div>
<div>
<Label htmlFor="default-tier">Default Tier</Label>
<Label htmlFor="default-tier">{t('cliproxyDialog.defaultTier')}</Label>
<select
id="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"
>
<option value="opus">Opus</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
<option value="opus">{t('cliproxyDialog.opus')}</option>
<option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
<option value="haiku">{t('cliproxyDialog.haiku')}</option>
</select>
</div>
<div>
<Label htmlFor="composite-target">Default Target</Label>
<Label htmlFor="composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="composite-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"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
{createMutation.isPending
? t('cliproxyDialog.creating')
: t('cliproxyDialog.create')}
</Button>
</div>
</form>
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { useUpdateVariant } from '@/hooks/use-cliproxy';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
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) {
const { t } = useTranslation();
const updateMutation = useUpdateVariant();
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">
<DialogHeader>
<DialogTitle>
Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name}
{isComposite
? t('cliproxyDialog.editCompositeTitle', { name: variant.name })
: t('cliproxyDialog.editSingleTitle', { name: variant.name })}
</DialogTitle>
</DialogHeader>
{isComposite ? (
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
<div>
<Label>Tier Configuration</Label>
<Label>{t('cliproxyDialog.tierConfig')}</Label>
<Tabs defaultValue="opus" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="opus">Opus</TabsTrigger>
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
<TabsTrigger value="haiku">Haiku</TabsTrigger>
<TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
<TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
<TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
</TabsList>
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<TabsContent key={tier} value={tier} className="space-y-3">
<div>
<Label htmlFor={`edit-${tier}-provider`}>Provider</Label>
<Label htmlFor={`edit-${tier}-provider`}>
{t('cliproxyDialog.provider')}
</Label>
<select
id={`edit-${tier}-provider`}
{...compositeForm.register(`tiers.${tier}.provider`)}
@@ -295,11 +301,11 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</select>
</div>
<div>
<Label htmlFor={`edit-${tier}-model`}>Model</Label>
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
<Input
id={`edit-${tier}-model`}
{...compositeForm.register(`tiers.${tier}.model`)}
placeholder="model-id"
placeholder={t('cliproxyDialog.placeholderModelId')}
/>
{compositeForm.formState.errors.tiers?.[tier]?.model && (
<span className="text-xs text-red-500">
@@ -308,11 +314,13 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
)}
</div>
<div>
<Label htmlFor={`edit-${tier}-account`}>Account (optional)</Label>
<Label htmlFor={`edit-${tier}-account`}>
{t('cliproxyDialog.accountOptional')}
</Label>
<Input
id={`edit-${tier}-account`}
{...compositeForm.register(`tiers.${tier}.account`)}
placeholder="account-id"
placeholder={t('cliproxyDialog.placeholderAccountId')}
/>
</div>
</TabsContent>
@@ -321,43 +329,45 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</div>
<div>
<Label htmlFor="edit-default-tier">Default Tier</Label>
<Label htmlFor="edit-default-tier">{t('cliproxyDialog.defaultTier')}</Label>
<select
id="edit-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"
>
<option value="opus">Opus</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
<option value="opus">{t('cliproxyDialog.opus')}</option>
<option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
<option value="haiku">{t('cliproxyDialog.haiku')}</option>
</select>
</div>
<div>
<Label htmlFor="edit-composite-target">Default Target</Label>
<Label htmlFor="edit-composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
<select
id="edit-composite-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"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
{updateMutation.isPending
? t('cliproxyDialog.saving')
: t('cliproxyDialog.saveChanges')}
</Button>
</div>
</form>
) : (
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
<div>
<Label htmlFor="edit-provider">Provider</Label>
<Label htmlFor="edit-provider">{t('cliproxyDialog.provider')}</Label>
<select
id="edit-provider"
{...singleForm.register('provider')}
@@ -372,37 +382,43 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</div>
<div>
<Label htmlFor="edit-model">Model</Label>
<Input id="edit-model" {...singleForm.register('model')} placeholder="model-id" />
</div>
<div>
<Label htmlFor="edit-account">Account (optional)</Label>
<Label htmlFor="edit-model">{t('cliproxyDialog.model')}</Label>
<Input
id="edit-account"
{...singleForm.register('account')}
placeholder="account-id"
id="edit-model"
{...singleForm.register('model')}
placeholder={t('cliproxyDialog.placeholderModelId')}
/>
</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
id="edit-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"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('cliproxyDialog.cancel')}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
{updateMutation.isPending
? t('cliproxyDialog.saving')
: t('cliproxyDialog.saveChanges')}
</Button>
</div>
</form>
@@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
@@ -35,6 +36,8 @@ function CredentialRow({
onRefresh,
privacyMode,
}: CredentialRowProps) {
const { t } = useTranslation();
const statusConfig = {
ready: {
icon: CheckCircle2,
@@ -62,21 +65,21 @@ function CredentialRow({
const Icon = config.icon;
const formatLastUsed = (date?: string) => {
if (!date) return 'Never used';
if (!date) return t('credentialHealth.neverUsed');
try {
const lastUsed = new Date(date);
const now = new Date();
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 hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
if (days > 0) return t('credentialHealth.daysAgo', { count: days });
if (hours > 0) return t('credentialHealth.hoursAgo', { count: hours });
if (minutes > 0) return t('credentialHealth.minutesAgo', { count: minutes });
return t('credentialHealth.justNow');
} catch {
return 'Unknown';
return t('credentialHealth.unknown');
}
};
@@ -120,10 +123,12 @@ function CredentialRow({
}
function CredentialHealthSkeleton() {
const { t } = useTranslation();
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
@@ -137,6 +142,7 @@ function CredentialHealthSkeleton() {
}
export function CredentialHealthList() {
const { t } = useTranslation();
const { data: authData, isLoading } = useCliproxyAuth();
const { data: stats } = useCliproxyStats(true);
const { privacyMode } = usePrivacy();
@@ -155,7 +161,8 @@ export function CredentialHealthList() {
name: account.id,
provider: status.provider,
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,
lastUsedAt: runtimeLastUsed || account.lastUsedAt,
};
@@ -166,11 +173,11 @@ export function CredentialHealthList() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
@@ -180,7 +187,7 @@ export function CredentialHealthList() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
</CardHeader>
<CardContent className="p-0">
{credentials.map((cred, i) => (
@@ -10,31 +10,34 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Cpu, AlertCircle } from 'lucide-react';
import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** Category display configuration */
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = {
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' },
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' },
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' },
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
const CATEGORY_CONFIG: Record<string, { key: string; color: string; bgColor: string }> = {
google: { key: 'google', color: 'text-blue-600', bgColor: 'bg-blue-50' },
openai: { key: 'openai', color: 'text-green-600', bgColor: 'bg-green-50' },
anthropic: { key: 'anthropic', color: 'text-orange-600', bgColor: 'bg-orange-50' },
antigravity: { key: 'antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
other: { key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
};
function getCategoryDisplay(category: string) {
const normalized = category.toLowerCase();
const configured = CATEGORY_CONFIG[normalized];
return (
CATEGORY_CONFIG[category.toLowerCase()] || {
name: category.charAt(0).toUpperCase() + category.slice(1),
configured || {
key: 'other',
color: 'text-gray-600',
bgColor: 'bg-gray-50',
}
);
}
function ModelPreferencesSkeleton() {
function ModelPreferencesSkeleton({ title }: { title: string }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Available Models</CardTitle>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
<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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Available Models</CardTitle>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
<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">
<AlertCircle className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-1">No models available</p>
<p className="text-xs text-muted-foreground">
Start a CLIProxy session to fetch available models
</p>
<p className="text-sm text-muted-foreground mb-1">{noModels}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
</CardContent>
</Card>
@@ -69,6 +78,7 @@ function EmptyModelsState() {
}
export function ModelPreferencesGrid() {
const { t } = useTranslation();
const { data: modelsData, isLoading, isError } = useCliproxyModels();
const { data: updateCheck } = useCliproxyUpdateCheck();
const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy';
@@ -86,11 +96,17 @@ export function ModelPreferencesGrid() {
}, [modelsData]);
if (isLoading) {
return <ModelPreferencesSkeleton />;
return <ModelPreferencesSkeleton title={t('cliproxyOverviewComponents.availableModels')} />;
}
if (isError || !modelsData || modelsData.totalCount === 0) {
return <EmptyModelsState />;
return (
<EmptyModelsState
title={t('cliproxyOverviewComponents.availableModels')}
noModels={t('cliproxyOverviewComponents.noModelsAvailable')}
hint={t('cliproxyOverviewComponents.startSessionHint')}
/>
);
}
return (
@@ -98,13 +114,13 @@ export function ModelPreferencesGrid() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="w-4 h-4" />
Available Models
{t('cliproxyOverviewComponents.availableModels')}
<Badge variant="secondary" className="text-xs">
{modelsData.totalCount} total
{t('cliproxyOverviewComponents.totalCount', { count: modelsData.totalCount })}
</Badge>
</CardTitle>
<CardDescription>
Models available through {backendLabel}, grouped by provider
{t('cliproxyOverviewComponents.availableThroughGroupedByProvider', { backendLabel })}
</CardDescription>
</CardHeader>
<CardContent>
@@ -115,7 +131,9 @@ export function ModelPreferencesGrid() {
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
>
<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">
{models.length}
</Badge>
@@ -132,7 +150,7 @@ export function ModelPreferencesGrid() {
))}
{models.length > 5 && (
<div className="text-xs text-muted-foreground italic">
+{models.length - 5} more
{t('cliproxyOverviewComponents.moreCount', { count: models.length - 5 })}
</div>
)}
</div>
@@ -4,6 +4,7 @@
*/
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card';
import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react';
import { useCliproxyStats } from '@/hooks/use-cliproxy';
@@ -45,6 +46,7 @@ function StatsSkeleton() {
}
export function QuickStatsRow() {
const { t } = useTranslation();
const { data: stats, isLoading } = useCliproxyStats();
if (isLoading) {
@@ -71,16 +73,24 @@ export function QuickStatsRow() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={<Activity className="w-4 h-4" />}
label="Total Requests"
label={t('quickStats.totalRequests')}
value={totalRequests}
/>
<StatCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success Rate"
label={t('quickStats.successRate')}
value={`${successRate}%`}
/>
<StatCard icon={<Coins className="w-4 h-4" />} label="Total Tokens" value={tokenDisplay} />
<StatCard icon={<Cpu className="w-4 h-4" />} label="Active Models" value={modelCount} />
<StatCard
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>
);
}
@@ -18,6 +18,7 @@ import {
import { Sparkles, Loader2, Star, Zap } from 'lucide-react';
import { FlexibleModelSelector } from '../provider-model-selector';
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
import { useTranslation } from 'react-i18next';
export function CustomPresetDialog({
open,
@@ -29,6 +30,7 @@ export function CustomPresetDialog({
catalog,
allModels,
}: CustomPresetDialogProps) {
const { t } = useTranslation();
const [values, setValues] = useState<ModelMappingValues>(currentValues);
const [presetName, setPresetName] = useState('');
@@ -48,54 +50,54 @@ export function CustomPresetDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Custom Preset
{t('customPresetDialog.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="preset-name">Preset Name (optional)</Label>
<Label htmlFor="preset-name">{t('customPresetDialog.presetNameOptional')}</Label>
<Input
id="preset-name"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g., My Custom Config"
placeholder={t('customPresetDialog.presetNamePlaceholder')}
className="text-sm"
/>
</div>
<Separator />
{catalog?.provider === 'codex' && (
<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>{' '}
pin effort. Unsuffixed models use Thinking settings.
{t('customPresetDialog.codexTipPrefix')} <code>-medium</code>, <code>-high</code>, and{' '}
<code>-xhigh</code> {t('customPresetDialog.codexTipSuffix')}
</p>
)}
<FlexibleModelSelector
label="Default Model"
description="Used when no specific tier is requested"
label={t('customPresetDialog.defaultModel')}
description={t('customPresetDialog.defaultModelDesc')}
value={values.default}
onChange={(model) => setValues({ ...values, default: model })}
catalog={catalog}
allModels={allModels}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
label={t('customPresetDialog.opusModel')}
description={t('customPresetDialog.opusModelDesc')}
value={values.opus}
onChange={(model) => setValues({ ...values, opus: model })}
catalog={catalog}
allModels={allModels}
/>
<FlexibleModelSelector
label="Sonnet (Balanced)"
description="Balance of speed and capability"
label={t('customPresetDialog.sonnetModel')}
description={t('customPresetDialog.sonnetModelDesc')}
value={values.sonnet}
onChange={(model) => setValues({ ...values, sonnet: model })}
catalog={catalog}
allModels={allModels}
/>
<FlexibleModelSelector
label="Haiku (Fast)"
description="Quick responses for simple tasks"
label={t('customPresetDialog.haikuModel')}
description={t('customPresetDialog.haikuModelDesc')}
value={values.haiku}
onChange={(model) => setValues({ ...values, haiku: model })}
catalog={catalog}
@@ -104,7 +106,7 @@ export function CustomPresetDialog({
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>
Cancel
{t('customPresetDialog.cancel')}
</Button>
{onSave && (
<Button
@@ -117,12 +119,12 @@ export function CustomPresetDialog({
) : (
<Star className="w-4 h-4 mr-1" />
)}
Save Preset
{t('customPresetDialog.savePreset')}
</Button>
)}
<Button onClick={() => onApply(values, presetName || undefined)}>
<Zap className="w-4 h-4 mr-1" />
Apply Preset
{t('customPresetDialog.applyPreset')}
</Button>
</DialogFooter>
</DialogContent>
@@ -17,6 +17,7 @@ import {
useDeletePreset,
} from '@/hooks/use-cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import i18n from '@/lib/i18n';
import { usePrivacy } from '@/contexts/privacy-context';
import { useProviderEditor } from './use-provider-editor';
import { CustomPresetDialog } from './custom-preset-dialog';
@@ -155,7 +156,7 @@ export function ProviderEditor({
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
if (!presetName) {
toast.error('Please enter a preset name to save');
toast.error(i18n.t('commonToast.enterPresetName'));
return;
}
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 { cn } from '@/lib/utils';
import { getCodexEffortDisplay } from '@/lib/codex-effort';
import { useTranslation } from 'react-i18next';
/** Model entry from catalog */
export interface ModelEntry {
@@ -72,9 +73,12 @@ export function ProviderModelSelector({
value,
onChange,
disabled,
placeholder = 'Select a model',
placeholder,
className,
}: ProviderModelSelectorProps) {
const { t } = useTranslation();
const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel');
// Group models by tier
const groupedModels = useMemo(() => {
if (!catalog?.models) return { free: [], paid: [] };
@@ -95,7 +99,7 @@ export function ProviderModelSelector({
if (!catalog || catalog.models.length === 0) {
return (
<div className={cn('text-sm text-muted-foreground py-2', className)}>
No models available for this provider
{t('providerModelSelector.noModelsForProvider')}
</div>
);
}
@@ -110,12 +114,12 @@ export function ProviderModelSelector({
<span className="truncate">{model.name}</span>
{model.broken && (
<Badge variant="destructive" className="text-[9px] h-4 px-1">
BROKEN
{t('providerModelSelector.broken')}
</Badge>
)}
{model.deprecated && (
<Badge variant="secondary" className="text-[9px] h-4 px-1">
DEPRECATED
{t('providerModelSelector.deprecated')}
</Badge>
)}
{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)}>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder}>
<SelectValue placeholder={resolvedPlaceholder}>
{selectedModel && (
<div className="flex items-center gap-2">
<span className="truncate">{selectedModel.name}</span>
{selectedModel.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
{t('providerModelSelector.paid')}
</Badge>
)}
</div>
@@ -143,13 +147,17 @@ export function ProviderModelSelector({
<SelectContent>
{groupedModels.free.length > 0 && (
<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)}
</SelectGroup>
)}
{groupedModels.paid.length > 0 && (
<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)}
</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">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div>
<p className="font-medium">This model has known issues</p>
<p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
{selectedModel.issueUrl && (
<a
href={selectedModel.issueUrl}
@@ -169,7 +177,7 @@ export function ProviderModelSelector({
rel="noopener noreferrer"
className="underline hover:no-underline"
>
View issue details
{t('providerModelSelector.viewIssueDetails')}
</a>
)}
</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">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div>
<p className="font-medium">This model is deprecated</p>
<p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
{selectedModel.deprecationReason && (
<p className="opacity-80">{selectedModel.deprecationReason}</p>
)}
@@ -212,6 +220,7 @@ export function ModelMappingSelector({
onChange,
disabled,
}: ModelMappingSelectorProps) {
const { t } = useTranslation();
if (!catalog) return null;
return (
@@ -219,7 +228,7 @@ export function ModelMappingSelector({
<label className="text-xs font-medium text-muted-foreground">{label}</label>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<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>}
</SelectValue>
</SelectTrigger>
@@ -230,7 +239,7 @@ export function ModelMappingSelector({
<span className="truncate text-sm">{model.name}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
{t('providerModelSelector.paid')}
</Badge>
)}
</div>
@@ -262,6 +271,7 @@ export function FlexibleModelSelector({
allModels,
disabled,
}: FlexibleModelSelectorProps) {
const { t } = useTranslation();
// Combine catalog models (recommended) with all available models
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
const isCodexProvider = catalog?.provider === 'codex';
@@ -275,7 +285,7 @@ export function FlexibleModelSelector({
</div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select model">
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
@@ -295,7 +305,9 @@ export function FlexibleModelSelector({
{/* Recommended models from catalog */}
{catalog && catalog.models.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel>
<SelectLabel className="text-xs text-primary">
{t('providerModelSelector.recommended')}
</SelectLabel>
{catalog.models.map((model) => {
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
return (
@@ -304,7 +316,7 @@ export function FlexibleModelSelector({
<span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
{t('providerModelSelector.paid')}
</Badge>
)}
{codexEffort && (
@@ -327,7 +339,7 @@ export function FlexibleModelSelector({
{allModels.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
All Models ({allModels.length})
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
</SelectLabel>
{allModels
.filter((m) => !catalogModelIds.has(m.id))
@@ -355,7 +367,9 @@ export function FlexibleModelSelector({
{/* Fallback if no models available */}
{(!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>
</Select>
+13 -13
View File
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Upload, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync';
@@ -23,6 +24,7 @@ interface SyncDialogProps {
}
export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
const { t } = useTranslation();
const { data: preview, isLoading: previewLoading } = useSyncPreview();
const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync();
@@ -45,11 +47,9 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Sync Profiles to Local CLIProxy
{t('syncDialog.title')}
</DialogTitle>
<DialogDescription>
Sync your CCS API profiles to the local CLIProxy config.yaml.
</DialogDescription>
<DialogDescription>{t('syncDialog.description')}</DialogDescription>
</DialogHeader>
<div className="mt-4">
@@ -59,8 +59,8 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
</div>
) : preview?.count === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No profiles configured to sync.</p>
<p className="text-sm mt-2">Create API profiles first using the Profiles tab.</p>
<p>{t('syncDialog.noProfiles')}</p>
<p className="text-sm mt-2">{t('syncDialog.createProfilesFirst')}</p>
</div>
) : (
<ScrollArea className="h-[300px] pr-4">
@@ -74,7 +74,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
<div className="font-medium">{profile.name}</div>
{profile.modelName && (
<div className="text-xs text-muted-foreground truncate max-w-[300px]">
Model: {profile.modelName}
{t('syncDialog.modelLabel')} {profile.modelName}
</div>
)}
{profile.baseUrl && (
@@ -84,7 +84,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
)}
</div>
<Badge variant="outline" className="text-xs">
Ready
{t('syncDialog.ready')}
</Badge>
</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="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 className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
Cancel
{t('syncDialog.cancel')}
</Button>
<Button
onClick={handleSync}
@@ -108,17 +108,17 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
{isSyncing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Syncing...
{t('syncDialog.syncing')}
</>
) : isSuccess ? (
<>
<CheckCircle className="w-4 h-4" />
Synced!
{t('syncDialog.synced')}
</>
) : (
<>
<Upload className="w-4 h-4" />
Sync Now
{t('syncDialog.syncNow')}
</>
)}
</Button>
@@ -9,10 +9,12 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { SyncDialog } from './sync-dialog';
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
export function SyncStatusCard() {
const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState(false);
const { data: status, isLoading, refetch } = useSyncStatus();
const { mutate: executeSync, isPending: isSyncing } = useExecuteSync();
@@ -31,7 +33,7 @@ export function SyncStatusCard() {
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileDown className="w-4 h-4" />
Profile Sync
{t('syncStatusCard.profileSync')}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-6">
@@ -50,7 +52,7 @@ export function SyncStatusCard() {
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileDown className="w-4 h-4" />
Profile Sync
{t('syncStatusCard.profileSync')}
</CardTitle>
<Badge
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 ? 'Ready' : 'No Config'}
{isConfigured ? t('syncStatusCard.ready') : t('syncStatusCard.noConfig')}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isConfigured && (
<div className="text-xs text-muted-foreground">
Syncs API profiles to local CLIProxy config
{t('syncStatusCard.syncsProfilesDesc')}
</div>
)}
{!isConfigured && (
<p className="text-xs text-muted-foreground">
Run <code className="bg-muted px-1 rounded">ccs doctor --fix</code> to generate
config.
</p>
<p
className="text-xs text-muted-foreground"
dangerouslySetInnerHTML={{
__html: t('syncStatusCard.runDoctorHint'),
}}
/>
)}
{status?.error && <p className="text-xs text-red-500">{status.error}</p>}
@@ -89,7 +93,7 @@ export function SyncStatusCard() {
className="flex-1"
onClick={() => setDialogOpen(true)}
>
Details
{t('syncStatusCard.details')}
</Button>
<Button
variant="default"
@@ -103,7 +107,7 @@ export function SyncStatusCard() {
) : (
<RefreshCw className="w-3 h-3" />
)}
Sync Now
{t('syncStatusCard.syncNow')}
</Button>
</div>
</CardContent>
@@ -4,6 +4,7 @@ import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CodeEditor } from '@/components/shared/code-editor';
import i18n from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface RawJsonSettingsEditorPanelProps {
@@ -39,7 +40,7 @@ export function RawJsonSettingsEditorPanel({
if (!value) return;
await navigator.clipboard.writeText(value);
setCopied(true);
toast.success('Settings copied to clipboard');
toast.success(i18n.t('commonToast.settingsCopied'));
window.setTimeout(() => setCopied(false), 1500);
};
@@ -16,6 +16,7 @@ import {
import { Check } from 'lucide-react';
import type { FlexibleModelSelectorProps } from './types';
import { getPlanBadgeStyle, getMultiplierDisplay } from './utils';
import { useTranslation } from 'react-i18next';
export function FlexibleModelSelector({
label,
@@ -25,6 +26,7 @@ export function FlexibleModelSelector({
models,
disabled,
}: FlexibleModelSelectorProps) {
const { t } = useTranslation();
// Find current model for display
const currentModel = models.find((m) => m.id === value);
@@ -36,7 +38,7 @@ export function FlexibleModelSelector({
</div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select model">
<SelectValue placeholder={t('componentModelSelector.selectModel')}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
@@ -55,7 +57,7 @@ export function FlexibleModelSelector({
<SelectContent className="max-h-[300px]">
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
Available Models ({models.length})
{t('componentModelSelector.availableModelsCount', { count: models.length })}
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
@@ -76,7 +78,7 @@ export function FlexibleModelSelector({
)}
{model.preview && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
Preview
{t('componentModelSelector.preview')}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
@@ -16,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTranslation } from 'react-i18next';
interface SettingsTabProps {
enabled: boolean;
@@ -46,6 +47,8 @@ export function SettingsTab({
onUpdateRateLimit,
onUpdateWaitOnLimit,
}: SettingsTabProps) {
const { t } = useTranslation();
return (
<TabsContent
value="settings"
@@ -57,10 +60,10 @@ export function SettingsTab({
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium">
Enable Copilot
{t('copilotSettings.enableCopilot')}
</Label>
<p className="text-xs text-muted-foreground">
Allow using GitHub Copilot subscription
{t('copilotSettings.enableCopilotDesc')}
</p>
</div>
<Switch id="enabled" checked={enabled} onCheckedChange={onUpdateEnabled} />
@@ -68,12 +71,12 @@ export function SettingsTab({
{/* Basic Settings */}
<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 */}
<div className="space-y-2">
<Label htmlFor="port" className="text-xs">
Port
{t('copilotPage.port')}
</Label>
<Input
id="port"
@@ -89,16 +92,22 @@ export function SettingsTab({
{/* Account Type */}
<div className="space-y-2">
<Label htmlFor="account-type" className="text-xs">
Account Type
{t('copilotSettings.accountType')}
</Label>
<Select value={accountType} onValueChange={onUpdateAccountType}>
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">Individual</SelectItem>
<SelectItem value="business">Business</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
<SelectItem value="individual">
{t('copilotSettings.accountTypeIndividual')}
</SelectItem>
<SelectItem value="business">
{t('copilotSettings.accountTypeBusiness')}
</SelectItem>
<SelectItem value="enterprise">
{t('copilotSettings.accountTypeEnterprise')}
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -108,18 +117,18 @@ export function SettingsTab({
{/* Rate Limiting */}
<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">
<Label htmlFor="rate-limit" className="text-xs">
Rate Limit (seconds)
{t('copilotSettings.rateLimitSeconds')}
</Label>
<Input
id="rate-limit"
type="number"
value={rateLimit}
onChange={(e) => onUpdateRateLimit(e.target.value)}
placeholder="No limit"
placeholder={t('copilotSettings.noLimit')}
min={0}
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="space-y-0.5">
<Label htmlFor="wait-on-limit" className="text-xs">
Wait on Rate Limit
{t('copilotSettings.waitOnRateLimit')}
</Label>
<p className="text-[10px] text-muted-foreground">
Wait instead of error when limit hit
{t('copilotSettings.waitOnRateLimitDesc')}
</p>
</div>
<Switch
@@ -146,15 +155,15 @@ export function SettingsTab({
{/* Daemon Settings */}
<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="space-y-0.5">
<Label htmlFor="auto-start" className="text-xs">
Auto-start Daemon
{t('copilotSettings.autoStartDaemon')}
</Label>
<p className="text-[10px] text-muted-foreground">
Start copilot-api when using profile
{t('copilotSettings.autoStartDaemonDesc')}
</p>
</div>
<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 { useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
const statusConfig = {
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
@@ -14,6 +15,7 @@ const statusConfig = {
};
export function HealthCheckItem({ check }: { check: HealthCheck }) {
const { t } = useTranslation();
const fixMutation = useFixHealth();
const config = statusConfig[check.status];
const [isOpen, setIsOpen] = useState(false);
@@ -21,7 +23,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
toast.success(t('health.copied'));
};
// 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"
>
<Wrench className="w-3 h-3 mr-1" />
Fix
{t('health.fix')}
</Button>
)}
</div>
@@ -149,7 +151,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
className="h-7 px-3 text-xs"
>
<Wrench className="w-3 h-3 mr-1" />
Apply Fix
{t('health.applyFix')}
</Button>
)}
</div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface HealthGaugeProps {
passed: number;
@@ -20,6 +21,7 @@ const statusColors = {
};
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
const { t } = useTranslation();
const config = sizeConfig[size];
const colors = statusColors[status];
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
)}
>
health
{t('health.healthLabel')}
</span>
</div>
</div>
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface HealthStatsBarProps {
total: number;
@@ -28,6 +29,7 @@ function StatItem({ label, value, color, bgColor }: StatItemProps) {
}
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
const { t } = useTranslation();
// Calculate percentages for the progress bar
const passedPct = (passed / 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 gap-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Checks
{t('health.checks')}
</span>
<span className="font-mono font-bold text-lg">{total}</span>
</div>
+59 -55
View File
@@ -35,6 +35,7 @@ import { useSidebar } from '@/hooks/use-sidebar';
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTranslation } from 'react-i18next';
interface SidebarBadge {
text: string;
@@ -62,66 +63,69 @@ interface SidebarGroupDef {
items: SidebarItem[];
}
// Define navigation groups
const navGroups: SidebarGroupDef[] = [
{
title: 'General',
items: [
{ path: '/', icon: Home, label: 'Home' },
{ path: '/analytics', icon: BarChart3, label: 'Analytics' },
],
},
{
title: 'Identity & Access',
items: [
{
path: '/providers',
icon: Key,
label: 'API Profiles',
badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' },
},
{
path: '/cliproxy',
icon: Zap,
label: 'CLIProxy Plus',
isCollapsible: true,
children: [
{ path: '/cliproxy', label: 'Overview' },
{ path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' },
],
},
{ path: '/copilot', icon: Github, label: 'GitHub Copilot' },
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: 'Cursor IDE' },
{
path: '/accounts',
icon: Users,
label: 'Accounts',
isCollapsible: true,
children: [
{ path: '/accounts', label: 'All Accounts' },
{ path: '/shared', icon: FolderOpen, label: 'Shared Data' },
],
},
],
},
{
title: 'Compatible CLIs',
items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }],
},
{
title: 'System',
items: [
{ path: '/health', icon: Activity, label: 'Health' },
{ path: '/settings', icon: Settings, label: 'Settings' },
],
},
];
function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
return [
{
title: t('nav.general'),
items: [
{ path: '/', icon: Home, label: t('nav.home') },
{ path: '/analytics', icon: BarChart3, label: t('nav.analytics') },
],
},
{
title: t('nav.identityAccess'),
items: [
{
path: '/providers',
icon: Key,
label: t('nav.apiProfiles'),
badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' },
},
{
path: '/cliproxy',
icon: Zap,
label: t('nav.cliproxyPlus'),
isCollapsible: true,
children: [
{ path: '/cliproxy', label: t('nav.cliproxyOverview') },
{ path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') },
],
},
{ path: '/copilot', icon: Github, label: t('nav.githubCopilot') },
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') },
{
path: '/accounts',
icon: Users,
label: t('nav.accounts'),
isCollapsible: true,
children: [
{ path: '/accounts', label: t('nav.allAccounts') },
{ path: '/shared', icon: FolderOpen, label: t('nav.sharedData') },
],
},
],
},
{
title: t('nav.compatibleClis'),
items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }],
},
{
title: t('nav.system'),
items: [
{ path: '/health', icon: Activity, label: t('nav.health') },
{ path: '/settings', icon: Settings, label: t('nav.settings') },
],
},
];
}
export function AppSidebar() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { state } = useSidebar();
const { data: updateCheck } = useCliproxyUpdateCheck();
const navGroups = buildNavGroups(t);
// Dynamic label for CLIProxy based on backend
const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy';
@@ -236,7 +240,7 @@ export function AppSidebar() {
</span>
</TooltipTrigger>
<TooltipContent side="right">
<p>349+ models via OpenRouter</p>
<p>{t('nav.openrouterTooltip')}</p>
</TooltipContent>
</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 { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
import { UserMenu } from '@/components/auth/user-menu';
import { LanguageSwitcher } from './language-switcher';
import { useProjectSelection } from '@/hooks/use-project-selection';
import { useDeviceCode } from '@/hooks/use-device-code';
import { storeLastRoute } from '@/lib/last-route';
@@ -47,6 +48,7 @@ export function Layout() {
</div>
<div className="flex items-center gap-2">
<ConnectionIndicator />
<LanguageSwitcher />
<DocsLink />
<GitHubLink />
<PrivacyToggle />
@@ -5,6 +5,7 @@
*/
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz';
@@ -20,6 +21,7 @@ import { SummaryCard } from './components/summary-card';
const STORAGE_KEY = 'auth-monitor-selected-provider';
export function AuthMonitor() {
const { t } = useTranslation();
const {
accounts,
totalSuccess,
@@ -117,7 +119,7 @@ export function AuthMonitor() {
<span>Updated {timeSinceUpdate || 'now'}</span>
</div>
<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>
</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">
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Accounts"
label={t('authMonitor.accounts')}
value={accounts.length}
color="var(--accent)"
/>
<SummaryCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success"
label={t('authMonitor.success')}
value={totalSuccess.toLocaleString()}
color={STATUS_COLORS.success}
/>
<SummaryCard
icon={<XCircle className="w-4 h-4" />}
label="Failed"
label={t('authMonitor.failed')}
value={totalFailure.toLocaleString()}
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
/>
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Success Rate"
label={t('authMonitor.successRate')}
value={`${overallSuccessRate}%`}
color={
overallSuccessRate === 100
@@ -14,10 +14,12 @@ import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ErrorLogItem } from './error-log-item';
import { LogContentPanel } from './log-content-panel';
export function ErrorLogsMonitor() {
const { t } = useTranslation();
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
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 gap-2.5">
<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">
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
{t('errorLogs.failedRequests', { count: errorCount })}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileWarning className="w-3.5 h-3.5" />
<span>CLIProxy Diagnostics</span>
<span>{t('errorLogs.cliproxyDiagnostics')}</span>
</div>
</div>
@@ -175,7 +177,7 @@ export function ErrorLogsMonitor() {
</div>
{logs.length > 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>
)}
</ScrollArea>
@@ -8,12 +8,14 @@ import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
import { Skeleton } from '@/components/ui/skeleton';
import { CopyButton } from '@/components/ui/copy-button';
import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { parseErrorLog } from '@/lib/error-log-parser';
import type { TabType, LogContentPanelProps } from './types';
import { TabButton, StatusBadge } from './ui-primitives';
import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('overview');
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="text-center space-y-3">
<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>
);
@@ -51,7 +53,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
if (error || !content || !parsed) {
return (
<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>
);
}
@@ -69,7 +71,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
{name && (
<CopyButton
value={absolutePath || name}
label="Copy absolute path"
label={t('errorLogs.copyAbsolutePath')}
size="icon-sm"
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 && (
<CopyButton
value={content}
label="Copy raw log content"
label={t('errorLogs.copyRawContent')}
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
@@ -99,31 +101,31 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
onClick={() => setActiveTab('overview')}
icon={Info}
>
Overview
{t('errorLogs.tabOverview')}
</TabButton>
<TabButton
active={activeTab === 'headers'}
onClick={() => setActiveTab('headers')}
icon={Code}
>
Headers
{t('errorLogs.tabHeaders')}
</TabButton>
<TabButton
active={activeTab === 'request'}
onClick={() => setActiveTab('request')}
icon={ArrowUpRight}
>
Request
{t('errorLogs.tabRequest')}
</TabButton>
<TabButton
active={activeTab === 'response'}
onClick={() => setActiveTab('response')}
icon={ArrowDownLeft}
>
Response
{t('errorLogs.tabResponse')}
</TabButton>
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
Raw
{t('errorLogs.tabRaw')}
</TabButton>
</div>
@@ -5,6 +5,7 @@
import { ScrollArea } from '@/components/ui/scroll-area';
import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import {
getErrorTypeLabel,
@@ -16,9 +17,11 @@ import { StatusBadge } from './ui-primitives';
/** Overview tab content */
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
const { t } = useTranslation();
const quotaResetDisplay =
formatQuotaResetDelay(parsed.quotaResetDelay) ||
formatQuotaResetTimestamp(parsed.quotaResetTimestamp);
const na = t('errorLogs.na');
return (
<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">
<Cpu className="w-4 h-4 text-violet-500 shrink-0" />
<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">
{parsed.model}
</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">
<Clock className="w-4 h-4 text-amber-500 shrink-0" />
<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">
{quotaResetDisplay}
</span>
@@ -61,37 +64,37 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
{/* Key metrics grid */}
<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="text-muted-foreground mb-1">Method</div>
<div className="font-medium">{parsed.method || 'N/A'}</div>
<div className="text-muted-foreground mb-1">{t('errorLogs.method')}</div>
<div className="font-medium">{parsed.method || na}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div>
<div className="text-muted-foreground mb-1">{t('errorLogs.provider')}</div>
<div className="font-medium">{parsed.provider || na}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div>
<div className="font-medium">{parsed.version || 'N/A'}</div>
<div className="text-muted-foreground mb-1">{t('errorLogs.version')}</div>
<div className="font-medium">{parsed.version || na}</div>
</div>
<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}>
{parsed.endpoint || 'N/A'}
{parsed.endpoint || na}
</div>
</div>
</div>
{/* URL */}
<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">
{parsed.url || 'N/A'}
{parsed.url || na}
</div>
</div>
{/* Timestamp */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
<div className="text-muted-foreground mb-1.5">{t('errorLogs.timestamp')}</div>
<div className="font-mono">{parsed.timestamp || na}</div>
</div>
{/* Actionable suggestion based on error type */}
@@ -123,27 +126,14 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
: 'text-blue-600 dark:text-blue-400'
)}
>
{parsed.errorType === 'rate_limit' && (
<>
<strong>Rate Limited.</strong> Switch to a different account or wait for quota
reset.
{parsed.model && (
<>
{' '}
Model <code className="font-mono text-[11px]">{parsed.model}</code> has
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.'}
{parsed.errorType === 'rate_limit' &&
(parsed.model
? t('errorLogs.rateLimitedMsg', { model: parsed.model })
: t('errorLogs.rateLimitedMsgNoModel'))}
{parsed.errorType === 'auth' && t('errorLogs.authFailedMsg')}
{parsed.errorType === 'not_found' && t('errorLogs.notFoundMsg')}
{parsed.errorType === 'server' && t('errorLogs.serverErrorMsg')}
{parsed.errorType === 'timeout' && t('errorLogs.timeoutMsg')}
</div>
</div>
)}
@@ -178,8 +168,10 @@ export function HeadersTab({ headers }: { headers: Record<string, string> }) {
/** JSON/Body tab content */
export function BodyTab({ content, label }: { content: string; label: string }) {
const { t } = useTranslation();
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
@@ -51,6 +51,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useQuery, useIsMutating } from '@tanstack/react-query';
import { api, type CliproxyServerConfig } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
import {
useProxyStatus,
useStartProxy,
@@ -89,15 +90,17 @@ function formatUptime(startedAt?: string): string {
return `${minutes}m`;
}
function formatTimeAgo(timestamp?: number): string {
function formatTimeAgo(
timestamp: number | undefined,
t: (key: string, opts?: { count?: number }) => string
): string {
if (!timestamp) return '';
const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
return `${hours}h ago`;
if (minutes < 1) return t('proxyStatusWidget.justNow');
if (minutes < 60) return t('proxyStatusWidget.minutesAgo', { count: minutes });
return t('proxyStatusWidget.hoursAgo', { count: hours });
}
/** Icon button with tooltip wrapper */
@@ -148,6 +151,7 @@ function IconButton({
}
export function ProxyStatusWidget() {
const { t } = useTranslation();
const { data: status, isLoading } = useProxyStatus();
const { data: updateCheck } = useCliproxyUpdateCheck();
const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions();
@@ -196,7 +200,9 @@ export function ProxyStatusWidget() {
// Sync status
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
const targetVersion = isUnstable
@@ -257,12 +263,12 @@ export function ProxyStatusWidget() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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
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"
>
Active
{t('proxyStatusWidget.active')}
</Badge>
</div>
<Activity className="w-3 h-3 text-blue-600" />
@@ -273,7 +279,7 @@ export function ProxyStatusWidget() {
<span className="font-mono">{remoteDisplayHost}</span>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
Traffic auto-routed to remote server
{t('proxyStatusWidget.trafficAutoRouted')}
</p>
</div>
</div>
@@ -310,21 +316,21 @@ export function ProxyStatusWidget() {
<>
<IconButton
icon={isSyncing ? RefreshCw : FileDown}
tooltip="Sync profiles to CLIProxy"
tooltip={t('proxyStatusWidget.tooltipSync')}
onClick={() => executeSync()}
disabled={!isSyncConfigured || isActioning}
isPending={isSyncing}
/>
<IconButton
icon={RotateCw}
tooltip="Restart"
tooltip={t('proxyStatusWidget.tooltipRestart')}
onClick={() => restartProxy.mutate()}
disabled={isActioning}
isPending={restartProxy.isPending}
/>
<IconButton
icon={Square}
tooltip="Stop"
tooltip={t('proxyStatusWidget.tooltipStop')}
onClick={() => stopProxy.mutate()}
disabled={isActioning}
isPending={stopProxy.isPending}
@@ -335,7 +341,11 @@ export function ProxyStatusWidget() {
{/* Settings button always visible */}
<IconButton
icon={isExpanded ? X : Settings}
tooltip={isExpanded ? 'Close' : 'Version settings'}
tooltip={
isExpanded
? t('proxyStatusWidget.tooltipClose')
: t('proxyStatusWidget.tooltipVersionSettings')
}
onClick={() => setIsExpanded(!isExpanded)}
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'
)}
onClick={() => handleInstallVersion(targetVersion)}
title={`Click to ${isUnstable ? 'downgrade' : 'update'}`}
title={
isUnstable
? t('proxyStatusWidget.clickToDowngrade')
: t('proxyStatusWidget.clickToUpdate')
}
>
{isUnstable ? (
<ArrowDown className="w-2.5 h-2.5" />
@@ -379,11 +393,13 @@ export function ProxyStatusWidget() {
{/* Stats row when running */}
{isRunning && status && (
<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 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
{t('proxyStatusWidget.sessionCount', { count: status.sessionCount })}
</span>
)}
{status.startedAt && (
@@ -416,7 +432,9 @@ export function ProxyStatusWidget() {
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
{/* 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 */}
<div className="flex items-center gap-2">
@@ -427,7 +445,7 @@ export function ProxyStatusWidget() {
disabled={versionsLoading}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select version to install..." />
<SelectValue placeholder={t('proxyStatusWidget.selectVersionPlaceholder')} />
</SelectTrigger>
<SelectContent>
{versionsData?.versions.slice(0, 20).map((v) => {
@@ -439,7 +457,9 @@ export function ProxyStatusWidget() {
<span className="flex items-center gap-2">
v{v}
{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 && (
<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" />
)}
Install
{t('proxyStatusWidget.install')}
</Button>
</div>
@@ -474,14 +494,20 @@ export function ProxyStatusWidget() {
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
<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" />
<span>Versions above {versionsData.maxStableVersion} have known issues</span>
<span>
{t('proxyStatusWidget.versionsAboveUnstable', {
version: versionsData.maxStableVersion,
})}
</span>
</div>
)}
{/* Sync time */}
{updateCheck?.checkedAt && (
<div className="mt-2 text-[10px] text-muted-foreground/60">
Last checked {formatTimeAgo(updateCheck.checkedAt)}
{t('proxyStatusWidget.lastChecked', {
time: formatTimeAgo(updateCheck.checkedAt, t),
})}
</div>
)}
</CollapsibleContent>
@@ -490,7 +516,9 @@ export function ProxyStatusWidget() {
{/* Not running state */}
{!isRunning && (
<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
variant="outline"
size="sm"
@@ -503,7 +531,7 @@ export function ProxyStatusWidget() {
) : (
<Power className="w-3 h-3" />
)}
Start
{t('proxyStatusWidget.start')}
</Button>
</div>
)}
@@ -514,27 +542,32 @@ export function ProxyStatusWidget() {
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Install Unstable Version?
{t('proxyStatusWidget.installUnstableTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
You are about to install <strong>v{pendingInstallVersion}</strong>, which is above
the maximum stable version{' '}
<strong>v{versionsData?.maxStableVersion || '6.6.80'}</strong>.
</p>
<p
dangerouslySetInnerHTML={{
__html: t('proxyStatusWidget.installUnstableDesc', {
version: pendingInstallVersion ?? '',
maxStable: versionsData?.maxStableVersion || '6.6.80',
}),
}}
/>
<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>Are you sure you want to proceed?</p>
<p>{t('proxyStatusWidget.installUnstableConfirm')}</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelUnstableInstall}>Cancel</AlertDialogCancel>
<AlertDialogCancel onClick={handleCancelUnstableInstall}>
{t('proxyStatusWidget.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmUnstableInstall}
className="bg-amber-500 hover:bg-amber-600 text-white"
>
Install Anyway
{t('proxyStatusWidget.installAnyway')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -12,6 +12,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
import { Plus } from 'lucide-react';
import { isSensitiveKey } from './utils';
import type { Settings } from './types';
import { useTranslation } from 'react-i18next';
interface EnvEditorSectionProps {
currentSettings: Settings | undefined;
@@ -32,6 +33,7 @@ export function EnvEditorSection({
onEnvValueChange,
onAddEnvVar,
}: EnvEditorSectionProps) {
const { t } = useTranslation();
return (
<>
{/* Scrollable Environment Variables List */}
@@ -45,7 +47,7 @@ export function EnvEditorSection({
{key}
{isSensitiveKey(key) && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
sensitive
{t('envEditor.sensitive')}
</Badge>
)}
</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">
<p>No environment variables configured.</p>
<p className="text-xs mt-1 opacity-70">
Add variables using the input below or edit the JSON directly.
</p>
<p>{t('envEditor.none')}</p>
<p className="text-xs mt-1 opacity-70">{t('envEditor.noneHint')}</p>
</div>
)}
</div>
@@ -79,18 +79,18 @@ export function EnvEditorSection({
{/* Fixed Add Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable
{t('envEditor.addVariable')}
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
placeholder={t('envEditor.keyPlaceholder')}
value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8 w-2/5"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Input
placeholder="value"
placeholder={t('envEditor.valuePlaceholder')}
value={newEnvValue}
onChange={(e) => onNewEnvValueChange(e.target.value)}
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 { toast } from 'sonner';
import { cn } from '@/lib/utils';
import i18n from '@/lib/i18n';
import type { Settings, SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
@@ -81,7 +82,7 @@ export function FriendlyUISection({
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
}
// Show feedback toast
toast.success('Applied model to all tiers', { duration: 2000 });
toast.success(i18n.t('commonToast.appliedModelAllTiers'), { duration: 2000 });
};
// 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 type { ProfileEditorProps, Settings, SettingsResponse } from './types';
import { api, type CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n';
export function ProfileEditor({
profileName,
@@ -143,7 +144,7 @@ export function ProfileEditor({
queryClient.invalidateQueries({ queryKey: ['profiles'] });
setLocalEdits({});
setRawJsonEdits(null);
toast.success('Settings saved');
toast.success(i18n.t('commonToast.settingsSaved'));
},
onError: (error: Error) => {
if (error.message === 'CONFLICT') setConflictDialog(true);
@@ -158,12 +159,12 @@ export function ProfileEditor({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Default target updated');
toast.success(i18n.t('commonToast.defaultTargetUpdated'));
},
onError: (error: Error, target: CliTarget) => {
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
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
*/
import { useTranslation } from 'react-i18next';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button';
@@ -17,6 +18,7 @@ interface InfoSectionProps {
}
export function InfoSection({ profileName, target, data }: InfoSectionProps) {
const { t } = useTranslation();
const isDroidTarget = target === 'droid';
return (
@@ -26,17 +28,21 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Profile Information
{t('profileEditor.profileInfo')}
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
{data && (
<>
<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>
</div>
<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">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path}
@@ -45,11 +51,15 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div>
</div>
<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>
</div>
<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>
</div>
</>
@@ -59,10 +69,12 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
{/* Usage */}
<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>
<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">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} "prompt"
@@ -72,7 +84,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
{isDroidTarget
? t('profileEditor.droidAliasExplicit')
: t('profileEditor.runOnDroid')}
</Label>
<div className="mt-1 flex gap-2">
<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>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Override to Claude' : 'Override to Claude (explicit)'}
{isDroidTarget
? t('profileEditor.overrideToClaude')
: t('profileEditor.overrideToClaudeExplicit')}
</Label>
<div className="mt-1 flex gap-2">
<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>
<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">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs default {profileName}
@@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { suggestTierMappings } from '@/lib/openrouter-utils';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export interface TierMapping {
opus?: string;
@@ -32,6 +33,7 @@ export function ModelTierMapping({
onChange,
className,
}: ModelTierMappingProps) {
const { t } = useTranslation();
const { models } = useOpenRouterCatalog();
const suggestions = useMemo(() => {
@@ -53,18 +55,18 @@ export function ModelTierMapping({
<Collapsible className={cn('group', className)}>
<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" />
Model Tier Mapping
<span className="text-muted-foreground font-normal">(Advanced)</span>
{t('modelTierMapping.title')}
<span className="text-muted-foreground font-normal">
({t('modelTierMapping.advanced')})
</span>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<p className="text-muted-foreground text-sm">
Configure different models for Claude Code&apos;s opus/sonnet/haiku tiers.
</p>
<p className="text-muted-foreground text-sm">{t('modelTierMapping.description')}</p>
{hasSuggestions && (
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
<Wand2 className="mr-1 h-4 w-4" />
Auto-suggest based on {selectedModel?.split('/')[0]}
{t('modelTierMapping.autoSuggest', { provider: selectedModel?.split('/')[0] })}
</Button>
)}
@@ -77,7 +79,7 @@ export function ModelTierMapping({
id="tier-opus"
value={value.opus ?? ''}
onChange={(e) => updateTier('opus', e.target.value)}
placeholder="e.g., anthropic/claude-opus-4"
placeholder={t('modelTierMapping.opusPlaceholder')}
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
@@ -88,7 +90,7 @@ export function ModelTierMapping({
id="tier-sonnet"
value={value.sonnet ?? ''}
onChange={(e) => updateTier('sonnet', e.target.value)}
placeholder="e.g., anthropic/claude-sonnet-4"
placeholder={t('modelTierMapping.sonnetPlaceholder')}
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
@@ -99,15 +101,12 @@ export function ModelTierMapping({
id="tier-haiku"
value={value.haiku ?? ''}
onChange={(e) => updateTier('haiku', e.target.value)}
placeholder="e.g., anthropic/claude-3.5-haiku"
placeholder={t('modelTierMapping.haikuPlaceholder')}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL.
</p>
<p className="text-muted-foreground text-xs">{t('modelTierMapping.footer')}</p>
</CollapsibleContent>
</Collapsible>
);
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void;
@@ -19,6 +20,7 @@ export function OpenRouterQuickStart({
onOpenRouterClick,
onCustomClick,
}: OpenRouterQuickStartProps) {
const { t } = useTranslation();
const { modelCount, isLoading } = useOpenRouterReady();
return (
@@ -35,13 +37,14 @@ export function OpenRouterQuickStart({
variant="secondary"
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
>
Recommended
{t('openrouterQuickStart.recommended')}
</Badge>
</div>
<CardTitle className="text-xl">Start with OpenRouter</CardTitle>
<CardTitle className="text-xl">{t('openrouterQuickStart.title')}</CardTitle>
<CardDescription className="text-base">
Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more - all through one API.
{t('openrouterQuickStart.description', {
modelCountLabel: isLoading ? '300+' : `${modelCount}+`,
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -49,11 +52,11 @@ export function OpenRouterQuickStart({
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Zap className="w-4 h-4 text-accent" />
<span>One API, all providers</span>
<span>{t('openrouterQuickStart.featureOneApi')}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="w-4 h-4 text-accent" />
<span>Model tier mapping</span>
<span>{t('openrouterQuickStart.featureTierMapping')}</span>
</div>
</div>
@@ -62,12 +65,12 @@ export function OpenRouterQuickStart({
className="w-full bg-accent hover:bg-accent/90 text-white"
size="lg"
>
Create OpenRouter Profile
{t('openrouterQuickStart.createOpenRouterProfile')}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<p className="text-xs text-center text-muted-foreground">
Get your API key at{' '}
{t('openrouterQuickStart.getApiKeyAt')}{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
@@ -84,13 +87,13 @@ export function OpenRouterQuickStart({
{/* Divider */}
<div className="flex items-center gap-4">
<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" />
</div>
{/* Custom Option */}
<Button variant="outline" onClick={onCustomClick} className="w-full">
Create Custom API Profile
{t('openrouterQuickStart.createCustomProfile')}
</Button>
</div>
</div>
@@ -32,6 +32,7 @@ import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
@@ -49,6 +50,7 @@ import {
} from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types';
import type { CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n';
const schema = z.object({
name: z
@@ -104,6 +106,7 @@ export function ProfileCreateDialog({
onSuccess,
initialMode = 'openrouter',
}: ProfileCreateDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile();
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null);
@@ -242,7 +245,7 @@ export function ProfileCreateDialog({
const onSubmit = async (data: FormData) => {
// Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error('API key is required');
toast.error(i18n.t('commonToast.apiKeyRequired'));
return;
}
// 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">
{/* Main Options: OpenRouter + Custom */}
<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">
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
@@ -312,7 +317,7 @@ export function ProfileCreateDialog({
)}
>
<Settings2 className="w-4 h-4" />
<span>Custom</span>
<span>{t('profileEditor.custom')}</span>
</button>
</div>
</div>
+30 -18
View File
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label';
import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles';
import type { Profile } from '@/lib/api-client';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
@@ -41,6 +42,7 @@ interface ProfileDialogProps {
}
export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile();
const updateMutation = useUpdateProfile();
const [showModelMapping, setShowModelMapping] = useState(false);
@@ -115,34 +117,45 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{profile ? 'Edit Profile' : 'Create API Profile'}</DialogTitle>
<DialogTitle>
{profile ? t('profileDialog.editTitle') : t('profileDialog.createTitle')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...register('name')} placeholder="my-api" disabled={!!profile} />
<Label htmlFor="name">{t('profileDialog.name')}</Label>
<Input
id="name"
{...register('name')}
placeholder={t('profileDialog.namePlaceholder')}
disabled={!!profile}
/>
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
</div>
<div>
<Label htmlFor="baseUrl">Base URL</Label>
<Input id="baseUrl" {...register('baseUrl')} placeholder="https://api.example.com" />
<Label htmlFor="baseUrl">{t('profileDialog.baseUrl')}</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder={t('profileDialog.baseUrlPlaceholder')}
/>
{errors.baseUrl && (
<span className="text-xs text-red-500">{errors.baseUrl.message}</span>
)}
</div>
<div>
<Label htmlFor="apiKey">API Key</Label>
<Label htmlFor="apiKey">{t('profileDialog.apiKey')}</Label>
<Input id="apiKey" type="password" {...register('apiKey')} />
{errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
</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} />
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use: {DEFAULT_MODEL}
{t('profileDialog.defaultModelHint', { model: DEFAULT_MODEL })}
</p>
</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"
onClick={() => setShowModelMapping(!showModelMapping)}
>
<span>Model Mapping (Opus/Sonnet/Haiku)</span>
<span>{t('profileDialog.modelMappingTitle')}</span>
{showModelMapping ? (
<ChevronDown className="w-4 h-4" />
) : (
@@ -164,13 +177,12 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
{showModelMapping && (
<div className="p-3 pt-0 space-y-3 border-t">
<p className="text-xs text-muted-foreground">
Configure different model IDs for each tier. Useful for API proxies that route
different model types to different backends.
{t('profileDialog.modelMappingDesc')}
</p>
<div>
<Label htmlFor="opusModel" className="text-xs">
Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)
{t('profileDialog.opusModel')}
</Label>
<Input
id="opusModel"
@@ -182,7 +194,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div>
<Label htmlFor="sonnetModel" className="text-xs">
Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)
{t('profileDialog.sonnetModel')}
</Label>
<Input
id="sonnetModel"
@@ -194,7 +206,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div>
<Label htmlFor="haikuModel" className="text-xs">
Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)
{t('profileDialog.haikuModel')}
</Label>
<Input
id="haikuModel"
@@ -209,14 +221,14 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t('profileDialog.cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending
? 'Saving...'
? t('profileDialog.saving')
: profile
? 'Update'
: 'Create'}
? t('profileDialog.update')
: t('profileDialog.create')}
</Button>
</div>
</form>
+2 -1
View File
@@ -24,6 +24,7 @@ import {
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import type { CLIProxyProvider } from '@/lib/provider-config';
import { applyDefaultPreset } from '@/lib/preset-utils';
import i18n from '@/lib/i18n';
import { usePrivacy } from '@/contexts/privacy-context';
import { toast } from 'sonner';
@@ -100,7 +101,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
if (result.success && result.presetName) {
toast.success(`Applied "${result.presetName}" preset`);
} 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 { MODEL_CATALOGS } from '@/lib/model-catalogs';
import type { VariantStepProps } from '../types';
import { useTranslation } from 'react-i18next';
const CUSTOM_MODEL_VALUE = '__custom__';
@@ -34,6 +35,7 @@ export function VariantStep({
onSkip,
onCreate,
}: VariantStepProps) {
const { t } = useTranslation();
// Track if user selected custom model option
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
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">
<User className="w-4 h-4" />
<span>
Using:{' '}
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
</span>
@@ -64,33 +66,33 @@ export function VariantStep({
)}
<div className="space-y-2">
<Label htmlFor="variant-name">Variant Name *</Label>
<Label htmlFor="variant-name">{t('setupVariant.variantNameRequired')}</Label>
<Input
id="variant-name"
value={variantName}
onChange={(e) => onVariantNameChange(e.target.value)}
placeholder="e.g., my-gemini, g3, flash"
placeholder={t('setupVariant.variantNamePlaceholder')}
/>
<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 className="space-y-2">
<Label>Model</Label>
<Label>{t('setupVariant.model')}</Label>
{showCustomInput ? (
<div className="space-y-2">
<Input
value={modelName}
onChange={(e) => onModelChange(e.target.value)}
placeholder="e.g., gemini-2.5-pro, claude-opus-4-5"
placeholder={t('setupVariant.modelPlaceholder')}
/>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowCustomInput(false)}
>
Choose from presets instead
{t('setupVariant.choosePresetInstead')}
</button>
</div>
) : (
@@ -99,7 +101,7 @@ export function VariantStep({
onValueChange={handleModelSelect}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
<SelectValue placeholder={t('setupVariant.selectModel')} />
</SelectTrigger>
<SelectContent>
{catalogModels.map((m) => (
@@ -113,35 +115,37 @@ export function VariantStep({
</SelectItem>
))}
<SelectItem value={CUSTOM_MODEL_VALUE}>
<span className="text-primary">Custom model name...</span>
<span className="text-primary">{t('setupVariant.customModelName')}</span>
</SelectItem>
</SelectContent>
</Select>
)}
<div className="text-xs text-muted-foreground">
{showCustomInput
? 'Enter any model name supported by CLIProxy'
: `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`}
? t('setupVariant.enterAnyModel')
: t('setupVariant.defaultModel', {
model:
MODEL_CATALOGS[selectedProvider]?.defaultModel ||
t('setupVariant.providerDefault'),
})}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
{t('setupVariant.back')}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onSkip}>
Skip
{t('setupVariant.skip')}
</Button>
<Button onClick={onCreate} disabled={!variantName || isPending}>
{isPending ? 'Creating...' : 'Create Variant'}
{isPending ? t('setupVariant.creating') : t('setupVariant.createVariant')}
</Button>
</div>
</div>
<p className="text-xs text-center text-muted-foreground">
Skip if you just wanted to add an account without creating a variant
</p>
<p className="text-xs text-center text-muted-foreground">{t('setupVariant.skipHint')}</p>
</div>
);
}
@@ -21,6 +21,7 @@ import {
getDeviceCodeProviderDisplayName,
getDeviceCodeProviderInstruction,
} from '@/lib/provider-config';
import i18n from '@/lib/i18n';
interface DeviceCodeDialogProps {
open: boolean;
@@ -72,10 +73,10 @@ export function DeviceCodeDialog({
try {
await navigator.clipboard.writeText(userCode);
setHasCopied(true);
toast.success('Code copied to clipboard');
toast.success(i18n.t('commonToast.codeCopied'));
setTimeout(() => setHasCopied(false), 2000);
} catch {
toast.error('Failed to copy code');
toast.error(i18n.t('commonToast.copyCodeFailed'));
}
}, [userCode]);
+2 -1
View File
@@ -20,6 +20,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
import { ConfirmDialog } from './confirm-dialog';
import { Save, X, Loader2, Code2 } from 'lucide-react';
import { toast } from 'sonner';
import i18n from '@/lib/i18n';
// Lazy load CodeEditor to reduce initial bundle size
const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor })));
@@ -154,7 +155,7 @@ function SettingsDialogContent({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Settings saved');
toast.success(i18n.t('commonToast.settingsSaved'));
onClose();
},
onError: (error: Error) => {
@@ -1,33 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { type NoticeProgressState } from '@/lib/updates-notice-state';
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',
},
};
import { useTranslation } from 'react-i18next';
export function NoticeProgressBadge({
state,
@@ -36,7 +10,30 @@ export function NoticeProgressBadge({
state: NoticeProgressState;
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 (
<Badge
@@ -8,12 +8,7 @@ import {
type SupportScope,
} from '@/lib/support-updates-catalog';
import { SupportStatusBadge } from './support-status-badge';
const PILLAR_LABELS: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'auth', label: 'Auth' },
{ key: 'model', label: 'Model' },
];
import { useTranslation } from 'react-i18next';
const SCOPE_STYLES: Record<SupportScope, string> = {
target:
@@ -27,6 +22,13 @@ const SCOPE_STYLES: Record<SupportScope, string> = {
};
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 (
<Card className="h-full gap-4">
<CardHeader className="gap-3">
@@ -45,7 +47,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
<CardContent className="space-y-4">
<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">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{pillar.label}
@@ -57,7 +59,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Dashboard
{t('updates.dashboard')}
</p>
<div className="flex flex-wrap gap-2">
{entry.routes.map((route) => (
@@ -75,7 +77,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
CLI Usage
{t('updates.cliUsage')}
</p>
<div className="space-y-1">
{entry.commands.slice(0, 2).map((command) => (
@@ -1,12 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SupportStatus } from '@/lib/support-updates-catalog';
const STATUS_LABELS: Record<SupportStatus, string> = {
new: 'New',
stable: 'Stable',
planned: 'Planned',
};
import { useTranslation } from 'react-i18next';
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',
@@ -23,9 +18,16 @@ export function SupportStatusBadge({
status: SupportStatus;
className?: string;
}) {
const { t } = useTranslation();
const labels: Record<SupportStatus, string> = {
new: t('updates.statusNew'),
stable: t('updates.statusStable'),
planned: t('updates.statusPlanned'),
};
return (
<Badge variant="outline" className={cn('font-medium', STATUS_STYLES[status], className)}>
{STATUS_LABELS[status]}
{labels[status]}
</Badge>
);
}
@@ -15,6 +15,7 @@ import {
type SupportNotice,
} from '@/lib/support-updates-catalog';
import { type NoticeProgressState } from '@/lib/updates-notice-state';
import { useTranslation } from 'react-i18next';
type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed';
@@ -29,10 +30,11 @@ export function UpdatesDetailsPanel({
relatedEntries: CliSupportEntry[];
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
}) {
const { t } = useTranslation();
if (!notice) {
return (
<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>
);
}
@@ -53,21 +55,21 @@ export function UpdatesDetailsPanel({
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<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 className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => onUpdateProgress('done')}>
<CheckCircle2 className="h-4 w-4" />
Mark done
{t('updates.markDone')}
</Button>
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
<EyeOff className="h-4 w-4" />
Dismiss
{t('updates.dismiss')}
</Button>
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
<RotateCcw className="h-4 w-4" />
Reopen
{t('updates.reopen')}
</Button>
</div>
</div>
@@ -78,7 +80,7 @@ export function UpdatesDetailsPanel({
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Do Next</CardTitle>
<CardTitle className="text-base">{t('updates.doNext')}</CardTitle>
</div>
<CardDescription>{notice.primaryAction}</CardDescription>
</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">
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Impacted Integrations</CardTitle>
<CardDescription>Related areas based on update scope and routing.</CardDescription>
<CardTitle className="text-base">{t('updates.impactedIntegrations')}</CardTitle>
<CardDescription>{t('updates.impactedDesc')}</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
@@ -134,10 +136,8 @@ export function UpdatesDetailsPanel({
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Why It Matters</CardTitle>
<CardDescription>
Short context only, no wall-of-text release notes.
</CardDescription>
<CardTitle className="text-base">{t('updates.whyMatters')}</CardTitle>
<CardDescription>{t('updates.whyMattersDesc')}</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
@@ -3,8 +3,10 @@ import { ArrowUpRight, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CopyButton } from '@/components/ui/copy-button';
import { type SupportNoticeAction } from '@/lib/support-updates-catalog';
import { useTranslation } from 'react-i18next';
export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) {
const { t } = useTranslation();
return (
<div className="rounded-md border bg-muted/20 p-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 && (
<Button size="sm" asChild>
<Link to={action.path}>
Open
{t('updates.open')}
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</Button>
+39 -20
View File
@@ -1,3 +1,5 @@
import { getFormattingLocale } from '@/lib/locales';
/**
* Error Log Parser Utility
*
@@ -394,7 +396,9 @@ function getStatusText(code: number): 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 modified = modifiedSeconds * 1000; // Convert to milliseconds
const diff = now - modified;
@@ -404,14 +408,14 @@ export function formatRelativeTime(modifiedSeconds: number): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
if (seconds < 60) return isZh ? '刚刚' : 'just now';
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
// Format as date for older logs
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
*/
export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string {
const labels: Record<string, string> = {
rate_limit: 'Rate Limited',
auth: 'Auth Error',
not_found: 'Not Found',
server: 'Server Error',
timeout: 'Timeout',
unknown: 'Error',
};
return labels[type] || 'Error';
export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
const isZh = getFormattingLocale(locale) === 'zh-CN';
const labels: Record<string, string> = isZh
? {
rate_limit: '限流',
auth: '认证错误',
not_found: '未找到',
server: '服务错误',
timeout: '超时',
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
*/
export function formatQuotaResetTimestamp(timestamp: string | null): string | null {
export function formatQuotaResetTimestamp(
timestamp: string | null,
locale?: string
): string | null {
if (!timestamp) return null;
const isZh = getFormattingLocale(locale) === 'zh-CN';
try {
const resetDate = new Date(timestamp);
const now = new Date();
const diff = resetDate.getTime() - now.getTime();
if (diff <= 0) return 'now';
if (diff <= 0) return isZh ? '现在' : 'now';
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 60) return isZh ? `${seconds}` : `${seconds}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
return isZh ? `${mins}分钟` : `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} catch {
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 { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export function AccountsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data, isLoading } = useAccounts();
const confirmLegacyMutation = useConfirmLegacyAccountPolicies();
@@ -59,12 +61,12 @@ export function AccountsPage() {
<div className="p-4 border-b bg-background space-y-2">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Accounts</h1>
<h1 className="font-semibold">{t('accountsPage.title')}</h1>
</div>
<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>
accounts and pool onboarding from one panel.
{t('accountsPage.manageSuffix')}
</p>
</div>
@@ -72,7 +74,7 @@ export function AccountsPage() {
<div className="p-4 space-y-3">
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Primary Actions
{t('accountsPage.primaryActions')}
</p>
<Button
size="sm"
@@ -80,7 +82,7 @@ export function AccountsPage() {
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Account
{t('accountsPage.createAccount')}
</Button>
<Button
size="sm"
@@ -88,7 +90,7 @@ export function AccountsPage() {
onClick={handleOpenClaudePoolAuth}
>
<Zap className="w-4 h-4 mr-2" />
Authenticate Claude in Pool
{t('accountsPage.authClaudeInPool')}
</Button>
<Button
variant="outline"
@@ -96,7 +98,7 @@ export function AccountsPage() {
className="w-full justify-start"
onClick={handleOpenClaudePool}
>
Open Claude Pool Settings
{t('accountsPage.openClaudePoolSettings')}
<ArrowRight className="w-4 h-4 ml-auto" />
</Button>
</div>
@@ -104,7 +106,7 @@ export function AccountsPage() {
{hasLegacyFollowUp ? (
<section className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Migration Follow-up
{t('accountsPage.migrationFollowup')}
</p>
<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">
@@ -112,16 +114,14 @@ export function AccountsPage() {
<div className="space-y-1 text-xs">
{legacyContextCount > 0 && (
<p className="text-amber-800 dark:text-amber-300">
{legacyContextCount} account
{legacyContextCount > 1 ? 's still need' : ' still needs'} first-time
mode confirmation.
{t('accountsPage.legacyContextPending', { count: legacyContextCount })}
</p>
)}
{legacyContinuityCount > 0 && (
<p className="text-amber-800 dark:text-amber-300">
{legacyContinuityCount} shared account
{legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy
continuity depth.
{t('accountsPage.legacyContinuityPending', {
count: legacyContinuityCount,
})}
</p>
)}
</div>
@@ -134,14 +134,14 @@ export function AccountsPage() {
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
>
{confirmLegacyMutation.isPending
? 'Confirming Legacy Policies...'
: `Confirm Legacy Policies (${legacyTargetCount})`}
? t('accountsPage.confirmingLegacy')
: t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
</Button>
</div>
</section>
) : (
<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>
)}
@@ -151,9 +151,11 @@ export function AccountsPage() {
<CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
<div className="text-left">
<CardTitle className="text-sm">Continuity Guide</CardTitle>
<CardTitle className="text-sm">
{t('accountsPage.continuityGuide')}
</CardTitle>
<CardDescription className="mt-1">
Expand only when needed.
{t('accountsPage.expandWhenNeeded')}
</CardDescription>
</div>
<ChevronDown
@@ -165,21 +167,26 @@ export function AccountsPage() {
<CollapsibleContent>
<CardContent className="space-y-3 text-xs text-muted-foreground">
<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">
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>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Shared Deeper</p>
<p className="mt-1">
Adds <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, <code>todos</code>.
<p className="font-semibold text-foreground">
{t('accountsPage.isolated')}
</p>
</div>
<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>
<p className="mt-1">{t('accountsPage.isolatedDesc')}</p>
</div>
</CardContent>
</CollapsibleContent>
@@ -188,8 +195,8 @@ export function AccountsPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Quick Commands</CardTitle>
<CardDescription>Copy and run in terminal.</CardDescription>
<CardTitle className="text-sm">{t('accountsPage.quickCommands')}</CardTitle>
<CardDescription>{t('accountsPage.quickCommandsDesc')}</CardDescription>
</CardHeader>
<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">
@@ -215,16 +222,18 @@ export function AccountsPage() {
<div className="flex-1 min-w-0 flex flex-col bg-background">
<div className="px-5 py-4 border-b bg-background">
<div className="flex items-center gap-2">
<Badge variant="outline">ccs auth Workspace</Badge>
<Badge variant="secondary">History Sync Controls</Badge>
<Badge variant="outline">{t('accountsPage.workspaceBadge')}</Badge>
<Badge variant="secondary">{t('accountsPage.historySyncBadge')}</Badge>
</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">
This table is intentionally scoped to
{t('accountsPage.tableScopePrefix')}
<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>
for mode/group/depth changes.
{t('accountsPage.tableScopeSuffix')}
</p>
</div>
@@ -240,15 +249,14 @@ export function AccountsPage() {
<Card className="flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Account Matrix</CardTitle>
<CardTitle className="text-lg">{t('accountsPage.accountMatrix')}</CardTitle>
<CardDescription>
Shared total: {sharedCount}. Actions include Sync settings and legacy
confirmation.
{t('accountsPage.sharedTotalDesc', { count: sharedCount })}
</CardDescription>
</CardHeader>
<CardContent>
{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} />
)}
@@ -262,24 +270,24 @@ export function AccountsPage() {
<div className="p-4 space-y-4 lg:hidden">
<Card>
<CardHeader>
<CardTitle className="text-lg">Accounts</CardTitle>
<CardTitle className="text-lg">{t('accountsPage.title')}</CardTitle>
<CardDescription>
Manage
{t('accountsPage.managePrefix')}
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
continuity per account.
{t('accountsPage.mobileManageSuffix')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button className="w-full" onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Account
{t('accountsPage.createAccount')}
</Button>
<Button variant="outline" className="w-full" onClick={handleOpenClaudePool}>
Open CLIProxy Claude Pool
{t('accountsPage.openCliProxyClaudePool')}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}>
Authenticate Claude in Pool
{t('accountsPage.authClaudeInPool')}
<Zap className="w-4 h-4 ml-2" />
</Button>
<Button
@@ -289,8 +297,8 @@ export function AccountsPage() {
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
>
{confirmLegacyMutation.isPending
? 'Confirming Legacy Policies...'
: `Confirm Legacy Policies (${legacyTargetCount})`}
? t('accountsPage.confirmingLegacy')
: t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
</Button>
</CardContent>
</Card>
@@ -306,11 +314,11 @@ export function AccountsPage() {
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account Matrix</CardTitle>
<CardTitle className="text-base">{t('accountsPage.accountMatrix')}</CardTitle>
</CardHeader>
<CardContent>
{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} />
)}
@@ -9,6 +9,7 @@ import { subDays, startOfMonth } from 'date-fns';
import { Button } from '@/components/ui/button';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AnalyticsHeaderProps {
dateRange: DateRange | undefined;
@@ -29,11 +30,13 @@ export function AnalyticsHeader({
lastUpdatedText,
viewMode,
}: AnalyticsHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3 shrink-0 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
<h1 className="text-xl font-semibold">{t('analytics.title')}</h1>
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Button
@@ -51,14 +54,17 @@ export function AnalyticsHeader({
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), 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 && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
{t('analytics.updated', { value: lastUpdatedText })}
</span>
)}
<Button
+28 -21
View File
@@ -29,8 +29,10 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import type { Profile } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next';
export function ApiPage() {
const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useProfiles();
const deleteMutation = useDeleteProfile();
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 gap-2">
<Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">API Profiles</h1>
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
</div>
<Button
size="sm"
@@ -114,7 +116,7 @@ export function ApiPage() {
}}
>
<Plus className="w-4 h-4 mr-1" />
New
{t('apiProfiles.new')}
</Button>
</div>
@@ -122,7 +124,7 @@ export function ApiPage() {
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search profiles..."
placeholder={t('apiProfiles.searchPlaceholder')}
className="pl-8 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -133,20 +135,22 @@ export function ApiPage() {
{/* Profile List */}
<ScrollArea className="flex-1">
{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 ? (
<div className="p-4 text-center">
<div className="space-y-3 py-8">
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
<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">
Unable to fetch API profiles. Please try again.
{t('apiProfiles.failedLoadDesc')}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Retry
{t('apiProfiles.retry')}
</Button>
</div>
</div>
@@ -156,9 +160,9 @@ export function ApiPage() {
<div className="space-y-3 py-8">
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
<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">
Create your first profile to connect to custom API endpoints
{t('apiProfiles.noProfilesDesc')}
</p>
</div>
<Button
@@ -169,12 +173,12 @@ export function ApiPage() {
}}
>
<Plus className="w-4 h-4 mr-1" />
Create Profile
{t('apiProfiles.createProfile')}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground py-4">
No profiles match "{searchQuery}"
{t('apiProfiles.noProfileMatch', { query: searchQuery })}
</p>
)}
</div>
@@ -197,12 +201,12 @@ export function ApiPage() {
{profiles.length > 0 && (
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
</span>
<span>{t('apiProfiles.profileCount', { count: profiles.length })}</span>
<span className="flex items-center gap-1">
<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>
</div>
</div>
@@ -253,9 +257,9 @@ export function ApiPage() {
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deleteConfirm}
title="Delete Profile"
description={`Are you sure you want to delete "${deleteConfirm}"? This will remove the settings file and cannot be undone.`}
confirmText="Delete"
title={t('apiProfiles.deleteProfileTitle')}
description={t('apiProfiles.deleteProfileDesc', { name: deleteConfirm ?? '' })}
confirmText={t('apiProfiles.delete')}
variant="destructive"
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
@@ -264,9 +268,12 @@ export function ApiPage() {
{/* Unsaved Changes Confirmation */}
<ConfirmDialog
open={!!pendingSwitch}
title="Unsaved Changes"
description={`You have unsaved changes in "${selectedProfile}". Discard and switch to "${pendingSwitch}"?`}
confirmText="Discard & Switch"
title={t('apiProfiles.unsavedChangesTitle')}
description={t('apiProfiles.unsavedChangesDesc', {
current: selectedProfile ?? '',
next: pendingSwitch ?? '',
})}
confirmText={t('apiProfiles.discardSwitch')}
variant="destructive"
onConfirm={() => {
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 { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
// Sidebar provider item
function ProviderSidebarItem({
@@ -45,6 +46,7 @@ function ProviderSidebarItem({
isSelected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const accountCount = status.accounts?.length || 0;
return (
@@ -71,12 +73,14 @@ function ProviderSidebarItem({
{status.authenticated ? (
<>
<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" />
<span className="text-xs text-muted-foreground">Not connected</span>
<span className="text-xs text-muted-foreground">
{t('cliproxyPage.notConnected')}
</span>
</>
)}
</div>
@@ -101,6 +105,7 @@ function VariantSidebarItem({
onDelete: () => void;
isDeleting?: boolean;
}) {
const { t } = useTranslation();
return (
<button
className={cn(
@@ -119,7 +124,7 @@ function VariantSidebarItem({
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{variant.name}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
variant
{t('cliproxyPage.variant')}
</Badge>
<Badge variant="outline" className="text-[9px] h-4 px-1 uppercase">
{variant.target || 'claude'}
@@ -129,12 +134,16 @@ function VariantSidebarItem({
{parentAuth?.authenticated ? (
<>
<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" />
<span className="text-xs text-muted-foreground">Parent not connected</span>
<span className="text-xs text-muted-foreground">
{t('cliproxyPage.parentNotConnected')}
</span>
</>
)}
</div>
@@ -157,27 +166,25 @@ function VariantSidebarItem({
// Empty state for right panel
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
const { t } = useTranslation();
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<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">
<Zap className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2>
<p className="text-muted-foreground mb-4">
Manage OAuth authentication, account preferences, and model selection for CLIProxy
providers. Configure how CCS routes requests to different AI backends.
</p>
<h2 className="text-xl font-semibold mb-2">{t('cliproxyPage.emptyTitle')}</h2>
<p className="text-muted-foreground mb-4">{t('cliproxyPage.emptyDesc')}</p>
<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">
Control Panel
{t('cliproxyPage.controlPanel')}
</a>
.
</p>
<Button onClick={onSetup} className="gap-2">
<Sparkles className="w-4 h-4" />
Quick Setup
{t('cliproxyPage.quickSetup')}
</Button>
</div>
</div>
@@ -185,6 +192,7 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
}
export function CliproxyPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
const { data: variantsData, isFetching } = useCliproxy();
@@ -337,7 +345,9 @@ export function CliproxyPage() {
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
</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
variant="default"
@@ -346,7 +356,7 @@ export function CliproxyPage() {
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
Quick Setup
{t('cliproxyPage.quickSetup')}
</Button>
</div>
@@ -354,7 +364,7 @@ export function CliproxyPage() {
<ScrollArea className="flex-1">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
Providers
{t('cliproxyPage.providers')}
</div>
{authLoading ? (
<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">
<GitBranch className="w-3 h-3" />
Variants
{t('cliproxyPage.variants')}
</div>
<div className="space-y-1">
{variants.map((variant) => (
@@ -408,12 +418,12 @@ export function CliproxyPage() {
{/* Footer Stats */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{providers.length} provider{providers.length !== 1 ? 's' : ''}
</span>
<span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
<span className="flex items-center gap-1">
<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>
</div>
</div>
@@ -429,7 +439,10 @@ export function CliproxyPage() {
// Variant selected - show ProviderEditor with variant profile name
<ProviderEditor
provider={selectedVariantData.name}
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`}
displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name,
provider: selectedVariantData.provider,
})}
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
+38 -26
View File
@@ -24,6 +24,7 @@ import {
import { useCopilot } from '@/hooks/use-copilot';
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
// Status section component
function StatusSection({ title, children }: { title: string; children: React.ReactNode }) {
@@ -99,6 +100,7 @@ function LoadingSidebar() {
}
export function CopilotPage() {
const { t } = useTranslation();
const {
status,
statusLoading,
@@ -122,7 +124,7 @@ export function CopilotPage() {
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-primary" />
<h1 className="font-semibold">Copilot</h1>
<h1 className="font-semibold">{t('copilotPage.title')}</h1>
</div>
<Button
variant="ghost"
@@ -134,7 +136,7 @@ export function CopilotPage() {
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">GitHub Copilot proxy</p>
<p className="text-xs text-muted-foreground">{t('copilotPage.subtitle')}</p>
</div>
{/* Status Overview */}
@@ -148,18 +150,18 @@ export function CopilotPage() {
<div className="flex items-center gap-2">
<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">
Unofficial API - Use at Your Own Risk
{t('copilotPage.unofficialTitle')}
</span>
</div>
<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>Excessive use may trigger account restrictions</li>
<li>No warranty, no responsibility from CCS</li>
<li>{t('copilotPage.unofficialItem1')}</li>
<li>{t('copilotPage.unofficialItem2')}</li>
<li>{t('copilotPage.unofficialItem3')}</li>
</ul>
</div>
{/* Setup - Binary first, then enabled status */}
<StatusSection title="Setup">
<StatusSection title={t('copilotPage.setup')}>
<StatusItem
icon={Server}
label="copilot-api"
@@ -168,8 +170,8 @@ export function CopilotPage() {
status?.installed
? status.version
? `v${status.version}`
: 'Installed'
: 'Missing'
: t('copilotPage.installed')
: t('copilotPage.missing')
}
/>
{!status?.installed && (
@@ -182,12 +184,12 @@ export function CopilotPage() {
{isInstalling ? (
<>
<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" />
Install copilot-api
{t('copilotPage.installCopilotApi')}
</>
)}
</Button>
@@ -195,21 +197,27 @@ export function CopilotPage() {
{status?.installed && (
<StatusItem
icon={Power}
label="Integration"
label={t('copilotPage.integration')}
status={status?.enabled ?? false}
statusText={status?.enabled ? 'Enabled' : 'Disabled'}
statusText={
status?.enabled ? t('copilotPage.enabled') : t('copilotPage.disabled')
}
/>
)}
</StatusSection>
{/* Authentication - only show after binary installed */}
{status?.installed && (
<StatusSection title="Auth">
<StatusSection title={t('copilotPage.auth')}>
<StatusItem
icon={Key}
label="GitHub"
label={t('copilotPage.github')}
status={status?.authenticated ?? false}
statusText={status?.authenticated ? 'Connected' : 'Not Connected'}
statusText={
status?.authenticated
? t('copilotPage.connected')
: t('copilotPage.notConnected')
}
/>
{!status?.authenticated && (
<Button
@@ -218,7 +226,9 @@ export function CopilotPage() {
onClick={() => startAuth()}
disabled={isAuthenticating}
>
{isAuthenticating ? 'Authenticating...' : 'Authenticate'}
{isAuthenticating
? t('copilotPage.authenticating')
: t('copilotPage.authenticate')}
</Button>
)}
</StatusSection>
@@ -226,15 +236,17 @@ export function CopilotPage() {
{/* Daemon - only show after authenticated */}
{status?.authenticated && (
<StatusSection title="Daemon">
<StatusSection title={t('copilotPage.daemon')}>
<StatusItem
icon={Cpu}
label="Status"
label={t('copilotPage.status')}
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">
Port: {status?.port ?? 4141}
{t('copilotPage.port')}: {status?.port ?? 4141}
</div>
<div className="px-1">
{status?.daemon_running ? (
@@ -246,7 +258,7 @@ export function CopilotPage() {
disabled={isStoppingDaemon}
>
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
{isStoppingDaemon ? 'Stopping...' : 'Stop'}
{isStoppingDaemon ? t('copilotPage.stopping') : t('copilotPage.stop')}
</Button>
) : (
<Button
@@ -257,7 +269,7 @@ export function CopilotPage() {
disabled={isStartingDaemon}
>
<Power className="w-3.5 h-3.5 mr-1.5" />
{isStartingDaemon ? 'Starting...' : 'Start'}
{isStartingDaemon ? t('copilotPage.starting') : t('copilotPage.start')}
</Button>
)}
</div>
@@ -270,16 +282,16 @@ export function CopilotPage() {
{/* Footer */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>Proxy</span>
<span>{t('copilotPage.proxy')}</span>
{status?.daemon_running ? (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" />
Active
{t('copilotPage.active')}
</span>
) : (
<span className="flex items-center gap-1">
<XCircle className="w-3 h-3 text-muted-foreground" />
Inactive
{t('copilotPage.inactive')}
</span>
)}
</div>
+127 -99
View File
@@ -52,6 +52,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useTranslation } from 'react-i18next';
interface CursorConfigDraft {
port: string;
@@ -156,6 +157,7 @@ function CursorModelSelector({
allowDefaultFallback?: boolean;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const selectorValue = value || (allowDefaultFallback ? '__default' : '');
const selected = models.find((model) => model.id === value);
@@ -177,12 +179,12 @@ function CursorModelSelector({
disabled={disabled}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select model">
<SelectValue placeholder={t('cursorPage.selectModel')}>
{selectorValue && (
<div className="flex items-center gap-2 min-w-0">
<span className="truncate font-mono text-xs">
{allowDefaultFallback && selectorValue === '__default'
? 'Use Default Model'
? t('cursorPage.useDefaultModel')
: selected?.name || selectorValue}
</span>
{selected?.provider && (
@@ -197,9 +199,11 @@ function CursorModelSelector({
<SelectContent className="max-h-[300px]">
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
Available Models ({models.length})
{t('cursorPage.availableModelCount', { count: models.length })}
</SelectLabel>
{allowDefaultFallback && <SelectItem value="__default">Use Default Model</SelectItem>}
{allowDefaultFallback && (
<SelectItem value="__default">{t('cursorPage.useDefaultModel')}</SelectItem>
)}
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2 min-w-0">
@@ -252,6 +256,7 @@ function StatusItem({
}
export function CursorPage() {
const { t } = useTranslation();
const {
status,
statusLoading,
@@ -342,8 +347,13 @@ export function CursorPage() {
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
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 ({
@@ -351,11 +361,11 @@ export function CursorPage() {
}: { suppressSuccessToast?: boolean } = {}) => {
const parsedPort = Number(effectivePort);
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;
}
if (!effectiveModel.trim()) {
toast.error('Default model is required');
toast.error(t('cursorPage.defaultModelRequired'));
return false;
}
@@ -382,23 +392,23 @@ export function CursorPage() {
})
);
if (!suppressSuccessToast) {
toast.success('Cursor config and model mapping saved');
toast.success(t('cursorPage.savedConfig'));
}
return true;
} catch (error) {
toast.error((error as Error).message || 'Failed to save config');
toast.error((error as Error).message || t('cursorPage.failedSaveConfig'));
return false;
}
};
const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => {
if (modelsLoading) {
toast.error('Models are still loading. Please wait before applying a preset.');
toast.error(t('cursorPage.modelsLoadingWait'));
return;
}
if (models.length === 0) {
toast.error('No models available yet. Start the daemon and refresh first.');
toast.error(t('cursorPage.noModelsAvailable'));
return;
}
@@ -457,7 +467,7 @@ export function CursorPage() {
sonnet_model: codex53,
haiku_model: codexMini,
}));
toast.success('Applied GPT-5.3 Codex preset');
toast.success(t('cursorPage.appliedCodexPreset'));
return;
}
@@ -469,7 +479,7 @@ export function CursorPage() {
sonnet_model: sonnet45,
haiku_model: haiku45,
}));
toast.success('Applied Claude 4.6 preset');
toast.success(t('cursorPage.appliedClaudePreset'));
return;
}
@@ -480,30 +490,32 @@ export function CursorPage() {
sonnet_model: gemini3Pro,
haiku_model: gemini3Flash,
}));
toast.success('Applied Gemini 3 preset');
toast.success(t('cursorPage.appliedGeminiPreset'));
};
const handleToggleEnabled = async (enabled: boolean) => {
try {
await updateConfigAsync({ enabled });
toast.success(enabled ? 'Cursor integration enabled' : 'Cursor integration disabled');
toast.success(
enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled')
);
} 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 () => {
try {
await autoDetectAuthAsync();
toast.success('Cursor credentials imported');
toast.success(t('cursorPage.credentialsImported'));
} catch (error) {
toast.error((error as Error).message || 'Auto-detect failed');
toast.error((error as Error).message || t('cursorPage.autoDetectFailed'));
}
};
const handleManualAuthImport = async () => {
if (!manualToken.trim() || !manualMachineId.trim()) {
toast.error('Token and machine ID are required');
toast.error(t('cursorPage.manualRequired'));
return;
}
@@ -512,12 +524,12 @@ export function CursorPage() {
accessToken: manualToken.trim(),
machineId: manualMachineId.trim(),
});
toast.success('Cursor credentials imported');
toast.success(t('cursorPage.credentialsImported'));
setManualAuthOpen(false);
setManualToken('');
setManualMachineId('');
} 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 {
const result = await startDaemonAsync();
if (!result.success) {
toast.error(result.error || 'Failed to start daemon');
toast.error(result.error || t('cursorPage.failedStartDaemon'));
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) {
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 {
const result = await stopDaemonAsync();
if (!result.success) {
toast.error(result.error || 'Failed to stop daemon');
toast.error(result.error || t('cursorPage.failedStopDaemon'));
return;
}
toast.success('Daemon stopped');
toast.success(t('cursorPage.daemonStopped'));
} 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?: boolean } = {}) => {
if (!rawSettingsReady) {
toast.error('Raw settings are still loading. Please wait and try again.');
toast.error(t('cursorPage.rawLoading'));
return false;
}
if (!rawParseResult.isValid || !rawParseResult.settings) {
toast.error(rawParseResult.error || 'Invalid JSON');
toast.error(rawParseResult.error || t('cursorPage.invalidJson'));
return false;
}
@@ -567,14 +583,14 @@ export function CursorPage() {
});
setRawConfigDirty(false);
if (!suppressSuccessToast) {
toast.success('Raw settings saved');
toast.success(t('cursorPage.rawSaved'));
}
return true;
} catch (error) {
if (isApiConflictError(error)) {
toast.error('Raw settings changed externally. Refresh and retry.');
toast.error(t('cursorPage.rawChanged'));
} else {
toast.error((error as Error).message || 'Failed to save raw settings');
toast.error((error as Error).message || t('cursorPage.failedSaveRaw'));
}
return false;
}
@@ -601,7 +617,7 @@ export function CursorPage() {
}
if (saveConfig && saveRawSettings) {
toast.success('Cursor configuration saved');
toast.success(t('cursorPage.savedAll'));
}
};
@@ -623,12 +639,12 @@ export function CursorPage() {
alt=""
className="w-5 h-5 object-contain shrink-0"
/>
<h1 className="font-semibold">Cursor</h1>
<h1 className="font-semibold">{t('cursorPage.title')}</h1>
<Badge
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"
>
Beta
{t('cursorPage.beta')}
</Badge>
{integrationBadge}
</div>
@@ -642,7 +658,7 @@ export function CursorPage() {
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
</Button>
</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>
<ScrollArea className="flex-1">
@@ -651,46 +667,48 @@ export function CursorPage() {
<div className="flex items-center gap-2">
<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">
Unofficial API - Use at Your Own Risk
{t('cursorPage.unofficialTitle')}
</span>
</div>
<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>Abuse or excessive usage may risk account restrictions</li>
<li>No warranty, no responsibility from CCS</li>
<li>{t('cursorPage.unofficialItem1')}</li>
<li>{t('cursorPage.unofficialItem2')}</li>
<li>{t('cursorPage.unofficialItem3')}</li>
</ul>
</div>
<div className="space-y-2">
<StatusItem
icon={ShieldCheck}
label="Integration"
label={t('cursorPage.integration')}
ok={Boolean(status?.enabled)}
detail={status?.enabled ? 'Enabled' : 'Disabled'}
detail={status?.enabled ? t('cursorPage.enabled') : t('cursorPage.disabled')}
/>
<StatusItem
icon={Key}
label="Authentication"
label={t('cursorPage.authentication')}
ok={Boolean(status?.authenticated && !status?.token_expired)}
detail={
status?.authenticated
? status?.token_expired
? 'Expired'
: (status.auth_method ?? 'Connected')
: 'Not connected'
? t('cursorPage.expired')
: (status.auth_method ?? t('cursorPage.connected'))
: t('cursorPage.notConnected')
}
/>
<StatusItem
icon={Server}
label="Daemon"
label={t('cursorPage.daemon')}
ok={Boolean(status?.daemon_running)}
detail={status?.daemon_running ? 'Running' : 'Stopped'}
detail={
status?.daemon_running ? t('cursorPage.running') : t('cursorPage.stopped')
}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Actions
{t('cursorPage.actions')}
</p>
{status?.enabled ? (
@@ -702,7 +720,7 @@ export function CursorPage() {
disabled={isUpdatingConfig}
>
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
Disable Integration
{t('cursorPage.disableIntegration')}
</Button>
) : (
<Button
@@ -712,7 +730,7 @@ export function CursorPage() {
disabled={isUpdatingConfig}
>
<Power className="w-3.5 h-3.5 mr-1.5" />
Enable Integration
{t('cursorPage.enableIntegration')}
</Button>
)}
@@ -728,7 +746,7 @@ export function CursorPage() {
) : (
<Key className="w-3.5 h-3.5 mr-1.5" />
)}
Auto-detect Auth
{t('cursorPage.autoDetectAuth')}
</Button>
<Button
@@ -738,7 +756,7 @@ export function CursorPage() {
onClick={() => setManualAuthOpen(true)}
>
<Key className="w-3.5 h-3.5 mr-1.5" />
Manual Auth Import
{t('cursorPage.manualAuthImport')}
</Button>
{status?.daemon_running ? (
@@ -754,7 +772,7 @@ export function CursorPage() {
) : (
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
)}
Stop Daemon
{t('cursorPage.stopDaemon')}
</Button>
) : (
<Button
@@ -768,7 +786,7 @@ export function CursorPage() {
) : (
<Play className="w-3.5 h-3.5 mr-1.5" />
)}
Start Daemon
{t('cursorPage.startDaemon')}
</Button>
)}
</div>
@@ -777,7 +795,7 @@ export function CursorPage() {
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>Port</span>
<span>{t('cursorPage.port')}</span>
<span>{status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}</span>
</div>
</div>
@@ -789,7 +807,7 @@ export function CursorPage() {
<div className="flex items-center gap-3">
<div>
<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 && (
<Badge variant="outline" className="text-xs">
cursor.settings.json
@@ -798,10 +816,10 @@ export function CursorPage() {
</div>
{rawSettings && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified:{' '}
{t('cursorPage.lastModified')}{' '}
{rawSettings.exists
? new Date(rawSettings.mtime).toLocaleString()
: 'Never saved'}
: t('cursorPage.neverSaved')}
</p>
)}
</div>
@@ -828,12 +846,12 @@ export function CursorPage() {
{isUpdatingConfig || isSavingRawSettings ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving...
{t('cursorPage.saving')}
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
Save
{t('cursorPage.save')}
</>
)}
</Button>
@@ -846,13 +864,13 @@ export function CursorPage() {
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="config" className="flex-1">
Model Config
{t('cursorPage.modelConfig')}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
Settings
{t('cursorPage.settings')}
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info
{t('cursorPage.info')}
</TabsTrigger>
</TabsList>
</div>
@@ -867,10 +885,10 @@ export function CursorPage() {
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Presets
{t('cursorPage.presets')}
</h3>
<p className="text-xs text-muted-foreground mb-3">
Apply pre-configured model mappings
{t('cursorPage.presetsDesc')}
</p>
<div className="flex flex-wrap gap-2">
<Button
@@ -912,14 +930,16 @@ export function CursorPage() {
<Separator />
<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">
Configure which models to use for each tier
{t('cursorPage.modelMappingDesc')}
</p>
<div className="space-y-4">
<CursorModelSelector
label="Default Model"
description="Used when no specific tier is requested."
label={t('cursorPage.defaultModel')}
description={t('cursorPage.defaultModelDesc')}
value={effectiveModel}
models={orderedModels}
disabled={modelsLoading}
@@ -928,8 +948,8 @@ export function CursorPage() {
}}
/>
<CursorModelSelector
label="Opus (Most capable)"
description="Complex reasoning and highest quality responses."
label={t('cursorPage.opusModel')}
description={t('cursorPage.opusModelDesc')}
value={effectiveOpusModel}
models={orderedModels}
disabled={modelsLoading}
@@ -939,8 +959,8 @@ export function CursorPage() {
}}
/>
<CursorModelSelector
label="Sonnet (Balanced)"
description="General coding and day-to-day chat workloads."
label={t('cursorPage.sonnetModel')}
description={t('cursorPage.sonnetModelDesc')}
value={effectiveSonnetModel}
models={orderedModels}
disabled={modelsLoading}
@@ -950,8 +970,8 @@ export function CursorPage() {
}}
/>
<CursorModelSelector
label="Haiku (Fast)"
description="Low-latency and lightweight request paths."
label={t('cursorPage.haikuModel')}
description={t('cursorPage.haikuModelDesc')}
value={effectiveHaikuModel}
models={orderedModels}
disabled={modelsLoading}
@@ -973,7 +993,9 @@ export function CursorPage() {
<ScrollArea className="flex-1">
<div className="p-4 space-y-6">
<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">
<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="space-y-0.5">
<Label htmlFor="cursor-auto-start" className="text-xs">
Auto-start Daemon
{t('cursorPage.autoStartDaemon')}
</Label>
<p className="text-[10px] text-muted-foreground">
Start Cursor daemon automatically when integration is used.
{t('cursorPage.autoStartDesc')}
</p>
</div>
<Switch
@@ -1016,10 +1038,10 @@ export function CursorPage() {
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="cursor-ghost-mode" className="text-xs">
Ghost Mode
{t('cursorPage.ghostMode')}
</Label>
<p className="text-[10px] text-muted-foreground">
Request `x-ghost-mode` to reduce telemetry.
{t('cursorPage.ghostModeDesc')}
</p>
</div>
<Switch
@@ -1043,11 +1065,15 @@ export function CursorPage() {
<div className="p-4 space-y-6">
<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">
<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>
</div>
<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">
{rawSettings?.path ?? '~/.ccs/cursor.settings.json'}
</code>
@@ -1060,15 +1086,17 @@ export function CursorPage() {
</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 ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Loading models...
{t('cursorPage.loadingModels')}
</div>
) : models.length === 0 ? (
<p className="text-sm text-muted-foreground">
No model metadata available yet. Start daemon and refresh.
{t('cursorPage.noModels')}
</p>
) : (
<div className="space-y-2">
@@ -1083,7 +1111,9 @@ export function CursorPage() {
{model.name} {model.provider}
</p>
</div>
{model.id === currentModel && <Badge>Default</Badge>}
{model.id === currentModel && (
<Badge>{t('cursorPage.default')}</Badge>
)}
</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]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
{t('cursorPage.rawConfiguration')}
</span>
</div>
<RawEditorSection
@@ -1122,36 +1152,34 @@ export function CursorPage() {
<Dialog open={manualAuthOpen} onOpenChange={setManualAuthOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Manual Cursor Auth Import</DialogTitle>
<DialogDescription>
Provide Cursor access token and machine ID if auto-detect is unavailable.
</DialogDescription>
<DialogTitle>{t('cursorPage.manualImportTitle')}</DialogTitle>
<DialogDescription>{t('cursorPage.manualImportDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="cursor-manual-token">Access Token</Label>
<Label htmlFor="cursor-manual-token">{t('cursorPage.accessToken')}</Label>
<Input
id="cursor-manual-token"
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder="Paste Cursor access token"
placeholder={t('cursorPage.accessTokenPlaceholder')}
/>
</div>
<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
id="cursor-manual-machine-id"
value={manualMachineId}
onChange={(e) => setManualMachineId(e.target.value)}
placeholder="32-char hex (UUID without hyphens)"
placeholder={t('cursorPage.machineIdPlaceholder')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setManualAuthOpen(false)}>
Cancel
{t('cursorPage.cancel')}
</Button>
<Button onClick={handleManualAuthImport} disabled={isImportingManualAuth}>
{isImportingManualAuth ? (
@@ -1159,7 +1187,7 @@ export function CursorPage() {
) : (
<Key className="w-4 h-4 mr-2" />
)}
Import
{t('cursorPage.import')}
</Button>
</DialogFooter>
</DialogContent>
+69 -51
View File
@@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import {
applyAnthropicBudgetTokensToDroidByokModel,
applyReasoningEffortToDroidByokModel,
@@ -166,6 +167,7 @@ function DetailRow({
}
export function DroidPage() {
const { t } = useTranslation();
const {
diagnostics,
diagnosticsLoading,
@@ -201,7 +203,7 @@ export function DroidPage() {
const updateSettingsField = (key: string, value: unknown | null) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before using quick settings controls.');
toast.error(t('droidPage.fixJsonBeforeQuickSettings'));
return;
}
@@ -248,7 +250,7 @@ export function DroidPage() {
const handleSaveRawSettings = async () => {
if (!rawEditorValidation.valid) {
toast.error(`Invalid JSON: ${rawEditorValidation.error}`);
toast.error(t('droidPage.invalidJson', { value: rawEditorValidation.error }));
return;
}
@@ -258,12 +260,12 @@ export function DroidPage() {
expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined,
});
setRawDraftText(null);
toast.success('Droid settings saved');
toast.success(t('droidPage.saved'));
} catch (error) {
if (isApiConflictError(error)) {
toast.error('Droid settings changed externally. Refresh and retry.');
toast.error(t('droidPage.changedExternally'));
} 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 (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading Droid diagnostics...
{t('droidPage.loadingDiagnostics')}
</div>
);
}
@@ -287,7 +289,7 @@ export function DroidPage() {
if (diagnosticsError || !diagnostics) {
return (
<div className="flex h-full items-center justify-center text-destructive px-6 text-center">
Failed to load Droid diagnostics.
{t('droidPage.failedDiagnostics')}
</div>
);
}
@@ -314,9 +316,9 @@ export function DroidPage() {
<Tabs defaultValue="byok" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="byok">BYOK</TabsTrigger>
<TabsTrigger value="docs">Docs</TabsTrigger>
<TabsTrigger value="overview">{t('droidPage.overview')}</TabsTrigger>
<TabsTrigger value="byok">{t('droidPage.byok')}</TabsTrigger>
<TabsTrigger value="docs">{t('droidPage.docs')}</TabsTrigger>
</TabsList>
</div>
@@ -328,34 +330,40 @@ export function DroidPage() {
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TerminalSquare className="h-4 w-4" />
Runtime & Installation
{t('droidPage.runtimeInstall')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<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'}>
{diagnostics.binary.installed ? 'Detected' : 'Not Found'}
{diagnostics.binary.installed
? t('droidPage.detected')
: t('droidPage.notFound')}
</Badge>
</div>
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow
label="Binary path"
value={diagnostics.binary.path || 'Not detected'}
label={t('droidPage.detectionSource')}
value={diagnostics.binary.source}
mono
/>
<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'}
mono
/>
<DetailRow
label="Version"
label={t('droidPage.version')}
value={diagnostics.binary.version || 'Unknown'}
mono
/>
<DetailRow
label="Override (CCS_DROID_PATH)"
label={t('droidPage.overridePath')}
value={diagnostics.binary.overridePath || 'Not set'}
mono
/>
@@ -366,7 +374,7 @@ export function DroidPage() {
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Folder className="h-4 w-4" />
Config Files
{t('droidPage.configFiles')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
@@ -380,15 +388,25 @@ export function DroidPage() {
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<DetailRow label="Path" value={file.path} mono />
<DetailRow label="Resolved" value={file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} />
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} />
<DetailRow label={t('droidPage.path')} value={file.path} mono />
<DetailRow label={t('droidPage.resolved')} value={file.resolvedPath} mono />
<DetailRow
label={t('droidPage.size')}
value={formatBytes(file.sizeBytes)}
/>
<DetailRow
label={t('droidPage.lastModified')}
value={formatTimestamp(file.mtimeMs)}
/>
{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 && (
<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>
))}
@@ -400,7 +418,7 @@ export function DroidPage() {
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings
{t('droidPage.warnings')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
@@ -444,11 +462,11 @@ export function DroidPage() {
disabledReason={
rawEditorParsed.valid
? null
: `BYOK reasoning controls disabled: ${rawEditorParsed.error}`
: `${t('droidPage.byok')}: ${rawEditorParsed.error}`
}
onEffortChange={(modelId, effort) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating BYOK reasoning settings.');
toast.error(t('droidPage.fixJsonBeforeReasoning'));
return;
}
@@ -458,7 +476,7 @@ export function DroidPage() {
effort
);
if (!nextSettings) {
toast.error('Unable to update selected BYOK model reasoning setting.');
toast.error(t('droidPage.unableUpdateReasoning'));
return;
}
@@ -466,7 +484,7 @@ export function DroidPage() {
}}
onAnthropicBudgetChange={(modelId, budgetTokens) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating thinking budget.');
toast.error(t('droidPage.fixJsonBeforeBudget'));
return;
}
@@ -476,7 +494,7 @@ export function DroidPage() {
budgetTokens
);
if (!nextSettings) {
toast.error('Thinking budget is only available for Anthropic BYOK models.');
toast.error(t('droidPage.anthropicOnlyBudget'));
return;
}
@@ -488,38 +506,38 @@ export function DroidPage() {
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Server className="h-4 w-4" />
BYOK Summary
{t('droidPage.byokSummary')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<DetailRow
label="Active model selector"
label={t('droidPage.activeModelSelector')}
value={diagnostics.byok.activeModelSelector || 'Not set'}
mono
/>
<DetailRow
label="Custom models"
label={t('droidPage.customModels')}
value={String(diagnostics.byok.customModelCount)}
/>
<DetailRow
label="CCS-managed"
label={t('droidPage.ccsManaged')}
value={String(diagnostics.byok.ccsManagedCount)}
/>
<DetailRow
label="User-managed"
label={t('droidPage.userManaged')}
value={String(diagnostics.byok.userManagedCount)}
/>
<DetailRow
label="Malformed entries"
label={t('droidPage.malformedEntries')}
value={String(diagnostics.byok.invalidModelEntryCount)}
/>
<Separator />
<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">
{providerRows.length === 0 && (
<Badge variant="secondary" className="font-mono">
none
{t('droidPage.none')}
</Badge>
)}
{providerRows.map(([provider, count]) => (
@@ -534,20 +552,20 @@ export function DroidPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Custom Models</CardTitle>
<CardTitle className="text-base">{t('droidPage.customModelsTitle')}</CardTitle>
</CardHeader>
<CardContent>
<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">
<span>Name / Model</span>
<span>Provider</span>
<span>Base URL</span>
<span>{t('droidPage.modelName')}</span>
<span>{t('droidPage.provider')}</span>
<span>{t('droidPage.baseUrl')}</span>
</div>
<ScrollArea className="h-52">
<div className="divide-y">
{customModels.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground">
No custom models
{t('droidPage.noCustomModels')}
</div>
)}
{customModels.map((model) => (
@@ -593,7 +611,7 @@ export function DroidPage() {
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Docs-Aligned Notes
{t('droidPage.docsAlignedNotes')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
@@ -605,7 +623,7 @@ export function DroidPage() {
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Factory Docs
{t('droidPage.factoryDocs')}
</p>
<div className="space-y-1.5">
{docsLinks.map((link) => (
@@ -633,7 +651,7 @@ export function DroidPage() {
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Provider Fact-Check Docs
{t('droidPage.providerFactCheckDocs')}
</p>
<div className="space-y-1.5">
{providerDocs.map((providerDoc) => (
@@ -660,10 +678,10 @@ export function DroidPage() {
</div>
<Separator />
<p className="text-xs text-muted-foreground">
Provider values: {providerValues.join(', ')}
{t('droidPage.providerValues', { value: providerValues.join(', ') })}
</p>
<p className="text-xs text-muted-foreground">
Settings hierarchy: {settingsHierarchy.join(' -> ')}
{t('droidPage.settingsHierarchy', { value: settingsHierarchy.join(' -> ') })}
</p>
</CardContent>
</Card>
@@ -686,7 +704,7 @@ export function DroidPage() {
</PanelResizeHandle>
<Panel defaultSize={55} minSize={35}>
<RawJsonSettingsEditorPanel
title="Droid BYOK Settings"
title={t('droidPage.settingsTitle')}
pathLabel={rawSettings?.path || '~/.factory/settings.json'}
loading={rawSettingsLoading}
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 { toast } from 'sonner';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
if (summary.errors > 0) return 'error';
@@ -16,14 +17,17 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n
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);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 5) return t('health.justNow');
if (seconds < 60) return t('health.secondsAgo', { count: seconds });
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);
return `${hours}h ago`;
return t('health.hoursAgo', { count: hours });
}
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
@@ -75,6 +79,7 @@ function LoadingSkeleton() {
}
export function HealthPage() {
const { t } = useTranslation();
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
// Use dataUpdatedAt directly instead of storing in state
@@ -91,12 +96,12 @@ export function HealthPage() {
const copyDoctorCommand = () => {
navigator.clipboard.writeText('ccs doctor');
toast.success('Copied to clipboard');
toast.success(t('health.copied'));
};
const handleRefresh = () => {
refetch();
toast.info('Refreshing health checks...');
toast.info(t('health.refreshing'));
};
if (isLoading && !data) {
@@ -154,10 +159,12 @@ export function HealthPage() {
{/* Main title */}
<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 && (
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
build {data.version}
{t('health.build', { version: data.version })}
</Badge>
)}
</div>
@@ -165,12 +172,12 @@ export function HealthPage() {
{/* Status message */}
<div className="flex items-center gap-2 text-sm">
<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">
{lastRefresh ? formatRelativeTime(lastRefresh) : '--'}
{lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'}
</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>
</div>
</div>
@@ -195,7 +202,7 @@ export function HealthPage() {
className="gap-2"
>
<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">
R
</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 gap-4">
<span>
Version <span className="font-mono">{data?.version ?? '--'}</span>
{t('health.version')} <span className="font-mono">{data?.version ?? '--'}</span>
</span>
<span>
Platform{' '}
{t('health.platform')}{' '}
<span className="font-mono">
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
</span>
@@ -248,7 +255,7 @@ export function HealthPage() {
</div>
<div className="flex items-center gap-2">
<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>
+7 -5
View File
@@ -9,6 +9,7 @@ import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared';
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const HEALTH_VARIANTS = {
ok: 'success',
@@ -62,6 +63,7 @@ function InlineStat({
}
export function HomePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: overview, isLoading: isOverviewLoading } = useOverview();
const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
@@ -134,28 +136,28 @@ export function HomePage() {
{/* Right: Inline Stats */}
<div className="flex flex-wrap items-center gap-3">
<InlineStat
title="Profiles"
title={t('home.profiles')}
value={overview?.profiles ?? 0}
icon={Key}
variant="accent"
onClick={() => navigate('/providers')}
/>
<InlineStat
title="CLIProxy"
title={t('home.cliproxy')}
value={overview?.cliproxy ?? 0}
icon={Zap}
variant="accent"
onClick={() => navigate('/cliproxy')}
/>
<InlineStat
title="Accounts"
title={t('home.accounts')}
value={overview?.accounts ?? 0}
icon={Users}
variant="default"
onClick={() => navigate('/accounts')}
/>
<InlineStat
title="Health"
title={t('home.health')}
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
icon={Activity}
variant={healthVariant}
@@ -169,7 +171,7 @@ export function HomePage() {
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle>
<AlertTitle>{t('home.configurationRequired')}</AlertTitle>
<AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
</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 { AlertCircle, Loader2, Lock } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useTranslation } from 'react-i18next';
export function LoginPage() {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -47,7 +49,7 @@ export function LoginPage() {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
setError(err instanceof Error ? err.message : t('auth.loginFailed'));
} finally {
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">
<Lock className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">CCS Dashboard</CardTitle>
<CardDescription>Enter your credentials to access the dashboard</CardDescription>
<CardTitle className="text-2xl">{t('auth.dashboardTitle')}</CardTitle>
<CardDescription>{t('auth.loginDescription')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -72,26 +74,26 @@ export function LoginPage() {
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t('auth.username')}</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
placeholder={t('auth.usernamePlaceholder')}
autoComplete="username"
disabled={loading}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('auth.password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
placeholder={t('auth.passwordPlaceholder')}
autoComplete="current-password"
disabled={loading}
required
@@ -101,10 +103,10 @@ export function LoginPage() {
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
{t('auth.signingIn')}
</>
) : (
'Sign In'
t('auth.signIn')
)}
</Button>
</form>
@@ -6,22 +6,24 @@
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react';
import type { SettingsTab } from '../types';
import { useTranslation } from 'react-i18next';
interface TabNavigationProps {
activeTab: SettingsTab;
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) {
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 (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<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 { SectionSkeleton } from './components/section-skeleton';
import type { SettingsTab } from './types';
import { useTranslation } from 'react-i18next';
import i18n from '@/lib/i18n';
/**
* 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="text-center p-6 max-w-md">
<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="text-sm mb-4">{this.state.error?.message || 'Unknown error occurred'}</p>
<p className="font-medium text-foreground mb-2">
{i18n.t('settings.sectionLoadFailed')}
</p>
<p className="text-sm mb-4">
{this.state.error?.message || i18n.t('settings.unknownError')}
</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="inline-flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Reload page
{i18n.t('settings.reloadPage')}
</Button>
</div>
</div>
@@ -93,6 +99,7 @@ class SectionErrorBoundary extends Component<
// Inner component that uses context
function SettingsPageInner() {
const { t } = useTranslation();
const { activeTab, setActiveTab } = useSettingsTab();
const {
rawConfig,
@@ -178,12 +185,12 @@ function SettingsPageInner() {
{copied ? (
<>
<Check className="w-4 h-4 mr-1" />
Copied
{t('settings.copied')}
</>
) : (
<>
<Copy className="w-4 h-4 mr-1" />
Copy
{t('settings.copy')}
</>
)}
</Button>
@@ -203,7 +210,7 @@ function SettingsPageInner() {
{rawConfigLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
Loading...
{t('settings.loading')}
</div>
) : rawConfig ? (
<CodeEditor
@@ -218,7 +225,7 @@ function SettingsPageInner() {
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<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">
ccs migrate
</code>
+29 -33
View File
@@ -23,6 +23,7 @@ import {
Save,
} from 'lucide-react';
import { useRawConfig } from '../hooks';
import { useTranslation } from 'react-i18next';
interface TokenInfo {
value: string;
@@ -35,6 +36,7 @@ interface AuthTokens {
}
export default function AuthSection() {
const { t } = useTranslation();
const { fetchRawConfig } = useRawConfig();
// State
@@ -60,16 +62,16 @@ export default function AuthSection() {
// Use /raw to get unmasked values for editing
const response = await fetch('/api/settings/auth/tokens/raw');
if (!response.ok) {
throw new Error('Failed to fetch auth tokens');
throw new Error(t('settingsAuth.failedFetchTokens'));
}
const data = await response.json();
setTokens(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
// Load on mount
useEffect(() => {
@@ -117,16 +119,16 @@ export default function AuthSection() {
if (!response.ok) {
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);
setEditedSecret(null);
await fetchTokens();
await fetchRawConfig();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally {
setSaving(false);
}
@@ -143,14 +145,14 @@ export default function AuthSection() {
if (!response.ok) {
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 fetchRawConfig();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally {
setSaving(false);
}
@@ -167,16 +169,16 @@ export default function AuthSection() {
if (!response.ok) {
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);
setEditedSecret(null);
await fetchTokens();
await fetchRawConfig();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setError(err instanceof Error ? err.message : t('settings.unknownError'));
} finally {
setSaving(false);
}
@@ -209,7 +211,7 @@ export default function AuthSection() {
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
<span>{t('settings.loading')}</span>
</div>
</div>
);
@@ -251,31 +253,27 @@ export default function AuthSection() {
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
Configure CLIProxy authentication tokens. Changes require CLIProxy restart.
</p>
<p className="text-sm text-muted-foreground">{t('settingsAuth.description')}</p>
{/* API Key Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<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 && (
<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>
)}
</div>
<p className="text-xs text-muted-foreground">
Used by Claude Code to authenticate with CLIProxy
</p>
<p className="text-xs text-muted-foreground">{t('settingsAuth.apiKeyDesc')}</p>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showApiKey ? 'text' : 'password'}
value={displayApiKey}
onChange={(e) => setEditedApiKey(e.target.value)}
placeholder="API key"
placeholder={t('settingsAuth.apiKeyPlaceholder')}
disabled={saving}
className="pr-20 font-mono text-sm"
/>
@@ -310,15 +308,15 @@ export default function AuthSection() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<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 && (
<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>
)}
</div>
<p className="text-xs text-muted-foreground">
Used by CCS dashboard to access CLIProxy management APIs
{t('settingsAuth.managementSecretDesc')}
</p>
<div className="flex gap-2">
<div className="relative flex-1">
@@ -326,7 +324,7 @@ export default function AuthSection() {
type={showSecret ? 'text' : 'password'}
value={displaySecret}
onChange={(e) => setEditedSecret(e.target.value)}
placeholder="Management secret"
placeholder={t('settingsAuth.managementSecretPlaceholder')}
disabled={saving}
className="pr-20 font-mono text-sm"
/>
@@ -359,7 +357,7 @@ export default function AuthSection() {
size="sm"
onClick={regenerateSecret}
disabled={saving}
title="Generate new secure secret"
title={t('settingsAuth.generateSecret')}
>
<Sparkles className="w-4 h-4" />
</Button>
@@ -375,11 +373,9 @@ export default function AuthSection() {
className="gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset to Defaults
{t('settingsAuth.resetDefaults')}
</Button>
<p className="text-xs text-muted-foreground mt-2">
Resets both API key and management secret to their default values.
</p>
<p className="text-xs text-muted-foreground mt-2">{t('settingsAuth.resetDesc')}</p>
</div>
</div>
</ScrollArea>
@@ -394,7 +390,7 @@ export default function AuthSection() {
className="flex-1"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
{t('settings.refresh')}
</Button>
<Button
variant="default"
@@ -404,7 +400,7 @@ export default function AuthSection() {
className="flex-1"
>
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save'}
{saving ? t('settingsAuth.saving') : t('settingsAuth.save')}
</Button>
</div>
</>
@@ -22,6 +22,7 @@ import {
} from '@/components/ui/alert-dialog';
import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react';
import { useRawConfig } from '../hooks';
import { useTranslation } from 'react-i18next';
/** Duration in ms before success toast auto-dismisses */
const SUCCESS_DISPLAY_DURATION_MS = 3000;
@@ -39,6 +40,7 @@ interface BackupsResponse {
}
export default function BackupsSection() {
const { t } = useTranslation();
const { fetchRawConfig } = useRawConfig();
// AbortController refs for cleanup
@@ -66,18 +68,18 @@ export default function BackupsSection() {
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error('Failed to fetch backups');
throw new Error(t('settingsBackups.failedFetch'));
}
const data: BackupsResponse = await response.json();
setBackups(data.backups || []);
} catch (err) {
// Ignore abort errors
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 {
setLoading(false);
}
}, []);
}, [t]);
// Restore backup (wrapped in useCallback for callback stability)
const restoreBackup = useCallback(
@@ -98,21 +100,21 @@ export default function BackupsSection() {
if (!response.ok) {
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 fetchRawConfig();
} catch (err) {
// Ignore abort errors
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 {
setRestoring(null);
}
},
[fetchBackups, fetchRawConfig]
[fetchBackups, fetchRawConfig, t]
);
// Load on mount
@@ -205,12 +207,9 @@ export default function BackupsSection() {
<div>
<div className="flex items-center gap-2 mb-2">
<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>
<p className="text-sm text-muted-foreground">
Restore previous versions of your settings.json file. Backups are created
automatically when settings are modified.
</p>
<p className="text-sm text-muted-foreground">{t('settingsBackups.description')}</p>
</div>
{/* Backups List */}
@@ -218,9 +217,9 @@ export default function BackupsSection() {
<Card className="p-8">
<div className="text-center">
<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">
Backups will appear here when you modify settings
{t('settingsBackups.noneHint')}
</p>
</div>
</Card>
@@ -236,7 +235,7 @@ export default function BackupsSection() {
<p className="text-sm font-medium font-mono">{backup.timestamp}</p>
{index === 0 && (
<Badge variant="secondary" className="text-xs">
Latest
{t('settingsBackups.latest')}
</Badge>
)}
</div>
@@ -253,7 +252,9 @@ export default function BackupsSection() {
<RotateCcw
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>
</div>
</Card>
@@ -276,7 +277,7 @@ export default function BackupsSection() {
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
{t('settings.refresh')}
</Button>
</div>
@@ -284,17 +285,17 @@ export default function BackupsSection() {
<AlertDialog open={!!confirmRestore} onOpenChange={() => setConfirmRestore(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore Backup?</AlertDialogTitle>
<AlertDialogTitle>{t('settingsBackups.restoreConfirmTitle')}</AlertDialogTitle>
<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">
{confirmRestore}
</code>
. This action cannot be undone.
. {t('settingsBackups.restoreConfirmSuffix')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('settingsBackups.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmRestore) {
@@ -303,7 +304,7 @@ export default function BackupsSection() {
setConfirmRestore(null);
}}
>
Restore
{t('settingsBackups.restore')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -11,8 +11,10 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { useGlobalEnvConfig, useRawConfig } from '../hooks';
import { useTranslation } from 'react-i18next';
export default function GlobalEnvSection() {
const { t } = useTranslation();
const {
config,
loading,
@@ -46,7 +48,7 @@ export default function GlobalEnvSection() {
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
<span>{t('settings.loading')}</span>
</div>
</div>
);
@@ -71,7 +73,7 @@ export default function GlobalEnvSection() {
{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">
<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>
@@ -79,21 +81,18 @@ export default function GlobalEnvSection() {
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
Environment variables injected into all non-Claude subscription profiles (gemini, codex,
agy, ghcp, etc.)
</p>
<p className="text-sm text-muted-foreground">{t('settingsGlobalEnv.description')}</p>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{config?.enabled ? 'Global Env enabled' : 'Global Env disabled'}
{config?.enabled ? t('settingsGlobalEnv.enabled') : t('settingsGlobalEnv.disabled')}
</p>
<p className="text-sm text-muted-foreground">
{config?.enabled
? 'Env vars will be injected into third-party profiles'
: 'Env vars will not be injected'}
? t('settingsGlobalEnv.enabledDesc')
: t('settingsGlobalEnv.disabledDesc')}
</p>
</div>
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
@@ -101,7 +100,7 @@ export default function GlobalEnvSection() {
{/* Current Environment Variables */}
<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 ? (
<div className="space-y-2">
@@ -127,18 +126,18 @@ export default function GlobalEnvSection() {
</div>
) : (
<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>
)}
{/* Add New Variable */}
<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">
<Input
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder="KEY_NAME"
placeholder={t('settingsGlobalEnv.keyName')}
className="flex-1 font-mono text-sm h-9"
disabled={saving}
/>
@@ -146,7 +145,7 @@ export default function GlobalEnvSection() {
<Input
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
placeholder="value"
placeholder={t('settingsGlobalEnv.value')}
className="flex-1 font-mono text-sm h-9"
disabled={saving}
/>
@@ -157,14 +156,14 @@ export default function GlobalEnvSection() {
className="h-9"
>
<Plus className="w-4 h-4 mr-1" />
Add
{t('settingsGlobalEnv.add')}
</Button>
</div>
</div>
{/* Common Variables Quick Add */}
<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">
{[
{ key: 'DISABLE_BUG_COMMAND', value: '1' },
@@ -192,7 +191,7 @@ export default function GlobalEnvSection() {
(k) => config.env[k]
) && (
<span className="text-sm text-muted-foreground">
All common variables are configured
{t('settingsGlobalEnv.allConfigured')}
</span>
)}
</div>
@@ -214,7 +213,7 @@ export default function GlobalEnvSection() {
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
{t('settings.refresh')}
</Button>
</div>
</>
+73 -70
View File
@@ -30,6 +30,7 @@ import { api } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
/** LocalStorage key for debug mode preference */
const DEBUG_MODE_KEY = 'ccs_debug_mode';
@@ -42,6 +43,7 @@ function normalizeRiskAckPhrase(value: string): string {
}
export default function ProxySection() {
const { t } = useTranslation();
const {
config,
loading,
@@ -98,17 +100,17 @@ export default function ProxySection() {
setAgyAckBypassLoading(true);
const response = await fetch('/api/settings/auth/antigravity-risk');
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 };
setAgyAckBypass(data.antigravityAckBypass === true);
} 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);
} finally {
setAgyAckBypassLoading(false);
}
}, []);
}, [t]);
const persistAgyAckBypass = useCallback(
async (nextValue: boolean) => {
@@ -130,7 +132,7 @@ export default function ProxySection() {
};
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;
@@ -139,29 +141,29 @@ export default function ProxySection() {
cache: 'no-store',
});
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 verifiedValue = verifyData.antigravityAckBypass === true;
if (verifiedValue !== nextValue) {
throw new Error(
'AGY power user mode was not persisted. Config may have been modified by another process.'
);
throw new Error(t('settingsProxy.notPersistedAgyMode'));
}
setAgyAckBypass(verifiedValue && persistedValue);
setShowAgyEnableConfirm(false);
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();
} 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 {
agyAckBypassSavingRef.current = false;
setAgyAckBypassSaving(false);
}
},
[agyAckBypassSaving, fetchRawConfig, saving]
[agyAckBypassSaving, fetchRawConfig, saving, t]
);
const handleAgyAckBypassChange = useCallback(
@@ -182,11 +184,11 @@ export default function ProxySection() {
const confirmAgyEnable = useCallback(() => {
if (!isAgyConfirmPhraseValid) {
toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`);
toast.error(t('settingsProxy.typePhraseToContinue', { value: RISK_ACK_PHRASE }));
return;
}
void persistAgyAckBypass(true);
}, [isAgyConfirmPhraseValid, persistAgyAckBypass]);
}, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
// Backend state (loaded from API) + mutation hook for proper query invalidation
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 items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
<span>{t('settings.loading')}</span>
</div>
</div>
);
@@ -308,7 +310,7 @@ export default function ProxySection() {
const parsedPort = Number(portStr);
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);
return;
}
@@ -340,7 +342,7 @@ export default function ProxySection() {
const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr);
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);
return;
}
@@ -379,7 +381,7 @@ export default function ProxySection() {
{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">
<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>
@@ -388,21 +390,23 @@ export default function ProxySection() {
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
Configure local or remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} connection
for proxy-based profiles
{t('settingsProxy.description', {
backend:
backend === 'plus' ? t('settingsProxy.backendPlus') : t('settingsProxy.backend'),
})}
</p>
{/* Proxy Status Widget - Quick access to start/stop controls */}
{!isRemoteMode && (
<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 />
</div>
)}
{/* Mode Toggle - Card based selection */}
<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">
{/* Local Mode Card */}
<button
@@ -418,10 +422,15 @@ export default function ProxySection() {
<Laptop
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>
<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>
</button>
@@ -439,10 +448,15 @@ export default function ProxySection() {
<Cloud
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>
<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>
</button>
</div>
@@ -452,14 +466,14 @@ export default function ProxySection() {
<div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2">
<Box className="w-4 h-4" />
Backend Binary
{t('settingsProxy.backendBinary')}
</h3>
{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */}
{!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">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400">
Stop the running proxy in Instance Status to switch backend.
{t('settingsProxy.stopProxyToSwitch')}
</AlertDescription>
</Alert>
)}
@@ -475,14 +489,12 @@ export default function ProxySection() {
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<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">
Default
{t('settingsProxy.default')}
</span>
</div>
<p className="text-xs text-muted-foreground">
Full provider support including Kiro and GitHub Copilot
</p>
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
</button>
{/* Original Backend Card */}
@@ -496,21 +508,16 @@ export default function ProxySection() {
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPI</span>
<span className="font-medium">{t('settingsProxy.backendApi')}</span>
</div>
<p className="text-xs text-muted-foreground">
Original binary (Gemini, Codex, Antigravity only)
</p>
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
</button>
</div>
{/* Warning when original backend selected with Kiro/ghcp variants */}
{backend === 'original' && hasKiroGhcpVariants && (
<Alert variant="destructive" className="py-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to
CLIProxyAPIPlus or remove those variants.
</AlertDescription>
<AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
</Alert>
)}
</div>
@@ -519,15 +526,13 @@ export default function ProxySection() {
<div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" />
Safety
{t('settingsProxy.safety')}
</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="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="font-medium text-sm">Antigravity Power User Mode</p>
<p className="text-xs text-muted-foreground">
Skip AGY responsibility checklist in Add Account and `ccs agy` flows.
</p>
<p className="font-medium text-sm">{t('settingsProxy.agyModeTitle')}</p>
<p className="text-xs text-muted-foreground">{t('settingsProxy.agyModeDesc')}</p>
</div>
<Switch
aria-labelledby="agy-power-user-mode-label"
@@ -541,24 +546,22 @@ export default function ProxySection() {
id="agy-power-user-mode-description"
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
cannot assume responsibility for account loss.
{t('settingsProxy.agyWarning')}
</p>
{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-1.5">
<p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200">
Final confirmation required
{t('settingsProxy.finalConfirm')}
</p>
<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.
Review issue #509 and type the exact phrase to proceed.
{t('settingsProxy.finalConfirmDesc')}
</p>
</div>
<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">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 1
{t('settingsProxy.step1')}
</p>
<a
href="https://github.com/kaitranntt/ccs/issues/509"
@@ -566,20 +569,20 @@ export default function ProxySection() {
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"
>
Read issue #509
{t('settingsProxy.readIssue')}
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<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">
Step 2
{t('settingsProxy.step2')}
</p>
<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">
{RISK_ACK_PHRASE}
</code>{' '}
to enable.
{t('settingsProxy.typeSuffix')}
</p>
</div>
</div>
@@ -590,10 +593,10 @@ export default function ProxySection() {
placeholder={RISK_ACK_PHRASE}
disabled={agyAckBypassSaving || saving}
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">
Exact phrase required.
{t('settingsProxy.exactPhrase')}
</p>
</div>
<div className="flex items-center justify-end gap-2">
@@ -606,7 +609,7 @@ export default function ProxySection() {
}}
disabled={agyAckBypassSaving || saving}
>
Cancel
{t('settingsBackups.cancel')}
</Button>
<Button
size="sm"
@@ -614,13 +617,13 @@ export default function ProxySection() {
onClick={confirmAgyEnable}
disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving}
>
Enable Power User Mode
{t('settingsProxy.enableAgyMode')}
</Button>
</div>
</div>
)}
<span id="agy-power-user-mode-label" className="sr-only">
Toggle AGY power user mode
{t('settingsProxy.toggleAgyMode')}
</span>
</div>
</div>
@@ -651,14 +654,14 @@ export default function ProxySection() {
{/* Fallback Settings */}
<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">
{/* Enable Fallback */}
<div className="flex items-center justify-between">
<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">
Use local proxy if remote is unreachable
{t('settingsProxy.enableFallbackDesc')}
</p>
</div>
<Switch
@@ -673,9 +676,9 @@ export default function ProxySection() {
{/* Auto-start on fallback */}
<div className="flex items-center justify-between">
<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">
Automatically start local proxy on fallback
{t('settingsProxy.autoStartLocalDesc')}
</p>
</div>
<Switch
@@ -693,15 +696,15 @@ export default function ProxySection() {
<div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2">
<Bug className="w-4 h-4" />
Advanced
{t('settingsProxy.advanced')}
</h3>
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* Debug Mode Toggle */}
<div className="flex items-center justify-between">
<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">
Enable developer diagnostics in browser console
{t('settingsProxy.debugModeDesc')}
</p>
</div>
<Switch
@@ -712,7 +715,7 @@ export default function ProxySection() {
</div>
{debugMode && (
<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>
)}
</div>
@@ -748,7 +751,7 @@ export default function ProxySection() {
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
{t('settings.refresh')}
</Button>
</div>
</>
@@ -7,6 +7,7 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import type { CliproxyServerConfig } from '../../types';
import { useTranslation } from 'react-i18next';
interface LocalProxyCardProps {
config: CliproxyServerConfig;
@@ -25,15 +26,16 @@ export function LocalProxyCard({
onSaveLocalPort,
onSaveConfig,
}: LocalProxyCardProps) {
const { t } = useTranslation();
const localConfig = config.local;
return (
<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">
{/* Port */}
<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
type="text"
inputMode="numeric"
@@ -49,10 +51,8 @@ export function LocalProxyCard({
{/* Auto-start */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Auto-start</p>
<p className="text-xs text-muted-foreground">
Start local proxy automatically when needed
</p>
<p className="font-medium text-sm">{t('settingsProxy.autoStart')}</p>
<p className="text-xs text-muted-foreground">{t('settingsProxy.localAutoStartDesc')}</p>
</div>
<Switch
checked={localConfig.auto_start ?? true}
@@ -15,6 +15,7 @@ import {
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
import { useTranslation } from 'react-i18next';
interface RemoteProxyCardProps {
config: CliproxyServerConfig;
@@ -57,6 +58,7 @@ export function RemoteProxyCard({
onSaveConfig,
onTestConnection,
}: RemoteProxyCardProps) {
const { t } = useTranslation();
const remoteConfig = config.remote;
// 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">
<h4 className="text-sm font-medium flex items-center gap-2">
<Cloud className="w-4 h-4" />
Remote Server Configuration
{t('settingsProxy.remoteConfigTitle')}
</h4>
{/* Host */}
<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
value={displayHost}
onChange={(e) => setEditedHost(e.target.value)}
onBlur={onSaveHost}
placeholder="192.168.1.100 or proxy.example.com"
placeholder={t('settingsProxy.hostPlaceholder')}
className="font-mono"
disabled={saving}
/>
@@ -87,9 +89,13 @@ export function RemoteProxyCard({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-sm text-muted-foreground">
Port{' '}
{t('settingsProxy.port')}{' '}
<span className="text-xs opacity-70">
(default: {getDefaultPort(config.remote.protocol || 'http')})
(
{t('settingsProxy.defaultPort', {
value: getDefaultPort(config.remote.protocol || 'http'),
})}
)
</span>
</label>
<Input
@@ -98,13 +104,15 @@ export function RemoteProxyCard({
value={displayPort}
onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))}
onBlur={onSavePort}
placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`}
placeholder={t('settingsProxy.portPlaceholder', {
value: getDefaultPort(config.remote.protocol || 'http'),
})}
className="font-mono"
disabled={saving}
/>
</div>
<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
value={config.remote.protocol || 'http'}
onValueChange={(value: 'http' | 'https') =>
@@ -116,8 +124,8 @@ export function RemoteProxyCard({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
<SelectItem value="http">{t('settingsProxy.http')}</SelectItem>
<SelectItem value="https">{t('settingsProxy.https')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -125,36 +133,34 @@ export function RemoteProxyCard({
{/* Auth Token (API Key) */}
<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
type="password"
value={displayAuthToken}
onChange={(e) => setEditedAuthToken(e.target.value)}
onBlur={onSaveAuthToken}
placeholder="For /v1/* API endpoints"
placeholder={t('settingsProxy.apiKeyPlaceholder')}
className="font-mono"
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Used for API requests to /v1/chat/completions
</p>
<p className="text-xs text-muted-foreground">{t('settingsProxy.apiKeyDesc')}</p>
</div>
{/* Management Key */}
<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
type="password"
value={displayManagementKey}
onChange={(e) => setEditedManagementKey(e.target.value)}
onBlur={onSaveManagementKey}
placeholder="For /v0/management/* endpoints"
placeholder={t('settingsProxy.managementKeyPlaceholder')}
className="font-mono"
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Used for dashboard management APIs. Falls back to API Key if not set.
</p>
<p className="text-xs text-muted-foreground">{t('settingsProxy.managementKeyDesc')}</p>
</div>
{/* Test Connection */}
@@ -168,12 +174,12 @@ export function RemoteProxyCard({
{testing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Testing...
{t('settingsProxy.testing')}
</>
) : (
<>
<Wifi className="w-4 h-4 mr-2" />
Test Connection
{t('settingsProxy.testConnection')}
</>
)}
</Button>
@@ -192,14 +198,14 @@ export function RemoteProxyCard({
<>
<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">
Connected ({testResult.latencyMs}ms)
{t('settingsProxy.connectedLatency', { value: testResult.latencyMs })}
</span>
</>
) : (
<>
<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">
{testResult.error || 'Connection failed'}
{testResult.error || t('settingsProxy.connectionFailed')}
</span>
</>
)}
@@ -20,6 +20,7 @@ import {
import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react';
import { useThinkingConfig } from '../../hooks';
import type { ThinkingMode } from '../../types';
import { useTranslation } from 'react-i18next';
const THINKING_LEVELS = [
{ value: 'minimal', label: 'Minimal (512 tokens)' },
@@ -42,6 +43,7 @@ const THINKING_BUDGET_MIN = 0;
const THINKING_BUDGET_MAX = 100000;
export default function ThinkingSection() {
const { t } = useTranslation();
const {
config,
loading,
@@ -125,7 +127,7 @@ export default function ThinkingSection() {
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
<span>{t('settings.loading')}</span>
</div>
</div>
);
@@ -156,7 +158,7 @@ export default function ThinkingSection() {
className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10"
>
<RefreshCw className="w-3 h-3 mr-1" />
Retry
{t('sharedPage.retry')}
</Button>
</div>
</Alert>
@@ -164,7 +166,7 @@ export default function ThinkingSection() {
{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">
<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>
@@ -174,27 +176,28 @@ export default function ThinkingSection() {
<div className="p-5 space-y-6">
<div className="flex items-center gap-3">
<Brain className="w-5 h-5 text-primary" />
<p className="text-sm text-muted-foreground">
Configure extended thinking/reasoning for supported models.
</p>
<p className="text-sm text-muted-foreground">{t('settingsThinking.description')}</p>
</div>
{/* 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">
<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">
<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">
<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>
Reasoning effort: <strong>codex</strong> (suffix or <code>--effort</code>:
medium/high/xhigh)
{t('settingsThinking.supportLine2Prefix')} <strong>codex</strong>{' '}
{t('settingsThinking.supportLine2SuffixPrefix')}
<code>--effort</code>
{t('settingsThinking.supportLine2SuffixPostfix')}
</li>
<li>
Codex suffixes pin effort (for example <code>-high</code>); unsuffixed models use
Thinking mode.
{t('settingsThinking.supportLine3Prefix')} <code>-high</code>
{t('settingsThinking.supportLine3Suffix')}
</li>
</ul>
</div>
@@ -202,7 +205,7 @@ export default function ThinkingSection() {
{/* Mode Selection */}
<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">
{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (
<div
@@ -221,10 +224,9 @@ export default function ThinkingSection() {
<div>
<p className="font-medium capitalize">{mode}</p>
<p className="text-sm text-muted-foreground">
{mode === 'auto' && 'Automatically set thinking based on model tier'}
{mode === 'off' && 'Disable extended thinking'}
{mode === 'manual' &&
'Set a persistent override level or use CLI flags per run'}
{mode === 'auto' && t('settingsThinking.modeAutoDesc')}
{mode === 'off' && t('settingsThinking.modeOffDesc')}
{mode === 'manual' && t('settingsThinking.modeManualDesc')}
</p>
</div>
<div
@@ -242,9 +244,9 @@ export default function ThinkingSection() {
{/* Tier Defaults (only shown when mode is auto) */}
{config.mode === 'auto' && (
<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">
Default thinking level for each model tier when in auto mode.
{t('settingsThinking.tierDefaultsDesc')}
</p>
<div className="space-y-3">
@@ -276,9 +278,9 @@ export default function ThinkingSection() {
{/* Manual Override (shown when mode is manual) */}
{config.mode === 'manual' && (
<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">
Applied to all sessions. CLI flags still take priority.
{t('settingsThinking.persistentOverrideDesc')}
</p>
<Select
value={overrideSelectValue}
@@ -320,7 +322,7 @@ export default function ThinkingSection() {
onChange={(event) => setCustomOverrideBudgetInput(event.target.value)}
onBlur={handleApplyCustomBudget}
disabled={saving}
placeholder="Enter custom budget"
placeholder={t('settingsThinking.enterCustomBudget')}
/>
<Button
type="button"
@@ -329,7 +331,7 @@ export default function ThinkingSection() {
onClick={handleApplyCustomBudget}
disabled={saving}
>
Apply
{t('settingsThinking.apply')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
@@ -353,19 +355,21 @@ export default function ThinkingSection() {
<ChevronDown
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>
{providerOverridesOpen && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Override tier defaults for specific providers. Add custom provider keys as needed.
{t('settingsThinking.providerOverridesDesc')}
</p>
<div className="flex items-center gap-2">
<Input
value={customProviderInput}
onChange={(event) => setCustomProviderInput(event.target.value)}
disabled={saving}
placeholder="Add provider key (e.g. qwen)"
placeholder={t('settingsThinking.addProviderPlaceholder')}
/>
<Button
type="button"
@@ -374,7 +378,7 @@ export default function ThinkingSection() {
onClick={handleAddProvider}
disabled={saving}
>
Add
{t('settingsGlobalEnv.add')}
</Button>
</div>
{providerKeys.map((provider) => (
@@ -404,7 +408,9 @@ export default function ThinkingSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default</SelectItem>
<SelectItem value="__default__">
{t('cursorPage.default')}
</SelectItem>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
@@ -425,9 +431,9 @@ export default function ThinkingSection() {
{/* Show Warnings Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">Show Warnings</p>
<p className="font-medium">{t('settingsThinking.showWarnings')}</p>
<p className="text-sm text-muted-foreground">
Display warnings when thinking values are clamped or adjusted
{t('settingsThinking.showWarningsDesc')}
</p>
</div>
<Switch
@@ -439,7 +445,7 @@ export default function ThinkingSection() {
{/* Info Box */}
<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">
Override per session with flags or{' '}
<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"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
{t('settings.refresh')}
</Button>
</div>
</>
@@ -10,8 +10,10 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
import { useWebSearchConfig, useRawConfig } from '../../hooks';
import { ProviderCard } from './provider-card';
import { useTranslation } from 'react-i18next';
export default function WebSearchSection() {
const { t } = useTranslation();
const {
config,
status,
@@ -83,7 +85,7 @@ export default function WebSearchSection() {
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
<span>{t('settings.loading')}</span>
</div>
</div>
);
@@ -108,7 +110,7 @@ export default function WebSearchSection() {
{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">
<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>
@@ -116,18 +118,16 @@ export default function WebSearchSection() {
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
CLI-based web search for third-party profiles (gemini, codex, agy, etc.)
</p>
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.description')}</p>
{/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
{isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}
</p>
{statusLoading ? (
<p className="text-sm text-muted-foreground">Checking status...</p>
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.checking')}</p>
) : status?.readiness ? (
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
) : null}
@@ -139,19 +139,21 @@ export default function WebSearchSection() {
{/* CLI Providers */}
<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 */}
{!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">
<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">
Install CLI tools to enable web search providers
{t('settingsWebsearch.noneConfiguredHint')}
</p>
<Button variant="outline" size="sm" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" />
Check for providers
{t('settingsWebsearch.checkProviders')}
</Button>
</div>
)}
@@ -233,7 +235,7 @@ export default function WebSearchSection() {
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
{t('settings.refresh')}
</Button>
</div>
</>
@@ -6,6 +6,7 @@
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface ProviderCardProps {
name: string;
@@ -50,6 +51,7 @@ export function ProviderCard({
docsUrl,
hintColor,
}: ProviderCardProps) {
const { t } = useTranslation();
const badgeClass =
badgeColor === 'green'
? 'bg-green-500/10 text-green-600'
@@ -98,11 +100,11 @@ export function ProviderCard({
</span>
{installed ? (
<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 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>
)}
</div>
@@ -116,7 +118,9 @@ export function ProviderCard({
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
<div className="px-4 pb-4 pt-0">
<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
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
@@ -128,7 +132,7 @@ export function ProviderCard({
{modelSaved && (
<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" />
Saved
{t('settings.saved')}
</span>
)}
</div>
@@ -143,13 +147,16 @@ export function ProviderCard({
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" />}
How to install {displayName} CLI
{t('settingsWebsearch.howInstall', { name: displayName })}
</button>
{showHint && (
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
<p className="mb-2">
Install globally{' '}
{badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}:
{t('settingsWebsearch.installGlobally')}{' '}
{badge === 'GROK_API_KEY'
? t('settingsWebsearch.requiresKey')
: t('settingsWebsearch.freeTier')}
:
</p>
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
{installCmd}
@@ -161,7 +168,7 @@ export function ProviderCard({
className="hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
{t('settingsWebsearch.viewDocs')}
</a>
</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 { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
import { cn } from '@/lib/utils';
import '@/lib/i18n';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
AlertTriangle,
@@ -21,14 +23,15 @@ import {
type TabType = 'commands' | 'skills' | 'agents';
const tabLabels: Record<TabType, string> = {
commands: 'Commands',
skills: 'Skills',
agents: 'Agents',
};
export function SharedPage() {
const { t } = useTranslation();
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 [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="space-y-3">
<div>
<h1 className="text-2xl font-bold">Shared Data</h1>
<p className="text-muted-foreground">
Commands, skills, and agents shared across Claude instances
</p>
<h1 className="text-2xl font-bold">{t('sharedPage.title')}</h1>
<p className="text-muted-foreground">{t('sharedPage.subtitle')}</p>
</div>
<Tabs
@@ -133,14 +134,18 @@ export function SharedPage() {
<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">
<HeaderMetricCard label="Total Shared" value={totalSharedItems} />
<HeaderMetricCard label={t('sharedPage.totalShared')} value={totalSharedItems} />
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
<HeaderMetricCard label="Visible" value={filteredItems.length} />
<HeaderMetricCard label={t('sharedPage.visible')} value={filteredItems.length} />
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary">Markdown detail view</Badge>
{activeQuery ? <Badge variant="outline">Filter: {activeQuery}</Badge> : null}
<Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
{activeQuery ? (
<Badge variant="outline">
{t('sharedPage.filterPrefix')} {activeQuery}
</Badge>
) : null}
</div>
</div>
</div>
@@ -148,7 +153,7 @@ export function SharedPage() {
{summary && !summary.symlinkStatus.valid && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle>
<AlertTitle>{t('sharedPage.configurationRequired')}</AlertTitle>
<AlertDescription>
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
</AlertDescription>
@@ -158,7 +163,7 @@ export function SharedPage() {
{isSummaryError && (
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Counts unavailable</AlertTitle>
<AlertTitle>{t('sharedPage.countsUnavailable')}</AlertTitle>
<AlertDescription>
<p>{summaryErrorMessage}</p>
<div className="mt-3">
@@ -170,7 +175,7 @@ export function SharedPage() {
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry counts
{t('sharedPage.retryCounts')}
</Button>
</div>
</AlertDescription>
@@ -200,30 +205,38 @@ export function SharedPage() {
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Filter ${tab} by name, description, or path`}
aria-label={`Filter ${tab} by name, description, or path`}
placeholder={t('sharedPage.filterPlaceholder', { tab })}
aria-label={t('sharedPage.filterPlaceholder', { tab })}
className="pl-8 h-9"
/>
</div>
{!isLoading && !isError && (
<p className="text-xs text-muted-foreground">
Showing {filteredItems.length} of {allItems.length} {tab}
{activeQuery ? ` for "${activeQuery}"` : ''}
{isFetching ? ' (refreshing...)' : ''}
{t('sharedPage.showing', {
visible: filteredItems.length,
total: allItems.length,
tab,
})}
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
{isFetching ? t('sharedPage.refreshing') : ''}
</p>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
{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 ? (
<div className="p-4 text-center">
<div className="space-y-3 py-8">
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
<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>
</div>
<Button
@@ -234,17 +247,17 @@ export function SharedPage() {
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
{t('sharedPage.retry')}
</Button>
</div>
</div>
) : hasNoItems ? (
<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>
) : hasNoMatches ? (
<div className="p-4 text-sm text-muted-foreground">
No {tab} match "{activeQuery}".
{t('sharedPage.noMatch', { tab, query: activeQuery })}
</div>
) : (
<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">
{!selectedItem ? (
<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>
) : (
<>
@@ -293,11 +306,15 @@ export function SharedPage() {
<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="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 !== selectedItem.path && (
<MetadataField
label="Resolved Source"
label={t('sharedPage.resolvedSource')}
value={selectedItemContent.contentPath}
mono
/>
@@ -310,12 +327,12 @@ export function SharedPage() {
<ScrollArea className="h-full px-5 py-4">
{isContentLoading ? (
<p className="text-sm text-muted-foreground">
Loading markdown content...
{t('sharedPage.loadingMarkdown')}
</p>
) : isContentError ? (
<Alert variant="destructive" className="max-w-2xl">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Failed to load content</AlertTitle>
<AlertTitle>{t('sharedPage.failedLoadContent')}</AlertTitle>
<AlertDescription>
<p>{contentErrorMessage}</p>
<div className="mt-3">
@@ -327,7 +344,7 @@ export function SharedPage() {
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry content
{t('sharedPage.retryContent')}
</Button>
</div>
</AlertDescription>
@@ -433,10 +450,11 @@ interface ParsedMarkdownDocument {
}
function MarkdownViewer({ content }: { content: string }) {
const { t } = useTranslation();
const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]);
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 (
+13 -15
View File
@@ -17,15 +17,10 @@ import {
writeNoticeProgressMap,
type NoticeProgressMap,
} from '@/lib/updates-notice-state';
import { useTranslation } from 'react-i18next';
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 {
if (!queryValue) {
return true;
@@ -49,6 +44,7 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean
}
export function UpdatesPage() {
const { t } = useTranslation();
const notices = useMemo(
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[]
@@ -112,20 +108,18 @@ export function UpdatesPage() {
<div className="space-y-1">
<div className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Updates Inbox</h1>
<h1 className="font-semibold">{t('updates.inboxTitle')}</h1>
</div>
<p className="text-xs text-muted-foreground">
Focus on actions, then mark updates done or dismissed.
</p>
<p className="text-xs text-muted-foreground">{t('updates.inboxSubtitle')}</p>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<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>
</div>
<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>
</div>
</div>
@@ -135,13 +129,17 @@ export function UpdatesPage() {
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search actions or commands"
placeholder={t('updates.searchPlaceholder')}
className="h-9 pl-8"
/>
</div>
<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
key={mode.id}
size="sm"
@@ -158,7 +156,7 @@ export function UpdatesPage() {
<div className="space-y-2 p-2">
{visibleNotices.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
No notices match this view.
{t('updates.noNotices')}
</div>
) : (
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();
});
});