mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
feat(ui): complete dashboard i18n coverage across 162 files
Replace all remaining hardcoded English strings with t() calls across the CCS dashboard UI. Add zh-CN, vi, ja translations for all new keys. - 162 files updated (components, hooks, pages, lib utilities) - 7 domains covered: layout/shared, accounts/auth, CLIProxy, compatible CLI (Codex/Cursor/Droid/Copilot), logs/monitoring, analytics, and lib utilities - 4 locales: en, zh-CN, vi, ja with natural (not literal) translations - Fix duplicate customPresetDialog key shadowing in en/zh-CN - Fix missing ja nav.logs and providerModelSelector keys - Fix openrouter-banner count type (string → number) - Fix add-account-dialog double setLocalError call - Fix codex-model-providers-card missing useTranslation Closes #983
This commit is contained in:
@@ -115,6 +115,7 @@ export function AccountSafetyWarningCard({
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{/* TODO i18n: missing key for "High Risk" badge */}
|
||||
High Risk
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -123,6 +124,7 @@ export function AccountSafetyWarningCard({
|
||||
<p>{firstLine}</p>
|
||||
<p className="font-medium text-amber-900 dark:text-amber-200">{secondLine}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key for disclaimer text */}
|
||||
CCS is provided as-is and does not take responsibility for suspension, bans, or access
|
||||
loss from upstream providers.
|
||||
</p>
|
||||
@@ -148,6 +150,7 @@ export function AccountSafetyWarningCard({
|
||||
</a>
|
||||
)}
|
||||
<span className="rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key for "Applies to CLI and dashboard auth" */}
|
||||
Applies to CLI and dashboard auth
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,6 +158,7 @@ export function AccountSafetyWarningCard({
|
||||
{showAcknowledgement && onAcknowledgementTextChange && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 p-2.5">
|
||||
<Label htmlFor="account-risk-ack-text" className="text-xs leading-5">
|
||||
{/* TODO i18n: missing key */}
|
||||
Type exact phrase to continue:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono">
|
||||
{acknowledgementPhrase}
|
||||
|
||||
@@ -294,6 +294,7 @@ export function AddAccountDialog({
|
||||
}
|
||||
if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) {
|
||||
setLocalError(
|
||||
// TODO i18n: missing key for AGY responsibility error
|
||||
'Complete all Antigravity responsibility steps before authenticating this provider.'
|
||||
);
|
||||
return;
|
||||
@@ -431,6 +432,7 @@ export function AddAccountDialog({
|
||||
<p className="text-xs text-muted-foreground">{kiroMethodOption.description}</p>
|
||||
{isKiroSocial && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key for Kiro social browser hint */}
|
||||
If your browser does not return automatically after login, CCS can accept the
|
||||
final
|
||||
<span className="mx-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">
|
||||
@@ -445,7 +447,9 @@ export function AddAccountDialog({
|
||||
{isKiroIdc && !showAuthUI && (
|
||||
<div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-idc-start-url">IDC Start URL</Label>
|
||||
<Label htmlFor="kiro-idc-start-url">
|
||||
{/* TODO i18n: missing key */}IDC Start URL
|
||||
</Label>
|
||||
<Input
|
||||
id="kiro-idc-start-url"
|
||||
value={kiroIDCStartUrl}
|
||||
@@ -457,12 +461,12 @@ export function AddAccountDialog({
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for organization IAM Identity Center login.
|
||||
{/* TODO i18n: missing key */}Required for organization IAM Identity Center login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-idc-region">IDC Region</Label>
|
||||
<Label htmlFor="kiro-idc-region">{/* TODO i18n: missing key */}IDC Region</Label>
|
||||
<Input
|
||||
id="kiro-idc-region"
|
||||
value={kiroIDCRegion}
|
||||
@@ -474,12 +478,13 @@ export function AddAccountDialog({
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Leave blank to use the upstream default region.
|
||||
{/* TODO i18n: missing key */}Optional. Leave blank to use the upstream default
|
||||
region.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-idc-flow">IDC Flow</Label>
|
||||
<Label htmlFor="kiro-idc-flow">{/* TODO i18n: missing key */}IDC Flow</Label>
|
||||
<Select
|
||||
value={kiroIDCFlow}
|
||||
onValueChange={(value) => {
|
||||
@@ -488,16 +493,20 @@ export function AddAccountDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="kiro-idc-flow">
|
||||
<SelectValue placeholder="Select IDC flow" />
|
||||
<SelectValue placeholder="{/* TODO i18n: missing key */}Select IDC flow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="authcode">Authorization Code</SelectItem>
|
||||
<SelectItem value="device">Device Code</SelectItem>
|
||||
<SelectItem value="authcode">
|
||||
{/* TODO i18n: missing key */}Authorization Code
|
||||
</SelectItem>
|
||||
<SelectItem value="device">
|
||||
{/* TODO i18n: missing key */}Device Code
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auth Code opens a browser and may need the final callback URL pasted back. Device
|
||||
Code shows a verification code instead.
|
||||
{/* TODO i18n: missing key */}Auth Code opens a browser and may need the final
|
||||
callback URL pasted back. Device Code shows a verification code instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,7 +549,8 @@ export function AddAccountDialog({
|
||||
{authFlow.isDeviceCodeFlow
|
||||
? t('addAccountDialog.deviceCodeHint')
|
||||
: isKiroSocial
|
||||
? 'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.'
|
||||
? // TODO i18n: missing key for Kiro social callback hint
|
||||
'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.'
|
||||
: t('addAccountDialog.browserHint')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -590,7 +600,8 @@ export function AddAccountDialog({
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="callback-url" className="text-xs">
|
||||
{isKiroSocial
|
||||
? 'Browser did not return? Paste the final kiro:// callback URL:'
|
||||
? // TODO i18n: missing key
|
||||
'Browser did not return? Paste the final kiro:// callback URL:'
|
||||
: t('addAccountDialog.redirectPasteLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -626,7 +637,8 @@ export function AddAccountDialog({
|
||||
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{isKiroSocial
|
||||
? 'Preparing the Kiro sign-in URL. If it does not open automatically, it will appear here shortly.'
|
||||
? // TODO i18n: missing key for Kiro social preparing URL
|
||||
'Preparing the Kiro sign-in URL. If it does not open automatically, it will appear here shortly.'
|
||||
: t('addAccountDialog.preparingUrl')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,7 @@ export function AntigravityResponsibilityChecklist({
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="agy-step-reviewed-issue" className="text-xs leading-5">
|
||||
{/* TODO i18n: missing key for step 1 */}
|
||||
Step 1: I reviewed issue #509 and understand AGY OAuth can trigger account
|
||||
bans/suspensions.
|
||||
</Label>
|
||||
@@ -91,6 +92,7 @@ export function AntigravityResponsibilityChecklist({
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="agy-step-understands-risk" className="text-xs leading-5">
|
||||
{/* TODO i18n: missing key for step 2 */}
|
||||
Step 2: I understand this OAuth action is my own decision and I accept the upstream
|
||||
risk.
|
||||
</Label>
|
||||
@@ -106,6 +108,7 @@ export function AntigravityResponsibilityChecklist({
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="agy-step-accept-responsibility" className="text-xs leading-5">
|
||||
{/* TODO i18n: missing key for step 3 */}
|
||||
Step 3: I accept full responsibility. CCS is not liable for suspension, bans, or
|
||||
access loss.
|
||||
</Label>
|
||||
@@ -115,6 +118,7 @@ export function AntigravityResponsibilityChecklist({
|
||||
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
<ShieldAlert className="h-3.5 w-3.5" />
|
||||
{/* TODO i18n: missing key for step 4 */}
|
||||
Step 4: Type exact phrase to continue
|
||||
</div>
|
||||
<Input
|
||||
@@ -133,7 +137,7 @@ export function AntigravityResponsibilityChecklist({
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 font-medium text-rose-800 transition-colors hover:bg-rose-500/15 dark:text-rose-200"
|
||||
>
|
||||
Read issue #509
|
||||
{/* TODO i18n: missing key */}Read issue #509
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AccountCardStatsProps {
|
||||
success: number;
|
||||
@@ -12,6 +13,7 @@ interface AccountCardStatsProps {
|
||||
}
|
||||
|
||||
export function AccountCardStats({ success, failure, showDetails }: AccountCardStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const total = success + failure;
|
||||
const successRate = total > 0 ? (success / total) * 100 : 100;
|
||||
|
||||
@@ -21,7 +23,7 @@ export function AccountCardStats({ success, failure, showDetails }: AccountCardS
|
||||
<div className="flex items-end justify-between px-0.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
Success Rate
|
||||
{t('authMonitorLive.successRate')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -37,6 +39,7 @@ export function AccountCardStats({ success, failure, showDetails }: AccountCardS
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
{/* TODO i18n: missing key for "Volume" */}
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
Volume
|
||||
</span>
|
||||
|
||||
@@ -173,7 +173,10 @@ export function AccountCard({
|
||||
<div
|
||||
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
|
||||
title={groupedHeaderVariants
|
||||
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
|
||||
.map(
|
||||
(variant) =>
|
||||
variant.audienceLabel ?? variant.detailLabel ?? t('accountSurfaceCard.variant')
|
||||
)
|
||||
.join(' • ')}
|
||||
>
|
||||
{groupedVariantSummaryLabel ? (
|
||||
@@ -204,6 +207,7 @@ export function AccountCard({
|
||||
</div>
|
||||
{account.paused && (
|
||||
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
|
||||
{/* TODO i18n: missing key for compact "Paused" */}
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -144,7 +144,9 @@ export function AccountQuotaPanel({
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
|
||||
<span>
|
||||
{mode === 'compact' ? t('accountCard.quotaLoading') : t('accountQuotaPanel.loadingQuota')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,20 +206,24 @@ export function AccountQuotaPanel({
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
{/* TODO i18n: missing key for "Active" */}Active ·{' '}
|
||||
{formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
{/* TODO i18n: missing key for "Last used" */}Last used{' '}
|
||||
{formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('accountCardStats.notUsedYet')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pause, Star, User } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AccountQuotaPanel } from './account-quota-panel';
|
||||
|
||||
@@ -53,9 +54,12 @@ function getTierBadgeClass(tier: AccountTier | undefined) {
|
||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
|
||||
}
|
||||
|
||||
function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unknown') {
|
||||
if (audience === 'business') return 'Biz';
|
||||
if (audience === 'personal') return 'Pers';
|
||||
function getCompactAudienceBadgeLabel(
|
||||
audience: 'business' | 'personal' | 'unknown',
|
||||
t: (key: string) => string
|
||||
) {
|
||||
if (audience === 'business') return t('accountSurfaceCard.business');
|
||||
if (audience === 'personal') return t('accountSurfaceCard.personal');
|
||||
return '?';
|
||||
}
|
||||
|
||||
@@ -95,6 +99,7 @@ export function AccountSurfaceCard({
|
||||
quotaInsetClassName,
|
||||
className,
|
||||
}: AccountSurfaceCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
const title = displayEmail || identity.email || accountId;
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
@@ -129,11 +134,12 @@ export function AccountSurfaceCard({
|
||||
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{getCompactAudienceBadgeLabel(identity.audience)}
|
||||
{getCompactAudienceBadgeLabel(identity.audience, t)}
|
||||
</span>
|
||||
)}
|
||||
{paused && (
|
||||
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
|
||||
{/* TODO i18n: missing key for compact "Paused" badge */}
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
@@ -205,6 +211,7 @@ export function AccountSurfaceCard({
|
||||
{!isCompact && isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
{/* TODO i18n: missing key for "Default" badge */}
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
@@ -214,6 +221,7 @@ export function AccountSurfaceCard({
|
||||
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
|
||||
>
|
||||
<Pause className="w-2 h-2 mr-0.5" />
|
||||
{/* TODO i18n: missing key for "Paused" badge */}
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Database, TrendingUp, Zap } from 'lucide-react';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CacheEfficiencyCardProps {
|
||||
data: UsageSummary | undefined;
|
||||
@@ -21,6 +22,7 @@ interface CacheEfficiencyCardProps {
|
||||
|
||||
export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (!data) return null;
|
||||
@@ -73,11 +75,14 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Cache Efficiency" */}
|
||||
Cache Efficiency
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground text-center">No cache data available</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('analyticsCards.noCacheData')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -88,6 +93,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Cache Efficiency" */}
|
||||
Cache Efficiency
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -101,6 +107,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
{/* TODO i18n: missing key for "Estimated Savings" */}
|
||||
Estimated Savings
|
||||
</p>
|
||||
</div>
|
||||
@@ -115,7 +122,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
{metrics.cacheHitRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Hit Rate</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{t('analyticsCards.hitRate')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cache Cost */}
|
||||
@@ -123,7 +132,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
<span className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${metrics.cacheCost.toFixed(2)}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Cache Cost</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{t('analyticsCards.cacheCost')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +148,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
>
|
||||
<span>Reads: {formatCompact(metrics.totalCacheReads)}</span>
|
||||
<span>Writes: {formatCompact(metrics.totalCacheWrites)}</span>
|
||||
{/* TODO i18n: missing keys for "Reads:" / "Writes:" */}
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div
|
||||
@@ -145,7 +157,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
backgroundColor: '#9e2a2b',
|
||||
width: `${(metrics.totalCacheReads / metrics.totalCacheTokens) * 100}%`,
|
||||
}}
|
||||
title={`Cache Reads: ${metrics.totalCacheReads.toLocaleString()}`}
|
||||
title={`Cache Reads: ${metrics.totalCacheReads.toLocaleString()}`} // TODO i18n: missing key
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
@@ -153,16 +165,18 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
backgroundColor: '#e09f3e',
|
||||
width: `${(metrics.totalCacheWrites / metrics.totalCacheTokens) * 100}%`,
|
||||
}}
|
||||
title={`Cache Writes: ${metrics.totalCacheWrites.toLocaleString()}`}
|
||||
title={`Cache Writes: ${metrics.totalCacheWrites.toLocaleString()}`} // TODO i18n: missing key
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#9e2a2b' }} />
|
||||
{/* TODO i18n: missing key for "Read" */}
|
||||
Read
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#e09f3e' }} />
|
||||
{/* TODO i18n: missing key for "Write" */}
|
||||
Write
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Server, Zap, Cpu, Coins } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CliproxyStatsCardProps {
|
||||
className?: string;
|
||||
@@ -27,6 +28,7 @@ export function CliproxyStatsCard({
|
||||
}: CliproxyStatsCardProps) {
|
||||
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isLoading = externalLoading || statusLoading || (status?.running && statsLoading);
|
||||
|
||||
@@ -36,6 +38,7 @@ export function CliproxyStatsCard({
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key for "CLIProxy Stats" */}
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -62,16 +65,17 @@ export function CliproxyStatsCard({
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
{/* TODO i18n: missing key for "CLIProxy Stats" */}
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
Offline
|
||||
{t('cliproxyStatsOverview.offline')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Start a CLIProxy session (gemini, codex, agy) to collect stats.
|
||||
{t('cliproxyStatsOverview.noActiveSessionHint')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -91,9 +95,11 @@ export function CliproxyStatsCard({
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key for "CLIProxy Stats" */}
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
{/* TODO i18n: missing key for "Error" */}
|
||||
Error
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -125,6 +131,7 @@ export function CliproxyStatsCard({
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key for "CLIProxy Stats" */}
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge
|
||||
@@ -132,7 +139,7 @@ export function CliproxyStatsCard({
|
||||
className="text-[10px] h-5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
<Zap className="h-3 w-3 mr-0.5" />
|
||||
Running
|
||||
{t('cliproxyStatsOverview.running')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -176,6 +183,7 @@ export function CliproxyStatsCard({
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{failedRequests > 0 ? `${failedRequests} failed` : 'All success'}
|
||||
{/* TODO i18n: missing keys for "failed" / "All success" */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +195,9 @@ export function CliproxyStatsCard({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-bold leading-none">{formatNumber(totalTokens)}</div>
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">Total tokens</div>
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{t('cliproxyStatsOverview.totalTokens')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +207,7 @@ export function CliproxyStatsCard({
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Cpu className="h-3 w-3" />
|
||||
Models Used
|
||||
{t('cliproxyStatsOverview.modelsUsed')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{models.map(([model, count]) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value?: DateRange;
|
||||
@@ -54,6 +55,7 @@ export function DateRangeFilter({
|
||||
className,
|
||||
}: DateRangeFilterProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Helper to check if a preset is currently selected
|
||||
const isPresetSelected = (presetRange: DateRange) => {
|
||||
@@ -101,7 +103,7 @@ export function DateRangeFilter({
|
||||
format(value.from, 'LLL dd, y')
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
<span>{t('dateRangeFilter.pickADate')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
import { cn, getModelColor } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ModelBreakdownChartProps {
|
||||
data: ModelUsage[];
|
||||
@@ -20,6 +21,7 @@ interface ModelBreakdownChartProps {
|
||||
|
||||
export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
@@ -40,7 +42,7 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={cn('h-full min-h-[100px] flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">No model data available</p>
|
||||
<p className="text-muted-foreground">{t('analyticsCards.noModelData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ModelDetailsContentProps {
|
||||
model: ModelUsage;
|
||||
@@ -10,6 +11,7 @@ interface ModelDetailsContentProps {
|
||||
|
||||
export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
|
||||
|
||||
return (
|
||||
@@ -38,22 +40,28 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
<p className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${model.cost.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Cost</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{t('analyticsCards.totalCost')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<p className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatCompactNumber(model.tokens)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Tokens</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{t('analyticsCards.totalTokens')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{/* TODO i18n: missing key for "Token Breakdown" */}
|
||||
Token Breakdown
|
||||
</h5>
|
||||
<div className={cn('space-y-1', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{/* TODO i18n: missing keys for Input/Output/Cache Write/Cache Read labels */}
|
||||
<TokenRow
|
||||
label="Input"
|
||||
tokens={model.inputTokens}
|
||||
@@ -89,7 +97,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
<div className="p-2.5 rounded-md border bg-muted/20 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Input/Output Ratio</span>
|
||||
<span className="text-xs font-medium">{t('analyticsCards.inputOutputRatio')}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{ioRatioStatus.description}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { getProjectDisplayName } from './project-name-utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SessionStatsCardProps {
|
||||
data: PaginatedSessions | undefined;
|
||||
@@ -23,6 +24,7 @@ interface SessionStatsCardProps {
|
||||
|
||||
export function SessionStatsCard({ data, isLoading, className }: SessionStatsCardProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!data?.sessions || data.sessions.length === 0) return null;
|
||||
@@ -72,11 +74,14 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Session Stats" */}
|
||||
Session Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground text-center">No session data available</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('analyticsCards.noSessionData')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -89,6 +94,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Session Stats" */}
|
||||
Session Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -102,6 +108,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<span className="text-xl font-bold">{stats.totalSessions}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
{/* TODO i18n: missing key for "Total Sessions" */}
|
||||
Total Sessions
|
||||
</p>
|
||||
</div>
|
||||
@@ -115,6 +122,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
{/* TODO i18n: missing key for "Avg Cost/Session" */}
|
||||
Avg Cost/Session
|
||||
</p>
|
||||
</div>
|
||||
@@ -124,6 +132,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<div className="flex-1 min-h-0 space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground font-medium mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{/* TODO i18n: missing key for "Recent Activity" */}
|
||||
Recent Activity
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-full overflow-y-auto pr-1">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { TokenBreakdown } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TokenBreakdownChartProps {
|
||||
data?: TokenBreakdown;
|
||||
@@ -36,6 +37,7 @@ const COLORS = {
|
||||
|
||||
export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
@@ -82,7 +84,7 @@ export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdo
|
||||
if (!data || chartData.every((d) => d.tokens === 0)) {
|
||||
return (
|
||||
<div className={cn('h-[250px] flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">No token data available</p>
|
||||
<p className="text-muted-foreground">{t('analyticsCards.noTokenData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
|
||||
import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface UsageInsightsCardProps {
|
||||
anomalies?: Anomaly[];
|
||||
@@ -53,6 +54,8 @@ export function UsageInsightsCard({
|
||||
isLoading,
|
||||
className,
|
||||
}: UsageInsightsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card
|
||||
@@ -90,7 +93,9 @@ export function UsageInsightsCard({
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3 ring-4 ring-green-50 dark:ring-green-900/10">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground text-sm">All Systems Nominal</h3>
|
||||
<h3 className="font-medium text-foreground text-sm">
|
||||
{t('healthCard.allSystemsNominal')}
|
||||
</h3>
|
||||
<p className="text-xs mt-1.5 max-w-[200px] leading-relaxed">
|
||||
Your usage patterns are within normal ranges for the selected period.
|
||||
</p>
|
||||
@@ -104,13 +109,15 @@ export function UsageInsightsCard({
|
||||
<div className="px-1 py-2 flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="font-semibold text-sm">Usage Insights</h3>
|
||||
<h3 className="font-semibold text-sm">{t('analyticsCards.usageInsights')}</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-2 text-[10px] font-medium border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-400"
|
||||
>
|
||||
{summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
|
||||
{summary.totalAnomalies}{' '}
|
||||
{/* TODO i18n: missing key for singular/plural "Alert"/"Alerts" */}{' '}
|
||||
{summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
||||
|
||||
type ChartData = DailyUsage | HourlyUsage;
|
||||
|
||||
@@ -38,6 +39,8 @@ export function UsageTrendChart({
|
||||
className,
|
||||
}: UsageTrendChartProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
// TODO i18n: uncomment when keys for "No usage data for today" / "No usage data available" are added
|
||||
// const { t } = useTranslation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
@@ -64,6 +67,7 @@ export function UsageTrendChart({
|
||||
return (
|
||||
<div className={cn('h-full flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">
|
||||
{/* TODO i18n: missing keys for "No usage data for today" / "No usage data available" */}
|
||||
{granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function UserMenu() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleLogout} className="gap-2 text-destructive">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
{/* TODO i18n: missing key for "Sign Out" */}Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -19,19 +19,19 @@ function getStatusState(status: AiProviderFamilyState['status']) {
|
||||
case 'ready':
|
||||
return {
|
||||
icon: Check,
|
||||
text: 'Ready',
|
||||
text: 'Ready', // TODO i18n: missing key
|
||||
className: 'text-green-600',
|
||||
};
|
||||
case 'partial':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: 'Needs attention',
|
||||
text: 'Needs attention', // TODO i18n: missing key
|
||||
className: 'text-amber-600',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Circle,
|
||||
text: 'Not configured',
|
||||
text: 'Not configured', // TODO i18n: missing key
|
||||
className: 'text-muted-foreground',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ function renderSecretBadge(entry: AiProviderEntryView) {
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* TODO i18n: missing keys for 'Configured' / 'Missing secret' */}
|
||||
{entry.secretConfigured ? 'Configured' : 'Missing secret'}
|
||||
</Badge>
|
||||
);
|
||||
@@ -47,6 +48,7 @@ export function ProviderEntryCard({
|
||||
isSelected = false,
|
||||
variant = 'detail',
|
||||
}: ProviderEntryCardProps) {
|
||||
// i18n: most strings in this file lack keys. See TODO comments below.
|
||||
const hasAdvancedRouting = entry.prefix || entry.proxyUrl || entry.excludedModels.length > 0;
|
||||
|
||||
if (variant === 'row') {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
AiProviderEntryView,
|
||||
AiProviderFamilyId,
|
||||
@@ -271,6 +272,7 @@ export function ProviderEntryDialog({
|
||||
onSubmit,
|
||||
isSaving,
|
||||
}: ProviderEntryDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const guide = useMemo(() => getDialogGuide(family), [family]);
|
||||
const isEditing = Boolean(entry);
|
||||
const supportsOpenAiCompat = family === 'openai-compatibility';
|
||||
@@ -382,7 +384,9 @@ export function ProviderEntryDialog({
|
||||
<div className="space-y-6 px-6 py-6">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Required setup</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{t('aiProvidersEntryDialog.requiredSetup')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Save the smallest working configuration first.
|
||||
</div>
|
||||
@@ -391,7 +395,9 @@ export function ProviderEntryDialog({
|
||||
{supportsOpenAiCompat ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="connector-name">Connector Name</Label>
|
||||
<Label htmlFor="connector-name">
|
||||
{t('aiProvidersEntryDialog.connectorName')}
|
||||
</Label>
|
||||
<Input
|
||||
id="connector-name"
|
||||
value={name}
|
||||
@@ -440,7 +446,9 @@ export function ProviderEntryDialog({
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Optional routing</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{t('aiProvidersEntryDialog.optionalRouting')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Only fill these when the route needs more than the default behavior.
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
|
||||
label: getProviderDisplayName(id),
|
||||
}));
|
||||
const AGY_DENYLIST_MESSAGE =
|
||||
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.';
|
||||
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; // TODO i18n: use t('providerEditor.agyDenylist')
|
||||
|
||||
function isDeniedAgyModelForProvider(provider: string, modelId: string | undefined): boolean {
|
||||
return provider === 'agy' && typeof modelId === 'string' && isDeniedAgyModelId(modelId);
|
||||
@@ -152,7 +152,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create CLIProxy Variant</DialogTitle>
|
||||
<DialogTitle>{t('providerEditor.createVariant')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -64,7 +64,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
|
||||
|
||||
const COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const;
|
||||
const AGY_DENYLIST_MESSAGE =
|
||||
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.';
|
||||
'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; // TODO i18n: use t('providerEditor.agyDenylist')
|
||||
|
||||
function normalizeOptionalValue(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface VersionInfo {
|
||||
currentVersion: string;
|
||||
@@ -117,6 +118,7 @@ export function CliproxyHeader({
|
||||
}: CliproxyHeaderProps) {
|
||||
const { data: authData } = useCliproxyAuth();
|
||||
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
|
||||
const { t } = useTranslation();
|
||||
const lastUpdatedText = useRelativeTime(lastUpdated);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
|
||||
|
||||
@@ -157,7 +159,9 @@ export function CliproxyHeader({
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{versionInfo?.backendLabel ?? 'CLIProxy'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">CCS-level account management</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('cliproxyHeader.ccsLevelAccountManagement')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Buttons - Wrap on mobile */}
|
||||
@@ -188,7 +192,7 @@ export function CliproxyHeader({
|
||||
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
{isRunning ? 'Running' : 'Offline'}
|
||||
{isRunning ? t('cliproxyStatsOverview.running') : t('cliproxyStatsOverview.offline')}
|
||||
</Badge>
|
||||
|
||||
{versionInfo && (
|
||||
|
||||
@@ -29,12 +29,14 @@ import { cn } from '@/lib/utils';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CliproxyStatsOverviewProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||
@@ -71,15 +73,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Session Statistics
|
||||
{t('cliproxyStatsOverview.sessionStatistics')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time usage metrics from {backendLabel}
|
||||
{t('cliproxyStatsOverview.realTimeMetrics', { backend: backendLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="w-fit gap-1.5">
|
||||
<ZapOff className="h-3.5 w-3.5" />
|
||||
Offline
|
||||
{t('cliproxyStatsOverview.offline')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -88,13 +90,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-medium mb-1">No Active Session</h3>
|
||||
<h3 className="font-medium mb-1">{t('cliproxyStatsOverview.noActiveSession')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Start a CLIProxy session using{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs gemini</code>,{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs codex</code>,
|
||||
or <code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs agy</code>{' '}
|
||||
to view real-time statistics.
|
||||
{t('cliproxyStatsOverview.noActiveSessionHint')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -110,7 +108,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<CardContent className="flex items-center gap-4 py-6">
|
||||
<XCircle className="h-8 w-8 text-destructive shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-medium">Failed to Load Statistics</h3>
|
||||
<h3 className="font-medium">{t('cliproxyStatsOverview.failedLoadStats')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -147,10 +145,10 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Session Statistics
|
||||
{t('cliproxyStatsOverview.sessionStatistics')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time usage metrics from {backendLabel}
|
||||
{t('cliproxyStatsOverview.realTimeMetrics', { backend: backendLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -158,7 +156,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
className="w-fit gap-1.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Running
|
||||
{t('cliproxyStatsOverview.running')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -169,11 +167,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Total Requests</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t('cliproxyStatsOverview.totalRequests')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(totalRequests)}</p>
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
<span className="text-muted-foreground">{successRequests} success</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('cliproxyStatsOverview.successCount', { count: successRequests })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/20">
|
||||
@@ -188,7 +190,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Success Rate</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t('cliproxyStatsOverview.successRate')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{successRate}%</p>
|
||||
<div className="h-1.5 mt-2 bg-muted/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -223,7 +227,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t('cliproxyStatsOverview.totalTokens')}
|
||||
</p>
|
||||
<p className={cn('text-2xl font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatNumber(totalTokens)}
|
||||
</p>
|
||||
@@ -233,7 +239,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
~${estimateCost(totalTokens).toFixed(2)} estimated
|
||||
{t('cliproxyStatsOverview.estimatedCost', {
|
||||
cost: estimateCost(totalTokens).toFixed(2),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/20">
|
||||
@@ -248,7 +256,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Models Used</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t('cliproxyStatsOverview.modelsUsed')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{models.length}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{models.length > 0 ? formatModelName(models[0][0]) : 'None'}
|
||||
@@ -268,7 +278,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
Model Usage Distribution
|
||||
{t('cliproxyStatsOverview.modelUsageDistribution')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
@@ -289,7 +299,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
|
||||
<span>{count} requests</span>
|
||||
<span>{t('cliproxyStatsOverview.requestCount', { count })}</span>
|
||||
<span className="text-xs font-medium w-10 text-right">{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useDeleteVariant } from '@/hooks/use-cliproxy';
|
||||
import { CliproxyEditDialog } from './cliproxy-edit-dialog';
|
||||
import type { Variant } from '@/lib/api-client';
|
||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CliproxyTableProps {
|
||||
data: Variant[];
|
||||
@@ -36,30 +37,35 @@ interface CliproxyTableProps {
|
||||
export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
const deleteMutation = useDeleteVariant();
|
||||
const [editingVariant, setEditingVariant] = useState<Variant | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: ColumnDef<Variant>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
header: t('cliproxyTable.name'),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'provider',
|
||||
header: 'Provider',
|
||||
header: t('cliproxyTable.provider'),
|
||||
cell: ({ row }) => {
|
||||
if (row.original.type === 'composite') {
|
||||
return <Badge variant="secondary">composite</Badge>;
|
||||
return <Badge variant="secondary">{t('providerEditor.composite')}</Badge>;
|
||||
}
|
||||
return getProviderDisplayName(row.original.provider);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'account',
|
||||
header: 'Account',
|
||||
header: t('cliproxyTable.account'),
|
||||
cell: ({ row }) => {
|
||||
const account = row.original.account;
|
||||
if (!account) {
|
||||
return <span className="text-muted-foreground text-xs italic">default</span>;
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
{t('providerEditor.defaultLabel')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
@@ -89,7 +95,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
header: t('cliproxyTable.actions'),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
@@ -102,6 +108,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
<DropdownMenuContent align="end" className="bg-white dark:bg-zinc-950">
|
||||
<DropdownMenuItem onClick={() => setEditingVariant(row.original)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
{/* TODO i18n: missing key for "Edit" */}
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -109,6 +116,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
onClick={() => deleteMutation.mutate(row.original.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{/* TODO i18n: missing key for "Delete" */}
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -128,7 +136,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/5 border-dashed">
|
||||
<div className="text-muted-foreground text-sm">No CLIProxy variants found.</div>
|
||||
<div className="text-muted-foreground text-sm">{t('cliproxyHeader.noVariants')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Create one to use OAuth-based providers with specific account configurations.
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { LayoutDashboard, FileCode, ScrollText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type CliproxyTabValue = 'overview' | 'config' | 'logs';
|
||||
|
||||
@@ -20,12 +21,13 @@ interface CliproxyTabsProps {
|
||||
}
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ value: 'overview' as const, label: 'Overview', icon: LayoutDashboard },
|
||||
{ value: 'config' as const, label: 'Config', icon: FileCode },
|
||||
{ value: 'logs' as const, label: 'Logs', icon: ScrollText },
|
||||
{ value: 'overview' as const, labelKey: 'cliproxyTabs.overview', icon: LayoutDashboard },
|
||||
{ value: 'config' as const, labelKey: 'Config', icon: FileCode },
|
||||
{ value: 'logs' as const, labelKey: 'Logs', icon: ScrollText },
|
||||
];
|
||||
|
||||
export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -33,10 +35,10 @@ export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsP
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-md">
|
||||
{TAB_CONFIG.map(({ value, label, icon: Icon }) => (
|
||||
{TAB_CONFIG.map(({ value, labelKey, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<span className="hidden sm:inline">{t(labelKey)}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
@@ -12,8 +12,10 @@ import { buildFileTree } from './file-tree-utils';
|
||||
import { YamlEditor, EditorStatusBar } from './yaml-editor';
|
||||
import { DiffDialog } from './diff-dialog';
|
||||
import { useCliproxyConfig, useCliproxyAuthFile } from '@/hooks/use-cliproxy-config';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ConfigSplitView() {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFile, setSelectedFile] = useState<string>('config.yaml');
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
@@ -82,7 +84,7 @@ export function ConfigSplitView() {
|
||||
<span className="font-medium text-sm">{selectedFile}</span>
|
||||
{isDirty && isEditingConfig && (
|
||||
<span className="text-xs text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
|
||||
Modified
|
||||
{t('cliproxyConfig.modified')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DiffDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,6 +31,7 @@ export function DiffDialog({
|
||||
onConfirmSave,
|
||||
isSaving,
|
||||
}: DiffDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const originalLines = original.split('\n');
|
||||
const modifiedLines = modified.split('\n');
|
||||
|
||||
@@ -63,13 +65,13 @@ export function DiffDialog({
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Changes</DialogTitle>
|
||||
<DialogTitle>{t('cliproxyConfig.reviewChanges')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2 text-xs font-medium bg-muted p-2 border-b">
|
||||
<div className="text-red-600">Original</div>
|
||||
<div className="text-green-600 border-l pl-2">Modified</div>
|
||||
<div className="text-red-600">{t('cliproxyConfig.original')}</div>
|
||||
<div className="text-green-600 border-l pl-2">{t('cliproxyConfig.modified')}</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="divide-y">{renderDiff()}</div>
|
||||
@@ -78,10 +80,13 @@ export function DiffDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{/* TODO i18n: missing key for "Cancel" in diff context */}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirmSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
{isSaving
|
||||
? /* TODO i18n: missing key */ 'Saving...'
|
||||
: /* TODO i18n: missing key */ 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Highlight, themes } from 'prism-react-renderer';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface YamlEditorProps {
|
||||
value: string;
|
||||
@@ -100,6 +101,7 @@ export function EditorStatusBar({
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
}: EditorStatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -116,7 +118,7 @@ export function EditorStatusBar({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isDirty && <span className="text-amber-500">Unsaved changes</span>}
|
||||
{isDirty && <span className="text-amber-500">{t('cliproxyConfig.unsavedChanges')}</span>}
|
||||
</div>
|
||||
|
||||
{cursorLine && cursorCol && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { api, withApiBase } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AuthTokensResponse {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
@@ -57,6 +58,7 @@ function clearLocalControlPanelSession(): void {
|
||||
}
|
||||
|
||||
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
|
||||
const { t } = useTranslation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loadedFrameKey, setLoadedFrameKey] = useState<string | null>(null);
|
||||
const [iframeRevision, setIframeRevision] = useState(0);
|
||||
@@ -280,7 +282,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-semibold">CLIProxy Control Panel</h2>
|
||||
<h2 className="font-semibold">{t('cliproxyHeader.cliproxyControlPanel')}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
|
||||
@@ -295,7 +297,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
|
||||
<AlertCircle className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">CLIProxy Not Available</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('cliproxyHeader.cliproxyNotAvailable')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a CLIProxy session with{' '}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isNativeGeminiModel } from '@/lib/extended-context-utils';
|
||||
import type { ModelEntry } from './provider-model-selector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ExtendedContextToggleProps {
|
||||
/** Compatible selected models */
|
||||
@@ -33,6 +34,7 @@ export function ExtendedContextToggle({
|
||||
disabled,
|
||||
className,
|
||||
}: ExtendedContextToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +63,7 @@ export function ExtendedContextToggle({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className={cn('w-4 h-4', enabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="text-sm font-medium">Extended Context</span>
|
||||
<span className="text-sm font-medium">{t('extendedContext.extendedContext')}</span>
|
||||
<Badge variant={enabled ? 'default' : 'secondary'} className="text-[10px] h-5 px-1.5">
|
||||
1M tokens
|
||||
</Badge>
|
||||
|
||||
@@ -29,8 +29,14 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { AccountItemProps } from './types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
|
||||
function renderProjectId(
|
||||
projectId: string | undefined,
|
||||
privacyMode: boolean | undefined,
|
||||
t: TFunction
|
||||
) {
|
||||
if (projectId) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -50,7 +56,7 @@ function renderProjectId(projectId: string | undefined, privacyMode: boolean | u
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||
<p className="text-xs">{t('providerEditor.gcpProjectIdReadonly')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -63,13 +69,13 @@ function renderProjectId(projectId: string | undefined, privacyMode: boolean | u
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||
<span>Project ID: N/A</span>
|
||||
<span>{t('providerEditor.projectIdNA')}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-amber-600">Missing Project ID</p>
|
||||
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
|
||||
<p className="font-medium text-amber-600">{t('providerEditor.missingProjectId')}</p>
|
||||
<p>{t('providerEditor.missingProjectIdHint')}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -90,6 +96,7 @@ export function AccountItem({
|
||||
selected,
|
||||
onSelectChange,
|
||||
}: AccountItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const normalizedProvider = account.provider.toLowerCase();
|
||||
const { data: stats } = useCliproxyStats(showQuota);
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
@@ -199,7 +206,7 @@ export function AccountItem({
|
||||
beforeIdentity={beforeIdentity}
|
||||
headerEnd={headerEnd}
|
||||
bodySlot={
|
||||
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
|
||||
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode, t) : null
|
||||
}
|
||||
quotaInsetClassName="pl-11"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { User, Plus, Globe } from 'lucide-react';
|
||||
import { AccountItem } from './account-item';
|
||||
import { BulkActionBar } from './bulk-action-bar';
|
||||
import type { OAuthAccount } from '@/lib/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AccountsSectionProps {
|
||||
accounts: OAuthAccount[];
|
||||
@@ -65,6 +66,7 @@ export function AccountsSection({
|
||||
onKiroNoIncognitoChange,
|
||||
kiroSettingsLoading,
|
||||
}: AccountsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
// Multi-select state - raw selection (may contain stale IDs)
|
||||
const [rawSelectedIds, setRawSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -140,6 +142,7 @@ export function AccountsSection({
|
||||
/>
|
||||
)}
|
||||
<User className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Accounts" */}
|
||||
Accounts
|
||||
{accounts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -191,8 +194,8 @@ export function AccountsSection({
|
||||
) : (
|
||||
<div className="py-6 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
|
||||
<User className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No accounts connected</p>
|
||||
<p className="text-xs opacity-70">Add an account to get started</p>
|
||||
<p className="text-sm">{t('providerEditor.noAccountsConnected')}</p>
|
||||
<p className="text-xs opacity-70">{t('providerEditor.addAccountToStart')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,7 +205,7 @@ export function AccountsSection({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>Use incognito</span>
|
||||
<span>{t('providerEditor.useIncognito')}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!kiroNoIncognito}
|
||||
|
||||
@@ -49,6 +49,7 @@ export function BulkActionBar({
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPausing ? <Loader2 className="w-3 h-3 animate-spin" /> : <Pause className="w-3 h-3" />}
|
||||
{/* TODO i18n: missing key for "Pause Selected" */}
|
||||
Pause Selected
|
||||
</Button>
|
||||
<Button
|
||||
@@ -59,6 +60,7 @@ export function BulkActionBar({
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isResuming ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||
{/* TODO i18n: missing key for "Resume Selected" */}
|
||||
Resume Selected
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProviderEditor } from './use-provider-editor';
|
||||
import { CustomPresetDialog } from './custom-preset-dialog';
|
||||
import { RawEditorSection } from './raw-editor-section';
|
||||
@@ -55,6 +56,7 @@ export function ProviderEditor({
|
||||
}: ProviderEditorProps) {
|
||||
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: modelsData } = useCliproxyModels();
|
||||
const { data: presetsData } = usePresets(provider);
|
||||
@@ -158,7 +160,7 @@ export function ProviderEditor({
|
||||
updates.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
].some((modelId) => typeof modelId === 'string' && isDeniedAgyModelId(modelId))
|
||||
) {
|
||||
toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.');
|
||||
toast.error(t('providerEditor.agyDenylist'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,7 +180,7 @@ export function ProviderEditor({
|
||||
isDeniedAgyModelId(modelId)
|
||||
)
|
||||
) {
|
||||
toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.');
|
||||
toast.error(t('providerEditor.agyDenylist'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,7 +208,7 @@ export function ProviderEditor({
|
||||
isDeniedAgyModelId(modelId)
|
||||
)
|
||||
) {
|
||||
toast.error('Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.');
|
||||
toast.error(t('providerEditor.agyDenylist'));
|
||||
return;
|
||||
}
|
||||
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
|
||||
@@ -234,7 +236,7 @@ export function ProviderEditor({
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
<span className="ml-3 text-muted-foreground">{t('providerEditor.loadingSettings')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
@@ -312,7 +314,7 @@ export function ProviderEditor({
|
||||
<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('rawEditorSection.rawConfig')} (JSON)
|
||||
</span>
|
||||
</div>
|
||||
<RawEditorSection
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
|
||||
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
|
||||
import { findCatalogModel, getResolvedCatalogModels } from '@/lib/model-catalogs';
|
||||
import type { ModelConfigSectionProps } from './types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
|
||||
|
||||
@@ -53,6 +54,7 @@ export function ModelConfigSection({
|
||||
onDeletePreset,
|
||||
isDeletePending,
|
||||
}: ModelConfigSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable);
|
||||
const routingHintMap = useMemo(
|
||||
() =>
|
||||
@@ -118,9 +120,10 @@ export function ModelConfigSection({
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Presets" */}
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">{t('providerEditor.presets')}</p>
|
||||
<div className="space-y-4">
|
||||
{presetGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
@@ -204,7 +207,7 @@ export function ModelConfigSection({
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<h3 className="text-sm font-medium mb-2">{t('providerEditor.modelMapping')}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Save, Loader2, RefreshCw, Globe, Network } from 'lucide-react';
|
||||
import { ProviderLogo } from '../provider-logo';
|
||||
import type { SettingsResponse } from './types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProviderEditorHeaderProps {
|
||||
provider: string;
|
||||
@@ -38,6 +39,7 @@ export function ProviderEditorHeader({
|
||||
onRefetch,
|
||||
onSave,
|
||||
}: ProviderEditorHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -67,12 +69,13 @@ export function ProviderEditorHeader({
|
||||
</div>
|
||||
{isRemoteMode ? (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{/* TODO i18n: missing key for remote traffic text */}
|
||||
Traffic auto-routed to remote server
|
||||
</p>
|
||||
) : (
|
||||
data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
{t('providerEditor.lastModified')}: {new Date(data.mtime).toLocaleString()}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Info, Shield } from 'lucide-react';
|
||||
import { UsageCommand } from './usage-command';
|
||||
import type { SettingsResponse } from './types';
|
||||
import type { AuthStatus, CliTarget } from '@/lib/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProviderInfoTabProps {
|
||||
provider: string;
|
||||
@@ -28,6 +29,7 @@ export function ProviderInfoTab({
|
||||
authStatus,
|
||||
supportsModelConfig = false,
|
||||
}: ProviderInfoTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTarget = defaultTarget || 'claude';
|
||||
const isDroidTarget = resolvedTarget === 'droid';
|
||||
const isCodexProvider = provider === 'codex';
|
||||
@@ -44,17 +46,22 @@ export function ProviderInfoTab({
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for "Provider Information" */}
|
||||
Provider Information
|
||||
</h3>
|
||||
<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('providerEditor.provider')}
|
||||
</span>
|
||||
<span className="font-mono">{displayName}</span>
|
||||
</div>
|
||||
{data && (
|
||||
<>
|
||||
<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('providerEditor.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}
|
||||
@@ -63,13 +70,17 @@ export function ProviderInfoTab({
|
||||
</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('providerEditor.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">Status</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t('providerEditor.status')}
|
||||
</span>
|
||||
{authStatus.authenticated ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -85,7 +96,9 @@ export function ProviderInfoTab({
|
||||
)}
|
||||
</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('providerEditor.defaultTarget')}
|
||||
</span>
|
||||
<span className="font-mono">{resolvedTarget}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +106,7 @@ export function ProviderInfoTab({
|
||||
|
||||
{/* Quick Usage */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||
<h3 className="text-sm font-medium mb-3">{t('providerEditor.quickUsage')}</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<UsageCommand label="Run with prompt" command={`ccs ${provider} "your prompt"`} />
|
||||
{isCodexProvider && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||
import type { RawEditorSectionProps } from './types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
const CodeEditor = lazy(() =>
|
||||
@@ -21,6 +22,7 @@ export function RawEditorSection({
|
||||
profileEnv,
|
||||
missingRequiredFields = [],
|
||||
}: RawEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasMissingFields = missingRequiredFields.length > 0;
|
||||
|
||||
return (
|
||||
@@ -28,7 +30,7 @@ export function RawEditorSection({
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
<span className="ml-2 text-muted-foreground">{t('providerEditor.loadingEditor')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import i18n from '@/lib/i18n';
|
||||
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
||||
import type { ProviderCatalog } from '../provider-model-selector';
|
||||
import {
|
||||
@@ -177,11 +178,11 @@ export function useProviderEditor(
|
||||
setRawJsonEdits(null);
|
||||
// Show warning if fields missing (runtime uses defaults)
|
||||
if (responseData?.warning) {
|
||||
toast.success('Settings saved', {
|
||||
toast.success(i18n.t('settings.saved'), {
|
||||
description: responseData.warning,
|
||||
});
|
||||
} else {
|
||||
toast.success('Settings saved');
|
||||
toast.success(i18n.t('settings.saved'));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -379,12 +379,12 @@ export function FlexibleModelSelector({
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Shadowed
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Prefix only
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
@@ -425,12 +425,12 @@ export function FlexibleModelSelector({
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Shadowed
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Prefix only
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
@@ -450,7 +450,7 @@ export function FlexibleModelSelector({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Current
|
||||
{t('providerModelSelector.current')}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
@@ -458,7 +458,7 @@ export function FlexibleModelSelector({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
Current
|
||||
{t('providerModelSelector.current')}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
@@ -489,7 +489,11 @@ export function FlexibleModelSelector({
|
||||
? [
|
||||
{
|
||||
key: 'current',
|
||||
label: <span className="text-xs text-muted-foreground">Current value</span>,
|
||||
label: (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('providerModelSelector.currentValue')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -531,8 +535,8 @@ export function FlexibleModelSelector({
|
||||
>
|
||||
<div className="font-medium">
|
||||
{selectedRoutingHint.pinnedAvailable
|
||||
? 'Preferred pinned model:'
|
||||
: 'Pinned route status:'}{' '}
|
||||
? t('providerModelSelector.preferredPinnedModel')
|
||||
: t('providerModelSelector.pinnedRouteStatus')}{' '}
|
||||
<code>
|
||||
{selectedRoutingHint.pinnedAvailable
|
||||
? selectedRoutingHint.recommendedModelId
|
||||
@@ -544,7 +548,7 @@ export function FlexibleModelSelector({
|
||||
) : null}
|
||||
{value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
|
||||
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2.5 py-2 text-[11px] text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100">
|
||||
Pinned model is not currently advertised by the proxy: <code>{value}</code>
|
||||
{t('providerModelSelector.pinnedModelNotAdvertised', { model: value })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { CliproxyRoutingState, RoutingStrategy } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RoutingGuidanceCardProps {
|
||||
className?: string;
|
||||
@@ -35,6 +36,7 @@ export function RoutingGuidanceCard({
|
||||
error,
|
||||
onApply,
|
||||
}: RoutingGuidanceCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentStrategy = state?.strategy ?? 'round-robin';
|
||||
const [selected, setSelected] = useState<RoutingStrategy>(currentStrategy);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
@@ -114,7 +116,7 @@ export function RoutingGuidanceCard({
|
||||
<div className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-muted text-primary">
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-sm font-medium">Routing strategy</div>
|
||||
<div className="text-sm font-medium">{t('routingGuidance.routingStrategy')}</div>
|
||||
<Badge variant="secondary">{currentStrategy}</Badge>
|
||||
{state ? <Badge variant="outline">{sourceLabel}</Badge> : null}
|
||||
{state ? <Badge variant="outline">{state.target}</Badge> : null}
|
||||
@@ -164,6 +166,7 @@ export function RoutingGuidanceCard({
|
||||
) : (
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{/* TODO i18n: missing key for detail toggle */}
|
||||
{detailToggleLabel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => onApply(selected)} disabled={saveDisabled || !!error}>
|
||||
@@ -173,9 +176,9 @@ export function RoutingGuidanceCard({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
|
||||
<span>Round robin spreads usage.</span>
|
||||
<span>{t('routingGuidance.roundRobin')}</span>
|
||||
<span className="hidden text-border sm:inline">•</span>
|
||||
<span>Fill first keeps backup accounts cold until they are needed.</span>
|
||||
<span>{t('routingGuidance.fillFirst')}</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FileCode2, History, PenLine, Settings2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
|
||||
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
|
||||
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
|
||||
@@ -51,6 +52,8 @@ export function CodexControlCenterTab({
|
||||
saving,
|
||||
onPatch,
|
||||
}: CodexControlCenterTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 pr-1 pb-6">
|
||||
@@ -65,7 +68,7 @@ export function CodexControlCenterTab({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight text-foreground">
|
||||
Structured controls boundary
|
||||
{t('codex.controlCenter')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,6 +77,7 @@ export function CodexControlCenterTab({
|
||||
<li className="flex items-start gap-2.5">
|
||||
<PenLine className="h-4 w-4 shrink-0 text-muted-foreground/60 mt-0.5" />
|
||||
<span className="leading-relaxed">
|
||||
{/* TODO i18n: missing key codex.writesUserLayer */}
|
||||
Writes exclusively to user-layer{' '}
|
||||
<code className="text-[11px] bg-muted/70 px-1.5 py-0.5 rounded border border-border/50">
|
||||
config.toml
|
||||
@@ -83,6 +87,7 @@ export function CodexControlCenterTab({
|
||||
<li className="flex items-start gap-2.5">
|
||||
<History className="h-4 w-4 shrink-0 text-muted-foreground/60 mt-0.5" />
|
||||
<span className="leading-relaxed">
|
||||
{/* TODO i18n: missing key codex.noRepoTrustReflection */}
|
||||
Does not reflect repo trust layers or CLI overrides
|
||||
</span>
|
||||
</li>
|
||||
@@ -95,9 +100,11 @@ export function CodexControlCenterTab({
|
||||
<FileCode2 className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-bold text-amber-700 dark:text-amber-300 uppercase tracking-wider">
|
||||
{/* TODO i18n: missing key codex.formattingNote */}
|
||||
Formatting Note
|
||||
</p>
|
||||
<p className="text-[13px] leading-relaxed text-amber-800/80 dark:text-amber-200/70">
|
||||
{/* TODO i18n: missing key codex.formattingNoteDesc */}
|
||||
Saves normalize TOML formatting and strip comments. Switch to the raw editor
|
||||
if exact layout matters.
|
||||
</p>
|
||||
@@ -115,7 +122,7 @@ export function CodexControlCenterTab({
|
||||
disabledReason={disabledReason}
|
||||
saving={saving}
|
||||
onSave={(values: CodexTopLevelSettingsPatch) =>
|
||||
onPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
|
||||
onPatch({ kind: 'top-level', values }, t('toasts.codexSaved'))
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -128,6 +135,7 @@ export function CodexControlCenterTab({
|
||||
onSave={(projectPath, trustLevel) =>
|
||||
onPatch(
|
||||
{ kind: 'project-trust', path: projectPath, trustLevel },
|
||||
// TODO i18n: missing keys codex.savedProjectTrust / codex.removedProjectTrust
|
||||
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
|
||||
)
|
||||
}
|
||||
@@ -143,14 +151,23 @@ export function CodexControlCenterTab({
|
||||
onSave={(name, values: CodexProfilePatchValues, setAsActive) =>
|
||||
onPatch(
|
||||
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
|
||||
// TODO i18n: missing key codex.savedProfile
|
||||
'Saved profile.'
|
||||
)
|
||||
}
|
||||
onDelete={(name) =>
|
||||
onPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
|
||||
onPatch(
|
||||
{ kind: 'profile', action: 'delete', name },
|
||||
// TODO i18n: missing key codex.deletedProfile
|
||||
'Deleted profile.'
|
||||
)
|
||||
}
|
||||
onSetActive={(name) =>
|
||||
onPatch({ kind: 'profile', action: 'set-active', name }, 'Set active profile.')
|
||||
onPatch(
|
||||
{ kind: 'profile', action: 'set-active', name },
|
||||
// TODO i18n: missing key codex.setActiveProfile
|
||||
'Set active profile.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -162,11 +179,16 @@ export function CodexControlCenterTab({
|
||||
onSave={(name, values) =>
|
||||
onPatch(
|
||||
{ kind: 'model-provider', action: 'upsert', name, values },
|
||||
// TODO i18n: missing key codex.savedModelProvider
|
||||
'Saved model provider.'
|
||||
)
|
||||
}
|
||||
onDelete={(name) =>
|
||||
onPatch({ kind: 'model-provider', action: 'delete', name }, 'Deleted model provider.')
|
||||
onPatch(
|
||||
{ kind: 'model-provider', action: 'delete', name },
|
||||
// TODO i18n: missing key codex.deletedModelProvider
|
||||
'Deleted model provider.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -176,10 +198,18 @@ export function CodexControlCenterTab({
|
||||
disabledReason={disabledReason}
|
||||
saving={saving}
|
||||
onSave={(name, values) =>
|
||||
onPatch({ kind: 'mcp-server', action: 'upsert', name, values }, 'Saved MCP server.')
|
||||
onPatch(
|
||||
{ kind: 'mcp-server', action: 'upsert', name, values },
|
||||
// TODO i18n: missing key codex.savedMcpServer
|
||||
'Saved MCP server.'
|
||||
)
|
||||
}
|
||||
onDelete={(name) =>
|
||||
onPatch({ kind: 'mcp-server', action: 'delete', name }, 'Deleted MCP server.')
|
||||
onPatch(
|
||||
{ kind: 'mcp-server', action: 'delete', name },
|
||||
// TODO i18n: missing key codex.deletedMcpServer
|
||||
'Deleted MCP server.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -189,7 +219,11 @@ export function CodexControlCenterTab({
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onToggle={(feature, enabled) =>
|
||||
onPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
|
||||
onPatch(
|
||||
{ kind: 'feature', feature, enabled },
|
||||
// TODO i18n: missing key codex.savedFeatureToggle
|
||||
'Saved feature toggle.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { ExternalLink, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -85,6 +86,8 @@ interface CodexDocsTabProps {
|
||||
}
|
||||
|
||||
export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const docsReference = diagnostics.docsReference ?? {
|
||||
notes: [],
|
||||
links: [],
|
||||
@@ -103,15 +106,18 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.ccsBridgeRecipe */}
|
||||
CCS bridge recipe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="space-y-1.5">
|
||||
<p>
|
||||
{/* TODO i18n: missing key codex.builtInLabel */}
|
||||
<strong>Built-in:</strong> Use <code>ccsxp</code> for the CCS provider shortcut.
|
||||
</p>
|
||||
<p>
|
||||
{/* TODO i18n: missing key codex.nativeLabelRecipe */}
|
||||
<strong>Native:</strong> Configure the recipe below to use CLIProxy directly with{' '}
|
||||
<code>codex</code>.
|
||||
</p>
|
||||
@@ -121,12 +127,15 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
</pre>
|
||||
<ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1">
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.saveCliproxyProvider */}
|
||||
Save the <code>cliproxy</code> provider in your user config.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.setTopLevelModelProvider */}
|
||||
Set top-level <code>model_provider</code> to <code>cliproxy</code>.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.exportCliproxyApiKey */}
|
||||
Export <code>CLIPROXY_API_KEY</code> before launching native Codex.
|
||||
</li>
|
||||
</ol>
|
||||
@@ -137,6 +146,7 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.upstreamNotes */}
|
||||
Upstream notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -150,7 +160,9 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
)}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Codex docs</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t('codex.codexDocs')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{docsLinks.map((link) => (
|
||||
<a
|
||||
@@ -175,6 +187,7 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.providerBridgeReference */}
|
||||
Provider / bridge reference
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
@@ -204,12 +217,14 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.providerValues */}
|
||||
Provider values: {docsReference.providerValues.join(', ')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{docsReference.settingsHierarchy.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.settingsHierarchy */}
|
||||
Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -20,6 +21,8 @@ export function CodexFeaturesCard({
|
||||
disabledReason,
|
||||
onToggle,
|
||||
}: CodexFeaturesCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const knownFeatureNames = new Set(catalog.map((feature) => feature.name));
|
||||
const configOnlyFeatures = Object.entries(state)
|
||||
.filter(([name]) => !knownFeatureNames.has(name))
|
||||
@@ -27,9 +30,11 @@ export function CodexFeaturesCard({
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
// TODO i18n: missing key codex.features
|
||||
title="Features"
|
||||
badge="features"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
// TODO i18n: missing key codex.featuresDesc
|
||||
description="Toggle the supported Codex feature flags CCS can safely manage."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
@@ -58,7 +63,7 @@ export function CodexFeaturesCard({
|
||||
onClick={() => onToggle(feature.name, null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Use default
|
||||
{t('codex.useDefault')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Switch
|
||||
@@ -76,9 +81,11 @@ export function CodexFeaturesCard({
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.configOnlyFlags */}
|
||||
Existing config-only flags
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.configOnlyFlagsDesc */}
|
||||
These feature keys already exist in your `config.toml`, so CCS can surface them
|
||||
without claiming full catalog coverage.
|
||||
</p>
|
||||
@@ -92,17 +99,19 @@ export function CodexFeaturesCard({
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{/* TODO i18n: missing key codex.existing */}
|
||||
existing
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing keys codex.nonBooleanForm / codex.discoveredFromFile */}
|
||||
{current === null
|
||||
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
|
||||
: "Discovered from the current file instead of CCS's built-in catalog."}
|
||||
</p>
|
||||
</div>
|
||||
{current === null ? (
|
||||
<Badge variant="outline">Raw only</Badge>
|
||||
<Badge variant="outline">{t('codex.rawOnly')}</Badge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -111,7 +120,7 @@ export function CodexFeaturesCard({
|
||||
onClick={() => onToggle(name, null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Use default
|
||||
{t('codex.useDefault')}
|
||||
</Button>
|
||||
<Switch
|
||||
checked={current === true}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, PlugZap, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -67,6 +68,7 @@ function McpServerEditor({
|
||||
onSave,
|
||||
onDelete,
|
||||
}: McpServerEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
|
||||
|
||||
return (
|
||||
@@ -92,8 +94,8 @@ function McpServerEditor({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="streamable-http">streamable-http</SelectItem>
|
||||
<SelectItem value="stdio">{t('codex.stdio')}</SelectItem>
|
||||
<SelectItem value="streamable-http">{t('codex.streamableHttp')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{draft.transport === 'stdio' ? (
|
||||
@@ -136,6 +138,7 @@ function McpServerEditor({
|
||||
startupTimeoutSec: event.target.value ? Number(event.target.value) : null,
|
||||
}))
|
||||
}
|
||||
// TODO i18n: missing key codex.startupTimeoutSec
|
||||
placeholder="Startup timeout (sec)"
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -149,6 +152,7 @@ function McpServerEditor({
|
||||
toolTimeoutSec: event.target.value ? Number(event.target.value) : null,
|
||||
}))
|
||||
}
|
||||
// TODO i18n: missing key codex.toolTimeoutSec
|
||||
placeholder="Tool timeout (sec)"
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -172,6 +176,7 @@ function McpServerEditor({
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
{/* TODO i18n: missing key codex.enabled */}
|
||||
Enabled
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
@@ -180,6 +185,7 @@ function McpServerEditor({
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
{/* TODO i18n: missing key codex.required */}
|
||||
Required
|
||||
<Switch
|
||||
checked={draft.required}
|
||||
@@ -192,6 +198,7 @@ function McpServerEditor({
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{/* TODO i18n: missing key common.delete */}
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
@@ -212,6 +219,7 @@ function McpServerEditor({
|
||||
disabled={disabled || saving || draft.name.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{/* TODO i18n: missing key codex.saveMcpServer */}
|
||||
Save MCP server
|
||||
</Button>
|
||||
</div>
|
||||
@@ -227,6 +235,8 @@ export function CodexMcpServersCard({
|
||||
onSave,
|
||||
onDelete,
|
||||
}: CodexMcpServersCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedName, setSelectedName] = useState('new');
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.name === selectedName) ?? null,
|
||||
@@ -237,18 +247,21 @@ export function CodexMcpServersCard({
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
// TODO i18n: missing key codex.mcpServers
|
||||
title="MCP servers"
|
||||
badge="mcp_servers"
|
||||
icon={<PlugZap className="h-4 w-4" />}
|
||||
// TODO i18n: missing key codex.mcpServersDesc
|
||||
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
{/* TODO i18n: missing key codex.selectMcpServer */}
|
||||
<SelectValue placeholder="Select MCP server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">Create new MCP server</SelectItem>
|
||||
<SelectItem value="new">{t('codex.createNewMcpServer')}</SelectItem>
|
||||
{entries.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -62,6 +63,7 @@ function ModelProviderEditor({
|
||||
onSave,
|
||||
onDelete,
|
||||
}: ModelProviderEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<CodexModelProviderEntry>(initialDraft);
|
||||
|
||||
return (
|
||||
@@ -69,8 +71,10 @@ function ModelProviderEditor({
|
||||
{isNew && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
{/* TODO i18n: missing key codex.cliproxyQuickStart */}
|
||||
Quick start: apply the CLIProxy Codex preset here, then set{' '}
|
||||
<strong>Default provider</strong> to <code>cliproxy</code> in Top-level settings.
|
||||
<strong>{t('codex.defaultProvider')}</strong> to <code>cliproxy</code> in Top-level
|
||||
settings.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -78,6 +82,7 @@ function ModelProviderEditor({
|
||||
onClick={() => setDraft(CLIPROXY_CODEX_PROVIDER_PRESET)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.useCliproxyCodexPreset */}
|
||||
Use CLIProxy Codex preset
|
||||
</Button>
|
||||
</div>
|
||||
@@ -87,6 +92,7 @@ function ModelProviderEditor({
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
// TODO i18n: missing key codex.providerId
|
||||
placeholder="Provider id"
|
||||
disabled={disabled || !isNew}
|
||||
/>
|
||||
@@ -95,6 +101,7 @@ function ModelProviderEditor({
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, displayName: event.target.value || null }))
|
||||
}
|
||||
// TODO i18n: missing key codex.displayName
|
||||
placeholder="Display name"
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -126,10 +133,11 @@ function ModelProviderEditor({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="responses">responses</SelectItem>
|
||||
<SelectItem value="responses">{t('codex.responses')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
{/* TODO i18n: missing key codex.requiresOpenaiAuth */}
|
||||
Requires OpenAI auth
|
||||
<Switch
|
||||
checked={draft.requiresOpenaiAuth}
|
||||
@@ -140,6 +148,7 @@ function ModelProviderEditor({
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
{/* TODO i18n: missing key codex.supportsWebsockets */}
|
||||
Supports websockets
|
||||
<Switch
|
||||
checked={draft.supportsWebsockets}
|
||||
@@ -154,6 +163,7 @@ function ModelProviderEditor({
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{/* TODO i18n: missing key common.delete */}
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
@@ -170,14 +180,16 @@ function ModelProviderEditor({
|
||||
disabled={disabled || saving || draft.name.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{/* TODO i18n: missing key codex.saveProvider */}
|
||||
Save provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.nativeCodexCliproxyHint */}
|
||||
If you want plain native <code>codex</code> to default to CLIProxy, save a provider named{' '}
|
||||
<code>cliproxy</code> with <code>CLIPROXY_API_KEY</code> here, then pick{' '}
|
||||
<code>cliproxy</code> in the <strong>Default provider</strong> control above.
|
||||
<code>cliproxy</code> in the <strong>{t('codex.defaultProvider')}</strong> control above.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
@@ -191,6 +203,7 @@ export function CodexModelProvidersCard({
|
||||
onSave,
|
||||
onDelete,
|
||||
}: CodexModelProvidersCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedName, setSelectedName] = useState<string>('new');
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.name === selectedName) ?? null,
|
||||
@@ -201,18 +214,21 @@ export function CodexModelProvidersCard({
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
// TODO i18n: missing key codex.modelProviders
|
||||
title="Model providers"
|
||||
badge="model_providers"
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
// TODO i18n: missing key codex.modelProvidersDesc
|
||||
description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
{/* TODO i18n: missing key codex.selectProvider */}
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">Create new provider</SelectItem>
|
||||
<SelectItem value="new">{t('codex.createNewProvider')}</SelectItem>
|
||||
{entries.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TerminalSquare,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuickCommands } from '@/components/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -59,6 +60,8 @@ interface CodexOverviewTabProps {
|
||||
}
|
||||
|
||||
export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const inspectProfileCommand = diagnostics.config.activeProfile
|
||||
? `codex --profile ${diagnostics.config.activeProfile}`
|
||||
: 'codex';
|
||||
@@ -71,25 +74,29 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Info className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.howCodexWorks */}
|
||||
How Codex works in CCS
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
<ul className="ml-4 list-disc space-y-1.5 [&>li]:pl-1">
|
||||
<li>Codex is a first-class, runtime-only target in CCS v1.</li>
|
||||
<li>{t('codex.nativeDesc')}</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.nativeConfigLabel */}
|
||||
<strong>Native config:</strong> <code>ccs-codex</code> and <code>ccsx</code> launch
|
||||
native Codex using your saved defaults.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.transientOverrides */}
|
||||
<strong>Transient overrides:</strong> <code>ccsxp</code> (or{' '}
|
||||
<code>ccs codex --target codex</code>) uses the CCS provider shortcut.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.cliproxyDefault */}
|
||||
<strong>CLIProxy default:</strong> To make plain <code>codex</code> use CLIProxy,
|
||||
set <code>model_provider = "cliproxy"</code> and add the recipe below.
|
||||
</li>
|
||||
<li>API profiles continue to default to Claude or Droid.</li>
|
||||
<li>{t('codex.apiProfilesDefault')}</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -98,16 +105,19 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.runtimeInstall */}
|
||||
Runtime install
|
||||
</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('codex.status')}</span>
|
||||
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
||||
{/* TODO i18n: missing keys codex.detected / codex.notFound */}
|
||||
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* TODO i18n: missing keys codex.detectionSource / codex.binaryPath / codex.installDirectory / codex.version / codex.nativeAliases / codex.ccsProviderShortcut / codex.configOverrideSupport */}
|
||||
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
||||
<DetailRow label="Binary path" value={diagnostics.binary.path || 'Not found'} mono />
|
||||
<DetailRow
|
||||
@@ -121,6 +131,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">--config override support</span>
|
||||
<Badge variant={diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'}>
|
||||
{/* TODO i18n: missing keys codex.available / codex.missing */}
|
||||
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -131,6 +142,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Route className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.cliproxyNativeCodex */}
|
||||
CLIProxy-backed native Codex
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -139,34 +151,39 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<p>
|
||||
<strong>Two supported paths:</strong>
|
||||
<strong>{t('codex.twoSupportedPaths')}</strong>
|
||||
</p>
|
||||
<ul className="ml-4 list-disc space-y-1 [&>li]:pl-1">
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.builtInCcsxp */}
|
||||
<strong>Built-in:</strong> Use <code>ccsxp</code> for the CCS provider
|
||||
shortcut.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.nativeRecipe */}
|
||||
<strong>Native:</strong> Configure the recipe below to use CLIProxy directly
|
||||
with <code>codex</code>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
<p className="font-medium text-foreground">Saved native Codex recipe</p>
|
||||
<p className="font-medium text-foreground">{t('codex.codexNativeRecipe')}</p>
|
||||
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-3 text-xs text-foreground">
|
||||
{CLIPROXY_NATIVE_CODEX_RECIPE}
|
||||
</pre>
|
||||
</div>
|
||||
<ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1">
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.saveProviderNamedCliproxy */}
|
||||
Save a provider named <code>cliproxy</code> with the base URL and env key above.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.inTopLevelSetDefault */}
|
||||
In <strong>Top-level settings</strong>, set <strong>Default provider</strong> to{' '}
|
||||
<code>cliproxy</code>.
|
||||
</li>
|
||||
<li>
|
||||
{/* TODO i18n: missing key codex.exportCliproxyApiKey */}
|
||||
Export <code>CLIPROXY_API_KEY</code> in your shell before launching native
|
||||
Codex.
|
||||
</li>
|
||||
@@ -174,6 +191,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{/* TODO i18n: missing key codex.noConfigOverrides */}
|
||||
This Codex build can still use the native path, but CCS-backed Codex routing via{' '}
|
||||
<code>ccsxp</code> or <code>ccs codex --target codex</code> stays unavailable until
|
||||
the detected Codex binary exposes <code>--config</code> overrides.
|
||||
@@ -186,30 +204,34 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Folder className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.configFile */}
|
||||
Config file
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">User config</span>
|
||||
<span className="font-medium text-sm">{t('codex.userConfig')}</span>
|
||||
{diagnostics.file.exists ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{/* TODO i18n: missing keys codex.path / codex.resolved / codex.size / codex.lastModified */}
|
||||
<DetailRow label="Path" value={diagnostics.file.path} mono />
|
||||
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
|
||||
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
|
||||
<DetailRow label="Last modified" value={formatTimestamp(diagnostics.file.mtimeMs)} />
|
||||
{diagnostics.file.parseError && (
|
||||
<p className="text-xs text-amber-600">
|
||||
{/* TODO i18n: missing key codex.tomlWarning */}
|
||||
TOML warning: {diagnostics.file.parseError}
|
||||
</p>
|
||||
)}
|
||||
{diagnostics.file.readError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{/* TODO i18n: missing key codex.readWarning */}
|
||||
Read warning: {diagnostics.file.readError}
|
||||
</p>
|
||||
)}
|
||||
@@ -221,34 +243,46 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.currentUserLayerSummary */}
|
||||
Current user-layer summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<DetailRow label="Model" value={diagnostics.config.model || 'Not set'} mono />
|
||||
{/* TODO i18n: missing key codex.notSet */}
|
||||
<DetailRow
|
||||
label="Model provider"
|
||||
label={t('codex.model')}
|
||||
value={diagnostics.config.model || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('codex.defaultProvider')}
|
||||
value={diagnostics.config.modelProvider || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
// TODO i18n: missing key codex.activeProfile
|
||||
label="Active profile"
|
||||
value={diagnostics.config.activeProfile || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Approval policy"
|
||||
label={t('codex.approvalPolicy')}
|
||||
value={diagnostics.config.approvalPolicy || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Sandbox mode"
|
||||
label={t('codex.sandboxMode')}
|
||||
value={diagnostics.config.sandboxMode || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow label="Web search" value={diagnostics.config.webSearch || 'Not set'} mono />
|
||||
<DetailRow
|
||||
label={t('codex.webSearch')}
|
||||
value={diagnostics.config.webSearch || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{/* TODO i18n: missing keys codex.providersCount / codex.profilesCount / codex.enabledFeaturesCount / codex.mcpServersCount */}
|
||||
<Badge variant="outline" className="justify-center">
|
||||
providers: {diagnostics.config.modelProviderCount}
|
||||
</Badge>
|
||||
@@ -265,6 +299,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
{diagnostics.config.topLevelKeys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.userLayerKeysPresent */}
|
||||
User-layer keys present
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -282,28 +317,31 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<QuickCommands
|
||||
snippets={[
|
||||
{
|
||||
// TODO i18n: missing key codex.nativeShortAlias
|
||||
label: 'Native short alias',
|
||||
command: 'ccsx',
|
||||
description: 'Launch the short native Codex runtime alias.',
|
||||
},
|
||||
{
|
||||
// TODO i18n: missing key codex.ccsCodexShortcut
|
||||
label: 'CCS Codex shortcut',
|
||||
command: 'ccsxp "your prompt"',
|
||||
description: supportsManagedRouting
|
||||
? 'Run the built-in CCS Codex provider on native Codex.'
|
||||
? t('codex.runBuiltInCodex')
|
||||
: 'Requires a Codex build that exposes --config overrides.',
|
||||
},
|
||||
{
|
||||
// TODO i18n: missing key codex.explicitProviderRoute
|
||||
label: 'Explicit provider route',
|
||||
command: 'ccs codex --target codex "your prompt"',
|
||||
description: supportsManagedRouting
|
||||
? 'Use the explicit built-in Codex provider route.'
|
||||
? t('codex.runBuiltInCodexExplicit')
|
||||
: 'Requires a Codex build that exposes --config overrides.',
|
||||
},
|
||||
{
|
||||
label: diagnostics.config.activeProfile
|
||||
? 'Inspect active profile'
|
||||
: 'Open native Codex',
|
||||
: t('codex.openNativeCodex'),
|
||||
command: inspectProfileCommand,
|
||||
description: diagnostics.config.activeProfile
|
||||
? 'Inspect the active named profile directly in native Codex.'
|
||||
@@ -316,12 +354,13 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Route className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key codex.runtimeVsProvider */}
|
||||
Runtime vs provider
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
<div className="flex flex-col rounded-md border p-3 text-sm">
|
||||
<p className="font-medium text-foreground">Native Codex runtime</p>
|
||||
<p className="font-medium text-foreground">{t('codex.nativeCodexRuntime')}</p>
|
||||
<ul className="mt-2 flex-grow list-disc space-y-1.5 pl-4 text-muted-foreground [&>li]:pl-1">
|
||||
<li>
|
||||
<code>ccs-codex</code>
|
||||
@@ -334,11 +373,12 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
</li>
|
||||
</ul>
|
||||
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
|
||||
{/* TODO i18n: missing key codex.honorsSavedNativeConfig */}
|
||||
Honors saved native user config
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-md border p-3 text-sm">
|
||||
<p className="font-medium text-foreground">CCS Codex provider / bridge</p>
|
||||
<p className="font-medium text-foreground">{t('codex.ccsCodexProvider')}</p>
|
||||
{supportsManagedRouting ? (
|
||||
<>
|
||||
<ul className="mt-2 flex-grow list-disc space-y-1.5 pl-4 text-muted-foreground [&>li]:pl-1">
|
||||
@@ -350,11 +390,13 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
</li>
|
||||
</ul>
|
||||
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
|
||||
{/* TODO i18n: missing key codex.usesTransientOverrides */}
|
||||
Uses transient overrides
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.unavailableNoConfig */}
|
||||
Unavailable (Codex build lacks <code>--config</code> support).
|
||||
</p>
|
||||
)}
|
||||
@@ -364,15 +406,15 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Supported flows</CardTitle>
|
||||
<CardTitle className="text-base">{t('codex.supportedFlows')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Flow</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead>{t('codex.flow')}</TableHead>
|
||||
<TableHead>{t('codex.status')}</TableHead>
|
||||
<TableHead>{t('codex.notes')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -381,6 +423,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={entry.supported ? 'default' : 'secondary'}>
|
||||
{/* TODO i18n: missing keys common.yes / common.no */}
|
||||
{entry.supported ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -397,6 +440,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
{/* TODO i18n: missing key codex.warnings */}
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Layers3, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ function ProfileEditor({
|
||||
onDelete,
|
||||
onSetActive,
|
||||
}: ProfileEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [nameDraft, setNameDraft] = useState(initialName);
|
||||
const [modelDraft, setModelDraft] = useState<string | null>(initialModel);
|
||||
const [providerDraft, setProviderDraft] = useState<string | null>(initialProvider);
|
||||
@@ -88,10 +90,10 @@ function ProfileEditor({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use global provider" />
|
||||
<SelectValue placeholder={t('codex.useGlobalProvider')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">Use global provider</SelectItem>
|
||||
<SelectItem value="__unset__">{t('codex.useGlobalProvider')}</SelectItem>
|
||||
{providerNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
@@ -105,10 +107,10 @@ function ProfileEditor({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use global effort" />
|
||||
<SelectValue placeholder={t('codex.useGlobalEffort')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">Use global effort</SelectItem>
|
||||
<SelectItem value="__unset__">{t('codex.useGlobalEffort')}</SelectItem>
|
||||
{['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
@@ -126,6 +128,7 @@ function ProfileEditor({
|
||||
disabled={disabled || saving || !selectedEntryName}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{/* TODO i18n: missing key common.delete */}
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
@@ -135,6 +138,7 @@ function ProfileEditor({
|
||||
disabled || saving || !selectedEntryName || selectedEntryName === activeProfile
|
||||
}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.setActive */}
|
||||
Set active
|
||||
</Button>
|
||||
</div>
|
||||
@@ -155,6 +159,7 @@ function ProfileEditor({
|
||||
disabled={disabled || saving || nameDraft.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{/* TODO i18n: missing key codex.saveProfile */}
|
||||
Save profile
|
||||
</Button>
|
||||
<Button
|
||||
@@ -172,6 +177,7 @@ function ProfileEditor({
|
||||
disabled={disabled || saving || nameDraft.trim().length === 0}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{/* TODO i18n: missing key codex.saveAndActivate */}
|
||||
Save + activate
|
||||
</Button>
|
||||
</div>
|
||||
@@ -191,6 +197,8 @@ export function CodexProfilesCard({
|
||||
onDelete,
|
||||
onSetActive,
|
||||
}: CodexProfilesCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedName, setSelectedName] = useState('new');
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((entry) => entry.name === selectedName) ?? null,
|
||||
@@ -200,21 +208,24 @@ export function CodexProfilesCard({
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
title="Profiles"
|
||||
title={t('codex.profiles')}
|
||||
badge="profiles"
|
||||
icon={<Layers3 className="h-4 w-4" />}
|
||||
// TODO i18n: missing key codex.profilesDesc
|
||||
description="Create reusable Codex overlays and set the active default profile."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
{/* TODO i18n: missing key codex.selectProfile */}
|
||||
<SelectValue placeholder="Select profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">Create new profile</SelectItem>
|
||||
<SelectItem value="new">{t('codex.createNewProfile')}</SelectItem>
|
||||
{entries.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.name}
|
||||
{/* TODO i18n: missing key codex.activeSuffix */}
|
||||
{entry.name === activeProfile ? ' (active)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { FolderCheck, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ function ProjectTrustComposer({
|
||||
saving,
|
||||
onSave,
|
||||
}: ProjectTrustComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pathDraft, setPathDraft] = useState(workspacePath);
|
||||
const [trustLevel, setTrustLevel] = useState('trusted');
|
||||
|
||||
@@ -50,12 +52,13 @@ function ProjectTrustComposer({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="trusted">trusted</SelectItem>
|
||||
<SelectItem value="untrusted">untrusted</SelectItem>
|
||||
<SelectItem value="trusted">{t('codex.trusted')}</SelectItem>
|
||||
<SelectItem value="untrusted">{t('codex.untrusted')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{/* TODO i18n: missing key codex.saveTrust */}
|
||||
Save trust
|
||||
</Button>
|
||||
</div>
|
||||
@@ -70,15 +73,20 @@ export function CodexProjectTrustCard({
|
||||
saving = false,
|
||||
onSave,
|
||||
}: CodexProjectTrustCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
// TODO i18n: missing key codex.projectTrust
|
||||
title="Project trust"
|
||||
badge="projects"
|
||||
icon={<FolderCheck className="h-4 w-4" />}
|
||||
// TODO i18n: missing key codex.projectTrustDesc
|
||||
description="Trust current workspaces or remove stale trust entries without opening raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.trustPathsHint */}
|
||||
Paths must be absolute or start with <code>~/</code>. Relative paths are rejected so CCS
|
||||
does not trust the wrong folder.
|
||||
</p>
|
||||
@@ -96,12 +104,13 @@ export function CodexProjectTrustCard({
|
||||
onClick={() => onSave(workspacePath, 'trusted')}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.trustCurrentWorkspace */}
|
||||
Trust current workspace
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No explicit project trust entries saved.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('codex.noProjectTrustEntries')}</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
@@ -121,6 +130,7 @@ export function CodexProjectTrustCard({
|
||||
}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.toggle */}
|
||||
Toggle
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { CircleAlert, Loader2, SlidersHorizontal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -93,6 +94,8 @@ function TopLevelControlsForm({
|
||||
saving,
|
||||
onSave,
|
||||
}: TopLevelControlsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [draft, setDraft] = useState<CodexTopLevelSettingsView>(initialValues);
|
||||
const reasoningOptions = withCurrentValue(
|
||||
['minimal', 'low', 'medium', 'high', 'xhigh'],
|
||||
@@ -121,7 +124,7 @@ function TopLevelControlsForm({
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Model</p>
|
||||
<p className="text-xs font-medium">{t('codex.model')}</p>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={(event) =>
|
||||
@@ -133,7 +136,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Reasoning effort</p>
|
||||
<p className="text-xs font-medium">{t('codex.reasoningEffort')}</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.modelReasoningEffort)}
|
||||
onValueChange={(next) =>
|
||||
@@ -145,10 +148,10 @@ function TopLevelControlsForm({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
|
||||
{reasoningOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
@@ -159,7 +162,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Default provider</p>
|
||||
<p className="text-xs font-medium">{t('codex.defaultProvider')}</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.modelProvider)}
|
||||
onValueChange={(next) =>
|
||||
@@ -168,10 +171,10 @@ function TopLevelControlsForm({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use Codex default" />
|
||||
<SelectValue placeholder={t('codex.useCodexDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use Codex default</SelectItem>
|
||||
<SelectItem value={UNSET}>{t('codex.useCodexDefault')}</SelectItem>
|
||||
{providerOptions.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
@@ -182,7 +185,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Approval policy</p>
|
||||
<p className="text-xs font-medium">{t('codex.approvalPolicy')}</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.approvalPolicy)}
|
||||
onValueChange={(next) =>
|
||||
@@ -191,10 +194,10 @@ function TopLevelControlsForm({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
|
||||
{approvalOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
@@ -205,7 +208,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Sandbox mode</p>
|
||||
<p className="text-xs font-medium">{t('codex.sandboxMode')}</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.sandboxMode)}
|
||||
onValueChange={(next) =>
|
||||
@@ -214,10 +217,10 @@ function TopLevelControlsForm({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
|
||||
{sandboxOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
@@ -228,7 +231,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Web search</p>
|
||||
<p className="text-xs font-medium">{t('codex.webSearch')}</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.webSearch)}
|
||||
onValueChange={(next) =>
|
||||
@@ -237,10 +240,10 @@ function TopLevelControlsForm({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
|
||||
{webSearchOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
@@ -251,7 +254,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Tool output token limit</p>
|
||||
<p className="text-xs font-medium">{t('codex.toolOutputTokenLimit')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -268,7 +271,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Personality</p>
|
||||
<p className="text-xs font-medium">{t('codex.personality')}</p>
|
||||
<Select
|
||||
value={toSelectValue(draft.personality)}
|
||||
onValueChange={(next) =>
|
||||
@@ -277,10 +280,10 @@ function TopLevelControlsForm({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET}>{t('codex.useDefault')}</SelectItem>
|
||||
{personalityOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
@@ -296,21 +299,24 @@ function TopLevelControlsForm({
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4 text-amber-600 dark:text-amber-300" />
|
||||
<p className="text-sm font-semibold">Long context override</p>
|
||||
<p className="text-sm font-semibold">{t('codex.longContextOverride')}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 bg-background/80 text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{/* TODO i18n: missing key codex.manualOptInOnly */}
|
||||
Manual opt-in only
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground"
|
||||
>
|
||||
{/* TODO i18n: missing keys codex.gpt54Selected / codex.gpt54Reference */}
|
||||
{isGpt54Selected ? 'GPT-5.4 selected' : 'GPT-5.4 reference'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.draftValuesOnly */}
|
||||
Draft values only. Nothing applies until Save.
|
||||
</p>
|
||||
</div>
|
||||
@@ -329,6 +335,7 @@ function TopLevelControlsForm({
|
||||
}))
|
||||
}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.fillCautiousPair */}
|
||||
Fill cautious pair
|
||||
</Button>
|
||||
<Button
|
||||
@@ -343,6 +350,7 @@ function TopLevelControlsForm({
|
||||
}))
|
||||
}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.setOfficialMaxWindow */}
|
||||
Set official max window
|
||||
</Button>
|
||||
<Button
|
||||
@@ -358,6 +366,7 @@ function TopLevelControlsForm({
|
||||
}))
|
||||
}
|
||||
>
|
||||
{/* TODO i18n: missing key codex.clear */}
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
@@ -366,26 +375,31 @@ function TopLevelControlsForm({
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.officialMax */}
|
||||
Official max
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">1.05M / 1M</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">GPT-5.4 context cap</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{t('codex.gptContextCap')}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.standardWindow */}
|
||||
Standard window
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">
|
||||
{formatInteger(GPT_54_STANDARD_CONTEXT_WINDOW)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Normal usage window</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{t('codex.normalUsageWindow')}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.above272k */}
|
||||
Above 272K
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">Counts 2x</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Usage-limit cost above 272K</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">
|
||||
{t('codex.counts2x')}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{t('codex.usageLimitCost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -393,12 +407,15 @@ function TopLevelControlsForm({
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.oneCautiousPair */}
|
||||
One cautious pair
|
||||
</p>
|
||||
<div className="rounded-full border bg-background px-2.5 py-1 font-mono text-[11px] font-medium">
|
||||
{/* TODO i18n: missing key codex.context */}
|
||||
Context {formatInteger(CCS_GPT_54_STARTER_CONTEXT_WINDOW)}
|
||||
</div>
|
||||
<div className="rounded-full border bg-background px-2.5 py-1 font-mono text-[11px] font-medium">
|
||||
{/* TODO i18n: missing key codex.autoCompact */}
|
||||
Auto-compact {formatInteger(CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -407,20 +424,23 @@ function TopLevelControlsForm({
|
||||
variant="outline"
|
||||
className="border-border/70 bg-background/80 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
|
||||
>
|
||||
{/* TODO i18n: missing key codex.notOfficial */}
|
||||
Not official
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/70 bg-background/80 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
|
||||
>
|
||||
{/* TODO i18n: missing key codex.draftOnly */}
|
||||
Draft only
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Quick-fill only. Review before saving.</span>
|
||||
<span>{t('codex.quickFillWarning')}</span>
|
||||
{!isGpt54Selected && draft.model ? (
|
||||
<span>
|
||||
{/* TODO i18n: missing key codex.shouldBeCheckedSeparately */}
|
||||
<code>{draft.model}</code> should be checked separately.
|
||||
</span>
|
||||
) : null}
|
||||
@@ -429,9 +449,9 @@ function TopLevelControlsForm({
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Model context window</p>
|
||||
<p className="text-xs font-medium">{t('codex.modelContextWindow')}</p>
|
||||
<Input
|
||||
aria-label="Model context window"
|
||||
aria-label={t('codex.modelContextWindow')}
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.modelContextWindow ?? ''}
|
||||
@@ -441,18 +461,20 @@ function TopLevelControlsForm({
|
||||
modelContextWindow: parseOptionalInteger(event.target.value),
|
||||
}))
|
||||
}
|
||||
// TODO i18n: missing key codex.unset
|
||||
placeholder="Unset"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.writesModelContextWindow */}
|
||||
Writes <code>model_context_window</code>. Leave unset to keep Codex defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Auto-compact token limit</p>
|
||||
<p className="text-xs font-medium">{t('codex.autoCompactTokenLimit')}</p>
|
||||
<Input
|
||||
aria-label="Auto-compact token limit"
|
||||
aria-label={t('codex.autoCompactTokenLimit')}
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.modelAutoCompactTokenLimit ?? ''}
|
||||
@@ -462,10 +484,12 @@ function TopLevelControlsForm({
|
||||
modelAutoCompactTokenLimit: parseOptionalInteger(event.target.value),
|
||||
}))
|
||||
}
|
||||
// TODO i18n: missing key codex.unset
|
||||
placeholder="Unset"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.writesAutoCompactTokenLimit */}
|
||||
Writes <code>model_auto_compact_token_limit</code>. Leave unset to keep model
|
||||
defaults.
|
||||
</p>
|
||||
@@ -473,6 +497,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.docs */}
|
||||
<span className="text-[10px] uppercase tracking-[0.14em]">Docs</span>
|
||||
<a
|
||||
href="https://developers.openai.com/api/docs/models/gpt-5.4"
|
||||
@@ -480,6 +505,7 @@ function TopLevelControlsForm({
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{/* TODO i18n: missing key codex.gpt54ModelPage */}
|
||||
GPT-5.4 model page
|
||||
</a>
|
||||
<a
|
||||
@@ -488,6 +514,7 @@ function TopLevelControlsForm({
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{/* TODO i18n: missing key codex.releaseNotes */}
|
||||
Release notes
|
||||
</a>
|
||||
<a
|
||||
@@ -496,6 +523,7 @@ function TopLevelControlsForm({
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{/* TODO i18n: missing key codex.configReference */}
|
||||
Config reference
|
||||
</a>
|
||||
</div>
|
||||
@@ -504,6 +532,7 @@ function TopLevelControlsForm({
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onSave(patch)} disabled={disabled || saving || !hasChanges}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{/* TODO i18n: missing key codex.saveTopLevelSettings */}
|
||||
Save top-level settings
|
||||
</Button>
|
||||
</div>
|
||||
@@ -521,9 +550,11 @@ export function CodexTopLevelControlsCard({
|
||||
}: CodexTopLevelControlsCardProps) {
|
||||
return (
|
||||
<CodexConfigCardShell
|
||||
// TODO i18n: missing key codex.topLevelControls
|
||||
title="Top-level controls"
|
||||
badge="config.toml"
|
||||
icon={<SlidersHorizontal className="h-4 w-4" />}
|
||||
// TODO i18n: missing key codex.topLevelControlsDesc
|
||||
description="Structured controls for the stable top-level Codex settings users touch most often. Unsupported upstream shapes stay untouched and should be edited in raw TOML."
|
||||
disabledReason={disabledReason}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrainCircuit } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -39,12 +40,14 @@ export function DroidByokReasoningControlsCard({
|
||||
onEffortChange,
|
||||
onAnthropicBudgetChange,
|
||||
}: DroidByokReasoningControlsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BrainCircuit className="h-4 w-4" />
|
||||
BYOK Reasoning / Thinking
|
||||
{t('droidSettings.reasoningControls')}
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
customModels
|
||||
</Badge>
|
||||
@@ -55,6 +58,7 @@ export function DroidByokReasoningControlsCard({
|
||||
|
||||
{models.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO i18n: missing key droidSettings.noByokModels */}
|
||||
No BYOK custom models found in settings.json (`customModels` or `custom_models`).
|
||||
</p>
|
||||
) : (
|
||||
@@ -75,7 +79,9 @@ export function DroidByokReasoningControlsCard({
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium">Reasoning Effort</p>
|
||||
<p className="text-[11px] font-medium">
|
||||
{t('codex.reasoningEffortCapitalized')}
|
||||
</p>
|
||||
<Select
|
||||
value={model.effort ?? UNSET_VALUE}
|
||||
onValueChange={(next) =>
|
||||
@@ -84,9 +90,11 @@ export function DroidByokReasoningControlsCard({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
{/* TODO i18n: missing key droidSettings.useProviderDefault */}
|
||||
<SelectValue placeholder="Use provider default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* TODO i18n: missing key droidSettings.useProviderDefault */}
|
||||
<SelectItem value={UNSET_VALUE}>Use provider default</SelectItem>
|
||||
{DROID_REASONING_EFFORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
@@ -99,7 +107,7 @@ export function DroidByokReasoningControlsCard({
|
||||
|
||||
{model.providerKind === 'anthropic' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium">Thinking Budget Tokens</p>
|
||||
<p className="text-[11px] font-medium">{t('codex.thinkingBudgetTokens')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1024}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -123,12 +124,14 @@ export function DroidSettingsQuickControlsCard({
|
||||
onBooleanSettingChange,
|
||||
onNumberSettingChange,
|
||||
}: DroidSettingsQuickControlsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Quick Settings
|
||||
{t('droidSettings.quickControls')}
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
settings.json
|
||||
</Badge>
|
||||
@@ -140,6 +143,7 @@ export function DroidSettingsQuickControlsCard({
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{enumFieldConfig.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
{/* TODO i18n: missing keys for droidSettings enum/boolean/number field labels */}
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
<Select
|
||||
value={values[field.key] ?? UNSET_VALUE}
|
||||
@@ -149,10 +153,10 @@ export function DroidSettingsQuickControlsCard({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET_VALUE}>{t('codex.useDefault')}</SelectItem>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -166,6 +170,7 @@ export function DroidSettingsQuickControlsCard({
|
||||
|
||||
{numberFieldConfig.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
{/* TODO i18n: missing keys for droidSettings number field labels */}
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -190,6 +195,7 @@ export function DroidSettingsQuickControlsCard({
|
||||
|
||||
{booleanFieldConfig.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
{/* TODO i18n: missing keys for droidSettings boolean field labels */}
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
<Select
|
||||
value={toBooleanSelectValue(values[field.key])}
|
||||
@@ -197,10 +203,10 @@ export function DroidSettingsQuickControlsCard({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Use default" />
|
||||
<SelectValue placeholder={t('codex.useDefault')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
|
||||
<SelectItem value={UNSET_VALUE}>{t('codex.useDefault')}</SelectItem>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -67,6 +67,7 @@ export function RawConfigEditorPanel({
|
||||
{title}
|
||||
{dirty && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{/* TODO i18n: missing key rawJsonSettingsEditor.unsaved */}
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
@@ -80,15 +81,18 @@ export function RawConfigEditorPanel({
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{/* TODO i18n: missing key rawJsonSettingsEditor.save */}
|
||||
Save
|
||||
</Button>
|
||||
{onDiscard ? (
|
||||
<Button variant="outline" size="sm" onClick={onDiscard} disabled={!dirty || loading}>
|
||||
{/* TODO i18n: missing key rawJsonSettingsEditor.discard */}
|
||||
Discard
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
{/* TODO i18n: missing keys rawJsonSettingsEditor.copied / rawJsonSettingsEditor.copy */}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} aria-label="Refresh raw config">
|
||||
@@ -113,6 +117,7 @@ export function RawConfigEditorPanel({
|
||||
)}
|
||||
{readWarning && (
|
||||
<div className="mx-4 mt-4 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{/* TODO i18n: missing key rawJsonSettingsEditor.readOnly */}
|
||||
Read-only: {readWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Save, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RawSettings {
|
||||
path: string;
|
||||
@@ -35,12 +36,14 @@ export function HeaderSection({
|
||||
onRefresh,
|
||||
onSave,
|
||||
}: HeaderSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Copilot Configuration</h2>
|
||||
<h2 className="text-lg font-semibold">{t('copilotConfigForm.copilotConfiguration')}</h2>
|
||||
{rawSettings && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
copilot.settings.json
|
||||
@@ -49,7 +52,8 @@ export function HeaderSection({
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified:{' '}
|
||||
{/* TODO i18n: missing key copilotConfigForm.lastModified */}
|
||||
Last modified: {/* TODO i18n: missing key copilotConfigForm.neverSaved */}
|
||||
{rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
|
||||
</p>
|
||||
)}
|
||||
@@ -67,11 +71,13 @@ export function HeaderSection({
|
||||
{isUpdating || isSavingRawSettings ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
{/* TODO i18n: missing key copilotConfigForm.saving */}
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{/* TODO i18n: missing key copilotConfigForm.save */}
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AlertTriangle, Code2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||
|
||||
import { HeaderSection } from './header-section';
|
||||
@@ -22,6 +23,7 @@ import { RawEditorSection } from './raw-editor-section';
|
||||
import { useCopilotConfigForm } from './use-copilot-config-form';
|
||||
|
||||
export function CopilotConfigForm() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
configLoading,
|
||||
rawSettingsLoading,
|
||||
@@ -83,9 +85,10 @@ export function CopilotConfigForm() {
|
||||
<div className="px-6 pt-4 shrink-0">
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Deprecated Copilot models detected</AlertTitle>
|
||||
<AlertTitle>{t('copilotConfigForm.deprecatedModels')}</AlertTitle>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>
|
||||
{/* TODO i18n: missing key copilotConfigForm.deprecatedModelsDesc */}
|
||||
Loading this page did not rewrite your files. Save the Copilot configuration to
|
||||
persist these replacements.
|
||||
</p>
|
||||
@@ -108,12 +111,15 @@ export function CopilotConfigForm() {
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config" className="flex-1">
|
||||
{/* TODO i18n: missing key copilotConfigForm.modelConfig */}
|
||||
Model Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{/* TODO i18n: missing key copilotConfigForm.settings */}
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{/* TODO i18n: missing key copilotConfigForm.info */}
|
||||
Info
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info } from 'lucide-react';
|
||||
import { TabsContent } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UsageCommand } from './usage-command';
|
||||
|
||||
interface RawSettings {
|
||||
@@ -22,6 +23,7 @@ interface InfoTabProps {
|
||||
}
|
||||
|
||||
export function InfoTab({ rawSettings }: InfoTabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TabsContent value="info" className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden">
|
||||
<ScrollArea className="h-full">
|
||||
@@ -29,17 +31,22 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for 'Configuration Info' */}
|
||||
Configuration Info
|
||||
</h3>
|
||||
<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('copilotConfigForm.provider')}
|
||||
</span>
|
||||
<span className="font-mono">GitHub Copilot</span>
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<>
|
||||
<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('copilotConfigForm.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">
|
||||
{rawSettings.path}
|
||||
@@ -48,7 +55,9 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Status</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t('copilotConfigForm.status')}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
@@ -57,6 +66,7 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
|
||||
: 'w-fit text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{/* TODO i18n: missing key for 'File exists' / 'Using defaults' */}
|
||||
{rawSettings.exists ? 'File exists' : 'Using defaults'}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -66,8 +76,9 @@ export function InfoTab({ rawSettings }: InfoTabProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||
<h3 className="text-sm font-medium mb-3">{t('copilotConfigForm.quickUsage')}</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
{/* TODO i18n: missing keys for usage command labels */}
|
||||
<UsageCommand label="Run with Copilot" command="ccs copilot" />
|
||||
<UsageCommand label="Authenticate" command="ccs copilot auth" />
|
||||
<UsageCommand label="Start daemon" command="ccs copilot --start" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
import { TabsContent } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CopilotModel } from '@/hooks/use-copilot';
|
||||
import { FREE_PRESETS, PAID_PRESETS } from './presets';
|
||||
import { FlexibleModelSelector } from './model-selector';
|
||||
@@ -70,6 +71,7 @@ export function ModelConfigTab({
|
||||
onUpdateSonnetModel,
|
||||
onUpdateHaikuModel,
|
||||
}: ModelConfigTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const mappedModelLimits = [
|
||||
{ label: 'Default', id: currentModel },
|
||||
{ label: 'Opus', id: opusModel || currentModel },
|
||||
@@ -94,10 +96,10 @@ export function ModelConfigTab({
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Presets
|
||||
{t('providerEditor.presets')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Apply pre-configured model mappings
|
||||
{t('copilotConfigForm.modelMapping')}
|
||||
</p>
|
||||
|
||||
{/* Free Tier Presets */}
|
||||
@@ -107,9 +109,12 @@ export function ModelConfigTab({
|
||||
variant="outline"
|
||||
className="text-[10px] bg-green-100 text-green-700 border-green-200"
|
||||
>
|
||||
{/* TODO i18n: missing key for 'Free Tier' */}
|
||||
Free Tier
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">No premium usage count</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('copilotConfigForm.noPremiumUsage')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FREE_PRESETS.map((preset) => (
|
||||
@@ -135,9 +140,11 @@ export function ModelConfigTab({
|
||||
variant="outline"
|
||||
className="text-[10px] bg-blue-100 text-blue-700 border-blue-200"
|
||||
>
|
||||
{/* TODO i18n: missing key for 'Pro+ Required' */}
|
||||
Pro+ Required
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{/* TODO i18n: missing key for 'Uses premium request quota' */}
|
||||
Uses premium request quota
|
||||
</span>
|
||||
</div>
|
||||
@@ -163,13 +170,15 @@ export function ModelConfigTab({
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<h3 className="text-sm font-medium mb-2">{t('copilotConfigForm.modelMapping')}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{/* TODO i18n: missing key for model mapping description */}
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
<div className="mb-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-900 dark:text-amber-200">
|
||||
<p className="font-medium">GitHub Copilot controls prompt/context limits upstream.</p>
|
||||
<p className="font-medium">{t('copilotConfigForm.githubCopilotControls')}</p>
|
||||
<p className="mt-1">
|
||||
{/* TODO i18n: missing key for 'CCS can switch Copilot models...' */}
|
||||
CCS can switch Copilot models, but it cannot increase the provider's max prompt
|
||||
or context window.
|
||||
</p>
|
||||
@@ -183,6 +192,7 @@ export function ModelConfigTab({
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] font-mono">
|
||||
{/* TODO i18n: missing key for 'Start the daemon to inspect...' */}
|
||||
Start the daemon to inspect live model limits from GitHub Copilot metadata.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { ModelPreset } from './types';
|
||||
|
||||
// TODO i18n: missing keys for preset descriptions ("Free tier - no premium usage", etc.)
|
||||
// Note: ALL Claude models require paid Copilot subscription
|
||||
export const FREE_PRESETS: ModelPreset[] = [
|
||||
{
|
||||
@@ -25,6 +26,7 @@ export const FREE_PRESETS: ModelPreset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// TODO i18n: missing keys for paid preset descriptions
|
||||
export const PAID_PRESETS: ModelPreset[] = [
|
||||
{
|
||||
name: 'Claude Opus 4.5',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
@@ -29,6 +30,7 @@ export function RawEditorSection({
|
||||
onChange,
|
||||
missingRequiredFields = [],
|
||||
}: RawEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasMissingFields = missingRequiredFields.length > 0;
|
||||
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ export function RawEditorSection({
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
<span className="ml-2 text-muted-foreground">{t('providerEditor.loadingEditor')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -44,6 +46,7 @@ export function RawEditorSection({
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
{/* TODO i18n: missing key for 'Invalid JSON syntax' */}
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
@@ -52,12 +55,14 @@ export function RawEditorSection({
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{/* TODO i18n: missing key for 'Missing required fields:' */}
|
||||
Missing required fields:
|
||||
</span>{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{missingRequiredFields.join(', ')}
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{/* TODO i18n: missing key for 'These fields will use default values at runtime.' */}
|
||||
These fields will use default values at runtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCopilot } from '@/hooks/use-copilot';
|
||||
import type { CopilotNormalizationWarning } from '@/hooks/use-copilot';
|
||||
import { isApiConflictError } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ModelPreset } from './types';
|
||||
|
||||
/** Required env vars for Copilot settings (informational only - runtime fills defaults) */
|
||||
@@ -31,6 +32,7 @@ function dedupeWarnings(
|
||||
}
|
||||
|
||||
export function useCopilotConfigForm() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
config,
|
||||
configLoading,
|
||||
@@ -91,7 +93,7 @@ export function useCopilotConfigForm() {
|
||||
sonnetModel: preset.sonnet,
|
||||
haikuModel: preset.haiku,
|
||||
}));
|
||||
toast.success(`Applied "${preset.name}" preset`);
|
||||
toast.success(t('toasts.presetApplied', { name: preset.name }));
|
||||
};
|
||||
|
||||
// Raw JSON content
|
||||
@@ -191,15 +193,15 @@ export function useCopilotConfigForm() {
|
||||
}
|
||||
|
||||
if (uniqueWarnings.length > 0) {
|
||||
toast.warning('Copilot configuration saved with model adjustments', {
|
||||
toast.warning(t('toasts.settingsSavedWithAdjustments'), {
|
||||
description: descriptions.join(' '),
|
||||
});
|
||||
} else if (descriptions.length > 0) {
|
||||
toast.success('Copilot configuration saved', {
|
||||
toast.success(t('toasts.settingsSaved'), {
|
||||
description: descriptions.join(' '),
|
||||
});
|
||||
} else {
|
||||
toast.success('Copilot configuration saved');
|
||||
toast.success(t('toasts.settingsSaved'));
|
||||
}
|
||||
|
||||
// Clear local state
|
||||
@@ -209,7 +211,7 @@ export function useCopilotConfigForm() {
|
||||
if (isApiConflictError(error)) {
|
||||
setConflictDialog(true);
|
||||
} else {
|
||||
toast.error('Failed to save settings');
|
||||
toast.error(t('toasts.failedSaveSettings'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export function getPlanBadgeStyle(plan?: CopilotPlanTier): string {
|
||||
/** Get multiplier display */
|
||||
export function getMultiplierDisplay(multiplier?: number): string | null {
|
||||
if (multiplier === undefined || multiplier === null) return null;
|
||||
// TODO i18n: missing key for 'Free' multiplier label
|
||||
if (multiplier === 0) return 'Free';
|
||||
if (multiplier < 1) return `${multiplier}x`;
|
||||
if (multiplier === 1) return '1x';
|
||||
|
||||
@@ -9,8 +9,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCopilot } from '@/hooks/use-copilot';
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Loader2, Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function CopilotStatusCard() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
status,
|
||||
statusLoading,
|
||||
@@ -28,7 +30,7 @@ export function CopilotStatusCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GitHub Copilot Status</CardTitle>
|
||||
<CardTitle>{t('copilotPage.status')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
@@ -41,10 +43,10 @@ export function CopilotStatusCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GitHub Copilot Status</CardTitle>
|
||||
<CardTitle>{t('copilotPage.status')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Failed to load status</p>
|
||||
<p className="text-muted-foreground">{t('copilotConfigForm.failedLoadStatus')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -54,21 +56,21 @@ export function CopilotStatusCard() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
GitHub Copilot Status
|
||||
{t('copilotPage.status')}
|
||||
{status.enabled ? (
|
||||
<Badge variant="default">Enabled</Badge>
|
||||
<Badge variant="default">{t('copilotPage.enabled')}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
<Badge variant="secondary">{t('copilotPage.disabled')}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>Use your GitHub Copilot subscription with Claude Code</CardDescription>
|
||||
<CardDescription>{t('copilotConfigForm.useWithClaudeCode')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Warning Banner */}
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-500/20 bg-yellow-500/10 p-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
This uses a reverse-engineered API. Excessive usage may trigger GitHub abuse detection.
|
||||
{t('copilotPage.unofficialItem2')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +84,7 @@ export function CopilotStatusCard() {
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
copilot-api {status.installed ? `v${status.version}` : 'Not Installed'}
|
||||
copilot-api {status.installed ? `v${status.version}` : t('copilotPage.missing')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +96,7 @@ export function CopilotStatusCard() {
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{status.authenticated ? 'Authenticated' : 'Not Authenticated'}
|
||||
{status.authenticated ? t('copilotPage.connected') : t('copilotPage.notConnected')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -105,15 +107,25 @@ export function CopilotStatusCard() {
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">Daemon {status.daemon_running ? 'Running' : 'Stopped'}</span>
|
||||
<span className="text-sm">
|
||||
{t('copilotPage.daemon')}{' '}
|
||||
{status.daemon_running ? t('copilotPage.running') : t('copilotPage.stopped')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<span>Port: {status.port}</span>
|
||||
<span>Model: {status.model}</span>
|
||||
<span>Auto-start: {status.auto_start ? 'Yes' : 'No'}</span>
|
||||
<span>
|
||||
{t('copilotPage.port')}: {status.port}
|
||||
</span>
|
||||
<span>
|
||||
{t('providerEditor.modelMapping')}: {status.model}
|
||||
</span>
|
||||
<span>
|
||||
{/* TODO i18n: missing key for 'Auto-start' */}
|
||||
Auto-start: {status.auto_start ? t('copilotPage.yes') : t('copilotPage.no')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -123,12 +135,12 @@ export function CopilotStatusCard() {
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Installing...
|
||||
{t('copilotPage.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Install copilot-api
|
||||
{t('copilotPage.installCopilotApi')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -143,10 +155,10 @@ export function CopilotStatusCard() {
|
||||
{isAuthenticating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
{t('copilotPage.authenticating')}
|
||||
</>
|
||||
) : (
|
||||
'Authenticate with GitHub'
|
||||
t('copilotPage.authenticate')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -161,10 +173,10 @@ export function CopilotStatusCard() {
|
||||
{isStoppingDaemon ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Stopping...
|
||||
{t('copilotPage.stopping')}
|
||||
</>
|
||||
) : (
|
||||
'Stop Daemon'
|
||||
t('copilotPage.stop')
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -177,10 +189,10 @@ export function CopilotStatusCard() {
|
||||
{isStartingDaemon ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
{t('copilotPage.starting')}
|
||||
</>
|
||||
) : (
|
||||
'Start Daemon'
|
||||
t('copilotPage.start')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||
import { useFixHealth } from '@/hooks/use-health';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HealthCheck {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ const statusConfig = {
|
||||
|
||||
export function HealthCard({ check }: { check: HealthCheck }) {
|
||||
const fixMutation = useFixHealth();
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[check.status];
|
||||
const Icon = config.icon;
|
||||
|
||||
@@ -54,7 +56,7 @@ export function HealthCard({ check }: { check: HealthCheck }) {
|
||||
disabled={fixMutation.isPending}
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
Fix
|
||||
{t('health.fix')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CcsLogo } from '@/components/shared/ccs-logo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HeroSectionProps {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function HeroSection({ version }: HeroSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<CcsLogo size="lg" showText={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">CCS Config</h1>
|
||||
<h1 className="text-2xl font-bold">{t('heroSection.title')}</h1>
|
||||
{version && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">{t('heroSection.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileTextIcon, SettingsIcon, GithubIcon, ExternalLinkIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function HubFooter() {
|
||||
const { t } = useTranslation();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
icon: <FileTextIcon className="w-4 h-4" />,
|
||||
label: 'Logs',
|
||||
label: t('hubFooter.logs'),
|
||||
href: '#logs',
|
||||
onClick: () => console.log('Navigate to Logs'),
|
||||
},
|
||||
{
|
||||
icon: <SettingsIcon className="w-4 h-4" />,
|
||||
label: 'Settings',
|
||||
label: t('hubFooter.settings'),
|
||||
href: '#settings',
|
||||
onClick: () => console.log('Navigate to Settings'),
|
||||
},
|
||||
{
|
||||
icon: <GithubIcon className="w-4 h-4" />,
|
||||
label: 'GitHub',
|
||||
label: t('hubFooter.github'),
|
||||
href: 'https://github.com/kaitranntt/ccs',
|
||||
external: true,
|
||||
},
|
||||
@@ -32,7 +34,7 @@ export function HubFooter() {
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>CCS v0.0.0</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>© {currentYear} kaitranntt</span>
|
||||
<span>{t('hubFooter.copyright', { year: currentYear })}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -13,7 +15,7 @@ export function ThemeToggle() {
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
<span className="sr-only">{t('themeToggle.srLabel')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
||||
|
||||
function parseInteger(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -32,6 +33,8 @@ export function LogsConfigCard({
|
||||
onSave: (payload: UpdateLogsConfigPayload) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
// TODO i18n: uncomment when keys for Commit Changes, Rollback Draft, etc. are added
|
||||
// const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(config);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -247,6 +250,7 @@ export function LogsConfigCard({
|
||||
className="h-10 w-full gap-2 rounded-xl bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{/* TODO i18n: missing key for "Commit Changes" */}
|
||||
Commit Changes
|
||||
</Button>
|
||||
<Button
|
||||
@@ -256,6 +260,7 @@ export function LogsConfigCard({
|
||||
className="h-9 gap-2 text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/45 hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
{/* TODO i18n: missing key for "Rollback Draft" */}
|
||||
Rollback Draft
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { formatJson } from './utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MetaRow({
|
||||
label,
|
||||
@@ -52,6 +53,8 @@ export function LogsDetailPanel({
|
||||
entry: LogsEntry | null;
|
||||
sourceLabel?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center animate-in fade-in duration-1000">
|
||||
@@ -63,6 +66,7 @@ export function LogsDetailPanel({
|
||||
</div>
|
||||
<div className="max-w-xs space-y-3">
|
||||
<h3 className="text-[15px] font-semibold uppercase tracking-[0.14em] text-foreground/65">
|
||||
{/* TODO i18n: missing key for "Inspector Standby" */}
|
||||
Inspector Standby
|
||||
</h3>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground/55 font-medium">
|
||||
@@ -134,13 +138,14 @@ export function LogsDetailPanel({
|
||||
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Details
|
||||
{t('logsDetailPanel.details')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="raw"
|
||||
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
||||
>
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
{/* TODO i18n: missing key for "Raw Context" */}
|
||||
Raw Context
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogsEntryList({
|
||||
entries,
|
||||
@@ -19,6 +20,8 @@ export function LogsEntryList({
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background/50 backdrop-blur-sm">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card/40 px-6 py-3 shadow-sm">
|
||||
@@ -53,13 +56,23 @@ export function LogsEntryList({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0 border-b border-border bg-muted/30 px-0 py-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/45">
|
||||
<div className="w-[6.5rem] shrink-0 px-6">Time</div>
|
||||
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">Lvl</div>
|
||||
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">Source</div>
|
||||
<div className="flex-1 border-l border-border/10 px-4">Message</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">Proc</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">Run</div>
|
||||
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">Open</div>
|
||||
<div className="w-[6.5rem] shrink-0 px-6">{t('logsConfig.time')}</div>
|
||||
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">
|
||||
{t('logsConfig.level')}
|
||||
</div>
|
||||
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">
|
||||
{t('logsConfig.source')}
|
||||
</div>
|
||||
<div className="flex-1 border-l border-border/10 px-4">{t('logsConfig.message')}</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">
|
||||
{t('logsConfig.proc')}
|
||||
</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">
|
||||
{t('logsConfig.run')}
|
||||
</div>
|
||||
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">
|
||||
{t('logsConfig.open')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LogsSource } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
|
||||
import { getLogLevelOptions } from '@/hooks/use-logs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogsFilters({
|
||||
sources,
|
||||
@@ -32,6 +33,7 @@ export function LogsFilters({
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const levels = getLogLevelOptions();
|
||||
const limits = [50, 100, 150, 250];
|
||||
|
||||
@@ -188,7 +190,7 @@ export function LogsFilters({
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
||||
<span>Refresh Entries</span>
|
||||
<span>{t('logsConfig.refreshEntries')}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Activity, Archive, Database, RadioTower } from 'lucide-react';
|
||||
import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils';
|
||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
@@ -43,12 +44,15 @@ export function LogsOverviewCards({
|
||||
entries: LogsEntry[];
|
||||
latestTimestamp: string | null;
|
||||
}) {
|
||||
// TODO i18n: uncomment when keys for Pipeline/Retention/Coverage/Visible Entries are added
|
||||
// const { t } = useTranslation();
|
||||
const nativeSources = sources.filter((source) => source.kind === 'native').length;
|
||||
const legacySources = sources.length - nativeSources;
|
||||
const errorCount = entries.filter((entry) => entry.level === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{/* TODO i18n: missing keys for Pipeline/Retention/Coverage/Visible Entries labels and detail strings */}
|
||||
<MetricCard
|
||||
label="Pipeline"
|
||||
value={config.enabled ? 'Enabled' : 'Disabled'}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogsPageSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6" aria-label="Loading logs workspace">
|
||||
<div className="space-y-6" aria-label={t('logsPageSkeleton.loadingLogs')}>
|
||||
<Card className="gap-4">
|
||||
<CardHeader className="space-y-3">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { LogsLevel } from '@/lib/api-client';
|
||||
// NOTE: This module contains utility functions that are not directly i18n-aware.
|
||||
// String literals here ("No activity yet", "Error", etc.) are used as fallbacks
|
||||
// and defaults in non-component contexts. Components consuming these values
|
||||
// should wrap them with t() calls when rendering.
|
||||
// TODO i18n: Consider making formatRelativeLogTime/formatLogTimestamp i18n-aware
|
||||
|
||||
export function formatLogTimestamp(timestamp: string | null | undefined) {
|
||||
if (!timestamp) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface InlineStatsBadgeProps {
|
||||
success: number;
|
||||
@@ -10,8 +11,13 @@ interface InlineStatsBadgeProps {
|
||||
}
|
||||
|
||||
export function InlineStatsBadge({ success, failure }: InlineStatsBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
if (success === 0 && failure === 0) {
|
||||
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
|
||||
return (
|
||||
<span className="text-[9px] text-muted-foreground/50 font-mono">
|
||||
{t('authMonitorLive.noActivity')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import type { ProviderStats } from '../types';
|
||||
import { getSuccessRate } from '../utils';
|
||||
import { InlineStatsBadge } from './inline-stats-badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProviderCardProps {
|
||||
stats: ProviderStats;
|
||||
@@ -30,6 +31,7 @@ export function ProviderCard({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
|
||||
const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280';
|
||||
|
||||
@@ -59,6 +61,7 @@ export function ProviderCard({
|
||||
{stats.displayName}
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{/* TODO i18n: missing key for account count */}
|
||||
{stats.accountCount} account{stats.accountCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,11 +76,11 @@ export function ProviderCard({
|
||||
<div className="space-y-2">
|
||||
{/* Inline success/failure stats - immediately visible */}
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Stats</span>
|
||||
<span className="text-muted-foreground">{t('authMonitorLive.stats')}</span>
|
||||
<InlineStatsBadge success={stats.successCount} failure={stats.failureCount} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Success Rate</span>
|
||||
<span className="text-muted-foreground">{t('authMonitorLive.successRate')}</span>
|
||||
<span
|
||||
className="font-mono font-semibold"
|
||||
style={{
|
||||
@@ -125,11 +128,11 @@ export function ProviderCard({
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle
|
||||
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
|
||||
aria-label="Missing Project ID"
|
||||
aria-label={t('providerCard.missingProjectIdAria')}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Missing Project ID - re-add account to fix
|
||||
{/* TODO i18n: missing key */}Missing Project ID - re-add account to fix
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -146,17 +146,27 @@ export function AuthMonitor() {
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<LivePulse />
|
||||
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
|
||||
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
|
||||
<span className="text-xs font-semibold tracking-tight text-foreground">
|
||||
{t('authMonitorLive.live')}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('authMonitorLive.accountMonitor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Radio className="w-3 h-3 animate-pulse" />
|
||||
<span>Updated {timeSinceUpdate || 'now'}</span>
|
||||
<span>
|
||||
{timeSinceUpdate
|
||||
? t('authMonitorLive.updated', { time: timeSinceUpdate })
|
||||
: t('authMonitorLive.updatedNow')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span>{t('authMonitor.accountsCount', { count: displayedAccountCount })}</span>
|
||||
<span className="font-mono">{displayedTotalRequests.toLocaleString()} req</span>
|
||||
<span className="font-mono">
|
||||
{displayedTotalRequests.toLocaleString()} {t('authMonitorLive.requestsLabel')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -210,6 +220,7 @@ export function AuthMonitor() {
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
{/* TODO i18n: missing key for "Request Distribution by Provider" */}
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Request Distribution by Provider
|
||||
</div>
|
||||
|
||||
@@ -133,8 +133,12 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
||||
<div className="flex-1 overflow-hidden bg-card/30">
|
||||
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
|
||||
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
|
||||
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
|
||||
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
|
||||
{activeTab === 'request' && (
|
||||
<BodyTab content={parsed.requestBody} label={t('errorLogs.tabRequest')} />
|
||||
)}
|
||||
{activeTab === 'response' && (
|
||||
<BodyTab content={parsed.responseBody} label={t('errorLogs.tabResponse')} />
|
||||
)}
|
||||
{activeTab === 'raw' && <RawTab content={content} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,9 +144,10 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
||||
|
||||
/** Headers tab content */
|
||||
export function HeadersTab({ headers }: { headers: Record<string, string> }) {
|
||||
const { t } = useTranslation();
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length === 0) {
|
||||
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
|
||||
return <div className="p-4 text-xs text-muted-foreground">{t('errorLogs.noHeaders')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './uti
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Settings, SettingsResponse } from './types';
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
|
||||
@@ -50,6 +51,7 @@ export function FriendlyUISection({
|
||||
onAddEnvVar,
|
||||
onEnvBulkChange,
|
||||
}: FriendlyUISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const isOpenRouter = isOpenRouterProfile(currentSettings);
|
||||
const settingsEnv = currentSettings?.env;
|
||||
|
||||
@@ -124,11 +126,13 @@ export function FriendlyUISection({
|
||||
<Tabs defaultValue="env" className="h-full w-full min-w-0 flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
{/* TODO i18n: missing key for "Configuration" tab label */}
|
||||
<TabsTrigger value="env" className="flex-1">
|
||||
{isOpenRouter ? 'Configuration' : 'Environment Variables'}
|
||||
{isOpenRouter ? 'Configuration' : t('settingsDialog.envTab')}
|
||||
</TabsTrigger>
|
||||
{/* TODO i18n: missing key for "Info & Usage" tab label */}
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
Info & Usage
|
||||
Info & Usage
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -145,6 +149,7 @@ export function FriendlyUISection({
|
||||
<div className="h-full overflow-y-auto overflow-x-hidden p-4 space-y-6">
|
||||
{/* Model Selection - Primary Focus */}
|
||||
<div className="space-y-3">
|
||||
{/* TODO i18n: missing key for "Model Selection" label */}
|
||||
<Label className="text-sm font-medium">Model Selection</Label>
|
||||
<OpenRouterModelPicker
|
||||
value={currentEnv.ANTHROPIC_MODEL}
|
||||
@@ -162,7 +167,7 @@ export function FriendlyUISection({
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Key</Label>
|
||||
<Label className="text-sm font-medium">{t('profileDialog.apiKey')}</Label>
|
||||
<MaskedInput
|
||||
value={currentEnv.ANTHROPIC_AUTH_TOKEN || ''}
|
||||
onChange={(e) => onEnvValueChange('ANTHROPIC_AUTH_TOKEN', e.target.value)}
|
||||
@@ -193,6 +198,7 @@ export function FriendlyUISection({
|
||||
)}
|
||||
/>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{/* TODO i18n: missing key for "Additional Variables" label */}
|
||||
<span>Additional Variables</span>
|
||||
<span className="text-xs font-normal opacity-70">
|
||||
({unmanagedEnvVars.length})
|
||||
@@ -220,18 +226,18 @@ export function FriendlyUISection({
|
||||
{/* Fixed Add Variable 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"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
|
||||
import { isOpenRouterProfile } from './utils';
|
||||
import type { Settings } from './types';
|
||||
@@ -49,6 +50,7 @@ export function HeaderSection({
|
||||
onDelete,
|
||||
onSave,
|
||||
}: HeaderSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMutating = isSaving || isTargetSaving;
|
||||
const disableHeaderActions = isLoading || isMutating;
|
||||
|
||||
@@ -66,11 +68,11 @@ export function HeaderSection({
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
{t('profileEditor.lastModified')}: {new Date(data.mtime).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Default target:</span>
|
||||
<span className="text-xs text-muted-foreground">{t('profileEditor.defaultTarget')}:</span>
|
||||
<Select
|
||||
value={target}
|
||||
onValueChange={(value) => {
|
||||
@@ -83,9 +85,9 @@ export function HeaderSection({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">Claude Code</SelectItem>
|
||||
<SelectItem value="droid">Factory Droid</SelectItem>
|
||||
<SelectItem value="codex">Codex CLI</SelectItem>
|
||||
<SelectItem value="claude">{t('profileEditor.targetClaude')}</SelectItem>
|
||||
<SelectItem value="droid">{t('profileEditor.targetDroid')}</SelectItem>
|
||||
<SelectItem value="codex">{t('profileEditor.targetCodex')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isTargetSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
@@ -104,12 +106,12 @@ export function HeaderSection({
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
{t('profileEditor.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
{t('settingsAuth.save')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowUpRight, Image as ImageIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -15,33 +16,38 @@ interface ImageAnalysisStatusSectionProps {
|
||||
onToggleNativeRead?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const TARGET_LABELS: Record<CliTarget, string> = {
|
||||
claude: 'Claude Code',
|
||||
droid: 'Factory Droid',
|
||||
codex: 'Codex CLI',
|
||||
};
|
||||
|
||||
function getPreviewLabel(
|
||||
t: (key: string) => string,
|
||||
source: 'saved' | 'editor',
|
||||
previewState: ImageAnalysisStatusSectionProps['previewState']
|
||||
) {
|
||||
if (previewState === 'refreshing') return 'Refreshing preview';
|
||||
if (previewState === 'invalid') return 'Saved status';
|
||||
return source === 'editor' ? 'Live preview' : 'Saved status';
|
||||
if (previewState === 'refreshing') return t('imageAnalysisStatus.refreshingPreview');
|
||||
if (previewState === 'invalid') return t('imageAnalysisStatus.savedStatus');
|
||||
return source === 'editor'
|
||||
? t('imageAnalysisStatus.livePreview')
|
||||
: t('imageAnalysisStatus.savedStatus');
|
||||
}
|
||||
|
||||
function getHeaderLabel(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||
if (status.status === 'disabled') return 'Disabled globally';
|
||||
if (target !== 'claude') return `${TARGET_LABELS[target]} bypasses the hook`;
|
||||
if (status.nativeReadPreference) return 'Native image reading';
|
||||
if (status.status === 'hook-missing') return 'Setup needed';
|
||||
if (status.authReadiness === 'missing') return 'Needs auth';
|
||||
if (status.proxyReadiness === 'unavailable') return 'Needs proxy';
|
||||
if (status.effectiveRuntimeMode === 'native-read') return 'Native fallback';
|
||||
return 'Transformer ready';
|
||||
function getHeaderLabel(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
status: ImageAnalysisStatus,
|
||||
target: CliTarget
|
||||
): string {
|
||||
if (status.status === 'disabled') return t('imageAnalysisStatus.disabledGlobally');
|
||||
if (target !== 'claude')
|
||||
return t('imageAnalysisStatus.targetBypassesHook', {
|
||||
target: t(`imageAnalysisStatus.targetLabel.${target}`),
|
||||
});
|
||||
if (status.nativeReadPreference) return t('imageAnalysisStatus.nativeImageReading');
|
||||
if (status.status === 'hook-missing') return t('imageAnalysisStatus.setupNeeded');
|
||||
if (status.authReadiness === 'missing') return t('imageAnalysisStatus.needsAuth');
|
||||
if (status.proxyReadiness === 'unavailable') return t('imageAnalysisStatus.needsProxy');
|
||||
if (status.effectiveRuntimeMode === 'native-read') return t('imageAnalysisStatus.nativeFallback');
|
||||
return t('imageAnalysisStatus.transformerReady');
|
||||
}
|
||||
|
||||
function getHeaderBadge(
|
||||
t: (key: string) => string,
|
||||
status: ImageAnalysisStatus,
|
||||
target: CliTarget
|
||||
): {
|
||||
@@ -50,75 +56,93 @@ function getHeaderBadge(
|
||||
} {
|
||||
if (status.status === 'disabled') {
|
||||
return {
|
||||
label: 'Disabled',
|
||||
label: t('imageAnalysisStatus.badgeDisabled'),
|
||||
className: 'border-border/80 bg-background/85 text-muted-foreground',
|
||||
};
|
||||
}
|
||||
if (target !== 'claude') {
|
||||
return {
|
||||
label: 'Bypassed',
|
||||
label: t('imageAnalysisStatus.badgeBypassed'),
|
||||
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
|
||||
};
|
||||
}
|
||||
if (status.nativeReadPreference) {
|
||||
return {
|
||||
label: 'Native',
|
||||
label: t('imageAnalysisStatus.badgeNative'),
|
||||
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
|
||||
};
|
||||
}
|
||||
if (status.status === 'hook-missing' || status.authReadiness === 'missing') {
|
||||
return {
|
||||
label: status.status === 'hook-missing' ? 'Setup' : 'Auth',
|
||||
label:
|
||||
status.status === 'hook-missing'
|
||||
? t('imageAnalysisStatus.badgeSetup')
|
||||
: t('imageAnalysisStatus.badgeAuth'),
|
||||
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
if (status.proxyReadiness === 'unavailable') {
|
||||
return {
|
||||
label: 'Proxy',
|
||||
label: t('imageAnalysisStatus.badgeProxy'),
|
||||
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'Ready',
|
||||
label: t('imageAnalysisStatus.badgeReady'),
|
||||
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
|
||||
};
|
||||
}
|
||||
|
||||
function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||
function getToggleSummary(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
status: ImageAnalysisStatus,
|
||||
target: CliTarget
|
||||
): string {
|
||||
if (status.nativeReadPreference) {
|
||||
if (status.profileModel && status.nativeImageCapable) {
|
||||
return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`;
|
||||
return t('imageAnalysisStatus.toggleSummaryNativeCapable', { model: status.profileModel });
|
||||
}
|
||||
if (status.profileModel) {
|
||||
return `CCS will prefer native reading for ${status.profileModel}.`;
|
||||
return t('imageAnalysisStatus.toggleSummaryNativeModel', { model: status.profileModel });
|
||||
}
|
||||
return 'CCS will prefer native image reading for this profile.';
|
||||
return t('imageAnalysisStatus.toggleSummaryNativeDefault');
|
||||
}
|
||||
|
||||
if (!status.backendDisplayName && target === 'claude') {
|
||||
return 'This profile currently stays on native file access.';
|
||||
return t('imageAnalysisStatus.toggleSummaryNativeFileAccess');
|
||||
}
|
||||
|
||||
if (!status.backendDisplayName) {
|
||||
return `Saved Claude-side image routing is inactive while ${TARGET_LABELS[target]} is selected.`;
|
||||
return t('imageAnalysisStatus.toggleSummaryInactiveTarget', {
|
||||
target: t(`imageAnalysisStatus.targetLabel.${target}`),
|
||||
});
|
||||
}
|
||||
|
||||
const modelSuffix = status.model ? ` · ${status.model}` : '';
|
||||
return `Transformer route: ${status.backendDisplayName}${modelSuffix}.`;
|
||||
return t('imageAnalysisStatus.toggleSummaryTransformerRoute', {
|
||||
backend: status.backendDisplayName,
|
||||
modelSuffix,
|
||||
});
|
||||
}
|
||||
|
||||
function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null {
|
||||
function getExceptionalNote(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
status: ImageAnalysisStatus,
|
||||
target: CliTarget
|
||||
): string | null {
|
||||
if (status.status === 'disabled') {
|
||||
return 'Image is disabled globally in CCS settings.';
|
||||
return t('imageAnalysisStatus.noteDisabledGlobally');
|
||||
}
|
||||
if (target !== 'claude') {
|
||||
return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`;
|
||||
return t('imageAnalysisStatus.noteTargetBypassesHook', {
|
||||
target: t(`imageAnalysisStatus.targetLabel.${target}`),
|
||||
});
|
||||
}
|
||||
if (status.nativeReadPreference) {
|
||||
return status.nativeImageCapable === true ? null : status.nativeImageReason;
|
||||
}
|
||||
if (status.status === 'hook-missing') {
|
||||
return 'Persist the profile hook before transformer routing can run here.';
|
||||
return t('imageAnalysisStatus.notePersistHook');
|
||||
}
|
||||
if (status.authReadiness === 'missing') {
|
||||
return status.authReason;
|
||||
@@ -137,6 +161,8 @@ export function ImageAnalysisStatusSection({
|
||||
nativeReadPreferenceOverride,
|
||||
onToggleNativeRead,
|
||||
}: ImageAnalysisStatusSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
|
||||
@@ -148,12 +174,12 @@ export function ImageAnalysisStatusSection({
|
||||
|
||||
const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference;
|
||||
const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked };
|
||||
const headerBadge = getHeaderBadge(effectiveStatus, target);
|
||||
const note = getExceptionalNote(effectiveStatus, target);
|
||||
const headerBadge = getHeaderBadge(t, effectiveStatus, target);
|
||||
const note = getExceptionalNote(t, effectiveStatus, target);
|
||||
const capabilityLabel = status.nativeImageCapable
|
||||
? 'Verified'
|
||||
? t('imageAnalysisStatus.capabilityVerified')
|
||||
: status.profileModel
|
||||
? 'Unknown'
|
||||
? t('imageAnalysisStatus.capabilityUnknown')
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -166,13 +192,14 @@ export function ImageAnalysisStatusSection({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Image</h3>
|
||||
<h3 className="text-sm font-semibold">{t('imageAnalysisStatus.sectionTitle')}</h3>
|
||||
<Badge className={cn('h-5 border px-1.5 text-[10px]', headerBadge.className)}>
|
||||
{headerBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)}
|
||||
{getPreviewLabel(t, source, previewState)} ·{' '}
|
||||
{getHeaderLabel(t, effectiveStatus, target)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +207,7 @@ export function ImageAnalysisStatusSection({
|
||||
|
||||
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
|
||||
<Link to="/settings?tab=image">
|
||||
Open Settings
|
||||
{t('imageAnalysisStatus.openSettings')}
|
||||
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -190,7 +217,9 @@ export function ImageAnalysisStatusSection({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-foreground">Use native image reading</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t('imageAnalysisStatus.useNativeImageReading')}
|
||||
</div>
|
||||
{capabilityLabel && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{capabilityLabel}
|
||||
@@ -198,7 +227,7 @@ export function ImageAnalysisStatusSection({
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{getToggleSummary(effectiveStatus, target)}
|
||||
{getToggleSummary(t, effectiveStatus, target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +235,7 @@ export function ImageAnalysisStatusSection({
|
||||
checked={nativeReadChecked}
|
||||
onCheckedChange={onToggleNativeRead}
|
||||
disabled={!onToggleNativeRead}
|
||||
aria-label="Use native image reading"
|
||||
aria-label={t('imageAnalysisStatus.useNativeImageReading')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||
import { Loader2, Code2, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { HeaderSection } from './header-section';
|
||||
import { FriendlyUISection } from './friendly-ui-section';
|
||||
@@ -24,6 +25,7 @@ export function ProfileEditor({
|
||||
onDelete,
|
||||
onHasChangesUpdate,
|
||||
}: ProfileEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
@@ -299,15 +301,15 @@ export function ProfileEditor({
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
<span className="ml-3 text-muted-foreground">{t('settingsDialog.loadingSettings')}</span>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Failed to load settings.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('settingsPage.failedLoad')}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Retry
|
||||
{t('apiProfiles.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,7 +334,7 @@ export function ProfileEditor({
|
||||
<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('rawEditorSection.rawConfig')}
|
||||
</span>
|
||||
</div>
|
||||
<RawEditorSection
|
||||
@@ -355,9 +357,9 @@ export function ProfileEditor({
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
title={t('settingsDialog.conflictTitle')}
|
||||
description={t('settingsDialog.conflictDesc')}
|
||||
confirmText={t('settingsDialog.overwrite')}
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||
import { ImageAnalysisStatusSection } from './image-analysis-status-section';
|
||||
import type { Settings } from './types';
|
||||
@@ -44,6 +45,7 @@ export function RawEditorSection({
|
||||
onChange,
|
||||
missingRequiredFields = [],
|
||||
}: RawEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasMissingFields = missingRequiredFields.length > 0;
|
||||
|
||||
return (
|
||||
@@ -51,7 +53,9 @@ export function RawEditorSection({
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t('profileEditorSections.loadingEditor')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -59,7 +63,7 @@ export function RawEditorSection({
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
Invalid JSON syntax
|
||||
{t('profileEditor.invalidJson')}
|
||||
</div>
|
||||
)}
|
||||
{isRawJsonValid && hasMissingFields && (
|
||||
@@ -67,13 +71,13 @@ export function RawEditorSection({
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
Missing required fields:
|
||||
{t('profileEditor.missingFields')}:
|
||||
</span>{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{missingRequiredFields.join(', ')}
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
These fields will use default values at runtime.
|
||||
{t('profileEditor.missingFieldsHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Settings, SettingsResponse } from './types';
|
||||
|
||||
/** Required env vars for profiles to function (informational only - runtime fills defaults) */
|
||||
@@ -34,6 +35,7 @@ export function useProfileEditor({
|
||||
onSuccess,
|
||||
onConflict,
|
||||
}: UseProfileEditorOptions) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch settings for selected profile
|
||||
@@ -132,11 +134,11 @@ export function useProfileEditor({
|
||||
onSuccess();
|
||||
// Show warning if fields missing (runtime uses defaults)
|
||||
if (data?.warning) {
|
||||
toast.success('Settings saved', {
|
||||
toast.success(t('commonToast.settingsSaved'), {
|
||||
description: data.warning,
|
||||
});
|
||||
} else {
|
||||
toast.success('Settings saved');
|
||||
toast.success(t('commonToast.settingsSaved'));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Visual indicator for OpenRouter-configured profiles
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -13,6 +14,8 @@ interface OpenRouterBadgeProps {
|
||||
}
|
||||
|
||||
export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -33,7 +36,7 @@ export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBad
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Access 349+ models via OpenRouter</p>
|
||||
<p>{t('openrouterBadge.integration')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Sparkles, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
@@ -16,6 +17,7 @@ interface OpenRouterBannerProps {
|
||||
}
|
||||
|
||||
export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(true); // Start hidden to avoid flash
|
||||
const { modelCount, isLoading } = useOpenRouterReady();
|
||||
|
||||
@@ -40,10 +42,13 @@ export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">NEW: OpenRouter Integration</p>
|
||||
<p className="font-medium text-sm">
|
||||
{t('openrouterBadge.new')}: {t('openrouterBadge.integration')}
|
||||
</p>
|
||||
<p className="text-xs text-white/80 truncate">
|
||||
Browse {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
|
||||
Meta and more.
|
||||
{t('openrouterBanner.accessModels', {
|
||||
count: isLoading ? 300 : modelCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +61,7 @@ export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
|
||||
onClick={onCreateClick}
|
||||
className="bg-white text-accent hover:bg-white/90 h-8"
|
||||
>
|
||||
Try it now
|
||||
{t('openrouterBanner.add')}
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -33,9 +34,10 @@ interface OpenRouterModelPickerProps {
|
||||
export function OpenRouterModelPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search models...',
|
||||
placeholder,
|
||||
className,
|
||||
}: OpenRouterModelPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<ModelCategory | null>(null);
|
||||
|
||||
@@ -105,7 +107,7 @@ export function OpenRouterModelPicker({
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder ?? t('openrouterModelPicker.searchModels')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -180,7 +182,7 @@ export function OpenRouterModelPicker({
|
||||
<div>
|
||||
<div className="text-muted-foreground bg-background sticky top-0 mb-2 flex items-center gap-1.5 py-1.5 text-xs font-semibold border-b pb-2">
|
||||
<Sparkles className="h-3 w-3 text-accent" />
|
||||
<span>Newest Models</span>
|
||||
<span>{t('openrouterModelPicker.newestModels')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{newestModels.map((model) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Permanent promotional card for OpenRouter - always visible in sidebar footer
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { Zap } from 'lucide-react';
|
||||
@@ -12,7 +13,8 @@ interface OpenRouterPromoCardProps {
|
||||
}
|
||||
|
||||
export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps) {
|
||||
const { modelCount, isLoading } = useOpenRouterReady();
|
||||
const { t } = useTranslation();
|
||||
useOpenRouterReady();
|
||||
|
||||
return (
|
||||
<div className="p-3 border-t bg-gradient-to-r from-accent/5 to-accent/10 dark:from-accent/10 dark:to-accent/15">
|
||||
@@ -21,9 +23,11 @@ export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps)
|
||||
<img src="/icons/openrouter.svg" alt="" className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-accent dark:text-accent-foreground">OpenRouter</p>
|
||||
<p className="text-xs font-medium text-accent dark:text-accent-foreground">
|
||||
{t('openrouterPromoCard.title')}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{isLoading ? '300+' : `${modelCount}+`} models available
|
||||
{t('openrouterPromoCard.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -33,7 +37,7 @@ export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps)
|
||||
className="h-7 px-2 text-accent hover:text-accent hover:bg-accent/10 dark:hover:bg-accent/20"
|
||||
>
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
<span className="text-xs">Add</span>
|
||||
<span className="text-xs">{t('openrouterBanner.add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -23,6 +24,7 @@ interface ProfileCardProps {
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: ProfileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const showOpenRouterIcon = isOpenRouterProfile(settings);
|
||||
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: P
|
||||
<TooltipTrigger asChild>
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-4 h-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>OpenRouter profile</TooltipContent>
|
||||
<TooltipContent>{t('profileCard.openRouter')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{profile.isActive && (
|
||||
|
||||
@@ -230,7 +230,7 @@ export function ProfileCreateDialog({
|
||||
setValue('haikuModel', model.id);
|
||||
setModelSearch(model.name);
|
||||
// Show feedback that model was applied to all tiers
|
||||
toast.success(`Applied "${model.name}" to all model tiers`, {
|
||||
toast.success(t('profileCreateDialog.appliedModelToTiers', { model: model.name }), {
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
@@ -268,11 +268,11 @@ export function ProfileCreateDialog({
|
||||
};
|
||||
try {
|
||||
await createMutation.mutateAsync(finalData);
|
||||
toast.success(`Profile "${finalData.name}" created`);
|
||||
toast.success(t('profileCreateDialog.profileCreated', { name: finalData.name }));
|
||||
onSuccess(finalData.name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to create profile');
|
||||
toast.error((error as Error).message || t('profileCreateDialog.failedCreate'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -289,11 +289,9 @@ export function ProfileCreateDialog({
|
||||
<DialogHeader className="p-6 pb-4 border-b">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-primary" />
|
||||
Create API Profile
|
||||
{t('profileCreateDialog.createProfile')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a provider preset or configure a custom API endpoint.
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('profileCreateDialog.chooseProviderHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -361,7 +359,7 @@ export function ProfileCreateDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
Local Runtimes
|
||||
{t('openrouterQuickStart.localRuntimesTitle')}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LOCAL_RUNTIME_PRESETS.map((preset) => (
|
||||
@@ -386,13 +384,13 @@ export function ProfileCreateDialog({
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="relative">
|
||||
Basic Information
|
||||
{t('profileCreateDialog.basicInformation')}
|
||||
{hasBasicErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="relative">
|
||||
Model Configuration
|
||||
{t('profileCreateDialog.modelConfiguration')}
|
||||
{hasModelErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
@@ -405,19 +403,19 @@ export function ProfileCreateDialog({
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
{t('profileDialog.name')} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
placeholder="my-api"
|
||||
placeholder={t('profileDialog.namePlaceholder')}
|
||||
className="font-mono"
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in CLI:{' '}
|
||||
{t('profileCreateDialog.usedInCli')}{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
|
||||
</p>
|
||||
)}
|
||||
@@ -425,11 +423,11 @@ export function ProfileCreateDialog({
|
||||
|
||||
{/* Base URL - always editable, pre-filled from preset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Label htmlFor="baseUrl">{t('profileCreateDialog.apiBaseUrl')}</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
{...register('baseUrl')}
|
||||
placeholder="https://api.example.com/v1"
|
||||
placeholder={t('profileCreateDialog.baseUrlPlaceholder')}
|
||||
/>
|
||||
{errors.baseUrl ? (
|
||||
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
||||
@@ -441,12 +439,12 @@ export function ProfileCreateDialog({
|
||||
) : currentPreset ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentPreset.baseUrl
|
||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||
? t('profileCreateDialog.prefilledFromPreset', { name: currentPreset.name })
|
||||
: t('profileCreateDialog.optionalForPreset', { name: currentPreset.name })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
||||
{t('profileCreateDialog.endpointHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -454,12 +452,14 @@ export function ProfileCreateDialog({
|
||||
{/* API Key - optional for presets that don't require it */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key{' '}
|
||||
{t('profileDialog.apiKey')}{' '}
|
||||
{currentPreset?.requiresApiKey !== false && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{currentPreset?.requiresApiKey === false && (
|
||||
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{t('profileCreateDialog.optional')}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
@@ -469,8 +469,9 @@ export function ProfileCreateDialog({
|
||||
{...register('apiKey')}
|
||||
placeholder={
|
||||
currentPreset?.requiresApiKey === false
|
||||
? 'Optional - only if auth is enabled'
|
||||
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
||||
? t('profileCreateDialog.apiKeyOptionalPlaceholder')
|
||||
: (currentPreset?.apiKeyPlaceholder ??
|
||||
t('profileCreateDialog.apiKeyPlaceholder'))
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
@@ -489,7 +490,7 @@ export function ProfileCreateDialog({
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
) : currentPreset?.requiresApiKey === false ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only needed if your local endpoint has authentication enabled
|
||||
{t('profileCreateDialog.apiKeyOptionalHint')}
|
||||
</p>
|
||||
) : (
|
||||
currentPreset?.apiKeyHint && (
|
||||
@@ -499,7 +500,7 @@ export function ProfileCreateDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="target">Default Target CLI</Label>
|
||||
<Label htmlFor="target">{t('codex.defaultTargetCli')}</Label>
|
||||
<Select
|
||||
value={targetValue}
|
||||
onValueChange={(value) => setValue('target', value as CliTarget)}
|
||||
@@ -508,9 +509,9 @@ export function ProfileCreateDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">Claude Code (default)</SelectItem>
|
||||
<SelectItem value="droid">Factory Droid</SelectItem>
|
||||
<SelectItem value="codex">Codex CLI</SelectItem>
|
||||
<SelectItem value="claude">{t('profileCard.claudeCodeDefault')}</SelectItem>
|
||||
<SelectItem value="droid">{t('profileCard.factoryDroid')}</SelectItem>
|
||||
<SelectItem value="codex">{t('profileCard.codexCli')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -553,10 +554,9 @@ export function ProfileCreateDialog({
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Model Mapping</p>
|
||||
<p className="font-medium mb-1">{t('profileCreateDialog.modelMapping')}</p>
|
||||
<p className="text-xs opacity-90">
|
||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||
provider.
|
||||
{t('profileCreateDialog.modelMappingDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,11 +564,11 @@ export function ProfileCreateDialog({
|
||||
{/* OpenRouter Model Picker */}
|
||||
{isOpenRouter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Search Models</Label>
|
||||
<Label>{t('openrouterModelPicker.searchModels')}</Label>
|
||||
<Input
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||
placeholder={t('profileCreateDialog.searchModelsPlaceholder')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filteredModels.length > 0) {
|
||||
e.preventDefault();
|
||||
@@ -580,15 +580,15 @@ export function ProfileCreateDialog({
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||
{modelSearch
|
||||
? `No models found for "${modelSearch}"`
|
||||
: 'Loading models...'}
|
||||
? t('profileCreateDialog.noModelsFound', { query: modelSearch })
|
||||
: t('profileCreateDialog.loadingModels')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!modelSearch && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 text-accent" />
|
||||
<span>Newest Models</span>
|
||||
<span>{t('openrouterModelPicker.newestModels')}</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredModels.map((model) => (
|
||||
@@ -609,7 +609,7 @@ export function ProfileCreateDialog({
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="model">
|
||||
Default Model
|
||||
{t('profileCreateDialog.defaultModel')}
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
ANTHROPIC_MODEL
|
||||
</Badge>
|
||||
@@ -625,7 +625,7 @@ export function ProfileCreateDialog({
|
||||
<div className="grid gap-3 pt-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sonnetModel" className="text-sm">
|
||||
Sonnet Mapping
|
||||
{t('profileCreateDialog.sonnetMapping')}
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_SONNET
|
||||
</Badge>
|
||||
@@ -633,14 +633,14 @@ export function ProfileCreateDialog({
|
||||
<Input
|
||||
id="sonnetModel"
|
||||
{...register('sonnetModel')}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||
placeholder={t('profileCreateDialog.sonnetMappingPlaceholder')}
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="opusModel" className="text-sm">
|
||||
Opus Mapping
|
||||
{t('profileCreateDialog.opusMapping')}
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_OPUS
|
||||
</Badge>
|
||||
@@ -648,14 +648,14 @@ export function ProfileCreateDialog({
|
||||
<Input
|
||||
id="opusModel"
|
||||
{...register('opusModel')}
|
||||
placeholder="e.g. o1, claude-opus-4.5"
|
||||
placeholder={t('profileCreateDialog.opusMappingPlaceholder')}
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="haikuModel" className="text-sm">
|
||||
Haiku Mapping
|
||||
{t('profileCreateDialog.haikuMapping')}
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_HAIKU
|
||||
</Badge>
|
||||
@@ -663,7 +663,7 @@ export function ProfileCreateDialog({
|
||||
<Input
|
||||
id="haikuModel"
|
||||
{...register('haikuModel')}
|
||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||
placeholder={t('profileCreateDialog.haikuMappingPlaceholder')}
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -674,7 +674,7 @@ export function ProfileCreateDialog({
|
||||
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('profileDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -684,12 +684,12 @@ export function ProfileCreateDialog({
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
{t('cliproxyDialog.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
{t('profileCreateDialog.createProfile')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -803,6 +803,7 @@ function ModelSearchItem({
|
||||
onClick: () => void;
|
||||
showAge?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -824,7 +825,7 @@ function ModelSearchItem({
|
||||
variant="secondary"
|
||||
className="text-xs group-hover:bg-accent-foreground/20 group-hover:text-accent-foreground"
|
||||
>
|
||||
Free
|
||||
{t('profileCreateDialog.free')}
|
||||
</Badge>
|
||||
) : (
|
||||
<span>{formatPricingPair(model.pricing)}</span>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useProfiles } from '@/hooks/use-profiles';
|
||||
import { ProfileCard } from './profile-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ProfileDeck() {
|
||||
const { t } = useTranslation();
|
||||
const { data: response, isLoading, error } = useProfiles();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -18,7 +20,11 @@ export function ProfileDeck() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-destructive text-sm">Failed to load profiles: {error.message}</div>;
|
||||
return (
|
||||
<div className="text-destructive text-sm">
|
||||
{t('apiProfiles.failedLoadTitle')}: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = response?.profiles || [];
|
||||
@@ -26,7 +32,7 @@ export function ProfileDeck() {
|
||||
if (!profiles || profiles.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-center py-8">
|
||||
No profiles configured. Create your first profile to get started.
|
||||
{t('apiProfiles.noProfilesDesc')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +45,7 @@ export function ProfileDeck() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Profiles</h2>
|
||||
<h2 className="text-lg font-semibold">{t('profileDeck.profiles')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{normalizedProfiles.map((profile) => (
|
||||
<ProfileCard
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||
// TODO i18n: missing keys for Zod validation messages ("Invalid URL", "Name is required", "Invalid profile name", "API key must be at least 10 characters")
|
||||
const optionalUrlSchema = z
|
||||
.string()
|
||||
.refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { MoreHorizontal, Trash2, Edit } from 'lucide-react';
|
||||
import { useDeleteProfile } from '@/hooks/use-profiles';
|
||||
import type { Profile } from '@/lib/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProfilesTableProps {
|
||||
data: Profile[];
|
||||
@@ -31,20 +32,21 @@ interface ProfilesTableProps {
|
||||
|
||||
export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
|
||||
const deleteMutation = useDeleteProfile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: ColumnDef<Profile>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
header: t('profilesTable.name'),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'settingsPath',
|
||||
header: 'Settings Path',
|
||||
header: t('profileEditor.filePath'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'configured',
|
||||
header: 'Status',
|
||||
header: t('cliproxyTable.status'),
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
|
||||
@@ -54,7 +56,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
header: t('profilesTable.actions'),
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
@@ -67,7 +69,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
|
||||
{onEditSettings && (
|
||||
<DropdownMenuItem onClick={() => onEditSettings(row.original)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
{t('profilesTable.edit')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -76,7 +78,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
|
||||
onClick={() => deleteMutation.mutate(row.original.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
{t('apiProfiles.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -93,9 +95,7 @@ export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) {
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No profiles found. Create one to get started.
|
||||
</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{t('apiProfiles.noProfilesYet')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,14 +192,14 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Quick Setup Wizard
|
||||
{i18n.t('setupWizard.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'provider' && 'Select a provider to get started'}
|
||||
{step === 'auth' && 'Authenticate with your provider'}
|
||||
{step === 'account' && 'Select which account to use'}
|
||||
{step === 'variant' && 'Create your custom variant'}
|
||||
{step === 'success' && 'Setup complete!'}
|
||||
{step === 'provider' && i18n.t('setupWizard.stepProviderDesc')}
|
||||
{step === 'auth' && i18n.t('setupWizard.stepAuthDesc')}
|
||||
{step === 'account' && i18n.t('setupWizard.stepAccountDesc')}
|
||||
{step === 'variant' && i18n.t('setupWizard.stepVariantDesc')}
|
||||
{step === 'success' && i18n.t('setupWizard.stepSuccessDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { AccountStepProps } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function AccountStep({
|
||||
accounts,
|
||||
@@ -17,11 +18,12 @@ export function AccountStep({
|
||||
onAddNew,
|
||||
onBack,
|
||||
}: AccountStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Existing accounts header */}
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Select an account ({accounts.length})
|
||||
{t('setupWizard.accountStep.selectAccount', { count: accounts.length })}
|
||||
</div>
|
||||
|
||||
{/* Scrollable account list with max-height for many accounts */}
|
||||
@@ -63,7 +65,9 @@ export function AccountStep({
|
||||
</Badge>
|
||||
)}
|
||||
{acc.isDefault && (
|
||||
<span className="text-xs text-muted-foreground">Default account</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('setupWizard.accountStep.defaultAccount')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +84,9 @@ export function AccountStep({
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
{t('setupWizard.accountStep.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,15 +100,19 @@ export function AccountStep({
|
||||
<ExternalLink className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-primary">Add new account</div>
|
||||
<div className="text-xs text-muted-foreground">Authenticate with a different account</div>
|
||||
<div className="font-medium text-primary">
|
||||
{t('setupWizard.accountStep.addNewAccount')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('setupWizard.accountStep.addNewAccountDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
{t('setupWizard.accountStep.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Copy, Check, RefreshCw, ArrowLeft, Terminal, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import type { AuthStepProps } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function AuthStep({
|
||||
selectedProvider,
|
||||
@@ -18,6 +19,7 @@ export function AuthStep({
|
||||
onStartAuth,
|
||||
onRefresh,
|
||||
}: AuthStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyCommand = async (cmd: string) => {
|
||||
@@ -31,26 +33,25 @@ export function AuthStep({
|
||||
{/* Primary: OAuth Button */}
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add an
|
||||
account
|
||||
{t('setupWizard.authStep.authenticateWith', {
|
||||
provider: providers.find((p) => p.id === selectedProvider)?.name,
|
||||
})}
|
||||
</p>
|
||||
<Button onClick={onStartAuth} disabled={isPending} className="w-full gap-2" size="lg">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Authenticating...
|
||||
{t('setupWizard.authStep.authenticating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Authenticate in Browser
|
||||
{t('setupWizard.authStep.authenticateInBrowser')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isPending && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Complete the OAuth flow in your browser...
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('setupWizard.authStep.completeOAuth')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -60,7 +61,9 @@ export function AuthStep({
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or use terminal</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
{t('setupWizard.authStep.orUseTerminal')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +72,7 @@ export function AuthStep({
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Run this command in your terminal:
|
||||
{t('setupWizard.authStep.runCommandHint')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
|
||||
@@ -85,11 +88,13 @@ export function AuthStep({
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={onBack} disabled={isPending}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
{t('setupWizard.authStep.back')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onRefresh} disabled={isRefreshing || isPending}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Checking...' : 'Refresh Status'}
|
||||
{isRefreshing
|
||||
? t('setupWizard.authStep.checking')
|
||||
: t('setupWizard.authStep.refreshStatus')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { SuccessStepProps } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function SuccessStep({ variantName, onClose }: SuccessStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
@@ -16,19 +18,19 @@ export function SuccessStep({ variantName, onClose }: SuccessStepProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-lg">Variant Created!</div>
|
||||
<div className="text-sm text-muted-foreground">Your custom variant is ready to use</div>
|
||||
<div className="font-semibold text-lg">{t('setupWizard.successStep.title')}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('setupWizard.successStep.subtitle')}</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Usage:</div>
|
||||
<div className="text-sm text-muted-foreground">{t('setupWizard.successStep.usage')}</div>
|
||||
<code className="block px-3 py-2 bg-muted rounded-md font-mono text-sm">
|
||||
ccs {variantName} "your prompt here"
|
||||
</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button onClick={onClose} className="w-full">
|
||||
Done
|
||||
{t('setupWizard.successStep.done')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -127,9 +127,7 @@ export function VariantStep({
|
||||
placeholder={t('setupVariant.modelPlaceholder')}
|
||||
/>
|
||||
{deniedCustomModel && (
|
||||
<p className="text-xs text-destructive">
|
||||
Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.
|
||||
</p>
|
||||
<p className="text-xs text-destructive">{t('providerEditor.agyDenylist')}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CcsLogoProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
@@ -13,18 +14,19 @@ const sizeMap = {
|
||||
};
|
||||
|
||||
export function CcsLogo({ size = 'md', className, showText = true }: CcsLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const dimension = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<img
|
||||
src="/logo/ccs-logo-256.png"
|
||||
alt="CCS Logo"
|
||||
alt={t('ccsLogo.alt')}
|
||||
width={dimension}
|
||||
height={dimension}
|
||||
className="rounded"
|
||||
/>
|
||||
{showText && <span className="font-bold text-lg">CCS Config</span>}
|
||||
{showText && <span className="font-bold text-lg">{t('ccsLogo.text')}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user