Merge branch 'master' into codex/web-queue-actions

This commit is contained in:
imccyu
2026-07-30 10:50:34 +08:00
committed by GitHub
45 changed files with 946 additions and 192 deletions

View File

@@ -268,7 +268,6 @@ flowchart TD
pkg_client_modules --> pkg_invariants
pkg_client_runtime --> pkg_invariants
pkg_client_ui_primitives --> pkg_invariants
pkg_client_ui_question --> pkg_invariants
pkg_client_ui_slots --> pkg_invariants
pkg_client_web --> pkg_invariants
pkg_client_web_react --> pkg_invariants
@@ -304,10 +303,6 @@ flowchart TD
pkg_client_ui_settings --> pkg_client_ui_primitives
pkg_client_ui_settings --> pkg_client_ui_slots
pkg_client_ui_settings --> pkg_invariants
pkg_client_ui_sidebar --> pkg_client_runtime
pkg_client_ui_sidebar --> pkg_client_ui_primitives
pkg_client_ui_sidebar --> pkg_client_ui_slots
pkg_client_ui_sidebar --> pkg_invariants
pkg_client_ui_trajectory --> pkg_client_ui_primitives
pkg_client_ui_trajectory --> pkg_invariants
pkg_client_ui_workspace --> pkg_client_runtime
@@ -345,12 +340,19 @@ flowchart TD
pkg_system_prompt --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_client_ui_question --> pkg_client_locale
pkg_client_ui_question --> pkg_invariants
pkg_client_ui_settings_general --> pkg_client_locale
pkg_client_ui_settings_general --> pkg_client_runtime
pkg_client_ui_settings_general --> pkg_client_ui_primitives
pkg_client_ui_settings_general --> pkg_client_ui_settings
pkg_client_ui_settings_general --> pkg_client_ui_slots
pkg_client_ui_settings_general --> pkg_invariants
pkg_client_ui_sidebar --> pkg_client_locale
pkg_client_ui_sidebar --> pkg_client_runtime
pkg_client_ui_sidebar --> pkg_client_ui_primitives
pkg_client_ui_sidebar --> pkg_client_ui_slots
pkg_client_ui_sidebar --> pkg_invariants
pkg_client_ui_slash --> pkg_client_locale
pkg_client_ui_slash --> pkg_client_runtime
pkg_client_ui_slash --> pkg_client_ui_primitives
@@ -618,6 +620,7 @@ flowchart TD
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_model --> pkg_client_connection
pkg_client_ui_model --> pkg_client_locale
pkg_client_ui_model --> pkg_client_runtime
pkg_client_ui_model --> pkg_client_ui_command
pkg_client_ui_model --> pkg_client_ui_conversation
@@ -1001,7 +1004,6 @@ flowchart TD
| [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
@@ -1021,7 +1023,6 @@ flowchart TD
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1036,7 +1037,9 @@ flowchart TD
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
@@ -1101,7 +1104,7 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: 9015af2b44a33771b06863ace139fe97695df616
README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf
README.md: c2adbcabc77def740094288da4643032873aa5b8
README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
## Model Experience
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 为键持久化;提供 gettersetter并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
locale 插件LocaleService——浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → zh → key。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
## 模型体验
@@ -14,5 +14,5 @@ locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以
## 已知限制与暂缓事项
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述在重新注册前保持注册时的语言slot 渲染的文案随切换实时更新

View File

@@ -5,23 +5,22 @@
* settings surface.
*/
import { useState } from 'react'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
/** Injected business face: the preference write (t rides the standard locale seat). */
export interface LanguageRowInjected {
/** Translate a `settings.locale` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the active locale (a registered locale id). */
setLocale: (id: string) => void
}
/** Full component props: runtime share + store share + injected face. */
/** Full component props: runtime share + store share + locale seat + injected face. */
export type LanguageRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>>
& PropsLocale<'settings.locale'> & LanguageRowInjected
/**
* Render the Language row.

View File

@@ -4,11 +4,21 @@
* preference row into the settings General section — the locale feature owns
* its own settings surface.
*/
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
* ui-slots): in THIS unit the map holds only this package's own merges, but
* consumers merge more namespaces in and the intersection keeps them
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from 'cordis'
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
deferRegistration,
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
import { en, zh, type CommonKey } from '../locales/index.ts'
import {
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
} from '../locales/settings.ts'
import type { LanguageRowInjected } from './LanguageRow.tsx'
import { LanguageRow } from './LanguageRow.tsx'
import { createLanguageRowStore } from './settings-store.ts'
@@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
// The translate currency lives in ui-slots (the render machinery synthesizes
// the seat); re-exported here so dictionary owners import one package.
// TranslateNS<'model'> is the namespace-addressed developer-facing form.
export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
common: CommonKey
/** This feature's own settings-row copy (the Language row). */
'settings.locale': SettingsLocaleKey
}
}
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
@@ -50,7 +72,10 @@ declare module 'cordis' {
}
interface Events {
/**
* Locale state changed (active locale switched or registry updated).
* The active locale switched. Dictionary registrations do NOT emit this
* event (listeners may re-register slots in response, and boot registers
* one namespace per package); continuous render refresh rides the
* LocaleFace revision instead.
* @param snapshot - Current immutable locale snapshot.
* @mode emit
*/
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
])
/**
* Dictionary registry plus locale preference. Lookup chain per key: active
* locale -> zh fallback -> the key itself (missing text stays visible, fail
* loud in the UI rather than blank). Reads go through {@link getLocale};
* writes only through {@link setLocale}; continuous sync only through the
* `locale/change` event.
* Dictionary registry plus locale preference. Lookup chain per key: the
* entry's namespace in the active locale -> that namespace's zh fallback ->
* the shared common namespace (active, then zh) -> the key itself (missing
* text stays visible, fail loud in the UI rather than blank). Reads go
* through {@link getLocale}; writes only through {@link setLocale};
* continuous sync through the `locale/change` event, or through the
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
* (installed via `ctx.slots.installLocale`).
*/
export class LocaleService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private listeners = new Set<() => void>()
private readonly ctx: Context
/**
@@ -105,6 +134,27 @@ export class LocaleService {
return this.snapshot
}
/**
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
* reference between changes, uSES-safe).
* @returns the current snapshot.
*/
getSnapshot(): LocaleSnapshot {
return this.snapshot
}
/**
* LocaleFace subscribe: notified on every snapshot change (locale switch
* or dictionary registration — registrations bump the revision so already
* rendered outlets pick up late-arriving dictionaries).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
@@ -114,44 +164,80 @@ export class LocaleService {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
this.snapshot = Object.freeze({
active: match.id,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
persistPreference(match.id)
this.ctx.emit('locale/change', this.snapshot)
this.publish(match.id, true)
}
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* Register a declared namespace's dictionaries, all locales in one call —
* the typed form: each dictionary is checked against the namespace's
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
* compile error), and every shipped locale is required (bilingual balance
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
* namespace's texts have one owner). Registration bumps the revision so
* mounted outlets pick up late-arriving dictionaries.
* @param ns - a namespace merged into LocaleNamespaceMap.
* @param dicts - complete dictionaries keyed by locale id.
* @returns disposer removing every locale registered by this call (idempotent).
*/
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
/**
* Single-locale untyped form for namespaces outside the merge table
* (dynamic composition, tests).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param locale - locale tag.
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
register(ns: string, locale: string, dict: LocaleDict): () => void
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
// Overload guarantees dict on the single-locale arm.
? [[localeOrDicts, dict as LocaleDict]]
: Object.entries(localeOrDicts)
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
for (const [locale] of pairs) {
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
}
for (const [locale, entries] of pairs) locales.set(locale, entries)
this.publish(this.snapshot.active, false)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
/* v8 ignore next -- defensive: a namespace's locales map is created on
* first register and never removed, so the disposer always finds it. */
if (!owner) return
let removed = false
for (const [locale, entries] of pairs) {
if (owner.get(locale) === entries) {
owner.delete(locale)
removed = true
}
}
if (removed) this.publish(this.snapshot.active, false)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the active locale at call time).
* Bind a declared namespace to a translate function typed to its
* dictionary key union (plus the shared common vocabulary) — the same key
* domain the framework-injected `t` seat carries. The returned reference
* is stable per namespace (repeat binds return the same function), so it
* can ride inject surfaces without breaking memoization.
* @param ns - a namespace merged into LocaleNamespaceMap.
* @returns the typed translate function (reads the active locale at call time).
*/
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
/**
* Untyped form for namespaces outside the merge table (dynamic
* composition, tests).
* @param ns - namespace.
* @returns the translate function.
*/
bind(ns: string): Translate
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
@@ -163,14 +249,43 @@ export class LocaleService {
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.snapshot.active)?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
const template = this.lookup(ns, key)
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
private lookup(ns: string, key: string): string | undefined {
const locales = this.dicts.get(ns)
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
}
/**
* Advance the snapshot revision and notify LocaleFace subscribers (render
* refresh). Only an active-locale switch additionally emits
* `locale/change` — dictionary registrations stay off the event so
* registration-heavy boot cannot storm event listeners (which may
* re-register slots in response).
*/
private publish(active: LocaleId, localeChanged: boolean): void {
this.snapshot = Object.freeze({
active,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
for (const fn of [...this.listeners]) {
try {
fn()
} catch (error) {
// One throwing subscriber must not strand the rest on a stale
// revision (outlets would keep the previous language).
console.error('locale subscriber crashed:', error)
}
}
}
}
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
@@ -208,11 +323,12 @@ export const inject = ['slots']
*/
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
locale.register(COMMON_NS, 'zh', zh)
locale.register(COMMON_NS, 'en', en)
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
ctx.provide('locale', locale)
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
// so the render machinery can synthesize the `t` standard seat.
ctx.slots.installLocale(locale)
const store = createLanguageRowStore()
let bound: BoundActions<typeof store> | undefined
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
// first render (the store's revision guard drops stale duplicates).
sync(locale.getLocale())
return {
t: locale.bind(SETTINGS_NS),
setLocale: (id) => { locale.setLocale(id) },
}
}
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
id: 'language',
order: 0,
store,
locale: SETTINGS_NS,
inject: injected,
}, LanguageRow))
return () => { deferred.dispose() }

View File

@@ -1,2 +1,29 @@
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const en: Record<string, string> = {}
import type { CommonKey } from './zh.ts'
/** en base dictionary for the common namespace, checked complete against the zh key set. */
export const en = {
'ok': 'OK',
'cancel': 'Cancel',
'close': 'Close',
'copy': 'Copy',
'copied': 'Copied',
'retry': 'Retry',
'loading': 'Loading…',
'load.failed': 'Failed to load',
'submit': 'Submit',
'submitting': 'Submitting…',
'next': 'Next',
'previous': 'Previous',
'skip': 'Skip',
'delete': 'Delete',
'edit': 'Edit',
'save': 'Save',
'search': 'Search',
'more': 'More',
'collapse': 'Collapse',
'expand': 'Expand',
'back': 'Back',
'unknown': 'Unknown',
'none': 'None',
'truncated': 'Truncated',
} satisfies Record<CommonKey, string>

View File

@@ -0,0 +1,8 @@
/**
* The common-namespace dictionary pair. zh is the source of truth for the
* key set (Chinese-first repo convention); en is checked complete against it
* — a missing or extra en key is a compile error.
*/
export { zh } from './zh.ts'
export { en } from './en.ts'
export type { CommonKey } from './zh.ts'

View File

@@ -0,0 +1,14 @@
/** `settings.locale` namespace dictionaries (the Language row's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'language.title': '语言',
} satisfies Record<string, string>
/** The settings.locale namespace key union. */
export type SettingsLocaleKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'language.title': 'Language',
} satisfies Record<SettingsLocaleKey, string>

View File

@@ -1,2 +1,30 @@
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const zh: Record<string, string> = {}
/** zh base dictionary for the common namespace: cross-feature standard words. */
export const zh = {
'ok': '确定',
'cancel': '取消',
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'retry': '重试',
'loading': '加载中…',
'load.failed': '加载失败',
'submit': '提交',
'submitting': '正在提交…',
'next': '下一步',
'previous': '上一步',
'skip': '跳过',
'delete': '删除',
'edit': '编辑',
'save': '保存',
'search': '搜索',
'more': '更多',
'collapse': '收起',
'expand': '展开',
'back': '返回',
'unknown': '未知',
'none': '无',
'truncated': '已截断',
} satisfies Record<string, string>
/** The common vocabulary key union (zh is the key-set source of truth). */
export type CommonKey = keyof typeof zh

View File

@@ -69,16 +69,18 @@ describe('locale apply', () => {
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
const { instance, face } = faceOf(b.slots)
const { entry, instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().active).toBe('en')
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
expect(face.t('language.title')).toBe('Language')
// Copy rides the standard locale seat: the entry declares the namespace.
expect(entry.locale).toBe(SETTINGS_NS)
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
face.setLocale('zh')
expect(locale.getLocale().active).toBe('zh')
expect(instance.getSnapshot().active).toBe('zh')
expect(face.t('language.title')).toBe('语言')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

View File

@@ -29,6 +29,24 @@ describe('LocaleService', () => {
expect(t('missing.key')).toBe('missing.key')
})
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
const { svc } = make()
// The shipped common pair is registered by apply; the bench registers it
// directly to pin the production chain: ns -> common -> zh -> key.
svc.register('common', 'zh', { retry: '重试' })
svc.register('common', 'en', { retry: 'Retry' })
svc.register('ns', 'zh', { own: '自有' })
const t = svc.bind('ns')
expect(t('retry')).toBe('重试')
svc.setLocale('en')
expect(t('retry')).toBe('Retry')
expect(t('own')).toBe('自有')
// common itself must not recurse: a miss inside common echoes the key.
// (Wide-string ns hits the untyped bind overload — the typed one rejects
// unknown keys at compile time, which is the point of the seam.)
expect(svc.bind('common' as string)('nope')).toBe('nope')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
expect(t('k')).toBe('v2')
})
it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
const { svc } = make()
const seen: number[] = []
const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
expect(svc.getSnapshot()).toBe(svc.getLocale())
const r0 = svc.getSnapshot().revision
svc.register('ns', 'zh', { k: 'v' })
expect(svc.getSnapshot().revision).toBe(r0 + 1)
svc.setLocale('en')
expect(seen).toEqual([r0 + 1, r0 + 2])
off()
svc.setLocale('zh')
expect(seen).toHaveLength(2)
})
it('isolates a throwing subscriber: the rest still see the new revision', () => {
const { svc } = make()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const seen: number[] = []
svc.subscribe(() => { throw new Error('boom') })
svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
svc.setLocale('en')
expect(seen).toEqual([1])
expect(spy).toHaveBeenCalledOnce()
} finally {
spy.mockRestore()
}
})
it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v' })
const before = svc.getSnapshot().revision
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
// Second run hits the idempotent arm: nothing removed, no republish.
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')

View File

@@ -18,7 +18,7 @@ import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -70,6 +70,8 @@ interface ErasedRegisterOptions {
select?: (owner: never) => unknown
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
priority?: number
/** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */
locale?: string
registrant?: string
}
@@ -82,6 +84,7 @@ export class SlotsService extends Service {
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
private _renderer: SlotRenderer | undefined
private _locale: LocaleFace | undefined
private _host: SlotRendererHost | undefined
/**
@@ -127,6 +130,23 @@ export class SlotsService extends Service {
}, 'slots.install()')
}
/**
* Install the locale face backing the `t` standard seat (the locale
* plugin's product; same boot-once discipline as the renderer install).
* Runs through the caller's ctx.effect, so the installing fiber's unload
* uninstalls the face.
* @param face - namespace binder + revision observable.
*/
installLocale(face: LocaleFace): void {
if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)')
this.ctx.effect(() => {
this._locale = face
return () => {
if (this._locale === face) this._locale = undefined
}
}, 'slots.installLocale()')
}
/**
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
@@ -246,6 +266,12 @@ export class SlotsService extends Service {
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// `locale` is a live getter: the face installs (and, under HMR, swaps)
// on the locale plugin's own fiber lifetime, while this host object is
// built once — a captured value would strand renders on a dead face. The
// alias is required: `this` inside the getter is the host literal.
// oxlint-disable-next-line typescript/no-this-alias
const service = this
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
@@ -259,6 +285,7 @@ export class SlotsService extends Service {
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
get locale() { return service._locale },
}
return this._host
}

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
@@ -36,6 +37,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
@@ -49,6 +51,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

View File

@@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
@@ -34,10 +35,13 @@ interface EffortChoice {
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory store/verbs).
* @param props - owner share (locked) + injected face (shared directory
* store/verbs) + the standard locale seat.
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
export function ModelSelect(
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
@@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
const effortLabel = reasoning === undefined
? undefined
: effectiveEffort === undefined
? 'Provider default'
? t('effort.providerDefault')
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
? []
: [
...reasoning.defaultEffort === undefined
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
: [],
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
key: `effort:${effort.id}`,
@@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
label: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
})),
], [reasoning])
], [reasoning, t])
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
@@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
itemRefs.current = []
let itemIndex = 0
@@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
aria-label={effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
@@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型与推理等级"
aria-label={t('menu.aria')}
aria-busy={state.status === 'loading' || busy}
>
{pane === 'root' && (
<>
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
<span className={css.cellLabel}>Model</span>
<span className={css.cellLabel}>{t('menu.model')}</span>
<span className={css.cellValue}>{modelLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
{reasoning !== undefined && (
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
<span className={css.cellLabel}>Effort</span>
<span className={css.cellLabel}>{t('menu.effort')}</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
@@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
{pane === 'model' && (
<>
{state.status === 'loading' && (
<div className={css.status}></div>
<div className={css.status}>{t('status.loading')}</div>
)}
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} {failure.message}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}> · </span>
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
)}
</span>
<span className={css.check}>
@@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
})}
</div>
{state.status === 'ready' && choices.length === 0 && (
<div className={css.empty}></div>
<div className={css.empty}>{t('empty.models')}</div>
)}
</>
)}
@@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
<>
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
</div>
)}
{effortChoices.length === 0
? <div className={css.empty}></div>
? <div className={css.empty}>{t('empty.efforts')}</div>
: effortChoices.map(level => (
<button
ref={itemRef()}

View File

@@ -14,15 +14,27 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelDirectoryState } from './directory.ts'
import { ModelService } from './service.ts'
import type { ModelSelectInjected } from './slots.ts'
import { ModelSelect } from './ModelSelect.tsx'
import { en, zh, type ModelKey } from './locales.ts'
export { ModelDirectory } from './directory.ts'
export type { ModelDirectoryState } from './directory.ts'
export { ModelService } from './service.ts'
export type { ModelSelectInjected } from './slots.ts'
export type { ModelKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The model selection surfaces' copy (/model popup + composer seat). */
model: ModelKey
}
}
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
function rowId(providerId: string, modelId: string): string {
@@ -30,7 +42,7 @@ function rowId(providerId: string, modelId: string): string {
}
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
function optionsOf(directory: SessionModels): SelectOption[] {
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
const rows: SelectOption[] = []
for (const group of directory.groups) {
for (const model of group.models) {
@@ -38,7 +50,7 @@ function optionsOf(directory: SessionModels): SelectOption[] {
id: rowId(group.id, model.id),
label: model.name,
detail: model.unlisted === true
? `${group.name} · 未列入目录`
? t('option.unlisted', { group: group.name })
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
? { active: true } : {}),
@@ -46,7 +58,11 @@ function optionsOf(directory: SessionModels): SelectOption[] {
}
}
for (const failure of directory.failures) {
rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
rows.push({
id: `failure/${failure.id}`,
label: failure.name,
detail: t('option.loadError', { message: failure.message }),
})
}
return rows
}
@@ -76,28 +92,40 @@ function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefin
return undefined
}
/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
export const inject = ['command', 'connection', 'sessions', 'slots']
/** Dictionary namespace owned by this plugin. */
const NS = 'model'
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
/**
* Client plugin body: mount ModelService, then register the /model popup
* contribution and the composer model seat over it.
* Client plugin body: mount ModelService, register the `model` dictionaries,
* then register the /model popup contribution and the composer model seat
* over the service.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.plugin(ModelService)
// Entry 1: the /model popupSelect over the shared directory.
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
// Non-slot faces (the command description, the popup option builder) read
// through the bound translate; the seat component reads the standard seat.
const t = ctx.locale.bind(NS)
// Entry 1: the /model popupSelect over the shared directory. The command
// description is registry-held text: it reads t() once at registration and
// refreshes only on re-registration, not on locale change.
ctx.inject(['command', 'models'], (scope: ClientContext) => {
const command = scope.get('command') as CommandServiceContract
const models = scope.models
scope.effect(() => command.register({
name: 'model',
description: 'Select the model for this conversation',
description: t('command.description'),
available: () => true,
ui: {
kind: 'popupSelect',
options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
onSelect: async (option, session) => {
const directory = models.directoryFor(session.sessionId)
const target = targetOf(directory.store.getSnapshot(), option.id)
@@ -117,6 +145,7 @@ export function apply(ctx: ClientContext): void {
const models = scope.models
scope.effect(() => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
return {

View File

@@ -0,0 +1,46 @@
/** `model` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command.description': '选择本会话使用的模型',
'option.unlisted': '{group} · 未列入目录',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
'trigger.aria': '选择模型,当前 {model}',
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
'menu.aria': '模型与推理等级',
'menu.model': '模型',
'menu.effort': '推理等级',
'effort.providerDefault': '服务商默认',
'status.loading': '正在刷新模型列表…',
'error.action': '模型操作失败:{message}',
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'option.currentUnlisted': '当前模型 · 未列入目录',
'empty.models': '没有可用的模型。',
'empty.efforts': '当前模型未提供推理等级。',
} satisfies Record<string, string>
/** The model namespace key union. */
export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'command.description': 'Select the model for this conversation',
'option.unlisted': '{group} · Not in catalog',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
'trigger.aria': 'Select model, current {model}',
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
'menu.aria': 'Model and reasoning effort',
'menu.model': 'Model',
'menu.effort': 'Effort',
'effort.providerDefault': 'Provider default',
'status.loading': 'Refreshing model list…',
'error.action': 'Model operation failed: {message}',
'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<ModelKey, string>

View File

@@ -12,6 +12,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
@@ -79,14 +80,18 @@ async function bench() {
return () => { contribution = undefined }
},
})
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
const seats = new Map<string, {
inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
locale: string | undefined
}>()
ctx.provide('slots', {
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject })
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject, locale: options.locale })
return () => { seats.delete(options.name) }
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const scopes = new Map<SessionId, Context>()
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -114,6 +119,8 @@ describe('ui-model dual entry', () => {
expect(b.contribution().name).toBe('model')
expect(b.contribution().ui.kind).toBe('popupSelect')
expect(b.seat().inject).toBeTypeOf('function')
// Copy rides the standard locale seat.
expect(b.seat().locale).toBe('model')
})
it('popup options mark the host current active with the provider group in the detail', async () => {

View File

@@ -3,8 +3,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
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 mirrors the real lookup
// chain: package dictionary, then common vocabulary, then the key.
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
const template = (zh as Record<string, string>)[key]
?? (commonZh as Record<string, string>)[key]
?? key
return params === undefined
? template
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
}
const reasoning = {
efforts: [
@@ -44,13 +58,14 @@ describe('ModelSelect reasoning effort', () => {
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
const trigger = screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash推理等级 High',
})
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Off', 'High', 'MaxLargest budget'])
@@ -83,13 +98,14 @@ describe('ModelSelect reasoning effort', () => {
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
fireEvent.click(screen.getByRole('button', {
name: '选择模型,当前 Model推理等级 Provider default',
name: '选择模型,当前 Model推理等级 服务商默认',
}))
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Provider default', 'Standard'])
.toEqual(['服务商默认', 'Standard'])
})
})

View File

@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
@@ -44,11 +45,13 @@
"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"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -4,7 +4,10 @@ import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import {
PendingQuestion,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
@@ -52,17 +55,20 @@ export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
return <QuestionFlow key={question.key} pending={question} />
return <QuestionFlow key={question.key} pending={question} t={props.t} />
}
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(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.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
@@ -111,7 +117,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
setError({ key: 'error.incomplete' })
return
}
const answer: QuestionAnswer = {
@@ -130,13 +136,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
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('请选择一个选项或填写自定义答案。')
setError({ key: 'error.unanswered' })
return
}
if (index < questions.length - 1) {
@@ -174,28 +180,30 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
{question.multiSelect === true && (
<span className={css.multiSelectHint}>{t('title.multi')}</span>
)}
</h2>
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
@@ -230,7 +238,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}></span>}
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
@@ -255,7 +265,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
<span>{t('option.custom')}</span>
</button>
)}
{draft.customOpen && (
@@ -265,7 +275,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
placeholder={t('custom.placeholder')}
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
@@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary" size="sm"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>

View File

@@ -6,7 +6,7 @@
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
// entry) into every program that sees this contract, so PropsRuntime resolves.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -70,8 +70,9 @@ export class PendingQuestion {
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the question carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface.
* selector result, already narrowed to the question carrier — plus the
* standard locale seat; the carrier plus the domain face above carry the
* whole behavior surface.
*/
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>

View File

@@ -1,18 +1,32 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain. Pure
* consumer — the selector narrows the owner's currency to the question
* carrier (matched prop), and the whole behavior surface rides the carrier
* (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
* selector-routed entry of the conversation-declared composer chain, plus the
* `question` dictionaries. The selector narrows the owner's currency to the
* question carrier (matched prop), and the whole behavior surface rides the
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The question composer's copy. */
question: QuestionKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'question'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
}
/**
* Client plugin body: register the question composer into the composer chain.
* Zero business face — data and verbs both live on the matched carrier.
* Client plugin body: register the `question` dictionaries and the question
* composer into the composer chain. Zero business face — data and verbs live
* on the matched carrier; t rides the standard locale seat.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const slots = ctx.slots
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
ctx.effect(
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
() => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
),
'ui-question: composer chain registration',
)
}

View File

@@ -0,0 +1,34 @@
/** `question` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.incomplete': '请先完成这道问题。',
'error.unanswered': '请选择一个选项或填写自定义答案。',
'title.multi': '可多选',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'option.custom': '其他,请填写自定义答案',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
} satisfies Record<string, string>
/** The question namespace key union. */
export type QuestionKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
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',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'option.custom': 'Other — enter a custom answer',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',
} satisfies Record<QuestionKey, string>

View File

@@ -9,6 +9,7 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
@@ -24,12 +25,13 @@ async function bench() {
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'conversation'])
expect(inject).toEqual(['slots', 'conversation', 'locale'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
@@ -38,6 +40,7 @@ describe('apply', () => {
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
@@ -47,8 +50,10 @@ describe('apply', () => {
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
expect(entry.component).toBe(QuestionComposer)
// The whole behavior surface rides the matched carrier: no business face.
// The whole behavior surface rides the matched carrier: no business face;
// copy rides the standard locale seat.
expect(entry.inject).toBeUndefined()
expect(entry.locale).toBe('question')
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }

View File

@@ -8,10 +8,12 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
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)
@@ -28,6 +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 mirrors the real
// lookup chain: package dictionary, then common vocabulary, then the key.
t: (key => (zh as Record<string, string>)[key]
?? (commonZh as Record<string, string>)[key]
?? key) as QuestionComposerProps['t'],
}
const QUESTIONS = [

View File

@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},

View File

@@ -25,7 +25,8 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
},
@@ -38,6 +39,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -46,6 +48,7 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",

View File

@@ -32,6 +32,7 @@ export function SidebarRoot({
width,
startSession,
toggleSidebar,
t,
renderSlot,
}: SidebarRootComponentProps) {
// Wide content stays mounted while the collapse animates (fading via
@@ -67,7 +68,7 @@ export function SidebarRoot({
<button
type="button"
className={clsx(css.brand, css.wide)}
aria-label="New session"
aria-label={t('session.new.label')}
onClick={() => { startSession() }}
>
<BrandWordmark />
@@ -75,11 +76,11 @@ export function SidebarRoot({
)}
{/* Rail resting state is the whale mark; hovering swaps in the panel
icon (the expand affordance, figma sidebar-hover flow). */}
<Tooltip label="Open sidebar" disabled={wide}>
<Tooltip label={t('toggle.open')} disabled={wide}>
<button
type="button"
className={clsx(css.iconButton, css.toggle)}
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')}
onClick={() => { toggleSidebar() }}
>
{!wide && <FishLogo className={css.railFish} size={24} />}
@@ -89,15 +90,15 @@ export function SidebarRoot({
</Tooltip>
</div>
<Tooltip label="New session" disabled={wide}>
<Tooltip label={t('session.new.label')} disabled={wide}>
<button
type="button"
className={css.newSession}
aria-label="New session"
aria-label={t('session.new.label')}
onClick={() => { startSession() }}
>
<IconNewChatOutline16 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
</button>
</Tooltip>

View File

@@ -6,7 +6,7 @@
* `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
* `sidebar.settings` registrant's (ui-settings).
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -68,7 +68,9 @@ export type SidebarRootInjected = {
/**
* Full component props: layout owner state/actions plus the declared holes'
* render shares and this package's injected callbacks. No store is registered.
* render shares, this package's injected callbacks, and the standard locale
* seat. No store is registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
& SidebarRootInjected & PropsLocale<'sidebar'>

View File

@@ -1,17 +1,33 @@
/** Registers the sidebar shell into the layout-owned slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
import { en, zh, type SidebarKey } from './locales.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
export type { SidebarKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Sidebar shell controls copy. */
sidebar: SidebarKey
}
}
/** Dictionary namespace owned by this plugin (shell controls copy). */
const NS = 'sidebar'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
/** Registers the sidebar shell and its service callbacks.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
const injectProps = (): SidebarRootInjected => ({
// The shell's New Session button rides the runtime's shared action
// (recent-Workspace targeting; explicit Workspace wins for scoped actions).
@@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(
() => ctx.slots.register({
name: 'sidebar',
locale: NS,
// The shell owns geometry; ui-workspace registers the whole browsing
// region (header, search, session list, workspace dialogs), ui-settings
// registers the foot trigger + settings panel.

View File

@@ -0,0 +1,20 @@
/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'session.new': '新会话',
'session.new.label': '新建会话',
'toggle.open': '打开侧边栏',
'toggle.collapse': '收起侧边栏',
} satisfies Record<string, string>
/** The sidebar namespace key union. */
export type SidebarKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'session.new': 'New Session',
'session.new.label': 'New session',
'toggle.open': 'Open sidebar',
'toggle.collapse': 'Collapse sidebar',
} satisfies Record<SidebarKey, string>

View File

@@ -2,6 +2,7 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -14,6 +15,7 @@ async function bench(declare = true) {
ctx.provide('layout', layout)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspaces as never)
ctx.provide('locale', new LocaleService(ctx))
const slots = ctx.get('slots') as SlotsService
if (declare) {
slots.register(
@@ -26,7 +28,7 @@ async function bench(declare = true) {
describe('ui-sidebar apply', () => {
it('declares only the services it uses', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
})
it('registers the shell and declares the browsing-region hole', async () => {
@@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => {
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
// Copy rides the standard locale seat, not the inject face.
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
// Both arms delegate to the runtime's shared New Session action.

View File

@@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import { en } from '../src/client/locales.ts'
// English-dictionary translate stub: the shell renders the same copy the
// assertions below query by accessible name.
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
afterEach(() => {
cleanup()
@@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={neverHook} useWorkspaces={neverHook}
startSession={startSession} toggleSidebar={toggleSidebar}
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
if (key === 'sidebar.settings') {
settingsOwner = owner

View File

@@ -11,6 +11,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, waitFor } from '@testing-library/react'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
afterEach(cleanup)
@@ -18,6 +19,12 @@ afterEach(cleanup)
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { toggleSidebar: vi.fn() })
// English locale pins the snapshots to the copy they were recorded with;
// the installed face backs the entry's standard `t` seat.
const locale = new LocaleService(runtime.ctx)
locale.setLocale('en')
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
await runtime.mount({ inject: [...inject], apply })
return runtime

View File

@@ -26,6 +26,9 @@
{
"path": "../ui-layout"
},
{
"path": "../locale"
},
{
"path": "../../support/invariants"
}

View File

@@ -24,6 +24,67 @@ export * from './deferred.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
/**
* Locale namespace table. Dictionary owners extend via declaration merging
* (exactly like {@link SlotMap}, and declared in this entry module for the
* same lexical-merge reason): the key is the namespace string, the value is
* the union of its dictionary keys. Register sites declare one of these
* namespaces (`locale:`), which puts the typed `t` standard seat on the
* component props.
*/
export interface LocaleNamespaceMap {}
/**
* Translate a dictionary key with optional `{name}` template params.
* `K` narrows the accepted keys to the owning namespace's dictionary union
* (plus the shared common vocabulary where composed).
*/
export type Translate<K extends string = string> =
(key: K, params?: Record<string, unknown>) => string
/**
* The shared `common` vocabulary keys as merged by the locale plugin;
* resolves to `never` in programs without the merge (this package's tests),
* keeping the union collapse harmless.
*/
export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never
/**
* Key domain of a namespace-bound translate: the namespace's own dictionary
* union plus the shared common vocabulary (the lookup chain consults common
* after the namespace misses).
*/
export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> =
(LocaleNamespaceMap[N] & string) | CommonKeyOf
/**
* Namespace-addressed translate — the developer-facing alias over
* {@link Translate}: `TranslateNS<'model'>` is the translate function of the
* `model` namespace (key domain = its dictionary union plus the shared
* common vocabulary), the exact type of the framework-injected `t` seat and
* of the locale service's typed `bind`.
*/
export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>
/**
* Dictionary shape for a declared namespace: exactly the keys the namespace
* merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed
* registration site is a compile error.
*/
export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> =
Record<LocaleNamespaceMap[N] & string, string>
/**
* Locale share of the composed component props: the framework-injected `t`
* seat, present exactly on entries whose registration declares `locale:`.
*/
export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string
? {
/** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */
t: TranslateNS<N>
}
: object
/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
@@ -244,10 +305,11 @@ export type InjectFace<I extends object> =
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
/**
* The four-share component props intersection: runtime share (SlotMap) +
* The composed component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face (its hooks compartment bound, see
* {@link InjectFace}). Each share derives from its single source of truth;
* {@link InjectFace}) + the locale `t` seat (declared namespace, see
* {@link PropsLocale}). Each share derives from its single source of truth;
* components reference this composition, never re-type it.
*/
export type ComposedProps<
@@ -256,7 +318,8 @@ export type ComposedProps<
H,
I extends object,
M = never,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
N = undefined,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
/**
* Inject factory parameter list, derived from the registration's declaration:
@@ -303,13 +366,20 @@ type RendersCheck<C, D> =
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
store?: H
/**
* Dictionary namespace of this entry's copy. Declaring it puts the
* framework-synthesized `t` seat (typed to the namespace's dictionary
* union) on the component props; rendering requires an installed locale
* face — fails loud otherwise.
*/
locale?: N
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K], M>
@@ -330,6 +400,8 @@ export interface StoredEntry {
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
store?: StoreDecl | undefined
/** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */
locale?: string | undefined
/** Diagnostics label of who registered. */
registrant?: string | undefined
}
@@ -350,6 +422,7 @@ interface ErasedOptions {
priority?: number | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
locale?: string | undefined
/* oxlint-disable-next-line typescript/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
@@ -427,16 +500,20 @@ export class SlotCore {
* @returns disposer removing the registration and its declarations
* (idempotent; stale disposers after a cascade are no-ops).
*/
/* jscpd:ignore-start -- the two register overloads are deliberately
* parallel declarations differing only in the inject share; folding them
* would lose the per-overload inference of I. */
register<
K extends keyof SlotMap & string,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M> & { inject?: undefined },
options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/**
@@ -455,13 +532,15 @@ export class SlotCore {
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/* jscpd:ignore-end */
register(options: ErasedOptions, component: unknown): () => void {
const rec = this.records.get(options.name)
if (!rec?.spec) {
@@ -523,6 +602,7 @@ export class SlotCore {
...(options.inject !== undefined ? { inject: options.inject } : {}),
...(options.children !== undefined ? { children: options.children } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.locale !== undefined ? { locale: options.locale } : {}),
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
}
const next = [...rec.entries, entry]

View File

@@ -1,6 +1,31 @@
/** React-free contracts between the slot host and an installed renderer. */
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
/**
* The locale face the render machinery consumes: namespace binding plus an
* observable revision (getSnapshot/subscribe pair — the same HostObservable
* currency as every other standard-kit source). The revision moves on every
* active-locale or registry change; the renderer re-derives each entry's `t`
* from (namespace, revision), so a locale switch hands out NEW function
* references and memoized components re-render naturally. Implemented by the
* locale plugin, installed through the runtime SlotsService (installLocale).
* Install before the first render that needs the seat: outlets bind their
* revision subscription at mount, and a face appearing later has no channel
* to notify already-mounted outlets (the locale plugin is immediately-tier
* infrastructure, so normal compositions install during boot).
*/
export interface LocaleFace extends HostObservable<{ revision: number }> {
/**
* Bind a namespace to a translate function reading the active locale at
* call time. Identity may be stable per namespace — freshness of rendered
* text is carried by the renderer's (ns, revision) seat derivation, not by
* this binding.
* @param ns - dictionary namespace.
* @returns the namespace-bound translate function.
*/
bind(ns: string): Translate
}
/** Minimal observable surface for host-provided standard-kit data sources. */
export interface HostObservable<T> {
@@ -128,6 +153,12 @@ export interface SlotRendererHost {
/** Workspace list source backing the useWorkspaces standard hook. */
list: HostObservable<unknown>
}
/**
* Installed locale face backing the `t` standard seat (absent until the
* locale plugin installs one; rendering an entry that declared `locale:`
* without it is an assembly failure).
*/
locale?: LocaleFace | undefined
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */

View File

@@ -9,26 +9,26 @@ import clsx from 'clsx'
import {
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
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'
/** Injected business face: namespace-bound translate + the preference write. */
/** Injected business face: the preference write (t rides the standard locale seat). */
export interface AppearanceRowInjected {
/** Translate a `settings.theme` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the theme preference. */
setTheme: (id: ThemePreference) => void
}
/** Full component props: runtime share + store share + injected face. */
/** Full component props: runtime share + store share + locale seat + injected face. */
export type AppearanceRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>>
& PropsLocale<'settings.theme'> & AppearanceRowInjected
/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
const CUBES: readonly { id: ThemePreference; labelKey: string; 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 },

View File

@@ -14,13 +14,22 @@ 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'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The Appearance settings row's copy. */
'settings.theme': ThemeKey
}
}
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
export type ThemeTokens = Record<string, string>
@@ -228,23 +237,7 @@ export function apply(ctx: ClientContext): void {
const theme = new ThemeService(ctx)
ctx.provide('theme', theme)
ctx.effect(() => {
const disposers = [
ctx.locale.register(SETTINGS_NS, 'zh', {
'appearance.title': '外观',
'appearance.light': '浅色',
'appearance.dark': '深色',
'appearance.system': '跟随系统',
}),
ctx.locale.register(SETTINGS_NS, 'en', {
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, '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<typeof store> | undefined
@@ -258,7 +251,6 @@ export function apply(ctx: ClientContext): void {
// first render (the store's revision guard drops stale duplicates).
sync(theme.getTheme())
return {
t: ctx.locale.bind(SETTINGS_NS),
setTheme: (id) => { theme.setTheme(id) },
}
}
@@ -269,6 +261,7 @@ export function apply(ctx: ClientContext): void {
id: 'appearance',
order: 10,
store,
locale: SETTINGS_NS,
inject: injected,
}, AppearanceRow))
return () => { deferred.dispose() }

View File

@@ -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<string, string>
/** 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<ThemeKey, string>

View File

@@ -73,7 +73,8 @@ describe('ui-theme apply', () => {
const { instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().preference).toBe('dark')
expect(face.t('appearance.dark')).toBe('深色')
// Copy rides the standard locale seat: the entry declares the namespace.
expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS)
face.setTheme('system')
expect(theme.getTheme().preference).toBe('system')

View File

@@ -5,8 +5,9 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
type SlotScope, type StoredEntry, type Translate,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -159,6 +160,74 @@ function cachedSessionMaybeInject(
return props
}
/**
* Locale `t` seat bindings, cached per (face, namespace, revision). The
* revision is part of the cache key ON PURPOSE: a locale switch mints a NEW
* function reference per namespace, so `React.memo` components taking `t`
* re-render through ordinary shallow comparison — freshness rides identity,
* no extra invalidation channel. Within one revision the reference is stable
* (memoized children do not churn on unrelated re-renders).
*/
const localeSeatCache = new WeakMap<LocaleFace, Map<string, { revision: number; t: Translate }>>()
function localeSeat(face: LocaleFace, ns: string): Translate {
let perNs = localeSeatCache.get(face)
if (!perNs) {
perNs = new Map()
localeSeatCache.set(face, perNs)
}
const revision = face.getSnapshot().revision
const cached = perNs.get(ns)
if (cached && cached.revision === revision) return cached.t
const bound = face.bind(ns)
// Fresh wrapper per revision: bind() itself may return a stable reference.
const t: Translate = (key, params) => bound(key, params)
perNs.set(ns, { revision, t })
return t
}
const noopSubscribe = (): (() => void) => () => {}
const zeroRevision = (): number => 0
/**
* Per-face subscribe/getSnapshot closure pair. Cached by face identity: the
* face is one global source shared by every outlet, and uSES resubscribes
* whenever the subscribe reference changes — fresh closures per render would
* churn one unsubscribe/resubscribe pair per outlet per render.
*/
const localeSubscriptionCache = new WeakMap<LocaleFace, {
subscribe: (fn: () => void) => () => void
getRevision: () => number
}>()
function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } {
let cached = localeSubscriptionCache.get(face)
if (!cached) {
cached = {
subscribe: fn => face.subscribe(fn),
getRevision: () => face.getSnapshot().revision,
}
localeSubscriptionCache.set(face, cached)
}
return cached
}
/**
* Subscribe an outlet to the installed locale face's revision (0 while none
* is installed — exactly one uSES call either way, keeping hook order
* stable). Every outlet re-renders on a locale switch; entry bodies then
* re-derive their `t` seat at the new revision. The face must be installed
* before the first render that needs it — a face appearing later has no
* notification channel to already-mounted outlets.
*/
function useLocaleRevision(face: LocaleFace | undefined): number {
const subscription = face !== undefined ? localeSubscription(face) : undefined
return useSyncExternalStore(
subscription?.subscribe ?? noopSubscribe,
subscription?.getRevision ?? zeroRevision,
)
}
/**
* Entry-identity React keys for chain boundaries. A chain outlet renders ONE
* elected entry through an error boundary; without a key, a boundary that
@@ -242,6 +311,16 @@ function standardKit(
// reader, bound per provide bundle (cached by info identity).
kit['useProjection'] = projectionHook(info)
}
if (entry.locale !== undefined) {
const face = host.locale
// Loud assembly failure: locale is immediately-tier infrastructure; a
// declared namespace with no installed face is a miswired composition.
if (face === undefined) {
throw new SlotAssemblyError(
`entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`)
}
kit['t'] = localeSeat(face, entry.locale)
}
const store = scope === 'session-maybe' && info?.sessionId === undefined
? undefined
: host.storeOf(entry, info?.sessionId)
@@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
fn => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
// Locale revision tick: a locale switch re-renders every outlet, and entry
// bodies re-derive their `t` seat at the new revision (fresh identity).
useLocaleRevision(host.locale)
const sessionInfo = useSessionMaybeProvideInfo()
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
@@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
fn => host.subscribe('root', fn),
() => host.getVersion('root'),
)
useLocaleRevision(host.locale)
const entry = host.entriesOf('root')[0]
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
return (

9
pnpm-lock.yaml generated
View File

@@ -1172,6 +1172,9 @@ importers:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -1366,6 +1369,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -1452,6 +1458,9 @@ importers:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime