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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2433,7 +2433,10 @@ export const ar = defineLocale({
|
||||
other: 'غير ذلك',
|
||||
placeholder: 'اكتب إجابتك...',
|
||||
skip: 'تخطي',
|
||||
continueLabel: 'متابعة'
|
||||
continueLabel: 'متابعة',
|
||||
confirmAndContinueLabel: 'تأكيد ومتابعة',
|
||||
answeredBadge: 'تمت الإجابة',
|
||||
questionProgress: (answered, total) => `تمت الإجابة على ${answered} من ${total}`
|
||||
},
|
||||
tool: {
|
||||
copyCode: 'نسخ الكود',
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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: 'この質問はもう回答を待っていません。選択肢を選ぶとフォローアップメッセージとして下書きされます。'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '此問題已不再等待回答。選擇一個選項會將其起草為後續訊息。'
|
||||
|
||||
@@ -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: '此问题已不再等待回答。选择一个选项会将其起草为后续消息。'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user