diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 96f7bb821a7cd..5d87894a65fdf 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -3200,6 +3200,7 @@ def _execute(next_args: dict) -> Any: question=next_args.get("question", ""), choices=next_args.get("choices"), multi_select=next_args.get("multi_select", False), + questions=next_args.get("questions"), callback=agent.clarify_callback, ), next_args, diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 381f1000e9bbc..4fedb6dc7d193 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -2120,6 +2120,7 @@ def _execute(next_args: dict) -> Any: question=next_args.get("question", ""), choices=next_args.get("choices"), multi_select=next_args.get("multi_select", False), + questions=next_args.get("questions"), callback=agent.clarify_callback, ) function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware( diff --git a/apps/desktop/e2e/batch-clarify.spec.ts b/apps/desktop/e2e/batch-clarify.spec.ts new file mode 100644 index 0000000000000..8658bd3b1b5ce --- /dev/null +++ b/apps/desktop/e2e/batch-clarify.spec.ts @@ -0,0 +1,86 @@ +/** + * E2E batch clarify test — the multi-question clarify card must mount ONCE. + * + * Regression coverage for the duplicated-card bug: `tool.start` carries the + * model's tool_call_id while `clarify.request` carries a gateway-generated + * request_id. A batch payload has no top-level `question`, so the two rows + * only merge when the correlation key comes from the question list + * (`batchClarifyMatchValue` in lib/chat-messages.ts). Before that fix this + * exact flow rendered two identical interactive cards. + * + * The flow runs the real chain: composer → gateway → agent → clarify tool → + * clarify.request event → renderer, against the mock inference server. + */ + +import { expect, test } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from './mock-server' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('batch clarify card', () => { + test('renders exactly one card and completes via per-question locks', async () => { + const page = fixture!.page + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + + await composer.click() + await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + // The live batch form marks itself with data-clarify-batch=. + const batchCard = page.locator('form[data-clarify-batch]') + await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 }) + + // THE regression assertion: one card, not two. + await expect(batchCard).toHaveCount(1) + await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length)) + + // Both questions render inside the single card. + for (const entry of BATCH_CLARIFY_QUESTIONS) { + await expect(batchCard.getByText(entry.question)).toHaveCount(1) + } + + // Each question text also appears exactly once in the whole transcript — + // catches a duplicate that mounts outside a form[data-clarify-batch]. + for (const entry of BATCH_CLARIFY_QUESTIONS) { + await expect(page.getByText(entry.question)).toHaveCount(1) + } + + // Answer both questions: stage picks locally (no server traffic yet). + const confirmButton = batchCard.locator('button[type="submit"]') + await expect(confirmButton).toContainText('Confirm and continue') + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Coffee/ }).click() + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Morning/ }).click() + await expect(confirmButton).toBeEnabled() + + // ONE confirm submits the whole batch. + await confirmButton.click() + + // The settled card lists both questions with their locked answers. + const settled = page.locator('[data-clarify-settled]') + await settled.waitFor({ state: 'visible', timeout: 30_000 }) + await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible() + await expect(settled.getByText('Coffee', { exact: true })).toBeVisible() + await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible() + await expect(settled.getByText('Morning', { exact: true })).toBeVisible() + + // And still no duplicate live card lingering after settle. + await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0) + }) +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index 8de1af8aa4f98..37b5d15af7646 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -339,6 +339,40 @@ const BLOCKING_CLARIFY_TURN: ScriptedTurn = { toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }], } +/** + * A marker that makes the mock emit a blocking BATCH clarify tool call + * (multi-question form). Regression coverage for the duplicated-card bug: + * the tool.start row and the clarify.request row carry different ids and a + * batch payload has no top-level question, so the correlation key must come + * from the question list or the card mounts twice. + */ +export const BATCH_CLARIFY_TRIGGER = 'E2E_BATCH_CLARIFY_TRIGGER' +export const BATCH_CLARIFY_QUESTIONS = [ + { question: 'Pick a batch drink?', choices: ['Coffee', 'Tea'] }, + { question: 'Pick a batch time?', choices: ['Morning', 'Night'] }, +] + +const BATCH_CLARIFY_TURN: ScriptedTurn = { + text: '', + toolCalls: [{ name: 'clarify', args: { questions: BATCH_CLARIFY_QUESTIONS } }], +} + +function includesBatchClarifyTrigger(value: unknown): boolean { + if (typeof value === 'string') { + return value.includes(BATCH_CLARIFY_TRIGGER) + } + + if (Array.isArray(value)) { + return value.some(includesBatchClarifyTrigger) + } + + if (value && typeof value === 'object') { + return Object.values(value).some(includesBatchClarifyTrigger) + } + + return false +} + function includesBlockingClarifyTrigger(value: unknown): boolean { if (typeof value === 'string') { return value.includes(BLOCKING_CLARIFY_TRIGGER) @@ -484,6 +518,24 @@ export function startMockServer(options: MockServerOptions = {}): Promise message?.role === 'tool') + + if (!hasToolResult) { + if (stream) { + streamScriptedTurn(res, model, BATCH_CLARIFY_TURN) + } else { + nonStreamingScriptedTurn(res, model, BATCH_CLARIFY_TURN) + } + return + } + } + if (includesBlockingClarifyTrigger(parsed.messages)) { if (stream) { streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx index 306c460a5607e..b64171cc7ec25 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx @@ -169,4 +169,45 @@ describe('clarify.request stream hydration', () => { expect(clarifyParts()).toHaveLength(1) }) + + it('merges a BATCH tool.start row with its clarify.request (no top-level question)', async () => { + await mountStream() + + // The batch shape: tool args carry `questions`, no top-level `question`. + // The correlation key must come from the question list, or the two ids + // mount two cards (the duplicate seen in the field). + toolStart({ + args: { questions: [{ question: 'Drink?' }, { question: 'Productive when?' }] }, + name: 'clarify', + tool_id: 'call-batch' + }) + clarifyRequest({ + questions: [ + { qid: 'q0', question: 'Drink?' }, + { qid: 'q1', question: 'Productive when?' } + ], + request_id: 'req-batch' + }) + + expect(clarifyParts()).toHaveLength(1) + }) + + it('does not duplicate when the batch clarify.request arrives before tool.start', async () => { + await mountStream() + + clarifyRequest({ + questions: [ + { qid: 'q0', question: 'Drink?' }, + { qid: 'q1', question: 'Productive when?' } + ], + request_id: 'req-batch-2' + }) + toolStart({ + args: { questions: [{ question: 'Drink?' }, { question: 'Productive when?' }] }, + name: 'clarify', + tool_id: 'call-batch-2' + }) + + expect(clarifyParts()).toHaveLength(1) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 8ce770e3b281a..a4d00bb0a7673 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -25,7 +25,7 @@ import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' -import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' +import { clearClarifyRequest, normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' import { $gateway, activeGatewayConnectionId } from '@/store/gateway' @@ -1165,8 +1165,65 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const rawChoices = payload?.choices const choices = normalizeChoices(rawChoices) const multiSelect = payload?.multi_select === true + // Batch (multi-question) clarify: `questions` replaces question/choices + // on the wire. `answers` rides along only on reconnect replay, carrying + // the per-question locks the server already accepted. + const questions = normalizeQuestions(payload?.questions) + const lockedAnswers = + typeof payload?.answers === 'object' && payload?.answers !== null + ? Object.fromEntries( + Object.entries(payload.answers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : undefined + + if (requestId && questions.length > 0) { + setClarifyRequest({ + choices: null, + lockedAnswers, + multiSelect: false, + question: '', + questions, + requestId, + sessionId: sessionId ?? null + }) + + if (sessionId) { + // Same hydration-race guard as the single-question path below: the + // form mounts from the tool row, so upsert a stable one keyed by + // the request id in case tool.start was missed. + upsertToolCall( + sessionId, + { + args: { + questions: questions.map(q => ({ + choices: q.choices ?? undefined, + multi_select: q.multiSelect || undefined, + question: q.question + })) + }, + name: 'clarify', + tool_id: requestId + }, + 'running', + event.type, + occurredAt + ) + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - if (requestId && question) { + if (sessionId === activeSessionIdRef.current) { + requestScrollToBottom() + } + } + + dispatchNativeNotification({ + body: questions.map(q => q.question).join(' · '), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } else if (requestId && question) { if (rawChoices != null && choices.length === 0) { warnDroppedChoices('gateway', question, rawChoices) } diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index a0d616633cdf9..0da12eba13186 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -9,7 +9,7 @@ import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { $activeSessionId } from '@/store/session' -import { ClarifyTool, readClarifyResult } from './clarify-tool' +import { ClarifyTool, readClarifyBatchResult, readClarifyResult } from './clarify-tool' // The live pending card only renders while its message is running. Force that so // keyboard-navigation tests can exercise ClarifyToolPending directly. @@ -448,3 +448,189 @@ describe('ClarifyTool pending marker', () => { expect(document.querySelector('[data-clarify-choices]')).toBeNull() }) }) + +// ─── Batch (multi-question) clarify ───────────────────────────────────────── + +function batchArgs(): { questions: { question: string; choices?: string[] }[] } { + return { + questions: [ + { choices: ['red', 'blue'], question: 'Color?' }, + { question: 'Name?' } + ] + } +} + +function liveBatchProps(): ToolCallMessagePartProps { + const args = batchArgs() + + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result: undefined, + resume: vi.fn(), + status: { type: 'running' }, + toolCallId: 'clarify-batch', + toolName: 'clarify', + type: 'tool-call' + } +} + +function renderLiveBatch(lockedAnswers?: Record) { + const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] }) + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + setClarifyRequest({ + choices: null, + lockedAnswers, + multiSelect: false, + question: '', + questions: [ + { choices: ['red', 'blue'], multiSelect: false, qid: 'q0', question: 'Color?' }, + { choices: null, multiSelect: false, qid: 'q1', question: 'Name?' } + ], + requestId: 'request-batch', + sessionId: 'session-1' + }) + renderClarify() + + return request +} + +describe('readClarifyBatchResult', () => { + it('parses responses with string and list answers plus timed_out', () => { + const parsed = readClarifyBatchResult( + JSON.stringify({ + responses: [ + { question: 'Color?', user_response: 'red' }, + { question: 'Tools?', user_response: ['a', 'b'] }, + { question: 'Name?', user_response: '' } + ], + timed_out: true + }) + ) + + expect(parsed.timedOut).toBe(true) + expect(parsed.responses).toHaveLength(3) + expect(parsed.responses[1]?.answer).toEqual(['a', 'b']) + expect(parsed.responses[2]?.answer).toBe('') + }) + + it('returns empty responses for single-question payloads', () => { + expect(readClarifyBatchResult({ question: 'Q?', user_response: 'a' }).responses).toEqual([]) + }) +}) + +describe('ClarifyTool batch card', () => { + it('renders every question at once', () => { + renderLiveBatch() + + expect(screen.getByText('Color?')).toBeTruthy() + expect(screen.getByText('Name?')).toBeTruthy() + expect(screen.getByText('0 of 2 answered')).toBeTruthy() + }) + + it('stages locally and keeps the single confirm disabled until all answered', async () => { + const request = renderLiveBatch() + const confirm = screen.getByRole('button', { name: /Confirm and continue/ }) + + expect((confirm as HTMLButtonElement).disabled).toBe(true) + + // Staging a pick sends NOTHING to the server. + fireEvent.click(screen.getByRole('button', { name: /red/ })) + expect(screen.getByText('1 of 2 answered')).toBeTruthy() + expect(request).not.toHaveBeenCalled() + expect((confirm as HTMLButtonElement).disabled).toBe(true) + + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + expect(screen.getByText('2 of 2 answered')).toBeTruthy() + expect(request).not.toHaveBeenCalled() + expect((confirm as HTMLButtonElement).disabled).toBe(false) + }) + + it('confirm sends every per-question lock in order and completes the batch', async () => { + const request = renderLiveBatch() + + fireEvent.click(screen.getByRole('button', { name: /red/ })) + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + fireEvent.submit(document.querySelector('form') as HTMLFormElement) + + await waitFor(() => { + expect(request).toHaveBeenCalledTimes(2) + }) + expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', { + answer: 'red', + question_id: 'q0', + request_id: 'request-batch' + }) + expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', { + answer: 'packet', + question_id: 'q1', + request_id: 'request-batch' + }) + }) + + it('a staged answer stays editable before confirm', async () => { + const request = renderLiveBatch() + + fireEvent.click(screen.getByRole('button', { name: /red/ })) + fireEvent.click(screen.getByRole('button', { name: /blue/ })) + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + fireEvent.submit(document.querySelector('form') as HTMLFormElement) + + await waitFor(() => { + expect(request).toHaveBeenCalledTimes(2) + }) + // The re-pick won: blue, not red. + expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', { + answer: 'blue', + question_id: 'q0', + request_id: 'request-batch' + }) + }) + + it('pre-stages replayed locked answers from a reconnect', () => { + renderLiveBatch({ q0: 'red' }) + + // The replayed answer counts as staged: one question left to answer. + expect(screen.getByText('1 of 2 answered')).toBeTruthy() + }) + + it('Skip cancels the whole batch without a question_id', async () => { + const request = renderLiveBatch() + + fireEvent.click(screen.getByRole('button', { name: 'Skip' })) + + await waitFor(() => { + expect(request).toHaveBeenCalledWith('clarify.respond', { + answer: '', + request_id: 'request-batch' + }) + }) + }) + + it('renders the settled batch with all questions and answers', () => { + renderClarify( + + ) + + expect(screen.getByText('Color?')).toBeTruthy() + expect(screen.getByText('red')).toBeTruthy() + expect(screen.getByText('Name?')).toBeTruthy() + expect(screen.getByText('Skipped')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 27dd58fa5375c..b7f0fabd97c8c 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -27,6 +27,8 @@ import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' import { bareChoice, + type ClarifyQuestion, + type ClarifyRequest, clearClarifyRequest, normalizeChoices, RECOMMENDED_LABEL, @@ -43,6 +45,7 @@ interface ClarifyArgs { question?: string choices?: string[] | null multiSelect?: boolean + questions?: { question: string; choices?: string[] | null; multiSelect?: boolean }[] } interface ClarifyResult { @@ -72,13 +75,74 @@ function readClarifyArgs(args: unknown): ClarifyArgs { warnDroppedChoices('tool_args', question, rawChoices) } + // Batch form: tool args carry the model's questions array. Entries are + // normalized leniently here (qid comes from the gateway request, not args). + let questions: ClarifyArgs['questions'] + + if (Array.isArray(row.questions)) { + const parsed = row.questions + .map(entry => { + const item = parseMaybeObject(entry) + const text = stringField(item, 'question') + + if (!text) { + return null + } + + const itemChoices = normalizeChoices(item.choices) + + return { + choices: itemChoices.length > 0 ? itemChoices : null, + multiSelect: item.multi_select === true && itemChoices.length > 0, + question: text + } + }) + .filter((entry): entry is NonNullable => entry !== null) + + if (parsed.length > 0) { + questions = parsed + } + } + return { question, choices: choices.length > 0 ? choices : null, - multiSelect: row.multi_select === true + multiSelect: row.multi_select === true, + questions } } +interface ClarifyBatchResponse { + id?: string + question?: string + answer?: string | string[] +} + +/** Parse batch clarify tool JSON (`responses` array + optional timed_out). */ +export function readClarifyBatchResult(result: unknown): { + responses: ClarifyBatchResponse[] + timedOut: boolean +} { + const row = parseMaybeObject(result) + + if (!Array.isArray(row.responses)) { + return { responses: [], timedOut: false } + } + + const responses = row.responses.map((entry): ClarifyBatchResponse => { + const item = parseMaybeObject(entry) + const answer = item.user_response + + return { + answer: Array.isArray(answer) ? answer.map(String) : typeof answer === 'string' ? answer : undefined, + id: stringField(item, 'id'), + question: stringField(item, 'question') + } + }) + + return { responses, timedOut: row.timed_out === true } +} + /** Parse clarify tool JSON (`question` + `user_response`). */ export function readClarifyResult(result: unknown): ClarifyResult { const row = parseMaybeObject(result) @@ -236,7 +300,17 @@ function ClarifyToolLive(props: ToolCallMessagePartProps) { return } -function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { +function ClarifyToolSettled(props: ToolCallMessagePartProps) { + const batch = readClarifyBatchResult(props.result) + + if (batch.responses.length > 0) { + return + } + + return +} + +function ClarifyToolSingleSettled({ args, result }: ToolCallMessagePartProps) { const { t } = useI18n() const copy = t.assistant.clarify const fromArgs = useMemo(() => readClarifyArgs(args), [args]) @@ -303,19 +377,36 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { ) } -function ClarifyToolPending({ args }: ToolCallMessagePartProps) { - const { t } = useI18n() - const copy = t.assistant.clarify +function ClarifyToolPending(props: ToolCallMessagePartProps) { // The tool row is in whichever session's transcript rendered it — read THAT // session's clarify (primary or tile), not the globally-active one. const sessionId = useStore(useSessionView().$runtimeId) const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId]) const request = useStore($request) + const fromArgs = useMemo(() => readClarifyArgs(props.args), [props.args]) + + // Batch: the gateway request carries qid-keyed questions. Args alone can't + // drive the form (no qids to respond with), so batch waits for the request. + if (request?.questions?.length || fromArgs.questions) { + return + } + + return +} + +function ClarifyToolSinglePending({ + fromArgs, + request +}: { + fromArgs: ClarifyArgs + request: ClarifyRequest | null +}) { + const { t } = useI18n() + const copy = t.assistant.clarify const gateway = useStore($gateway) - const fromArgs = useMemo(() => readClarifyArgs(args), [args]) const matchingRequest = useMemo(() => { - if (!request) { + if (!request || request.questions?.length) { return null } @@ -699,3 +790,313 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { ) } + +// ─── Batch (multi-question) clarify ───────────────────────────────────────── + +/** Settled batch card: every question with its locked (or absent) answer. */ +function ClarifyToolBatchSettled({ responses }: { responses: { question?: string; answer?: string | string[] }[] }) { + const { t } = useI18n() + const copy = t.assistant.clarify + + return ( + + {responses.map((row, index) => { + const answer = Array.isArray(row.answer) ? row.answer.join(', ') : (row.answer ?? '') + const blank = !answer.trim() + + return ( +
+ {row.question ? ( + + + {row.question} + + + ) : null} + +

+ {blank ? copy.skipped : answer} +

+
+
+ ) + })} +
+ ) +} + +/** One question's interactive block inside the live batch card. */ +function BatchQuestionBlock({ + disabled, + locked, + onDraft, + onToggle, + question, + staged +}: { + disabled: boolean + locked: boolean + onDraft: (value: string) => void + onToggle: (choice: string) => void + question: ClarifyQuestion + staged: { choices: string[]; draft: string } +}) { + const { t } = useI18n() + const copy = t.assistant.clarify + const choices = question.choices ?? [] + + return ( +
+
+ + {question.question} + + {locked ? ( + + ✓ {copy.answeredBadge} + + ) : null} +
+ + {choices.length > 0 ? ( +
+ {choices.map((choice, index) => ( + onToggle(choice)} + selected={staged.choices.includes(choice)} + /> + ))} +