mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge pull request #63102 from NousResearch/bb/clarify-answer-visible
fix(desktop): keep answered clarify Q&A visible in the transcript
This commit is contained in:
commit
fc232f8ce6
8 changed files with 227 additions and 41 deletions
112
apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
Normal file
112
apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}>
|
||||
|
|
@ -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
|
||||
|
|
@ -96,21 +125,62 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
|
|||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
// Answered → settled Q&A (ToolFallback collapsed the answer away).
|
||||
if (props.result !== undefined) {
|
||||
return <ClarifyToolSettled {...props} />
|
||||
}
|
||||
|
||||
return <ClarifyToolLive {...props} />
|
||||
}
|
||||
|
||||
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 <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
|
||||
|
|
@ -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) {
|
|||
<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)
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2324,6 +2324,7 @@ export const en: Translations = {
|
|||
other: 'Other (type your answer)',
|
||||
placeholder: 'Type your answer…',
|
||||
skip: 'Skip',
|
||||
skipped: 'Skipped',
|
||||
continueLabel: 'Continue'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -2266,6 +2266,7 @@ export const ja = defineLocale({
|
|||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
skip: 'スキップ',
|
||||
skipped: 'スキップ済み',
|
||||
continueLabel: '続行'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -1947,6 +1947,7 @@ export interface Translations {
|
|||
other: string
|
||||
placeholder: string
|
||||
skip: string
|
||||
skipped: string
|
||||
continueLabel: string
|
||||
}
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -2197,6 +2197,7 @@ export const zhHant = defineLocale({
|
|||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
skip: '略過',
|
||||
skipped: '已略過',
|
||||
continueLabel: '繼續'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -2483,6 +2483,7 @@ export const zh: Translations = {
|
|||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
skip: '跳过',
|
||||
skipped: '已跳过',
|
||||
continueLabel: '继续'
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
IconLogin as LogIn,
|
||||
IconMail as Mail,
|
||||
IconMaximize as Maximize,
|
||||
IconCircleLetterA as CircleLetterA,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessageQuestion as MessageQuestion,
|
||||
IconMessage2 as MessageSquareText,
|
||||
|
|
@ -141,6 +142,7 @@ export {
|
|||
ChevronRight,
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
CircleLetterA,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Cloud,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue