feat(desktop): multi-question clarify card with per-question locks

The clarify card renders every batch question at once. Answers stage
locally per question; the footer button locks the staged answer with a
clarify.respond keyed by question_id. Locked answers stay editable — a
new pick un-locks the row and a re-lock overwrites server-side. When
exactly one question is unanswered the button relabels to Confirm and
continue, and that final lock completes the batch. Skip cancels the
whole batch (no question_id). Reconnect replay seeds the locked map so
a reattached window restores its earlier state.

The settled card lists every question with its answer; blank answers
render as Skipped. Single-question cards are untouched.
This commit is contained in:
ethernet
2026-08-18 16:12:58 -04:00
parent 879d6a4c78
commit e0d8a2eb89
12 changed files with 807 additions and 11 deletions
@@ -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<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
: undefined
if (requestId && question) {
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 (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)
}
@@ -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,184 @@ 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<string, string>) {
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(<ClarifyTool {...liveBatchProps()} />)
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('locks a picked choice via Continue, keyed by qid', async () => {
const request = renderLiveBatch()
fireEvent.click(screen.getByRole('button', { name: /red/ }))
fireEvent.submit(document.querySelector('form') as HTMLFormElement)
await waitFor(() => {
expect(request).toHaveBeenCalledWith('clarify.respond', {
answer: 'red',
question_id: 'q0',
request_id: 'request-batch'
})
})
await waitFor(() => {
expect(screen.getByText('1 of 2 answered')).toBeTruthy()
})
})
it('answers in any order: free-text question first', async () => {
const request = renderLiveBatch()
const nameBox = screen.getByPlaceholderText('Type your answer…')
fireEvent.change(nameBox, { target: { value: 'packet' } })
fireEvent.submit(document.querySelector('form') as HTMLFormElement)
await waitFor(() => {
expect(request).toHaveBeenCalledWith('clarify.respond', {
answer: 'packet',
question_id: 'q1',
request_id: 'request-batch'
})
})
})
it('relabels the button to Confirm and continue when one question remains', async () => {
renderLiveBatch({ q1: 'already locked' })
expect(screen.getByText('1 of 2 answered')).toBeTruthy()
expect(screen.getByRole('button', { name: /Confirm and continue/ })).toBeTruthy()
})
it('re-staging a locked answer un-locks it and a re-lock overwrites', async () => {
const request = renderLiveBatch({ q0: 'red' })
// q0 arrived locked from replay. Picking blue un-locks it locally…
fireEvent.click(screen.getByRole('button', { name: /blue/ }))
expect(screen.getByText('0 of 2 answered')).toBeTruthy()
// …and Continue re-locks with the new answer.
fireEvent.submit(document.querySelector('form') as HTMLFormElement)
await waitFor(() => {
expect(request).toHaveBeenCalledWith('clarify.respond', {
answer: 'blue',
question_id: 'q0',
request_id: 'request-batch'
})
})
})
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(
<ClarifyTool
{...settledClarifyProps(
batchArgs(),
JSON.stringify({
responses: [
{ choices_offered: ['red', 'blue'], question: 'Color?', user_response: 'red' },
{ choices_offered: null, question: 'Name?', user_response: '' }
]
}),
'clarify-batch-settled'
)}
/>
)
expect(screen.getByText('Color?')).toBeTruthy()
expect(screen.getByText('red')).toBeTruthy()
expect(screen.getByText('Name?')).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
})
})
@@ -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<typeof entry> => 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 <ClarifyToolPending {...props} />
}
function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
function ClarifyToolSettled(props: ToolCallMessagePartProps) {
const batch = readClarifyBatchResult(props.result)
if (batch.responses.length > 0) {
return <ClarifyToolBatchSettled responses={batch.responses} />
}
return <ClarifyToolSingleSettled {...props} />
}
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 <ClarifyToolBatchPending request={request} />
}
return <ClarifyToolSinglePending fromArgs={fromArgs} request={request} />
}
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,346 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
</form>
)
}
// ─── 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 (
<ClarifyShell className="my-1.5 grid gap-2.5" data-clarify-settled="">
{responses.map((row, index) => {
const answer = Array.isArray(row.answer) ? row.answer.join(', ') : (row.answer ?? '')
const blank = !answer.trim()
return (
<div className="grid gap-1" key={`${index}-${row.question ?? ''}`}>
{row.question ? (
<ClarifyLine icon={MessageQuestion}>
<span className="whitespace-pre-wrap font-medium leading-(--conversation-line-height)">
{row.question}
</span>
</ClarifyLine>
) : null}
<ClarifyLine icon={CircleLetterA}>
<p
className={cn(
'whitespace-pre-wrap leading-(--conversation-line-height)',
blank ? 'italic text-(--ui-text-tertiary)' : 'text-(--ui-text-secondary)'
)}
data-clarify-answer=""
>
{blank ? copy.skipped : answer}
</p>
</ClarifyLine>
</div>
)
})}
</ClarifyShell>
)
}
/** 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 (
<div className="grid gap-1" data-clarify-batch-question={question.qid} data-locked={locked || undefined}>
<div className="flex items-start gap-2">
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">
{question.question}
</span>
{locked ? (
<span className="shrink-0 rounded-sm bg-(--chrome-action-hover) px-1 py-px text-[0.625rem] text-(--ui-text-tertiary)">
✓ {copy.answeredBadge}
</span>
) : null}
</div>
{choices.length > 0 ? (
<div className="grid gap-px" role="group">
{choices.map((choice, index) => (
<ChoiceButton
char={letterFor(index)}
choice={choice}
disabled={disabled}
key={`${index}-${choice}`}
onClick={() => onToggle(choice)}
selected={staged.choices.includes(choice)}
/>
))}
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
<KeyBadge char={letterFor(choices.length)} selected={Boolean(staged.draft.trim())} />
<Textarea
className={CLARIFY_TEXTAREA_CLASS}
disabled={disabled}
onChange={event => onDraft(event.target.value)}
placeholder={copy.other}
rows={1}
size="sm"
value={staged.draft}
/>
</label>
</div>
) : (
<Textarea
className={CLARIFY_TEXTAREA_CLASS}
disabled={disabled}
onChange={event => onDraft(event.target.value)}
placeholder={copy.placeholder}
rows={1}
size="sm"
value={staged.draft}
/>
)}
</div>
)
}
const emptyStage = { choices: [] as string[], draft: '' }
/** Live batch card (B lock-in): all questions at once, staging per question,
* one footer button that locks the focused answer. Locked answers stay
* editable (a re-lock overwrites server-side) until the final confirm — the
* last unanswered question's lock IS the confirm, so the button relabels to
* "Confirm and continue" when exactly one remains. */
function ClarifyToolBatchPending({ request }: { request: ClarifyRequest | null }) {
const { t } = useI18n()
const copy = t.assistant.clarify
const gateway = useStore($gateway)
// qids only exist on the gateway request — args are a hydration-race
// fallback for display, never answerable (no ids to respond with).
const questions = request?.questions ?? []
const ready = Boolean(request?.requestId) && questions.length > 0
const [staged, setStaged] = useState<Record<string, { choices: string[]; draft: string }>>({})
// Locked = accepted by the server (clarify.respond succeeded). Seeded from
// the reconnect replay so a reattached window shows its earlier locks.
const [locked, setLocked] = useState<Record<string, string>>(() => request?.lockedAnswers ?? {})
// The question the footer button acts on: last interacted-with, unlocked.
const [focusedQid, setFocusedQid] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (request?.lockedAnswers) {
setLocked(current => ({ ...request.lockedAnswers, ...current }))
}
}, [request?.lockedAnswers])
const stageFor = (qid: string) => staged[qid] ?? emptyStage
const stagedAnswer = useCallback(
(question: ClarifyQuestion): string | null => {
const stage = staged[question.qid] ?? emptyStage
if (stage.choices.length > 0) {
return question.multiSelect ? JSON.stringify(stage.choices.map(bareChoice)) : bareChoice(stage.choices[0])
}
const draft = stage.draft.trim()
return draft ? draft : null
},
[staged]
)
const unansweredCount = questions.filter(q => locked[q.qid] === undefined).length
// The button targets the focused question when it has something staged;
// otherwise the first unlocked question with a staged answer.
const target = useMemo(() => {
const focused = questions.find(q => q.qid === focusedQid)
if (focused && stagedAnswer(focused) !== null) {
return focused
}
return questions.find(q => stagedAnswer(q) !== null) ?? null
}, [focusedQid, questions, stagedAnswer])
const lockAnswer = useCallback(
async (question: ClarifyQuestion, answer: string) => {
if (!request || !gateway) {
notifyError(new Error(request ? copy.gatewayDisconnected : copy.notReady), copy.sendFailed)
return
}
setSubmitting(true)
try {
await gateway.request<{ ok?: boolean }>('clarify.respond', {
answer,
question_id: question.qid,
request_id: request.requestId
})
triggerHaptic('submit')
setLocked(current => ({ ...current, [question.qid]: answer }))
setStaged(current => ({ ...current, [question.qid]: emptyStage }))
setFocusedQid(null)
// Locking the last unanswered question completes the batch: the
// server resolves the blocked tool and tool.complete lands next
// (→ ClarifyToolBatchSettled). Drop the store entry now, mirroring
// the single-question card.
const remaining = questions.filter(q => q.qid !== question.qid && locked[q.qid] === undefined)
if (remaining.length === 0) {
clearClarifyRequest(request.requestId, request.sessionId)
}
} catch (error) {
notifyError(error, copy.sendFailed)
} finally {
setSubmitting(false)
}
},
[copy, gateway, locked, questions, request]
)
const submitTarget = useCallback(() => {
if (!target) {
return
}
const answer = stagedAnswer(target)
if (answer !== null) {
void lockAnswer(target, answer)
}
}, [lockAnswer, stagedAnswer, target])
const toggleChoice = useCallback((question: ClarifyQuestion, choice: string) => {
setFocusedQid(question.qid)
setStaged(current => {
const stage = current[question.qid] ?? emptyStage
const next = question.multiSelect
? stage.choices.includes(choice)
? stage.choices.filter(value => value !== choice)
: [...stage.choices, choice]
: [choice]
return { ...current, [question.qid]: { choices: next, draft: '' } }
})
// A fresh pick un-locks the question for editing: the old answer stays on
// the server until the re-lock overwrites it.
setLocked(current => {
if (current[question.qid] === undefined) {
return current
}
const next = { ...current }
delete next[question.qid]
return next
})
}, [])
const draftFor = useCallback((question: ClarifyQuestion, value: string) => {
setFocusedQid(question.qid)
setStaged(current => ({ ...current, [question.qid]: { choices: [], draft: value } }))
setLocked(current => {
if (current[question.qid] === undefined || !value.trim()) {
return current
}
const next = { ...current }
delete next[question.qid]
return next
})
}, [])
const cancelAll = useCallback(async () => {
if (!request) {
return
}
clearClarifyRequest(request.requestId, request.sessionId)
try {
await gateway?.request('clarify.respond', { answer: '', request_id: request.requestId })
} catch {
// The tool times out on its own; a failed skip must never block the UI.
}
}, [gateway, request])
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
submitTarget()
},
[submitTarget]
)
if (!ready) {
return (
<ClarifyShell aria-label={copy.loadingQuestion} className="my-1.5 grid min-h-12 place-items-center" role="status">
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
</ClarifyShell>
)
}
const answeredCount = questions.length - unansweredCount
const lastOne = unansweredCount === 1
return (
<form className="my-1.5 grid gap-4" data-clarify-batch={questions.length} onSubmit={handleSubmit}>
<ClarifyShell className="grid gap-3">
<div className="flex items-start gap-2">
<span className="flex-1 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">
{copy.questionProgress(answeredCount, questions.length)}
</span>
<MessageQuestion aria-hidden className={CLARIFY_ICON_CLASS} />
</div>
{questions.map(question => (
<BatchQuestionBlock
disabled={submitting}
key={question.qid}
locked={locked[question.qid] !== undefined}
onDraft={value => draftFor(question, value)}
onToggle={choice => toggleChoice(question, choice)}
question={question}
staged={stageFor(question.qid)}
/>
))}
</ClarifyShell>
<div className="flex items-center justify-end gap-1">
<Button disabled={submitting} onClick={() => void cancelAll()} size="xs" type="button" variant="text">
{copy.skip}
</Button>
<Button disabled={submitting || !target} size="xs" type="submit">
{submitting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<>
{lastOne ? copy.confirmAndContinueLabel : copy.continueLabel}
<span aria-hidden className="ml-0.5 text-[0.625rem] opacity-70">
⏎
</span>
</>
)}
</Button>
</div>
</form>
)
}
+4 -1
View File
@@ -2433,7 +2433,10 @@ export const ar = defineLocale({
other: 'غير ذلك',
placeholder: 'اكتب إجابتك...',
skip: 'تخطي',
continueLabel: 'متابعة'
continueLabel: 'متابعة',
confirmAndContinueLabel: 'تأكيد ومتابعة',
answeredBadge: 'تمت الإجابة',
questionProgress: (answered, total) => `تمت الإجابة على ${answered} من ${total}`
},
tool: {
copyCode: 'نسخ الكود',
+3
View File
@@ -3072,6 +3072,9 @@ export const en: Translations = {
skip: 'Skip',
skipped: 'Skipped',
continueLabel: 'Continue',
confirmAndContinueLabel: 'Confirm and continue',
answeredBadge: 'Answered',
questionProgress: (answered, total) => `${answered} of ${total} answered`,
lateAnswer: (question, choice) => `Re: "${question}" — my answer: ${choice}`,
lateAnswerTip: 'Draft this answer as a follow-up message',
lateAnswerHint: 'This prompt is no longer waiting. Pick an option to draft it as a follow-up message.'
+3
View File
@@ -2726,6 +2726,9 @@ export const ja = defineLocale({
skip: 'スキップ',
skipped: 'スキップ済み',
continueLabel: '続行',
confirmAndContinueLabel: '確定して続行',
answeredBadge: '回答済み',
questionProgress: (answered, total) => `${total}問中${answered}問回答済み`,
lateAnswer: (question, choice) => `「${question}」について — 私の回答: ${choice}`,
lateAnswerTip: 'この回答をフォローアップメッセージとして下書きします',
lateAnswerHint: 'この質問はもう回答を待っていません。選択肢を選ぶとフォローアップメッセージとして下書きされます。'
+3
View File
@@ -2638,6 +2638,9 @@ export interface Translations {
skip: string
skipped: string
continueLabel: string
confirmAndContinueLabel: string
answeredBadge: string
questionProgress: (answered: number, total: number) => string
lateAnswer: (question: string, choice: string) => string
lateAnswerTip: string
lateAnswerHint: string
+3
View File
@@ -2637,6 +2637,9 @@ export const zhHant = defineLocale({
skip: '略過',
skipped: '已略過',
continueLabel: '繼續',
confirmAndContinueLabel: '確認並繼續',
answeredBadge: '已回答',
questionProgress: (answered, total) => `已回答 ${answered}/${total}`,
lateAnswer: (question, choice) => `關於「${question}」 — 我的回答: ${choice}`,
lateAnswerTip: '將此回答起草為後續訊息',
lateAnswerHint: '此問題已不再等待回答。選擇一個選項會將其起草為後續訊息。'
+3
View File
@@ -3234,6 +3234,9 @@ export const zh: Translations = {
skip: '跳过',
skipped: '已跳过',
continueLabel: '继续',
confirmAndContinueLabel: '确认并继续',
answeredBadge: '已回答',
questionProgress: (answered, total) => `已回答 ${answered}/${total}`,
lateAnswer: (question, choice) => `关于"${question}" — 我的回答: ${choice}`,
lateAnswerTip: '将此回答起草为后续消息',
lateAnswerHint: '此问题已不再等待回答。选择一个选项会将其起草为后续消息。'
+4
View File
@@ -91,6 +91,10 @@ export type GatewayEventPayload = {
question?: string
choices?: string[] | null
multi_select?: boolean
// clarify.request batch form: questions replaces question/choices, and
// answers (qid → locked answer) rides along on reconnect replay only.
questions?: unknown
answers?: Record<string, unknown>
// mcp.setup.request (setup_mcp tool — inline MCP consent card)
server?: string
action?: string
+48
View File
@@ -7,6 +7,7 @@ import {
clearClarifyRequest,
hasClarifyRequest,
normalizeChoices,
normalizeQuestions,
setClarifyRequest,
skipClarifyRequest
} from './clarify'
@@ -165,3 +166,50 @@ describe('normalizeChoices', () => {
expect(normalizeChoices([])).toEqual([])
})
})
describe('normalizeQuestions', () => {
it('returns empty array for non-array input', () => {
expect(normalizeQuestions(null)).toEqual([])
expect(normalizeQuestions('x')).toEqual([])
expect(normalizeQuestions({})).toEqual([])
})
it('normalizes a valid batch and keys by qid', () => {
const result = normalizeQuestions([
{ choices: ['a', 'b'], qid: 'q0', question: 'One?' },
{ qid: 'q1', question: 'Two?' }
])
expect(result).toEqual([
{ choices: ['a', 'b'], multiSelect: false, qid: 'q0', question: 'One?' },
{ choices: null, multiSelect: false, qid: 'q1', question: 'Two?' }
])
})
it('drops entries missing qid or question text', () => {
const result = normalizeQuestions([
{ qid: '', question: 'no qid' },
{ qid: 'q1', question: ' ' },
'not-an-object',
{ qid: 'q2', question: 'kept' }
])
expect(result.map(q => q.qid)).toEqual(['q2'])
})
it('degrades all-blank choices to open-ended per question', () => {
const result = normalizeQuestions([{ choices: ['', ' '], qid: 'q0', question: 'Q?' }])
expect(result[0]?.choices).toBeNull()
})
it('only honors multi_select when choices survive', () => {
const result = normalizeQuestions([
{ choices: ['a', 'b'], multi_select: true, qid: 'q0', question: 'A?' },
{ multi_select: true, qid: 'q1', question: 'B?' }
])
expect(result[0]?.multiSelect).toBe(true)
expect(result[1]?.multiSelect).toBe(false)
})
})
+54
View File
@@ -3,12 +3,24 @@ import { atom, computed } from 'nanostores'
import { $gateway } from './gateway'
import { $activeSessionId } from './session'
export interface ClarifyQuestion {
/** Server-generated wire id (q0..qN) — clarify.respond keys answers by it. */
qid: string
question: string
choices: string[] | null
multiSelect: boolean
}
export interface ClarifyRequest {
requestId: string
question: string
choices: string[] | null
multiSelect: boolean
sessionId: string | null
/** Batch (multi-question) clarify: present instead of question/choices. */
questions?: ClarifyQuestion[]
/** Answers already locked server-side (reconnect replay): qid → answer. */
lockedAnswers?: Record<string, string>
}
/**
@@ -52,6 +64,48 @@ export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: st
})
}
/**
* Validate and normalize a batch clarify payload's `questions` array.
*
* Keeps entries with a non-blank string `qid` and `question`; per-question
* choices go through `normalizeChoices` (all-blank → open-ended) and
* multi_select is only honored alongside surviving choices. Returns an empty
* array when nothing usable remains — the caller treats that as "not a
* batch" instead of rendering an unanswerable form.
*/
export function normalizeQuestions(questions: unknown): ClarifyQuestion[] {
if (!Array.isArray(questions)) {
return []
}
const normalized: ClarifyQuestion[] = []
for (const entry of questions) {
if (typeof entry !== 'object' || entry === null) {
continue
}
const row = entry as Record<string, unknown>
const qid = typeof row.qid === 'string' ? row.qid.trim() : ''
const question = typeof row.question === 'string' ? row.question.trim() : ''
if (!qid || !question) {
continue
}
const choices = normalizeChoices(row.choices)
normalized.push({
choices: choices.length > 0 ? choices : null,
multiSelect: row.multi_select === true && choices.length > 0,
qid,
question
})
}
return normalized
}
// Pending clarify requests keyed by the runtime session id that raised them.
// Storing per-session (instead of one shared slot) lets a *background* session
// park its clarify request while the user is looking at a different chat, then