feat(web): improve AskUserQuestion tool card UX with better answers visualization

- 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
This commit is contained in:
weishu
2025-12-20 18:04:40 +08:00
parent 3bac5c7bc6
commit ce5bdc8bed
5 changed files with 246 additions and 113 deletions
@@ -292,18 +292,18 @@ export function AskUserQuestionFooter(props: {
<div className="mt-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{questions[clampedStep]?.header ? (
{questions[clampedStep]?.header ? (
<div className="flex items-center gap-2">
<Badge variant="default">
{questions[clampedStep].header}
</Badge>
) : null}
<Badge variant="default">
{mode === 'multi' ? 'Multi' : 'Single'}
</Badge>
</div>
</div>
) : null}
{questions[clampedStep]?.question ? (
<div className="mt-2 text-sm text-[var(--app-fg)] break-words">
<div className={cn(
"text-sm text-[var(--app-fg)] break-words",
questions[clampedStep]?.header ? "mt-2" : ""
)}>
{questions[clampedStep].question}
</div>
) : null}
+26 -14
View File
@@ -386,20 +386,32 @@ function ToolCardInner(props: ToolCardProps) {
<DialogHeader>
<DialogTitle>{toolTitle}</DialogTitle>
</DialogHeader>
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Input</div>
{FullToolView ? (
<FullToolView block={props.block} metadata={props.metadata} />
) : (
renderToolInput(props.block)
)}
</div>
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Result</div>
<ResultToolView block={props.block} metadata={props.metadata} />
</div>
</div>
{(() => {
const isAskUserQuestionWithAnswers = isAskUserQuestion
&& permission?.answers
&& Object.keys(permission.answers).length > 0
return (
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">
{isAskUserQuestionWithAnswers ? 'Questions & Answers' : 'Input'}
</div>
{FullToolView ? (
<FullToolView block={props.block} metadata={props.metadata} />
) : (
renderToolInput(props.block)
)}
</div>
{!isAskUserQuestionWithAnswers && (
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Result</div>
<ResultToolView block={props.block} metadata={props.metadata} />
</div>
)}
</div>
)
})()}
</DialogContent>
</Dialog>
</CardHeader>
+40 -8
View File
@@ -299,13 +299,29 @@ export const knownTools: Record<string, {
AskUserQuestion: {
icon: () => <QuestionIcon className={DEFAULT_ICON_CLASS} />,
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<string, {
ask_user_question: {
icon: () => <QuestionIcon className={DEFAULT_ICON_CLASS} />,
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
@@ -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<string, string[]> | 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<string, string[]>,
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 (
<div className="flex flex-col gap-3">
{questions.map((q, idx) => (
<div key={idx} className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
<div className="flex items-center gap-2">
<Badge variant="default">
{q.header ?? `Question ${idx + 1}`}
</Badge>
<Badge variant="default">
{q.multiSelect ? 'Multi' : 'Single'}
</Badge>
<>
{otherAnswers.map((answer, i) => (
<div
key={`other-${i}`}
className="rounded-md border border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 px-2 py-2"
>
<div className="flex items-start gap-2">
<span className="shrink-0 text-sm text-emerald-600">
{isMulti ? '' : ''}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-emerald-700 dark:text-emerald-300 font-medium break-words">
{answer}
</div>
<div className="mt-0.5 text-xs text-[var(--app-hint)]">
(custom answer)
</div>
</div>
</div>
</div>
))}
</>
)
}
{q.question ? (
<div className="mt-2 text-sm text-[var(--app-fg)] break-words">
{q.question}
</div>
) : null}
function renderFreeformAnswers(
answers: Record<string, string[]>,
questionIdx: number
): ReactNode {
const questionAnswers = answers[String(questionIdx)]
if (!questionAnswers || !Array.isArray(questionAnswers)) return null
{q.options.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
{q.options.map((opt, optIdx) => (
<div key={optIdx} className="rounded-md border border-[var(--app-border)] px-2 py-2">
<div className="text-sm text-[var(--app-fg)] break-words">
{opt.label}
</div>
{opt.description ? (
<div className="mt-0.5 text-xs text-[var(--app-hint)] break-words">
{opt.description}
</div>
) : null}
</div>
))}
const cleaned = questionAnswers.map(a => a.trim()).filter(a => a.length > 0)
if (cleaned.length === 0) return null
return (
<div className="mt-3 flex flex-col gap-1">
{cleaned.map((answer, i) => (
<div
key={i}
className="rounded-md border border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 px-2 py-2"
>
<div className="flex items-start gap-2">
<span className="shrink-0 text-sm text-emerald-600"></span>
<div className="min-w-0 flex-1">
<div className="text-sm text-emerald-700 dark:text-emerald-300 font-medium break-words">
{answer}
</div>
</div>
) : null}
</div>
</div>
))}
</div>
)
}
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 (
<div className="flex flex-col gap-3">
{questions.map((q, idx) => {
const isMulti = q.multiSelect
return (
<div key={idx} className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
{q.question ? (
<div className="text-sm text-[var(--app-fg)] break-words">
{q.question}
</div>
) : null}
{q.options.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
{q.options.map((opt, optIdx) => {
const isSelected = isAnswerSelected(answers, idx, opt.label)
return (
<div
key={optIdx}
className={cn(
"rounded-md border px-2 py-2",
isSelected
? "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30"
: "border-[var(--app-border)]"
)}
>
<div className="flex items-start gap-2">
{hasAnswers && (
<span className={cn(
"shrink-0 text-sm",
isSelected
? "text-emerald-600"
: "text-[var(--app-hint)]"
)}>
{getSelectionMark(isMulti, isSelected)}
</span>
)}
<div className="min-w-0 flex-1">
<div className={cn(
"text-sm break-words",
isSelected
? "text-emerald-700 dark:text-emerald-300 font-medium"
: "text-[var(--app-fg)]"
)}>
{opt.label}
</div>
{opt.description ? (
<div className="mt-0.5 text-xs text-[var(--app-hint)] break-words">
{opt.description}
</div>
) : null}
</div>
</div>
</div>
)
})}
{hasAnswers && renderOtherAnswers(answers, idx, q.options, isMulti)}
</div>
) : hasAnswers && answers ? (
// Freeform question (no options) - show the answer directly
renderFreeformAnswers(answers, idx)
) : null}
</div>
)
})}
</div>
)
}
+7 -48
View File
@@ -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<string, unknown> {
@@ -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 <MarkdownResultView {...props} />
// 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 (
<div className="flex flex-col gap-2">
{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 (
<div key={key} className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2">
<div className="text-xs font-medium text-[var(--app-hint)] break-words">
{header}
</div>
{q?.question ? (
<div className="mt-1 text-xs text-[var(--app-hint)] break-words">
{q.question}
</div>
) : null}
{cleaned.length > 0 ? (
<ul className="mt-2 list-disc pl-5 text-sm text-[var(--app-fg)]">
{cleaned.map((v, i) => (
<li key={i} className="break-words">{v}</li>
))}
</ul>
) : (
<div className="mt-2 text-sm text-[var(--app-hint)]">
(no answer)
</div>
)}
</div>
)
})}
</div>
)
// Fallback for tools without structured answers
return <MarkdownResultView {...props} />
}
const BashResultView: ToolViewComponent = (props: ToolViewProps) => {