mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
feat(i18n): Added support for Chinese language pack
This commit is contained in:
+12
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "{deleteTarget}"? 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'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 -> Shared -> 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]"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 & 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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user