fix(web): contain long plan reviews

This commit is contained in:
fz
2026-07-24 14:28:36 +08:00
parent 2bc2534157
commit fd8b238d94
8 changed files with 128 additions and 83 deletions

View File

@@ -2,7 +2,7 @@
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. 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.

View File

@@ -67,7 +67,7 @@
}
.detail {
margin: 2px 0 0;
margin: 0 2px 8px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
@@ -113,13 +113,19 @@
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: 4px;
/* The scrollable region of the capped card (ChatView list pattern). */
min-height: 0;
overflow-y: auto;
}
.option {

View File

@@ -171,7 +171,6 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
</h2>
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
@@ -199,79 +198,82 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
</div>
</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 && 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)
}}
>
<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}></span>}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
</button>
)}
{draft.customOpen && (
<textarea
autoFocus
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
<div className={css.body} data-question-scroll>
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
<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 && 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()
continueFlow()
}
}}
/>
)}
submitDrafts(drafts)
}}
>
<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}></span>}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
</button>
)}
{draft.customOpen && (
<textarea
autoFocus
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
event.preventDefault()
continueFlow()
}
}}
/>
)}
</div>
</div>
</div>

View File

@@ -65,7 +65,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: /工程落地型/ }))