"use client" import * as React from 'react' import { TriangleAlert, Info, X } from 'lucide-react' import { Button } from '@open-mercato/ui/primitives/button' import { Alert } from '@open-mercato/ui/primitives/alert' import { Avatar } from '@open-mercato/ui/primitives/avatar' import { Input } from '@open-mercato/ui/primitives/input' import { Switch } from '@open-mercato/ui/primitives/switch' import { Textarea } from '@open-mercato/ui/primitives/textarea' import { StatusBadge } from '@open-mercato/ui/primitives/status-badge' import { KbdShortcut } from '@open-mercato/ui/primitives/kbd' import { SectionHeader } from '@open-mercato/ui/backend/SectionHeader' import { useT } from '@open-mercato/shared/lib/i18n/context' import { normalizeProposalEnvelope } from '../data/proposalEnvelope' import { ProposalOptionList } from './ProposalOptionList' import { formatConfidence, type AdHocProposalView, type ProposalView } from './types' import { agentAvatarIcon } from './agentChips' import { useAgentIconMap } from './useAgentIcons' import { proposalVerdict } from './cockpitStatus' import { proposalCaseStatusLabelKey, proposalCaseStatusVariant } from './proposalCaseStatus' import { humanizeKey } from './proposalFactsData' import { actionEditsToActions, deriveActionEdits, parseRawActions, reassembleProposalPayload, stringifyActions, type ActionEdit, } from './proposalEdit' export type DisposeKind = 'approved' | 'edited' | 'rejected' export type ProposalActionsConfig = { /** Whether the operator can dispose (RBAC `proposals.dispose`). */ canDispose: boolean /** Whether a write is currently in flight (buttons disabled). */ busy?: boolean onApprove: () => void onEdit: (payload: unknown, reason: string) => void onReject: (reason: string) => void } export type ProposalCardProps = { /** Persisted proposal (caseload detail). */ proposal?: ProposalView | null /** * Ad-hoc proposal a run just returned (playground proposal result). When * provided without `proposal`, the card renders read-only with disabled * actions. Build it with `mapAdHocProposal` — the card reads the option set * off `payload`, so it must be the envelope, not one option's actions. */ adHoc?: AdHocProposalView /** Disposition action wiring. Omit for a fully read-only card (playground). */ actions?: ProposalActionsConfig /** Opens the Agent I/O drawer. */ onInspect?: () => void /** Human-readable agent name; falls back to the agent id when omitted. */ agentLabel?: string /** * Which option the operator picked. `null` means no choice yet — approve and * edit stay disabled, because the endpoint requires one and guessing the * leader on the operator's behalf is exactly what Phase 4 removes. */ selectedOptionId?: string | null /** Omit to render the option set read-only (playground preview, disposed row). */ onSelectOption?: (optionId: string) => void } function stringifyPayload(payload: unknown): string { try { return JSON.stringify(payload ?? {}, null, 2) } catch { return '{}' } } type PayloadField = { key: string; kind: 'string' | 'number' | 'boolean'; value: string | number | boolean } function isPrimitive(value: unknown): boolean { return value === null || ['string', 'number', 'boolean'].includes(typeof value) } // A flat object of primitives renders as friendly form fields; anything nested // (objects/arrays) falls back to the raw JSON editor so we never lose data. function toPayloadFields(payload: unknown): PayloadField[] | null { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null const entries = Object.entries(payload as Record) if (entries.length === 0 || !entries.every(([, value]) => isPrimitive(value))) return null return entries.map(([key, value]) => ({ key, kind: typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string', value: value === null ? '' : (value as string | number | boolean), })) } function fieldsToPayload(fields: PayloadField[]): Record { return Object.fromEntries( fields.map((field) => { if (field.kind === 'number') return [field.key, Number(field.value)] if (field.kind === 'boolean') return [field.key, field.value === true || field.value === 'true'] return [field.key, field.value] }), ) } export function ProposalCard({ proposal, adHoc, actions, onInspect, agentLabel, selectedOptionId = null, onSelectOption, }: ProposalCardProps) { const t = useT() const [mode, setMode] = React.useState<'view' | 'edit' | 'reject'>('view') const [payloadText, setPayloadText] = React.useState('') const [payloadFields, setPayloadFields] = React.useState([]) const [isComplexPayload, setIsComplexPayload] = React.useState(false) // Structured (canonical `{actions}`) editing state — spec 4 Phase 4. The // legacy whole-payload states above remain only for non-canonical payloads. const [actionEdits, setActionEdits] = React.useState(null) const [rawMode, setRawMode] = React.useState(false) const [rawText, setRawText] = React.useState('') const [reason, setReason] = React.useState('') const [localError, setLocalError] = React.useState(null) const agentIcons = useAgentIconMap() const agentId = proposal?.agentId ?? adHoc?.agentId ?? '' const agentIconInfo = agentIcons.get(agentId) const confidence = proposal?.confidence ?? adHoc?.confidence ?? null const payload = proposal?.payload ?? adHoc?.payload ?? null const rationale = proposal?.rationale ?? adHoc?.rationale ?? null const confidenceLabel = formatConfidence(confidence) const verdict = proposalVerdict(confidence) const busy = actions?.busy ?? false const canDispose = actions?.canDispose ?? false const isPending = (proposal?.disposition ?? 'pending') === 'pending' const optionCount = React.useMemo(() => normalizeProposalEnvelope(payload).options.length, [payload]) // A verdict names the option it runs. With no option there is nothing to run, // and with an unmade choice there is nothing to name — either way the dispose // buttons stay inert rather than sending a guess. const optionChosen = optionCount > 0 && selectedOptionId != null const startEdit = React.useCallback(() => { // Canonical `{actions}` payloads get the structured per-action editor; // confidence/rationale are agent testimony and never become inputs. const edits = deriveActionEdits(payload, selectedOptionId) if (edits) { setActionEdits(edits) setRawMode(false) setRawText(stringifyActions(actionEditsToActions(edits))) } else { setActionEdits(null) const fields = toPayloadFields(payload) if (fields) { setPayloadFields(fields) setIsComplexPayload(false) } else { setPayloadText(stringifyPayload(payload)) setIsComplexPayload(true) } } setReason('') setLocalError(null) setMode('edit') }, [payload, selectedOptionId]) const updateField = React.useCallback((index: number, value: string | boolean) => { setPayloadFields((fields) => fields.map((field, i) => (i === index ? { ...field, value } : field))) }, []) const updateActionField = React.useCallback((actionIndex: number, fieldIndex: number, value: string | boolean) => { setActionEdits((edits) => edits ? edits.map((edit, i) => i === actionIndex ? { ...edit, fields: edit.fields.map((field, j) => (j === fieldIndex ? { ...field, value } : field)) } : edit, ) : edits, ) }, []) // The raw escape hatch edits ONLY the actions array — toggling to raw // serializes the current field edits; toggling back re-derives fields from // the raw text (invalid raw stays in raw mode with an inline error). const toggleRawMode = React.useCallback(() => { if (!actionEdits) return if (!rawMode) { setRawText(stringifyActions(actionEditsToActions(actionEdits))) setRawMode(true) setLocalError(null) return } const parsed = parseRawActions(rawText) if (!parsed.ok) { setLocalError( parsed.error === 'json' ? t('agent_orchestrator.proposal.edit.invalidJson') : t('agent_orchestrator.proposal.edit.invalidActions'), ) return } const reparsed = deriveActionEdits( reassembleProposalPayload(payload, parsed.actions, selectedOptionId), selectedOptionId, ) if (reparsed) setActionEdits(reparsed) setRawMode(false) setLocalError(null) }, [actionEdits, rawMode, rawText, payload, selectedOptionId, t]) const startReject = React.useCallback(() => { setReason('') setLocalError(null) setMode('reject') }, []) const cancel = React.useCallback(() => { setMode('view') setLocalError(null) }, []) const submitEdit = React.useCallback(() => { if (!actions) return if (!reason.trim()) { setLocalError(t('agent_orchestrator.proposal.reject.reasonRequired')) return } if (actionEdits) { // Structured path: zod-guard the edited actions, then reassemble the // canonical payload — rationale/confidence/extra keys pass through verbatim. let editedActions if (rawMode) { const parsed = parseRawActions(rawText) if (!parsed.ok) { setLocalError( parsed.error === 'json' ? t('agent_orchestrator.proposal.edit.invalidJson') : t('agent_orchestrator.proposal.edit.invalidActions'), ) return } editedActions = parsed.actions } else { editedActions = actionEditsToActions(actionEdits) } actions.onEdit(reassembleProposalPayload(payload, editedActions, selectedOptionId), reason.trim()) return } let parsed: unknown if (isComplexPayload) { try { parsed = JSON.parse(payloadText) } catch { setLocalError(t('agent_orchestrator.proposal.edit.invalidJson')) return } } else { parsed = fieldsToPayload(payloadFields) } actions.onEdit(parsed, reason.trim()) }, [ actions, actionEdits, rawMode, rawText, payload, selectedOptionId, isComplexPayload, payloadText, payloadFields, reason, t, ]) const submitReject = React.useCallback(() => { if (!actions) return if (!reason.trim()) { setLocalError(t('agent_orchestrator.proposal.reject.reasonRequired')) return } actions.onReject(reason.trim()) }, [actions, reason, t]) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() if (mode === 'edit') submitEdit() else if (mode === 'reject') submitReject() } else if (event.key === 'Escape') { event.preventDefault() cancel() } }, [mode, submitEdit, submitReject, cancel], ) return (
{/* Proposal header — clean card; the violet avatar ring signals the AI source. */}

{t('agent_orchestrator.proposal.proposes', undefined, { agent: agentLabel || agentId })}

{agentId}

{confidenceLabel ? (

{t('agent_orchestrator.proposal.confidence')}

{confidenceLabel}

) : null} {proposal ? ( {t(proposalCaseStatusLabelKey(proposal.disposition))} ) : null}
{/* Verdict block — confidence-driven, not a static "approve" banner. */} {t(verdict.labelKey)} {rationale ?

{rationale}

: null} {/* The option set the agent offered — ranked, with ONE selection control. An operator must see the alternatives that were considered, not only the verdict, which is the whole point of the envelope. */}
1} />
{/* Inspect run */} {onInspect ? ( ) : null} {/* Gate — the "approve/edit/reject" warning only renders where those buttons exist (Caseload, pending). In read-only mode (the Playground preview) the proposal is saved but not disposable here, so point to Caseload instead of promising buttons. Already-disposed proposals show no gate. */} {actions && isPending ? ( {t('agent_orchestrator.proposal.gate')} ) : !actions ? ( {t('agent_orchestrator.proposal.gateReadonly')} ) : null} {/* Disposition reason on already-disposed proposals */} {proposal && !isPending && proposal.dispositionReason ? (

{proposal.dispositionReason}

) : null} {/* Inline edit / reject editors */} {mode === 'edit' ? (
{rawMode ? t('agent_orchestrator.proposal.edit.fieldsToggle') : t('agent_orchestrator.proposal.edit.rawToggle')} ) : undefined } /> {actionEdits ? ( rawMode ? (