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,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
2026-07-24-web-plan-mode.md: 3b8a14a279dee478a291b19d5c0aed82f5a16c5c
2026-07-24-web-plan-mode.zh.md: ca52918257c289fab966f1915348b08de45d86f1
2026-07-24-web-plan-mode.md: 3f27dee80b4e059f8ed079f5d9d25b3472e7fcc7
2026-07-24-web-plan-mode.zh.md: fd0ca0d51814ee499bc28c41736d7bb87a5e406e

View File

@@ -34,7 +34,7 @@ Business and transport failures leave the confirmed snapshot unchanged, re-enabl
## Exit review
`exit_plan_mode` remains registered in both modes for request-cache stability. In plan mode the model submits the complete Markdown plan through that tool. The plan service asks through `ctx.userInteraction`, and the already-composed Web question plugin presents the plan detail with Approve, Keep planning, and the free-text answer channel.
`exit_plan_mode` remains registered in both modes for request-cache stability. In plan mode the model submits the complete Markdown plan through that tool. The plan service asks through `ctx.userInteraction`, and the already-composed Web question plugin presents the plan detail with Approve, Keep planning, and the free-text answer channel. The capped question card keeps its title, navigation, and submission actions fixed while the complete plan and choices share an internal scroll region.
Approval queues inactive mode for the next step; it does not rewrite the current tool batch. Keep planning or custom feedback leaves plan mode active and returns corrective feedback to the model. If the review channel is unavailable or aborted, the tool fails closed and the policy tells the model to ask the user to switch modes manually.

View File

@@ -34,7 +34,7 @@ Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-p
## 退出评审
`exit_plan_mode` 在两种模式下都会保持注册,以维持请求缓存稳定性。在 plan mode 中,模型通过该工具提交完整的 Markdown plan。plan 服务经由 `ctx.userInteraction` 发起询问,已组合进 Web 的问题插件会展示 plan 详情,并提供「批准」、「继续规划」和自由文本回答渠道。
`exit_plan_mode` 在两种模式下都会保持注册,以维持请求缓存稳定性。在 plan mode 中,模型通过该工具提交完整的 Markdown plan。plan 服务经由 `ctx.userInteraction` 发起询问,已组合进 Web 的问题插件会展示 plan 详情,并提供「批准」、「继续规划」和自由文本回答渠道。设有高度上限的问题卡片会固定显示标题、导航操作和提交操作,而完整 plan 与选项共享同一个内部滚动区域。
批准会将未激活模式排到下一步骤生效不会重写当前工具批次。选择继续规划或提供自定义反馈时plan mode 保持激活,并向模型返回修正反馈。若评审渠道不可用或已中止,工具会采取失败关闭策略,策略则要求模型请用户手动切换模式。

View File

@@ -312,6 +312,39 @@ describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', ()
await composer.getByRole('radio', { name: '工程落地型' }).click()
await composer.getByText('2 / 3', { exact: true }).waitFor()
await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
const detail = composer.getByText('按当前招聘目标选择;跳过则视为不设偏好。')
// Exercise provider-sized plan detail through the assembled composer
// without making the shared fixture transcript permanently enormous.
await detail.evaluate((element) => {
element.textContent = Array.from(
{ length: 80 },
(_, index) => `Plan section ${String(index + 1)} keeps review context readable.`,
).join(' ')
})
const scrollRegion = composer.locator('[data-question-scroll]')
const layout = await composer.evaluate((root) => {
const card = root.querySelector('section')
const scroller = root.querySelector('[data-question-scroll]')
const footer = root.querySelector('footer')
if (!(card instanceof HTMLElement) ||
!(scroller instanceof HTMLElement) ||
!(footer instanceof HTMLElement)) return null
const cardRect = card.getBoundingClientRect()
const footerRect = footer.getBoundingClientRect()
return {
cardClientHeight: card.clientHeight,
cardScrollHeight: card.scrollHeight,
footerInsideCard: footerRect.bottom <= cardRect.bottom,
scrollerClientHeight: scroller.clientHeight,
scrollerScrollHeight: scroller.scrollHeight,
}
})
expect(layout).not.toBeNull()
expect(layout?.cardScrollHeight).toBe(layout?.cardClientHeight)
expect(layout?.footerInsideCard).toBe(true)
expect(layout?.scrollerScrollHeight).toBeGreaterThan(layout?.scrollerClientHeight ?? 0)
await scrollRegion.evaluate((element) => { element.scrollTop = element.scrollHeight })
await expect.poll(() => scrollRegion.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
await composer.getByRole('checkbox', { name: '系统设计' }).click()
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')

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