feat(desktop): skipped clarify keeps its choices visible and answerable

When a clarify prompt times out, the settled card collapsed to just
'Skipped' — the options were unrecoverable (the args carry them, the
renderer dropped them) and there was no way to answer late.

The skipped card now:
- renders the original choices, letter-badged like the live card
- clicking one drafts a quoted follow-up ('Re: "<question>" — my
  answer: <choice>') into the composer via the insert bus. Enter sends
  it; if the agent is mid-turn it queues like any other prompt.
- a hint line explains the question timed out and what picking does

No retroactive resolution of the expired request: the tool already
returned empty and the turn moved on — injecting into past context
would break prompt-cache and role-alternation invariants, and
clarify.respond on an expired id hard-errors (#56558). The follow-up
message path needs no backend change and works against old backends.

Answered clarifies and free-text (no-choice) skips are unchanged.

Interim UX for #44845 (durable ID-addressable clarify decisions).
This commit is contained in:
SHL0MS 2026-07-22 21:08:28 -04:00 committed by Brooklyn Nicholson
parent 957ea640de
commit 8b96fc57ed
7 changed files with 125 additions and 5 deletions

View file

@ -1,8 +1,9 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onComposerInsertRequest } from '@/app/chat/composer/focus'
import { I18nProvider } from '@/i18n'
import { ClarifyTool, readClarifyResult } from './clarify-tool'
@ -114,4 +115,70 @@ describe('ClarifyTool settled view', () => {
expect(screen.getByText('Anything else?')).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
})
it('keeps the original choices visible and clickable after a skip', async () => {
const inserts: string[] = []
const stop = onComposerInsertRequest(detail => {
inserts.push(detail.text)
})
try {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{ question: 'Which deployment target?', user_response: '' },
'clarify-3'
)}
/>
)
// The skip label renders AND the original options are still on screen.
expect(screen.getByText('Skipped')).toBeTruthy()
const group = document.querySelector('[data-clarify-late-choices]')
expect(group).toBeTruthy()
expect(screen.getByText('staging')).toBeTruthy()
expect(screen.getByText('prod')).toBeTruthy()
// Picking one drafts a quoted follow-up into the composer. The insert
// bus defers dispatch by a macrotask, so flush one tick.
fireEvent.click(screen.getByText('prod'))
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(inserts).toHaveLength(1)
expect(inserts[0]).toContain('Which deployment target?')
expect(inserts[0]).toContain('prod')
} finally {
stop()
}
})
it('does not render late choices on an answered clarify', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{ question: 'Which deployment target?', user_response: 'staging' },
'clarify-4'
)}
/>
)
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})
it('does not render late choices for a free-text (no-choice) skip', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Anything else?' },
{ question: 'Anything else?', user_response: '' },
'clarify-5'
)}
/>
)
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})
})

View file

@ -13,6 +13,7 @@ import {
useState
} from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
@ -156,6 +157,22 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
const error = fromResult.error
const skipped = !error && answer !== undefined && !answer.trim()
const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim())
const choices = fromArgs.choices ?? []
// A skipped (timed-out) clarify keeps its choices on screen and actionable.
// The blocking request is long gone — the tool already returned empty — so a
// pick can't resolve it retroactively. Instead it drafts a quoted follow-up
// into the composer (Enter sends; if the agent is mid-turn it queues like
// any other prompt). Without this the card collapsed to just "Skipped" and
// the options were unrecoverable.
const followUp = useCallback(
(choice: string) => {
requestComposerInsert(copy.lateAnswer(question, choice), { mode: 'block' })
requestComposerFocus()
triggerHaptic('selection')
},
[copy, question]
)
return (
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
@ -178,6 +195,27 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
</p>
</ClarifyLine>
) : null}
{skipped && choices.length > 0 ? (
<div className="grid gap-px" data-clarify-late-choices="" role="group">
{choices.map((choice, index) => (
<button
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)'
)}
data-choice
key={`${index}-${choice}`}
onClick={() => followUp(choice)}
title={copy.lateAnswerTip}
type="button"
>
<KeyBadge char={letterFor(index)} selected={false} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
<p className="px-1.5 pt-0.5 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">{copy.lateAnswerHint}</p>
</div>
) : null}
</ClarifyShell>
)
}

View file

@ -2524,7 +2524,10 @@ export const en: Translations = {
placeholder: 'Type your answer…',
skip: 'Skip',
skipped: 'Skipped',
continueLabel: 'Continue'
continueLabel: 'Continue',
lateAnswer: (question, choice) => `Re: "${question}" — my answer: ${choice}`,
lateAnswerTip: 'Draft this answer as a follow-up message',
lateAnswerHint: 'This question timed out. Picking an option drafts it as a follow-up message.'
},
tool: {
code: 'Code',

View file

@ -2454,7 +2454,10 @@ export const ja = defineLocale({
placeholder: '回答を入力…',
skip: 'スキップ',
skipped: 'スキップ済み',
continueLabel: '続行'
continueLabel: '続行',
lateAnswer: (question, choice) => `${question}」について — 私の回答: ${choice}`,
lateAnswerTip: 'この回答をフォローアップメッセージとして下書きします',
lateAnswerHint: 'この質問はタイムアウトしました。選択肢を選ぶとフォローアップメッセージとして下書きされます。'
},
tool: {
code: 'コード',

View file

@ -2139,6 +2139,9 @@ export interface Translations {
skip: string
skipped: string
continueLabel: string
lateAnswer: (question: string, choice: string) => string
lateAnswerTip: string
lateAnswerHint: string
}
tool: {
code: string

View file

@ -2380,7 +2380,10 @@ export const zhHant = defineLocale({
placeholder: '輸入您的答案…',
skip: '略過',
skipped: '已略過',
continueLabel: '繼續'
continueLabel: '繼續',
lateAnswer: (question, choice) => `關於「${question}」 — 我的回答: ${choice}`,
lateAnswerTip: '將此回答起草為後續訊息',
lateAnswerHint: '此問題已逾時。選擇一個選項會將其起草為後續訊息。'
},
tool: {
code: '程式碼',

View file

@ -2696,7 +2696,10 @@ export const zh: Translations = {
placeholder: '输入你的答案…',
skip: '跳过',
skipped: '已跳过',
continueLabel: '继续'
continueLabel: '继续',
lateAnswer: (question, choice) => `关于"${question}" — 我的回答: ${choice}`,
lateAnswerTip: '将此回答起草为后续消息',
lateAnswerHint: '此问题已超时。选择一个选项会将其起草为后续消息。'
},
tool: {
code: '代码',