mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
commit
184323ba02
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user