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 cf9e35450191..cdf592b16b8e 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -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( + + ) + + // 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( + + ) + + expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() + }) + + it('does not render late choices for a free-text (no-choice) skip', () => { + renderClarify( + + ) + + expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() + }) }) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 72b6fbbdfa81..d5c04d7acdc4 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -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 + + ) +} + 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 ( @@ -178,6 +238,20 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {

) : null} + {skipped && choices.length > 0 ? ( +
+ {choices.map((choice, index) => ( + followUp(choice)} + title={copy.lateAnswerTip} + /> + ))} +

{copy.lateAnswerHint}

+
+ ) : null}
) } @@ -385,21 +459,14 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { {hasChoices ? (
{choices.map((choice, index) => ( - + selected={selectedChoice === choice} + /> ))}