mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">
|
||||
{questions[clampedStep].header}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="default">
|
||||
{mode === 'multi' ? 'Multi' : 'Single'}
|
||||
</Badge>
|
||||
</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}
|
||||
|
||||
@@ -386,20 +386,32 @@ function ToolCardInner(props: ToolCardProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{toolTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{(() => {
|
||||
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)]">Input</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>
|
||||
|
||||
@@ -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,36 +1,157 @@
|
||||
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'
|
||||
|
||||
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 (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderFreeformAnswers(
|
||||
answers: Record<string, string[]>,
|
||||
questionIdx: number
|
||||
): ReactNode {
|
||||
const questionAnswers = answers[String(questionIdx)]
|
||||
if (!questionAnswers || !Array.isArray(questionAnswers)) return null
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AskUserQuestionView(props: ToolViewProps) {
|
||||
const parsed = parseAskUserQuestionInput(props.block.tool.input)
|
||||
const questions = parsed.questions
|
||||
if (questions.length === 0) return null
|
||||
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) => (
|
||||
<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>
|
||||
</div>
|
||||
{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="mt-2 text-sm text-[var(--app-fg)] break-words">
|
||||
<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) => (
|
||||
<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">
|
||||
{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 ? (
|
||||
@@ -39,11 +160,20 @@ export function AskUserQuestionView(props: ToolViewProps) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user