Merge remote-tracking branch 'origin/master' into mergebot/pr883
# Conflicts: # packages/client/tsdown.client.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/PendingCard.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.tsx # packages/client/ui-conversation/src/client/toolviews/todo-row.module.css # packages/client/ui-conversation/src/client/toolviews/todo-row.tsx # packages/client/ui-conversation/tests/chat-apply.spec.tsx # packages/client/ui-conversation/tests/coverage-tails.spec.tsx # packages/client/ui-conversation/tests/todo-panel.spec.tsx # packages/client/ui-primitives/src/icons/index.tsx # packages/client/ui-primitives/tests/icons.spec.tsx # packages/client/ui-question/README.i18n.yaml # packages/client/ui-question/src/client/QuestionComposer.module.css # packages/client/ui-question/src/client/QuestionComposer.tsx
This commit is contained in:
@@ -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-question/README.md
|
||||
README.md: 4611fc340426098bc1aa12f17e580fd829f0c702
|
||||
README.zh.md: 50e69a25175a948c6c026a35d9a549397f820be6
|
||||
README.md: 0700375758774610fcd897b9a3e16484206a871d
|
||||
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow: hidden;
|
||||
/* Elevated surface in dark, same as the menus: the body inside scrolls
|
||||
once the card hits the cap above, so the thumb takes the l2 pair. Declared
|
||||
on the card because the elevation belongs to the surface, and the custom
|
||||
properties inherit down to `.body` (see ui-theme styles/scrollbar.css
|
||||
for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
@@ -62,11 +69,7 @@
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 2px 0 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
@@ -117,15 +120,21 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: 8px 0 0;
|
||||
padding: 4px 12px;
|
||||
/* The scrollable region of the capped card (ChatView list pattern). */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.option {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16,
|
||||
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'
|
||||
@@ -171,7 +171,6 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
|
||||
{question.question}
|
||||
</h2>
|
||||
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('dismiss')}
|
||||
@@ -182,67 +181,94 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
||||
{(question.options ?? []).map((option, optionIndex) => {
|
||||
const selected = draft.selected.includes(option.label)
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
return (
|
||||
<button
|
||||
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { choose(option.label) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||
event.preventDefault()
|
||||
submitDrafts(drafts)
|
||||
}}
|
||||
>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
|
||||
{selected && <IconCheckOutline14 size={12} />}
|
||||
<div className={css.body} data-question-scroll>
|
||||
{question.detail !== undefined && (
|
||||
<div className={css.detail}><MarkdownText text={question.detail} /></div>
|
||||
)}
|
||||
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
||||
{(question.options ?? []).map((option, optionIndex) => {
|
||||
const selected = draft.selected.includes(option.label)
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
return (
|
||||
<button
|
||||
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { choose(option.label) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||
event.preventDefault()
|
||||
submitDrafts(drafts)
|
||||
}}
|
||||
>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
|
||||
{selected && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: <span className={css.number}>{optionIndex + 1}</span>}
|
||||
<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>}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
: <span className={css.number}>{optionIndex + 1}</span>}
|
||||
<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>}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasOptions
|
||||
? (
|
||||
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span
|
||||
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className={css.number} aria-hidden="true">
|
||||
<IconEditOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className={css.customInput}
|
||||
{hasOptions
|
||||
? (
|
||||
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span
|
||||
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className={css.number} aria-hidden="true">
|
||||
<IconEditOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className={css.customInput}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, skipped: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !isComposing(event)) {
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customTextarea}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
@@ -251,36 +277,14 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !isComposing(event)) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customTextarea}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, skipped: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
|
||||
@@ -34,6 +34,7 @@ const kit = {
|
||||
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,
|
||||
}
|
||||
@@ -80,7 +81,11 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||
expect(screen.getByText('推荐')).toBeTruthy()
|
||||
expect(screen.getByText('工程落地型')).toBeTruthy()
|
||||
expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
|
||||
const detail = screen.getByText('按当前空缺岗位的优先级选择。')
|
||||
const scrollRegion = detail.closest('[data-question-scroll]')
|
||||
expect(scrollRegion).toBeTruthy()
|
||||
expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true)
|
||||
expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false)
|
||||
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
||||
@@ -111,6 +116,29 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('renders plan detail through the shared assistant Markdown primitive', () => {
|
||||
const carrier = new PendingWait(
|
||||
'question',
|
||||
RpcId('markdown-plan'),
|
||||
SID,
|
||||
{
|
||||
questions: [{
|
||||
id: 'plan',
|
||||
question: '批准这个计划吗?',
|
||||
detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`',
|
||||
options: [{ label: '批准' }],
|
||||
}],
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
const view = render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy()
|
||||
expect(view.container.querySelector('strong')?.textContent).toBe('先验证')
|
||||
expect(view.container.querySelector('code')?.textContent).toBe('QuestionComposer')
|
||||
expect(view.container.querySelectorAll('li')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('skips individual questions without discarding earlier answers', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
Reference in New Issue
Block a user