fix(i18n): address PR review feedback

Bugs:
- GeneralPage: network access toast used the keep-server-running title key
  (wrong semantic scope). Add networkAccess.updatedTitle and use it.
- GeneralPage: fallback "Unknown" version was stored as a translated string
  in state, so it stayed stale across language switches. Store null, resolve
  the label at render time.
- GeneralPage: memoize the zod resolver on t and retrigger validation when
  the locale changes so existing error messages retranslate.
- GpuPage: adding t to the CUDA progress EventSource effect deps caused the
  SSE connection to be torn down and reopened on every language change,
  potentially dropping in-flight download events. Capture t in a ref.
- HistoryTable: Effects dialog still rendered English "Source" / "Select
  source version" / "Cancel" / "Apply" / "Applying..." — localize them.
- Locales: zh-CN / zh-TW / ja devSuffix was missing the leading space before
  "(开发版)"/"(開發版)"/"(開発版)", so dev builds rendered "v0.4.2(开发版)"
  instead of "v0.4.2 (开发版)".

Nits:
- ModelManagement: rename .find((t) => ...) callback param to avoid
  shadowing useTranslation().t.
- GenerationPage: rename chunkLimit.value interpolation key from count →
  chars so i18next doesn't silently activate pluralization if a translator
  later adds _one/_other forms.
- LanguageSelect: narrow onValueChange handler param to LanguageCode.

Key count now 559 across en/zh-CN/zh-TW/ja (added 4 effectsDialog keys
plus networkAccess.updatedTitle).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 04:31:53 -07:00
co-authored by Claude Opus 4.7
parent 9c888008c9
commit d2ecffb455
10 changed files with 72 additions and 29 deletions
+8 -4
View File
@@ -897,13 +897,15 @@ export function HistoryTable() {
</DialogHeader>
{effectsTargetVersions.length > 1 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Source</label>
<label className="text-xs font-medium text-muted-foreground">
{t('history.effectsDialog.sourceLabel')}
</label>
<Select
value={effectsSourceVersionId ?? ''}
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select source version" />
<SelectValue placeholder={t('history.effectsDialog.sourcePlaceholder')} />
</SelectTrigger>
<SelectContent>
{effectsTargetVersions.map((v) => (
@@ -925,13 +927,15 @@ export function HistoryTable() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleApplyEffectsConfirm}
disabled={applyingEffects || effectsChain.length === 0}
>
{applyingEffects ? 'Applying...' : 'Apply'}
{applyingEffects
? t('history.effectsDialog.applying')
: t('history.effectsDialog.apply')}
</Button>
</DialogFooter>
</DialogContent>
@@ -431,7 +431,7 @@ export function ModelManagement() {
// Derive license from HF data
const license =
hfModelInfo?.cardData?.license ||
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
hfModelInfo?.tags?.find((tag) => tag.startsWith('license:'))?.replace('license:', '');
return (
<div className="flex flex-col h-full">
@@ -522,7 +522,7 @@ export function ModelManagement() {
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelStatus ? (
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
<div className="flex-1 min-h-0 overflow-y-auto space-y-6 pb-6">
{sections.map((section) => (
<div key={section.label}>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
+20 -7
View File
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import * as z from 'zod';
@@ -37,8 +37,12 @@ export function GeneralPage() {
const { toast } = useToast();
const { data: health, isLoading, error: healthError } = useServerHealth();
const resolver = useMemo(
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
[t],
);
const form = useForm<ConnectionFormValues>({
resolver: zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
resolver,
defaultValues: { serverUrl },
});
@@ -46,6 +50,13 @@ export function GeneralPage() {
form.reset({ serverUrl });
}, [serverUrl, form]);
// Re-run validation when the locale changes so existing error messages retranslate.
useEffect(() => {
if (form.formState.errors.serverUrl) {
form.trigger('serverUrl');
}
}, [t, form]);
const { isDirty } = form.formState;
function onSubmit(data: ConnectionFormValues) {
@@ -171,7 +182,7 @@ export function GeneralPage() {
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: t('settings.general.keepServerRunning.updatedTitle'),
title: t('settings.general.networkAccess.updatedTitle'),
description: checked
? t('settings.general.networkAccess.enabled')
: t('settings.general.networkAccess.disabled'),
@@ -247,20 +258,22 @@ function UpdatesSection() {
const { t } = useTranslation();
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
const [currentVersion, setCurrentVersion] = useState<string | null>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => {
platform.metadata
.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion(t('common.unknown')));
}, [platform, t]);
.catch(() => setCurrentVersion(null));
}, [platform]);
const versionLabel = currentVersion ?? t('common.unknown');
return (
<SettingSection
title={t('settings.general.updates.title')}
description={`v${currentVersion}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
>
{isDev ? (
<SettingRow
@@ -58,7 +58,7 @@ export function GenerationPage() {
description={t('settings.generation.chunkLimit.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{t('settings.generation.chunkLimit.value', { count: maxChunkChars })}
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
</span>
}
>
+8 -2
View File
@@ -117,6 +117,12 @@ export function GpuPage() {
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
// tear down and reconnect the EventSource every time the language changes.
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const {
data: cudaStatus,
@@ -159,7 +165,7 @@ export function GpuPage() {
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || t('settings.gpu.errors.downloadFailed'));
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setDownloadProgress(null);
refetchCudaStatus();
}
@@ -175,7 +181,7 @@ export function GpuPage() {
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus, t]);
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
const clearHealthPolling = useCallback(() => {
if (healthPollRef.current) {
@@ -6,7 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SUPPORTED_LANGUAGES } from '@/i18n';
import { type LanguageCode, SUPPORTED_LANGUAGES } from '@/i18n';
export function LanguageSelect() {
const { i18n } = useTranslation();
@@ -16,7 +16,7 @@ export function LanguageSelect() {
<Select
value={current}
onValueChange={(value) => {
void i18n.changeLanguage(value);
void i18n.changeLanguage(value as LanguageCode);
}}
>
<SelectTrigger className="h-9 w-[180px]">
+7 -2
View File
@@ -518,7 +518,11 @@
},
"effectsDialog": {
"title": "Apply Effects",
"body": "Configure post-processing effects to apply to this generation. A new version will be created."
"body": "Configure post-processing effects to apply to this generation. A new version will be created.",
"sourceLabel": "Source",
"sourcePlaceholder": "Select source version",
"apply": "Apply",
"applying": "Applying…"
}
},
"generation": {
@@ -598,6 +602,7 @@
"networkAccess": {
"title": "Allow network access",
"description": "Makes the server accessible from other devices on your network. Restart the app after changing.",
"updatedTitle": "Setting updated",
"enabled": "Network access enabled. Restart the app to apply.",
"disabled": "Network access disabled. Restart the app to apply."
},
@@ -651,7 +656,7 @@
"chunkLimit": {
"title": "Auto-chunking limit",
"description": "Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs.",
"value": "{{count}} chars"
"value": "{{chars}} chars"
},
"crossfade": {
"title": "Chunk crossfade",
+8 -3
View File
@@ -518,7 +518,11 @@
},
"effectsDialog": {
"title": "エフェクトを適用",
"body": "この生成に適用するポストプロセッシングのエフェクトを設定します。新しいバージョンが作成されます。"
"body": "この生成に適用するポストプロセッシングのエフェクトを設定します。新しいバージョンが作成されます。",
"sourceLabel": "ソース",
"sourcePlaceholder": "ソースバージョンを選択",
"apply": "適用",
"applying": "適用中…"
}
},
"generation": {
@@ -598,6 +602,7 @@
"networkAccess": {
"title": "ネットワークアクセスを許可",
"description": "ネットワーク上の他のデバイスからサーバーにアクセスできるようにします。変更後はアプリを再起動してください。",
"updatedTitle": "設定を更新しました",
"enabled": "ネットワークアクセスが有効になりました。適用するにはアプリを再起動してください。",
"disabled": "ネットワークアクセスが無効になりました。適用するにはアプリを再起動してください。"
},
@@ -608,7 +613,7 @@
},
"updates": {
"title": "アプリの更新",
"devSuffix": "(開発版)",
"devSuffix": " (開発版)",
"devMode": {
"title": "開発モード",
"description": "開発モードでは自動更新が無効になっています。"
@@ -651,7 +656,7 @@
"chunkLimit": {
"title": "自動チャンク分割の上限",
"description": "長文は文境界でチャンクに分割されます。値を小さくすると長い出力の品質が向上することがあります。",
"value": "{{count}} 文字"
"value": "{{chars}} 文字"
},
"crossfade": {
"title": "チャンク間のクロスフェード",
+8 -3
View File
@@ -518,7 +518,11 @@
},
"effectsDialog": {
"title": "应用效果",
"body": "配置应用于此次生成的后处理效果。将会创建一个新版本。"
"body": "配置应用于此次生成的后处理效果。将会创建一个新版本。",
"sourceLabel": "来源",
"sourcePlaceholder": "选择来源版本",
"apply": "应用",
"applying": "应用中…"
}
},
"generation": {
@@ -598,6 +602,7 @@
"networkAccess": {
"title": "允许网络访问",
"description": "使网络上的其他设备可以访问服务器。更改后请重启应用。",
"updatedTitle": "设置已更新",
"enabled": "已启用网络访问。重启应用以应用更改。",
"disabled": "已禁用网络访问。重启应用以应用更改。"
},
@@ -608,7 +613,7 @@
},
"updates": {
"title": "应用更新",
"devSuffix": "(开发版)",
"devSuffix": " (开发版)",
"devMode": {
"title": "开发模式",
"description": "开发模式下已禁用自动更新。"
@@ -651,7 +656,7 @@
"chunkLimit": {
"title": "自动分块上限",
"description": "长文本在句子边界处分块。较低的值可以提高长输出的质量。",
"value": "{{count}} 字符"
"value": "{{chars}} 字符"
},
"crossfade": {
"title": "块间淡入淡出",
+8 -3
View File
@@ -518,7 +518,11 @@
},
"effectsDialog": {
"title": "套用效果",
"body": "設定要套用於此生成的後製效果。將會建立一個新版本。"
"body": "設定要套用於此生成的後製效果。將會建立一個新版本。",
"sourceLabel": "來源",
"sourcePlaceholder": "選擇來源版本",
"apply": "套用",
"applying": "套用中…"
}
},
"generation": {
@@ -598,6 +602,7 @@
"networkAccess": {
"title": "允許網路存取",
"description": "讓網路上的其他裝置可存取伺服器。變更後請重新啟動應用程式。",
"updatedTitle": "設定已更新",
"enabled": "已啟用網路存取。重新啟動應用程式以套用。",
"disabled": "已停用網路存取。重新啟動應用程式以套用。"
},
@@ -608,7 +613,7 @@
},
"updates": {
"title": "應用程式更新",
"devSuffix": "(開發版)",
"devSuffix": " (開發版)",
"devMode": {
"title": "開發模式",
"description": "開發模式下已停用自動更新。"
@@ -651,7 +656,7 @@
"chunkLimit": {
"title": "自動分塊上限",
"description": "長文字會在句子邊界處分塊。較低的值可以提升長輸出的品質。",
"value": "{{count}} 字元"
"value": "{{chars}} 字元"
},
"crossfade": {
"title": "區塊間淡入淡出",