From 2c5114c0603ea683c7a292d7e4f2093441f76638 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:04:58 +0800 Subject: [PATCH] feat(client): adopt the locale seat in theme, sidebar, question, and model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each package ships its zh/en dictionaries as satisfies-typed pairs (zh is the key-set source of truth; en is checked complete against it), merges its namespace into LocaleNamespaceMap, and declares locale: NS at register — components read the framework-injected typed t seat instead of a hand-carried inject member. Overlapping verbatim words (retry, submit, submitting) drop out of package dictionaries in favor of the shared common vocabulary; the question composer stores validation feedback as dictionary keys so shown feedback follows a locale switch. --- .../ui-model/src/client/ModelSelect.tsx | 4 ++-- .../client/ui-model/src/client/locales.ts | 6 ++---- .../ui-model/tests/model-select.spec.tsx | 9 ++++++--- packages/client/ui-question/package.json | 1 + .../src/client/QuestionComposer.tsx | 19 ++++++++++-------- .../client/ui-question/src/client/locales.ts | 8 ++------ .../tests/question-composer.spec.tsx | 9 ++++++--- .../client/ui-sidebar/src/client/locales.ts | 4 ++-- .../ui-theme/src/client/AppearanceRow.tsx | 3 ++- packages/client/ui-theme/src/client/index.ts | 19 ++++-------------- .../client/ui-theme/src/client/locales.ts | 20 +++++++++++++++++++ 11 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 packages/client/ui-theme/src/client/locales.ts diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index c170fd06f0..4cc2687832 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -238,13 +238,13 @@ export function ModelSelect( {state.error !== null && (
{t('error.action', { message: state.error })} - +
)} {state.failures.map(failure => (
{t('warning.groupLoad', { name: failure.name, message: failure.message })} - +
))}
diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index d93e9b76d5..5a835f2b81 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -14,7 +14,6 @@ export const zh = { 'effort.providerDefault': '服务商默认', 'status.loading': '正在刷新模型列表…', 'error.action': '模型操作失败:{message}', - 'action.retry': '重试', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'option.currentUnlisted': '当前模型 · 未列入目录', @@ -26,7 +25,7 @@ export const zh = { export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'command.description': 'Select the model for this conversation', 'option.unlisted': '{group} · Not in catalog', 'option.loadError': 'Catalog failed to load: {message}', @@ -39,10 +38,9 @@ export const en: Record = { 'effort.providerDefault': 'Provider default', 'status.loading': 'Refreshing model list…', 'error.action': 'Model operation failed: {message}', - 'action.retry': 'Retry', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'option.currentUnlisted': 'Current model · Not in catalog', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', -} +} satisfies Record diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index f2d9db3f18..533587aa6c 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -7,11 +7,14 @@ import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -// The seat's key domain is model ∪ common; the stub answers from the package -// dictionary (with template params) and falls back to the key like the real chain. +// The seat's key domain is model ∪ common; the stub mirrors the real lookup +// chain: package dictionary, then common vocabulary, then the key. const t: ComponentProps['t'] = (key, params) => { - const template = (zh as Record)[key] ?? key + const template = (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key return params === undefined ? template : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index f23c3d8fc8..fbf1dbb098 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -45,6 +45,7 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 055a3e5af6..c30737cfea 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -65,7 +65,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick(null) - const [error, setError] = useState(null) + // Validation feedback is stored as a dictionary KEY and translated at + // render, so already-shown feedback follows a locale switch; runtime + // failure messages (finished strings from the wire) pass through verbatim. + const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null) // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const question = questions[index]! @@ -78,7 +81,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setBusy(null) - setError(cause instanceof Error ? cause.message : String(cause)) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) }) } @@ -114,7 +117,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick !completed(item)) if (missing >= 0) { setIndex(missing) - setError(t('error.incomplete')) + setError({ key: 'error.incomplete' }) return } const answer: QuestionAnswer = { @@ -133,13 +136,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setBusy(null) - setError(cause instanceof Error ? cause.message : String(cause)) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) }) } const continueFlow = (): void => { if (!answered(draft)) { - setError(t('error.unanswered')) + setError({ key: 'error.unanswered' }) return } if (index < questions.length - 1) { @@ -292,7 +295,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick
-
{error}
+
{error === null ? null : 'key' in error ? t(error.key) : error.text}
diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts index fd4156759b..95465f4af2 100644 --- a/packages/client/ui-question/src/client/locales.ts +++ b/packages/client/ui-question/src/client/locales.ts @@ -12,8 +12,6 @@ export const zh = { 'option.custom': '其他,请填写自定义答案', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过本题', - 'action.submitting': '正在提交…', - 'action.submit': '提交', 'action.next': '下一题', } satisfies Record @@ -21,7 +19,7 @@ export const zh = { export type QuestionKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'error.incomplete': 'Please complete this question first.', 'error.unanswered': 'Please select an option or enter a custom answer.', 'title.multi': 'Multi-select', @@ -32,7 +30,5 @@ export const en: Record = { 'option.custom': 'Other — enter a custom answer', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip this question', - 'action.submitting': 'Submitting…', - 'action.submit': 'Submit', 'action.next': 'Next', -} +} satisfies Record diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 3e5d766c6a..4a8983571c 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -13,6 +13,7 @@ import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' afterEach(cleanup) @@ -29,9 +30,11 @@ const kit = { useProjection: (() => undefined) as never, useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, - // The seat's key domain is question ∪ common; the stub answers from the - // package dictionary and falls back to the key like the real chain. - t: (key => (zh as Record)[key] ?? key) as QuestionComposerProps['t'], + // The seat's key domain is question ∪ common; the stub mirrors the real + // lookup chain: package dictionary, then common vocabulary, then the key. + t: (key => (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key) as QuestionComposerProps['t'], } const QUESTIONS = [ diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 21a359c15b..8cf5ac6d7b 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -12,9 +12,9 @@ export const zh = { export type SidebarKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'session.new': 'New Session', 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', 'toggle.collapse': 'Collapse sidebar', -} +} satisfies Record diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index 465fc07b4a..a0e04b67a6 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -11,6 +11,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from './index.ts' +import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' @@ -27,7 +28,7 @@ export type AppearanceRowComponentProps = & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: 'appearance.light' | 'appearance.dark' | 'appearance.system'; Icon: typeof IconLightOutline16 }[] = [ +const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [ { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index f833a7392a..133436c693 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -14,9 +14,11 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' +import { en, zh, type ThemeKey } from './locales.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' +export type { ThemeKey } from './locales.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' @@ -24,7 +26,7 @@ export const SETTINGS_NS = 'settings.theme' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** The Appearance settings row's copy. */ - 'settings.theme': 'appearance.title' | 'appearance.light' | 'appearance.dark' | 'appearance.system' + 'settings.theme': ThemeKey } } @@ -235,20 +237,7 @@ export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) ctx.provide('theme', theme) - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { - zh: { - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', - }, - en: { - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', - }, - }), 'ui-theme: settings row dictionaries') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined diff --git a/packages/client/ui-theme/src/client/locales.ts b/packages/client/ui-theme/src/client/locales.ts new file mode 100644 index 0000000000..6df56ceb96 --- /dev/null +++ b/packages/client/ui-theme/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.theme` namespace dictionaries (the Appearance row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', +} satisfies Record + +/** The settings.theme namespace key union. */ +export type ThemeKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} satisfies Record