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
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
@ -41,21 +45,34 @@ function parseInput(input: unknown): Question[] {
// Shape 2: { question: "...", options: [...] }
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 []
}
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 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 questions = parseInput(input)
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<Record<number, string>>({})
const [selections, setSelections] = useState<QuestionSelections>({})
const [freeText, setFreeText] = useState('')
const [hasSubmitted, setHasSubmitted] = useState(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)
.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])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
setSelections((prev) => {
// Toggle: deselect if already selected
if (prev[qIndex] === label) {
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 }
return { ...prev, [qIndex]: [label] }
})
setFreeText('')
}
@ -101,25 +134,26 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const selected = selections[i]
const selected = getSelectedAnswer(questions[i]!, selections[i])
if (selected) parts.push(selected)
}
const response = freeText.trim() || parts.join('; ') || ''
if (!response) return
if (!activeTabId || !pendingRequest) return
if (!targetSessionId || !pendingRequest) return
const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
if (freeText.trim()) {
acc[question.question] = freeText.trim()
} else if (selections[index]) {
acc[question.question] = selections[index]!
} else {
const selected = getSelectedAnswer(question, selections[index])
if (selected) acc[question.question] = selected
}
return acc
}, {})
setHasSubmitted(true)
respondToPermission(activeTabId, pendingRequest.requestId, true, {
respondToPermission(targetSessionId, pendingRequest.requestId, true, {
updatedInput: {
...inputObject,
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
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 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">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = selections[i] !== undefined
const isAnswered = (selections[i]?.length ?? 0) > 0
const tabLabel = q.header || `Q${i + 1}`
return (
<button
@ -203,7 +237,8 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="space-y-2 mb-3">
{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 (
<button
key={optIndex}
@ -216,12 +251,12 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
} ${submitted ? 'cursor-default' : ''}`}
>
<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 ${
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" />

View File

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

View File

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