diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
new file mode 100644
index 000000000000..a508b8471c51
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
@@ -0,0 +1,112 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import type { ToolCallMessagePartProps } from '@assistant-ui/react'
+import type { ReactNode } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { I18nProvider } from '@/i18n'
+
+import { ClarifyTool, readClarifyResult } from './clarify-tool'
+
+afterEach(() => {
+ cleanup()
+})
+
+function renderClarify(ui: ReactNode) {
+ return render(
+
+ {ui}
+
+ )
+}
+
+function settledClarifyProps(
+ args: ToolCallMessagePartProps['args'],
+ result: ToolCallMessagePartProps['result'],
+ toolCallId: string
+): ToolCallMessagePartProps {
+ return {
+ addResult: vi.fn(),
+ args,
+ argsText: JSON.stringify(args),
+ isError: false,
+ result,
+ resume: vi.fn(),
+ status: { type: 'complete' },
+ toolCallId,
+ toolName: 'clarify',
+ type: 'tool-call'
+ }
+}
+
+describe('readClarifyResult', () => {
+ it('reads question + user_response from the tool JSON payload', () => {
+ expect(
+ readClarifyResult({
+ question: 'Which target?',
+ choices_offered: ['staging', 'prod'],
+ user_response: 'staging'
+ })
+ ).toEqual({
+ question: 'Which target?',
+ answer: 'staging',
+ error: undefined
+ })
+ })
+
+ it('parses a JSON string result the same way as an object', () => {
+ expect(
+ readClarifyResult(
+ JSON.stringify({
+ question: 'Ship it?',
+ user_response: 'yes'
+ })
+ )
+ ).toEqual({
+ question: 'Ship it?',
+ answer: 'yes',
+ error: undefined
+ })
+ })
+
+ it('keeps an empty user_response so Skip can render as skipped', () => {
+ expect(readClarifyResult({ question: 'Ok?', user_response: '' })).toEqual({
+ question: 'Ok?',
+ answer: '',
+ error: undefined
+ })
+ })
+})
+
+describe('ClarifyTool settled view', () => {
+ it('keeps the question and answer visible after the tool completes', () => {
+ renderClarify(
+
+ )
+
+ expect(screen.getByText('Which deployment target?')).toBeTruthy()
+ expect(screen.getByText('staging')).toBeTruthy()
+ expect(document.querySelector('[data-clarify-settled]')).toBeTruthy()
+ expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging')
+ })
+
+ it('labels an empty response as Skipped', () => {
+ renderClarify(
+
+ )
+
+ expect(screen.getByText('Anything else?')).toBeTruthy()
+ expect(screen.getByText('Skipped')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
index ed3ee6be8d47..967c9aac2e4c 100644
--- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
@@ -19,55 +19,74 @@ import { Kbd } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
-import { Loader2, MessageQuestion } from '@/lib/icons'
+import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { selectMessageRunning } from './tool/fallback-model'
+import { parseMaybeObject } from './tool/fallback-model/format'
interface ClarifyArgs {
question?: string
choices?: string[] | null
}
-function readClarifyArgs(args: unknown): ClarifyArgs {
- if (!args || typeof args !== 'object') {
- return {}
- }
+interface ClarifyResult {
+ question?: string
+ answer?: string
+ error?: string
+}
- const row = args as Record
+function stringField(row: Record, ...keys: string[]): string | undefined {
+ for (const key of keys) {
+ const value = row[key]
+
+ if (typeof value === 'string') {
+ return value
+ }
+ }
+}
+
+function readClarifyArgs(args: unknown): ClarifyArgs {
+ const row = parseMaybeObject(args)
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
return {
- question: typeof row.question === 'string' ? row.question : undefined,
+ question: stringField(row, 'question'),
choices: choices && choices.length > 0 ? choices : null
}
}
-// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing
-// that letter — the badge doubles as the shortcut hint.
+/** Parse clarify tool JSON (`question` + `user_response`). */
+export function readClarifyResult(result: unknown): ClarifyResult {
+ const row = parseMaybeObject(result)
+
+ if (Object.keys(row).length === 0) {
+ return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {}
+ }
+
+ return {
+ question: stringField(row, 'question'),
+ answer: stringField(row, 'user_response', 'answer'),
+ error: stringField(row, 'error')
+ }
+}
+
const letterFor = (index: number): string => String.fromCharCode(65 + index)
-// Choice and "Other" rows share a layout; only color differs. Mirrors a tool
-// row's compact rhythm so the panel reads as part of the transcript.
const OPTION_ROW_CLASS =
'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50'
-// Content-sizing freeform field (CSS `field-sizing` — same primitive as the
-// commit bar and search field): starts at one line, grows with what's typed,
-// and never reflows the panel when focused. Bare so the "Other" row matches the
-// choice rows above it.
-const FREEFORM_INPUT_CLASS =
- 'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50'
+// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners.
+const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none'
-// Quiet inline panel that matches the surrounding tool rows: a single hairline
-// border in the shared stroke token, a soft surface fill, and a faint primary
-// accent that signals "this one needs you" without the loud animated ring.
const CLARIFY_SHELL_CLASS =
'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)'
+const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)'
+
function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) {
return (
@@ -76,10 +95,20 @@ function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>)
)
}
-// Selection lives on the letter badge alone — a solid primary fill — not the
-// whole row, which stays a quiet hover target. `preview` is the focused-but-empty
-// "Other" state: the badge outlines in primary to show it's armed, then fills
-// once a value is actually typed.
+function ClarifyLine({
+ children,
+ className,
+ icon: Icon,
+ ...props
+}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) {
+ return (
+
+
{children}
+
+
+ )
+}
+
function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) {
return (
{
+ // Answered → settled Q&A (ToolFallback collapsed the answer away).
+ if (props.result !== undefined) {
+ return
+ }
+
+ return
+}
+
+function ClarifyToolLive(props: ToolCallMessagePartProps) {
const messageRunning = useAuiState(selectMessageRunning)
- // Only the live, still-blocked turn shows the interactive panel. Once the
- // message stops running — answered, the turn ended, or the user hit Stop —
- // fall back to the standard tool block so the Q/A settles like every other
- // row instead of stranding a dead prompt the gateway no longer waits on.
- const isPending = messageRunning && props.result === undefined
-
- if (!isPending) {
+ // Stopped mid-prompt with no result — don't leave a dead interactive panel.
+ if (!messageRunning) {
return
}
return
}
+function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
+ const { t } = useI18n()
+ const copy = t.assistant.clarify
+ const fromArgs = useMemo(() => readClarifyArgs(args), [args])
+ const fromResult = useMemo(() => readClarifyResult(result), [result])
+
+ const question = fromResult.question || fromArgs.question || ''
+ const answer = fromResult.answer
+ const error = fromResult.error
+ const skipped = !error && answer !== undefined && !answer.trim()
+ const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim())
+
+ return (
+
+ {question ? (
+
+ {question}
+
+ ) : null}
+ {answerText ? (
+
+
+ {answerText}
+
+
+ ) : null}
+
+ )
+}
+
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
@@ -175,8 +245,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
})
triggerHaptic('submit')
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
- // The matching tool.complete will land shortly after, swapping this
- // panel for the ToolFallback view above.
+ // tool.complete lands next → ClarifyToolSettled.
} catch (error) {
notifyError(error, copy.sendFailed)
setSubmitting(false)
@@ -327,17 +396,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{choice}
))}
- {/* "Other" is an inline content-sizing field, not a separate view. */}
-