cc-haha/desktop/src/components/chat/AskUserQuestion.tsx
程序员阿江(Relakkes) 60f69c2062 fix: respect AskUserQuestion multi-select markers
Desktop question prompts can carry CLI-provided multiSelect flags, so the answer UI now stores selections per question as arrays and only allows multiple checked options when that flag is true. The message renderer also passes the owning session id into permission UI so pending responses go back to the session that produced the prompt instead of whichever tab is active.

Constraint: AskUserQuestion input may arrive as either questions[] or single question shape
Rejected: Make all option questions multi-select | single-select prompts must remain constrained unless the CLI marks them multiSelect
Confidence: high
Scope-risk: narrow
Directive: Do not infer multi-select from option count; honor the explicit multiSelect flag
Tested: cd desktop && bun run test -- src/components/chat/AskUserQuestion.test.tsx src/components/chat/chatBlocks.test.tsx src/components/chat/MessageList.test.tsx
Tested: bun run check:desktop
Tested: Chrome/CDP smoke against /tmp/cc-haha-ask-user-real verified multi-select and single-select behavior
Not-tested: Chrome extension RPC path, which timed out in this environment despite native host checks passing
2026-05-09 21:48:16 +08:00

342 lines
13 KiB
TypeScript

import { useMemo, useRef, useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
type QuestionOption = {
label: string
description?: string
}
type Question = {
question: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
type AskUserInput = {
questions?: Question[]
question?: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
type Props = {
sessionId?: string | null
toolUseId: string
input: unknown
result?: unknown
}
/**
* Parse the AskUserQuestion input which may come in different shapes.
*/
function parseInput(input: unknown): Question[] {
if (!input || typeof input !== 'object') return []
const obj = input as AskUserInput
// Shape 1: { questions: [...] }
if (Array.isArray(obj.questions)) {
return obj.questions
}
// Shape 2: { question: "...", options: [...] }
if (typeof obj.question === 'string') {
return [{
question: obj.question,
header: obj.header,
options: obj.options,
multiSelect: obj.multiSelect,
}]
}
return []
}
type QuestionSelections = Record<number, string[]>
function getSelectedAnswer(question: Question, selected: string[] | undefined) {
if (!selected || selected.length === 0) return ''
return question.multiSelect ? selected.join(', ') : selected[0] ?? ''
}
export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) {
const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const targetSessionId = sessionId ?? activeTabId
const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined)
const t = useTranslation()
const questions = parseInput(input)
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<QuestionSelections>({})
const [freeText, setFreeText] = useState('')
const [hasSubmitted, setHasSubmitted] = useState(false)
const composingRef = useRef(false)
if (questions.length === 0) return null
const resultAnswers = useMemo(() => {
if (!result || typeof result !== 'object') return {}
const answers = (result as { answers?: unknown }).answers
return answers && typeof answers === 'object'
? answers as Record<string, string>
: {}
}, [result])
const pendingRequest = pendingPermission?.toolUseId === toolUseId ? pendingPermission : null
const answeredText = useMemo(() => {
if (Object.keys(resultAnswers).length > 0) {
return questions
.map((question) => resultAnswers[question.question])
.filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0)
.join(', ')
}
return freeText.trim() || questions
.map((question, index) => getSelectedAnswer(question, selections[index]))
.filter(Boolean)
.join('; ')
}, [freeText, questions, resultAnswers, selections])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
setSelections((prev) => {
const question = questions[qIndex]
const selected = prev[qIndex] ?? []
if (question?.multiSelect) {
const nextSelected = selected.includes(label)
? selected.filter((value) => value !== label)
: [...selected, label]
const next = { ...prev }
if (nextSelected.length > 0) {
next[qIndex] = nextSelected
} else {
delete next[qIndex]
}
return next
}
if (selected[0] === label) {
const next = { ...prev }
delete next[qIndex]
return next
}
return { ...prev, [qIndex]: [label] }
})
setFreeText('')
}
const handleSubmit = () => {
if (submitted) return
const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const selected = getSelectedAnswer(questions[i]!, selections[i])
if (selected) parts.push(selected)
}
const response = freeText.trim() || parts.join('; ') || ''
if (!response) return
if (!targetSessionId || !pendingRequest) return
const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
if (freeText.trim()) {
acc[question.question] = freeText.trim()
} else {
const selected = getSelectedAnswer(question, selections[index])
if (selected) acc[question.question] = selected
}
return acc
}, {})
setHasSubmitted(true)
respondToPermission(targetSessionId, pendingRequest.requestId, true, {
updatedInput: {
...inputObject,
answers,
},
})
}
// All questions must be answered (via selection or free text) to enable submit
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => (selections[i]?.length ?? 0) > 0)
const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]
if (!activeQuestion) return null
return (
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
}`}>
{/* Header */}
<div className={`flex items-center gap-3 px-4 py-3 ${
submitted
? 'bg-[var(--color-surface-container-low)]'
: 'bg-[var(--color-surface-container)]'
}`}>
<div className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<span className="material-symbols-outlined text-[18px] text-[var(--color-secondary)]">
help
</span>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('question.needsInput')}
</span>
{submitted && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
{t('question.answered')}
</span>
)}
</div>
</div>
{/* Question tabs — horizontal tab bar (only show when multiple questions) */}
{questions.length > 1 && (
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = (selections[i]?.length ?? 0) > 0
const tabLabel = q.header || `Q${i + 1}`
return (
<button
key={i}
onClick={() => setActiveTab(i)}
className={`relative flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium whitespace-nowrap transition-colors ${
isActive
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{isAnswered && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
)}
{tabLabel}
{isActive && (
<div className="absolute bottom-0 left-2 right-2 h-[2px] bg-[var(--color-secondary)] rounded-t" />
)}
</button>
)
})}
</div>
)}
{/* Active question content */}
<div className="px-4 py-3">
<p className="text-sm font-medium text-[var(--color-text-primary)] mb-3">
{activeQuestion.question}
</p>
{/* Option cards */}
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="space-y-2 mb-3">
{activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[safeActiveTab]?.includes(opt.label) ?? false
const isMultiSelect = activeQuestion.multiSelect === true
return (
<button
key={optIndex}
onClick={() => handleSelect(safeActiveTab, opt.label)}
disabled={submitted}
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]/8 ring-1 ring-[var(--color-secondary)]/30'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] hover:border-[var(--color-outline-variant)] hover:bg-[var(--color-surface-container-low)]'
} ${submitted ? 'cursor-default' : ''}`}
>
<div className="flex items-start gap-3">
{/* Selection indicator */}
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]'
: 'border-[var(--color-outline)]'
} ${isMultiSelect ? 'rounded-[var(--radius-xs)]' : 'rounded-full'}`}>
{isSelected && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${
isSelected
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-primary)]'
}`}>
{opt.label}
</span>
{opt.description && (
<p className="text-xs text-[var(--color-text-secondary)] mt-0.5">
{opt.description}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
)}
{/* Free text input */}
{!submitted && (
<div>
<label className="text-xs text-[var(--color-text-tertiary)] mb-1.5 block">
{t('question.customResponse')}
</label>
<input
type="text"
value={freeText}
onChange={(e) => {
setFreeText(e.target.value)
if (e.target.value.trim()) setSelections({})
}}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (composingRef.current || e.nativeEvent.isComposing || e.keyCode === 229) return
if (e.key === 'Enter' && allAnswered) handleSubmit()
}}
placeholder={t('question.typePlaceholder')}
className="w-full px-3 py-2 text-sm bg-[var(--color-surface)] border border-[var(--color-outline-variant)]/40 rounded-[var(--radius-md)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-secondary)] focus:ring-1 focus:ring-[var(--color-secondary)]/30"
/>
</div>
)}
{/* Submitted answer display */}
{submitted && (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<span>
{t('question.answeredPrefix')}<strong>{answeredText}</strong>
</span>
</div>
)}
</div>
{/* Submit button */}
{!submitted && (
<div className="flex items-center gap-2 px-4 py-3 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)]">
<Button
variant="primary"
size="sm"
disabled={!allAnswered || !pendingRequest}
onClick={handleSubmit}
icon={
<span className="material-symbols-outlined text-[14px]">send</span>
}
>
{t('question.submit')}
</Button>
</div>
)}
</div>
)
}