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
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