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. */} -