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 ? (
{choices.map((choice, index) => ( - + /> ))}

{copy.lateAnswerHint}

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