mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(web): make the ask-user question card collapsible
The ask-user takeover rendered the pending question set as a bottom card
capped at min(60vh, 520px) with no way to reduce it, which buried the
conversation above while the user decided. Add a minimize toggle next to
the dismiss action: collapsed, the card becomes a header strip (title plus
the two icon buttons) and the option body and footer unmount; expanding
restores the full card. Drafts and the current question index live in
QuestionFlow local state, so collapse/expand never loses them. The
free-form textarea autofocuses only on first presentation, so re-expanding
does not steal focus from the toggle.
Agent Note: .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.{md,zh.md,i18n.yaml}
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md
|
||||
2026-08-11-collapsible-ask-user-question-card.md: 5c7e62749e63a6042285b79751c400ba09038b49
|
||||
2026-08-11-collapsible-ask-user-question-card.zh.md: 5f4b5851e4b595e634841bf87827db70fe928afb
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Collapsible Ask-User Question Card
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-11-collapsible-ask-user-question-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh`'s ask-user takeover renders the pending question set as a bottom card capped at `min(60vh, 520px)`, so a long batch or a user who wants to re-read the conversation above before deciding has no way to reduce the card — the conversation above becomes hard to read because only a few lines peek out at the top.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a minimize/maximize toggle to the question card header, next to the existing dismiss action. Collapsing hides the option body and the footer actions, leaving a header strip (eyebrow, title, both icon buttons) so the user still sees that a question is pending; expanding restores the full card.
|
||||
|
||||
- State lives in `QuestionFlow` local state (`minimized`), so drafts and the current question index survive collapse/expand — nothing is re-derived or reset, and the answers already picked remain submit-ready.
|
||||
- The toggle is a plain `IconChevronDownOutline14` / `IconChevronUpOutline14` pair on the existing 24px icon-button grid; `aria-expanded` reflects the card state and the label flips between `nav.minimize` / `nav.maximize` (the collapsed button reads "expand" for screen readers).
|
||||
- While minimized the option body and footer are unmounted (`{!minimized && ...}`), so no hidden interactive surface remains in the a11y tree.
|
||||
- The collapse button is disabled while a submit/cancel is in flight (`busy !== null`), matching the dismiss button's existing guard.
|
||||
- CSS: `.cardMinimized` drops the `max-height` cap and hides `.body` / `.footer`; `.header` gains bottom padding so the strip is not cramped.
|
||||
- Scope: only the generic question flow (`QuestionFlow`) gets the toggle. The plan-review card (`PlanReviewPanel`) is a different shape (one decision over one plan) and keeps its current layout.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users can shrink the question card to read the conversation, then expand to answer — drafts and position are preserved because the state lives in the flow component, not in the DOM.
|
||||
- The minimize action is visually adjacent to dismiss; both share the icon button style, so the header stays balanced.
|
||||
- Product copy additions are confined to the `question` locale namespace (`nav.minimize` / `nav.maximize`), paired zh/en per the dictionary contract.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Auto-collapse on scroll**: collapsing the card when the user scrolls the conversation would reclaim space without a button, but it fights the user mid-interaction and hides the pending-question signal unexpectedly; an explicit toggle keeps the decision with the user.
|
||||
- **Resizable card**: a drag handle would let users size the card freely, but it is more machinery than the ask needs and does not address "I want the card out of the way entirely".
|
||||
- **Persisting the collapsed state per session**: nice-to-have, but the ask is per-interaction; persisting adds storage and sync complexity without a clear win for this surface.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: 可收起的提问卡片
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-11-collapsible-ask-user-question-card.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh` 的 ask-user 接管界面把待回答的问题组渲染为底部卡片,高度上限为 `min(60vh, 520px)`;当问题批次较长、或用户想先阅读上方的会话记录再决定时,卡片会占满大部分视口且无法缩小——上方会话几乎被遮住,只能看到顶部几行。
|
||||
|
||||
## Decision
|
||||
|
||||
在提问卡片头部(现有的"放弃整组问题"按钮旁)增加收起/展开切换按钮。收起时隐藏选项主体和底部操作区,只保留一条头部(eyebrow、标题、两个图标按钮),用户仍能看到"有未答问题"的信号;展开后恢复完整卡片。
|
||||
|
||||
- 状态存放在 `QuestionFlow` 的本地 state(`minimized`),因此收起/展开不会丢失草稿和当前题目索引——已选答案仍可直接提交。
|
||||
- 切换按钮使用 `IconChevronDownOutline14` / `IconChevronUpOutline14`,复用现有 24px 图标按钮网格;`aria-expanded` 反映卡片状态,文案在 `nav.minimize` / `nav.maximize` 之间切换(收起后按钮对读屏器显示为"展开")。
|
||||
- 收起时选项主体和底部通过 `{!minimized && ...}` 卸载,a11y 树中不残留隐藏的可交互面。
|
||||
- 提交/取消进行中(`busy !== null`)时收起按钮禁用,与现有放弃按钮的守卫一致。
|
||||
- CSS:`.cardMinimized` 去掉 `max-height` 上限并隐藏 `.body` / `.footer`;`.header` 增加底部 padding,避免折叠后过于局促。
|
||||
- 范围:只有通用提问流(`QuestionFlow`)获得该切换。计划评审卡片(`PlanReviewPanel`)是另一种形态(对一个计划做一次决策),保持现有布局。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 用户可以缩小提问卡片以阅读会话,再展开作答——草稿和位置因状态存放在流程组件中而得以保留。
|
||||
- 收起动作紧邻放弃按钮,二者共用图标按钮样式,头部保持平衡。
|
||||
- 新增产品文案仅落在 `question` locale 命名空间(`nav.minimize` / `nav.maximize`),按字典契约中英成对。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **滚动时自动收起**:用户滚动会话时自动折叠卡片可以省空间,但会在交互中途与用户对抗,并意外隐藏"待答问题"信号;显式切换把决定权交给用户。
|
||||
- **可拖拽调整大小**:拖拽手柄让用户自由调整卡片大小,但比需求所需的机制更复杂,也没有解决"让卡片完全让开"的诉求。
|
||||
- **按会话持久化折叠状态**:锦上添花,但提问是单次交互;持久化引入存储与同步复杂度,对这个界面没有明确收益。
|
||||
@@ -39,6 +39,28 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Collapsed to the header strip: drop the height cap and the inner scroll
|
||||
seat so the card hugs the title row, freeing the viewport for the
|
||||
conversation above while the question stays pending. */
|
||||
.cardMinimized {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* The header strip is the whole card when collapsed: the title row needs
|
||||
bottom padding once the body that normally carries it is hidden. */
|
||||
.cardMinimized .header {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Header button group: minimize sits next to the close action, both on the
|
||||
same 24px icon-button grid. */
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
|
||||
pager sits in the footer to balance the card. */
|
||||
.header {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
Button, IconCheckOutline14, IconChevronDownOutline14, IconChevronLeftOutline14,
|
||||
IconChevronRightOutline14, IconChevronUpOutline14, IconCloseOutline16,
|
||||
IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
PendingQuestion, planReviewOf,
|
||||
@@ -75,6 +76,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<Feedback | null>(null)
|
||||
// Collapsed to the header strip so the conversation above stays readable
|
||||
// while the user decides; the drafts survive because the state lives here.
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
// The free-form textarea autofocuses on first presentation; re-expanding a
|
||||
// collapsed question must not steal focus from the expand toggle back into
|
||||
// the input, so focus is granted once per question index.
|
||||
const focusedQuestions = useRef(new Set<number>())
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
@@ -191,7 +199,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
|
||||
return (
|
||||
<div className={css.frame} data-question-key={pending.key}>
|
||||
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
|
||||
<section
|
||||
className={clsx(css.card, minimized && css.cardMinimized)}
|
||||
aria-labelledby={`question-${pending.key}-${String(index)}`}
|
||||
>
|
||||
<header className={css.header}>
|
||||
<div className={css.headingBlock}>
|
||||
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
|
||||
@@ -199,138 +210,155 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
{question.question}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
<div className={css.headerActions}>
|
||||
<button
|
||||
type="button" className={css.iconButton}
|
||||
aria-label={t(minimized ? 'nav.maximize' : 'nav.minimize')}
|
||||
title={t(minimized ? 'nav.maximize' : 'nav.minimize')}
|
||||
aria-expanded={!minimized}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { setMinimized(current => !current) }}
|
||||
>
|
||||
{minimized ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
</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}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customTextarea}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
{!minimized && (
|
||||
<>
|
||||
<div className={css.body} data-question-scroll>
|
||||
{question.detail !== undefined && (
|
||||
<div className={css.detail}><MarkdownText text={question.detail} /></div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.pager}>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.feedback} role="status">
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.text}
|
||||
</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
{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={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
autoFocus={!focusedQuestions.current.has(index)}
|
||||
className={css.customTextarea}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onFocus={() => { focusedQuestions.current.add(index) }}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.pager}>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.feedback} role="status">
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.text}
|
||||
</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ export const zh = {
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'nav.prev': '上一题',
|
||||
'nav.next': '下一题',
|
||||
'nav.minimize': '收起问题卡片',
|
||||
'nav.maximize': '展开问题卡片',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
@@ -26,6 +28,8 @@ export const en = {
|
||||
'error.unanswered': 'Please select an option or enter a custom answer.',
|
||||
'nav.prev': 'Previous question',
|
||||
'nav.next': 'Next question',
|
||||
'nav.minimize': 'Collapse the question card',
|
||||
'nav.maximize': 'Expand the question card',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
|
||||
Reference in New Issue
Block a user