Merge pull request #69769 from NousResearch/bb/salvage-69720-clarify-late

feat(desktop): skipped clarify keeps its choices visible and answerable
This commit is contained in:
brooklyn! 2026-07-22 22:12:53 -05:00 committed by GitHub
commit 6d76f5ca51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 166 additions and 17 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,11 +13,13 @@ 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'
import { Kbd } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
@ -125,6 +127,48 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
)
}
/** A letter-badged option row. Shared by the live pending card (where a click
* selects an answer) and the settled skip card (where a click drafts a
* follow-up), so both stay visually identical. */
function ChoiceButton({
char,
choice,
disabled,
onClick,
selected = false,
title
}: {
char: string
choice: string
disabled?: boolean
onClick: () => void
selected?: boolean
title?: string
}) {
// `Tip` is the repo's themed replacement for native `title=` (a native
// tooltip on a <button> is banned by the no-native-title guard). It renders
// the child untouched when `label` is falsy, so the live card (no tip) is
// unaffected and only the settled skip card gets the hover hint.
return (
<Tip label={title}>
<button
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
selected && 'text-(--ui-text-primary)'
)}
data-choice
disabled={disabled}
onClick={onClick}
type="button"
>
<KeyBadge char={char} selected={selected} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
</Tip>
)
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
// Answered → settled Q&A (ToolFallback collapsed the answer away).
if (props.result !== undefined) {
@ -156,6 +200,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 +238,20 @@ 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) => (
<ChoiceButton
char={letterFor(index)}
choice={choice}
key={`${index}-${choice}`}
onClick={() => followUp(choice)}
title={copy.lateAnswerTip}
/>
))}
<p className="px-1.5 pt-0.5 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">{copy.lateAnswerHint}</p>
</div>
) : null}
</ClarifyShell>
)
}
@ -385,21 +459,14 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{hasChoices ? (
<div className="grid gap-px" 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)',
selectedChoice === choice && 'text-(--ui-text-primary)'
)}
data-choice
<ChoiceButton
char={letterFor(index)}
choice={choice}
disabled={submitting}
key={`${index}-${choice}`}
onClick={() => selectChoice(choice)}
type="button"
>
<KeyBadge char={letterFor(index)} selected={selectedChoice === choice} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
selected={selectedChoice === choice}
/>
))}
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />

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 prompt is no longer waiting. Pick an option to draft 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: '代码',