Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider configClient={null} initialLocale="en">
{ui}
</I18nProvider>
)
}

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(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{
question: 'Which deployment target?',
choices_offered: ['staging', 'prod'],
user_response: 'staging'
},
'clarify-1'
)}
/>
)

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(
<ClarifyTool
{...settledClarifyProps({ question: 'Anything else?' }, { question: 'Anything else?', user_response: '' }, 'clarify-2')}
/>
)

expect(screen.getByText('Anything else?')).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
})
})
147 changes: 107 additions & 40 deletions apps/desktop/src/components/assistant-ui/clarify-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

function stringField(row: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = row[key]

if (typeof value === 'string') {
return value
}
}
}

const row = args as Record<string, unknown>
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 (
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
Expand All @@ -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 (
<div className={cn('flex items-start gap-2', className)} {...props}>
<div className="min-w-0 flex-1">{children}</div>
<Icon aria-hidden className={CLARIFY_ICON_CLASS} />
</div>
)
}

function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) {
return (
<Kbd
Expand All @@ -96,21 +125,62 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
}

export const ClarifyTool = (props: ToolCallMessagePartProps) => {
const messageRunning = useAuiState(selectMessageRunning)
// Answered → settled Q&A (ToolFallback collapsed the answer away).
if (props.result !== undefined) {
return <ClarifyToolSettled {...props} />
}

// 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
return <ClarifyToolLive {...props} />
}

if (!isPending) {
function ClarifyToolLive(props: ToolCallMessagePartProps) {
const messageRunning = useAuiState(selectMessageRunning)

// Stopped mid-prompt with no result — don't leave a dead interactive panel.
if (!messageRunning) {
return <ToolFallback {...props} />
}

return <ClarifyToolPending {...props} />
}

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 (
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
{question ? (
<ClarifyLine icon={MessageQuestion}>
<span className="whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
</ClarifyLine>
) : null}
{answerText ? (
<ClarifyLine icon={CircleLetterA}>
<p
className={cn(
'whitespace-pre-wrap leading-(--conversation-line-height)',
error ? 'text-destructive' : 'text-(--ui-text-secondary)',
skipped && 'italic text-(--ui-text-tertiary)'
)}
data-clarify-answer=""
>
{answerText}
</p>
</ClarifyLine>
) : null}
</ClarifyShell>
)
}

function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -327,17 +396,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
{/* "Other" is an inline content-sizing field, not a separate view. */}
<label className={cn(OPTION_ROW_CLASS, 'focus-within:bg-(--chrome-action-hover)')}>
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
<textarea
className={FREEFORM_INPUT_CLASS}
<Textarea
className={CLARIFY_TEXTAREA_CLASS}
disabled={submitting}
onBlur={() => setOtherFocused(false)}
onChange={event => onDraftChange(event.target.value)}
// Focusing "Other" is a switch to typing your own answer, so it
// deselects any picked choice — a chosen option and an active
// Other field can never both look selected.
onFocus={() => {
setSelectedChoice(null)
setOtherFocused(true)
Expand All @@ -346,19 +411,21 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
placeholder={copy.other}
ref={textareaRef}
rows={1}
size="sm"
value={draft}
/>
</label>
</div>
) : (
<Textarea
className={FREEFORM_INPUT_CLASS}
className={CLARIFY_TEXTAREA_CLASS}
disabled={submitting}
onChange={event => onDraftChange(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder={copy.placeholder}
ref={textareaRef}
rows={1}
size="sm"
value={draft}
/>
)}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2324,6 +2324,7 @@ export const en: Translations = {
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
skip: 'Skip',
skipped: 'Skipped',
continueLabel: 'Continue'
},
tool: {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2266,6 +2266,7 @@ export const ja = defineLocale({
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
skip: 'スキップ',
skipped: 'スキップ済み',
continueLabel: '続行'
},
tool: {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,7 @@ export interface Translations {
other: string
placeholder: string
skip: string
skipped: string
continueLabel: string
}
tool: {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2197,6 +2197,7 @@ export const zhHant = defineLocale({
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
skip: '略過',
skipped: '已略過',
continueLabel: '繼續'
},
tool: {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2483,6 +2483,7 @@ export const zh: Translations = {
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
skip: '跳过',
skipped: '已跳过',
continueLabel: '继续'
},
tool: {
Expand Down
Loading
Loading