feat(desktop): render the clarify (Recommended) label in tertiary text
The card reads the labelled choices off the gateway request rather than the raw tool args -- the backend applies the label there, and the card only mounts once the request exists, so the args are a hydration-race fallback. RECOMMENDED_LABEL and bareChoice live in the clarify store so the component and the choice-length guard share one definition; without the guard a long option could be dropped for length the label added.
This commit is contained in:
parent
10cf651484
commit
fd6af8f832
|
|
@ -297,6 +297,39 @@ describe('ClarifyTool keyboard navigation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool recommended option', () => {
|
||||
it('dims the (Recommended) label and answers with the choice the backend sent', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
$activeSessionId.set('session-1')
|
||||
$gateway.set({ request } as never)
|
||||
setClarifyRequest({
|
||||
choices: ['staging (Recommended)', 'production'],
|
||||
question: 'Which deployment target?',
|
||||
requestId: 'request-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
renderClarify(<ClarifyTool {...liveClarifyProps(['staging (Recommended)', 'production'])} />)
|
||||
|
||||
const recommended = screen.getByRole('button', { name: /staging/ })
|
||||
|
||||
// The label rides in its own muted span so the option text still reads first.
|
||||
expect(recommended.querySelector('.text-\\(--ui-text-tertiary\\)')?.textContent).toBe('(Recommended)')
|
||||
|
||||
fireEvent.click(recommended)
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
// The decorated string goes back verbatim; the tool strips the label before
|
||||
// the agent ever sees the answer.
|
||||
await waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith('clarify.respond', {
|
||||
answer: 'staging (Recommended)',
|
||||
request_id: 'request-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool pending marker', () => {
|
||||
it('marks a live choices card with its row count so type-to-focus yields exactly its keys', () => {
|
||||
renderLiveClarify()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,14 @@ import { useI18n } from '@/i18n'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
import {
|
||||
bareChoice,
|
||||
clearClarifyRequest,
|
||||
normalizeChoices,
|
||||
RECOMMENDED_LABEL,
|
||||
sessionClarifyRequest,
|
||||
warnDroppedChoices
|
||||
} from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
|
|
@ -87,6 +94,22 @@ export function readClarifyResult(result: unknown): ClarifyResult {
|
|||
|
||||
const letterFor = (index: number): string => String.fromCharCode(65 + index)
|
||||
|
||||
// The backend tags the agent's preferred option (`mark_recommended`); the card
|
||||
// renders the label in tertiary text so the option itself still reads first.
|
||||
function ChoiceLabel({ choice }: { choice: string }) {
|
||||
const bare = bareChoice(choice)
|
||||
|
||||
if (bare === choice) {
|
||||
return <>{choice}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{bare} <span className="text-(--ui-text-tertiary)">{RECOMMENDED_LABEL}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
|
|
@ -182,7 +205,9 @@ function ChoiceButton({
|
|||
type="button"
|
||||
>
|
||||
<KeyBadge char={char} preview={active} selected={selected} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
<span className="flex-1 wrap-anywhere">
|
||||
<ChoiceLabel choice={choice} />
|
||||
</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
|
|
@ -301,7 +326,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
const question = fromArgs.question || matchingRequest?.question || ''
|
||||
|
||||
const choices = useMemo(
|
||||
() => fromArgs.choices ?? matchingRequest?.choices ?? [],
|
||||
// Prefer the gateway request's choices over the raw tool args: the backend
|
||||
// labels the recommended option there (`mark_recommended`), and the card
|
||||
// only renders once `matchingRequest` exists, so the args are a fallback
|
||||
// for a hydration race, not the normal path.
|
||||
() => matchingRequest?.choices ?? fromArgs.choices ?? [],
|
||||
[fromArgs.choices, matchingRequest?.choices]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ export interface ClarifyRequest {
|
|||
sessionId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend labels the agent's recommended option by appending this to the
|
||||
* first choice (`tools/clarify_tool.py::mark_recommended`). The renderer never
|
||||
* writes it — it only styles it, and discounts it when measuring a choice so a
|
||||
* long option isn't dropped for length the label added.
|
||||
*/
|
||||
export const RECOMMENDED_LABEL = '(Recommended)'
|
||||
|
||||
export const bareChoice = (choice: string): string =>
|
||||
choice.endsWith(RECOMMENDED_LABEL) ? choice.slice(0, -RECOMMENDED_LABEL.length).trim() : choice
|
||||
|
||||
/**
|
||||
* Validate and normalize a choices array.
|
||||
*
|
||||
|
|
@ -23,7 +34,8 @@ export function normalizeChoices(choices: unknown): string[] {
|
|||
}
|
||||
|
||||
return choices.filter(
|
||||
(c): c is string => typeof c === 'string' && c.trim().length > 0 && c.length <= 200 && !c.includes('\n')
|
||||
(c): c is string =>
|
||||
typeof c === 'string' && c.trim().length > 0 && bareChoice(c).length <= 200 && !c.includes('\n')
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue