From 8b96fc57ed186905fb3e354761cd2e0b6b52d1a8 Mon Sep 17 00:00:00 2001
From: SHL0MS
Date: Wed, 22 Jul 2026 21:08:28 -0400
Subject: [PATCH] feat(desktop): skipped clarify keeps its choices visible and
answerable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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: "" — my
answer: ') 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).
---
.../assistant-ui/clarify-tool.test.tsx | 69 ++++++++++++++++++-
.../components/assistant-ui/clarify-tool.tsx | 38 ++++++++++
apps/desktop/src/i18n/en.ts | 5 +-
apps/desktop/src/i18n/ja.ts | 5 +-
apps/desktop/src/i18n/types.ts | 3 +
apps/desktop/src/i18n/zh-hant.ts | 5 +-
apps/desktop/src/i18n/zh.ts | 5 +-
7 files changed, 125 insertions(+), 5 deletions(-)
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..d06ed1ef16ea 100644
--- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
@@ -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 (
@@ -178,6 +195,27 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {