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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 21:48:16 +08:00
parent 3193e741c7
commit 60f69c2062
4 changed files with 217 additions and 23 deletions

View File

@ -108,4 +108,156 @@ describe('AskUserQuestion', () => {
}, },
}) })
}) })
it('allows multiple selections when a question is marked multiSelect', () => {
render(
<AskUserQuestion
toolUseId="tool-1"
input={{
questions: [
{
question: 'Which tasks should run?',
multiSelect: true,
options: [
{ label: 'Lint' },
{ label: 'Tests' },
{ label: 'Build' },
],
},
],
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^Lint$/ }))
fireEvent.click(screen.getByRole('button', { name: /^Tests$/ }))
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, {
type: 'permission_response',
requestId: 'perm-1',
allowed: true,
updatedInput: {
questions: [
{
question: 'Which tasks should run?',
multiSelect: true,
options: [
{ label: 'Lint' },
{ label: 'Tests' },
{ label: 'Build' },
],
},
],
answers: {
'Which tasks should run?': 'Lint, Tests',
},
},
})
})
it('preserves multiSelect for single-question input shape', () => {
render(
<AskUserQuestion
toolUseId="tool-1"
input={{
question: 'Which tasks should run?',
multiSelect: true,
options: [
{ label: 'Lint' },
{ label: 'Tests' },
{ label: 'Build' },
],
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^Lint$/ }))
fireEvent.click(screen.getByRole('button', { name: /^Tests$/ }))
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, {
type: 'permission_response',
requestId: 'perm-1',
allowed: true,
updatedInput: {
question: 'Which tasks should run?',
multiSelect: true,
options: [
{ label: 'Lint' },
{ label: 'Tests' },
{ label: 'Build' },
],
answers: {
'Which tasks should run?': 'Lint, Tests',
},
},
})
})
it('responds to the provided session instead of the active tab', () => {
useTabStore.setState({
activeTabId: 'other-tab',
tabs: [
{ sessionId: 'other-tab', title: 'Other', type: 'session', status: 'idle' },
{ sessionId: 'target-tab', title: 'Target', type: 'session', status: 'idle' },
],
})
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
'target-tab': {
...state.sessions[ACTIVE_TAB]!,
pendingPermission: {
requestId: 'perm-target',
toolName: 'AskUserQuestion',
toolUseId: 'tool-target',
input: {
questions: [
{
question: 'Run tests?',
options: [{ label: 'No' }, { label: 'Yes' }],
},
],
},
},
},
},
}))
render(
<AskUserQuestion
sessionId="target-tab"
toolUseId="tool-target"
input={{
questions: [
{
question: 'Run tests?',
options: [{ label: 'No' }, { label: 'Yes' }],
},
],
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^Yes$/ }))
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(sendMock).toHaveBeenCalledWith('target-tab', {
type: 'permission_response',
requestId: 'perm-target',
allowed: true,
updatedInput: {
questions: [
{
question: 'Run tests?',
options: [{ label: 'No' }, { label: 'Yes' }],
},
],
answers: {
'Run tests?': 'Yes',
},
},
})
})
}) })

View File

@ -13,15 +13,19 @@ type Question = {
question: string question: string
header?: string header?: string
options?: QuestionOption[] options?: QuestionOption[]
multiSelect?: boolean
} }
type AskUserInput = { type AskUserInput = {
questions?: Question[] questions?: Question[]
question?: string question?: string
header?: string
options?: QuestionOption[] options?: QuestionOption[]
multiSelect?: boolean
} }
type Props = { type Props = {
sessionId?: string | null
toolUseId: string toolUseId: string
input: unknown input: unknown
result?: unknown result?: unknown
@ -41,21 +45,34 @@ function parseInput(input: unknown): Question[] {
// Shape 2: { question: "...", options: [...] } // Shape 2: { question: "...", options: [...] }
if (typeof obj.question === 'string') { if (typeof obj.question === 'string') {
return [{ question: obj.question, options: obj.options }] return [{
question: obj.question,
header: obj.header,
options: obj.options,
multiSelect: obj.multiSelect,
}]
} }
return [] return []
} }
export function AskUserQuestion({ toolUseId, input, result }: Props) { 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 { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId) const activeTabId = useTabStore((s) => s.activeTabId)
const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined) const targetSessionId = sessionId ?? activeTabId
const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined)
const t = useTranslation() const t = useTranslation()
const questions = parseInput(input) const questions = parseInput(input)
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {} const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const [activeTab, setActiveTab] = useState(0) const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<Record<number, string>>({}) const [selections, setSelections] = useState<QuestionSelections>({})
const [freeText, setFreeText] = useState('') const [freeText, setFreeText] = useState('')
const [hasSubmitted, setHasSubmitted] = useState(false) const [hasSubmitted, setHasSubmitted] = useState(false)
const composingRef = useRef(false) const composingRef = useRef(false)
@ -78,20 +95,36 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
.filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0) .filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0)
.join(', ') .join(', ')
} }
return freeText.trim() || Object.values(selections).join(', ') return freeText.trim() || questions
.map((question, index) => getSelectedAnswer(question, selections[index]))
.filter(Boolean)
.join('; ')
}, [freeText, questions, resultAnswers, selections]) }, [freeText, questions, resultAnswers, selections])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted
const handleSelect = (qIndex: number, label: string) => { const handleSelect = (qIndex: number, label: string) => {
if (submitted) return if (submitted) return
setSelections((prev) => { setSelections((prev) => {
// Toggle: deselect if already selected const question = questions[qIndex]
if (prev[qIndex] === label) { 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 } const next = { ...prev }
delete next[qIndex] delete next[qIndex]
return next return next
} }
return { ...prev, [qIndex]: label } return { ...prev, [qIndex]: [label] }
}) })
setFreeText('') setFreeText('')
} }
@ -101,25 +134,26 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
const parts: string[] = [] const parts: string[] = []
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
const selected = selections[i] const selected = getSelectedAnswer(questions[i]!, selections[i])
if (selected) parts.push(selected) if (selected) parts.push(selected)
} }
const response = freeText.trim() || parts.join('; ') || '' const response = freeText.trim() || parts.join('; ') || ''
if (!response) return if (!response) return
if (!activeTabId || !pendingRequest) return if (!targetSessionId || !pendingRequest) return
const answers = questions.reduce<Record<string, string>>((acc, question, index) => { const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
if (freeText.trim()) { if (freeText.trim()) {
acc[question.question] = freeText.trim() acc[question.question] = freeText.trim()
} else if (selections[index]) { } else {
acc[question.question] = selections[index]! const selected = getSelectedAnswer(question, selections[index])
if (selected) acc[question.question] = selected
} }
return acc return acc
}, {}) }, {})
setHasSubmitted(true) setHasSubmitted(true)
respondToPermission(activeTabId, pendingRequest.requestId, true, { respondToPermission(targetSessionId, pendingRequest.requestId, true, {
updatedInput: { updatedInput: {
...inputObject, ...inputObject,
answers, answers,
@ -128,7 +162,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
} }
// All questions must be answered (via selection or free text) to enable submit // All questions must be answered (via selection or free text) to enable submit
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selections[i] !== undefined) const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => (selections[i]?.length ?? 0) > 0)
const safeActiveTab = Math.min(activeTab, questions.length - 1) const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab] const activeQuestion = questions[safeActiveTab]
@ -168,7 +202,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto"> <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) => { {questions.map((q, i) => {
const isActive = safeActiveTab === i const isActive = safeActiveTab === i
const isAnswered = selections[i] !== undefined const isAnswered = (selections[i]?.length ?? 0) > 0
const tabLabel = q.header || `Q${i + 1}` const tabLabel = q.header || `Q${i + 1}`
return ( return (
<button <button
@ -203,7 +237,8 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
{activeQuestion.options && activeQuestion.options.length > 0 && ( {activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{activeQuestion.options.map((opt, optIndex) => { {activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[activeTab] === opt.label const isSelected = selections[safeActiveTab]?.includes(opt.label) ?? false
const isMultiSelect = activeQuestion.multiSelect === true
return ( return (
<button <button
key={optIndex} key={optIndex}
@ -216,12 +251,12 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
} ${submitted ? 'cursor-default' : ''}`} } ${submitted ? 'cursor-default' : ''}`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Check indicator */} {/* 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 ${ <div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]' ? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]'
: 'border-[var(--color-outline)]' : 'border-[var(--color-outline)]'
}`}> } ${isMultiSelect ? 'rounded-[var(--radius-xs)]' : 'rounded-full'}`}>
{isSelected && ( {isSelected && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"> <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" /> <polyline points="20 6 9 17 4 12" />

View File

@ -470,6 +470,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
/> />
) : ( ) : (
<MessageBlock <MessageBlock
sessionId={resolvedSessionId}
message={item.message} message={item.message}
activeThinkingId={activeThinkingId} activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications} agentTaskNotifications={agentTaskNotifications}
@ -550,11 +551,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
} }
export const MessageBlock = memo(function MessageBlock({ export const MessageBlock = memo(function MessageBlock({
sessionId,
message, message,
activeThinkingId, activeThinkingId,
agentTaskNotifications, agentTaskNotifications,
toolResult, toolResult,
}: { }: {
sessionId?: string | null
message: UIMessage message: UIMessage
activeThinkingId: string | null activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification> agentTaskNotifications: Record<string, AgentTaskNotification>
@ -578,6 +581,7 @@ export const MessageBlock = memo(function MessageBlock({
if (message.toolName === 'AskUserQuestion') { if (message.toolName === 'AskUserQuestion') {
return ( return (
<AskUserQuestion <AskUserQuestion
sessionId={sessionId}
toolUseId={message.toolUseId} toolUseId={message.toolUseId}
input={message.input} input={message.input}
result={toolResult?.content} result={toolResult?.content}
@ -607,6 +611,7 @@ export const MessageBlock = memo(function MessageBlock({
case 'permission_request': case 'permission_request':
return ( return (
<PermissionDialog <PermissionDialog
sessionId={sessionId}
requestId={message.requestId} requestId={message.requestId}
toolName={message.toolName} toolName={message.toolName}
input={message.input} input={message.input}

View File

@ -7,6 +7,7 @@ import { Button } from '../shared/Button'
import { DiffViewer } from './DiffViewer' import { DiffViewer } from './DiffViewer'
type Props = { type Props = {
sessionId?: string | null
requestId: string requestId: string
toolName: string toolName: string
input: unknown input: unknown
@ -111,10 +112,11 @@ function renderPermissionPreview(toolName: string, input: unknown) {
return null return null
} }
export function PermissionDialog({ requestId, toolName, input, description }: Props) { export function PermissionDialog({ sessionId, requestId, toolName, input, description }: Props) {
const { respondToPermission } = useChatStore() const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId) const activeTabId = useTabStore((s) => s.activeTabId)
const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined) const targetSessionId = sessionId ?? activeTabId
const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined)
const t = useTranslation() const t = useTranslation()
const isPending = pendingPermission?.requestId === requestId const isPending = pendingPermission?.requestId === requestId
const [showRaw, setShowRaw] = useState(false) const [showRaw, setShowRaw] = useState(false)
@ -227,7 +229,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true)} onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true)}
icon={ icon={
<span className="material-symbols-outlined text-[14px]">check</span> <span className="material-symbols-outlined text-[14px]">check</span>
} }
@ -237,7 +239,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true, { rule: 'always' })} onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true, { rule: 'always' })}
icon={ icon={
<span className="material-symbols-outlined text-[14px]">verified</span> <span className="material-symbols-outlined text-[14px]">verified</span>
} }
@ -248,7 +250,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<Button <Button
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, false)} onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, false)}
icon={ icon={
<span className="material-symbols-outlined text-[14px]">close</span> <span className="material-symbols-outlined text-[14px]">close</span>
} }