Merge remote-tracking branch 'origin/master' into mergebot/pr883

# Conflicts:
#	packages/client/ui-question/package.json
#	packages/client/ui-question/src/client/QuestionComposer.tsx
#	packages/client/ui-question/src/client/contract/slots.ts
#	packages/client/ui-question/src/client/index.ts
#	packages/client/ui-question/src/client/locales.ts
#	packages/client/ui-question/tests/browser-plugin.spec.ts
#	packages/client/ui-question/tests/question-composer.spec.tsx
This commit is contained in:
imccyu
2026-07-30 15:01:10 +08:00
254 changed files with 3802 additions and 1162 deletions

View File

@@ -12,7 +12,7 @@ export type {
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -1150,6 +1150,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
)
return ok(request, { accepted: true as const })
},
updateQueue: request => err(request, {
code: 'queue-item-not-found',
message: 'fixture has no pending queue item',
details: { itemId: request.payload.itemId },
}),
cancel: (request) => {
const replay = replays.get(request.payload.sessionId)
if (replay !== undefined) {
@@ -1587,6 +1592,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)

View File

@@ -17,7 +17,7 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

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

@@ -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/runtime/README.md
README.md: b51cc0276d8635ea9faa506e30246a107c1c1418
README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692

View File

@@ -16,6 +16,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## Code Mode sub-dispatch index
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.

View File

@@ -16,6 +16,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering中途引导不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。

View File

@@ -8,7 +8,9 @@
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
InboxItemId, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -36,6 +38,13 @@ export interface ISession {
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Apply one mutation to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn.
* @returns acceptance, or the business error.

View File

@@ -7,7 +7,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -216,10 +216,12 @@ export interface RunningToolCall {
}
/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */
/** One independently addressable row from the transient queue snapshot. */
export interface QueuedMessage {
readonly key: string
readonly id: InboxItemId
readonly preview: string
/** Complete editable text; null when the message contains non-text blocks. */
readonly text: string | null
}
/** In-progress assistant output (chunk accumulator product). */
@@ -277,7 +279,7 @@ export interface ConversationSnapshot {
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
queue: readonly QueuedMessage[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */

View File

@@ -351,14 +351,14 @@ export class SessionManager {
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
this.notifier.markDirty()
// New mux-generation baseline: buffered session/queued frames belong to
// New mux-generation baseline: buffered session/queue frames belong to
// the previous generation and the host is about to resend the live
// snapshot — drop them, or every reconnect appends a duplicate batch
// (and enough reconnects push real approval/question frames past the
// cap). Same re-baseline signal Session uses for its own mirror.
const buffered = this.pendingBuffers.get(frame.sessionId)
if (buffered !== undefined) {
const kept = buffered.filter(item => item.payload.type !== 'session/queued')
const kept = buffered.filter(item => item.payload.type !== 'session/queue')
if (kept.length !== buffered.length) {
if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
else this.pendingBuffers.set(frame.sessionId, kept)
@@ -383,7 +383,7 @@ export class SessionManager {
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question/queued frames never hit history: buffer for replay on
// Approval/question/queue frames never hit history: buffer for replay on
// instantiation; everything else drops (not instantiated — history fully
// backfills on open).
switch (frame.type) {
@@ -391,8 +391,12 @@ export class SessionManager {
case 'approval/resolved':
case 'question/requested':
case 'question/resolved':
case 'session/queued': {
case 'session/queue': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
const prior = frame.type === 'session/queue'
? buffer.findIndex(item => item.payload.type === 'session/queue')
: -1
if (prior !== -1) buffer.splice(prior, 1)
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
this.pendingBuffers.set(frame.sessionId, buffer)

View File

@@ -4,8 +4,8 @@ import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResult, SessionId, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -48,14 +48,6 @@ export interface SessionOptions {
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
const QUEUE_PREVIEW_CHARS = 200
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
interface QueuedEntry {
row: QueuedMessage
steering: boolean
/** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */
sourceJson: string
}
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
function queuePreviewOf(content: readonly ContentBlock[]): string {
const flat = content
@@ -65,6 +57,12 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
/** Recover complete composer text only when editing cannot discard non-text blocks. */
function queueTextOf(content: readonly ContentBlock[]): string | null {
if (!content.every(block => block.type === 'text')) return null
return content.map(block => block.text).join('')
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer. Features see only
@@ -102,9 +100,8 @@ export class Session implements SessionFace {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
/** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history,
* so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */
private queued: QueuedEntry[] = []
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
@@ -234,6 +231,15 @@ export class Session implements SessionFace {
return result
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
try {
return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result
} catch (error) {
return transportError(error)
}
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* @returns the cancel result.
@@ -393,20 +399,15 @@ export class Session implements SessionFace {
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
switch (frame.type) {
case 'session/event': {
this.retireQueued(frame.event)
this.acceptLiveEvent(frame.event, frame.view)
return
}
case 'session/queued': {
const message = frame.message
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
// provisional-echo reconciliation key); otherwise the frame envelope id.
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
this.queued.push({
row: { key, preview: queuePreviewOf(message.content) },
steering: frame.steering,
sourceJson: JSON.stringify(message.source),
})
case 'session/queue': {
this.queued = frame.items.map(item => ({
id: item.id,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
}))
this.queueRev++
this.notifier.markDirty()
return
@@ -459,15 +460,6 @@ export class Session implements SessionFace {
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
// Leave-running sweep (host queuedMirror precedent): discard paths (cancel,
// terminal steering drop) have no per-entry frame, so ANY not-running signal
// with a nonempty mirror clears it — checked before the equality return so a
// stale replay on an already-idle session still sweeps.
if (!running && this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
// Turn-start conversion: a blank session never runs, so the first
// running:true proves another端's first message landed (设计稿 2.2).
if (running && this.blankBit) {
@@ -632,27 +624,6 @@ export class Session implements SessionFace {
}
}
/** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered
* turn/start claims the oldest non-steering entry; a steering/message drains the oldest
* steering entry with the same source (loop-authored steering matches nothing and drops none). */
private retireQueued(event: SessionEvent): void {
if (this.queued.length === 0) return
let index = -1
if (event.type === 'turn/start') {
if (event.data.trigger.kind !== 'message') return
index = this.queued.findIndex(entry => !entry.steering)
} else if (event.type === 'steering/message') {
const source = JSON.stringify(event.data.message.source)
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
} else {
return
}
if (index < 0) return
this.queued.splice(index, 1)
this.queueRev++
this.notifier.markDirty()
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
@@ -832,7 +803,7 @@ export class Session implements SessionFace {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
return {

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

@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -1,32 +1,41 @@
/**
* Queue mirror semantics (web input-triggers queue cut 1): session/queued
* intake, host-rule retirement (message turn/start claims oldest non-steering;
* steering/message drains by source), leave-running sweep, reconnect reset,
* pre-instantiation buffering, and snapshot reference stability.
* Queue snapshot semantics: authoritative replacement after every host-side
* change, reconnect re-baselining, pre-instantiation buffering, editable-text
* projection, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
InboxItemId, MuxFrame, RpcId, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
import { ev } from './event-script.ts'
const SID = 'fk-q1' as SessionId
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
const rid = (id: string): RpcId => id as RpcId
const iid = (id: string): InboxItemId => id as InboxItemId
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
interface QueueFixture {
id: string
body: string
content?: ContentBlock[]
}
/** Build one authoritative queue snapshot. */
function queueFrame(items: QueueFixture[]): MuxFrame {
return {
type: 'session/queued',
type: 'session/queue',
sessionId: SID,
message: createUserMessage({
content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
}),
steering,
items: items.map(item => ({
id: iid(item.id),
message: createUserMessage({
content: item.content ?? text(item.body),
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
}),
})),
}
}
@@ -34,201 +43,131 @@ function makeSession(): Session {
return new Session(SID, new FakeApiClient())
}
describe('queue intake', () => {
it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => {
describe('queue snapshot intake', () => {
it('projects stable ids, flat previews, and complete text', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
const queue = session.getSnapshot().queue
expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
session.handleMuxEnvelope(rid('env-1'), queueFrame([
{ id: 'q-1', body: '第一条 排队\n消息' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
])
})
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
it('marks mixed-content messages non-editable while retaining their preview', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
}),
steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
session.handleMuxEnvelope(rid('env-2'), queueFrame([{
id: 'q-image',
body: '',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
}]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-image', preview: 'hi [image]', text: null },
])
})
it('caps the preview at 200 code points with an ellipsis', () => {
it('caps previews at 200 code points and preserves the full editable text', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
const preview = session.getSnapshot().queue[0]?.preview ?? ''
expect(Array.from(preview)).toHaveLength(201) // 200 + …
expect(preview.endsWith('')).toBe(true)
const body = '长'.repeat(201)
session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }]))
const row = session.getSnapshot().queue[0]
expect(Array.from(row?.preview ?? '')).toHaveLength(201)
expect(row?.preview.endsWith('…')).toBe(true)
expect(row?.text).toBe(body)
})
it('replaces content, order, and membership from each authoritative frame', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-4'), queueFrame([
{ id: 'q-1', body: 'one' },
{ id: 'q-2', body: 'two' },
]))
session.handleMuxEnvelope(rid('env-5'), queueFrame([
{ id: 'q-2', body: 'two edited' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
])
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
expect(session.getSnapshot().queue).toEqual([])
})
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }]))
const before = session.getSnapshot().queue
session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
session.handleAgentError('unrelated')
expect(session.getSnapshot().queue).toBe(before)
})
})
describe('queue retirement (host queuedMirror rules)', () => {
it('a message-triggered turn/start claims the oldest non-steering row', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
})
describe('queue operation transport', () => {
it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
it('an injection-triggered turn/start claims nothing', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
const injection = {
...ev.turnStart(0, 0),
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
} as never
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
expect(session.getSnapshot().queue).toHaveLength(1)
})
it('steering/message drains the source-matched steering row only', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering
session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true))
// Loop-authored steering (different source) must not consume the user entry.
const foreignSteering = {
seq: 0, time: 1,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 0,
message: createUserMessage({
content: text('loop'),
source: { kind: 'plugin', plugin: 'loop' },
}),
},
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 0,
message: createUserMessage({
content: text('插话'),
source: { kind: 'user', rpcId: rid('p-2') },
}),
},
} as never
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
})
it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => {
const session = makeSession()
session.handleRunning(true)
session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2'))
session.handleRunning(false)
expect(session.getSnapshot().queue).toEqual([])
})
it('a stale not-running relay on an idle session still sweeps replayed rows', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1'))
session.handleRunning(false) // running already false: equality path must not skip the sweep
expect(session.getSnapshot().queue).toEqual([])
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
}])
expect(session.getSnapshot().queue).toBe(before)
})
})
describe('queue reconnect semantics', () => {
it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
it('session/subscribed clears stale state before the fresh snapshot lands', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
// New mux generation: subscribed arrives first on the same stream...
session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }]))
session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
expect(session.getSnapshot().queue).toEqual([])
// ...then the queue snapshot replays the live inbox.
session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new'))
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new'])
session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }]))
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
const session = makeSession()
// Reconnect ordering that broke: mux opened first and already delivered
// the fresh generation's baseline; host stream (and with it onConnected →
// resync) lands after. The host never resends — clearing here left the
// dock empty until the next enqueue.
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh'))
session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }]))
await session.resync()
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
})
it('replayed steering retires without a replayed turn/start', () => {
it('running-status changes never guess at queue retirement', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true))
const committed = {
seq: 6, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 1,
message: createUserMessage({
content: text('重连插话'),
source: { kind: 'user', rpcId: rid('p-steer') },
}),
},
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
expect(session.getSnapshot().queue).toEqual([])
session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }]))
session.handleRunning(true)
session.handleRunning(false)
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
})
})
describe('manager buffering of queued frames', () => {
it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') })
// Instantiation replays the buffer; no summary exists, so no running sweep runs.
const session = manager.get(SID)
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1'])
// The buffer is consumed: a second get must not double-replay.
expect(manager.get(SID).getSnapshot().queue).toHaveLength(1)
describe('manager buffering of queue snapshots', () => {
it('replays only the latest snapshot for an uninstantiated session', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
it('a not-running list summary sweeps replayed rows at instantiation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }]))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') })
expect(manager.get(SID).getSnapshot().queue).toEqual([])
})
it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// Generation 1 baseline lands while the session is uninstantiated, along
// with a pending approval (never re-derivable from history).
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') })
it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
manager.handleMuxEnvelope({
rpcId: rid('g1b'),
payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
})
// Reconnect: generation 2 replays subscribed + the SAME live queue entry.
manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } })
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') })
manager.handleMuxEnvelope({
rpcId: rid('g2a'),
payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 },
})
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) })
const snapshot = manager.get(SID).getSnapshot()
// One queue row (no duplicate batch); the approval survived the re-baseline.
expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1'])
expect(snapshot.pending.map(p => p.kind)).toEqual(['approval'])
expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval'])
})
})
/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */
function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) {
return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } }
}

View File

@@ -84,6 +84,14 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
updateQueue(): never {
throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
* @returns never — always throws.

View File

@@ -467,6 +467,7 @@ describe('fixture session face', () => {
await runtime.sessions.add({ id: 's1' })
const bare = runtime.sessions.behavior('s1')
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)

View File

@@ -128,6 +128,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
this.addWatchFile(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({

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/ui-conversation/README.md
README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f
README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e
README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b

View File

@@ -34,9 +34,11 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.

View File

@@ -34,9 +34,11 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行没有耗时区段**assistant `usage` 只携带 token 计数;耗时需要主机数据源
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**包含非文本块的行仍显示扁平化预览但由于内联编辑器无法保留这些块其编辑控件会被禁用。文本行进入编辑模式后删除会替换为保存和取消Enter 保存Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:在 steering中途引导拥有专用交互之前Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript文本记录因此从外部提交的 steering 在回放时仍能如实呈现。

View File

@@ -34,11 +34,3 @@
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
margin-left: -6px;
}
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
@media (hover: hover) {
.root:hover .actions,
.root:focus-within .actions {
opacity: 1;
}
}

View File

@@ -4,7 +4,8 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
// Finalized content (text) nodes append IconActions once streaming ends;
// Think / tool-head-only nodes stay chrome-free.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -38,6 +39,11 @@ function copyText(blocks: readonly AssistantBlock[]): string {
return parts.join('')
}
/** True when the node has model-visible text content worth chrome under. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
@@ -64,8 +70,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
// Footer only after the turn settles with a known event time; streaming omits it.
const showActions = !streaming && time !== undefined
// Footer only under settled content text; Think-only / streaming omit it.
const showActions = !streaming && time !== undefined && hasContentText(blocks)
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>

View File

@@ -144,8 +144,11 @@
}
:global([data-conversation-scroll]) .toBottomSlot {
/* Clears the sticky composer stack (stats + docks + input card). */
bottom: 168px;
/* Clears the sticky composer stack (docks + input card + stats): the live
height rides --dsh-composer-height (ConversationRoot's seat observer) so
the control follows a growing textarea; the fallback covers the first
paint before the observer fires. */
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
}
.toBottom {

View File

@@ -1,5 +1,5 @@
/* Shared message IconActions row (user + assistant). Parent modules own
hover-reveal selectors and layout offsets via the composed className. */
layout offsets via the composed className. Always visible when mounted. */
.actions {
display: flex;
@@ -25,14 +25,6 @@
white-space: nowrap;
}
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
}
.action {
display: inline-flex;
align-items: center;

View File

@@ -18,7 +18,7 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Parent layout / hover-reveal class composed onto the actions row. */
/** Parent layout class composed onto the actions row. */
className?: string | undefined
}

View File

@@ -20,14 +20,6 @@
color: var(--dsw-alias-label-primary);
}
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
@media (hover: hover) {
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.badge {
display: inline-block;
margin-bottom: 4px;

View File

@@ -2,12 +2,22 @@
736px message column axis. */
.root {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 736px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px 24px 8px;
padding: 4px 24px 0px;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
}
.sep {
color: var(--dsw-alias-separator-primary);
}

View File

@@ -2,7 +2,7 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { memo, useMemo } from 'react'
import { Fragment, memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import css from './StatsLine.module.css'
@@ -10,7 +10,13 @@ import css from './StatsLine.module.css'
interface UsageTotals {
turns: number
steps: number
tokens: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
inputTokens: number
outputTokens: number
cacheHitPct: number | null
}
@@ -22,35 +28,72 @@ interface UsageLike {
}
/**
* Fold assistant nodes into display totals.
* Fold assistant and tool-result nodes into display totals.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
const turns = new Set<number>()
let steps = 0
let tokens = 0
let llmMs = 0
let toolMs = 0
let input = 0
let output = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
continue
}
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
output += usage.outputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
tokens,
llmMs,
toolMs,
inputTokens: input + cacheRead,
outputTokens: output,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
}
/**
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
* @param n - token count.
* @returns display string.
*/
export function formatTokens(n: number): string {
const scaled = (v: number): string =>
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
if (n < 1_000) return String(n)
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
return `${scaled(n / 1_000_000)}M`
}
/**
* Compact duration: 45.2s under a minute, 2m42s from there on.
* @param ms - duration in milliseconds.
* @returns display string.
*/
export function formatDuration(ms: number): string {
const s = ms / 1_000
if (s < 60) return `${Math.round(s * 10) / 10}s`
const whole = Math.round(s)
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
@@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
parts.push(`${stats.turns} turns`)
parts.push(`${stats.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
return (
<div className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <span className={css.sep} aria-hidden>|</span>}
<span>{group}</span>
</Fragment>
))}
</div>
)
})

View File

@@ -0,0 +1,13 @@
/** Queue contracts derived from the runtime session face and snapshot. */
import type {
ConversationSnapshot, SessionFace,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One address accepted by the runtime session's queue mutation verb. */
export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
/** One mutation accepted by the runtime session's queue mutation verb. */
export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
/** One row projected by the runtime session's authoritative queue snapshot. */
export type QueueRow = ConversationSnapshot['queue'][number]

View File

@@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* design §6 MIX evidence: entries coexist in fixed order).
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** The composer top-edge band (stats line family). */
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
@@ -253,6 +253,8 @@ export interface ComposerBarOwnerProps {
leftItems?: ReactNode
/** input.right slot entries (tool row, before the primary button). */
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}

View File

@@ -10,6 +10,7 @@ import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { QueueRow } from '../contract/queue.ts'
/**
* The scoped-event application verbs: the hub's bail listeners call these,
@@ -99,12 +100,8 @@ export interface ComposerKeyboard {
dismissPopup(): void
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
export interface QueuedMessage {
/** Stable row key: the enqueueing prompt's rpcId. */
readonly key: string
readonly preview: string
}
/** One independently addressable row projected from the transient queue snapshot. */
export type QueuedMessage = QueueRow
/** Guard union of the scoped consume-token event, checked by the machine. */
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']

View File

@@ -1,30 +1,114 @@
/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
.dock {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-separator-primary);
border-radius: 10px;
background: var(--dsw-alias-bg-base);
box-sizing: border-box;
flex: none;
width: 100%;
max-width: 776px;
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
the later composer sibling paints its surface and shadow over this edge. */
margin: 0 auto -10px;
padding: 2px 12px;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
.panel {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 2px;
border-radius: 14px 14px 0 0;
background: var(--dsw-specific-tip);
}
.panel::after {
position: absolute;
inset: 0;
border: 1px solid var(--dsw-alias-border-l1);
border-bottom: none;
border-radius: inherit;
content: '';
pointer-events: none;
}
.list {
margin: 4px 0 0;
margin: 0;
padding: 0;
list-style: none;
}
.row {
overflow: hidden;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 5px 4px 12px;
border-radius: 8px;
}
.preview,
.editor {
flex: 1 1 auto;
min-width: 0;
font: var(--dsw-font-xs-13);
font-family: Inter, var(--dsw-font-family);
}
.preview {
overflow: hidden;
color: var(--dsw-alias-label-primary-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
}
.editor {
box-sizing: border-box;
height: 28px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
outline: none;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
.editor:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 10px;
}
.action {
display: grid;
flex: none;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 2px solid var(--dsw-alias-label-tertiary);
outline-offset: -2px;
}
.action:disabled {
cursor: default;
opacity: 0.45;
}

View File

@@ -1,48 +1,185 @@
// Read-only queue dock entry (design v4 queue cut 1): renders the session's
// inbox mirror (session/queued frames + connect baseline) as one stacked
// strip above the input. No per-row actions — the host inbox has no
// addressable entries yet (queue cut 2 ledger).
// Queue dock entry: renders the authoritative transient inbox snapshot and
// addresses per-row mutations through the session-scoped conversation face.
//
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import css from './QueueDock.module.css'
/** Queue operations injected by the session-scoped registration. */
export interface QueueDockInjected {
updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
notify: (level: 'info' | 'error', text: string) => void
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'>
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
export function QueueDock({ useSession }: QueueDockProps) {
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
useEffect(() => {
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [editing, queue])
if (queue.length === 0) return null
const applyAction = async (
itemId: QueueItemId,
action: QueueAction,
failure: string,
): Promise<boolean> => {
setBusy(itemId)
try {
await updateQueue(itemId, action)
return true
} catch {
notify('error', failure)
return false
} finally {
setBusy(current => current === itemId ? null : current)
}
}
const saveEdit = async (): Promise<void> => {
if (editing === null || editing.text.trim() === '') return
if (await applyAction(
editing.id,
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
'编辑失败:这条消息可能已经开始发送。',
)) setEditing(null)
}
return (
<div className={css.dock}>
<div className={css.title}> {queue.length} </div>
<ul className={css.list}>
{queue.map(row => (
<li key={row.key} className={css.row}>{row.preview}</li>
))}
</ul>
<div className={css.panel}>
<ul className={css.list}>
{queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label="编辑排队消息"
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: <span className={css.preview}>{row.preview}</span>}
<div className={css.actions}>
{editing?.id === row.id
? (
<>
<button
type="button"
className={css.action}
aria-label="保存排队消息"
title="保存排队消息"
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label="取消编辑"
title="取消编辑"
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label="编辑排队消息"
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label="删除排队消息"
title="删除排队消息"
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
'删除失败:这条消息可能已经开始发送。',
)
}}
>
<IconTrashOutline16 size={14} />
</button>
</>
)}
</div>
</li>
))}
</ul>
</div>
</div>
)
}
/**
* The dock entry as a plain registrant plugin (bash posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
* The dock entry as a plain registrant plugin. The conversation service is the
* ordering and action seam; session scopes provide the exact queue owner.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation'],
inject: ['slots', 'conversation', 'sessions'],
/**
* Register the queue strip into the input dock (list entry, order 0).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock)
ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 0,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
const conversation = actx.get('conversation')
if (conversation === undefined) throw new Error('queue dock: conversation service unavailable')
return {
updateQueue: (itemId, action) => conversation.updateQueue(itemId, action),
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
}
},
}, QueueDock)
},
}

View File

@@ -11,8 +11,8 @@ import type { QueuedMessage } from '../input/contract.ts'
/**
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally the
* same frozen shape ({key, preview}).
* QueuedMessage and the input-contract QueuedMessage are structurally
* identical.
* @param session - the resident session face.
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
*/

View File

@@ -13,6 +13,7 @@ import type { Context } from 'cordis'
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueueAction, QueueItemId } from './contract/queue.ts'
import type { InputService } from './input/contract.ts'
/**
@@ -30,6 +31,13 @@ export interface IConversation {
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @returns completion; business failures reject.
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* @returns completion; failures reject as in send.
@@ -71,6 +79,15 @@ export class ConversationService extends Service implements IConversation {
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
/** Apply one operation to a pending queue occurrence. */
async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
const session = this.scopedSession('updateQueue')
const result = await session.updateQueue(itemId, action)
if (!result.ok) {
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
}
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')

View File

@@ -127,10 +127,14 @@
min-height: 0;
}
/* Composer stack: dock strips above the input card (design §6 MIX order). */
/* Composer stack: dock strips above the input card (design §6 MIX order).
The stack owns the vertical rhythm: one gap here, entries carry no outer
margins — an entry that renders null costs nothing, so spacing stays
correct for any dock combination. */
.composerStack {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
@@ -170,7 +174,17 @@
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. */
z-index: 7;
background: var(--dsw-alias-bg-base);
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
band at the seat's top (the figma 24% of the resting ~150px composer),
solid below — px stops, not %, so a growing draft only widens the solid
region and the fade band never stretches. The 0px stop is bg-base at
zero alpha (not white, which the figma export hardcodes) so both themes
fade from their own base. */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
var(--dsw-alias-bg-base) 36px
);
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is

View File

@@ -2,7 +2,7 @@
// chain stay mounted across no-session/session transitions. Only the inert
// input body swaps for the strict session InputBar.
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
@@ -29,6 +29,23 @@ export function ConversationRoot({
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
const pickerAnchor = useRef<HTMLButtonElement>(null)
// Publishes the seat's live height as --dsh-composer-height on the scroll
// body so floating controls (ChatView back-to-bottom) clear the composer as
// it grows. Callback ref, not an effect: the seat remounts when the tree
// moves between the no-session and session paths. Stable identity so React
// reattaches only on those remounts, not on every render.
const seatObserver = useRef<ResizeObserver | null>(null)
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
seatObserver.current?.disconnect()
seatObserver.current = null
const scroller = seat?.parentElement ?? null
if (seat === null || scroller === null) return
seatObserver.current = new ResizeObserver(() => {
scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
})
seatObserver.current.observe(seat)
}, [])
const sessionWorkspace = sessionId === undefined
? undefined
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
@@ -106,6 +123,9 @@ export function ConversationRoot({
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Stats band under the card, inside the bar's width column so both
// share one constraint (composer.dock = stats-line family).
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
const composerBar = (
@@ -113,9 +133,6 @@ export function ConversationRoot({
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && heroWorkspaceRow}
{/* Stats band above the input-dock strips so the prior ChatView footer
order (stats → todo/queue → card) is preserved under the sticky stack. */}
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
</div>
@@ -133,7 +150,7 @@ export function ConversationRoot({
// on the fallback alone would leave Question/Approval panels at the content
// end off-screen when the user is not pinned to the floor.
const composerSeat = (
<div className={css.composerSeat} data-composer-seat="">
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
{composer}
</div>
)

View File

@@ -20,10 +20,10 @@
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
margin + 6px here); error/status strips still carry their own margin. */
padding: 6px 32px 12px;
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
the chat scroller. No top pad: the composer stack's gap owns the space
above; error/status strips still carry their own margin. */
padding: 0 32px 8px;
}
.hero {
@@ -209,12 +209,16 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
min-height: 52px;
max-height: 336px;
overflow: hidden;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
line + 4pt); the docked composer collapses to the content height. */
.hero .mirror {
min-height: 52px;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {

View File

@@ -30,7 +30,7 @@ export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
@@ -417,6 +417,7 @@ export function InputBar({
</div>
</div>
</div>
{footer}
</div>
)
}

View File

@@ -1,13 +1,14 @@
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
tip surface, 14px radius, status icons + secondary item labels. Column is
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
stack owns the gap. */
.root {
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(100% - 88px);
max-width: 776px;
max-width: 752px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);
@@ -20,11 +21,13 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Compact scale (GoalBar reference): collapsed header totals the goal
strip's 38px (8+8 pad + 20 line + 2 border). */
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px;
gap: 8px;
padding: 8px 14px;
}
.header {
@@ -41,8 +44,8 @@
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}

View File

@@ -29,9 +29,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const TODOS: TodoItem[] = [

View File

@@ -177,7 +177,7 @@ describe('small branch tails', () => {
expect(view.getByText('one-liner')).toBeTruthy()
})
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -199,6 +199,17 @@ describe('small branch tails', () => {
expect(writeText).toHaveBeenCalledWith('answer body')
settled.unmount()
const thinkOnly = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
/>,
)
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
expect(thinkOnly.queryByText('14:24')).toBeNull()
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
@@ -216,6 +227,6 @@ describe('small branch tails', () => {
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
})
})

View File

@@ -23,9 +23,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'

View File

@@ -12,7 +12,7 @@ import type {
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
afterEach(cleanup)
@@ -51,7 +51,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
describe('deriveStats', () => {
it('folds turns/steps/tokens and cache hit percentage', () => {
it('folds turns/steps/token split and cache hit percentage', () => {
const stats = deriveStats([
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
@@ -59,19 +59,53 @@ describe('deriveStats', () => {
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
expect(stats.tokens).toBe(1200)
expect(stats.inputTokens).toBe(1100)
expect(stats.outputTokens).toBe(100)
expect(stats.cacheHitPct).toBe(82)
})
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
expect(stats.toolMs).toBe(0)
expect(stats.cacheHitPct).toBeNull()
})
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1),
timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 },
}
const untimed: AssistantMessageNode = {
...assistant(2, 1),
timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 },
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
isError: false, callView: null, resultView: null,
}
const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500)
expect(stats.toolMs).toBe(3_000)
})
})
describe('formatters', () => {
it('formats token counts compactly', () => {
expect(formatTokens(517)).toBe('517')
expect(formatTokens(12_240)).toBe('12.2K')
expect(formatTokens(517_000)).toBe('517K')
expect(formatTokens(1_230_000)).toBe('1.2M')
})
it('formats durations under and over a minute', () => {
expect(formatDuration(45_230)).toBe('45.2s')
expect(formatDuration(162_000)).toBe('2m42s')
})
})
describe('StatsLine', () => {
@@ -79,12 +113,13 @@ describe('StatsLine', () => {
return { useSession: bindSnapshotSelector(source) }
}
it('renders the joined stats row and hides with zero steps', () => {
it('renders the grouped stats row and hides with zero steps', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
})
const view = render(<StatsLine {...props(source)} />)
expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
// No timing on the fixture: the duration group drops out whole.
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source)} />)
expect(emptyView.container.textContent).toBe('')

View File

@@ -21,10 +21,21 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
const SID = 's1' as SessionId
afterEach(cleanup)
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({

View File

@@ -49,7 +49,7 @@ describe('render branch tails', () => {
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

View File

@@ -1,20 +1,27 @@
// @vitest-environment jsdom
/**
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
* nothing, rows render one preview line each keyed by rpcId, and the strip
* follows queue changes through the useSession selector.
* QueueDock rendering and operations: authoritative rows, inline editing,
* removal, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { QueueItemId } from '../src/client/contract/queue.ts'
import type { InputState } from '../src/client/input/contract.ts'
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const iid = (id: string): QueueItemId => id as QueueItemId
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
return { id: iid(id), preview, text }
}
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
@@ -24,31 +31,30 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
}
}
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
/** Minimal live source backing the useSession stub. */
function liveSession(initial: ConversationSnapshot) {
let snapshot = initial
const listeners = new Set<() => void>()
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
const useSession: SnapshotSelectorHook<ConversationSnapshot> = selector =>
useSyncExternalStore(
(fn) => {
listeners.add(fn)
return () => listeners.delete(fn)
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => sel(snapshot),
() => selector(snapshot),
)
return {
useSession,
push(next: ConversationSnapshot): void {
snapshot = next
for (const fn of [...listeners]) fn()
for (const listener of [...listeners]) listener()
},
}
}
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
function kitFor(snapshot: ConversationSnapshot) {
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
return {
sessionId: SID,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
@@ -58,6 +64,9 @@ function kitFor(snapshot: ConversationSnapshot) {
inputActions: { setDraft: () => {}, submit: () => {} } as never,
session: snapshot,
input: INPUT_STATE,
updateQueue: vi.fn(() => Promise.resolve()),
notify: vi.fn(),
...injected,
}
}
@@ -69,20 +78,118 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('renders one preview row per queued message with the count strip', () => {
it('renders active actions and disables editing for mixed-content rows', () => {
const snap = snapshotWith([
{ key: 'p-1', preview: '第一条排队消息' },
{ key: 'p-2', preview: 'second queued line' },
row('i-1', '第一条排队消息'),
row('i-2', null, 'image [image]'),
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('已排队 2 条')
const rows = [...container.querySelectorAll('li')]
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(4)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
.toBe('包含非文本内容,暂不支持编辑')
})
it('follows queue changes: retirement empties the strip back to null', () => {
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText, queryByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息') as HTMLInputElement
expect(getByLabelText('保存排队消息')).toBeTruthy()
expect(getByLabelText('取消编辑')).toBeTruthy()
expect(queryByLabelText('删除排队消息')).toBeNull()
fireEvent.change(editor, { target: { value: 'after' } })
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
kind: 'edit',
content: [{ type: 'text', text: 'after' }],
})
})
})
it('cancels an edit by button or Escape without mutating the queue', () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
fireEvent.click(getByLabelText('取消编辑'))
expect(getByText('before')).toBeTruthy()
fireEvent.click(getByLabelText('编辑排队消息'))
fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
expect(getByText('before')).toBeTruthy()
expect(updateQueue).not.toHaveBeenCalled()
})
it('keeps editing during IME composition and disables a blank save', () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息')
fireEvent.change(editor, { target: { value: ' ' } })
expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
fireEvent.change(editor, { target: { value: '输入中' } })
fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
expect(updateQueue).not.toHaveBeenCalled()
expect(getByLabelText('编辑排队消息')).toBeTruthy()
})
it('removes the addressed row', async () => {
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getAllByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
})
})
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
const snap = snapshotWith([row('i-race', 'pending')])
const source = liveSession(snap)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('删除排队消息'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
})
expect(getByText('pending')).toBeTruthy()
})
it('follows authoritative retirement back to null', () => {
const snap = snapshotWith([row('i-1', '在场')])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('在场')
@@ -90,11 +197,9 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
// Registration itself runs under T5's slot declaration; here we pin the
// frozen registration surface so the wiring layer can mount it verbatim.
it('ships the session-scoped registrant plugin shape', () => {
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
expect(typeof queueDockEntry.apply).toBe('function')
})
})

View File

@@ -12,11 +12,12 @@ import { InputHub } from '../src/client/input/hub.ts'
async function bench() {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
await runtime.sessions.add({
id: 's1',
session: { prompt, cancel, loadOlder },
session: { prompt, updateQueue, cancel, loadOlder },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
@@ -26,16 +27,18 @@ async function bench() {
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, cancel, loadOlder }
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
await b.scoped.cancel()
await b.scoped.loadOlder()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
await b.runtime.dispose()

View File

@@ -28,8 +28,21 @@ function fakeWiring() {
return { wiring: shell, sink, shell }
}
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId

View File

@@ -1,11 +1,12 @@
/* GoalBar: the goal strip docked above the composer card. The dock mirrors
InputBar's horizontal geometry (32px side padding, 776px centered cap)
plus the mock's 12px inset, so the bar's edges land 12px inside the
composer card's edges in both the capped and the squeezed regimes. The
negative bottom margin eats InputBar's 8px top padding and tucks the
/* GoalBar: the goal strip docked above the composer card. The dock's 44px
side padding and the bar's 752px cap match the todo strip's column
(TodoPanel.module.css), 24px inside the composer card's edges. The
negative bottom margin cancels the composer stack's 8px gap and tucks the
bar's square bottom edge 2px under the composer card's top edge (the
card, later in DOM order, paints over it). All states share one fixed
38px height so switching between them never resizes the strip. */
card, later in DOM order, paints over it). Surface matches the todo
strip: tip fill, l1 border — no bottom edge where it disappears under the
card. All states share one fixed 38px height so switching between them
never resizes the strip. */
.dock {
padding: 0 44px;
@@ -20,10 +21,10 @@
height: 38px;
margin: 0 auto -10px;
padding: 0 14px;
border: 1px solid var(--dsw-alias-border-l1);
border-bottom: none;
border-radius: 14px 14px 0 0;
/* Translucent hover gray doubles as the mock's #F5F6F7 over the white
base and lifts the strip off the composer card in dark mode. */
background: var(--dsw-alias-interactive-bg-hover);
background: var(--dsw-specific-tip);
}
.sparkle {

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': 'Default',
'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': '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: [
@@ -34,9 +48,9 @@ afterEach(cleanup)
describe('ModelSelect reasoning effort', () => {
it('renders adapter metadata and submits the effort as part of the session target', async () => {
const directory = createSnapshotStore(state())
const directory = createSnapshotStore<ModelDirectoryState>(state())
const select = vi.fn(async (target: ModelTarget) => {
directory.update((snapshot) => { snapshot.current = target })
directory.set(state({ current: target }))
return true
})
render(<ModelSelect
@@ -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推理等级 Default',
}))
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(['Default', 'Standard'])
})
})

View File

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

View File

@@ -24,8 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
@@ -36,7 +36,6 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
@@ -46,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,9 +4,10 @@ import {
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
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 {
@@ -16,11 +17,12 @@ interface DraftAnswer {
}
/**
* Displayed feedback: validation feedback is stored as a dictionary key so a
* locale flip re-translates it; carrier failures arrive as raw (untranslated)
* messages and display verbatim.
* Displayed feedback: 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.
*/
type Feedback = { key: 'error.incomplete' | 'error.empty' } | { message: string }
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
/**
* Split the conventional recommendation suffix without changing the answer value.
@@ -51,17 +53,10 @@ 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} t={props.t} useLocale={props.useLocale} />
return <QuestionFlow key={question.key} pending={question} t={props.t} />
}
function QuestionFlow({ pending, t, useLocale }: {
pending: PendingQuestion
t: Translate
useLocale: SnapshotSelectorHook<LocaleSnapshot>
}) {
// Subscription only: t reads the active locale at call time, so the
// revision selector exists to re-render this tree on locale flips.
useLocale(snapshot => snapshot.revision)
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(() => ({
@@ -81,7 +76,7 @@ function QuestionFlow({ pending, t, useLocale }: {
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError({ message: cause instanceof Error ? cause.message : String(cause) })
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
@@ -132,13 +127,13 @@ function QuestionFlow({ pending, t, useLocale }: {
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError({ message: cause instanceof Error ? cause.message : String(cause) })
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError({ key: 'error.empty' })
setError({ key: 'error.unanswered' })
return
}
if (index < questions.length - 1) {
@@ -190,8 +185,8 @@ function QuestionFlow({ pending, t, useLocale }: {
</h2>
</div>
<button
type="button" className={css.iconButton} aria-label={t('dismiss')}
title={t('dismiss')}
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
@@ -231,7 +226,9 @@ function QuestionFlow({ pending, t, useLocale }: {
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}>{t('option.recommended')}</span>}
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
@@ -287,7 +284,7 @@ function QuestionFlow({ pending, t, useLocale }: {
<footer className={css.footer}>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('pager.prev')}
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
@@ -295,7 +292,7 @@ function QuestionFlow({ pending, t, useLocale }: {
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('pager.next')}
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
@@ -303,7 +300,7 @@ function QuestionFlow({ pending, t, useLocale }: {
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.message}
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
@@ -314,8 +311,8 @@ function QuestionFlow({ pending, t, useLocale }: {
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? t('action.submitting')
: t(index === questions.length - 1 ? 'action.submit' : 'action.next')}
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>

View File

@@ -6,13 +6,12 @@
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { HostObservable, InjectFace, 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'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
/** The pending question carrier the owner dispatches into the composer slot. */
export type QuestionWait = PendingWait<'question'>
@@ -68,26 +67,12 @@ export class PendingQuestion {
}
}
/**
* Registrant-injected share: the `question`-namespace translator plus the
* locale snapshot as a hooks-compartment source. `t` reads the active locale
* at call time; the bound `useLocale` subscription is what re-renders the
* composer when the locale flips.
*/
export interface QuestionComposerInjected {
/** Translator bound to the `question` namespace. */
t: Translate
hooks: {
/** Live locale snapshot (bound to the `useLocale` selector hook). */
locale: HostObservable<LocaleSnapshot>
}
}
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit), the injected locale share, and the chain
* `matched` share — the entry's selector result, already narrowed to the
* question carrier. Data and verbs ride the carrier plus the domain face.
* session/global standard kit) plus the chain `matched` share — the entry's
* 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'> & InjectFace<QuestionComposerInjected> & { matched: QuestionWait }
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>

View File

@@ -1,23 +1,32 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain. The
* selector narrows the owner's currency to the question carrier (matched
* prop); answer/cancel behavior rides the carrier (domain encoding in
* contract/slots.ts PendingQuestion); the inject face carries only the
* locale share (bound translator + snapshot source). 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 { QuestionComposerInjected, QuestionWait } from './contract/slots.ts'
import { en, QUESTION_NS, zh } from './locales.ts'
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, QuestionComposerInjected, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export { QUESTION_NS } from './locales.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
@@ -33,33 +42,17 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
}
/**
* Client plugin body: register the composer's bilingual copy and the question
* composer itself into the composer chain. The inject face hands the entry
* its namespace-bound translator plus the locale snapshot source; data and
* verbs 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 {
ctx.effect(() => {
const disposers = [
ctx.locale.register(QUESTION_NS, 'zh', zh),
ctx.locale.register(QUESTION_NS, 'en', en),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-question: composer dictionaries')
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
const injected = (): QuestionComposerInjected => ({
t: ctx.locale.bind(QUESTION_NS),
hooks: {
locale: {
getSnapshot: () => ctx.locale.getLocale(),
subscribe: fn => ctx.on('locale/change', fn),
},
},
})
ctx.effect(
() => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, inject: injected },
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
),
'ui-question: composer chain registration',

View File

@@ -1,39 +1,30 @@
/**
* Bilingual copy of the question composer, registered under the `question`
* namespace. Question/option text itself arrives from the model verbatim —
* these dictionaries cover only the chrome around it.
*/
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
/** `question` namespace dictionaries. */
/** Namespace owning the question-composer copy. */
export const QUESTION_NS = 'question'
/** Simplified Chinese dictionary (the fallback locale). */
export const zh: LocaleDict = {
'dismiss': '放弃整组问题',
'pager.prev': '上一题',
'pager.next': '下一题',
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.incomplete': '请先完成这道问题。',
'error.unanswered': '请选择一个选项或填写自定义答案。',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
'error.incomplete': '请先完成这道问题。',
'error.empty': '请选择一个选项或填写自定义答案。',
'action.skip': '跳过本题',
'action.next': '下一题',
'action.submit': '提交',
'action.submitting': '正在提交…',
}
} satisfies Record<string, string>
/** English dictionary. */
export const en: LocaleDict = {
'dismiss': 'Dismiss all questions',
'pager.prev': 'Previous question',
'pager.next': 'Next question',
/** 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.',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',
'error.incomplete': 'Please finish this question first.',
'error.empty': 'Choose an option or type a custom answer.',
'action.skip': 'Skip this question',
'action.next': 'Next',
'action.submit': 'Submit',
'action.submitting': 'Submitting…',
}
} satisfies Record<QuestionKey, string>

View File

@@ -1,19 +1,17 @@
/**
* apply wiring on a real cordis Context + SlotsService + LocaleService:
* QuestionComposer registered as the `question` entry of the
* conversation-declared composer slot, bilingual dictionaries registered
* under the `question` namespace, the locale share handed through the inject
* face, load-order fail-loud, and fiber-teardown unregistration. Component
* and domain-face behavior is covered props-direct in
* question-composer.spec.tsx; no renderer machinery here.
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* load-order fail-loud, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
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 type { QuestionComposerInjected } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject, QUESTION_NS } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
async function bench() {
const ctx = new Context()
@@ -27,9 +25,8 @@ 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', {})
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots, locale }
ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots }
}
describe('apply', () => {
@@ -48,41 +45,29 @@ describe('apply', () => {
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
it('registers the question entry: routing selector plus the locale share face', async () => {
const { ctx, slots, locale } = await bench()
it('registers the question entry: routing selector, no inject face', async () => {
const { ctx, slots } = await bench()
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;
// 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' }
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
// The inject face carries the namespace-bound translator and the live
// locale snapshot source (subscription rides locale/change).
const face = (entry.inject as unknown as () => QuestionComposerInjected)()
expect(face.t('action.submit')).toBe('提交')
expect(face.hooks.locale.getSnapshot()).toBe(locale.getLocale())
const changed = vi.fn()
const off = face.hooks.locale.subscribe(changed)
locale.setLocale('en')
expect(changed).toHaveBeenCalledTimes(1)
expect(face.t('action.submit')).toBe('Submit')
off()
locale.setLocale('zh')
expect(changed).toHaveBeenCalledTimes(1)
})
it('teardown unregisters the slot entry and the dictionaries', async () => {
const { ctx, slots, locale } = await bench()
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
expect(locale.bind(QUESTION_NS)('action.submit')).toBe('提交')
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
// Unregistered namespace: the lookup chain bottoms out at the key itself.
expect(locale.bind(QUESTION_NS)('action.submit')).toBe('action.submit')
})
})

View File

@@ -8,35 +8,33 @@ 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 type { LocaleDict, LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import { en, zh } from '../src/client/locales.ts'
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Dictionary-backed translate stub (the lookup chain is the locale package's contract, not re-tested here). */
const translateOver = (dict: LocaleDict): Translate => key => dict[key] ?? key
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Locale-share stub: static snapshot, no subscription machinery. */
const useLocale: SnapshotSelectorHook<LocaleSnapshot> = select =>
select({ active: 'zh', locales: [], revision: 0 })
/** Framework standard-kit stubs: the composer consumes only the locale share;
/** Framework standard-kit stubs: the composer consumes only the locale seat;
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kit = {
sessionId: SID,
t: translateOver(zh),
useLocale,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
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.
t: seatOver(zh, commonZh),
}
const QUESTIONS = [
@@ -245,7 +243,7 @@ describe('QuestionComposer', () => {
const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))
const carrier = new PendingWait(
'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={translateOver(en)} />)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()

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 (