Merge AskUserQuestion multi-select fix into main

The detached desktop bugfix worktree contains the AskUserQuestion multi-select handling change, while local main already carries separate local commits. Merge instead of cherry-picking so main records the worktree integration point without dropping local-only history.

Constraint: Local main and the detached worktree had diverged
Rejected: Reset main to the worktree HEAD | would discard local main commits
Confidence: high
Scope-risk: narrow
Directive: Keep AskUserQuestion selection mode controlled by the explicit multiSelect marker
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
Not-tested: Full root bun run verify after merge
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 21:49:59 +08:00
commit 184323ba02
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>
} }