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
@@ -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>
@@ -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'>
+24 -31
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',
@@ -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>