From ce5bdc8bed0b99ce8275c290e2b7a886fe683401 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 20 Dec 2025 18:04:40 +0800 Subject: [PATCH] feat(web): improve AskUserQuestion tool card UX with better answers visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show selected answers with green borders and checkmarks in the question view - Use circles (●/○) for single-select and squares (☑/☐) for multi-select - Multi-question titles now show "N Questions" with subtitle "(+N more)" - Remove redundant Single/Multi badges from footer - Hide empty Result section when answers are shown in the view - Dialog header updates to "Questions & Answers" when answered - Handle freeform/fallback answers gracefully in all display modes --- .../ToolCard/AskUserQuestionFooter.tsx | 16 +- web/src/components/ToolCard/ToolCard.tsx | 40 ++-- web/src/components/ToolCard/knownTools.tsx | 48 ++++- .../ToolCard/views/AskUserQuestionView.tsx | 200 +++++++++++++++--- .../components/ToolCard/views/_results.tsx | 55 +---- 5 files changed, 246 insertions(+), 113 deletions(-) diff --git a/web/src/components/ToolCard/AskUserQuestionFooter.tsx b/web/src/components/ToolCard/AskUserQuestionFooter.tsx index d9f8347c..51d088df 100644 --- a/web/src/components/ToolCard/AskUserQuestionFooter.tsx +++ b/web/src/components/ToolCard/AskUserQuestionFooter.tsx @@ -292,18 +292,18 @@ export function AskUserQuestionFooter(props: {
-
- {questions[clampedStep]?.header ? ( + {questions[clampedStep]?.header ? ( +
{questions[clampedStep].header} - ) : null} - - {mode === 'multi' ? 'Multi' : 'Single'} - -
+
+ ) : null} {questions[clampedStep]?.question ? ( -
+
{questions[clampedStep].question}
) : null} diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index b3cb9247..35fab619 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -386,20 +386,32 @@ function ToolCardInner(props: ToolCardProps) { {toolTitle} -
-
-
Input
- {FullToolView ? ( - - ) : ( - renderToolInput(props.block) - )} -
-
-
Result
- -
-
+ {(() => { + const isAskUserQuestionWithAnswers = isAskUserQuestion + && permission?.answers + && Object.keys(permission.answers).length > 0 + + return ( +
+
+
+ {isAskUserQuestionWithAnswers ? 'Questions & Answers' : 'Input'} +
+ {FullToolView ? ( + + ) : ( + renderToolInput(props.block) + )} +
+ {!isAskUserQuestionWithAnswers && ( +
+
Result
+ +
+ )} +
+ ) + })()} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 6a49385c..b92aedbe 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -299,13 +299,29 @@ export const knownTools: Record , title: (opts) => { - const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null - const header = isObject(first) && typeof first.header === 'string' ? first.header.trim() : '' + const questions = isObject(opts.input) && Array.isArray(opts.input.questions) + ? opts.input.questions : [] + const count = questions.length + const first = questions[0] ?? null + const header = isObject(first) && typeof first.header === 'string' + ? first.header.trim() : '' + + if (count > 1) { + return `${count} Questions` + } return header.length > 0 ? header : 'Question' }, subtitle: (opts) => { - const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null - const question = isObject(first) && typeof first.question === 'string' ? first.question.trim() : '' + const questions = isObject(opts.input) && Array.isArray(opts.input.questions) + ? opts.input.questions : [] + const count = questions.length + const first = questions[0] ?? null + const question = isObject(first) && typeof first.question === 'string' + ? first.question.trim() : '' + + if (count > 1 && question.length > 0) { + return truncate(question, 100) + ` (+${count - 1} more)` + } return question.length > 0 ? truncate(question, 120) : null }, minimal: true @@ -313,13 +329,29 @@ export const knownTools: Record , title: (opts) => { - const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null - const header = isObject(first) && typeof first.header === 'string' ? first.header.trim() : '' + const questions = isObject(opts.input) && Array.isArray(opts.input.questions) + ? opts.input.questions : [] + const count = questions.length + const first = questions[0] ?? null + const header = isObject(first) && typeof first.header === 'string' + ? first.header.trim() : '' + + if (count > 1) { + return `${count} Questions` + } return header.length > 0 ? header : 'Question' }, subtitle: (opts) => { - const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null - const question = isObject(first) && typeof first.question === 'string' ? first.question.trim() : '' + const questions = isObject(opts.input) && Array.isArray(opts.input.questions) + ? opts.input.questions : [] + const count = questions.length + const first = questions[0] ?? null + const question = isObject(first) && typeof first.question === 'string' + ? first.question.trim() : '' + + if (count > 1 && question.length > 0) { + return truncate(question, 100) + ` (+${count - 1} more)` + } return question.length > 0 ? truncate(question, 120) : null }, minimal: true diff --git a/web/src/components/ToolCard/views/AskUserQuestionView.tsx b/web/src/components/ToolCard/views/AskUserQuestionView.tsx index 882b7d52..6c756e7e 100644 --- a/web/src/components/ToolCard/views/AskUserQuestionView.tsx +++ b/web/src/components/ToolCard/views/AskUserQuestionView.tsx @@ -1,49 +1,179 @@ +import type { ReactNode } from 'react' import type { ToolViewProps } from '@/components/ToolCard/views/_all' -import { Badge } from '@/components/ui/badge' import { parseAskUserQuestionInput } from '@/components/ToolCard/askUserQuestion' +import { cn } from '@/lib/utils' -export function AskUserQuestionView(props: ToolViewProps) { - const parsed = parseAskUserQuestionInput(props.block.tool.input) - const questions = parsed.questions - if (questions.length === 0) return null +function isAnswerSelected( + answers: Record | undefined, + questionIdx: number, + optionLabel: string +): boolean { + if (!answers) return false + const questionAnswers = answers[String(questionIdx)] + if (!questionAnswers || !Array.isArray(questionAnswers)) return false + return questionAnswers.some(a => a.trim() === optionLabel.trim()) +} + +function getSelectionMark(isMulti: boolean, isSelected: boolean): string { + if (isMulti) { + return isSelected ? '☑' : '☐' + } + return isSelected ? '●' : '○' +} + +function renderOtherAnswers( + answers: Record, + questionIdx: number, + options: { label: string }[], + isMulti: boolean +): ReactNode { + const questionAnswers = answers[String(questionIdx)] + if (!questionAnswers || !Array.isArray(questionAnswers)) return null + + const optionLabels = new Set(options.map(o => o.label.trim())) + const otherAnswers = questionAnswers.filter(a => !optionLabels.has(a.trim())) + + if (otherAnswers.length === 0) return null return ( -
- {questions.map((q, idx) => ( -
-
- - {q.header ?? `Question ${idx + 1}`} - - - {q.multiSelect ? 'Multi' : 'Single'} - + <> + {otherAnswers.map((answer, i) => ( +
+
+ + {isMulti ? '☑' : '●'} + +
+
+ {answer} +
+
+ (custom answer) +
+
+
+ ))} + + ) +} - {q.question ? ( -
- {q.question} -
- ) : null} +function renderFreeformAnswers( + answers: Record, + questionIdx: number +): ReactNode { + const questionAnswers = answers[String(questionIdx)] + if (!questionAnswers || !Array.isArray(questionAnswers)) return null - {q.options.length > 0 ? ( -
- {q.options.map((opt, optIdx) => ( -
-
- {opt.label} -
- {opt.description ? ( -
- {opt.description} -
- ) : null} -
- ))} + const cleaned = questionAnswers.map(a => a.trim()).filter(a => a.length > 0) + if (cleaned.length === 0) return null + + return ( +
+ {cleaned.map((answer, i) => ( +
+
+ +
+
+ {answer} +
- ) : null} +
))}
) } + +export function AskUserQuestionView(props: ToolViewProps) { + const parsed = parseAskUserQuestionInput(props.block.tool.input) + const questions = parsed.questions + const answers = props.block.tool.permission?.answers ?? undefined + const hasAnswers = answers && Object.keys(answers).length > 0 + + // When questions array is empty but answers exist (fallback path), + // render the answers directly + if (questions.length === 0) { + if (hasAnswers && answers) { + return renderFreeformAnswers(answers, 0) + } + return null + } + + return ( +
+ {questions.map((q, idx) => { + const isMulti = q.multiSelect + + return ( +
+ {q.question ? ( +
+ {q.question} +
+ ) : null} + + {q.options.length > 0 ? ( +
+ {q.options.map((opt, optIdx) => { + const isSelected = isAnswerSelected(answers, idx, opt.label) + return ( +
+
+ {hasAnswers && ( + + {getSelectionMark(isMulti, isSelected)} + + )} +
+
+ {opt.label} +
+ {opt.description ? ( +
+ {opt.description} +
+ ) : null} +
+
+
+ ) + })} + + {hasAnswers && renderOtherAnswers(answers, idx, q.options, isMulti)} +
+ ) : hasAnswers && answers ? ( + // Freeform question (no options) - show the answer directly + renderFreeformAnswers(answers, idx) + ) : null} +
+ ) + })} +
+ ) +} diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index adc71011..525b9611 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -1,7 +1,6 @@ import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/views/_all' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' -import { extractAskUserQuestionQuestionsInfo } from '@/components/ToolCard/askUserQuestion' import { basename, resolveDisplayPath } from '@/components/ToolCard/path' function isObject(value: unknown): value is Record { @@ -196,55 +195,15 @@ function isProbablyMarkdownList(text: string): boolean { const AskUserQuestionResultView: ToolViewComponent = (props: ToolViewProps) => { const answers = props.block.tool.permission?.answers ?? null - if (!answers || Object.keys(answers).length === 0) { - return + + // If answers exist, AskUserQuestionView already shows them with highlighting + // Return null to avoid duplicate display + if (answers && Object.keys(answers).length > 0) { + return null } - const questions = extractAskUserQuestionQuestionsInfo(props.block.tool.input) - const keys = Object.keys(answers).sort((a, b) => { - const aNum = Number.parseInt(a, 10) - const bNum = Number.parseInt(b, 10) - if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum - if (Number.isFinite(aNum)) return -1 - if (Number.isFinite(bNum)) return 1 - return a.localeCompare(b) - }) - - return ( -
- {keys.map((key) => { - const idx = Number.parseInt(key, 10) - const q = questions && Number.isFinite(idx) ? questions[idx] : null - const header = q?.header ?? (Number.isFinite(idx) ? `Question ${idx + 1}` : `Question ${key}`) - const values = answers[key] ?? [] - const cleaned = values.map((v) => String(v)).map((v) => v.trim()).filter((v) => v.length > 0) - - return ( -
-
- {header} -
- {q?.question ? ( -
- {q.question} -
- ) : null} - {cleaned.length > 0 ? ( -
    - {cleaned.map((v, i) => ( -
  • {v}
  • - ))} -
- ) : ( -
- (no answer) -
- )} -
- ) - })} -
- ) + // Fallback for tools without structured answers + return } const BashResultView: ToolViewComponent = (props: ToolViewProps) => {