fix(ui): finish dashboard i18n follow-up pass

This commit is contained in:
Tam Nhu Tran committed 2026-04-13 22:55:13 -04:00
1 parent c7e60974ac
commit 87899a489c
25 files changed
+1149 -412

No files matched your search

+9 -1
View File
@@ -1,6 +1,6 @@
# Dashboard i18n Guide # Dashboard i18n Guide
Last Updated: 2026-03-02 Last Updated: 2026-04-14
This document describes the internationalization (i18n) architecture used by the CCS Dashboard (`ui/`), how locale selection works, and how to add new languages safely. This document describes the internationalization (i18n) architecture used by the CCS Dashboard (`ui/`), how locale selection works, and how to add new languages safely.
@@ -14,6 +14,7 @@ Dashboard i18n currently covers UI text rendered by React components.
- `en` (English) - `en` (English)
- `zh-CN` (Simplified Chinese) - `zh-CN` (Simplified Chinese)
- `vi` (Vietnamese) - `vi` (Vietnamese)
- `ja` (Japanese)
- Locale state is persisted in browser localStorage using `ccs-ui-locale`. - Locale state is persisted in browser localStorage using `ccs-ui-locale`.
- Fallback language is `en`. - Fallback language is `en`.
@@ -38,6 +39,7 @@ Out of scope:
- `en.translation` - `en.translation`
- `zh-CN.translation` - `zh-CN.translation`
- `vi.translation` - `vi.translation`
- `ja.translation`
- Uses `initReactI18next` for React integration. - Uses `initReactI18next` for React integration.
### Locale utilities ### Locale utilities
@@ -54,6 +56,12 @@ Out of scope:
- Uses `react-i18next` + shadcn `Select`. - Uses `react-i18next` + shadcn `Select`.
- Calls `persistLocale` and `i18n.changeLanguage` on selection. - Calls `persistLocale` and `i18n.changeLanguage` on selection.
### Test bootstrap
- File: `ui/tests/setup/vitest-setup.ts`
- Test setup must import `ui/src/lib/i18n.ts` so direct `useTranslation()` consumers resolve the same singleton instance as the app.
- If a test mocks `useTranslation()`, keep the mocked key surface aligned with the component output or the assertions will drift.
--- ---
## Key Conventions ## Key Conventions
@@ -30,12 +30,10 @@ export function CodexFeaturesCard({
return ( return (
<CodexConfigCardShell <CodexConfigCardShell
// TODO i18n: missing key codex.features title={t('codex.features')}
title="Features"
badge="features" badge="features"
icon={<Sparkles className="h-4 w-4" />} icon={<Sparkles className="h-4 w-4" />}
// TODO i18n: missing key codex.featuresDesc description={t('codex.featuresDesc')}
description="Toggle the supported Codex feature flags CCS can safely manage."
disabledReason={disabledReason} disabledReason={disabledReason}
> >
<div className="space-y-2"> <div className="space-y-2">
@@ -81,14 +79,9 @@ export function CodexFeaturesCard({
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.configOnlyFlags */} {t('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> </p>
<p className="text-xs text-muted-foreground">{t('codex.configOnlyFlagsDesc')}</p>
</div> </div>
{configOnlyFeatures.map(([name, current]) => ( {configOnlyFeatures.map(([name, current]) => (
<div <div
@@ -99,15 +92,11 @@ export function CodexFeaturesCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-medium">{name}</p> <p className="text-sm font-medium">{name}</p>
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
{/* TODO i18n: missing key codex.existing */} {t('codex.existing')}
existing
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{/* TODO i18n: missing keys codex.nonBooleanForm / codex.discoveredFromFile */} {current === null ? t('codex.nonBooleanForm') : t('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> </p>
</div> </div>
{current === null ? ( {current === null ? (
@@ -74,27 +74,21 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
{/* TODO i18n: missing key codex.howCodexWorks */} {t('codex.howCodexWorks')}
How Codex works in CCS
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-muted-foreground"> <CardContent className="text-sm text-muted-foreground">
<ul className="ml-4 list-disc space-y-1.5 [&>li]:pl-1"> <ul className="ml-4 list-disc space-y-1.5 [&>li]:pl-1">
<li>{t('codex.nativeDesc')}</li> <li>{t('codex.nativeDesc')}</li>
<li> <li>
{/* TODO i18n: missing key codex.nativeConfigLabel */} <strong>{t('codex.nativeConfigLabel')}</strong> {t('codex.nativeConfigDesc')}
<strong>Native config:</strong> <code>ccs-codex</code> and <code>ccsx</code> launch
native Codex using your saved defaults.
</li> </li>
<li> <li>
{/* TODO i18n: missing key codex.transientOverrides */} <strong>{t('codex.transientOverridesLabel')}</strong>{' '}
<strong>Transient overrides:</strong> <code>ccsxp</code> (or{' '} {t('codex.transientOverridesDesc')}
<code>ccs codex --target codex</code>) uses the CCS provider shortcut.
</li> </li>
<li> <li>
{/* TODO i18n: missing key codex.cliproxyDefault */} <strong>{t('codex.cliproxyDefaultLabel')}</strong> {t('codex.cliproxyDefaultDesc')}
<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>
<li>{t('codex.apiProfilesDefault')}</li> <li>{t('codex.apiProfilesDefault')}</li>
</ul> </ul>
@@ -105,34 +99,42 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<TerminalSquare className="h-4 w-4" /> <TerminalSquare className="h-4 w-4" />
{/* TODO i18n: missing key codex.runtimeInstall */} {t('codex.runtimeInstall')}
Runtime install
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t('codex.status')}</span> <span className="text-sm text-muted-foreground">{t('codex.status')}</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}> <Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys codex.detected / codex.notFound */} {diagnostics.binary.installed ? t('codex.detected') : t('codex.notFound')}
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
</Badge> </Badge>
</div> </div>
{/* TODO i18n: missing keys codex.detectionSource / codex.binaryPath / codex.installDirectory / codex.version / codex.nativeAliases / codex.ccsProviderShortcut / codex.configOverrideSupport */} <DetailRow label={t('codex.detectionSource')} value={diagnostics.binary.source} mono />
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow label="Binary path" value={diagnostics.binary.path || 'Not found'} mono />
<DetailRow <DetailRow
label="Install directory" label={t('codex.binaryPath')}
value={diagnostics.binary.path || t('codex.notFound')}
mono
/>
<DetailRow
label={t('codex.installDirectory')}
value={diagnostics.binary.installDir || 'N/A'} value={diagnostics.binary.installDir || 'N/A'}
mono mono
/> />
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono /> <DetailRow
<DetailRow label="Native aliases" value="ccs-codex, ccsx" mono /> label={t('codex.versionLabel')}
<DetailRow label="CCS provider shortcut" value="ccsxp" mono /> value={diagnostics.binary.version || 'Unknown'}
mono
/>
<DetailRow label={t('codex.nativeAliases')} value="ccs-codex, ccsx" mono />
<DetailRow label={t('codex.ccsProviderShortcut')} value="ccsxp" mono />
<div className="flex items-center justify-between rounded-md border px-3 py-2"> <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> <span className="text-sm text-muted-foreground">
{t('codex.configOverrideSupport')}
</span>
<Badge variant={diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'}> <Badge variant={diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys codex.available / codex.missing */} {diagnostics.binary.supportsConfigOverrides
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'} ? t('codex.available')
: t('codex.missing')}
</Badge> </Badge>
</div> </div>
</CardContent> </CardContent>
@@ -142,8 +144,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" /> <Route className="h-4 w-4" />
{/* TODO i18n: missing key codex.cliproxyNativeCodex */} {t('codex.cliproxyNativeCodex')}
CLIProxy-backed native Codex
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground"> <CardContent className="space-y-3 text-sm text-muted-foreground">
@@ -155,14 +156,10 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</p> </p>
<ul className="ml-4 list-disc space-y-1 [&>li]:pl-1"> <ul className="ml-4 list-disc space-y-1 [&>li]:pl-1">
<li> <li>
{/* TODO i18n: missing key codex.builtInCcsxp */} <strong>{t('codex.builtInLabel')}</strong> {t('codex.builtInCcsxpDesc')}
<strong>Built-in:</strong> Use <code>ccsxp</code> for the CCS provider
shortcut.
</li> </li>
<li> <li>
{/* TODO i18n: missing key codex.nativeRecipe */} <strong>{t('codex.nativeRecipeLabel')}</strong> {t('codex.nativeRecipeDesc')}
<strong>Native:</strong> Configure the recipe below to use CLIProxy directly
with <code>codex</code>.
</li> </li>
</ul> </ul>
</div> </div>
@@ -173,29 +170,13 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</pre> </pre>
</div> </div>
<ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1"> <ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1">
<li> <li>{t('codex.saveProviderNamedCliproxy')}</li>
{/* TODO i18n: missing key codex.saveProviderNamedCliproxy */} <li>{t('codex.inTopLevelSetDefault')}</li>
Save a provider named <code>cliproxy</code> with the base URL and env key above. <li>{t('codex.exportCliproxyApiKey')}</li>
</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>
</ol> </ol>
</> </>
) : ( ) : (
<p> <p>{t('codex.noConfigOverrides')}</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.
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -204,8 +185,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
{/* TODO i18n: missing key codex.configFile */} {t('codex.configFile')}
Config file
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -218,21 +198,21 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<XCircle className="h-4 w-4 text-muted-foreground" /> <XCircle className="h-4 w-4 text-muted-foreground" />
)} )}
</div> </div>
{/* TODO i18n: missing keys codex.path / codex.resolved / codex.size / codex.lastModified */} <DetailRow label={t('codex.path')} value={diagnostics.file.path} mono />
<DetailRow label="Path" value={diagnostics.file.path} mono /> <DetailRow label={t('codex.resolved')} value={diagnostics.file.resolvedPath} mono />
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono /> <DetailRow label={t('codex.size')} value={formatBytes(diagnostics.file.sizeBytes)} />
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} /> <DetailRow
<DetailRow label="Last modified" value={formatTimestamp(diagnostics.file.mtimeMs)} /> label={t('codex.lastModified')}
value={formatTimestamp(diagnostics.file.mtimeMs)}
/>
{diagnostics.file.parseError && ( {diagnostics.file.parseError && (
<p className="text-xs text-amber-600"> <p className="text-xs text-amber-600">
{/* TODO i18n: missing key codex.tomlWarning */} {t('codex.tomlWarning')}: {diagnostics.file.parseError}
TOML warning: {diagnostics.file.parseError}
</p> </p>
)} )}
{diagnostics.file.readError && ( {diagnostics.file.readError && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
{/* TODO i18n: missing key codex.readWarning */} {t('codex.readWarning')}: {diagnostics.file.readError}
Read warning: {diagnostics.file.readError}
</p> </p>
)} )}
</div> </div>
@@ -243,64 +223,61 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
{/* TODO i18n: missing key codex.currentUserLayerSummary */} {t('codex.currentUserLayerSummary')}
Current user-layer summary
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* TODO i18n: missing key codex.notSet */}
<DetailRow <DetailRow
label={t('codex.model')} label={t('codex.model')}
value={diagnostics.config.model || 'Not set'} value={diagnostics.config.model || t('codex.notSet')}
mono mono
/> />
<DetailRow <DetailRow
label={t('codex.defaultProvider')} label={t('codex.defaultProvider')}
value={diagnostics.config.modelProvider || 'Not set'} value={diagnostics.config.modelProvider || t('codex.notSet')}
mono mono
/> />
<DetailRow <DetailRow
// TODO i18n: missing key codex.activeProfile label={t('codex.activeProfile')}
label="Active profile" value={diagnostics.config.activeProfile || t('codex.notSet')}
value={diagnostics.config.activeProfile || 'Not set'}
mono mono
/> />
<DetailRow <DetailRow
label={t('codex.approvalPolicy')} label={t('codex.approvalPolicy')}
value={diagnostics.config.approvalPolicy || 'Not set'} value={diagnostics.config.approvalPolicy || t('codex.notSet')}
mono mono
/> />
<DetailRow <DetailRow
label={t('codex.sandboxMode')} label={t('codex.sandboxMode')}
value={diagnostics.config.sandboxMode || 'Not set'} value={diagnostics.config.sandboxMode || t('codex.notSet')}
mono mono
/> />
<DetailRow <DetailRow
label={t('codex.webSearch')} label={t('codex.webSearch')}
value={diagnostics.config.webSearch || 'Not set'} value={diagnostics.config.webSearch || t('codex.notSet')}
mono mono
/> />
<Separator /> <Separator />
<div className="grid grid-cols-2 gap-2 text-xs"> <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"> <Badge variant="outline" className="justify-center">
providers: {diagnostics.config.modelProviderCount} {t('codex.providersCount', { count: diagnostics.config.modelProviderCount })}
</Badge> </Badge>
<Badge variant="outline" className="justify-center"> <Badge variant="outline" className="justify-center">
profiles: {diagnostics.config.profileCount} {t('codex.profilesCount', { count: diagnostics.config.profileCount })}
</Badge> </Badge>
<Badge variant="outline" className="justify-center"> <Badge variant="outline" className="justify-center">
enabled features: {diagnostics.config.enabledFeatures.length} {t('codex.enabledFeaturesCount', {
count: diagnostics.config.enabledFeatures.length,
})}
</Badge> </Badge>
<Badge variant="outline" className="justify-center"> <Badge variant="outline" className="justify-center">
MCP servers: {diagnostics.config.mcpServerCount} {t('codex.mcpServersCount', { count: diagnostics.config.mcpServerCount })}
</Badge> </Badge>
</div> </div>
{diagnostics.config.topLevelKeys.length > 0 && ( {diagnostics.config.topLevelKeys.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.userLayerKeysPresent */} {t('codex.userLayerKeysPresent')}
User-layer keys present
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{diagnostics.config.topLevelKeys.map((key) => ( {diagnostics.config.topLevelKeys.map((key) => (
@@ -317,22 +294,19 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<QuickCommands <QuickCommands
snippets={[ snippets={[
{ {
// TODO i18n: missing key codex.nativeShortAlias label: t('codex.nativeShortAlias'),
label: 'Native short alias',
command: 'ccsx', command: 'ccsx',
description: 'Launch the short native Codex runtime alias.', description: 'Launch the short native Codex runtime alias.',
}, },
{ {
// TODO i18n: missing key codex.ccsCodexShortcut label: t('codex.ccsCodexShortcut'),
label: 'CCS Codex shortcut',
command: 'ccsxp "your prompt"', command: 'ccsxp "your prompt"',
description: supportsManagedRouting description: supportsManagedRouting
? t('codex.runBuiltInCodex') ? t('codex.runBuiltInCodex')
: 'Requires a Codex build that exposes --config overrides.', : 'Requires a Codex build that exposes --config overrides.',
}, },
{ {
// TODO i18n: missing key codex.explicitProviderRoute label: t('codex.explicitProviderRoute'),
label: 'Explicit provider route',
command: 'ccs codex --target codex "your prompt"', command: 'ccs codex --target codex "your prompt"',
description: supportsManagedRouting description: supportsManagedRouting
? t('codex.runBuiltInCodexExplicit') ? t('codex.runBuiltInCodexExplicit')
@@ -354,8 +328,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" /> <Route className="h-4 w-4" />
{/* TODO i18n: missing key codex.runtimeVsProvider */} {t('codex.runtimeVsProvider')}
Runtime vs provider
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2"> <CardContent className="grid gap-3 md:grid-cols-2">
@@ -373,8 +346,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</li> </li>
</ul> </ul>
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal"> <Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
{/* TODO i18n: missing key codex.honorsSavedNativeConfig */} {t('codex.honorsSavedNativeConfig')}
Honors saved native user config
</Badge> </Badge>
</div> </div>
<div className="flex flex-col rounded-md border p-3 text-sm"> <div className="flex flex-col rounded-md border p-3 text-sm">
@@ -390,15 +362,11 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</li> </li>
</ul> </ul>
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal"> <Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
{/* TODO i18n: missing key codex.usesTransientOverrides */} {t('codex.usesTransientOverrides')}
Uses transient overrides
</Badge> </Badge>
</> </>
) : ( ) : (
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">{t('codex.unavailableNoConfig')}</p>
{/* TODO i18n: missing key codex.unavailableNoConfig */}
Unavailable (Codex build lacks <code>--config</code> support).
</p>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -423,8 +391,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<TableCell className="font-mono text-xs">{entry.label}</TableCell> <TableCell className="font-mono text-xs">{entry.label}</TableCell>
<TableCell> <TableCell>
<Badge variant={entry.supported ? 'default' : 'secondary'}> <Badge variant={entry.supported ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys common.yes / common.no */} {entry.supported ? t('codex.yes') : t('codex.no')}
{entry.supported ? 'Yes' : 'No'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground">{entry.notes}</TableCell> <TableCell className="text-xs text-muted-foreground">{entry.notes}</TableCell>
@@ -440,8 +407,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600" /> <AlertTriangle className="h-4 w-4 text-amber-600" />
{/* TODO i18n: missing key codex.warnings */} {t('codex.warningsTitle')}
Warnings
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-1.5"> <CardContent className="space-y-1.5">
+2 -2
View File
@@ -120,7 +120,7 @@ function SettingsDialogContent({
try { try {
settingsToSave = JSON.parse(rawJsonContent); settingsToSave = JSON.parse(rawJsonContent);
} catch { } catch {
throw new Error('Invalid JSON'); throw new Error(i18n.t('settingsDialog.invalidJson'));
} }
} else { } else {
// Use form-based edits // Use form-based edits
@@ -147,7 +147,7 @@ function SettingsDialogContent({
} }
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to save'); throw new Error(i18n.t('settingsDialog.failedSave'));
} }
return res.json(); return res.json();
@@ -3,7 +3,7 @@ import { ArrowUpRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
SUPPORT_SCOPE_LABELS, getSupportScopeLabels,
type CliSupportEntry, type CliSupportEntry,
type SupportScope, type SupportScope,
} from '@/lib/support-updates-catalog'; } from '@/lib/support-updates-catalog';
@@ -23,6 +23,7 @@ const SCOPE_STYLES: Record<SupportScope, string> = {
export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) { export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
const { t } = useTranslation(); const { t } = useTranslation();
const scopeLabels = getSupportScopeLabels();
const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [ const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
{ key: 'baseUrl', label: t('profileDialog.baseUrl') }, { key: 'baseUrl', label: t('profileDialog.baseUrl') },
{ key: 'auth', label: t('copilotPage.auth') }, { key: 'auth', label: t('copilotPage.auth') },
@@ -41,7 +42,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
</div> </div>
<Badge variant="outline" className={SCOPE_STYLES[entry.scope]}> <Badge variant="outline" className={SCOPE_STYLES[entry.scope]}>
{SUPPORT_SCOPE_LABELS[entry.scope]} {scopeLabels[entry.scope]}
</Badge> </Badge>
</CardHeader> </CardHeader>
@@ -9,7 +9,7 @@ import { SupportStatusBadge } from '@/components/updates/support-status-badge';
import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge'; import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge';
import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row'; import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row';
import { import {
SUPPORT_SCOPE_LABELS, getSupportScopeLabels,
formatCatalogDate, formatCatalogDate,
type CliSupportEntry, type CliSupportEntry,
type SupportNotice, type SupportNotice,
@@ -31,6 +31,7 @@ export function UpdatesDetailsPanel({
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const scopeLabels = getSupportScopeLabels();
if (!notice) { if (!notice) {
return ( return (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6"> <div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
@@ -109,7 +110,7 @@ export function UpdatesDetailsPanel({
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium">{entry.name}</p> <p className="min-w-0 truncate text-sm font-medium">{entry.name}</p>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]"> <Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{SUPPORT_SCOPE_LABELS[entry.scope]} {scopeLabels[entry.scope]}
</Badge> </Badge>
</div> </div>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
+9 -9
View File
@@ -135,12 +135,16 @@ export function useUpdateAccountContext() {
}) => api.accounts.updateContext(name, { context_mode, context_group, continuity_mode }), }) => api.accounts.updateContext(name, { context_mode, context_group, continuity_mode }),
onSuccess: (_data, vars) => { onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] }); queryClient.invalidateQueries({ queryKey: ['accounts'] });
const normalizedGroup = (vars.context_group || 'default')
.trim()
.toLowerCase()
.replace(/\s+/g, '-');
const contextSummary = const contextSummary =
vars.context_mode === 'shared' vars.context_mode === 'shared'
? vars.continuity_mode === 'deeper' ? vars.continuity_mode === 'deeper'
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)` ? `${t('accountsPage.sharedDeeper')} (${normalizedGroup})`
: `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)` : `${t('accountsPage.sharedStandard')} (${normalizedGroup})`
: 'isolated'; : t('accountsPage.isolated');
toast.success(t('toasts.contextUpdated', { name: vars.name, summary: contextSummary })); toast.success(t('toasts.contextUpdated', { name: vars.name, summary: contextSummary }));
}, },
onError: (error: Error) => { onError: (error: Error) => {
@@ -180,18 +184,14 @@ export function useConfirmLegacyAccountPolicies() {
onSuccess: ({ updatedCount, failedCount }) => { onSuccess: ({ updatedCount, failedCount }) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] }); queryClient.invalidateQueries({ queryKey: ['accounts'] });
if (failedCount > 0 && updatedCount > 0) { if (failedCount > 0 && updatedCount > 0) {
// TODO i18n: missing key for partial legacy confirmation with failures
toast.error( toast.error(
`Confirmed ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}, but ${failedCount} update${failedCount > 1 ? 's' : ''} failed. Refreshed account state.` t('toasts.legacyConfirmPartial', { updated: updatedCount, failed: failedCount })
); );
return; return;
} }
if (failedCount > 0) { if (failedCount > 0) {
// TODO i18n: missing key for all legacy confirmations failed toast.error(t('toasts.legacyConfirmAllFailed', { failed: failedCount }));
toast.error(
`Failed to confirm ${failedCount} legacy account${failedCount > 1 ? 's' : ''}. Refreshed account state.`
);
return; return;
} }
+14 -20
View File
@@ -131,8 +131,7 @@ export function useCliproxyAuthFlow() {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
isAuthenticating: false, isAuthenticating: false,
// TODO i18n: missing key for 'Authentication timed out. Please try again.' error: t('toasts.providerAuthTimeout'),
error: 'Authentication timed out. Please try again.',
})); }));
} }
return; return;
@@ -161,8 +160,7 @@ export function useCliproxyAuthFlow() {
const hasAccount = typeof data.account === 'object' && data.account !== null; const hasAccount = typeof data.account === 'object' && data.account !== null;
if (!hasAccount) { if (!hasAccount) {
stopPolling(); stopPolling();
// TODO i18n: missing key for 'Authenticated account could not be registered' const errorMsg = t('toasts.providerAccountRegistrationFailed');
const errorMsg = 'Authenticated account could not be registered';
toast.error(errorMsg); toast.error(errorMsg);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -226,11 +224,10 @@ export function useCliproxyAuthFlow() {
} }
stopPolling(); stopPolling();
// TODO i18n: missing key for 'Lost contact with the auth status endpoint'
const message = const message =
error instanceof Error && error.message.trim().length > 0 error instanceof Error && error.message.trim().length > 0
? error.message ? error.message
: 'Lost contact with the auth status endpoint'; : t('toasts.providerLostStatusEndpoint');
toast.error(message); toast.error(message);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -245,10 +242,9 @@ export function useCliproxyAuthFlow() {
const startAuth = useCallback( const startAuth = useCallback(
async (provider: string, options?: StartAuthOptions) => { async (provider: string, options?: StartAuthOptions) => {
if (!isValidProvider(provider)) { if (!isValidProvider(provider)) {
// TODO i18n: missing key for 'Unknown provider: {{provider}}'
setState({ setState({
...INITIAL_STATE, ...INITIAL_STATE,
error: `Unknown provider: ${provider}`, error: t('toasts.providerUnknown', { provider }),
}); });
return; return;
} }
@@ -319,13 +315,12 @@ export function useCliproxyAuthFlow() {
openedAuthUrlRef.current = false; openedAuthUrlRef.current = false;
setState(INITIAL_STATE); setState(INITIAL_STATE);
} else { } else {
// TODO i18n: missing key for 'Authenticated account could not be registered' (start endpoint)
const errorMsg = const errorMsg =
typeof data.error === 'string' typeof data.error === 'string'
? data.error ? data.error
: success : success
? 'Authenticated account could not be registered' ? t('toasts.providerAccountRegistrationFailed')
: 'Authentication failed'; : t('auth.loginFailed');
toast.error(errorMsg); toast.error(errorMsg);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -370,8 +365,8 @@ export function useCliproxyAuthFlow() {
const success = data.success === true; const success = data.success === true;
if (!response.ok || !success) { if (!response.ok || !success) {
// TODO i18n: missing key for 'Failed to start OAuth' const errorMsg =
const errorMsg = typeof data.error === 'string' ? data.error : 'Failed to start OAuth'; typeof data.error === 'string' ? data.error : t('toasts.providerStartOAuthFailed');
throw new Error(errorMsg); throw new Error(errorMsg);
} }
@@ -418,7 +413,7 @@ export function useCliproxyAuthFlow() {
setState(INITIAL_STATE); setState(INITIAL_STATE);
return; return;
} }
const message = error instanceof Error ? error.message : 'Authentication failed'; const message = error instanceof Error ? error.message : t('auth.loginFailed');
toast.error(message); toast.error(message);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -427,7 +422,7 @@ export function useCliproxyAuthFlow() {
})); }));
} }
}, },
[isActiveAttempt, pollStatus, stopPolling, queryClient] [isActiveAttempt, pollStatus, stopPolling, queryClient, t]
); );
const cancelAuth = useCallback(() => { const cancelAuth = useCallback(() => {
@@ -486,21 +481,20 @@ export function useCliproxyAuthFlow() {
toast.success(t('toasts.providerAuthSuccess', { provider: currentProvider })); toast.success(t('toasts.providerAuthSuccess', { provider: currentProvider }));
setState(INITIAL_STATE); setState(INITIAL_STATE);
} else { } else {
// TODO i18n: missing key for 'Callback submission failed'
const errorMsg = const errorMsg =
typeof data.error === 'string' typeof data.error === 'string'
? data.error ? data.error
: success : success
? 'Authenticated account could not be registered' ? t('toasts.providerAccountRegistrationFailed')
: 'Callback submission failed'; : t('toasts.providerCallbackFailed');
throw new Error(errorMsg); throw new Error(errorMsg);
} }
} catch (error) { } catch (error) {
if (!isActiveAttempt(attemptId)) { if (!isActiveAttempt(attemptId)) {
return; return;
} }
// TODO i18n: missing key for 'Failed to submit callback' const message =
const message = error instanceof Error ? error.message : 'Failed to submit callback'; error instanceof Error ? error.message : t('toasts.providerSubmitCallbackFailed');
toast.error(message); toast.error(message);
setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message })); setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message }));
} }
+72 -43
View File
@@ -1,3 +1,5 @@
import i18n from '@/lib/i18n';
export interface CodexTopLevelSettingsView { export interface CodexTopLevelSettingsView {
model: string | null; model: string | null;
modelReasoningEffort: string | null; modelReasoningEffort: string | null;
@@ -58,46 +60,73 @@ base_url = "http://127.0.0.1:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY" env_key = "CLIPROXY_API_KEY"
wire_api = "responses"`; wire_api = "responses"`;
export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [ export const KNOWN_CODEX_FEATURE_NAMES = [
{ 'multi_agent',
name: 'multi_agent', 'unified_exec',
label: 'Multi-agent', // TODO i18n: missing key for codex feature 'shell_snapshot',
description: 'Enable subagent collaboration tools.', // TODO i18n: missing key 'apply_patch_freeform',
}, 'js_repl',
{ 'runtime_metrics',
name: 'unified_exec', 'prevent_idle_sleep',
label: 'Unified exec', // TODO i18n: missing key for codex feature 'fast_mode',
description: 'Use the PTY-backed unified exec tool.', // TODO i18n: missing key 'apps',
}, 'smart_approvals',
{ ] as const;
name: 'shell_snapshot',
label: 'Shell snapshot', // TODO i18n: missing key for codex feature export function getKnownCodexFeatures(): CodexFeatureCatalogEntry[] {
description: 'Reuse shell environment snapshots.', // TODO i18n: missing key return [
}, {
{ name: 'multi_agent',
name: 'apply_patch_freeform', label: i18n.t('codex.featureMultiAgentLabel'),
label: 'Apply patch', // TODO i18n: missing key for codex feature description: i18n.t('codex.featureMultiAgentDesc'),
description: 'Enable freeform apply_patch edits.', // TODO i18n: missing key },
}, {
{ name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' }, // TODO i18n: missing keys name: 'unified_exec',
{ label: i18n.t('codex.featureUnifiedExecLabel'),
name: 'runtime_metrics', description: i18n.t('codex.featureUnifiedExecDesc'),
label: 'Runtime metrics', // TODO i18n: missing key for codex feature },
description: 'Collect Codex runtime metrics.', // TODO i18n: missing key {
}, name: 'shell_snapshot',
{ label: i18n.t('codex.featureShellSnapshotLabel'),
name: 'prevent_idle_sleep', description: i18n.t('codex.featureShellSnapshotDesc'),
label: 'Prevent idle sleep', // TODO i18n: missing key for codex feature },
description: 'Keep the machine awake while active.', // TODO i18n: missing key {
}, name: 'apply_patch_freeform',
{ name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' }, // TODO i18n: missing keys label: i18n.t('codex.featureApplyPatchLabel'),
{ name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' }, // TODO i18n: missing keys description: i18n.t('codex.featureApplyPatchDesc'),
{ },
name: 'smart_approvals', {
label: 'Smart approvals', // TODO i18n: missing key for codex feature name: 'js_repl',
description: 'Route eligible approvals through the guardian flow.', // TODO i18n: missing key label: i18n.t('codex.featureJsReplLabel'),
}, description: i18n.t('codex.featureJsReplDesc'),
]; },
{
name: 'runtime_metrics',
label: i18n.t('codex.featureRuntimeMetricsLabel'),
description: i18n.t('codex.featureRuntimeMetricsDesc'),
},
{
name: 'prevent_idle_sleep',
label: i18n.t('codex.featurePreventIdleSleepLabel'),
description: i18n.t('codex.featurePreventIdleSleepDesc'),
},
{
name: 'fast_mode',
label: i18n.t('codex.featureFastModeLabel'),
description: i18n.t('codex.featureFastModeDesc'),
},
{
name: 'apps',
label: i18n.t('codex.featureAppsLabel'),
description: i18n.t('codex.featureAppsDesc'),
},
{
name: 'smart_approvals',
label: i18n.t('codex.featureSmartApprovalsLabel'),
description: i18n.t('codex.featureSmartApprovalsDesc'),
},
];
}
function asObject(value: unknown): Record<string, unknown> | null { function asObject(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -222,9 +251,9 @@ export function readCodexFeatureState(
const features = asObject(config?.features); const features = asObject(config?.features);
const state: Record<string, boolean | null> = {}; const state: Record<string, boolean | null> = {};
for (const feature of KNOWN_CODEX_FEATURES) { for (const featureName of KNOWN_CODEX_FEATURE_NAMES) {
const value = features?.[feature.name]; const value = features?.[featureName];
state[feature.name] = typeof value === 'boolean' ? value : null; state[featureName] = typeof value === 'boolean' ? value : null;
} }
if (features) { if (features) {
+472
View File
@@ -1066,6 +1066,8 @@ const resources = {
}, },
apiProfiles: { apiProfiles: {
title: 'Profiles', title: 'Profiles',
sidebarTitle: 'API Profiles',
sidebarSubtitle: 'Premium APIs, local runtimes, custom endpoints',
new: 'New', new: 'New',
searchPlaceholder: 'Search profiles...', searchPlaceholder: 'Search profiles...',
loadingProfiles: 'Loading profiles...', loadingProfiles: 'Loading profiles...',
@@ -1087,6 +1089,19 @@ const resources = {
unsavedChangesDesc: unsavedChangesDesc:
'You have unsaved changes in "{{current}}". Discard and switch to "{{next}}"?', 'You have unsaved changes in "{{current}}". Discard and switch to "{{next}}"?',
discardSwitch: 'Discard and Switch', discardSwitch: 'Discard and Switch',
noOrphansFound: 'No orphan profile settings found',
confirmRegisterOrphans:
'Found {{total}} orphan profile setting file(s), with {{valid}} ready to register. Register them now?',
registeredWithSkipped: ' ({{count}} skipped)',
registeredProfiles: 'Registered {{count}} profile(s)',
copyPrompt: 'Copy "{{name}}" to which new profile name?',
destinationEmpty: 'Destination profile name cannot be empty',
exportRedacted:
'Export created with redacted token. Use include-secrets flow in CLI if needed.',
exportDownloaded: 'Profile export downloaded',
importFailed: 'Failed to import profile bundle',
discoverOrphans: 'Discover orphan profiles',
importProfileBundle: 'Import profile bundle',
}, },
accountsPage: { accountsPage: {
title: 'Accounts', title: 'Accounts',
@@ -1578,6 +1593,11 @@ const resources = {
fiveHourResets: '5h resets {{time}}', fiveHourResets: '5h resets {{time}}',
weeklyResets: 'Weekly resets {{time}}', weeklyResets: 'Weekly resets {{time}}',
}, },
utils: {
codeReview5h: 'Code review (5h)',
codeReviewWeekly: 'Code review (weekly)',
codeReview: 'Code review',
},
sponsorButton: { sponsorButton: {
title: 'Sponsor this project on GitHub', title: 'Sponsor this project on GitHub',
sponsor: 'Sponsor', sponsor: 'Sponsor',
@@ -1635,6 +1655,8 @@ const resources = {
conflictDesc: conflictDesc:
'This settings file was modified by another process. Overwrite with your changes or discard?', 'This settings file was modified by another process. Overwrite with your changes or discard?',
overwrite: 'Overwrite', overwrite: 'Overwrite',
invalidJson: 'Invalid JSON',
failedSave: 'Failed to save',
}, },
// ======================================== // ========================================
@@ -1852,12 +1874,60 @@ const resources = {
nativeCodexRuntime: 'Native Codex Runtime', nativeCodexRuntime: 'Native Codex Runtime',
ccsCodexProvider: 'CCS Codex provider / bridge', ccsCodexProvider: 'CCS Codex provider / bridge',
codexDocs: 'Codex docs', codexDocs: 'Codex docs',
howCodexWorks: 'How Codex works in CCS',
supportedFlows: 'Supported flows', supportedFlows: 'Supported flows',
twoSupportedPaths: 'Two supported paths:', twoSupportedPaths: 'Two supported paths:',
nativeLabel: 'Native:', nativeLabel: 'Native:',
nativeDesc: 'Codex is a first-class, runtime-only target in CCS v1.', nativeDesc: 'Codex is a first-class, runtime-only target in CCS v1.',
nativeConfigLabel: 'Native config:',
nativeConfigDesc: 'ccs-codex and ccsx launch native Codex using your saved defaults.',
transientOverridesLabel: 'Transient overrides:',
transientOverridesDesc:
'ccsxp (or ccs codex --target codex) uses the CCS provider shortcut.',
cliproxyDefaultLabel: 'CLIProxy default:',
cliproxyDefaultDesc:
'To make plain codex use CLIProxy, set model_provider = "cliproxy" and add the recipe below.',
ccsBridge: 'CCS Bridge', ccsBridge: 'CCS Bridge',
apiProfilesDefault: 'API profiles continue to default to Claude or Droid.', apiProfilesDefault: 'API profiles continue to default to Claude or Droid.',
runtimeInstall: 'Runtime install',
detected: 'Detected',
notFound: 'Not found',
detectionSource: 'Detection source',
binaryPath: 'Binary path',
installDirectory: 'Install directory',
versionLabel: 'Version',
nativeAliases: 'Native aliases',
ccsProviderShortcut: 'CCS provider shortcut',
configOverrideSupport: '--config override support',
available: 'Available',
missing: 'Missing',
cliproxyNativeCodex: 'CLIProxy-backed native Codex',
builtInLabel: 'Built-in:',
builtInCcsxpDesc: 'Use ccsxp for the CCS provider shortcut.',
nativeRecipeLabel: 'Native:',
nativeRecipeDesc: 'Configure the recipe below to use CLIProxy directly with codex.',
saveProviderNamedCliproxy:
'Save a provider named cliproxy with the base URL and env key above.',
inTopLevelSetDefault: 'In Top-level settings, set Default provider to cliproxy.',
exportCliproxyApiKey:
'Export CLIPROXY_API_KEY in your shell before launching native Codex.',
noConfigOverrides:
'This Codex build can still use the native path, but CCS-backed Codex routing via ccsxp or ccs codex --target codex stays unavailable until the detected Codex binary exposes --config overrides.',
configFile: 'Config file',
path: 'Path',
resolved: 'Resolved',
size: 'Size',
lastModified: 'Last modified',
tomlWarning: 'TOML warning',
readWarning: 'Read warning',
currentUserLayerSummary: 'Current user-layer summary',
activeProfile: 'Active profile',
notSet: 'Not set',
providersCount: 'providers: {{count}}',
profilesCount: 'profiles: {{count}}',
enabledFeaturesCount: 'enabled features: {{count}}',
mcpServersCount: 'mcp servers: {{count}}',
userLayerKeysPresent: 'User-layer keys present',
recommendedSetupFlow: 'Recommended setup flow', recommendedSetupFlow: 'Recommended setup flow',
fastestPath: 'Fastest path', fastestPath: 'Fastest path',
officialChannels: 'Official Channels', officialChannels: 'Official Channels',
@@ -1866,6 +1936,9 @@ const resources = {
runBuiltInCodex: 'Run built-in Codex on Codex', runBuiltInCodex: 'Run built-in Codex on Codex',
runBuiltInCodexExplicit: 'Run built-in Codex on Codex (explicit)', runBuiltInCodexExplicit: 'Run built-in Codex on Codex (explicit)',
openCodexDashboard: 'Open Codex dashboard', openCodexDashboard: 'Open Codex dashboard',
nativeShortAlias: 'Native short alias',
ccsCodexShortcut: 'CCS Codex shortcut',
explicitProviderRoute: 'Explicit provider route',
status: 'Status', status: 'Status',
profiles: 'Profiles', profiles: 'Profiles',
createNewProfile: 'Create new profile', createNewProfile: 'Create new profile',
@@ -1895,6 +1968,10 @@ const resources = {
untrusted: 'untrusted', untrusted: 'untrusted',
noProjectTrustEntries: 'No explicit project trust entries saved.', noProjectTrustEntries: 'No explicit project trust entries saved.',
codexNativeRecipe: 'Saved native Codex recipe', codexNativeRecipe: 'Saved native Codex recipe',
runtimeVsProvider: 'Runtime vs provider',
honorsSavedNativeConfig: 'Honors saved native user config',
usesTransientOverrides: 'Uses transient overrides',
unavailableNoConfig: 'Unavailable (Codex build lacks --config support).',
gptContextCap: 'GPT-5.4 context cap', gptContextCap: 'GPT-5.4 context cap',
usageLimitCost: 'Usage-limit cost above 272K', usageLimitCost: 'Usage-limit cost above 272K',
longContextOverride: 'Long context override', longContextOverride: 'Long context override',
@@ -1911,6 +1988,37 @@ const resources = {
configYaml: 'config.yaml', configYaml: 'config.yaml',
flow: 'Flow', flow: 'Flow',
docsTab: 'Docs', docsTab: 'Docs',
yes: 'Yes',
no: 'No',
warningsTitle: 'Warnings',
features: 'Features',
featuresDesc: 'Toggle the supported Codex feature flags CCS can safely manage.',
configOnlyFlags: 'Existing config-only flags',
configOnlyFlagsDesc:
'These feature keys already exist in your `config.toml`, so CCS can surface them without claiming full catalog coverage.',
existing: 'existing',
nonBooleanForm: 'Stored in a non-boolean form. Use raw TOML if you need to edit it.',
discoveredFromFile: "Discovered from the current file instead of CCS's built-in catalog.",
featureMultiAgentLabel: 'Multi-agent',
featureMultiAgentDesc: 'Enable subagent collaboration tools.',
featureUnifiedExecLabel: 'Unified exec',
featureUnifiedExecDesc: 'Use the PTY-backed unified exec tool.',
featureShellSnapshotLabel: 'Shell snapshot',
featureShellSnapshotDesc: 'Reuse shell environment snapshots.',
featureApplyPatchLabel: 'Apply patch',
featureApplyPatchDesc: 'Enable freeform apply_patch edits.',
featureJsReplLabel: 'JS REPL',
featureJsReplDesc: 'Enable the Node-backed JavaScript REPL.',
featureRuntimeMetricsLabel: 'Runtime metrics',
featureRuntimeMetricsDesc: 'Collect Codex runtime metrics.',
featurePreventIdleSleepLabel: 'Prevent idle sleep',
featurePreventIdleSleepDesc: 'Keep the machine awake while active.',
featureFastModeLabel: 'Fast mode',
featureFastModeDesc: 'Allow the fast service tier path.',
featureAppsLabel: 'Apps',
featureAppsDesc: 'Enable ChatGPT Apps and connectors support.',
featureSmartApprovalsLabel: 'Smart approvals',
featureSmartApprovalsDesc: 'Route eligible approvals through the guardian flow.',
}, },
droidSettings: { droidSettings: {
quickControls: 'Quick Controls', quickControls: 'Quick Controls',
@@ -2022,6 +2130,13 @@ const resources = {
syncFailed: 'Sync failed: {{error}}', syncFailed: 'Sync failed: {{error}}',
providerAuthSuccess: '{{provider}} authentication successful', providerAuthSuccess: '{{provider}} authentication successful',
providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode', providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode',
providerAuthTimeout: 'Authentication timed out. Please try again.',
providerAccountRegistrationFailed: 'Authenticated account could not be registered',
providerLostStatusEndpoint: 'Lost contact with the auth status endpoint',
providerUnknown: 'Unknown provider: {{provider}}',
providerStartOAuthFailed: 'Failed to start OAuth',
providerCallbackFailed: 'Callback submission failed',
providerSubmitCallbackFailed: 'Failed to submit callback',
loggingConfigSaved: 'Logging configuration saved.', loggingConfigSaved: 'Logging configuration saved.',
loggingConfigSaveFailed: 'Failed to save logging configuration.', loggingConfigSaveFailed: 'Failed to save logging configuration.',
unifiedConfigUpdated: 'Configuration updated successfully', unifiedConfigUpdated: 'Configuration updated successfully',
@@ -2039,6 +2154,10 @@ const resources = {
legacyConfirmFailed: 'Legacy account "{{name}}" failed confirmation: {{error}}', legacyConfirmFailed: 'Legacy account "{{name}}" failed confirmation: {{error}}',
legacyConfirmSuccess_one: '{{count}} legacy account confirmed', legacyConfirmSuccess_one: '{{count}} legacy account confirmed',
legacyConfirmSuccess_other: '{{count}} legacy accounts confirmed', legacyConfirmSuccess_other: '{{count}} legacy accounts confirmed',
legacyConfirmPartial:
'Confirmed {{updated}} legacy account(s), but {{failed}} update(s) failed. Refreshed account state.',
legacyConfirmAllFailed:
'Failed to confirm {{failed}} legacy account(s). Refreshed account state.',
noLegacyAccounts: 'No legacy accounts need confirmation', noLegacyAccounts: 'No legacy accounts need confirmation',
routingStrategySet: 'Routing strategy set to {{strategy}}', routingStrategySet: 'Routing strategy set to {{strategy}}',
variantCreated: 'Variant created successfully', variantCreated: 'Variant created successfully',
@@ -3315,6 +3434,8 @@ const resources = {
}, },
apiProfiles: { apiProfiles: {
title: 'API 配置', title: 'API 配置',
sidebarTitle: 'API 配置',
sidebarSubtitle: '高级 API、本地运行时、自定义端点',
new: '新建', new: '新建',
searchPlaceholder: '搜索配置...', searchPlaceholder: '搜索配置...',
loadingProfiles: '正在加载配置...', loadingProfiles: '正在加载配置...',
@@ -3334,6 +3455,19 @@ const resources = {
unsavedChangesTitle: '未保存的更改', unsavedChangesTitle: '未保存的更改',
unsavedChangesDesc: '你在“{{current}}”中有未保存内容,是否丢弃并切换到“{{next}}”?', unsavedChangesDesc: '你在“{{current}}”中有未保存内容,是否丢弃并切换到“{{next}}”?',
discardSwitch: '丢弃并切换', discardSwitch: '丢弃并切换',
noOrphansFound: '未发现孤立配置',
confirmRegisterOrphans:
'发现 {{total}} 个孤立配置文件,其中 {{valid}} 个可直接注册。现在注册吗?',
registeredWithSkipped: '(跳过 {{count}} 个)',
registeredProfiles: '已注册 {{count}} 个配置',
copyPrompt: '将“{{name}}”复制为哪个新配置名称?',
destinationEmpty: '目标配置名称不能为空',
exportRedacted:
'导出内容中的令牌已脱敏。如需包含密钥,请改用 CLI 的 include-secrets 流程。',
exportDownloaded: '配置导出已下载',
importFailed: '导入配置包失败',
discoverOrphans: '发现孤立配置',
importProfileBundle: '导入配置包',
}, },
accountsPage: { accountsPage: {
title: '账号', title: '账号',
@@ -3812,6 +3946,11 @@ const resources = {
fiveHourResets: '5 小时重置于 {{time}}', fiveHourResets: '5 小时重置于 {{time}}',
weeklyResets: '每周重置于 {{time}}', weeklyResets: '每周重置于 {{time}}',
}, },
utils: {
codeReview5h: '代码审查(5 小时)',
codeReviewWeekly: '代码审查(每周)',
codeReview: '代码审查',
},
sponsorButton: { sponsorButton: {
title: '在 GitHub 上赞助此项目', title: '在 GitHub 上赞助此项目',
sponsor: '赞助', sponsor: '赞助',
@@ -3868,6 +4007,8 @@ const resources = {
conflictTitle: '文件被外部修改', conflictTitle: '文件被外部修改',
conflictDesc: '此设置文件已被其他进程修改。用你的更改覆盖还是丢弃?', conflictDesc: '此设置文件已被其他进程修改。用你的更改覆盖还是丢弃?',
overwrite: '覆盖', overwrite: '覆盖',
invalidJson: 'JSON 无效',
failedSave: '保存失败',
}, },
setupWizard: { setupWizard: {
title: '快速设置向导', title: '快速设置向导',
@@ -4131,6 +4272,90 @@ const resources = {
configYaml: 'config.yaml', configYaml: 'config.yaml',
flow: '工作流', flow: '工作流',
docsTab: '文档', docsTab: '文档',
features: '功能',
featuresDesc: '切换 CCS 可安全管理的 Codex 功能开关。',
configOnlyFlags: '仅配置文件中的现有开关',
configOnlyFlagsDesc:
'这些功能键已存在于你的 `config.toml` 中,因此 CCS 可以显示它们,而不必宣称自己覆盖了完整目录。',
existing: '已存在',
nonBooleanForm: '该值以非布尔形式保存。如果需要编辑,请使用原始 TOML。',
discoveredFromFile: '这是从当前文件中发现的,而不是来自 CCS 内置目录。',
featureMultiAgentLabel: 'Multi-agent',
featureMultiAgentDesc: '启用子代理协作工具。',
featureUnifiedExecLabel: 'Unified exec',
featureUnifiedExecDesc: '使用基于 PTY 的统一 exec 工具。',
featureShellSnapshotLabel: 'Shell snapshot',
featureShellSnapshotDesc: '复用 shell 环境快照。',
featureApplyPatchLabel: 'Apply patch',
featureApplyPatchDesc: '启用自由格式 apply_patch 编辑。',
featureJsReplLabel: 'JS REPL',
featureJsReplDesc: '启用基于 Node 的 JavaScript REPL。',
featureRuntimeMetricsLabel: 'Runtime metrics',
featureRuntimeMetricsDesc: '收集 Codex 运行时指标。',
featurePreventIdleSleepLabel: 'Prevent idle sleep',
featurePreventIdleSleepDesc: '保持机器在运行期间不休眠。',
featureFastModeLabel: 'Fast mode',
featureFastModeDesc: '允许使用快速服务层路径。',
featureAppsLabel: 'Apps',
featureAppsDesc: '启用 ChatGPT Apps 和连接器支持。',
featureSmartApprovalsLabel: 'Smart approvals',
featureSmartApprovalsDesc: '将符合条件的审批交给 guardian 流程处理。',
howCodexWorks: 'Codex 在 CCS 中的工作方式',
nativeConfigLabel: '原生配置:',
nativeConfigDesc: 'ccs-codex 和 ccsx 会使用你保存的默认值启动原生 Codex。',
transientOverridesLabel: '临时覆盖:',
transientOverridesDesc: 'ccsxp(或 ccs codex --target codex)会使用 CCS 提供商快捷方式。',
cliproxyDefaultLabel: 'CLIProxy 默认:',
cliproxyDefaultDesc:
'如果你想让普通的 codex 走 CLIProxy,请设置 model_provider = "cliproxy",并加入下面的配方。',
runtimeInstall: '运行时安装',
detected: '已检测到',
notFound: '未找到',
detectionSource: '检测来源',
binaryPath: '二进制路径',
installDirectory: '安装目录',
versionLabel: '版本',
nativeAliases: '原生别名',
ccsProviderShortcut: 'CCS 提供商快捷方式',
configOverrideSupport: '--config 覆盖支持',
available: '可用',
missing: '缺失',
cliproxyNativeCodex: '由 CLIProxy 支持的原生 Codex',
builtInLabel: '内置:',
builtInCcsxpDesc: '使用 ccsxp 作为 CCS 提供商快捷方式。',
nativeRecipeLabel: '原生:',
nativeRecipeDesc: '按下面的配方配置,即可让 codex 直接走 CLIProxy。',
saveProviderNamedCliproxy:
'保存一个名为 cliproxy 的 provider,并填入上面的 base URL 与 env key。',
inTopLevelSetDefault: '在顶层设置中,将 Default provider 设为 cliproxy。',
exportCliproxyApiKey: '在启动原生 Codex 之前,在 shell 中导出 CLIPROXY_API_KEY。',
noConfigOverrides:
'这个 Codex 版本仍然可以走原生路径,但在检测到的二进制支持 --config 覆盖之前,ccsxp 或 ccs codex --target codex 这样的 CCS 路由不可用。',
configFile: '配置文件',
path: '路径',
resolved: '已解析',
size: '大小',
lastModified: '最后修改',
tomlWarning: 'TOML 警告',
readWarning: '读取警告',
currentUserLayerSummary: '当前用户层摘要',
activeProfile: '当前激活的配置',
notSet: '未设置',
providersCount: 'providers: {{count}}',
profilesCount: 'profiles: {{count}}',
enabledFeaturesCount: 'enabled features: {{count}}',
mcpServersCount: 'mcp servers: {{count}}',
userLayerKeysPresent: '已存在的用户层键',
nativeShortAlias: '原生短别名',
ccsCodexShortcut: 'CCS Codex 快捷方式',
explicitProviderRoute: '显式 provider 路径',
runtimeVsProvider: '运行时与 provider 的区别',
honorsSavedNativeConfig: '遵循已保存的原生用户配置',
usesTransientOverrides: '使用临时覆盖',
unavailableNoConfig: '不可用(当前 Codex 版本缺少 --config 支持)。',
yes: '是',
no: '否',
warningsTitle: '警告',
}, },
droidSettings: { droidSettings: {
quickControls: '快捷控制', quickControls: '快捷控制',
@@ -4234,6 +4459,13 @@ const resources = {
syncFailed: '同步失败:{{error}}', syncFailed: '同步失败:{{error}}',
providerAuthSuccess: '{{provider}} 认证成功', providerAuthSuccess: '{{provider}} 认证成功',
providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程', providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程',
providerAuthTimeout: '认证超时,请重试。',
providerAccountRegistrationFailed: '已认证账号无法注册',
providerLostStatusEndpoint: '与认证状态端点失去连接',
providerUnknown: '未知提供商:{{provider}}',
providerStartOAuthFailed: '启动 OAuth 失败',
providerCallbackFailed: '回调提交失败',
providerSubmitCallbackFailed: '提交回调失败',
loggingConfigSaved: '日志配置已保存。', loggingConfigSaved: '日志配置已保存。',
loggingConfigSaveFailed: '保存日志配置失败。', loggingConfigSaveFailed: '保存日志配置失败。',
unifiedConfigUpdated: '配置更新成功', unifiedConfigUpdated: '配置更新成功',
@@ -4250,6 +4482,9 @@ const resources = {
legacyConfirmFailed: '旧版账号「{{name}}」确认失败:{{error}}', legacyConfirmFailed: '旧版账号「{{name}}」确认失败:{{error}}',
legacyConfirmSuccess_one: '{{count}} 个旧版账号已确认', legacyConfirmSuccess_one: '{{count}} 个旧版账号已确认',
legacyConfirmSuccess_other: '{{count}} 个旧版账号已确认', legacyConfirmSuccess_other: '{{count}} 个旧版账号已确认',
legacyConfirmPartial:
'已确认 {{updated}} 个旧版账号,但有 {{failed}} 个更新失败。账号状态已刷新。',
legacyConfirmAllFailed: '确认 {{failed}} 个旧版账号失败。账号状态已刷新。',
noLegacyAccounts: '没有需要确认的旧版账号', noLegacyAccounts: '没有需要确认的旧版账号',
routingStrategySet: '路由策略已设为 {{strategy}}', routingStrategySet: '路由策略已设为 {{strategy}}',
variantCreated: '变体创建成功', variantCreated: '变体创建成功',
@@ -5604,6 +5839,8 @@ const resources = {
}, },
apiProfiles: { apiProfiles: {
title: 'Hồ sơ API', title: 'Hồ sơ API',
sidebarTitle: 'Hồ sơ API',
sidebarSubtitle: 'API cao cấp, runtime cục bộ, endpoint tùy chỉnh',
new: 'Mới', new: 'Mới',
searchPlaceholder: 'Tìm kiếm hồ sơ...', searchPlaceholder: 'Tìm kiếm hồ sơ...',
loadingProfiles: 'Đang tải hồ sơ...', loadingProfiles: 'Đang tải hồ sơ...',
@@ -5625,6 +5862,18 @@ const resources = {
unsavedChangesDesc: unsavedChangesDesc:
'Bạn có những thay đổi chưa được lưu trong "{{current}}". Hủy và chuyển sang "{{next}}"?', 'Bạn có những thay đổi chưa được lưu trong "{{current}}". Hủy và chuyển sang "{{next}}"?',
discardSwitch: 'Loại bỏ và chuyển đổi', discardSwitch: 'Loại bỏ và chuyển đổi',
noOrphansFound: 'Không tìm thấy cấu hình hồ sơ mồ côi',
confirmRegisterOrphans:
'Đã tìm thấy {{total}} tệp cấu hình hồ sơ mồ côi, trong đó {{valid}} tệp sẵn sàng đăng ký. Đăng ký ngay?',
registeredWithSkipped: ' (bỏ qua {{count}})',
registeredProfiles: 'Đã đăng ký {{count}} hồ sơ',
copyPrompt: 'Sao chép "{{name}}" sang tên hồ sơ mới nào?',
destinationEmpty: 'Tên hồ sơ đích không được để trống',
exportRedacted: 'Bản xuất đã được che token. Dùng luồng include-secrets trong CLI nếu cần.',
exportDownloaded: 'Đã tải xuống bản xuất hồ sơ',
importFailed: 'Không nhập được gói hồ sơ',
discoverOrphans: 'Phát hiện hồ sơ mồ côi',
importProfileBundle: 'Nhập gói hồ sơ',
}, },
accountsPage: { accountsPage: {
title: 'Tài khoản', title: 'Tài khoản',
@@ -6116,6 +6365,11 @@ const resources = {
fiveHourResets: 'Reset 5h lúc {{time}}', fiveHourResets: 'Reset 5h lúc {{time}}',
weeklyResets: 'Reset hàng tuần lúc {{time}}', weeklyResets: 'Reset hàng tuần lúc {{time}}',
}, },
utils: {
codeReview5h: 'Đánh giá mã (5h)',
codeReviewWeekly: 'Đánh giá mã (hàng tuần)',
codeReview: 'Đánh giá mã',
},
sponsorButton: { sponsorButton: {
title: 'Tài trợ dự án này trên GitHub', title: 'Tài trợ dự án này trên GitHub',
sponsor: 'Tài trợ', sponsor: 'Tài trợ',
@@ -6173,6 +6427,8 @@ const resources = {
conflictDesc: conflictDesc:
'Tệp cài đặt này đã bị thay đổi bởi một tiến trình khác. Ghi đè thay đổi của bạn hay hủy?', 'Tệp cài đặt này đã bị thay đổi bởi một tiến trình khác. Ghi đè thay đổi của bạn hay hủy?',
overwrite: 'Ghi đè', overwrite: 'Ghi đè',
invalidJson: 'JSON không hợp lệ',
failedSave: 'Không lưu được',
}, },
setupWizard: { setupWizard: {
title: 'Trình thiết lập nhanh', title: 'Trình thiết lập nhanh',
@@ -6438,6 +6694,91 @@ const resources = {
configYaml: 'config.yaml', configYaml: 'config.yaml',
flow: 'Luồng', flow: 'Luồng',
docsTab: 'Tài liệu', docsTab: 'Tài liệu',
features: 'Tính năng',
featuresDesc: 'Bật/tắt các cờ tính năng Codex mà CCS có thể quản lý an toàn.',
configOnlyFlags: 'Cờ chỉ có trong cấu hình',
configOnlyFlagsDesc:
'Các khóa này đã tồn tại trong `config.toml`, nên CCS có thể hiển thị chúng mà không cần tuyên bố bao phủ toàn bộ danh mục.',
existing: 'đã có',
nonBooleanForm:
'Giá trị đang được lưu ở dạng không phải boolean. Hãy dùng TOML thô nếu bạn muốn chỉnh sửa.',
discoveredFromFile: 'Được phát hiện từ tệp hiện tại thay vì từ danh mục tích hợp của CCS.',
featureMultiAgentLabel: 'Multi-agent',
featureMultiAgentDesc: 'Bật các công cụ cộng tác giữa subagent.',
featureUnifiedExecLabel: 'Unified exec',
featureUnifiedExecDesc: 'Dùng công cụ exec hợp nhất dựa trên PTY.',
featureShellSnapshotLabel: 'Shell snapshot',
featureShellSnapshotDesc: 'Tái sử dụng snapshot môi trường shell.',
featureApplyPatchLabel: 'Apply patch',
featureApplyPatchDesc: 'Bật chỉnh sửa apply_patch dạng tự do.',
featureJsReplLabel: 'JS REPL',
featureJsReplDesc: 'Bật JavaScript REPL dựa trên Node.',
featureRuntimeMetricsLabel: 'Runtime metrics',
featureRuntimeMetricsDesc: 'Thu thập chỉ số runtime của Codex.',
featurePreventIdleSleepLabel: 'Prevent idle sleep',
featurePreventIdleSleepDesc: 'Giữ máy không ngủ khi đang hoạt động.',
featureFastModeLabel: 'Fast mode',
featureFastModeDesc: 'Cho phép dùng đường dẫn fast service tier.',
featureAppsLabel: 'Apps',
featureAppsDesc: 'Bật hỗ trợ ChatGPT Apps và connectors.',
featureSmartApprovalsLabel: 'Smart approvals',
featureSmartApprovalsDesc: 'Đưa các phê duyệt phù hợp qua luồng guardian.',
howCodexWorks: 'Codex hoạt động trong CCS như thế nào',
nativeConfigLabel: 'Cấu hình gốc:',
nativeConfigDesc: 'ccs-codex và ccsx khởi chạy Codex gốc bằng các mặc định bạn đã lưu.',
transientOverridesLabel: 'Ghi đè tạm thời:',
transientOverridesDesc:
'ccsxp (hoặc ccs codex --target codex) dùng đường tắt nhà cung cấp CCS.',
cliproxyDefaultLabel: 'Mặc định CLIProxy:',
cliproxyDefaultDesc:
'Để lệnh codex thông thường đi qua CLIProxy, hãy đặt model_provider = "cliproxy" và dùng công thức bên dưới.',
runtimeInstall: 'Cài đặt runtime',
detected: 'Đã phát hiện',
notFound: 'Không tìm thấy',
detectionSource: 'Nguồn phát hiện',
binaryPath: 'Đường dẫn binary',
installDirectory: 'Thư mục cài đặt',
versionLabel: 'Phiên bản',
nativeAliases: 'Bí danh gốc',
ccsProviderShortcut: 'Lối tắt nhà cung cấp CCS',
configOverrideSupport: 'Hỗ trợ ghi đè --config',
available: 'Có sẵn',
missing: 'Thiếu',
cliproxyNativeCodex: 'Codex gốc dùng CLIProxy',
builtInLabel: 'Tích hợp:',
builtInCcsxpDesc: 'Dùng ccsxp cho lối tắt nhà cung cấp CCS.',
nativeRecipeLabel: 'Gốc:',
nativeRecipeDesc: 'Cấu hình công thức bên dưới để dùng CLIProxy trực tiếp với codex.',
saveProviderNamedCliproxy: 'Lưu một provider tên cliproxy với base URL và env key ở trên.',
inTopLevelSetDefault: 'Trong Top-level settings, đặt Default provider là cliproxy.',
exportCliproxyApiKey: 'Xuất CLIPROXY_API_KEY trong shell trước khi chạy Codex gốc.',
noConfigOverrides:
'Bản Codex này vẫn có thể dùng đường gốc, nhưng định tuyến Codex qua CCS như ccsxp hoặc ccs codex --target codex sẽ không hoạt động cho đến khi binary hỗ trợ ghi đè --config.',
configFile: 'Tệp cấu hình',
path: 'Đường dẫn',
resolved: 'Đã phân giải',
size: 'Kích thước',
lastModified: 'Sửa đổi lần cuối',
tomlWarning: 'Cảnh báo TOML',
readWarning: 'Cảnh báo đọc',
currentUserLayerSummary: 'Tóm tắt lớp người dùng hiện tại',
activeProfile: 'Hồ sơ đang dùng',
notSet: 'Chưa đặt',
providersCount: 'providers: {{count}}',
profilesCount: 'profiles: {{count}}',
enabledFeaturesCount: 'enabled features: {{count}}',
mcpServersCount: 'mcp servers: {{count}}',
userLayerKeysPresent: 'Các khóa lớp người dùng hiện có',
nativeShortAlias: 'Bí danh gốc ngắn',
ccsCodexShortcut: 'Lối tắt CCS Codex',
explicitProviderRoute: 'Tuyến provider tường minh',
runtimeVsProvider: 'Runtime so với provider',
honorsSavedNativeConfig: 'Tuân theo cấu hình gốc đã lưu',
usesTransientOverrides: 'Dùng ghi đè tạm thời',
unavailableNoConfig: 'Không khả dụng (bản Codex này thiếu hỗ trợ --config).',
yes: 'Có',
no: 'Không',
warningsTitle: 'Cảnh báo',
}, },
droidSettings: { droidSettings: {
quickControls: 'Điều khiển nhanh', quickControls: 'Điều khiển nhanh',
@@ -6541,6 +6882,13 @@ const resources = {
syncFailed: 'Đồng bộ thất bại: {{error}}', syncFailed: 'Đồng bộ thất bại: {{error}}',
providerAuthSuccess: 'Xác thực {{provider}} thành công', providerAuthSuccess: 'Xác thực {{provider}} thành công',
providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback', providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback',
providerAuthTimeout: 'Đã hết thời gian xác thực. Vui lòng thử lại.',
providerAccountRegistrationFailed: 'Không thể đăng ký tài khoản đã xác thực',
providerLostStatusEndpoint: 'Mất kết nối với điểm cuối trạng thái xác thực',
providerUnknown: 'Nhà cung cấp không xác định: {{provider}}',
providerStartOAuthFailed: 'Không thể bắt đầu OAuth',
providerCallbackFailed: 'Gửi callback thất bại',
providerSubmitCallbackFailed: 'Không thể gửi callback',
loggingConfigSaved: 'Đã lưu cấu hình ghi nhật ký.', loggingConfigSaved: 'Đã lưu cấu hình ghi nhật ký.',
loggingConfigSaveFailed: 'Không lưu được cấu hình ghi nhật ký.', loggingConfigSaveFailed: 'Không lưu được cấu hình ghi nhật ký.',
unifiedConfigUpdated: 'Đã cập nhật cấu hình thành công', unifiedConfigUpdated: 'Đã cập nhật cấu hình thành công',
@@ -6558,6 +6906,10 @@ const resources = {
legacyConfirmFailed: 'Tài khoản cũ "{{name}}" xác nhận thất bại: {{error}}', legacyConfirmFailed: 'Tài khoản cũ "{{name}}" xác nhận thất bại: {{error}}',
legacyConfirmSuccess_one: 'Đã xác nhận {{count}} tài khoản cũ', legacyConfirmSuccess_one: 'Đã xác nhận {{count}} tài khoản cũ',
legacyConfirmSuccess_other: 'Đã xác nhận {{count}} tài khoản cũ', legacyConfirmSuccess_other: 'Đã xác nhận {{count}} tài khoản cũ',
legacyConfirmPartial:
'Đã xác nhận {{updated}} tài khoản cũ, nhưng {{failed}} lần cập nhật thất bại. Trạng thái tài khoản đã được làm mới.',
legacyConfirmAllFailed:
'Không thể xác nhận {{failed}} tài khoản cũ. Trạng thái tài khoản đã được làm mới.',
noLegacyAccounts: 'Không có tài khoản cũ nào cần xác nhận', noLegacyAccounts: 'Không có tài khoản cũ nào cần xác nhận',
routingStrategySet: 'Chiến lược định tuyến đã đặt thành {{strategy}}', routingStrategySet: 'Chiến lược định tuyến đã đặt thành {{strategy}}',
variantCreated: 'Đã tạo biến thể thành công', variantCreated: 'Đã tạo biến thể thành công',
@@ -7921,6 +8273,8 @@ const resources = {
}, },
apiProfiles: { apiProfiles: {
title: 'API プロファイル', title: 'API プロファイル',
sidebarTitle: 'API プロファイル',
sidebarSubtitle: 'プレミアム API、ローカルランタイム、カスタムエンドポイント',
new: '新規', new: '新規',
searchPlaceholder: 'プロファイルを検索...', searchPlaceholder: 'プロファイルを検索...',
loadingProfiles: 'プロファイルを読み込み中...', loadingProfiles: 'プロファイルを読み込み中...',
@@ -7942,6 +8296,19 @@ const resources = {
unsavedChangesDesc: unsavedChangesDesc:
'"{{current}}" に未保存の変更があります。破棄して "{{next}}" に切り替えますか?', '"{{current}}" に未保存の変更があります。破棄して "{{next}}" に切り替えますか?',
discardSwitch: '破棄して切り替え', discardSwitch: '破棄して切り替え',
noOrphansFound: '孤立したプロファイル設定は見つかりませんでした',
confirmRegisterOrphans:
'{{total}} 件の孤立したプロファイル設定ファイルが見つかりました。うち {{valid}} 件は登録可能です。今すぐ登録しますか?',
registeredWithSkipped: '({{count}} 件スキップ)',
registeredProfiles: '{{count}} 件のプロファイルを登録しました',
copyPrompt: '「{{name}}」をどの新しいプロファイル名にコピーしますか?',
destinationEmpty: 'コピー先のプロファイル名は空にできません',
exportRedacted:
'トークンをマスクした状態でエクスポートしました。必要なら CLI の include-secrets フローを使ってください。',
exportDownloaded: 'プロファイルのエクスポートをダウンロードしました',
importFailed: 'プロファイルバンドルをインポートできませんでした',
discoverOrphans: '孤立プロファイルを検出',
importProfileBundle: 'プロファイルバンドルをインポート',
}, },
accountsPage: { accountsPage: {
title: 'アカウント', title: 'アカウント',
@@ -8504,6 +8871,93 @@ const resources = {
configYaml: 'config.yaml', configYaml: 'config.yaml',
flow: 'フロー', flow: 'フロー',
docsTab: 'ドキュメント', docsTab: 'ドキュメント',
features: '機能',
featuresDesc: 'CCS が安全に管理できる Codex の機能フラグを切り替えます。',
configOnlyFlags: '設定ファイル内のみの既存フラグ',
configOnlyFlagsDesc:
'これらのキーはすでに `config.toml` に存在するため、CCS は完全なカタログ対応を主張せずに表示できます。',
existing: '既存',
nonBooleanForm:
'非 boolean 形式で保存されています。編集する場合は Raw TOML を使用してください。',
discoveredFromFile: 'CCS の内蔵カタログではなく現在のファイルから検出されました。',
featureMultiAgentLabel: 'Multi-agent',
featureMultiAgentDesc: 'subagent 連携ツールを有効にします。',
featureUnifiedExecLabel: 'Unified exec',
featureUnifiedExecDesc: 'PTY ベースの unified exec ツールを使用します。',
featureShellSnapshotLabel: 'Shell snapshot',
featureShellSnapshotDesc: 'shell 環境スナップショットを再利用します。',
featureApplyPatchLabel: 'Apply patch',
featureApplyPatchDesc: '自由形式の apply_patch 編集を有効にします。',
featureJsReplLabel: 'JS REPL',
featureJsReplDesc: 'Node ベースの JavaScript REPL を有効にします。',
featureRuntimeMetricsLabel: 'Runtime metrics',
featureRuntimeMetricsDesc: 'Codex のランタイム指標を収集します。',
featurePreventIdleSleepLabel: 'Prevent idle sleep',
featurePreventIdleSleepDesc: '動作中にマシンがスリープしないようにします。',
featureFastModeLabel: 'Fast mode',
featureFastModeDesc: '高速サービス階層パスを許可します。',
featureAppsLabel: 'Apps',
featureAppsDesc: 'ChatGPT Apps と connector のサポートを有効にします。',
featureSmartApprovalsLabel: 'Smart approvals',
featureSmartApprovalsDesc: '対象となる承認を guardian フローに回します。',
howCodexWorks: 'CCS での Codex の動作',
nativeConfigLabel: 'ネイティブ設定:',
nativeConfigDesc: 'ccs-codex と ccsx は保存済みデフォルトでネイティブ Codex を起動します。',
transientOverridesLabel: '一時オーバーライド:',
transientOverridesDesc:
'ccsxp(または ccs codex --target codex)は CCS プロバイダーのショートカットを使います。',
cliproxyDefaultLabel: 'CLIProxy デフォルト:',
cliproxyDefaultDesc:
'通常の codex を CLIProxy 経由にするには、model_provider = "cliproxy" を設定し、下のレシピを追加します。',
runtimeInstall: 'ランタイムのインストール',
detected: '検出済み',
notFound: '見つかりません',
detectionSource: '検出元',
binaryPath: 'バイナリパス',
installDirectory: 'インストール先',
versionLabel: 'バージョン',
nativeAliases: 'ネイティブ別名',
ccsProviderShortcut: 'CCS プロバイダーショートカット',
configOverrideSupport: '--config オーバーライド対応',
available: '利用可能',
missing: '不足',
cliproxyNativeCodex: 'CLIProxy 経由のネイティブ Codex',
builtInLabel: '内蔵:',
builtInCcsxpDesc: 'CCS プロバイダーショートカットとして ccsxp を使用します。',
nativeRecipeLabel: 'ネイティブ:',
nativeRecipeDesc: '下のレシピを設定すると codex を直接 CLIProxy 経由で利用できます。',
saveProviderNamedCliproxy:
'上の base URL と env key を使って cliproxy という名前の provider を保存します。',
inTopLevelSetDefault: 'Top-level settings で Default provider を cliproxy に設定します。',
exportCliproxyApiKey:
'ネイティブ Codex を起動する前に shell で CLIPROXY_API_KEY を export してください。',
noConfigOverrides:
'この Codex ビルドでもネイティブパスは使えますが、ccsxp や ccs codex --target codex などの CCS 側ルーティングは、検出された Codex バイナリが --config オーバーライドに対応するまで利用できません。',
configFile: '設定ファイル',
path: 'パス',
resolved: '解決先',
size: 'サイズ',
lastModified: '最終更新',
tomlWarning: 'TOML 警告',
readWarning: '読み込み警告',
currentUserLayerSummary: '現在のユーザーレイヤー概要',
activeProfile: 'アクティブプロファイル',
notSet: '未設定',
providersCount: 'providers: {{count}}',
profilesCount: 'profiles: {{count}}',
enabledFeaturesCount: 'enabled features: {{count}}',
mcpServersCount: 'mcp servers: {{count}}',
userLayerKeysPresent: '現在存在するユーザーレイヤーキー',
nativeShortAlias: 'ネイティブ短縮エイリアス',
ccsCodexShortcut: 'CCS Codex ショートカット',
explicitProviderRoute: '明示的な provider ルート',
runtimeVsProvider: 'ランタイムと provider の違い',
honorsSavedNativeConfig: '保存済みのネイティブ設定を尊重',
usesTransientOverrides: '一時オーバーライドを使用',
unavailableNoConfig: '利用不可(この Codex ビルドには --config サポートがありません)。',
yes: 'はい',
no: 'いいえ',
warningsTitle: '警告',
}, },
codexPage: { codexPage: {
title: 'Codex', title: 'Codex',
@@ -8913,6 +9367,11 @@ const resources = {
fiveHourResets: '5時間リセット {{time}}', fiveHourResets: '5時間リセット {{time}}',
weeklyResets: '週間リセット {{time}}', weeklyResets: '週間リセット {{time}}',
}, },
utils: {
codeReview5h: 'コードレビュー(5時間)',
codeReviewWeekly: 'コードレビュー(週次)',
codeReview: 'コードレビュー',
},
rawEditorSection: { rawEditorSection: {
rawConfig: 'Raw 設定', rawConfig: 'Raw 設定',
}, },
@@ -8946,6 +9405,8 @@ const resources = {
conflictDesc: conflictDesc:
'この設定ファイルは別のプロセスで変更されました。変更で上書きしますか?それとも破棄しますか?', 'この設定ファイルは別のプロセスで変更されました。変更で上書きしますか?それとも破棄しますか?',
overwrite: '上書き', overwrite: '上書き',
invalidJson: '無効な JSON です',
failedSave: '保存に失敗しました',
}, },
settingsPage: { settingsPage: {
title: '設定', title: '設定',
@@ -9080,6 +9541,13 @@ const resources = {
providerAuthSuccess: '{{provider}} の認証に成功しました', providerAuthSuccess: '{{provider}} の認証に成功しました',
providerDeviceCodeInCallback: providerDeviceCodeInCallback:
'コールバックモードでプロバイダーがデバイスコードフローを返しました', 'コールバックモードでプロバイダーがデバイスコードフローを返しました',
providerAuthTimeout: '認証がタイムアウトしました。もう一度お試しください。',
providerAccountRegistrationFailed: '認証済みアカウントを登録できませんでした',
providerLostStatusEndpoint: '認証ステータスエンドポイントとの接続が失われました',
providerUnknown: '不明なプロバイダーです: {{provider}}',
providerStartOAuthFailed: 'OAuth を開始できませんでした',
providerCallbackFailed: 'コールバック送信に失敗しました',
providerSubmitCallbackFailed: 'コールバックを送信できませんでした',
loggingConfigSaved: 'ログ設定を保存しました。', loggingConfigSaved: 'ログ設定を保存しました。',
loggingConfigSaveFailed: 'ログ設定の保存に失敗しました。', loggingConfigSaveFailed: 'ログ設定の保存に失敗しました。',
unifiedConfigUpdated: '設定を更新しました', unifiedConfigUpdated: '設定を更新しました',
@@ -9097,6 +9565,10 @@ const resources = {
legacyConfirmFailed: 'レガシーアカウント「{{name}}」の確認に失敗しました: {{error}}', legacyConfirmFailed: 'レガシーアカウント「{{name}}」の確認に失敗しました: {{error}}',
legacyConfirmSuccess_one: '{{count}} 件のレガシーアカウントを確認しました', legacyConfirmSuccess_one: '{{count}} 件のレガシーアカウントを確認しました',
legacyConfirmSuccess_other: '{{count}} 件のレガシーアカウントを確認しました', legacyConfirmSuccess_other: '{{count}} 件のレガシーアカウントを確認しました',
legacyConfirmPartial:
'{{updated}} 件のレガシーアカウントを確認しましたが、{{failed}} 件の更新に失敗しました。アカウント状態を再取得しました。',
legacyConfirmAllFailed:
'{{failed}} 件のレガシーアカウントの確認に失敗しました。アカウント状態を再取得しました。',
noLegacyAccounts: '確認が必要なレガシーアカウントはありません', noLegacyAccounts: '確認が必要なレガシーアカウントはありません',
routingStrategySet: 'ルーティング戦略を {{strategy}} に設定しました', routingStrategySet: 'ルーティング戦略を {{strategy}} に設定しました',
variantCreated: 'バリアントを作成しました', variantCreated: 'バリアントを作成しました',
+79 -11
View File
@@ -1,3 +1,5 @@
import i18n from '@/lib/i18n';
export type SupportStatus = 'new' | 'stable' | 'planned'; export type SupportStatus = 'new' | 'stable' | 'planned';
export type SupportScope = 'target' | 'cliproxy' | 'api-profiles' | 'websearch'; export type SupportScope = 'target' | 'cliproxy' | 'api-profiles' | 'websearch';
@@ -47,14 +49,14 @@ export interface CliSupportEntry {
notes?: string; notes?: string;
} }
export const SUPPORT_SCOPE_LABELS: Record<SupportScope, string> = { const BASE_SUPPORT_SCOPE_LABELS: Record<SupportScope, string> = {
target: 'Target CLI', // TODO i18n: missing key for support scope target target: 'Target CLI',
cliproxy: 'CLIProxy Provider', // TODO i18n: missing key for support scope cliproxy cliproxy: 'CLIProxy Provider',
'api-profiles': 'API Profile', // TODO i18n: missing key for support scope api-profiles 'api-profiles': 'API Profile',
websearch: 'WebSearch', // TODO i18n: missing key for support scope websearch websearch: 'WebSearch',
}; };
export const SUPPORT_NOTICES: SupportNotice[] = [ const BASE_SUPPORT_NOTICES: SupportNotice[] = [
{ {
id: 'codex-target-runtime-support', id: 'codex-target-runtime-support',
title: 'Native Codex runtime support is live', title: 'Native Codex runtime support is live',
@@ -204,7 +206,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
}, },
]; ];
export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ const BASE_CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
{ {
id: 'claude-target', id: 'claude-target',
name: 'Claude Code', name: 'Claude Code',
@@ -348,20 +350,86 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
}, },
]; ];
const SUPPORT_ENTRY_LOOKUP = new Map(CLI_SUPPORT_ENTRIES.map((entry) => [entry.id, entry])); function tx(key: string, defaultValue: string, options?: Record<string, unknown>): string {
return i18n.t(key, { defaultValue, ...options });
}
export function getSupportScopeLabels(): Record<SupportScope, string> {
return {
target: tx('supportCatalog.scope.target', BASE_SUPPORT_SCOPE_LABELS.target),
cliproxy: tx('supportCatalog.scope.cliproxy', BASE_SUPPORT_SCOPE_LABELS.cliproxy),
'api-profiles': tx(
'supportCatalog.scope.apiProfiles',
BASE_SUPPORT_SCOPE_LABELS['api-profiles']
),
websearch: tx('supportCatalog.scope.websearch', BASE_SUPPORT_SCOPE_LABELS.websearch),
};
}
function localizeSupportNotice(notice: SupportNotice): SupportNotice {
return {
...notice,
title: tx(`supportCatalog.notice.${notice.id}.title`, notice.title),
summary: tx(`supportCatalog.notice.${notice.id}.summary`, notice.summary),
primaryAction: tx(`supportCatalog.notice.${notice.id}.primaryAction`, notice.primaryAction),
highlights: notice.highlights.map((highlight, index) =>
tx(`supportCatalog.notice.${notice.id}.highlight.${index}`, highlight)
),
actions: notice.actions.map((action) => ({
...action,
label: tx(`supportCatalog.notice.${notice.id}.action.${action.id}.label`, action.label),
description: tx(
`supportCatalog.notice.${notice.id}.action.${action.id}.description`,
action.description
),
})),
routes: notice.routes.map((route, index) => ({
...route,
label: tx(`supportCatalog.notice.${notice.id}.route.${index}.label`, route.label),
})),
};
}
function localizeCliSupportEntry(entry: CliSupportEntry): CliSupportEntry {
return {
...entry,
name: tx(`supportCatalog.entry.${entry.id}.name`, entry.name),
summary: tx(`supportCatalog.entry.${entry.id}.summary`, entry.summary),
pillars: {
baseUrl: tx(`supportCatalog.entry.${entry.id}.pillar.baseUrl`, entry.pillars.baseUrl),
auth: tx(`supportCatalog.entry.${entry.id}.pillar.auth`, entry.pillars.auth),
model: tx(`supportCatalog.entry.${entry.id}.pillar.model`, entry.pillars.model),
},
routes: entry.routes.map((route, index) => ({
...route,
label: tx(`supportCatalog.entry.${entry.id}.route.${index}.label`, route.label),
})),
notes: entry.notes ? tx(`supportCatalog.entry.${entry.id}.notes`, entry.notes) : entry.notes,
};
}
export function getSupportNotices(): SupportNotice[] {
return BASE_SUPPORT_NOTICES.map(localizeSupportNotice);
}
export function getCliSupportEntries(): CliSupportEntry[] {
return BASE_CLI_SUPPORT_ENTRIES.map(localizeCliSupportEntry);
}
export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] { export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] {
const supportEntryLookup = new Map(getCliSupportEntries().map((entry) => [entry.id, entry]));
return notice.entryIds return notice.entryIds
.map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId)) .map((entryId) => supportEntryLookup.get(entryId))
.filter((entry): entry is CliSupportEntry => Boolean(entry)); .filter((entry): entry is CliSupportEntry => Boolean(entry));
} }
export function getLatestSupportNotice(): SupportNotice | null { export function getLatestSupportNotice(): SupportNotice | null {
if (SUPPORT_NOTICES.length === 0) { const notices = getSupportNotices();
if (notices.length === 0) {
return null; return null;
} }
return [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))[0]; return [...notices].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))[0];
} }
export function formatCatalogDate(value: string): string { export function formatCatalogDate(value: string): string {
-4
View File
@@ -38,10 +38,6 @@ import type { ProviderPreset } from '@/lib/provider-presets';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
// TODO i18n: missing keys for apiProfiles: noOrphansFound, confirmRegisterOrphans,
// registeredWithSkipped, registeredProfiles, copyPrompt, destinationEmpty,
// exportRedacted, exportDownloaded, importFailed, sidebarTitle, sidebarSubtitle,
// discoverOrphans, importProfileBundle
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
+172 -81
View File
@@ -227,7 +227,10 @@ function TargetStatusCard({
</div> </div>
<div className="rounded-lg border bg-muted/25 p-3 text-sm text-muted-foreground"> <div className="rounded-lg border bg-muted/25 p-3 text-sm text-muted-foreground">
{status?.message || 'Verify the binding after saving to inspect the current file state.'} {status?.message ||
t('claudeExtensionPage.verifyAfterSaving', {
defaultValue: 'Verify the binding after saving to inspect the current file state.',
})}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -374,7 +377,15 @@ export function ClaudeExtensionPage() {
async function handleDelete(): Promise<void> { async function handleDelete(): Promise<void> {
if (!effectiveSelectedBindingId || !selectedBinding) return; if (!effectiveSelectedBindingId || !selectedBinding) return;
if (!window.confirm(`Delete binding "${selectedBinding.name}"?`)) return; if (
!window.confirm(
t('claudeExtensionPage.deleteBindingConfirm', {
name: selectedBinding.name,
defaultValue: 'Delete binding "{{name}}"?',
})
)
)
return;
await deleteBinding.mutateAsync(effectiveSelectedBindingId); await deleteBinding.mutateAsync(effectiveSelectedBindingId);
const remaining = bindings.filter((binding) => binding.id !== effectiveSelectedBindingId); const remaining = bindings.filter((binding) => binding.id !== effectiveSelectedBindingId);
@@ -419,8 +430,9 @@ export function ClaudeExtensionPage() {
<div> <div>
<h1 className="font-semibold">{t('claudeExtensionPage.title')}</h1> <h1 className="font-semibold">{t('claudeExtensionPage.title')}</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key for subtitle */} {t('claudeExtensionPage.savedBindingsSubtitle', {
Saved IDE bindings for CCS profiles defaultValue: 'Saved IDE bindings for CCS profiles',
})}
</p> </p>
</div> </div>
</div> </div>
@@ -431,8 +443,7 @@ export function ClaudeExtensionPage() {
</div> </div>
<Button size="sm" onClick={startCreateMode} className="gap-1.5"> <Button size="sm" onClick={startCreateMode} className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for "New" */} {t('claudeExtensionPage.new', { defaultValue: 'New' })}
New
</Button> </Button>
</div> </div>
</div> </div>
@@ -442,34 +453,45 @@ export function ClaudeExtensionPage() {
<Card className="border-border/60 bg-card/80"> <Card className="border-border/60 bg-card/80">
<CardHeader> <CardHeader>
<CardTitle className="text-base"> <CardTitle className="text-base">
{/* TODO i18n: missing key for "Create binding"/"Binding editor" */} {creating
{creating ? 'Create binding' : 'Binding editor'} ? t('claudeExtensionPage.createBinding', { defaultValue: 'Create binding' })
: t('claudeExtensionPage.bindingEditor', { defaultValue: 'Binding editor' })}
</CardTitle> </CardTitle>
{/* TODO i18n: missing key for binding editor description */}
<CardDescription> <CardDescription>
Save a profile + IDE path once, then apply or reset it from the dashboard. {t('claudeExtensionPage.bindingEditorDescription', {
defaultValue:
'Save a profile + IDE path once, then apply or reset it from the dashboard.',
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
{/* TODO i18n: missing key for "Binding name" */} <div className="text-sm font-medium">
<div className="text-sm font-medium">Binding name</div> {t('settingsPage.thinkingSection.bindingName')}
</div>
<Input <Input
value={currentDraft.name} value={currentDraft.name}
onChange={(event) => updateDraft('name', event.target.value)} onChange={(event) => updateDraft('name', event.target.value)}
placeholder="VS Code · work profile" /* TODO i18n: missing key */ placeholder={t('claudeExtensionPage.bindingNamePlaceholder', {
defaultValue: 'VS Code · work profile',
})}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{/* TODO i18n: missing key for "CCS profile" */} <div className="text-sm font-medium">
<div className="text-sm font-medium">CCS profile</div> {t('claudeExtensionPage.ccsProfile', { defaultValue: 'CCS profile' })}
</div>
<Select <Select
value={currentDraft.profile} value={currentDraft.profile}
onValueChange={(value) => updateDraft('profile', value)} onValueChange={(value) => updateDraft('profile', value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a profile" /* TODO i18n: missing key */ /> <SelectValue
placeholder={t('claudeExtensionPage.selectProfile', {
defaultValue: 'Select a profile',
})}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{profiles.map((profile) => ( {profiles.map((profile) => (
@@ -481,7 +503,9 @@ export function ClaudeExtensionPage() {
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{selectedProfile?.description || {selectedProfile?.description ||
'Choose which CCS profile the IDE should inherit.'} t('claudeExtensionPage.chooseProfileHint', {
defaultValue: 'Choose which CCS profile the IDE should inherit.',
})}
</p> </p>
</div> </div>
@@ -494,7 +518,11 @@ export function ClaudeExtensionPage() {
onValueChange={(value) => updateDraft('host', value as BindingDraft['host'])} onValueChange={(value) => updateDraft('host', value as BindingDraft['host'])}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a host" /* TODO i18n: missing key */ /> <SelectValue
placeholder={t('claudeExtensionPage.selectHost', {
defaultValue: 'Select a host',
})}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{hosts.map((host) => ( {hosts.map((host) => (
@@ -515,22 +543,29 @@ export function ClaudeExtensionPage() {
onChange={(event) => updateDraft('ideSettingsPath', event.target.value)} onChange={(event) => updateDraft('ideSettingsPath', event.target.value)}
placeholder={ placeholder={
selectedHost?.defaultSettingsPath || selectedHost?.defaultSettingsPath ||
'Leave blank for the default user settings path' /* TODO i18n: missing key */ t('claudeExtensionPage.ideSettingsPathPlaceholder', {
defaultValue: 'Leave blank for the default user settings path',
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Leave blank to use the default user settings path for{' '} {t('claudeExtensionPage.ideSettingsPathHint', {
defaultValue: 'Leave blank to use the default user settings path for',
})}{' '}
{selectedHost?.label || 'this IDE'}. {selectedHost?.label || 'this IDE'}.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{/* TODO i18n: missing key for "Notes" */} <div className="text-sm font-medium">
<div className="text-sm font-medium">Notes</div> {t('settingsPage.thinkingSection.notes')}
</div>
<Input <Input
value={currentDraft.notes} value={currentDraft.notes}
onChange={(event) => updateDraft('notes', event.target.value)} onChange={(event) => updateDraft('notes', event.target.value)}
placeholder="Optional reminder for this machine or workspace" /* TODO i18n: missing key */ placeholder={t('claudeExtensionPage.notesPlaceholder', {
defaultValue: 'Optional reminder for this machine or workspace',
})}
/> />
</div> </div>
@@ -545,12 +580,12 @@ export function ClaudeExtensionPage() {
) : ( ) : (
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
)} )}
{/* TODO i18n: missing key for "Create"/"Save" */} {creating
{creating ? 'Create' : 'Save'} ? t('claudeExtensionPage.create', { defaultValue: 'Create' })
: t('claudeExtensionPage.save', { defaultValue: 'Save' })}
</Button> </Button>
<Button variant="outline" onClick={startCreateMode}> <Button variant="outline" onClick={startCreateMode}>
{/* TODO i18n: missing key for "Reset form" */} {t('claudeExtensionPage.resetForm', { defaultValue: 'Reset form' })}
Reset form
</Button> </Button>
</div> </div>
@@ -562,8 +597,7 @@ export function ClaudeExtensionPage() {
disabled={deleteBinding.isPending} disabled={deleteBinding.isPending}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for "Delete binding" */} {t('claudeExtensionPage.deleteBinding', { defaultValue: 'Delete binding' })}
Delete binding
</Button> </Button>
) : null} ) : null}
</CardContent> </CardContent>
@@ -571,8 +605,7 @@ export function ClaudeExtensionPage() {
<div className="space-y-2"> <div className="space-y-2">
<div className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key for "Saved bindings" */} {t('claudeExtensionPage.savedBindings', { defaultValue: 'Saved bindings' })}
Saved bindings
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{bindings.length > 0 ? ( {bindings.length > 0 ? (
@@ -591,9 +624,10 @@ export function ClaudeExtensionPage() {
) : ( ) : (
<Card className="border-dashed border-border/60 bg-card/60"> <Card className="border-dashed border-border/60 bg-card/60">
<CardContent className="pt-6 text-sm text-muted-foreground"> <CardContent className="pt-6 text-sm text-muted-foreground">
{/* TODO i18n: missing key for empty bindings text */} {t('claudeExtensionPage.emptyBindings', {
No saved bindings yet. Create one to manage apply, reset, and drift checks defaultValue:
from the dashboard. 'No saved bindings yet. Create one to manage apply, reset, and drift checks from the dashboard.',
})}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -626,13 +660,16 @@ export function ClaudeExtensionPage() {
</div> </div>
<div className="max-w-5xl"> <div className="max-w-5xl">
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
{/* TODO i18n: missing key for default binding name */} {selectedBinding?.name ||
{selectedBinding?.name || 'Claude extension binding'} t('claudeExtensionPage.defaultBindingName', {
defaultValue: 'Claude extension binding',
})}
</h2> </h2>
{/* TODO i18n: missing key for binding description */}
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Manage the shared Claude settings file and the IDE-local settings file as two {t('claudeExtensionPage.bindingDescription', {
scoped targets. defaultValue:
'Manage the shared Claude settings file and the IDE-local settings file as two scoped targets.',
})}
</p> </p>
</div> </div>
</div> </div>
@@ -648,8 +685,7 @@ export function ClaudeExtensionPage() {
) : ( ) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)} )}
{/* TODO i18n: missing key for "Verify" */} {t('claudeExtensionPage.verify', { defaultValue: 'Verify' })}
Verify
</Button> </Button>
{setup ? ( {setup ? (
<CopyButton value={setup.sharedSettings.command} label="Copy persist command" /> <CopyButton value={setup.sharedSettings.command} label="Copy persist command" />
@@ -669,8 +705,9 @@ export function ClaudeExtensionPage() {
{!activeError ? ( {!activeError ? (
<Tabs defaultValue="overview" className="flex flex-col gap-6"> <Tabs defaultValue="overview" className="flex flex-col gap-6">
<TabsList className="w-full justify-start"> <TabsList className="w-full justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>{' '} <TabsTrigger value="overview">
{/* TODO i18n: missing key */} {t('claudeExtensionPage.overview', { defaultValue: 'Overview' })}
</TabsTrigger>{' '}
<TabsTrigger value="advanced"> <TabsTrigger value="advanced">
{t('settingsPage.thinkingSection.advanced')} {t('settingsPage.thinkingSection.advanced')}
</TabsTrigger> </TabsTrigger>
@@ -679,11 +716,20 @@ export function ClaudeExtensionPage() {
<TabsContent value="overview" className="mt-0 space-y-6"> <TabsContent value="overview" className="mt-0 space-y-6">
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<TargetStatusCard <TargetStatusCard
title="Shared Claude settings" title={t('claudeExtensionPage.sharedClaudeSettings', {
description="Writes the managed env block inside ~/.claude/settings.json so CLI and IDE behavior stay aligned." defaultValue: 'Shared Claude settings',
})}
description={t('claudeExtensionPage.sharedClaudeSettingsDescription', {
defaultValue:
'Writes the managed env block inside ~/.claude/settings.json so CLI and IDE behavior stay aligned.',
})}
status={status?.sharedSettings} status={status?.sharedSettings}
applyLabel="Apply shared" /* TODO i18n: missing key */ applyLabel={t('claudeExtensionPage.applyShared', {
resetLabel="Reset shared" /* TODO i18n: missing key */ defaultValue: 'Apply shared',
})}
resetLabel={t('claudeExtensionPage.resetShared', {
defaultValue: 'Reset shared',
})}
onApply={() => runBindingAction('shared', 'apply')} onApply={() => runBindingAction('shared', 'apply')}
onReset={() => runBindingAction('shared', 'reset')} onReset={() => runBindingAction('shared', 'reset')}
disabled={creating} disabled={creating}
@@ -693,8 +739,8 @@ export function ClaudeExtensionPage() {
title={`${selectedHost?.label || 'IDE'} settings.json`} title={`${selectedHost?.label || 'IDE'} settings.json`}
description="Writes only the Anthropic extension keys so unrelated editor preferences stay untouched." description="Writes only the Anthropic extension keys so unrelated editor preferences stay untouched."
status={status?.ideSettings} status={status?.ideSettings}
applyLabel="Apply IDE" /* TODO i18n: missing key */ applyLabel={t('claudeExtensionPage.applyIde', { defaultValue: 'Apply IDE' })}
resetLabel="Reset IDE" /* TODO i18n: missing key */ resetLabel={t('claudeExtensionPage.resetIde', { defaultValue: 'Reset IDE' })}
onApply={() => runBindingAction('ide', 'apply')} onApply={() => runBindingAction('ide', 'apply')}
onReset={() => runBindingAction('ide', 'reset')} onReset={() => runBindingAction('ide', 'reset')}
disabled={creating} disabled={creating}
@@ -708,32 +754,41 @@ export function ClaudeExtensionPage() {
<CardTitle className="text-base"> <CardTitle className="text-base">
{t('settingsPage.thinkingSection.resolvedBinding')} {t('settingsPage.thinkingSection.resolvedBinding')}
</CardTitle> </CardTitle>
{/* TODO i18n: missing key for resolved binding description */}
<CardDescription> <CardDescription>
The binding uses the same profile resolution as `ccs persist` and `ccs {t('claudeExtensionPage.resolvedBindingDescription', {
env`. defaultValue:
'The binding uses the same profile resolution as `ccs persist` and `ccs env`.',
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<DetailRow <DetailRow
label="Profile" label={t('claudeExtensionPage.profile', { defaultValue: 'Profile' })}
value={setup?.profile.label || currentDraft.profile || 'Not selected'} value={setup?.profile.label || currentDraft.profile || 'Not selected'}
/> />
<DetailRow <DetailRow
label="Profile type" label={t('claudeExtensionPage.profileType', {
defaultValue: 'Profile type',
})}
value={setup?.profile.profileType || 'Unknown'} value={setup?.profile.profileType || 'Unknown'}
/> />
<DetailRow label="IDE host" value={selectedHost?.label || 'Not selected'} /> <DetailRow label="IDE host" value={selectedHost?.label || 'Not selected'} />
<DetailRow <DetailRow
label="IDE path mode" label={t('claudeExtensionPage.idePathMode', {
defaultValue: 'IDE path mode',
})}
value={ value={
currentDraft.ideSettingsPath.trim() currentDraft.ideSettingsPath.trim()
? 'Custom path' ? t('claudeExtensionPage.customPath', { defaultValue: 'Custom path' })
: 'Default user path' : t('claudeExtensionPage.defaultUserPath', {
defaultValue: 'Default user path',
})
} }
/> />
<DetailRow <DetailRow
label="Effective IDE path" label={t('claudeExtensionPage.effectiveIdePath', {
defaultValue: 'Effective IDE path',
})}
value={ value={
status?.ideSettings.path || status?.ideSettings.path ||
currentDraft.ideSettingsPath.trim() || currentDraft.ideSettingsPath.trim() ||
@@ -748,12 +803,22 @@ export function ClaudeExtensionPage() {
} }
/> />
<DetailRow <DetailRow
label="Persist command" label={t('claudeExtensionPage.persistCommand', {
value={setup?.sharedSettings.command || 'Save a valid binding first'} defaultValue: 'Persist command',
})}
value={
setup?.sharedSettings.command ||
t('claudeExtensionPage.saveValidBindingFirst', {
defaultValue: 'Save a valid binding first',
})
}
mono mono
/> />
{currentDraft.notes.trim() ? ( {currentDraft.notes.trim() ? (
<DetailRow label="Notes" value={currentDraft.notes.trim()} /> <DetailRow
label={t('settingsPage.thinkingSection.notes')}
value={currentDraft.notes.trim()}
/>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
@@ -763,9 +828,11 @@ export function ClaudeExtensionPage() {
<CardTitle className="text-base"> <CardTitle className="text-base">
{t('settingsPage.thinkingSection.managedPayload')} {t('settingsPage.thinkingSection.managedPayload')}
</CardTitle> </CardTitle>
{/* TODO i18n: missing key for managed payload description */}
<CardDescription> <CardDescription>
Keep the main view short. The full JSON stays in the Advanced tab. {t('claudeExtensionPage.managedPayloadDescription', {
defaultValue:
'Keep the main view short. The full JSON stays in the Advanced tab.',
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -788,17 +855,24 @@ export function ClaudeExtensionPage() {
{setup?.env.length ? ( {setup?.env.length ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium"> <div className="font-medium">
CCS will inject {setup.env.length} environment values. {t('claudeExtensionPage.envInjected', {
count: setup.env.length,
defaultValue: 'CCS will inject {{count}} environment values.',
})}
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
The IDE-local target receives the extension schema. The shared {t('claudeExtensionPage.envInjectedDescription', {
target receives the same env block through Claude settings. defaultValue:
'The IDE-local target receives the extension schema. The shared target receives the same env block through Claude settings.',
})}
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
This profile resolves to native Claude defaults, so apply/reset mainly {t('claudeExtensionPage.nativeDefaultsDescription', {
clears existing CCS-managed overrides. defaultValue:
'This profile resolves to native Claude defaults, so apply/reset mainly clears existing CCS-managed overrides.',
})}
</div> </div>
)} )}
</div> </div>
@@ -814,8 +888,9 @@ export function ClaudeExtensionPage() {
applyBinding.variables?.target === 'all' ? ( applyBinding.variables?.target === 'all' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null} ) : null}
{/* TODO i18n: missing key for "Apply both targets" */} {t('claudeExtensionPage.applyBothTargets', {
Apply both targets defaultValue: 'Apply both targets',
})}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -823,14 +898,17 @@ export function ClaudeExtensionPage() {
onClick={() => runBindingAction('all', 'reset')} onClick={() => runBindingAction('all', 'reset')}
disabled={resetBinding.isPending} disabled={resetBinding.isPending}
> >
{/* TODO i18n: missing key for "Reset both targets" */} {t('claudeExtensionPage.resetBothTargets', {
Reset both targets defaultValue: 'Reset both targets',
})}
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed bg-muted/15 p-4 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/15 p-4 text-sm text-muted-foreground">
{/* TODO i18n: missing key for "Save this draft..." */} {t('claudeExtensionPage.saveDraftToUnlock', {
Save this draft to unlock apply, reset, and verify actions. defaultValue:
'Save this draft to unlock apply, reset, and verify actions.',
})}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -905,13 +983,20 @@ export function ClaudeExtensionPage() {
<> <>
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<CodeBlockCard <CodeBlockCard
title="Shared Claude settings JSON" title={t('claudeExtensionPage.sharedSettingsJson', {
description="Managed env block for ~/.claude/settings.json." defaultValue: 'Shared Claude settings JSON',
})}
description={t('claudeExtensionPage.sharedSettingsJsonDescription', {
defaultValue: 'Managed env block for ~/.claude/settings.json.',
})}
value={setup.sharedSettings.json} value={setup.sharedSettings.json}
/> />
<CodeBlockCard <CodeBlockCard
title={`${selectedHost?.label || 'IDE'} settings JSON`} title={`${selectedHost?.label || 'IDE'} settings JSON`}
description={`Anthropic extension snippet for ${selectedHost?.settingsTargetLabel || 'settings.json'}.`} description={t('claudeExtensionPage.ideSettingsJsonDescription', {
label: selectedHost?.settingsTargetLabel || 'settings.json',
defaultValue: 'Anthropic extension snippet for {{label}}.',
})}
value={setup.ideSettings.json} value={setup.ideSettings.json}
/> />
</div> </div>
@@ -921,16 +1006,22 @@ export function ClaudeExtensionPage() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<CardTitle className="text-base"> <CardTitle className="text-base">
Resolved environment payload {t('claudeExtensionPage.resolvedEnvironmentPayload', {
defaultValue: 'Resolved environment payload',
})}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Exact environment values that the extension receives after CCS {t('claudeExtensionPage.resolvedEnvironmentPayloadDescription', {
expands this profile. defaultValue:
'Exact environment values that the extension receives after CCS expands this profile.',
})}
</CardDescription> </CardDescription>
</div> </div>
<CopyButton <CopyButton
value={JSON.stringify(setup.env, null, 2)} value={JSON.stringify(setup.env, null, 2)}
label="Copy environment payload" label={t('claudeExtensionPage.copyEnvironmentPayload', {
defaultValue: 'Copy environment payload',
})}
/> />
</div> </div>
</CardHeader> </CardHeader>
+3 -2
View File
@@ -11,7 +11,7 @@ import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
KNOWN_CODEX_FEATURES, getKnownCodexFeatures,
readCodexFeatureState, readCodexFeatureState,
readCodexMcpServers, readCodexMcpServers,
readCodexModelProviders, readCodexModelProviders,
@@ -23,6 +23,7 @@ import { safeParseTomlObject } from '@shared/toml-object';
export function CodexPage() { export function CodexPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const featureCatalog = getKnownCodexFeatures();
const { const {
diagnostics, diagnostics,
diagnosticsLoading, diagnosticsLoading,
@@ -198,7 +199,7 @@ export function CodexPage() {
profileEntries={profileEntries} profileEntries={profileEntries}
modelProviderEntries={modelProviderEntries} modelProviderEntries={modelProviderEntries}
mcpServerEntries={mcpServerEntries} mcpServerEntries={mcpServerEntries}
featureCatalog={KNOWN_CODEX_FEATURES} featureCatalog={featureCatalog}
featureState={featureState} featureState={featureState}
disabled={structuredControlsDisabled} disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason} disabledReason={controlsDisabledReason}
+108 -46
View File
@@ -185,15 +185,17 @@ export default function ChannelsSection() {
<MessageSquare className="h-5 w-5 text-primary" /> <MessageSquare className="h-5 w-5 text-primary" />
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium">{t('settingsPage.channelsSection.title')}</p> <p className="font-medium">{t('settingsPage.channelsSection.title')}</p>
{/* TODO i18n: missing key for channels description paragraphs */}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Configure official Claude channels here, then run <code>ccs</code> normally on a {t('settingsPage.channelsSection.configureDescription', {
supported native Claude session. defaultValue:
'Configure official Claude channels here, then run `ccs` normally on a supported native Claude session.',
})}
</p> </p>
{/* TODO i18n: missing key for channels storage description */}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
CCS stores only channel selection in <code>config.yaml</code>. Claude keeps the {t('settingsPage.channelsSection.storageDescription', {
machine-level channel state under <code>~/.claude/channels/</code>. defaultValue:
'CCS stores only channel selection in `config.yaml`. Claude keeps the machine-level channel state under `~/.claude/channels/`.',
})}
</p> </p>
</div> </div>
</div> </div>
@@ -212,13 +214,23 @@ export default function ChannelsSection() {
<p className="text-sm opacity-90">{status.summary.nextStep}</p> <p className="text-sm opacity-90">{status.summary.nextStep}</p>
</div> </div>
<div className="min-w-[220px] rounded-lg border border-current/10 bg-background/60 p-3 text-sm text-foreground"> <div className="min-w-[220px] rounded-lg border border-current/10 bg-background/60 p-3 text-sm text-foreground">
{/* TODO i18n: missing key for "Machine checks" */} <p className="font-medium">
<p className="font-medium">Machine checks</p> {t('settingsPage.channelsSection.machineChecks', {
defaultValue: 'Machine checks',
})}
</p>
<div className="mt-2 space-y-1 text-muted-foreground"> <div className="mt-2 space-y-1 text-muted-foreground">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span>Bun</span> <span>Bun</span>
{/* TODO i18n: missing key for "Installed"/"Missing" */} <span>
<span>{status.bunInstalled ? 'Installed' : 'Missing'}</span> {status.bunInstalled
? t('settingsPage.channelsSection.installed', {
defaultValue: 'Installed',
})
: t('settingsPage.channelsSection.missing', {
defaultValue: 'Missing',
})}
</span>
</div> </div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span>Claude Code</span> <span>Claude Code</span>
@@ -230,8 +242,12 @@ export default function ChannelsSection() {
</div> </div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span>Claude auth</span> <span>Claude auth</span>
{/* TODO i18n: missing key for "Unknown" */} <span>
<span>{status.auth.authMethod ?? 'Unknown'}</span> {status.auth.authMethod ||
t('settingsPage.channelsSection.unknown', {
defaultValue: 'Unknown',
})}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -248,21 +264,36 @@ export default function ChannelsSection() {
{status && ( {status && (
<div className="rounded-lg border bg-muted/20 p-4"> <div className="rounded-lg border bg-muted/20 p-4">
{/* TODO i18n: missing key for "Fastest path" and step descriptions */} <p className="font-medium">
<p className="font-medium">Fastest path</p> {t('settingsPage.channelsSection.fastestPath', {
defaultValue: 'Fastest path',
})}
</p>
<div className="mt-3 space-y-2 text-sm text-muted-foreground"> <div className="mt-3 space-y-2 text-sm text-muted-foreground">
<p>1. Turn on the channels you want below.</p>
<p>2. Save Telegram or Discord bot tokens here if that channel needs one.</p>
<p> <p>
3. Run <code>ccs</code> or a native Claude account profile. CCS adds{' '} {t('settingsPage.channelsSection.fastestStep1', {
<code>--channels</code> for you on supported runs. defaultValue: '1. Turn on the channels you want below.',
})}
</p>
<p>
{t('settingsPage.channelsSection.fastestStep2', {
defaultValue:
'2. Save Telegram or Discord bot tokens here if that channel needs one.',
})}
</p>
<p>
{t('settingsPage.channelsSection.fastestStep3', {
defaultValue:
'3. Run `ccs` or a native Claude account profile. CCS adds `--channels` for you on supported runs.',
})}
</p> </p>
<p>{status.supportMessage}</p> <p>{status.supportMessage}</p>
</div> </div>
<details className="mt-3 rounded-lg border bg-background p-4"> <details className="mt-3 rounded-lg border bg-background p-4">
{/* TODO i18n: missing key for "Advanced notes and scope" */}
<summary className="cursor-pointer text-sm font-medium"> <summary className="cursor-pointer text-sm font-medium">
Advanced notes and scope {t('settingsPage.channelsSection.advancedNotes', {
defaultValue: 'Advanced notes and scope',
})}
</summary> </summary>
<div className="mt-3 space-y-2 text-sm text-muted-foreground"> <div className="mt-3 space-y-2 text-sm text-muted-foreground">
<p>{status.accountStatusCaveat}</p> <p>{status.accountStatusCaveat}</p>
@@ -276,9 +307,10 @@ export default function ChannelsSection() {
<div className="rounded-lg border bg-background p-4"> <div className="rounded-lg border bg-background p-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
{/* TODO i18n: missing key for "If you run ccs now" */}
<p className="font-medium"> <p className="font-medium">
If you run <code>ccs</code> now {t('settingsPage.channelsSection.ifYouRunNow', {
defaultValue: 'If you run `ccs` now',
})}
</p> </p>
<p className="text-sm text-muted-foreground">{status.launchPreview.detail}</p> <p className="text-sm text-muted-foreground">{status.launchPreview.detail}</p>
</div> </div>
@@ -288,16 +320,20 @@ export default function ChannelsSection() {
</div> </div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm"> <div className="rounded-md bg-muted px-3 py-2 font-mono text-sm">
{/* TODO i18n: missing key for "You type:" */} <span className="text-muted-foreground">
<span className="text-muted-foreground">You type:</span>{' '} {t('settingsPage.thinkingSection.youType')}
</span>{' '}
{status.launchPreview.command} {status.launchPreview.command}
</div> </div>
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"> <div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all">
{/* TODO i18n: missing key for "CCS adds:" */} <span className="text-muted-foreground">
<span className="text-muted-foreground">CCS adds:</span>{' '} {t('settingsPage.thinkingSection.ccsAdds')}
</span>{' '}
{status.launchPreview.appendedArgs.length > 0 {status.launchPreview.appendedArgs.length > 0
? status.launchPreview.appendedArgs.join(' ') ? status.launchPreview.appendedArgs.join(' ')
: '(nothing yet)'} : t('settingsPage.channelsSection.nothingYet', {
defaultValue: '(nothing yet)',
})}
</div> </div>
</div> </div>
{status.launchPreview.skippedMessages.length > 0 && ( {status.launchPreview.skippedMessages.length > 0 && (
@@ -367,10 +403,22 @@ export default function ChannelsSection() {
<div className="space-y-3 rounded-lg bg-muted/30 p-4"> <div className="space-y-3 rounded-lg bg-muted/30 p-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{!channel.tokenConfigured && channel.tokenSource === 'process_env' {!channel.tokenConfigured && channel.tokenSource === 'process_env'
? `The current CCS process already has ${channel.envKey}. Save it here only if you want persistent Claude channel state.` ? t('settingsPage.channelsSection.tokenFromProcessEnv', {
envKey: channel.envKey,
defaultValue:
'The current CCS process already has {{envKey}}. Save it here only if you want persistent Claude channel state.',
})
: channel.tokenConfigured && channel.processEnvAvailable : channel.tokenConfigured && channel.processEnvAvailable
? `${channel.envKey} is saved in Claude channel state, and the current CCS process env also provides it.` ? t('settingsPage.channelsSection.tokenSavedAndProcessEnv', {
: `Save ${channel.envKey} in Claude's official channel env file. The dashboard never reads the token value back after save.`} envKey: channel.envKey,
defaultValue:
'{{envKey}} is saved in Claude channel state, and the current CCS process env also provides it.',
})
: t('settingsPage.channelsSection.tokenSaveHint', {
envKey: channel.envKey,
defaultValue:
"Save {{envKey}} in Claude's official channel env file. The dashboard never reads the token value back after save.",
})}
</p> </p>
{channel.tokenConfigured && ( {channel.tokenConfigured && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -385,10 +433,20 @@ export default function ChannelsSection() {
onChange={(event) => updateTokenDraft(channel.id, event.target.value)} onChange={(event) => updateTokenDraft(channel.id, event.target.value)}
placeholder={ placeholder={
channel.tokenConfigured channel.tokenConfigured
? `Configured. Enter a new ${channel.envKey} to replace it.` ? t('settingsPage.channelsSection.tokenConfiguredPlaceholder', {
envKey: channel.envKey,
defaultValue: 'Configured. Enter a new {{envKey}} to replace it.',
})
: !channel.tokenConfigured && channel.tokenSource === 'process_env' : !channel.tokenConfigured && channel.tokenSource === 'process_env'
? `Using current CCS process env. Enter a new ${channel.envKey} to save it for Claude.` ? t('settingsPage.channelsSection.tokenProcessEnvPlaceholder', {
: `Paste ${channel.envKey}` envKey: channel.envKey,
defaultValue:
'Using current CCS process env. Enter a new {{envKey}} to save it for Claude.',
})
: t('settingsPage.channelsSection.tokenPastePlaceholder', {
envKey: channel.envKey,
defaultValue: 'Paste {{envKey}}',
})
} }
disabled={saving} disabled={saving}
/> />
@@ -403,8 +461,9 @@ export default function ChannelsSection() {
disabled={saving || !tokenDraft.trim()} disabled={saving || !tokenDraft.trim()}
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key for "Save Token" */} {t('settingsPage.channelsSection.saveToken', {
Save Token defaultValue: 'Save Token',
})}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -412,17 +471,19 @@ export default function ChannelsSection() {
disabled={saving || !channel.tokenConfigured} disabled={saving || !channel.tokenConfigured}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key for "Clear Saved Token" */} {t('settingsPage.channelsSection.clearSavedToken', {
Clear Saved Token defaultValue: 'Clear Saved Token',
})}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
<details className="rounded-lg border bg-background p-4"> <details className="rounded-lg border bg-background p-4">
{/* TODO i18n: missing key for "Claude-side setup commands" */}
<summary className="cursor-pointer text-sm font-medium"> <summary className="cursor-pointer text-sm font-medium">
Claude-side setup commands {t('settingsPage.channelsSection.claudeSideSetupCommands', {
defaultValue: 'Claude-side setup commands',
})}
</summary> </summary>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{(channel.manualSetupCommands ?? []).map((command) => ( {(channel.manualSetupCommands ?? []).map((command) => (
@@ -441,10 +502,11 @@ export default function ChannelsSection() {
</div> </div>
<Alert> <Alert>
{/* TODO i18n: missing key for channels injection disclaimer */}
<AlertDescription> <AlertDescription>
CCS injects <code>--channels</code> only for the current Claude session. Telegram, {t('settingsPage.channelsSection.injectionDisclaimer', {
Discord, and iMessage stop receiving messages when that Claude session exits. defaultValue:
'CCS injects `--channels` only for the current Claude session. Telegram, Discord, and iMessage stop receiving messages when that Claude session exits.',
})}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -456,11 +518,11 @@ export default function ChannelsSection() {
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
{t('profileEditorSections.skipPermissionPrompts')} {t('profileEditorSections.skipPermissionPrompts')}
</Label> </Label>
{/* TODO i18n: missing key for skip permission description */}
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Optional advanced behavior. CCS adds <code>--dangerously-skip-permissions</code>{' '} {t('settingsPage.channelsSection.skipPermissionDescription', {
only when at least one selected channel is being auto-enabled and you did not defaultValue:
already pass a permission flag yourself. 'Optional advanced behavior. CCS adds `--dangerously-skip-permissions` only when at least one selected channel is being auto-enabled and you did not already pass a permission flag yourself.',
})}
</p> </p>
</div> </div>
</div> </div>
@@ -28,6 +28,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { api, type ImageAnalysisDashboardData } from '@/lib/api-client'; import { api, type ImageAnalysisDashboardData } from '@/lib/api-client';
import i18n from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useRawConfig } from '../../hooks'; import { useRawConfig } from '../../hooks';
@@ -116,25 +117,24 @@ function backendStateClass(state: ImageAnalysisDashboardData['backends'][number]
} }
} }
// TODO i18n: missing keys for currentTargetModeLabel values
function currentTargetModeLabel( function currentTargetModeLabel(
mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode'] mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode']
): string { ): string {
switch (mode) { switch (mode) {
case 'active': case 'active':
return 'Active'; return i18n.t('imageAnalysisStatus.badgeReady');
case 'bypassed': case 'bypassed':
return 'Bypassed'; return i18n.t('imageAnalysisStatus.badgeBypassed');
case 'fallback': case 'fallback':
return 'Native fallback'; return i18n.t('imageAnalysisStatus.nativeFallback');
case 'setup': case 'setup':
return 'Needs setup'; return i18n.t('imageAnalysisStatus.badgeSetup');
case 'disabled': case 'disabled':
return 'Disabled'; return i18n.t('imageAnalysisStatus.badgeDisabled');
case 'native': case 'native':
return 'Native'; return i18n.t('imageAnalysisStatus.badgeNative');
case 'unresolved': case 'unresolved':
return 'Native only'; return i18n.t('imageAnalysisStatus.nativeImageReading');
} }
} }
@@ -157,57 +157,84 @@ function currentTargetModeClass(
} }
} }
// TODO i18n: missing keys for backendStateLabel values
function backendStateLabel(state: ImageBackend['state']): string { function backendStateLabel(state: ImageBackend['state']): string {
switch (state) { switch (state) {
case 'starts_on_launch': case 'starts_on_launch':
return 'Starts on launch'; return i18n.t('imageAnalysisSection.backendStartsOnLaunch', {
defaultValue: 'Starts on launch',
});
case 'needs_auth': case 'needs_auth':
return 'Needs auth'; return i18n.t('imageAnalysisStatus.needsAuth');
case 'needs_proxy': case 'needs_proxy':
return 'Needs proxy'; return i18n.t('imageAnalysisStatus.needsProxy');
case 'review': case 'review':
return 'Review'; return i18n.t('imageAnalysisSection.review', { defaultValue: 'Review' });
case 'ready': case 'ready':
return 'Ready'; return i18n.t('imageAnalysisStatus.badgeReady');
} }
} }
// TODO i18n: missing keys for backendStatusNote values
function backendStatusNote(backend: ImageBackend | undefined): string | null { function backendStatusNote(backend: ImageBackend | undefined): string | null {
if (!backend) { if (!backend) {
return 'No model configured.'; return i18n.t('imageAnalysisSection.noModelConfigured', {
defaultValue: 'No model configured.',
});
} }
switch (backend.state) { switch (backend.state) {
case 'needs_auth': case 'needs_auth':
return backend.authReason || 'Authenticate to route here.'; return (
backend.authReason ||
i18n.t('imageAnalysisSection.authenticateToRoute', {
defaultValue: 'Authenticate to route here.',
})
);
case 'needs_proxy': case 'needs_proxy':
return backend.proxyReason || 'Proxy unavailable.'; return (
backend.proxyReason ||
i18n.t('imageAnalysisSection.proxyUnavailable', {
defaultValue: 'Proxy unavailable.',
})
);
case 'starts_on_launch': case 'starts_on_launch':
return 'Auth ready. Launches locally on demand.'; return i18n.t('imageAnalysisSection.authReadyLaunchesLocally', {
defaultValue: 'Auth ready. Launches locally on demand.',
});
case 'review': case 'review':
return 'Needs manual review.'; return i18n.t('imageAnalysisSection.needsManualReview', {
defaultValue: 'Needs manual review.',
});
case 'ready': case 'ready':
return null; return null;
} }
} }
// TODO i18n: missing keys for routeSourceLabel values
function routeSourceLabel(source: ImageProfile['resolutionSource']): string { function routeSourceLabel(source: ImageProfile['resolutionSource']): string {
switch (source) { switch (source) {
case 'profile-backend': case 'profile-backend':
return 'Explicit mapping'; return i18n.t('imageAnalysisSection.explicitMapping', {
defaultValue: 'Explicit mapping',
});
case 'fallback-backend': case 'fallback-backend':
return 'Fallback backend'; return i18n.t('imageAnalysisSection.fallbackBackend', {
defaultValue: 'Fallback backend',
});
case 'cliproxy-provider': case 'cliproxy-provider':
return 'Provider match'; return i18n.t('imageAnalysisSection.providerMatch', {
defaultValue: 'Provider match',
});
case 'cliproxy-bridge': case 'cliproxy-bridge':
return 'Bridge match'; return i18n.t('imageAnalysisSection.bridgeMatch', {
defaultValue: 'Bridge match',
});
case 'native-compatible': case 'native-compatible':
return 'Native path'; return i18n.t('imageAnalysisSection.nativePath', {
defaultValue: 'Native path',
});
case 'copilot-alias': case 'copilot-alias':
return 'Copilot alias'; return i18n.t('imageAnalysisSection.copilotAlias', {
defaultValue: 'Copilot alias',
});
default: default:
return source.replace(/-/g, ' '); return source.replace(/-/g, ' ');
} }
@@ -970,7 +997,9 @@ export default function ImageAnalysisSection() {
<div className="mt-2 flex flex-col gap-2 sm:flex-row"> <div className="mt-2 flex flex-col gap-2 sm:flex-row">
<Input <Input
className="h-10 flex-1 border-cyan-500/15 bg-background/88 text-base" className="h-10 flex-1 border-cyan-500/15 bg-background/88 text-base"
placeholder="Set model" /* TODO i18n: missing key */ placeholder={t('imageAnalysisSection.setModel', {
defaultValue: 'Set model',
})}
value={currentModel} value={currentModel}
disabled={saving} disabled={saving}
onChange={(event) => onChange={(event) =>
@@ -999,8 +1028,7 @@ export default function ImageAnalysisSection() {
void commitProviderModel(backendId, ''); void commitProviderModel(backendId, '');
}} }}
> >
{/* TODO i18n: missing key for "Clear" */} {t('common.clear', { defaultValue: 'Clear' })}
Clear
</Button> </Button>
)} )}
</div> </div>
@@ -1032,7 +1060,9 @@ export default function ImageAnalysisSection() {
getInsetPanelClass('emerald') getInsetPanelClass('emerald')
)} )}
> >
No profiles prefer native reading yet. {t('imageAnalysisSection.noProfilesPreferNative', {
defaultValue: 'No profiles prefer native reading yet.',
})}
</div> </div>
) : ( ) : (
<div className="grid gap-2 md:grid-cols-2"> <div className="grid gap-2 md:grid-cols-2">
@@ -1051,15 +1081,28 @@ export default function ImageAnalysisSection() {
{profile.name} {profile.name}
</div> </div>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{profile.kind === 'variant' ? 'Variant' : 'Profile'} {profile.kind === 'variant'
? t('cliproxyPage.variant')
: t('apiProfiles.title')}
</Badge> </Badge>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{profile.nativeImageCapable ? 'Verified' : 'Review'} {profile.nativeImageCapable
? t('imageAnalysisStatus.capabilityVerified')
: t('imageAnalysisSection.review', {
defaultValue: 'Review',
})}
</Badge> </Badge>
</div> </div>
<div className="mt-1 text-[11px] leading-4 text-muted-foreground"> <div className="mt-1 text-[11px] leading-4 text-muted-foreground">
{profile.profileModel || 'Model not detected'} ·{' '} {profile.profileModel ||
{profile.nativeImageReason || 'Native read preferred.'} t('imageAnalysisSection.modelNotDetected', {
defaultValue: 'Model not detected',
})}{' '}
·{' '}
{profile.nativeImageReason ||
t('imageAnalysisSection.nativeReadPreferred', {
defaultValue: 'Native read preferred.',
})}
</div> </div>
</div> </div>
<Badge <Badge
@@ -1101,8 +1144,9 @@ export default function ImageAnalysisSection() {
) : ( ) : (
<ChevronDown className="mr-1 h-4 w-4" /> <ChevronDown className="mr-1 h-4 w-4" />
)} )}
{/* TODO i18n: missing key for "Hide"/"Show" */} {showProfileRouting
{showProfileRouting ? 'Hide' : 'Show'} ? t('common.hide', { defaultValue: 'Hide' })
: t('common.show', { defaultValue: 'Show' })}
</Button> </Button>
{showProfileRouting && ( {showProfileRouting && (
<Button <Button
@@ -1122,8 +1166,9 @@ export default function ImageAnalysisSection() {
disabled={configuredBackendIds.length === 0 || saving} disabled={configuredBackendIds.length === 0 || saving}
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
{/* TODO i18n: missing key for "Add mapping" */} {t('imageAnalysisSection.addMapping', {
Add mapping defaultValue: 'Add mapping',
})}
</Button> </Button>
)} )}
</div> </div>
@@ -1156,7 +1201,11 @@ export default function ImageAnalysisSection() {
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-[11px] leading-4 text-muted-foreground"> <div className="flex items-center gap-2 text-[11px] leading-4 text-muted-foreground">
<span>Direct override</span> <span>
{t('imageAnalysisSection.directOverride', {
defaultValue: 'Direct override',
})}
</span>
{!(row.profileName.trim() && row.backendId) && ( {!(row.profileName.trim() && row.backendId) && (
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
Draft Draft
@@ -1176,8 +1225,7 @@ export default function ImageAnalysisSection() {
}} }}
> >
<Trash2 className="mr-1 h-4 w-4" /> <Trash2 className="mr-1 h-4 w-4" />
{/* TODO i18n: missing key for "Remove" */} {t('common.remove', { defaultValue: 'Remove' })}
Remove
</Button> </Button>
</div> </div>
@@ -1186,7 +1234,9 @@ export default function ImageAnalysisSection() {
value={row.profileName} value={row.profileName}
list="image-profile-suggestions" list="image-profile-suggestions"
disabled={saving} disabled={saving}
placeholder="Profile or variant name" /* TODO i18n: missing key */ placeholder={t('imageAnalysisSection.profileOrVariantName', {
defaultValue: 'Profile or variant name',
})}
className="h-10 border-slate-400/15 bg-background/88 text-base" className="h-10 border-slate-400/15 bg-background/88 text-base"
onChange={(event) => { onChange={(event) => {
updateMappingRow(row.id, { profileName: event.target.value }); updateMappingRow(row.id, { profileName: event.target.value });
@@ -1281,7 +1331,9 @@ export default function ImageAnalysisSection() {
{profile.name} {profile.name}
</div> </div>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{profile.kind === 'variant' ? 'Variant' : 'Profile'} {profile.kind === 'variant'
? t('cliproxyPage.variant')
: t('apiProfiles.title')}
</Badge> </Badge>
<Badge variant="outline" className="font-mono text-[10px] uppercase"> <Badge variant="outline" className="font-mono text-[10px] uppercase">
{profile.target} {profile.target}
+3 -4
View File
@@ -6,7 +6,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel'; import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel';
import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item'; import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item';
import { import {
SUPPORT_NOTICES, getSupportNotices,
getSupportEntriesForNotice, getSupportEntriesForNotice,
type SupportNotice, type SupportNotice,
} from '@/lib/support-updates-catalog'; } from '@/lib/support-updates-catalog';
@@ -45,9 +45,8 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean
export function UpdatesPage() { export function UpdatesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const notices = useMemo( const notices = [...getSupportNotices()].sort((a, b) =>
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), b.publishedAt.localeCompare(a.publishedAt)
[]
); );
const [viewMode, setViewMode] = useState<NoticeViewMode>('inbox'); const [viewMode, setViewMode] = useState<NoticeViewMode>('inbox');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
+1
View File
@@ -3,6 +3,7 @@
* Global test configuration and matchers * Global test configuration and matchers
*/ */
import '../../src/lib/i18n';
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest'; import { afterEach, vi } from 'vitest';
@@ -71,7 +71,8 @@ describe('ModelConfigSection presets', () => {
expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Free Tier')).not.toBeInTheDocument();
expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Gemini Pro' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Gemini Pro High' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Gemini Pro Low' })).toBeInTheDocument();
expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument(); expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument();
}); });
@@ -101,12 +102,12 @@ describe('ModelConfigSection presets', () => {
/> />
); );
await userEvent.click(screen.getByRole('button', { name: 'Gemini Pro' })); await userEvent.click(screen.getByRole('button', { name: 'Gemini Pro High' }));
expect(onApplyPreset).toHaveBeenCalledWith({ expect(onApplyPreset).toHaveBeenCalledWith({
ANTHROPIC_MODEL: 'gemini-3.9-pro-preview', ANTHROPIC_MODEL: 'gemini-3.1-pro-high',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.9-pro-preview', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.1-pro-high',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.9-pro-preview', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.1-pro-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview',
}); });
}); });
@@ -73,13 +73,8 @@ describe('CodexOverviewTab', () => {
expect(screen.getAllByText('codex').length).toBeGreaterThan(0); expect(screen.getAllByText('codex').length).toBeGreaterThan(0);
expect(screen.queryByText('codex --profile default')).not.toBeInTheDocument(); expect(screen.queryByText('codex --profile default')).not.toBeInTheDocument();
expect( expect(
screen.getAllByText( screen.getByText('API profiles continue to default to Claude or Droid.')
(_, node) => ).toBeInTheDocument();
node?.textContent?.includes(
'Built-in openai and oss providers are also valid native defaults'
) ?? false
).length
).toBeGreaterThan(0);
}); });
it('uses the active named profile when one is selected', () => { it('uses the active named profile when one is selected', () => {
@@ -45,7 +45,7 @@ describe('ProfileCreateDialog', () => {
expect(screen.getByText('Featured Providers')).toBeInTheDocument(); expect(screen.getByText('Featured Providers')).toBeInTheDocument();
expect(screen.getByText('More Presets')).toBeInTheDocument(); expect(screen.getByText('More Presets')).toBeInTheDocument();
expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); expect(screen.getByText('Local runtimes')).toBeInTheDocument();
expect(screen.getByText('Alibaba Coding Plan')).toBeVisible(); expect(screen.getByText('Alibaba Coding Plan')).toBeVisible();
expect(screen.getByText('Hugging Face')).toBeVisible(); expect(screen.getByText('Hugging Face')).toBeVisible();
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2); expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
@@ -73,7 +73,7 @@ describe('ProfileCreateDialog', () => {
expect(screen.getByDisplayValue('ollama')).toBeInTheDocument(); expect(screen.getByDisplayValue('ollama')).toBeInTheDocument();
expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument(); expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument();
expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); expect(screen.getByText('Local runtimes')).toBeInTheDocument();
}); });
it('steers the Hugging Face preset to the droid target by default', async () => { it('steers the Hugging Face preset to the droid target by default', async () => {
@@ -32,12 +32,23 @@ vi.mock(import('react-i18next'), async (importOriginal) => {
'authMonitor.success': 'Success', 'authMonitor.success': 'Success',
'authMonitor.failed': 'Failed', 'authMonitor.failed': 'Failed',
'authMonitor.successRate': 'Success Rate', 'authMonitor.successRate': 'Success Rate',
'authMonitorLive.live': 'LIVE',
'authMonitorLive.accountMonitor': 'Account Monitor',
'authMonitorLive.requestsLabel': 'req',
}; };
if (key === 'authMonitor.accountsCount') { if (key === 'authMonitor.accountsCount') {
return `${options?.count ?? 0} accounts`; return `${options?.count ?? 0} accounts`;
} }
if (key === 'authMonitorLive.updated') {
return `Updated ${options && 'time' in options ? String((options as { time?: string }).time ?? '') : ''}`.trim();
}
if (key === 'authMonitorLive.updatedNow') {
return 'Updated now';
}
return translations[key] ?? key; return translations[key] ?? key;
}, },
}), }),
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES } from '@/lib/support-updates-catalog'; import { getCliSupportEntries, getSupportNotices } from '@/lib/support-updates-catalog';
describe('support-updates catalog codex routing', () => { describe('support-updates catalog codex routing', () => {
it('routes the Codex runtime notice to the Codex dashboard', () => { it('routes the Codex runtime notice to the Codex dashboard', () => {
const notice = SUPPORT_NOTICES.find((entry) => entry.id === 'codex-target-runtime-support'); const notice = getSupportNotices().find((entry) => entry.id === 'codex-target-runtime-support');
expect(notice).toBeDefined(); expect(notice).toBeDefined();
expect(notice?.routes).toContainEqual({ label: 'Codex CLI', path: '/codex' }); expect(notice?.routes).toContainEqual({ label: 'Codex CLI', path: '/codex' });
@@ -17,14 +17,14 @@ describe('support-updates catalog codex routing', () => {
}); });
it('routes the Codex target entry to the Codex dashboard', () => { it('routes the Codex target entry to the Codex dashboard', () => {
const entry = CLI_SUPPORT_ENTRIES.find((item) => item.id === 'codex-target'); const entry = getCliSupportEntries().find((item) => item.id === 'codex-target');
expect(entry).toBeDefined(); expect(entry).toBeDefined();
expect(entry?.routes).toEqual([{ label: 'Codex CLI', path: '/codex' }]); expect(entry?.routes).toEqual([{ label: 'Codex CLI', path: '/codex' }]);
}); });
it('uses Updates Center naming consistently for the rollout notice', () => { it('uses Updates Center naming consistently for the rollout notice', () => {
const notice = SUPPORT_NOTICES.find((entry) => entry.id === 'updates-center-launch'); const notice = getSupportNotices().find((entry) => entry.id === 'updates-center-launch');
expect(notice).toBeDefined(); expect(notice).toBeDefined();
expect(notice?.title).toContain('Updates Center'); expect(notice?.title).toContain('Updates Center');
@@ -166,7 +166,7 @@ describe('ImageAnalysisSection', () => {
it('renders global controls and saves updated config', async () => { it('renders global controls and saves updated config', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true }); render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image')).toBeInTheDocument(); expect(await screen.findByText('Image Analysis')).toBeInTheDocument();
expect(screen.getByText('Partially ready')).toBeInTheDocument(); expect(screen.getByText('Partially ready')).toBeInTheDocument();
expect(screen.getByText('Core setup')).toBeInTheDocument(); expect(screen.getByText('Core setup')).toBeInTheDocument();
expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0); expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0);
@@ -206,7 +206,7 @@ describe('ImageAnalysisSection', () => {
}, },
}); });
expect(await screen.findByText('Image settings saved.')).toBeInTheDocument(); expect(await screen.findByText('Settings saved')).toBeInTheDocument();
}); });
it('allows saving a disabled configuration even when every provider model is cleared', async () => { it('allows saving a disabled configuration even when every provider model is cleared', async () => {
@@ -278,7 +278,7 @@ describe('ImageAnalysisSection', () => {
it('auto-saves edits without rendering a dedicated save button', async () => { it('auto-saves edits without rendering a dedicated save button', async () => {
const { container } = render(<ImageAnalysisSection />, { withSettingsProvider: true }); const { container } = render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image')).toBeInTheDocument(); expect(await screen.findByText('Image Analysis')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
expect(container.firstElementChild).toHaveClass( expect(container.firstElementChild).toHaveClass(
+1 -1
View File
@@ -143,7 +143,7 @@ describe('LogsPage', () => {
render(<LogsPage />); render(<LogsPage />);
expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument(); expect(screen.getByLabelText('Loading logs...')).toBeInTheDocument();
}); });
it('filters by source and search query', async () => { it('filters by source and search query', async () => {