mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge pull request #69769 from NousResearch/bb/salvage-69720-clarify-late
feat(desktop): skipped clarify keeps its choices visible and answerable
This commit is contained in:
commit
6d76f5ca51
7 changed files with 166 additions and 17 deletions
|
|
@ -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(
|
||||
<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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ 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'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
|
||||
|
|
@ -125,6 +127,48 @@ 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
|
||||
}) {
|
||||
// `Tip` is the repo's themed replacement for native `title=` (a native
|
||||
// tooltip on a <button> is banned by the no-native-title guard). It renders
|
||||
// the child untouched when `label` is falsy, so the live card (no tip) is
|
||||
// unaffected and only the settled skip card gets the hover hint.
|
||||
return (
|
||||
<Tip label={title}>
|
||||
<button
|
||||
className={cn(
|
||||
OPTION_ROW_CLASS,
|
||||
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
selected && 'text-(--ui-text-primary)'
|
||||
)}
|
||||
data-choice
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<KeyBadge char={char} selected={selected} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
// Answered → settled Q&A (ToolFallback collapsed the answer away).
|
||||
if (props.result !== undefined) {
|
||||
|
|
@ -156,6 +200,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="">
|
||||
|
|
@ -178,6 +238,20 @@ 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) => (
|
||||
<ChoiceButton
|
||||
char={letterFor(index)}
|
||||
choice={choice}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => followUp(choice)}
|
||||
title={copy.lateAnswerTip}
|
||||
/>
|
||||
))}
|
||||
<p className="px-1.5 pt-0.5 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">{copy.lateAnswerHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -385,21 +459,14 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
{hasChoices ? (
|
||||
<div className="grid gap-px" 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)',
|
||||
selectedChoice === choice && 'text-(--ui-text-primary)'
|
||||
)}
|
||||
data-choice
|
||||
<ChoiceButton
|
||||
char={letterFor(index)}
|
||||
choice={choice}
|
||||
disabled={submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => selectChoice(choice)}
|
||||
type="button"
|
||||
>
|
||||
<KeyBadge char={letterFor(index)} selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
selected={selectedChoice === choice}
|
||||
/>
|
||||
))}
|
||||
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
|
||||
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
|
||||
|
|
|
|||
|
|
@ -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 prompt is no longer waiting. Pick an option to draft it as a follow-up message.'
|
||||
},
|
||||
tool: {
|
||||
code: 'Code',
|
||||
|
|
|
|||
|
|
@ -2454,7 +2454,10 @@ export const ja = defineLocale({
|
|||
placeholder: '回答を入力…',
|
||||
skip: 'スキップ',
|
||||
skipped: 'スキップ済み',
|
||||
continueLabel: '続行'
|
||||
continueLabel: '続行',
|
||||
lateAnswer: (question, choice) => `「${question}」について — 私の回答: ${choice}`,
|
||||
lateAnswerTip: 'この回答をフォローアップメッセージとして下書きします',
|
||||
lateAnswerHint: 'この質問はもう回答を待っていません。選択肢を選ぶとフォローアップメッセージとして下書きされます。'
|
||||
},
|
||||
tool: {
|
||||
code: 'コード',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2380,7 +2380,10 @@ export const zhHant = defineLocale({
|
|||
placeholder: '輸入您的答案…',
|
||||
skip: '略過',
|
||||
skipped: '已略過',
|
||||
continueLabel: '繼續'
|
||||
continueLabel: '繼續',
|
||||
lateAnswer: (question, choice) => `關於「${question}」 — 我的回答: ${choice}`,
|
||||
lateAnswerTip: '將此回答起草為後續訊息',
|
||||
lateAnswerHint: '此問題已不再等待回答。選擇一個選項會將其起草為後續訊息。'
|
||||
},
|
||||
tool: {
|
||||
code: '程式碼',
|
||||
|
|
|
|||
|
|
@ -2696,7 +2696,10 @@ export const zh: Translations = {
|
|||
placeholder: '输入你的答案…',
|
||||
skip: '跳过',
|
||||
skipped: '已跳过',
|
||||
continueLabel: '继续'
|
||||
continueLabel: '继续',
|
||||
lateAnswer: (question, choice) => `关于"${question}" — 我的回答: ${choice}`,
|
||||
lateAnswerTip: '将此回答起草为后续消息',
|
||||
lateAnswerHint: '此问题已不再等待回答。选择一个选项会将其起草为后续消息。'
|
||||
},
|
||||
tool: {
|
||||
code: '代码',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue