From 8b96fc57ed186905fb3e354761cd2e0b6b52d1a8 Mon Sep 17 00:00:00 2001
From: SHL0MS
Date: Wed, 22 Jul 2026 21:08:28 -0400
Subject: [PATCH 1/3] 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) {
) : null}
+ {skipped && choices.length > 0 ? (
+
+ {choices.map((choice, index) => (
+
+ ))}
+
{copy.lateAnswerHint}
+
+ ) : null}
)
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 53802fcd8cdb..42a2c9a1ccf0 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index c712198e6132..673bfa686148 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -2454,7 +2454,10 @@ export const ja = defineLocale({
placeholder: '回答を入力…',
skip: 'スキップ',
skipped: 'スキップ済み',
- continueLabel: '続行'
+ continueLabel: '続行',
+ lateAnswer: (question, choice) => `「${question}」について — 私の回答: ${choice}`,
+ lateAnswerTip: 'この回答をフォローアップメッセージとして下書きします',
+ lateAnswerHint: 'この質問はタイムアウトしました。選択肢を選ぶとフォローアップメッセージとして下書きされます。'
},
tool: {
code: 'コード',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 18f96afb90d1..5eff59e68f2d 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -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
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index c19c10bb4769..7a6f77c93614 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -2380,7 +2380,10 @@ export const zhHant = defineLocale({
placeholder: '輸入您的答案…',
skip: '略過',
skipped: '已略過',
- continueLabel: '繼續'
+ continueLabel: '繼續',
+ lateAnswer: (question, choice) => `關於「${question}」 — 我的回答: ${choice}`,
+ lateAnswerTip: '將此回答起草為後續訊息',
+ lateAnswerHint: '此問題已逾時。選擇一個選項會將其起草為後續訊息。'
},
tool: {
code: '程式碼',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 8515ec15f429..ba7ed982fe10 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2696,7 +2696,10 @@ export const zh: Translations = {
placeholder: '输入你的答案…',
skip: '跳过',
skipped: '已跳过',
- continueLabel: '继续'
+ continueLabel: '继续',
+ lateAnswer: (question, choice) => `关于"${question}" — 我的回答: ${choice}`,
+ lateAnswerTip: '将此回答起草为后续消息',
+ lateAnswerHint: '此问题已超时。选择一个选项会将其起草为后续消息。'
},
tool: {
code: '代码',
From efa002dd5de43b5a36630b7f0adfce9c74dc0fb7 Mon Sep 17 00:00:00 2001
From: Brooklyn Nicholson
Date: Wed, 22 Jul 2026 21:39:27 -0500
Subject: [PATCH 2/3] refactor(desktop): share the clarify choice row and fix
skip copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Builds on the skipped-clarify card so it holds up beyond the timeout case:
- Extract a shared `ChoiceButton` (letter badge + label + row chrome) used
by both the live pending card and the settled skip card. The two blocks
had drifted into duplicated markup; now they can't diverge.
- Fix the hint copy. An empty `user_response` is emitted for BOTH a
server-side timeout AND a manual Skip (tools/clarify_tool.py) — there is
no field on the result that tells them apart — so asserting "This question
timed out" was wrong half the time. Neutral wording ("This prompt is no
longer waiting…") is correct for either, and the recover-your-answer path
now also helps someone who mis-clicked Skip. Updated en/ja/zh/zh-hant.
No behavior change to the live prompt or the follow-up-message flow.
Co-authored-by: SHL0MS
---
.../components/assistant-ui/clarify-tool.tsx | 69 ++++++++++++-------
apps/desktop/src/i18n/en.ts | 2 +-
apps/desktop/src/i18n/ja.ts | 2 +-
apps/desktop/src/i18n/zh-hant.ts | 2 +-
apps/desktop/src/i18n/zh.ts | 2 +-
5 files changed, 50 insertions(+), 27 deletions(-)
diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
index d06ed1ef16ea..db33eab308ce 100644
--- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
@@ -126,6 +126,43 @@ 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
+}) {
+ return (
+
+ )
+}
+
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
// Answered → settled Q&A (ToolFallback collapsed the answer away).
if (props.result !== undefined) {
@@ -198,20 +235,13 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
{skipped && choices.length > 0 ? (