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:
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user