"use client" import * as React from 'react' import { AlertTriangle, Check, Scale, ShieldAlert } from 'lucide-react' import { EmptyState } from '@open-mercato/ui/primitives/empty-state' import { StatusBadge } from '@open-mercato/ui/primitives/status-badge' import { cn } from '@open-mercato/shared/lib/utils' import { useT } from '@open-mercato/shared/lib/i18n/context' import { isProposalPayloadUnreadable, normalizeProposalEnvelope, rankProposalOptions } from '../data/proposalEnvelope' import type { ProposalOption } from '../data/validators' import { humanizeKey } from './proposalFactsData' import { autoDispositionBlockMessageKey } from './proposalCaseStatus' /** * The option set an agent offered, ranked, with ONE selection control * (spec `2026-08-11-agent-taxonomy.md`, Phase 4 step 11). * * The envelope exists so an operator can see the alternatives the agent * considered rather than a single verdict, so this component never collapses to * the leader: every option renders with its own rationale and confidence, and * the disposition runs the one the human picked. * * An EMPTY option set is `none_proposed` — the agent looked and had nothing to * offer. That is a real answer with no decision behind it, so it renders as an * EmptyState and never as a selectable row. */ export type ProposalOptionListProps = { /** Raw persisted/ad-hoc payload; coerced through `normalizeProposalEnvelope`. */ payload: unknown /** Currently picked option id, or null when the operator has not chosen yet. */ selectedOptionId?: string | null /** Omit for a read-only render (playground preview, disposed proposal). */ onSelect?: (optionId: string) => void disabled?: boolean /** * `agent_proposals.auto_disposition_block` — why a proposal that cleared its * confidence threshold was held for a person anyway. EVERY value renders, * because silence here reads as "the threshold was simply not met" and sends * the operator looking for a number when the real answer is a guardrail, the * tenant's risk ceiling, a missing trace or the policy switch. */ autoDispositionBlock?: string | null /** Renders the "you must pick one" hint below the list. */ showChooseHint?: boolean className?: string } const MAX_ACTION_CHIPS = 4 function confidencePct(option: ProposalOption): number | null { if (typeof option.confidence !== 'number') return null return Math.round(option.confidence * 100) } export function ProposalOptionList({ payload, selectedOptionId = null, onSelect, disabled = false, autoDispositionBlock = null, showChooseHint = false, className, }: ProposalOptionListProps) { const t = useT() const options = React.useMemo( () => rankProposalOptions(normalizeProposalEnvelope(payload).options), [payload], ) const payloadUnreadable = React.useMemo(() => isProposalPayloadUnreadable(payload), [payload]) const interactive = !!onSelect && !disabled const optionRefs = React.useRef(new Map()) const registerRef = React.useCallback((id: string, node: HTMLButtonElement | null) => { if (node) optionRefs.current.set(id, node) else optionRefs.current.delete(id) }, []) // Roving arrow-key navigation over the radio group: moving the cursor also // picks the option, which is the WAI-ARIA radiogroup behaviour and keeps the // keyboard triage flow (the whole point of the inbox) intact. const focusOption = React.useCallback( (index: number) => { const target = options[index] if (!target) return onSelect?.(target.id) optionRefs.current.get(target.id)?.focus() }, [options, onSelect], ) const onKeyDown = React.useCallback( (event: React.KeyboardEvent, index: number) => { if (!interactive) return switch (event.key) { case 'ArrowDown': case 'ArrowRight': event.preventDefault() focusOption(Math.min(index + 1, options.length - 1)) break case 'ArrowUp': case 'ArrowLeft': event.preventDefault() focusOption(Math.max(index - 1, 0)) break case 'Home': event.preventDefault() focusOption(0) break case 'End': event.preventDefault() focusOption(options.length - 1) break default: break } }, [interactive, focusOption, options.length], ) // A payload that is PRESENT but unreadable must never render as "the agent // proposed nothing". That sentence is a claim about what the agent decided, // and making it on the strength of a payload we failed to parse is how a real // proposal gets rejected by an operator who was never shown it. if (payloadUnreadable) { return (
} title={t('agent_orchestrator.proposal.options.unreadable.title', 'This proposal could not be read')} description={t( 'agent_orchestrator.proposal.options.unreadable.description', 'The agent recorded a decision, but its payload could not be decoded — so nothing here is safe to act on. Open the full trace to see what the run actually returned, and report this: it is a fault, not an empty result.', )} />
) } if (options.length === 0) { return (
} title={t('agent_orchestrator.proposal.options.noneProposed.title')} description={t('agent_orchestrator.proposal.options.noneProposed.description')} />
) } const selectedIndex = options.findIndex((option) => option.id === selectedOptionId) const nearTie = autoDispositionBlock === 'near_tie' const autoBlockMessageKey = autoDispositionBlockMessageKey(autoDispositionBlock) return (
{autoBlockMessageKey ? (
{nearTie ? ( ) : ( )} {t(autoBlockMessageKey)}
) : null}
    {options.map((option, index) => { const selected = option.id === selectedOptionId const pct = confidencePct(option) const body = ( <>
    {t('agent_orchestrator.proposal.options.rank', undefined, { rank: index + 1 })} {option.label} {pct == null ? t('agent_orchestrator.proposal.options.noConfidence') : t('agent_orchestrator.proposal.options.confidence', undefined, { pct })} {selected ? ( ) : null}
    {option.rationale ? (

    {option.rationale}

    ) : null}
    {option.actions.slice(0, MAX_ACTION_CHIPS).map((action, actionIndex) => ( {humanizeKey(action.type)} ))} {option.actions.length > MAX_ACTION_CHIPS ? ( {t('agent_orchestrator.proposal.options.moreActions', undefined, { count: option.actions.length - MAX_ACTION_CHIPS, })} ) : null}
    ) if (!interactive) { return (
  • {body}
  • ) } return (
  • ) })}
{showChooseHint && selectedIndex < 0 ? (

{t('agent_orchestrator.proposal.options.chooseHint')}

) : null}
) } export default ProposalOptionList