Skip to content
Closed
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
69 changes: 68 additions & 1 deletion apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -96,8 +97,8 @@

expect(screen.getByText('Which deployment target?')).toBeTruthy()
expect(screen.getByText('staging')).toBeTruthy()
expect(document.querySelector('[data-clarify-settled]')).toBeTruthy()

Check warning on line 100 in apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / Typecheck & Test (apps/desktop)

Unexpected use of 'document'
expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging')

Check warning on line 101 in apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / Typecheck & Test (apps/desktop)

Unexpected use of 'document'
})

it('labels an empty response as Skipped', () => {
Expand All @@ -114,4 +115,70 @@
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(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{ question: 'Which deployment target?', user_response: '' },
'clarify-3'
)}
/>
)

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

expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})

it('does not render late choices for a free-text (no-choice) skip', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Anything else?' },
{ question: 'Anything else?', user_response: '' },
'clarify-5'
)}
/>
)

expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})
})
38 changes: 38 additions & 0 deletions apps/desktop/src/components/assistant-ui/clarify-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
Expand All @@ -178,6 +195,27 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
</p>
</ClarifyLine>
) : null}
{skipped && choices.length > 0 ? (
<div className="grid gap-px" data-clarify-late-choices="" role="group">
{choices.map((choice, index) => (
<button
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)'
)}
data-choice
key={`${index}-${choice}`}
onClick={() => followUp(choice)}
title={copy.lateAnswerTip}
type="button"
>
<KeyBadge char={letterFor(index)} selected={false} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
<p className="px-1.5 pt-0.5 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">{copy.lateAnswerHint}</p>
</div>
) : null}
</ClarifyShell>
)
}
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2515,7 +2515,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',
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2445,7 +2445,10 @@ export const ja = defineLocale({
placeholder: '回答を入力…',
skip: 'スキップ',
skipped: 'スキップ済み',
continueLabel: '続行'
continueLabel: '続行',
lateAnswer: (question, choice) => `「${question}」について — 私の回答: ${choice}`,
lateAnswerTip: 'この回答をフォローアップメッセージとして下書きします',
lateAnswerHint: 'この質問はタイムアウトしました。選択肢を選ぶとフォローアップメッセージとして下書きされます。'
},
tool: {
code: 'コード',
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,9 @@ export interface Translations {
skip: string
skipped: string
continueLabel: string
lateAnswer: (question: string, choice: string) => string
lateAnswerTip: string
lateAnswerHint: string
}
tool: {
code: string
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2371,7 +2371,10 @@ export const zhHant = defineLocale({
placeholder: '輸入您的答案…',
skip: '略過',
skipped: '已略過',
continueLabel: '繼續'
continueLabel: '繼續',
lateAnswer: (question, choice) => `關於「${question}」 — 我的回答: ${choice}`,
lateAnswerTip: '將此回答起草為後續訊息',
lateAnswerHint: '此問題已逾時。選擇一個選項會將其起草為後續訊息。'
},
tool: {
code: '程式碼',
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2687,7 +2687,10 @@ export const zh: Translations = {
placeholder: '输入你的答案…',
skip: '跳过',
skipped: '已跳过',
continueLabel: '继续'
continueLabel: '继续',
lateAnswer: (question, choice) => `关于"${question}" — 我的回答: ${choice}`,
lateAnswerTip: '将此回答起草为后续消息',
lateAnswerHint: '此问题已超时。选择一个选项会将其起草为后续消息。'
},
tool: {
code: '代码',
Expand Down
Loading