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

This commit is contained in:
Tam Nhu Tran
2026-04-13 22:55:13 -04:00
parent c7e60974ac
commit 87899a489c
25 changed files with 1149 additions and 412 deletions
+9 -1
View File
@@ -1,6 +1,6 @@
# 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.
@@ -14,6 +14,7 @@ Dashboard i18n currently covers UI text rendered by React components.
- `en` (English)
- `zh-CN` (Simplified Chinese)
- `vi` (Vietnamese)
- `ja` (Japanese)
- Locale state is persisted in browser localStorage using `ccs-ui-locale`.
- Fallback language is `en`.
@@ -38,6 +39,7 @@ Out of scope:
- `en.translation`
- `zh-CN.translation`
- `vi.translation`
- `ja.translation`
- Uses `initReactI18next` for React integration.
### Locale utilities
@@ -54,6 +56,12 @@ Out of scope:
- Uses `react-i18next` + shadcn `Select`.
- 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
@@ -30,12 +30,10 @@ export function CodexFeaturesCard({
return (
<CodexConfigCardShell
// TODO i18n: missing key codex.features
title="Features"
title={t('codex.features')}
badge="features"
icon={<Sparkles className="h-4 w-4" />}
// TODO i18n: missing key codex.featuresDesc
description="Toggle the supported Codex feature flags CCS can safely manage."
description={t('codex.featuresDesc')}
disabledReason={disabledReason}
>
<div className="space-y-2">
@@ -81,14 +79,9 @@ export function CodexFeaturesCard({
<div className="space-y-2">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.configOnlyFlags */}
Existing config-only flags
</p>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key codex.configOnlyFlagsDesc */}
These feature keys already exist in your `config.toml`, so CCS can surface them
without claiming full catalog coverage.
{t('codex.configOnlyFlags')}
</p>
<p className="text-xs text-muted-foreground">{t('codex.configOnlyFlagsDesc')}</p>
</div>
{configOnlyFeatures.map(([name, current]) => (
<div
@@ -99,15 +92,11 @@ export function CodexFeaturesCard({
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{name}</p>
<Badge variant="secondary" className="text-[10px]">
{/* TODO i18n: missing key codex.existing */}
existing
{t('codex.existing')}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing keys codex.nonBooleanForm / codex.discoveredFromFile */}
{current === null
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
: "Discovered from the current file instead of CCS's built-in catalog."}
{current === null ? t('codex.nonBooleanForm') : t('codex.discoveredFromFile')}
</p>
</div>
{current === null ? (
@@ -74,27 +74,21 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-4 w-4" />
{/* TODO i18n: missing key codex.howCodexWorks */}
How Codex works in CCS
{t('codex.howCodexWorks')}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<ul className="ml-4 list-disc space-y-1.5 [&>li]:pl-1">
<li>{t('codex.nativeDesc')}</li>
<li>
{/* TODO i18n: missing key codex.nativeConfigLabel */}
<strong>Native config:</strong> <code>ccs-codex</code> and <code>ccsx</code> launch
native Codex using your saved defaults.
<strong>{t('codex.nativeConfigLabel')}</strong> {t('codex.nativeConfigDesc')}
</li>
<li>
{/* TODO i18n: missing key codex.transientOverrides */}
<strong>Transient overrides:</strong> <code>ccsxp</code> (or{' '}
<code>ccs codex --target codex</code>) uses the CCS provider shortcut.
<strong>{t('codex.transientOverridesLabel')}</strong>{' '}
{t('codex.transientOverridesDesc')}
</li>
<li>
{/* TODO i18n: missing key codex.cliproxyDefault */}
<strong>CLIProxy default:</strong> To make plain <code>codex</code> use CLIProxy,
set <code>model_provider = "cliproxy"</code> and add the recipe below.
<strong>{t('codex.cliproxyDefaultLabel')}</strong> {t('codex.cliproxyDefaultDesc')}
</li>
<li>{t('codex.apiProfilesDefault')}</li>
</ul>
@@ -105,34 +99,42 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<TerminalSquare className="h-4 w-4" />
{/* TODO i18n: missing key codex.runtimeInstall */}
Runtime install
{t('codex.runtimeInstall')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t('codex.status')}</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys codex.detected / codex.notFound */}
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
{diagnostics.binary.installed ? t('codex.detected') : t('codex.notFound')}
</Badge>
</div>
{/* TODO i18n: missing keys codex.detectionSource / codex.binaryPath / codex.installDirectory / codex.version / codex.nativeAliases / codex.ccsProviderShortcut / codex.configOverrideSupport */}
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow label="Binary path" value={diagnostics.binary.path || 'Not found'} mono />
<DetailRow label={t('codex.detectionSource')} value={diagnostics.binary.source} mono />
<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'}
mono
/>
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono />
<DetailRow label="Native aliases" value="ccs-codex, ccsx" mono />
<DetailRow label="CCS provider shortcut" value="ccsxp" mono />
<DetailRow
label={t('codex.versionLabel')}
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">
<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'}>
{/* TODO i18n: missing keys codex.available / codex.missing */}
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
{diagnostics.binary.supportsConfigOverrides
? t('codex.available')
: t('codex.missing')}
</Badge>
</div>
</CardContent>
@@ -142,8 +144,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" />
{/* TODO i18n: missing key codex.cliproxyNativeCodex */}
CLIProxy-backed native Codex
{t('codex.cliproxyNativeCodex')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
@@ -155,14 +156,10 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</p>
<ul className="ml-4 list-disc space-y-1 [&>li]:pl-1">
<li>
{/* TODO i18n: missing key codex.builtInCcsxp */}
<strong>Built-in:</strong> Use <code>ccsxp</code> for the CCS provider
shortcut.
<strong>{t('codex.builtInLabel')}</strong> {t('codex.builtInCcsxpDesc')}
</li>
<li>
{/* TODO i18n: missing key codex.nativeRecipe */}
<strong>Native:</strong> Configure the recipe below to use CLIProxy directly
with <code>codex</code>.
<strong>{t('codex.nativeRecipeLabel')}</strong> {t('codex.nativeRecipeDesc')}
</li>
</ul>
</div>
@@ -173,29 +170,13 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</pre>
</div>
<ol className="ml-4 list-decimal space-y-1.5 [&>li]:pl-1">
<li>
{/* TODO i18n: missing key codex.saveProviderNamedCliproxy */}
Save a provider named <code>cliproxy</code> with the base URL and env key above.
</li>
<li>
{/* TODO i18n: missing key codex.inTopLevelSetDefault */}
In <strong>Top-level settings</strong>, set <strong>Default provider</strong> to{' '}
<code>cliproxy</code>.
</li>
<li>
{/* TODO i18n: missing key codex.exportCliproxyApiKey */}
Export <code>CLIPROXY_API_KEY</code> in your shell before launching native
Codex.
</li>
<li>{t('codex.saveProviderNamedCliproxy')}</li>
<li>{t('codex.inTopLevelSetDefault')}</li>
<li>{t('codex.exportCliproxyApiKey')}</li>
</ol>
</>
) : (
<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>
<p>{t('codex.noConfigOverrides')}</p>
)}
</CardContent>
</Card>
@@ -204,8 +185,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Folder className="h-4 w-4" />
{/* TODO i18n: missing key codex.configFile */}
Config file
{t('codex.configFile')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
@@ -218,21 +198,21 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* TODO i18n: missing keys codex.path / codex.resolved / codex.size / codex.lastModified */}
<DetailRow label="Path" value={diagnostics.file.path} mono />
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
<DetailRow label="Last modified" value={formatTimestamp(diagnostics.file.mtimeMs)} />
<DetailRow label={t('codex.path')} value={diagnostics.file.path} mono />
<DetailRow label={t('codex.resolved')} value={diagnostics.file.resolvedPath} mono />
<DetailRow label={t('codex.size')} value={formatBytes(diagnostics.file.sizeBytes)} />
<DetailRow
label={t('codex.lastModified')}
value={formatTimestamp(diagnostics.file.mtimeMs)}
/>
{diagnostics.file.parseError && (
<p className="text-xs text-amber-600">
{/* TODO i18n: missing key codex.tomlWarning */}
TOML warning: {diagnostics.file.parseError}
{t('codex.tomlWarning')}: {diagnostics.file.parseError}
</p>
)}
{diagnostics.file.readError && (
<p className="text-xs text-destructive">
{/* TODO i18n: missing key codex.readWarning */}
Read warning: {diagnostics.file.readError}
{t('codex.readWarning')}: {diagnostics.file.readError}
</p>
)}
</div>
@@ -243,64 +223,61 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
{/* TODO i18n: missing key codex.currentUserLayerSummary */}
Current user-layer summary
{t('codex.currentUserLayerSummary')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* TODO i18n: missing key codex.notSet */}
<DetailRow
label={t('codex.model')}
value={diagnostics.config.model || 'Not set'}
value={diagnostics.config.model || t('codex.notSet')}
mono
/>
<DetailRow
label={t('codex.defaultProvider')}
value={diagnostics.config.modelProvider || 'Not set'}
value={diagnostics.config.modelProvider || t('codex.notSet')}
mono
/>
<DetailRow
// TODO i18n: missing key codex.activeProfile
label="Active profile"
value={diagnostics.config.activeProfile || 'Not set'}
label={t('codex.activeProfile')}
value={diagnostics.config.activeProfile || t('codex.notSet')}
mono
/>
<DetailRow
label={t('codex.approvalPolicy')}
value={diagnostics.config.approvalPolicy || 'Not set'}
value={diagnostics.config.approvalPolicy || t('codex.notSet')}
mono
/>
<DetailRow
label={t('codex.sandboxMode')}
value={diagnostics.config.sandboxMode || 'Not set'}
value={diagnostics.config.sandboxMode || t('codex.notSet')}
mono
/>
<DetailRow
label={t('codex.webSearch')}
value={diagnostics.config.webSearch || 'Not set'}
value={diagnostics.config.webSearch || t('codex.notSet')}
mono
/>
<Separator />
<div className="grid grid-cols-2 gap-2 text-xs">
{/* TODO i18n: missing keys codex.providersCount / codex.profilesCount / codex.enabledFeaturesCount / codex.mcpServersCount */}
<Badge variant="outline" className="justify-center">
providers: {diagnostics.config.modelProviderCount}
{t('codex.providersCount', { count: diagnostics.config.modelProviderCount })}
</Badge>
<Badge variant="outline" className="justify-center">
profiles: {diagnostics.config.profileCount}
{t('codex.profilesCount', { count: diagnostics.config.profileCount })}
</Badge>
<Badge variant="outline" className="justify-center">
enabled features: {diagnostics.config.enabledFeatures.length}
{t('codex.enabledFeaturesCount', {
count: diagnostics.config.enabledFeatures.length,
})}
</Badge>
<Badge variant="outline" className="justify-center">
MCP servers: {diagnostics.config.mcpServerCount}
{t('codex.mcpServersCount', { count: diagnostics.config.mcpServerCount })}
</Badge>
</div>
{diagnostics.config.topLevelKeys.length > 0 && (
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key codex.userLayerKeysPresent */}
User-layer keys present
{t('codex.userLayerKeysPresent')}
</p>
<div className="flex flex-wrap gap-1.5">
{diagnostics.config.topLevelKeys.map((key) => (
@@ -317,22 +294,19 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<QuickCommands
snippets={[
{
// TODO i18n: missing key codex.nativeShortAlias
label: 'Native short alias',
label: t('codex.nativeShortAlias'),
command: 'ccsx',
description: 'Launch the short native Codex runtime alias.',
},
{
// TODO i18n: missing key codex.ccsCodexShortcut
label: 'CCS Codex shortcut',
label: t('codex.ccsCodexShortcut'),
command: 'ccsxp "your prompt"',
description: supportsManagedRouting
? t('codex.runBuiltInCodex')
: 'Requires a Codex build that exposes --config overrides.',
},
{
// TODO i18n: missing key codex.explicitProviderRoute
label: 'Explicit provider route',
label: t('codex.explicitProviderRoute'),
command: 'ccs codex --target codex "your prompt"',
description: supportsManagedRouting
? t('codex.runBuiltInCodexExplicit')
@@ -354,8 +328,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Route className="h-4 w-4" />
{/* TODO i18n: missing key codex.runtimeVsProvider */}
Runtime vs provider
{t('codex.runtimeVsProvider')}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
@@ -373,8 +346,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</li>
</ul>
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
{/* TODO i18n: missing key codex.honorsSavedNativeConfig */}
Honors saved native user config
{t('codex.honorsSavedNativeConfig')}
</Badge>
</div>
<div className="flex flex-col rounded-md border p-3 text-sm">
@@ -390,15 +362,11 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
</li>
</ul>
<Badge variant="secondary" className="mt-4 w-fit justify-center font-normal">
{/* TODO i18n: missing key codex.usesTransientOverrides */}
Uses transient overrides
{t('codex.usesTransientOverrides')}
</Badge>
</>
) : (
<p className="mt-2 text-muted-foreground">
{/* TODO i18n: missing key codex.unavailableNoConfig */}
Unavailable (Codex build lacks <code>--config</code> support).
</p>
<p className="mt-2 text-muted-foreground">{t('codex.unavailableNoConfig')}</p>
)}
</div>
</CardContent>
@@ -423,8 +391,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
<TableCell>
<Badge variant={entry.supported ? 'default' : 'secondary'}>
{/* TODO i18n: missing keys common.yes / common.no */}
{entry.supported ? 'Yes' : 'No'}
{entry.supported ? t('codex.yes') : t('codex.no')}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{entry.notes}</TableCell>
@@ -440,8 +407,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600" />
{/* TODO i18n: missing key codex.warnings */}
Warnings
{t('codex.warningsTitle')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
+2 -2
View File
@@ -120,7 +120,7 @@ function SettingsDialogContent({
try {
settingsToSave = JSON.parse(rawJsonContent);
} catch {
throw new Error('Invalid JSON');
throw new Error(i18n.t('settingsDialog.invalidJson'));
}
} else {
// Use form-based edits
@@ -147,7 +147,7 @@ function SettingsDialogContent({
}
if (!res.ok) {
throw new Error('Failed to save');
throw new Error(i18n.t('settingsDialog.failedSave'));
}
return res.json();
@@ -3,7 +3,7 @@ import { ArrowUpRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
SUPPORT_SCOPE_LABELS,
getSupportScopeLabels,
type CliSupportEntry,
type SupportScope,
} from '@/lib/support-updates-catalog';
@@ -23,6 +23,7 @@ const SCOPE_STYLES: Record<SupportScope, string> = {
export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
const { t } = useTranslation();
const scopeLabels = getSupportScopeLabels();
const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
{ key: 'baseUrl', label: t('profileDialog.baseUrl') },
{ key: 'auth', label: t('copilotPage.auth') },
@@ -41,7 +42,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
</div>
<Badge variant="outline" className={SCOPE_STYLES[entry.scope]}>
{SUPPORT_SCOPE_LABELS[entry.scope]}
{scopeLabels[entry.scope]}
</Badge>
</CardHeader>
@@ -9,7 +9,7 @@ import { SupportStatusBadge } from '@/components/updates/support-status-badge';
import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge';
import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row';
import {
SUPPORT_SCOPE_LABELS,
getSupportScopeLabels,
formatCatalogDate,
type CliSupportEntry,
type SupportNotice,
@@ -31,6 +31,7 @@ export function UpdatesDetailsPanel({
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
}) {
const { t } = useTranslation();
const scopeLabels = getSupportScopeLabels();
if (!notice) {
return (
<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">
<p className="min-w-0 truncate text-sm font-medium">{entry.name}</p>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{SUPPORT_SCOPE_LABELS[entry.scope]}
{scopeLabels[entry.scope]}
</Badge>
</div>
<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 }),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
const normalizedGroup = (vars.context_group || 'default')
.trim()
.toLowerCase()
.replace(/\s+/g, '-');
const contextSummary =
vars.context_mode === 'shared'
? vars.continuity_mode === 'deeper'
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)`
: `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)`
: 'isolated';
? `${t('accountsPage.sharedDeeper')} (${normalizedGroup})`
: `${t('accountsPage.sharedStandard')} (${normalizedGroup})`
: t('accountsPage.isolated');
toast.success(t('toasts.contextUpdated', { name: vars.name, summary: contextSummary }));
},
onError: (error: Error) => {
@@ -180,18 +184,14 @@ export function useConfirmLegacyAccountPolicies() {
onSuccess: ({ updatedCount, failedCount }) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
if (failedCount > 0 && updatedCount > 0) {
// TODO i18n: missing key for partial legacy confirmation with failures
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;
}
if (failedCount > 0) {
// TODO i18n: missing key for all legacy confirmations failed
toast.error(
`Failed to confirm ${failedCount} legacy account${failedCount > 1 ? 's' : ''}. Refreshed account state.`
);
toast.error(t('toasts.legacyConfirmAllFailed', { failed: failedCount }));
return;
}
+14 -20
View File
@@ -131,8 +131,7 @@ export function useCliproxyAuthFlow() {
setState((prev) => ({
...prev,
isAuthenticating: false,
// TODO i18n: missing key for 'Authentication timed out. Please try again.'
error: 'Authentication timed out. Please try again.',
error: t('toasts.providerAuthTimeout'),
}));
}
return;
@@ -161,8 +160,7 @@ export function useCliproxyAuthFlow() {
const hasAccount = typeof data.account === 'object' && data.account !== null;
if (!hasAccount) {
stopPolling();
// TODO i18n: missing key for 'Authenticated account could not be registered'
const errorMsg = 'Authenticated account could not be registered';
const errorMsg = t('toasts.providerAccountRegistrationFailed');
toast.error(errorMsg);
setState((prev) => ({
...prev,
@@ -226,11 +224,10 @@ export function useCliproxyAuthFlow() {
}
stopPolling();
// TODO i18n: missing key for 'Lost contact with the auth status endpoint'
const message =
error instanceof Error && error.message.trim().length > 0
? error.message
: 'Lost contact with the auth status endpoint';
: t('toasts.providerLostStatusEndpoint');
toast.error(message);
setState((prev) => ({
...prev,
@@ -245,10 +242,9 @@ export function useCliproxyAuthFlow() {
const startAuth = useCallback(
async (provider: string, options?: StartAuthOptions) => {
if (!isValidProvider(provider)) {
// TODO i18n: missing key for 'Unknown provider: {{provider}}'
setState({
...INITIAL_STATE,
error: `Unknown provider: ${provider}`,
error: t('toasts.providerUnknown', { provider }),
});
return;
}
@@ -319,13 +315,12 @@ export function useCliproxyAuthFlow() {
openedAuthUrlRef.current = false;
setState(INITIAL_STATE);
} else {
// TODO i18n: missing key for 'Authenticated account could not be registered' (start endpoint)
const errorMsg =
typeof data.error === 'string'
? data.error
: success
? 'Authenticated account could not be registered'
: 'Authentication failed';
? t('toasts.providerAccountRegistrationFailed')
: t('auth.loginFailed');
toast.error(errorMsg);
setState((prev) => ({
...prev,
@@ -370,8 +365,8 @@ export function useCliproxyAuthFlow() {
const success = data.success === true;
if (!response.ok || !success) {
// TODO i18n: missing key for 'Failed to start OAuth'
const errorMsg = typeof data.error === 'string' ? data.error : 'Failed to start OAuth';
const errorMsg =
typeof data.error === 'string' ? data.error : t('toasts.providerStartOAuthFailed');
throw new Error(errorMsg);
}
@@ -418,7 +413,7 @@ export function useCliproxyAuthFlow() {
setState(INITIAL_STATE);
return;
}
const message = error instanceof Error ? error.message : 'Authentication failed';
const message = error instanceof Error ? error.message : t('auth.loginFailed');
toast.error(message);
setState((prev) => ({
...prev,
@@ -427,7 +422,7 @@ export function useCliproxyAuthFlow() {
}));
}
},
[isActiveAttempt, pollStatus, stopPolling, queryClient]
[isActiveAttempt, pollStatus, stopPolling, queryClient, t]
);
const cancelAuth = useCallback(() => {
@@ -486,21 +481,20 @@ export function useCliproxyAuthFlow() {
toast.success(t('toasts.providerAuthSuccess', { provider: currentProvider }));
setState(INITIAL_STATE);
} else {
// TODO i18n: missing key for 'Callback submission failed'
const errorMsg =
typeof data.error === 'string'
? data.error
: success
? 'Authenticated account could not be registered'
: 'Callback submission failed';
? t('toasts.providerAccountRegistrationFailed')
: t('toasts.providerCallbackFailed');
throw new Error(errorMsg);
}
} catch (error) {
if (!isActiveAttempt(attemptId)) {
return;
}
// TODO i18n: missing key for 'Failed to submit callback'
const message = error instanceof Error ? error.message : 'Failed to submit callback';
const message =
error instanceof Error ? error.message : t('toasts.providerSubmitCallbackFailed');
toast.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 {
model: 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"
wire_api = "responses"`;
export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [
{
name: 'multi_agent',
label: 'Multi-agent', // TODO i18n: missing key for codex feature
description: 'Enable subagent collaboration tools.', // TODO i18n: missing key
},
{
name: 'unified_exec',
label: 'Unified exec', // TODO i18n: missing key for codex feature
description: 'Use the PTY-backed unified exec tool.', // TODO i18n: missing key
},
{
name: 'shell_snapshot',
label: 'Shell snapshot', // TODO i18n: missing key for codex feature
description: 'Reuse shell environment snapshots.', // TODO i18n: missing key
},
{
name: 'apply_patch_freeform',
label: 'Apply patch', // TODO i18n: missing key for codex feature
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: 'runtime_metrics',
label: 'Runtime metrics', // TODO i18n: missing key for codex feature
description: 'Collect Codex runtime metrics.', // TODO i18n: missing key
},
{
name: 'prevent_idle_sleep',
label: 'Prevent idle sleep', // TODO i18n: missing key for codex feature
description: 'Keep the machine awake while active.', // TODO i18n: missing key
},
{ name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' }, // TODO i18n: missing keys
{ name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' }, // TODO i18n: missing keys
{
name: 'smart_approvals',
label: 'Smart approvals', // TODO i18n: missing key for codex feature
description: 'Route eligible approvals through the guardian flow.', // TODO i18n: missing key
},
];
export const KNOWN_CODEX_FEATURE_NAMES = [
'multi_agent',
'unified_exec',
'shell_snapshot',
'apply_patch_freeform',
'js_repl',
'runtime_metrics',
'prevent_idle_sleep',
'fast_mode',
'apps',
'smart_approvals',
] as const;
export function getKnownCodexFeatures(): CodexFeatureCatalogEntry[] {
return [
{
name: 'multi_agent',
label: i18n.t('codex.featureMultiAgentLabel'),
description: i18n.t('codex.featureMultiAgentDesc'),
},
{
name: 'unified_exec',
label: i18n.t('codex.featureUnifiedExecLabel'),
description: i18n.t('codex.featureUnifiedExecDesc'),
},
{
name: 'shell_snapshot',
label: i18n.t('codex.featureShellSnapshotLabel'),
description: i18n.t('codex.featureShellSnapshotDesc'),
},
{
name: 'apply_patch_freeform',
label: i18n.t('codex.featureApplyPatchLabel'),
description: i18n.t('codex.featureApplyPatchDesc'),
},
{
name: 'js_repl',
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 {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -222,9 +251,9 @@ export function readCodexFeatureState(
const features = asObject(config?.features);
const state: Record<string, boolean | null> = {};
for (const feature of KNOWN_CODEX_FEATURES) {
const value = features?.[feature.name];
state[feature.name] = typeof value === 'boolean' ? value : null;
for (const featureName of KNOWN_CODEX_FEATURE_NAMES) {
const value = features?.[featureName];
state[featureName] = typeof value === 'boolean' ? value : null;
}
if (features) {
+472
View File
@@ -1066,6 +1066,8 @@ const resources = {
},
apiProfiles: {
title: 'Profiles',
sidebarTitle: 'API Profiles',
sidebarSubtitle: 'Premium APIs, local runtimes, custom endpoints',
new: 'New',
searchPlaceholder: 'Search profiles...',
loadingProfiles: 'Loading profiles...',
@@ -1087,6 +1089,19 @@ const resources = {
unsavedChangesDesc:
'You have unsaved changes in "{{current}}". Discard and switch to "{{next}}"?',
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: {
title: 'Accounts',
@@ -1578,6 +1593,11 @@ const resources = {
fiveHourResets: '5h resets {{time}}',
weeklyResets: 'Weekly resets {{time}}',
},
utils: {
codeReview5h: 'Code review (5h)',
codeReviewWeekly: 'Code review (weekly)',
codeReview: 'Code review',
},
sponsorButton: {
title: 'Sponsor this project on GitHub',
sponsor: 'Sponsor',
@@ -1635,6 +1655,8 @@ const resources = {
conflictDesc:
'This settings file was modified by another process. Overwrite with your changes or discard?',
overwrite: 'Overwrite',
invalidJson: 'Invalid JSON',
failedSave: 'Failed to save',
},
// ========================================
@@ -1852,12 +1874,60 @@ const resources = {
nativeCodexRuntime: 'Native Codex Runtime',
ccsCodexProvider: 'CCS Codex provider / bridge',
codexDocs: 'Codex docs',
howCodexWorks: 'How Codex works in CCS',
supportedFlows: 'Supported flows',
twoSupportedPaths: 'Two supported paths:',
nativeLabel: 'Native:',
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',
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',
fastestPath: 'Fastest path',
officialChannels: 'Official Channels',
@@ -1866,6 +1936,9 @@ const resources = {
runBuiltInCodex: 'Run built-in Codex on Codex',
runBuiltInCodexExplicit: 'Run built-in Codex on Codex (explicit)',
openCodexDashboard: 'Open Codex dashboard',
nativeShortAlias: 'Native short alias',
ccsCodexShortcut: 'CCS Codex shortcut',
explicitProviderRoute: 'Explicit provider route',
status: 'Status',
profiles: 'Profiles',
createNewProfile: 'Create new profile',
@@ -1895,6 +1968,10 @@ const resources = {
untrusted: 'untrusted',
noProjectTrustEntries: 'No explicit project trust entries saved.',
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',
usageLimitCost: 'Usage-limit cost above 272K',
longContextOverride: 'Long context override',
@@ -1911,6 +1988,37 @@ const resources = {
configYaml: 'config.yaml',
flow: 'Flow',
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: {
quickControls: 'Quick Controls',
@@ -2022,6 +2130,13 @@ const resources = {
syncFailed: 'Sync failed: {{error}}',
providerAuthSuccess: '{{provider}} authentication successful',
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.',
loggingConfigSaveFailed: 'Failed to save logging configuration.',
unifiedConfigUpdated: 'Configuration updated successfully',
@@ -2039,6 +2154,10 @@ const resources = {
legacyConfirmFailed: 'Legacy account "{{name}}" failed confirmation: {{error}}',
legacyConfirmSuccess_one: '{{count}} legacy account 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',
routingStrategySet: 'Routing strategy set to {{strategy}}',
variantCreated: 'Variant created successfully',
@@ -3315,6 +3434,8 @@ const resources = {
},
apiProfiles: {
title: 'API 配置',
sidebarTitle: 'API 配置',
sidebarSubtitle: '高级 API、本地运行时、自定义端点',
new: '新建',
searchPlaceholder: '搜索配置...',
loadingProfiles: '正在加载配置...',
@@ -3334,6 +3455,19 @@ const resources = {
unsavedChangesTitle: '未保存的更改',
unsavedChangesDesc: '你在“{{current}}”中有未保存内容,是否丢弃并切换到“{{next}}”?',
discardSwitch: '丢弃并切换',
noOrphansFound: '未发现孤立配置',
confirmRegisterOrphans:
'发现 {{total}} 个孤立配置文件,其中 {{valid}} 个可直接注册。现在注册吗?',
registeredWithSkipped: '(跳过 {{count}} 个)',
registeredProfiles: '已注册 {{count}} 个配置',
copyPrompt: '将“{{name}}”复制为哪个新配置名称?',
destinationEmpty: '目标配置名称不能为空',
exportRedacted:
'导出内容中的令牌已脱敏。如需包含密钥,请改用 CLI 的 include-secrets 流程。',
exportDownloaded: '配置导出已下载',
importFailed: '导入配置包失败',
discoverOrphans: '发现孤立配置',
importProfileBundle: '导入配置包',
},
accountsPage: {
title: '账号',
@@ -3812,6 +3946,11 @@ const resources = {
fiveHourResets: '5 小时重置于 {{time}}',
weeklyResets: '每周重置于 {{time}}',
},
utils: {
codeReview5h: '代码审查(5 小时)',
codeReviewWeekly: '代码审查(每周)',
codeReview: '代码审查',
},
sponsorButton: {
title: '在 GitHub 上赞助此项目',
sponsor: '赞助',
@@ -3868,6 +4007,8 @@ const resources = {
conflictTitle: '文件被外部修改',
conflictDesc: '此设置文件已被其他进程修改。用你的更改覆盖还是丢弃?',
overwrite: '覆盖',
invalidJson: 'JSON 无效',
failedSave: '保存失败',
},
setupWizard: {
title: '快速设置向导',
@@ -4131,6 +4272,90 @@ const resources = {
configYaml: 'config.yaml',
flow: '工作流',
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: {
quickControls: '快捷控制',
@@ -4234,6 +4459,13 @@ const resources = {
syncFailed: '同步失败:{{error}}',
providerAuthSuccess: '{{provider}} 认证成功',
providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程',
providerAuthTimeout: '认证超时,请重试。',
providerAccountRegistrationFailed: '已认证账号无法注册',
providerLostStatusEndpoint: '与认证状态端点失去连接',
providerUnknown: '未知提供商:{{provider}}',
providerStartOAuthFailed: '启动 OAuth 失败',
providerCallbackFailed: '回调提交失败',
providerSubmitCallbackFailed: '提交回调失败',
loggingConfigSaved: '日志配置已保存。',
loggingConfigSaveFailed: '保存日志配置失败。',
unifiedConfigUpdated: '配置更新成功',
@@ -4250,6 +4482,9 @@ const resources = {
legacyConfirmFailed: '旧版账号「{{name}}」确认失败:{{error}}',
legacyConfirmSuccess_one: '{{count}} 个旧版账号已确认',
legacyConfirmSuccess_other: '{{count}} 个旧版账号已确认',
legacyConfirmPartial:
'已确认 {{updated}} 个旧版账号,但有 {{failed}} 个更新失败。账号状态已刷新。',
legacyConfirmAllFailed: '确认 {{failed}} 个旧版账号失败。账号状态已刷新。',
noLegacyAccounts: '没有需要确认的旧版账号',
routingStrategySet: '路由策略已设为 {{strategy}}',
variantCreated: '变体创建成功',
@@ -5604,6 +5839,8 @@ const resources = {
},
apiProfiles: {
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',
searchPlaceholder: 'Tìm kiếm hồ sơ...',
loadingProfiles: 'Đang tải hồ sơ...',
@@ -5625,6 +5862,18 @@ const resources = {
unsavedChangesDesc:
'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',
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: {
title: 'Tài khoản',
@@ -6116,6 +6365,11 @@ const resources = {
fiveHourResets: 'Reset 5h 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: {
title: 'Tài trợ dự án này trên GitHub',
sponsor: 'Tài trợ',
@@ -6173,6 +6427,8 @@ const resources = {
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?',
overwrite: 'Ghi đè',
invalidJson: 'JSON không hợp lệ',
failedSave: 'Không lưu được',
},
setupWizard: {
title: 'Trình thiết lập nhanh',
@@ -6438,6 +6694,91 @@ const resources = {
configYaml: 'config.yaml',
flow: 'Luồng',
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: {
quickControls: 'Điều khiển nhanh',
@@ -6541,6 +6882,13 @@ const resources = {
syncFailed: 'Đồng bộ thất bại: {{error}}',
providerAuthSuccess: 'Xác thực {{provider}} thành công',
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ý.',
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',
@@ -6558,6 +6906,10 @@ const resources = {
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_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',
routingStrategySet: 'Chiến lược định tuyến đã đặt thành {{strategy}}',
variantCreated: 'Đã tạo biến thể thành công',
@@ -7921,6 +8273,8 @@ const resources = {
},
apiProfiles: {
title: 'API プロファイル',
sidebarTitle: 'API プロファイル',
sidebarSubtitle: 'プレミアム API、ローカルランタイム、カスタムエンドポイント',
new: '新規',
searchPlaceholder: 'プロファイルを検索...',
loadingProfiles: 'プロファイルを読み込み中...',
@@ -7942,6 +8296,19 @@ const resources = {
unsavedChangesDesc:
'"{{current}}" に未保存の変更があります。破棄して "{{next}}" に切り替えますか?',
discardSwitch: '破棄して切り替え',
noOrphansFound: '孤立したプロファイル設定は見つかりませんでした',
confirmRegisterOrphans:
'{{total}} 件の孤立したプロファイル設定ファイルが見つかりました。うち {{valid}} 件は登録可能です。今すぐ登録しますか?',
registeredWithSkipped: '{{count}} 件スキップ)',
registeredProfiles: '{{count}} 件のプロファイルを登録しました',
copyPrompt: '「{{name}}」をどの新しいプロファイル名にコピーしますか?',
destinationEmpty: 'コピー先のプロファイル名は空にできません',
exportRedacted:
'トークンをマスクした状態でエクスポートしました。必要なら CLI の include-secrets フローを使ってください。',
exportDownloaded: 'プロファイルのエクスポートをダウンロードしました',
importFailed: 'プロファイルバンドルをインポートできませんでした',
discoverOrphans: '孤立プロファイルを検出',
importProfileBundle: 'プロファイルバンドルをインポート',
},
accountsPage: {
title: 'アカウント',
@@ -8504,6 +8871,93 @@ const resources = {
configYaml: 'config.yaml',
flow: 'フロー',
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: {
title: 'Codex',
@@ -8913,6 +9367,11 @@ const resources = {
fiveHourResets: '5時間リセット {{time}}',
weeklyResets: '週間リセット {{time}}',
},
utils: {
codeReview5h: 'コードレビュー(5時間)',
codeReviewWeekly: 'コードレビュー(週次)',
codeReview: 'コードレビュー',
},
rawEditorSection: {
rawConfig: 'Raw 設定',
},
@@ -8946,6 +9405,8 @@ const resources = {
conflictDesc:
'この設定ファイルは別のプロセスで変更されました。変更で上書きしますか?それとも破棄しますか?',
overwrite: '上書き',
invalidJson: '無効な JSON です',
failedSave: '保存に失敗しました',
},
settingsPage: {
title: '設定',
@@ -9080,6 +9541,13 @@ const resources = {
providerAuthSuccess: '{{provider}} の認証に成功しました',
providerDeviceCodeInCallback:
'コールバックモードでプロバイダーがデバイスコードフローを返しました',
providerAuthTimeout: '認証がタイムアウトしました。もう一度お試しください。',
providerAccountRegistrationFailed: '認証済みアカウントを登録できませんでした',
providerLostStatusEndpoint: '認証ステータスエンドポイントとの接続が失われました',
providerUnknown: '不明なプロバイダーです: {{provider}}',
providerStartOAuthFailed: 'OAuth を開始できませんでした',
providerCallbackFailed: 'コールバック送信に失敗しました',
providerSubmitCallbackFailed: 'コールバックを送信できませんでした',
loggingConfigSaved: 'ログ設定を保存しました。',
loggingConfigSaveFailed: 'ログ設定の保存に失敗しました。',
unifiedConfigUpdated: '設定を更新しました',
@@ -9097,6 +9565,10 @@ const resources = {
legacyConfirmFailed: 'レガシーアカウント「{{name}}」の確認に失敗しました: {{error}}',
legacyConfirmSuccess_one: '{{count}} 件のレガシーアカウントを確認しました',
legacyConfirmSuccess_other: '{{count}} 件のレガシーアカウントを確認しました',
legacyConfirmPartial:
'{{updated}} 件のレガシーアカウントを確認しましたが、{{failed}} 件の更新に失敗しました。アカウント状態を再取得しました。',
legacyConfirmAllFailed:
'{{failed}} 件のレガシーアカウントの確認に失敗しました。アカウント状態を再取得しました。',
noLegacyAccounts: '確認が必要なレガシーアカウントはありません',
routingStrategySet: 'ルーティング戦略を {{strategy}} に設定しました',
variantCreated: 'バリアントを作成しました',
+79 -11
View File
@@ -1,3 +1,5 @@
import i18n from '@/lib/i18n';
export type SupportStatus = 'new' | 'stable' | 'planned';
export type SupportScope = 'target' | 'cliproxy' | 'api-profiles' | 'websearch';
@@ -47,14 +49,14 @@ export interface CliSupportEntry {
notes?: string;
}
export const SUPPORT_SCOPE_LABELS: Record<SupportScope, string> = {
target: 'Target CLI', // TODO i18n: missing key for support scope target
cliproxy: 'CLIProxy Provider', // TODO i18n: missing key for support scope cliproxy
'api-profiles': 'API Profile', // TODO i18n: missing key for support scope api-profiles
websearch: 'WebSearch', // TODO i18n: missing key for support scope websearch
const BASE_SUPPORT_SCOPE_LABELS: Record<SupportScope, string> = {
target: 'Target CLI',
cliproxy: 'CLIProxy Provider',
'api-profiles': 'API Profile',
websearch: 'WebSearch',
};
export const SUPPORT_NOTICES: SupportNotice[] = [
const BASE_SUPPORT_NOTICES: SupportNotice[] = [
{
id: 'codex-target-runtime-support',
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',
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[] {
const supportEntryLookup = new Map(getCliSupportEntries().map((entry) => [entry.id, entry]));
return notice.entryIds
.map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId))
.map((entryId) => supportEntryLookup.get(entryId))
.filter((entry): entry is CliSupportEntry => Boolean(entry));
}
export function getLatestSupportNotice(): SupportNotice | null {
if (SUPPORT_NOTICES.length === 0) {
const notices = getSupportNotices();
if (notices.length === 0) {
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 {
-4
View File
@@ -38,10 +38,6 @@ import type { ProviderPreset } from '@/lib/provider-presets';
import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button';
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 { useNavigate } from 'react-router-dom';
+172 -81
View File
@@ -227,7 +227,10 @@ function TargetStatusCard({
</div>
<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 className="flex gap-2">
@@ -374,7 +377,15 @@ export function ClaudeExtensionPage() {
async function handleDelete(): Promise<void> {
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);
const remaining = bindings.filter((binding) => binding.id !== effectiveSelectedBindingId);
@@ -419,8 +430,9 @@ export function ClaudeExtensionPage() {
<div>
<h1 className="font-semibold">{t('claudeExtensionPage.title')}</h1>
<p className="text-xs text-muted-foreground">
{/* TODO i18n: missing key for subtitle */}
Saved IDE bindings for CCS profiles
{t('claudeExtensionPage.savedBindingsSubtitle', {
defaultValue: 'Saved IDE bindings for CCS profiles',
})}
</p>
</div>
</div>
@@ -431,8 +443,7 @@ export function ClaudeExtensionPage() {
</div>
<Button size="sm" onClick={startCreateMode} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for "New" */}
New
{t('claudeExtensionPage.new', { defaultValue: 'New' })}
</Button>
</div>
</div>
@@ -442,34 +453,45 @@ export function ClaudeExtensionPage() {
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">
{/* TODO i18n: missing key for "Create binding"/"Binding editor" */}
{creating ? 'Create binding' : 'Binding editor'}
{creating
? t('claudeExtensionPage.createBinding', { defaultValue: 'Create binding' })
: t('claudeExtensionPage.bindingEditor', { defaultValue: 'Binding editor' })}
</CardTitle>
{/* TODO i18n: missing key for binding editor description */}
<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>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{/* TODO i18n: missing key for "Binding name" */}
<div className="text-sm font-medium">Binding name</div>
<div className="text-sm font-medium">
{t('settingsPage.thinkingSection.bindingName')}
</div>
<Input
value={currentDraft.name}
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 className="space-y-2">
{/* TODO i18n: missing key for "CCS profile" */}
<div className="text-sm font-medium">CCS profile</div>
<div className="text-sm font-medium">
{t('claudeExtensionPage.ccsProfile', { defaultValue: 'CCS profile' })}
</div>
<Select
value={currentDraft.profile}
onValueChange={(value) => updateDraft('profile', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a profile" /* TODO i18n: missing key */ />
<SelectValue
placeholder={t('claudeExtensionPage.selectProfile', {
defaultValue: 'Select a profile',
})}
/>
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
@@ -481,7 +503,9 @@ export function ClaudeExtensionPage() {
</Select>
<p className="text-xs text-muted-foreground">
{selectedProfile?.description ||
'Choose which CCS profile the IDE should inherit.'}
t('claudeExtensionPage.chooseProfileHint', {
defaultValue: 'Choose which CCS profile the IDE should inherit.',
})}
</p>
</div>
@@ -494,7 +518,11 @@ export function ClaudeExtensionPage() {
onValueChange={(value) => updateDraft('host', value as BindingDraft['host'])}
>
<SelectTrigger>
<SelectValue placeholder="Select a host" /* TODO i18n: missing key */ />
<SelectValue
placeholder={t('claudeExtensionPage.selectHost', {
defaultValue: 'Select a host',
})}
/>
</SelectTrigger>
<SelectContent>
{hosts.map((host) => (
@@ -515,22 +543,29 @@ export function ClaudeExtensionPage() {
onChange={(event) => updateDraft('ideSettingsPath', event.target.value)}
placeholder={
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">
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'}.
</p>
</div>
<div className="space-y-2">
{/* TODO i18n: missing key for "Notes" */}
<div className="text-sm font-medium">Notes</div>
<div className="text-sm font-medium">
{t('settingsPage.thinkingSection.notes')}
</div>
<Input
value={currentDraft.notes}
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>
@@ -545,12 +580,12 @@ export function ClaudeExtensionPage() {
) : (
<Save className="h-3.5 w-3.5" />
)}
{/* TODO i18n: missing key for "Create"/"Save" */}
{creating ? 'Create' : 'Save'}
{creating
? t('claudeExtensionPage.create', { defaultValue: 'Create' })
: t('claudeExtensionPage.save', { defaultValue: 'Save' })}
</Button>
<Button variant="outline" onClick={startCreateMode}>
{/* TODO i18n: missing key for "Reset form" */}
Reset form
{t('claudeExtensionPage.resetForm', { defaultValue: 'Reset form' })}
</Button>
</div>
@@ -562,8 +597,7 @@ export function ClaudeExtensionPage() {
disabled={deleteBinding.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
{/* TODO i18n: missing key for "Delete binding" */}
Delete binding
{t('claudeExtensionPage.deleteBinding', { defaultValue: 'Delete binding' })}
</Button>
) : null}
</CardContent>
@@ -571,8 +605,7 @@ export function ClaudeExtensionPage() {
<div className="space-y-2">
<div className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{/* TODO i18n: missing key for "Saved bindings" */}
Saved bindings
{t('claudeExtensionPage.savedBindings', { defaultValue: 'Saved bindings' })}
</div>
<div className="space-y-2">
{bindings.length > 0 ? (
@@ -591,9 +624,10 @@ export function ClaudeExtensionPage() {
) : (
<Card className="border-dashed border-border/60 bg-card/60">
<CardContent className="pt-6 text-sm text-muted-foreground">
{/* TODO i18n: missing key for empty bindings text */}
No saved bindings yet. Create one to manage apply, reset, and drift checks
from the dashboard.
{t('claudeExtensionPage.emptyBindings', {
defaultValue:
'No saved bindings yet. Create one to manage apply, reset, and drift checks from the dashboard.',
})}
</CardContent>
</Card>
)}
@@ -626,13 +660,16 @@ export function ClaudeExtensionPage() {
</div>
<div className="max-w-5xl">
<h2 className="text-2xl font-semibold tracking-tight">
{/* TODO i18n: missing key for default binding name */}
{selectedBinding?.name || 'Claude extension binding'}
{selectedBinding?.name ||
t('claudeExtensionPage.defaultBindingName', {
defaultValue: 'Claude extension binding',
})}
</h2>
{/* TODO i18n: missing key for binding description */}
<p className="mt-1 text-sm text-muted-foreground">
Manage the shared Claude settings file and the IDE-local settings file as two
scoped targets.
{t('claudeExtensionPage.bindingDescription', {
defaultValue:
'Manage the shared Claude settings file and the IDE-local settings file as two scoped targets.',
})}
</p>
</div>
</div>
@@ -648,8 +685,7 @@ export function ClaudeExtensionPage() {
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{/* TODO i18n: missing key for "Verify" */}
Verify
{t('claudeExtensionPage.verify', { defaultValue: 'Verify' })}
</Button>
{setup ? (
<CopyButton value={setup.sharedSettings.command} label="Copy persist command" />
@@ -669,8 +705,9 @@ export function ClaudeExtensionPage() {
{!activeError ? (
<Tabs defaultValue="overview" className="flex flex-col gap-6">
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>{' '}
{/* TODO i18n: missing key */}
<TabsTrigger value="overview">
{t('claudeExtensionPage.overview', { defaultValue: 'Overview' })}
</TabsTrigger>{' '}
<TabsTrigger value="advanced">
{t('settingsPage.thinkingSection.advanced')}
</TabsTrigger>
@@ -679,11 +716,20 @@ export function ClaudeExtensionPage() {
<TabsContent value="overview" className="mt-0 space-y-6">
<div className="grid gap-6 xl:grid-cols-2">
<TargetStatusCard
title="Shared Claude settings"
description="Writes the managed env block inside ~/.claude/settings.json so CLI and IDE behavior stay aligned."
title={t('claudeExtensionPage.sharedClaudeSettings', {
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}
applyLabel="Apply shared" /* TODO i18n: missing key */
resetLabel="Reset shared" /* TODO i18n: missing key */
applyLabel={t('claudeExtensionPage.applyShared', {
defaultValue: 'Apply shared',
})}
resetLabel={t('claudeExtensionPage.resetShared', {
defaultValue: 'Reset shared',
})}
onApply={() => runBindingAction('shared', 'apply')}
onReset={() => runBindingAction('shared', 'reset')}
disabled={creating}
@@ -693,8 +739,8 @@ export function ClaudeExtensionPage() {
title={`${selectedHost?.label || 'IDE'} settings.json`}
description="Writes only the Anthropic extension keys so unrelated editor preferences stay untouched."
status={status?.ideSettings}
applyLabel="Apply IDE" /* TODO i18n: missing key */
resetLabel="Reset IDE" /* TODO i18n: missing key */
applyLabel={t('claudeExtensionPage.applyIde', { defaultValue: 'Apply IDE' })}
resetLabel={t('claudeExtensionPage.resetIde', { defaultValue: 'Reset IDE' })}
onApply={() => runBindingAction('ide', 'apply')}
onReset={() => runBindingAction('ide', 'reset')}
disabled={creating}
@@ -708,32 +754,41 @@ export function ClaudeExtensionPage() {
<CardTitle className="text-base">
{t('settingsPage.thinkingSection.resolvedBinding')}
</CardTitle>
{/* TODO i18n: missing key for resolved binding description */}
<CardDescription>
The binding uses the same profile resolution as `ccs persist` and `ccs
env`.
{t('claudeExtensionPage.resolvedBindingDescription', {
defaultValue:
'The binding uses the same profile resolution as `ccs persist` and `ccs env`.',
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow
label="Profile"
label={t('claudeExtensionPage.profile', { defaultValue: 'Profile' })}
value={setup?.profile.label || currentDraft.profile || 'Not selected'}
/>
<DetailRow
label="Profile type"
label={t('claudeExtensionPage.profileType', {
defaultValue: 'Profile type',
})}
value={setup?.profile.profileType || 'Unknown'}
/>
<DetailRow label="IDE host" value={selectedHost?.label || 'Not selected'} />
<DetailRow
label="IDE path mode"
label={t('claudeExtensionPage.idePathMode', {
defaultValue: 'IDE path mode',
})}
value={
currentDraft.ideSettingsPath.trim()
? 'Custom path'
: 'Default user path'
? t('claudeExtensionPage.customPath', { defaultValue: 'Custom path' })
: t('claudeExtensionPage.defaultUserPath', {
defaultValue: 'Default user path',
})
}
/>
<DetailRow
label="Effective IDE path"
label={t('claudeExtensionPage.effectiveIdePath', {
defaultValue: 'Effective IDE path',
})}
value={
status?.ideSettings.path ||
currentDraft.ideSettingsPath.trim() ||
@@ -748,12 +803,22 @@ export function ClaudeExtensionPage() {
}
/>
<DetailRow
label="Persist command"
value={setup?.sharedSettings.command || 'Save a valid binding first'}
label={t('claudeExtensionPage.persistCommand', {
defaultValue: 'Persist command',
})}
value={
setup?.sharedSettings.command ||
t('claudeExtensionPage.saveValidBindingFirst', {
defaultValue: 'Save a valid binding first',
})
}
mono
/>
{currentDraft.notes.trim() ? (
<DetailRow label="Notes" value={currentDraft.notes.trim()} />
<DetailRow
label={t('settingsPage.thinkingSection.notes')}
value={currentDraft.notes.trim()}
/>
) : null}
</CardContent>
</Card>
@@ -763,9 +828,11 @@ export function ClaudeExtensionPage() {
<CardTitle className="text-base">
{t('settingsPage.thinkingSection.managedPayload')}
</CardTitle>
{/* TODO i18n: missing key for managed payload description */}
<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>
</CardHeader>
<CardContent className="space-y-4">
@@ -788,17 +855,24 @@ export function ClaudeExtensionPage() {
{setup?.env.length ? (
<div className="space-y-2">
<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 className="text-muted-foreground">
The IDE-local target receives the extension schema. The shared
target receives the same env block through Claude settings.
{t('claudeExtensionPage.envInjectedDescription', {
defaultValue:
'The IDE-local target receives the extension schema. The shared target receives the same env block through Claude settings.',
})}
</div>
</div>
) : (
<div className="text-muted-foreground">
This profile resolves to native Claude defaults, so apply/reset mainly
clears existing CCS-managed overrides.
{t('claudeExtensionPage.nativeDefaultsDescription', {
defaultValue:
'This profile resolves to native Claude defaults, so apply/reset mainly clears existing CCS-managed overrides.',
})}
</div>
)}
</div>
@@ -814,8 +888,9 @@ export function ClaudeExtensionPage() {
applyBinding.variables?.target === 'all' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
{/* TODO i18n: missing key for "Apply both targets" */}
Apply both targets
{t('claudeExtensionPage.applyBothTargets', {
defaultValue: 'Apply both targets',
})}
</Button>
<Button
variant="outline"
@@ -823,14 +898,17 @@ export function ClaudeExtensionPage() {
onClick={() => runBindingAction('all', 'reset')}
disabled={resetBinding.isPending}
>
{/* TODO i18n: missing key for "Reset both targets" */}
Reset both targets
{t('claudeExtensionPage.resetBothTargets', {
defaultValue: 'Reset both targets',
})}
</Button>
</div>
) : (
<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..." */}
Save this draft to unlock apply, reset, and verify actions.
{t('claudeExtensionPage.saveDraftToUnlock', {
defaultValue:
'Save this draft to unlock apply, reset, and verify actions.',
})}
</div>
)}
</CardContent>
@@ -905,13 +983,20 @@ export function ClaudeExtensionPage() {
<>
<div className="grid gap-6 xl:grid-cols-2">
<CodeBlockCard
title="Shared Claude settings JSON"
description="Managed env block for ~/.claude/settings.json."
title={t('claudeExtensionPage.sharedSettingsJson', {
defaultValue: 'Shared Claude settings JSON',
})}
description={t('claudeExtensionPage.sharedSettingsJsonDescription', {
defaultValue: 'Managed env block for ~/.claude/settings.json.',
})}
value={setup.sharedSettings.json}
/>
<CodeBlockCard
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}
/>
</div>
@@ -921,16 +1006,22 @@ export function ClaudeExtensionPage() {
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">
Resolved environment payload
{t('claudeExtensionPage.resolvedEnvironmentPayload', {
defaultValue: 'Resolved environment payload',
})}
</CardTitle>
<CardDescription>
Exact environment values that the extension receives after CCS
expands this profile.
{t('claudeExtensionPage.resolvedEnvironmentPayloadDescription', {
defaultValue:
'Exact environment values that the extension receives after CCS expands this profile.',
})}
</CardDescription>
</div>
<CopyButton
value={JSON.stringify(setup.env, null, 2)}
label="Copy environment payload"
label={t('claudeExtensionPage.copyEnvironmentPayload', {
defaultValue: 'Copy environment payload',
})}
/>
</div>
</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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
KNOWN_CODEX_FEATURES,
getKnownCodexFeatures,
readCodexFeatureState,
readCodexMcpServers,
readCodexModelProviders,
@@ -23,6 +23,7 @@ import { safeParseTomlObject } from '@shared/toml-object';
export function CodexPage() {
const { t } = useTranslation();
const featureCatalog = getKnownCodexFeatures();
const {
diagnostics,
diagnosticsLoading,
@@ -198,7 +199,7 @@ export function CodexPage() {
profileEntries={profileEntries}
modelProviderEntries={modelProviderEntries}
mcpServerEntries={mcpServerEntries}
featureCatalog={KNOWN_CODEX_FEATURES}
featureCatalog={featureCatalog}
featureState={featureState}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
+108 -46
View File
@@ -185,15 +185,17 @@ export default function ChannelsSection() {
<MessageSquare className="h-5 w-5 text-primary" />
<div className="space-y-1">
<p className="font-medium">{t('settingsPage.channelsSection.title')}</p>
{/* TODO i18n: missing key for channels description paragraphs */}
<p className="text-sm text-muted-foreground">
Configure official Claude channels here, then run <code>ccs</code> normally on a
supported native Claude session.
{t('settingsPage.channelsSection.configureDescription', {
defaultValue:
'Configure official Claude channels here, then run `ccs` normally on a supported native Claude session.',
})}
</p>
{/* TODO i18n: missing key for channels storage description */}
<p className="text-sm text-muted-foreground">
CCS stores only channel selection in <code>config.yaml</code>. Claude keeps the
machine-level channel state under <code>~/.claude/channels/</code>.
{t('settingsPage.channelsSection.storageDescription', {
defaultValue:
'CCS stores only channel selection in `config.yaml`. Claude keeps the machine-level channel state under `~/.claude/channels/`.',
})}
</p>
</div>
</div>
@@ -212,13 +214,23 @@ export default function ChannelsSection() {
<p className="text-sm opacity-90">{status.summary.nextStep}</p>
</div>
<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">Machine checks</p>
<p className="font-medium">
{t('settingsPage.channelsSection.machineChecks', {
defaultValue: 'Machine checks',
})}
</p>
<div className="mt-2 space-y-1 text-muted-foreground">
<div className="flex items-center justify-between gap-4">
<span>Bun</span>
{/* TODO i18n: missing key for "Installed"/"Missing" */}
<span>{status.bunInstalled ? 'Installed' : 'Missing'}</span>
<span>
{status.bunInstalled
? t('settingsPage.channelsSection.installed', {
defaultValue: 'Installed',
})
: t('settingsPage.channelsSection.missing', {
defaultValue: 'Missing',
})}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span>Claude Code</span>
@@ -230,8 +242,12 @@ export default function ChannelsSection() {
</div>
<div className="flex items-center justify-between gap-4">
<span>Claude auth</span>
{/* TODO i18n: missing key for "Unknown" */}
<span>{status.auth.authMethod ?? 'Unknown'}</span>
<span>
{status.auth.authMethod ||
t('settingsPage.channelsSection.unknown', {
defaultValue: 'Unknown',
})}
</span>
</div>
</div>
</div>
@@ -248,21 +264,36 @@ export default function ChannelsSection() {
{status && (
<div className="rounded-lg border bg-muted/20 p-4">
{/* TODO i18n: missing key for "Fastest path" and step descriptions */}
<p className="font-medium">Fastest path</p>
<p className="font-medium">
{t('settingsPage.channelsSection.fastestPath', {
defaultValue: 'Fastest path',
})}
</p>
<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>
3. Run <code>ccs</code> or a native Claude account profile. CCS adds{' '}
<code>--channels</code> for you on supported runs.
{t('settingsPage.channelsSection.fastestStep1', {
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>{status.supportMessage}</p>
</div>
<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">
Advanced notes and scope
{t('settingsPage.channelsSection.advancedNotes', {
defaultValue: 'Advanced notes and scope',
})}
</summary>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<p>{status.accountStatusCaveat}</p>
@@ -276,9 +307,10 @@ export default function ChannelsSection() {
<div className="rounded-lg border bg-background p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
{/* TODO i18n: missing key for "If you run ccs now" */}
<p className="font-medium">
If you run <code>ccs</code> now
{t('settingsPage.channelsSection.ifYouRunNow', {
defaultValue: 'If you run `ccs` now',
})}
</p>
<p className="text-sm text-muted-foreground">{status.launchPreview.detail}</p>
</div>
@@ -288,16 +320,20 @@ export default function ChannelsSection() {
</div>
<div className="mt-3 space-y-2">
<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">You type:</span>{' '}
<span className="text-muted-foreground">
{t('settingsPage.thinkingSection.youType')}
</span>{' '}
{status.launchPreview.command}
</div>
<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">CCS adds:</span>{' '}
<span className="text-muted-foreground">
{t('settingsPage.thinkingSection.ccsAdds')}
</span>{' '}
{status.launchPreview.appendedArgs.length > 0
? status.launchPreview.appendedArgs.join(' ')
: '(nothing yet)'}
: t('settingsPage.channelsSection.nothingYet', {
defaultValue: '(nothing yet)',
})}
</div>
</div>
{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">
<p className="text-sm text-muted-foreground">
{!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.envKey} is saved in Claude channel state, and the current CCS process env also provides it.`
: `Save ${channel.envKey} in Claude's official channel env file. The dashboard never reads the token value back after save.`}
? t('settingsPage.channelsSection.tokenSavedAndProcessEnv', {
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>
{channel.tokenConfigured && (
<p className="text-sm text-muted-foreground">
@@ -385,10 +433,20 @@ export default function ChannelsSection() {
onChange={(event) => updateTokenDraft(channel.id, event.target.value)}
placeholder={
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'
? `Using current CCS process env. Enter a new ${channel.envKey} to save it for Claude.`
: `Paste ${channel.envKey}`
? t('settingsPage.channelsSection.tokenProcessEnvPlaceholder', {
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}
/>
@@ -403,8 +461,9 @@ export default function ChannelsSection() {
disabled={saving || !tokenDraft.trim()}
>
<Save className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key for "Save Token" */}
Save Token
{t('settingsPage.channelsSection.saveToken', {
defaultValue: 'Save Token',
})}
</Button>
<Button
variant="outline"
@@ -412,17 +471,19 @@ export default function ChannelsSection() {
disabled={saving || !channel.tokenConfigured}
>
<Trash2 className="mr-2 h-4 w-4" />
{/* TODO i18n: missing key for "Clear Saved Token" */}
Clear Saved Token
{t('settingsPage.channelsSection.clearSavedToken', {
defaultValue: 'Clear Saved Token',
})}
</Button>
</div>
</div>
)}
<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">
Claude-side setup commands
{t('settingsPage.channelsSection.claudeSideSetupCommands', {
defaultValue: 'Claude-side setup commands',
})}
</summary>
<div className="mt-3 space-y-2">
{(channel.manualSetupCommands ?? []).map((command) => (
@@ -441,10 +502,11 @@ export default function ChannelsSection() {
</div>
<Alert>
{/* TODO i18n: missing key for channels injection disclaimer */}
<AlertDescription>
CCS injects <code>--channels</code> only for the current Claude session. Telegram,
Discord, and iMessage stop receiving messages when that Claude session exits.
{t('settingsPage.channelsSection.injectionDisclaimer', {
defaultValue:
'CCS injects `--channels` only for the current Claude session. Telegram, Discord, and iMessage stop receiving messages when that Claude session exits.',
})}
</AlertDescription>
</Alert>
@@ -456,11 +518,11 @@ export default function ChannelsSection() {
<Label className="text-sm font-medium">
{t('profileEditorSections.skipPermissionPrompts')}
</Label>
{/* TODO i18n: missing key for skip permission description */}
<p className="mt-1 text-sm text-muted-foreground">
Optional advanced behavior. CCS adds <code>--dangerously-skip-permissions</code>{' '}
only when at least one selected channel is being auto-enabled and you did not
already pass a permission flag yourself.
{t('settingsPage.channelsSection.skipPermissionDescription', {
defaultValue:
'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>
</div>
</div>
@@ -28,6 +28,7 @@ import {
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api, type ImageAnalysisDashboardData } from '@/lib/api-client';
import i18n from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { useRawConfig } from '../../hooks';
@@ -116,25 +117,24 @@ function backendStateClass(state: ImageAnalysisDashboardData['backends'][number]
}
}
// TODO i18n: missing keys for currentTargetModeLabel values
function currentTargetModeLabel(
mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode']
): string {
switch (mode) {
case 'active':
return 'Active';
return i18n.t('imageAnalysisStatus.badgeReady');
case 'bypassed':
return 'Bypassed';
return i18n.t('imageAnalysisStatus.badgeBypassed');
case 'fallback':
return 'Native fallback';
return i18n.t('imageAnalysisStatus.nativeFallback');
case 'setup':
return 'Needs setup';
return i18n.t('imageAnalysisStatus.badgeSetup');
case 'disabled':
return 'Disabled';
return i18n.t('imageAnalysisStatus.badgeDisabled');
case 'native':
return 'Native';
return i18n.t('imageAnalysisStatus.badgeNative');
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 {
switch (state) {
case 'starts_on_launch':
return 'Starts on launch';
return i18n.t('imageAnalysisSection.backendStartsOnLaunch', {
defaultValue: 'Starts on launch',
});
case 'needs_auth':
return 'Needs auth';
return i18n.t('imageAnalysisStatus.needsAuth');
case 'needs_proxy':
return 'Needs proxy';
return i18n.t('imageAnalysisStatus.needsProxy');
case 'review':
return 'Review';
return i18n.t('imageAnalysisSection.review', { defaultValue: 'Review' });
case 'ready':
return 'Ready';
return i18n.t('imageAnalysisStatus.badgeReady');
}
}
// TODO i18n: missing keys for backendStatusNote values
function backendStatusNote(backend: ImageBackend | undefined): string | null {
if (!backend) {
return 'No model configured.';
return i18n.t('imageAnalysisSection.noModelConfigured', {
defaultValue: 'No model configured.',
});
}
switch (backend.state) {
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':
return backend.proxyReason || 'Proxy unavailable.';
return (
backend.proxyReason ||
i18n.t('imageAnalysisSection.proxyUnavailable', {
defaultValue: 'Proxy unavailable.',
})
);
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':
return 'Needs manual review.';
return i18n.t('imageAnalysisSection.needsManualReview', {
defaultValue: 'Needs manual review.',
});
case 'ready':
return null;
}
}
// TODO i18n: missing keys for routeSourceLabel values
function routeSourceLabel(source: ImageProfile['resolutionSource']): string {
switch (source) {
case 'profile-backend':
return 'Explicit mapping';
return i18n.t('imageAnalysisSection.explicitMapping', {
defaultValue: 'Explicit mapping',
});
case 'fallback-backend':
return 'Fallback backend';
return i18n.t('imageAnalysisSection.fallbackBackend', {
defaultValue: 'Fallback backend',
});
case 'cliproxy-provider':
return 'Provider match';
return i18n.t('imageAnalysisSection.providerMatch', {
defaultValue: 'Provider match',
});
case 'cliproxy-bridge':
return 'Bridge match';
return i18n.t('imageAnalysisSection.bridgeMatch', {
defaultValue: 'Bridge match',
});
case 'native-compatible':
return 'Native path';
return i18n.t('imageAnalysisSection.nativePath', {
defaultValue: 'Native path',
});
case 'copilot-alias':
return 'Copilot alias';
return i18n.t('imageAnalysisSection.copilotAlias', {
defaultValue: 'Copilot alias',
});
default:
return source.replace(/-/g, ' ');
}
@@ -970,7 +997,9 @@ export default function ImageAnalysisSection() {
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<Input
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}
disabled={saving}
onChange={(event) =>
@@ -999,8 +1028,7 @@ export default function ImageAnalysisSection() {
void commitProviderModel(backendId, '');
}}
>
{/* TODO i18n: missing key for "Clear" */}
Clear
{t('common.clear', { defaultValue: 'Clear' })}
</Button>
)}
</div>
@@ -1032,7 +1060,9 @@ export default function ImageAnalysisSection() {
getInsetPanelClass('emerald')
)}
>
No profiles prefer native reading yet.
{t('imageAnalysisSection.noProfilesPreferNative', {
defaultValue: 'No profiles prefer native reading yet.',
})}
</div>
) : (
<div className="grid gap-2 md:grid-cols-2">
@@ -1051,15 +1081,28 @@ export default function ImageAnalysisSection() {
{profile.name}
</div>
<Badge variant="outline" className="text-[10px]">
{profile.kind === 'variant' ? 'Variant' : 'Profile'}
{profile.kind === 'variant'
? t('cliproxyPage.variant')
: t('apiProfiles.title')}
</Badge>
<Badge variant="outline" className="text-[10px]">
{profile.nativeImageCapable ? 'Verified' : 'Review'}
{profile.nativeImageCapable
? t('imageAnalysisStatus.capabilityVerified')
: t('imageAnalysisSection.review', {
defaultValue: 'Review',
})}
</Badge>
</div>
<div className="mt-1 text-[11px] leading-4 text-muted-foreground">
{profile.profileModel || 'Model not detected'} ·{' '}
{profile.nativeImageReason || 'Native read preferred.'}
{profile.profileModel ||
t('imageAnalysisSection.modelNotDetected', {
defaultValue: 'Model not detected',
})}{' '}
·{' '}
{profile.nativeImageReason ||
t('imageAnalysisSection.nativeReadPreferred', {
defaultValue: 'Native read preferred.',
})}
</div>
</div>
<Badge
@@ -1101,8 +1144,9 @@ export default function ImageAnalysisSection() {
) : (
<ChevronDown className="mr-1 h-4 w-4" />
)}
{/* TODO i18n: missing key for "Hide"/"Show" */}
{showProfileRouting ? 'Hide' : 'Show'}
{showProfileRouting
? t('common.hide', { defaultValue: 'Hide' })
: t('common.show', { defaultValue: 'Show' })}
</Button>
{showProfileRouting && (
<Button
@@ -1122,8 +1166,9 @@ export default function ImageAnalysisSection() {
disabled={configuredBackendIds.length === 0 || saving}
>
<Plus className="mr-1 h-4 w-4" />
{/* TODO i18n: missing key for "Add mapping" */}
Add mapping
{t('imageAnalysisSection.addMapping', {
defaultValue: 'Add mapping',
})}
</Button>
)}
</div>
@@ -1156,7 +1201,11 @@ export default function ImageAnalysisSection() {
>
<div className="flex items-center justify-between gap-3">
<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) && (
<Badge variant="outline" className="text-[10px]">
Draft
@@ -1176,8 +1225,7 @@ export default function ImageAnalysisSection() {
}}
>
<Trash2 className="mr-1 h-4 w-4" />
{/* TODO i18n: missing key for "Remove" */}
Remove
{t('common.remove', { defaultValue: 'Remove' })}
</Button>
</div>
@@ -1186,7 +1234,9 @@ export default function ImageAnalysisSection() {
value={row.profileName}
list="image-profile-suggestions"
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"
onChange={(event) => {
updateMappingRow(row.id, { profileName: event.target.value });
@@ -1281,7 +1331,9 @@ export default function ImageAnalysisSection() {
{profile.name}
</div>
<Badge variant="outline" className="text-[10px]">
{profile.kind === 'variant' ? 'Variant' : 'Profile'}
{profile.kind === 'variant'
? t('cliproxyPage.variant')
: t('apiProfiles.title')}
</Badge>
<Badge variant="outline" className="font-mono text-[10px] uppercase">
{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 { UpdatesInboxItem } from '@/components/updates/updates-inbox-item';
import {
SUPPORT_NOTICES,
getSupportNotices,
getSupportEntriesForNotice,
type SupportNotice,
} from '@/lib/support-updates-catalog';
@@ -45,9 +45,8 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean
export function UpdatesPage() {
const { t } = useTranslation();
const notices = useMemo(
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[]
const notices = [...getSupportNotices()].sort((a, b) =>
b.publishedAt.localeCompare(a.publishedAt)
);
const [viewMode, setViewMode] = useState<NoticeViewMode>('inbox');
const [query, setQuery] = useState('');
+1
View File
@@ -3,6 +3,7 @@
* Global test configuration and matchers
*/
import '../../src/lib/i18n';
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
@@ -71,7 +71,8 @@ describe('ModelConfigSection presets', () => {
expect(screen.queryByText('Free 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: '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();
});
@@ -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({
ANTHROPIC_MODEL: 'gemini-3.9-pro-preview',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.9-pro-preview',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.9-pro-preview',
ANTHROPIC_MODEL: 'gemini-3.1-pro-high',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.1-pro-high',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.1-pro-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview',
});
});
@@ -73,13 +73,8 @@ describe('CodexOverviewTab', () => {
expect(screen.getAllByText('codex').length).toBeGreaterThan(0);
expect(screen.queryByText('codex --profile default')).not.toBeInTheDocument();
expect(
screen.getAllByText(
(_, node) =>
node?.textContent?.includes(
'Built-in openai and oss providers are also valid native defaults'
) ?? false
).length
).toBeGreaterThan(0);
screen.getByText('API profiles continue to default to Claude or Droid.')
).toBeInTheDocument();
});
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('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('Hugging Face')).toBeVisible();
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
@@ -73,7 +73,7 @@ describe('ProfileCreateDialog', () => {
expect(screen.getByDisplayValue('ollama')).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 () => {
@@ -32,12 +32,23 @@ vi.mock(import('react-i18next'), async (importOriginal) => {
'authMonitor.success': 'Success',
'authMonitor.failed': 'Failed',
'authMonitor.successRate': 'Success Rate',
'authMonitorLive.live': 'LIVE',
'authMonitorLive.accountMonitor': 'Account Monitor',
'authMonitorLive.requestsLabel': 'req',
};
if (key === 'authMonitor.accountsCount') {
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;
},
}),
@@ -1,9 +1,9 @@
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', () => {
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?.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', () => {
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?.routes).toEqual([{ label: 'Codex CLI', path: '/codex' }]);
});
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?.title).toContain('Updates Center');
@@ -166,7 +166,7 @@ describe('ImageAnalysisSection', () => {
it('renders global controls and saves updated config', async () => {
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('Core setup')).toBeInTheDocument();
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 () => {
@@ -278,7 +278,7 @@ describe('ImageAnalysisSection', () => {
it('auto-saves edits without rendering a dedicated save button', async () => {
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.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
expect(container.firstElementChild).toHaveClass(
+1 -1
View File
@@ -143,7 +143,7 @@ describe('LogsPage', () => {
render(<LogsPage />);
expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument();
expect(screen.getByLabelText('Loading logs...')).toBeInTheDocument();
});
it('filters by source and search query', async () => {