"use client" import * as React from 'react' import { useRouter, useSearchParams } from 'next/navigation' import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy' import { RotateCw, Check, X, Smile, Meh, Frown, Sparkles, TriangleAlert, Clock, ArrowUpDown, ChevronDown, Inbox, Activity, CheckCircle2, Keyboard, ShieldAlert } from 'lucide-react' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { Button } from '@open-mercato/ui/primitives/button' import { IconButton } from '@open-mercato/ui/primitives/icon-button' import { Avatar } from '@open-mercato/ui/primitives/avatar' import { agentAvatarIcon } from '../../components/agentChips' import { useAgentIconMap } from '../../components/useAgentIcons' import { StatusBadge } from '@open-mercato/ui/primitives/status-badge' import { EmptyState } from '@open-mercato/ui/primitives/empty-state' import { ProposeOnlyNotice } from '../../components/ProposeOnlyNotice' import { SegmentedControl, SegmentedControlItem } from '@open-mercato/ui/primitives/segmented-control' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select' import { SearchInput } from '@open-mercato/ui/primitives/search-input' import { Checkbox } from '@open-mercato/ui/primitives/checkbox' import { Popover, PopoverContent, PopoverTrigger } from '@open-mercato/ui/primitives/popover' import { Pagination } from '@open-mercato/ui/primitives/pagination' import { Kbd } from '@open-mercato/ui/primitives/kbd' import { DataTable } from '@open-mercato/ui/backend/DataTable' import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { apiCall, apiCallOrThrow, withScopedApiRequestHeaders, } from '@open-mercato/ui/backend/utils/apiCall' import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock' import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts' import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { cn } from '@open-mercato/shared/lib/utils' import { useT } from '@open-mercato/shared/lib/i18n/context' import { mapAgent, mapOverviewMetrics, mapProposal, type AgentFactView, type OverviewMetricsView, type ProposalView, } from '../../components/types' import { OPTIONAL_REQUEST_INIT } from '../../components/optionalRequest' import { shortCaseId, subjectLabelOf } from '../../components/subjectRef' import { useCoalescedReload } from '../../components/useCoalescedReload' import { summarizeProposalActions } from '../../components/proposalFactsData' import { normalizeProposalEnvelope, rankProposalOptions } from '../../data/proposalEnvelope' import { PROPOSAL_CASE_STATUS_DOT, PROPOSAL_CASE_STATUS_VARIANT, proposalCaseStatus, type ProposalCaseStatus, } from '../../components/proposalCaseStatus' import { DisposeDialog } from '../../components/DisposeDialog' import { ProposalOptionList } from '../../components/ProposalOptionList' import { FactsGrid, ProposedFields, ReasoningList } from '../../components/ProposalFacts' import { isAgentPreviewUiEnabled } from '../../lib/featureFlags' import { useInboxCursor, useCaseloadHotkeys, useDeferredApprove, intersectSelection, hasGuardRisk, parseQueueState, serializeQueueState, firstFailureMessage, pruneSelectionAfterDispose, type CaseloadHotkeyAction, type DisposeOutcome, } from './hooks' type ListResponse = { items?: Array>; total?: number; totalIsCapped?: boolean } // A single status taxonomy drives the tiles, the filter segment, and the table // Status column so the operator never has to reconcile two vocabularies. // `autoApproved` is a badge-level split of the approved family — the Approved // tab still groups all three dispositions, but rubber-stamp review needs to // SEE which approvals never had a human in the loop. // `noneProposed` is the agent's own terminus — it looked and had nothing to // offer — and is NOT an approval; conflating the two was the bug this phase closes. type CaseStatus = ProposalCaseStatus // Segments stay a three-way split — `autoApproved` is a badge-level status // only; the Approved tab keeps grouping approved + auto_approved + edited. type SegmentKey = 'actionRequired' | 'approved' | 'rejected' | 'all' type ViewKey = 'inbox' | 'list' type SortKey = 'waitingDesc' | 'waitingAsc' | 'confidenceDesc' | 'confidenceAsc' | 'agentAsc' const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [ { key: 'waitingDesc', labelKey: 'agent_orchestrator.caseload.sort.waitingDesc' }, { key: 'waitingAsc', labelKey: 'agent_orchestrator.caseload.sort.waitingAsc' }, { key: 'confidenceDesc', labelKey: 'agent_orchestrator.caseload.sort.confidenceDesc' }, { key: 'confidenceAsc', labelKey: 'agent_orchestrator.caseload.sort.confidenceAsc' }, { key: 'agentAsc', labelKey: 'agent_orchestrator.caseload.sort.agentAsc' }, ] // Sort + segment translate to server-side query params so ordering and // filtering apply to the WHOLE backlog, not just the loaded page. const SORT_PARAMS: Record = { waitingDesc: { field: 'createdAt', dir: 'asc' }, waitingAsc: { field: 'createdAt', dir: 'desc' }, confidenceDesc: { field: 'confidence', dir: 'desc' }, confidenceAsc: { field: 'confidence', dir: 'asc' }, agentAsc: { field: 'agentId', dir: 'asc' }, } // `all` enumerates the stored dispositions rather than sending no filter. It now // includes `none_proposed` — an agent that looked and had nothing to offer — because // Phase 4 gives that record its own badge and its own empty state; before it had // neither, and hiding it was the lesser of two wrongs against showing it as approved. const SEGMENT_DISPOSITIONS: Record = { actionRequired: 'pending', approved: 'approved,auto_approved,edited', rejected: 'rejected', all: 'pending,approved,auto_approved,edited,rejected,none_proposed', } type QueueRow = { id: string agentLabel: string /** Tenant-configured agent icon (lucide name) + kind for the avatar glyph. */ agentIcon: string | null agentResultKind: 'research' | 'proposal' | 'artifact' claim: string proposes: string /** Humanized primary action type — the bounded filter vocabulary. */ proposesType: string | null /** Raw persisted action type (`set_stage`) — tooltip material. */ proposesRawType: string | null confidencePct: number | null waitingLabel: string waitingStale: boolean status: CaseStatus waitingValue: number isPending: boolean updatedAt: string | null /** Guardrail verdict counts from `guard_results` (already in the list response). */ guardWarnCount: number guardBlockCount: number /** Any non-pass guardrail verdict — approve goes through the undo window. */ riskFlagged: boolean /** Inside its approve undo window — rendered approved, dispose not yet sent. */ pendingUndo: boolean /** How many mutually-exclusive options the agent offered. */ optionCount: number /** * The option an approve runs WITHOUT an operator choice: the one a disposed * proposal already ran, or the sole option when there is no choice to make. * Null when the agent offered real alternatives — then a human must pick, which * is what the envelope is for. */ defaultOptionId: string | null /** Which policy gate held a threshold-clearing auto-approval for a human. */ autoDispositionBlock: string | null } /** One proposal plus the option its verdict will run. */ type DisposeTarget = { row: QueueRow; optionId: string | null } /** Full data behind one queue row, feeding the DecisionPane's facts/reasoning. */ type DecisionDetail = { proposal: ProposalView facts?: AgentFactView[] runInput: unknown runOutput: unknown } const STATUS_VARIANT = PROPOSAL_CASE_STATUS_VARIANT const STATUS_DOT = PROPOSAL_CASE_STATUS_DOT /** * Every STORED disposition maps to its own badge through the shared taxonomy — * `none_proposed` is `noneProposed`, and an unrecognised value is `unknown`, * never `approved`. Painting an unknown verdict green would claim a decision * nobody gave. */ const statusOf = proposalCaseStatus /** * Whether a queue row still offers an inline approve/reject. A disposed case has * given its verdict, so the only thing left is its detail — which the row click * already opens. The list view asks this BEFORE rendering the actions column so * a closed segment does not grow a column that is empty on every row. */ function hasQuickActions(row: QueueRow): boolean { return row.isPending } function asString(value: unknown): string | null { return typeof value === 'string' ? value : null } function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null } function fieldOf(item: Record, ...keys: string[]): string { for (const key of keys) { const value = asString(item[key]) if (value) return value } return '' } /** * What the row "Proposes" — a thin wrapper over `summarizeProposalActions` * (spec 4 Phase 4): the first action's humanized type plus an action count, * NEVER rationale prose. Non-canonical payloads yield '—' and the headline * falls back to the agent label. */ function summarizeProposal( payload: unknown, more: (count: number) => string, ): { display: string; typeLabel: string | null; typeRaw: string | null } { const parts = summarizeProposalActions(payload) if (!parts) return { display: '—', typeLabel: null, typeRaw: null } return { display: parts.extraCount > 0 ? `${parts.typeLabel} · ${more(parts.extraCount)}` : parts.typeLabel, typeLabel: parts.typeLabel, typeRaw: parts.typeRaw, } } function confidencePctOf(confidence: number | null): number | null { if (confidence == null) return null return confidence <= 1 ? confidence * 100 : confidence } // Confidence reads faster as a face than a bare number: high = the agent is sure, // low = scrutinise it. Colours stay on success/neutral/error (no amber per DS). function confidenceFace(pct: number): { Icon: React.ComponentType<{ className?: string }>; color: string } { if (pct >= 70) return { Icon: Smile, color: 'text-status-success-text' } if (pct >= 40) return { Icon: Meh, color: 'text-muted-foreground' } return { Icon: Frown, color: 'text-status-error-text' } } function waitingFrom(createdAt: string | null, now: number): { label: string; stale: boolean; value: number } { if (!createdAt) return { label: '—', stale: false, value: 0 } const parsed = Date.parse(createdAt) if (Number.isNaN(parsed)) return { label: '—', stale: false, value: 0 } const minutes = Math.max(0, Math.round((now - parsed) / 60000)) if (minutes < 60) return { label: `${minutes}m`, stale: false, value: minutes } const hours = Math.round(minutes / 60) if (hours < 24) return { label: `${hours}h`, stale: hours >= 8, value: minutes } const days = Math.round(hours / 24) return { label: `${days}d`, stale: true, value: minutes } } function headlineOf(row: QueueRow): string { return row.proposes === '—' ? row.agentLabel : row.proposes } function matchesSearch(row: QueueRow, query: string): boolean { const q = query.trim().toLowerCase() if (!q) return true return row.claim.toLowerCase().includes(q) || row.agentLabel.toLowerCase().includes(q) || row.proposes.toLowerCase().includes(q) } function matchesFilters(row: QueueRow, agentFilters: string[], proposesFilters: string[]): boolean { if (agentFilters.length > 0 && !agentFilters.includes(row.agentLabel)) return false if (proposesFilters.length > 0 && (!row.proposesType || !proposesFilters.includes(row.proposesType))) return false return true } async function fetchItems(path: string): Promise>> { const call = await apiCall(path, OPTIONAL_REQUEST_INIT, { fallback: { items: [] } }) return call.ok && Array.isArray(call.result?.items) ? call.result!.items : [] } function LifecycleTile({ icon: Icon, label, value, sub }: { icon: React.ComponentType<{ className?: string }>; label: string; value: React.ReactNode; sub: React.ReactNode }) { return (

{label}

{value}
{sub}
) } export default function AgentCaseloadPage() { const t = useT() const router = useRouter() const searchParams = useSearchParams() // Queue state initializes from the URL exactly once (spec 4 Phase 5) — // afterwards state is the source of truth and the URL mirrors it below. const [initialQueue] = React.useState(() => parseQueueState(searchParams)) const [proposals, setProposals] = React.useState([]) const [total, setTotal] = React.useState(0) const [totalIsCapped, setTotalIsCapped] = React.useState(false) const [metrics, setMetrics] = React.useState(null) const [agentLabels, setAgentLabels] = React.useState>(new Map()) const [agentFacts, setAgentFacts] = React.useState>(new Map()) const [runClaims, setRunClaims] = React.useState>(new Map()) const [runIo, setRunIo] = React.useState>(new Map()) const [runningCount, setRunningCount] = React.useState(0) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState(null) const [view, setView] = React.useState(initialQueue.view) const [segment, setSegment] = React.useState(initialQueue.segment) const [search, setSearch] = React.useState(initialQueue.q) const [agentFilters, setAgentFilters] = React.useState([]) const [proposesFilters, setProposesFilters] = React.useState([]) const [sortKey, setSortKey] = React.useState(initialQueue.sort) const [page, setPage] = React.useState(initialQueue.page) const [pageSize, setPageSize] = React.useState(initialQueue.pageSize) const [reloadToken, setReloadToken] = React.useState(0) const [busy, setBusy] = React.useState(false) const [rejectDialog, setRejectDialog] = React.useState<{ open: boolean; rows: QueueRow[] }>({ open: false, rows: [] }) const [reason, setReason] = React.useState('') const [selectedIds, setSelectedIds] = React.useState>(new Set()) // Which option the operator picked, per proposal. Absent means "not chosen // yet" — the row falls back to its `defaultOptionId`, which is null whenever // there is a real choice to make. const [optionChoices, setOptionChoices] = React.useState>(new Map()) const { runMutation, retryLastMutation } = useGuardedMutation<{ retryLastMutation: () => Promise }>({ contextId: 'agent_orchestrator.caseload', blockedMessage: t('agent_orchestrator.proposal.flash.blocked'), }) // Warn-flagged approves defer their dispose behind an undo window (spec 4 // Phase 3). The committer is bound via a ref because `disposeRows` closes // over state declared below; the manager guarantees exactly-once per id. const commitDeferredRef = React.useRef<(id: string, target: DisposeTarget) => void>(() => {}) const deferredApprove = useDeferredApprove((id, target) => commitDeferredRef.current(id, target)) React.useEffect(() => { let cancelled = false async function load() { setIsLoading(true) setError(null) try { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) const disposition = SEGMENT_DISPOSITIONS[segment] if (disposition) params.set('disposition', disposition) const sort = SORT_PARAMS[sortKey] params.set('sortField', sort.field) params.set('sortDir', sort.dir) // Only the proposals list is load-bearing here. The lifecycle tiles and // the run enrichment are gated on features a dispose-only operator does // not need (`trace.view`), so they opt out of the throwing 403 path and // degrade to their own empty readings instead of failing the queue. const [proposalsCall, overviewCall, agents, runningCall] = await Promise.all([ apiCall(`/api/agent_orchestrator/proposals?${params.toString()}`, OPTIONAL_REQUEST_INIT, { fallback: { items: [] } }), apiCall>('/api/agent_orchestrator/metrics/overview?window=7d', OPTIONAL_REQUEST_INIT), fetchItems('/api/agent_orchestrator/agents'), apiCall('/api/agent_orchestrator/runs?status=running&pageSize=1', OPTIONAL_REQUEST_INIT, { fallback: { items: [] } }), ]) if (cancelled) return if (!proposalsCall.ok) { setError(t('agent_orchestrator.caseload.error')) return } const items = Array.isArray(proposalsCall.result?.items) ? proposalsCall.result!.items : [] const pageProposals = items.map((item) => mapProposal(item)).filter((row): row is ProposalView => !!row) // Enrich only the loaded page: fetch the runs its proposals reference. const runIds = Array.from(new Set(pageProposals.map((row) => row.runId))) const runs = runIds.length ? await fetchItems( `/api/agent_orchestrator/runs?ids=${runIds.map((id) => encodeURIComponent(id)).join(',')}&pageSize=${Math.min(runIds.length, 100)}`, ) : [] if (cancelled) return setProposals(pageProposals) setTotal(typeof proposalsCall.result?.total === 'number' ? proposalsCall.result.total : pageProposals.length) setTotalIsCapped(proposalsCall.result?.totalIsCapped === true) setMetrics(overviewCall.ok && overviewCall.result ? mapOverviewMetrics(overviewCall.result) : null) setRunningCount(runningCall.ok && typeof runningCall.result?.total === 'number' ? runningCall.result.total : 0) const labels = new Map() const facts = new Map() for (const item of agents) { const agent = mapAgent(item) if (!agent) continue labels.set(agent.id, agent.label || agent.id) if (agent.facts) facts.set(agent.id, agent.facts) } setAgentLabels(labels) setAgentFacts(facts) const claims = new Map() const io = new Map() for (const run of runs) { const id = fieldOf(run, 'id') if (!id) continue const input = asObject(run.input) claims.set(id, subjectLabelOf(input, id)) io.set(id, { input: run.input ?? null, output: run.output ?? null }) } setRunClaims(claims) setRunIo(io) } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : t('agent_orchestrator.caseload.error')) } finally { if (!cancelled) setIsLoading(false) } } load() return () => { cancelled = true } }, [segment, sortKey, page, pageSize, reloadToken, t]) const reload = React.useCallback(() => setReloadToken((token) => token + 1), []) // Live-refresh when a proposal is created, disposed, or becomes ready // (DOM Event Bridge, tenant/org-scoped server-side), coalesced so an event // burst triggers at most one refetch per interval. const coalescedReload = useCoalescedReload(reload) useAppEvent('agent_orchestrator.proposal.*', () => { coalescedReload() }) // A guardrail trip usually precedes proposal.created by seconds — subscribing // keeps the row-level risk chips fresh and the broadcast flag honest // (UX consistency pass, Area 1: the flag previously had zero listeners). useAppEvent('agent_orchestrator.guardrail.tripped', () => { coalescedReload() }) const decisionDetails = React.useMemo>(() => { const map = new Map() for (const proposal of proposals) { const io = runIo.get(proposal.runId) map.set(proposal.id, { proposal, facts: agentFacts.get(proposal.agentId), runInput: io?.input ?? null, runOutput: io?.output ?? null, }) } return map }, [proposals, agentFacts, runIo]) const agentIcons = useAgentIconMap() const pageRows = React.useMemo(() => { const now = Date.now() return proposals.map((proposal) => { const agentIconInfo = agentIcons.get(proposal.agentId) const waiting = waitingFrom(proposal.createdAt, now) const confidencePct = confidencePctOf(proposal.confidence) const guardWarnCount = proposal.guardResults.filter((check) => check.result === 'warn').length const guardBlockCount = proposal.guardResults.filter( (check) => check.result !== 'pass' && check.result !== 'warn', ).length // Rows inside their undo window render approved and stop being pending // (cursor, hotkeys, and bulk selection all skip them) even though the // dispose has not been sent yet — undo simply restores this overlay. const inUndoWindow = deferredApprove.pendingUndo.has(proposal.id) const summary = summarizeProposal(proposal.payload, (count) => t('agent_orchestrator.caseload.proposes.more', undefined, { count }), ) const options = rankProposalOptions(normalizeProposalEnvelope(proposal.payload).options) return { id: proposal.id, agentLabel: agentLabels.get(proposal.agentId) || proposal.agentId, agentIcon: agentIconInfo?.icon ?? null, agentResultKind: agentIconInfo?.resultKind ?? 'research', claim: runClaims.get(proposal.runId) || shortCaseId(proposal.id), proposes: summary.display, proposesType: summary.typeLabel, proposesRawType: summary.typeRaw, confidencePct, waitingLabel: waiting.label, waitingStale: waiting.stale, waitingValue: waiting.value, status: inUndoWindow ? 'approved' : statusOf(proposal.disposition), isPending: proposal.disposition === 'pending' && !inUndoWindow, updatedAt: proposal.updatedAt, guardWarnCount, guardBlockCount, riskFlagged: hasGuardRisk(proposal.guardResults), pendingUndo: inUndoWindow, optionCount: options.length, defaultOptionId: proposal.selectedOptionId ?? (options.length === 1 ? options[0].id : null), autoDispositionBlock: proposal.autoDispositionBlock, } }) }, [proposals, agentLabels, agentIcons, runClaims, deferredApprove.pendingUndo, t]) // Segment + sort are server-applied; text search and the agent/decision // pills narrow the LOADED page only, so both views show the same rows while // pagination totals stay server-driven. const visibleRows = React.useMemo( () => pageRows.filter((row) => matchesSearch(row, search) && matchesFilters(row, agentFilters, proposesFilters)), [pageRows, search, agentFilters, proposesFilters], ) const agentOptions = React.useMemo(() => Array.from(new Set(pageRows.map((row) => row.agentLabel))).sort((a, b) => a.localeCompare(b)), [pageRows]) // Decision filter options are the distinct humanized ACTION TYPES — a // bounded vocabulary, never prose (spec 4 Phase 4); tooltips keep the raw type. const proposesOptions = React.useMemo( () => Array.from(new Set(pageRows.map((row) => row.proposesType).filter((value): value is string => !!value))).sort( (a, b) => a.localeCompare(b), ), [pageRows], ) const proposesOptionTitles = React.useMemo(() => { const titles = new Map() for (const row of pageRows) { if (row.proposesType && row.proposesRawType && !titles.has(row.proposesType)) { titles.set(row.proposesType, row.proposesRawType) } } return titles }, [pageRows]) // Tab counts come from the org-level metrics endpoint (indexed disposition // counts), never from the loaded page. const counts = React.useMemo(() => { const dispositionCounts = metrics?.dispositionCounts ?? {} const countOf = (key: string) => dispositionCounts[key] ?? 0 return { actionRequired: countOf('pending'), approved: countOf('approved') + countOf('auto_approved') + countOf('edited'), rejected: countOf('rejected'), noneProposed: countOf('none_proposed'), } }, [metrics]) const grandTotal = counts.actionRequired + counts.approved + counts.rejected + counts.noneProposed // `waiting` stays 0 until runs can report a queued/scheduled status — the run // model only knows running|ok|error|cancelled today, so the previous // sample-derived value was always 0 as well. const lifecycle = React.useMemo( () => ({ needYou: counts.actionRequired, waiting: 0, running: runningCount }), [counts.actionRequired, runningCount], ) const totalPages = Math.max(1, Math.ceil(total / pageSize)) // Reset to page 1 when the segment/sort/pageSize change — but not on mount, // where the values just arrived from a deep link that may carry `page=3`. const skipPageResetRef = React.useRef(true) React.useEffect(() => { if (skipPageResetRef.current) { skipPageResetRef.current = false return } setPage(1) }, [segment, sortKey, pageSize]) // Mirror the queue state into the URL (debounced — `q` changes per // keystroke) so filtered queues are shareable and the detail page can // rebuild this exact view from the forwarded params. const queueQuery = React.useMemo( () => serializeQueueState({ view, segment, q: search, sort: sortKey, page, pageSize }), [view, segment, search, sortKey, page, pageSize], ) React.useEffect(() => { if ((searchParams?.toString() ?? '') === queueQuery) return const timer = window.setTimeout(() => { router.replace(queueQuery ? `/backend/caseload?${queueQuery}` : '/backend/caseload', { scroll: false }) }, 250) return () => window.clearTimeout(timer) }, [queueQuery, router, searchParams]) // Deliberate context switches clear the bulk selection; live refreshes only // prune ids that are no longer pending (spec 4 Phase 1 — an org-wide // proposal.* event must not wipe an operator's half-built selection). // They also commit any approve still inside its undo window — the operator // saw it confirmed, so leaving the queue must not silently drop it. React.useEffect(() => { setSelectedIds(new Set()); deferredApprove.flushAll() }, [segment, page, view, deferredApprove.flushAll]) React.useEffect(() => { setSelectedIds((prev) => intersectSelection(prev, pageRows.filter((row) => row.isPending).map((row) => row.id))) }, [pageRows]) // Explicit inbox cursor over the loaded, filtered row set — follows the row // id across refreshes and advances to the neighbor on dispose instead of // resetting to the top. const inboxCursor = useInboxCursor(visibleRows) const [legendOpen, setLegendOpen] = React.useState(false) const undoEntries = React.useMemo( () => Array.from(deferredApprove.pendingUndo.values()).map((target) => target.row), [deferredApprove.pendingUndo], ) const cursorRow = React.useMemo( () => visibleRows.find((row) => row.id === inboxCursor.cursorId) ?? null, [visibleRows, inboxCursor.cursorId], ) const selectableIds = React.useMemo(() => visibleRows.filter((row) => row.isPending).map((row) => row.id), [visibleRows]) const selectedRows = React.useMemo(() => visibleRows.filter((row) => selectedIds.has(row.id)), [visibleRows, selectedIds]) const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id)) const someSelected = selectedIds.size > 0 && !allSelected const clearSelection = React.useCallback(() => setSelectedIds(new Set()), []) const chooseOption = React.useCallback((proposalId: string, optionId: string) => { setOptionChoices((prev) => { if (prev.get(proposalId) === optionId) return prev const next = new Map(prev) next.set(proposalId, optionId) return next }) }, []) // The option a verdict on this row would run: the operator's pick, else the // row's default (a disposed proposal's own option, or the sole option). Null // means the agent offered alternatives and nobody has chosen between them. const chosenOptionOf = React.useCallback( (row: QueueRow): string | null => optionChoices.get(row.id) ?? row.defaultOptionId, [optionChoices], ) const toggleRow = React.useCallback((id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }, []) const toggleAll = React.useCallback(() => { setSelectedIds((prev) => { const everySelected = selectableIds.length > 0 && selectableIds.every((id) => prev.has(id)) return everySelected ? new Set() : new Set(selectableIds) }) }, [selectableIds]) // Sequentially dispose each pending row with its own optimistic-lock header. // Failures are AGGREGATED into the returned outcome (spec 4 Phase 5) — the // callers emit one flash instead of a per-row toast storm; conflict failures // carry a null message because they already surfaced on the conflict bar. const disposeTargets = React.useCallback( async ( targets: DisposeTarget[], disposition: 'approved' | 'rejected', rejectReason?: string, ): Promise => { const pending = targets.filter((target) => target.row.isPending) if (pending.length === 0) return { ok: 0, failures: [] } setBusy(true) let ok = 0 const failures: DisposeOutcome['failures'] = [] try { for (const target of pending) { const { row } = target try { await runMutation({ operation: () => withScopedApiRequestHeaders(buildOptimisticLockHeader(row.updatedAt), () => apiCallOrThrow(`/api/agent_orchestrator/proposals/${encodeURIComponent(row.id)}/dispose`, { method: 'POST', headers: { 'content-type': 'application/json' }, // `selectedOptionId` names which alternative an approve runs — // the one the operator chose, never a leader picked for them. // A reject runs none and must NOT carry one. body: JSON.stringify( rejectReason ? { disposition, reason: rejectReason } : { disposition, selectedOptionId: target.optionId }, ), }), ), context: { retryLastMutation }, mutationPayload: { disposition }, }) ok += 1 } catch (err) { const conflictSurfaced = surfaceRecordConflict(err, t) failures.push({ id: row.id, message: conflictSurfaced ? null : err instanceof Error ? err.message : null, }) } } } finally { setBusy(false) } return { ok, failures } }, [runMutation, retryLastMutation, t], ) // One flash per dispose attempt: all-success keeps the count flash, any // failure in a bulk emits the aggregate summary, a lone failure keeps the // plain error message (conflicts stay silent — the bar owns them). const flashDisposeOutcome = React.useCallback( (outcome: DisposeOutcome, attempted: number, summaryKey: string, successKey: string) => { if (outcome.failures.length === 0) { if (outcome.ok > 0) flash(t(successKey, undefined, { count: outcome.ok }), 'success') return } if (attempted > 1) { flash( t(summaryKey, undefined, { ok: outcome.ok, failed: outcome.failures.length, error: firstFailureMessage(outcome.failures) ?? t('agent_orchestrator.proposal.flash.error'), }), 'error', ) return } const message = firstFailureMessage(outcome.failures) if (message) flash(message, 'error') }, [t], ) const approveRows = React.useCallback( async (rows: QueueRow[], source: 'single' | 'bulk' = 'single'): Promise => { const pending = rows.filter((row) => row.isPending) // A proposal offering real alternatives cannot be approved without naming // one: the endpoint requires an option id and the whole point of the // envelope is that a human picks. Such rows are skipped and counted. const targets: DisposeTarget[] = [] let needsChoice = 0 for (const row of pending) { const optionId = chosenOptionOf(row) if (optionId == null) { needsChoice += 1 continue } targets.push({ row, optionId }) } if (needsChoice > 0) { flash(t('agent_orchestrator.caseload.bulk.needsOption', undefined, { count: needsChoice }), 'info') } if (targets.length === 0) return 0 // Warn-flagged single approves defer behind the undo window instead of a // confirm dialog (spec 4 Phase 3): one keystroke stays one keystroke, // mistakes stay recoverable. Bulk approve is a deliberate multi-select // act and commits immediately, as do clean rows. if (source === 'single' && targets.length === 1 && targets[0].row.riskFlagged) { const target = targets[0] deferredApprove.defer(target.row.id, target) inboxCursor.advanceAfterDispose([target.row.id]) return 1 } const outcome = await disposeTargets(targets, 'approved') flashDisposeOutcome(outcome, targets.length, 'agent_orchestrator.caseload.bulk.summary', 'agent_orchestrator.caseload.flash.approved') const failedIds = new Set(outcome.failures.map((failure) => failure.id)) const succeededIds = targets.map((target) => target.row.id).filter((id) => !failedIds.has(id)) if (succeededIds.length > 0) inboxCursor.advanceAfterDispose(succeededIds) // Successes leave the selection; failures stay selected for retry. setSelectedIds((prev) => pruneSelectionAfterDispose(prev, succeededIds)) reload() return outcome.ok }, [chosenOptionOf, disposeTargets, flashDisposeOutcome, reload, inboxCursor, deferredApprove, t], ) // The deferred committer sends the SAME guarded, lock-headered dispose the // immediate path uses (`isPending` restored — the overlay cleared it), then // refreshes counts. A 409 surfaces via the existing conflict bar. commitDeferredRef.current = (id, target) => { void disposeTargets( [{ ...target, row: { ...target.row, isPending: true, pendingUndo: false } }], 'approved', ).then((outcome) => { if (outcome.ok > 0) { reload() return } // A failed deferred commit restores the row to pending on the next // reload; a non-conflict failure still deserves its error message. const message = firstFailureMessage(outcome.failures) if (message) flash(message, 'error') reload() }) } const undoApprove = React.useCallback( (id: string) => { const target = deferredApprove.undo(id) if (target) inboxCursor.setCursor(id) }, [deferredApprove, inboxCursor], ) const openReject = React.useCallback((rows: QueueRow[]) => { setReason('') setRejectDialog({ open: true, rows }) }, []) const closeReject = React.useCallback(() => { setRejectDialog({ open: false, rows: [] }) setReason('') }, []) const confirmReject = React.useCallback(async () => { const trimmed = reason.trim() if (!trimmed || busy) return const rows = rejectDialog.rows const pending = rows.filter((row) => row.isPending) const outcome = await disposeTargets( rows.map((row) => ({ row, optionId: null })), 'rejected', trimmed, ) flashDisposeOutcome(outcome, pending.length, 'agent_orchestrator.caseload.bulk.summaryRejected', 'agent_orchestrator.caseload.flash.rejected') const failedIds = new Set(outcome.failures.map((failure) => failure.id)) const succeededIds = pending.map((row) => row.id).filter((id) => !failedIds.has(id)) if (succeededIds.length > 0) inboxCursor.advanceAfterDispose(succeededIds) setRejectDialog({ open: false, rows: [] }) setReason('') setSelectedIds((prev) => pruneSelectionAfterDispose(prev, succeededIds)) reload() }, [reason, busy, rejectDialog.rows, disposeTargets, flashDisposeOutcome, reload, inboxCursor]) const openDetail = React.useCallback( (row: QueueRow) => { // In-app navigation commits synchronously — the undo affordance is gone // once the queue is left behind. The current queue params travel on the // link so the detail page can return to this exact view (spec 4 Phase 5). deferredApprove.flushAll() router.push(`/backend/caseload/${encodeURIComponent(row.id)}${queueQuery ? `?${queueQuery}` : ''}`) }, [router, deferredApprove, queueQuery], ) // Keyboard-first triage (spec 4 Phase 2): j/k + A/R/E/X act on the cursor // row; the resolver's guards (editable focus, open dialog, modifiers) live // in the hook so a keystroke meant for a form can never dispose anything. const handleHotkey = React.useCallback( (action: CaseloadHotkeyAction) => { switch (action) { case 'next': inboxCursor.moveCursor(1) break case 'prev': inboxCursor.moveCursor(-1) break case 'open': if (!inboxCursor.cursorId) inboxCursor.moveCursor(1) break case 'approve': if (cursorRow?.isPending && !busy) void approveRows([cursorRow]) break case 'reject': if (cursorRow?.isPending && !busy) openReject([cursorRow]) break case 'edit': if (cursorRow) openDetail(cursorRow) break case 'toggleSelect': if (cursorRow?.isPending) toggleRow(cursorRow.id) break case 'legend': setLegendOpen(true) break case 'escape': inboxCursor.clearCursor() break } }, [inboxCursor, cursorRow, busy, approveRows, openReject, openDetail, toggleRow], ) useCaseloadHotkeys(view === 'inbox' && !isLoading && !error, handleHotkey) const rejectPendingCount = rejectDialog.rows.filter((row) => row.isPending).length const columns = React.useMemo[]>(() => { const base: ColumnDef[] = [ { accessorKey: 'agentLabel', header: t('agent_orchestrator.caseload.col.agent', 'Agent'), meta: { maxWidth: '240px' }, cell: ({ row }) => (
{row.original.agentLabel}
), }, { accessorKey: 'claim', header: t('agent_orchestrator.caseload.col.claim', 'Claim'), cell: ({ row }) => {row.original.claim}, }, { accessorKey: 'proposes', header: t('agent_orchestrator.caseload.col.proposes', 'Proposes'), meta: { maxWidth: '260px' }, cell: ({ row }) => row.original.proposes === '—' ? ( — ) : ( {row.original.proposes} ), }, { accessorKey: 'riskFlagged', header: t('agent_orchestrator.caseload.col.risk', 'Risk'), enableSorting: false, meta: { maxWidth: '80px' }, cell: ({ row }) => row.original.riskFlagged ? ( ) : ( — ), }, { accessorKey: 'confidencePct', header: t('agent_orchestrator.caseload.col.confidence', 'Confidence'), cell: ({ row }) => { const pct = row.original.confidencePct if (pct == null) return — const { Icon, color } = confidenceFace(pct) return (
{Math.round(pct)}%
) }, }, { accessorKey: 'waitingValue', header: t('agent_orchestrator.caseload.col.waiting', 'Waiting'), cell: ({ row }) => ( ), }, { accessorKey: 'status', header: t('agent_orchestrator.caseload.col.status', 'Status'), // A badge is an atom: clipped at the column edge it reads as a DIFFERENT // status ("Auto-approved" cut to "Auto-appro…"), so the pill sizes to its // own label instead of to the table's default status width. Truncation is // off rather than widened — the longest label is a translation away. meta: { truncate: false }, cell: ({ row }) => ( {t(`agent_orchestrator.caseload.status.${row.original.status}`)} ), }, ] const selectColumn: ColumnDef = { id: 'select', enableSorting: false, meta: { maxWidth: '44px' }, // No pending rows on this tab → nothing is selectable, so the select-all // checkbox would be a dead control (spec 4 Phase 5). header: () => selectableIds.length === 0 ? null : ( ), cell: ({ row }) => row.original.isPending ? (
event.stopPropagation()}> toggleRow(row.original.id)} aria-label={t('agent_orchestrator.caseload.bulk.selectRow')} />
) : null, } return [selectColumn, ...base] }, [t, selectedIds, selectableIds, allSelected, someSelected, toggleAll, toggleRow]) // Every quick action on a row is a DISPOSITION, so only a row that still owes // one has any. A closed segment (Approved / Rejected) offers exactly what the // decision pane offers a disposed case — the detail, which the row click // already opens — and an Actions column empty on every row is noise, not an // affordance. Both the predicate and the column presence read from here so the // header can never outlive the buttons. const rowActions = React.useCallback( (row: QueueRow) => { if (!hasQuickActions(row)) return null // A row whose agent offered alternatives has no approvable default: the // quick action is inert and points at the pane/detail where the choice lives. const optionId = chosenOptionOf(row) return (
event.stopPropagation()}> { void approveRows([row]) }} > openReject([row])} >
) }, [approveRows, openReject, busy, chosenOptionOf, t], ) const anyRowActionable = React.useMemo(() => visibleRows.some(hasQuickActions), [visibleRows]) // Defined once, composed into both the inbox (inside its container) and the // list (above the table) toolbars so the controls live where each view needs them. const searchControl = ( ) const filterPills = ( <> ) return (

{t('agent_orchestrator.caseload.title')}

{t('agent_orchestrator.caseload.subtitleQueue', undefined, { count: grandTotal, sort: t(SORT_OPTIONS.find((option) => option.key === sortKey)?.labelKey ?? SORT_OPTIONS[0].labelKey), })}

setView(value as ViewKey)}> {t('agent_orchestrator.caseload.view.inbox')} {t('agent_orchestrator.caseload.view.list')}
{!isLoading && !error && (grandTotal > 0 || runningCount > 0) ? (
{isAgentPreviewUiEnabled() ? ( {t('agent_orchestrator.agents.list.pending.backend', 'Needs backend')} } sub={t('agent_orchestrator.caseload.lifecycle.closedHint')} /> ) : null}
) : null} {isLoading ? ( ) : error ? ( ) : grandTotal === 0 && pageRows.length === 0 ? (
) : view === 'inbox' ? ( {searchControl}
{filterPills}
} rows={visibleRows} details={decisionDetails} counts={counts} total={grandTotal} segment={segment} onSegmentChange={setSegment} busy={busy} cursorId={inboxCursor.cursorId} onCursorChange={inboxCursor.setCursor} hotkeysBar={} position={ inboxCursor.cursorIndex >= 0 && total > 0 ? { current: Math.min((page - 1) * pageSize + inboxCursor.cursorIndex + 1, total), total } : null } optionChoiceOf={chosenOptionOf} onChooseOption={chooseOption} onApprove={(row) => approveRows([row])} onReject={(row) => openReject([row])} onOpenDetail={openDetail} undoBar={} footer={ total > 0 ? (
{t('agent_orchestrator.caseload.inbox.range', undefined, { from: Math.min((page - 1) * pageSize + 1, total), to: Math.min(page * pageSize, total), total, })} {total > pageSize ? ( { setPageSize(next); setPage(1) }} pageSizeOptions={[10, 20, 50]} showInfo={false} showFirstLast={false} /> ) : null}
) : null } /> ) : ( <>
{searchControl}
{filterPills}
{undoEntries.length > 0 ? (
) : null} {selectedRows.length > 0 ? (
{t('agent_orchestrator.caseload.bulk.selected', undefined, { count: selectedRows.length })}
) : null} columns={columns} data={visibleRows} sortable rowActions={anyRowActionable ? rowActions : undefined} onRowClick={(row) => openDetail(row)} pagination={{ page, pageSize, total, totalPages, totalIsCapped, onPageChange: setPage, pageSizeOptions: [10, 20, 50], onPageSizeChange: (next) => { setPageSize(next); setPage(1) }, }} /> )} { if (!next) closeReject() }} title={t('agent_orchestrator.proposal.reject.heading')} description={ rejectPendingCount === 1 ? t('agent_orchestrator.caseload.reject.descriptionOne') : t('agent_orchestrator.caseload.reject.description', undefined, { count: rejectPendingCount }) } reason={reason} onReasonChange={setReason} reasonPlaceholder={t('agent_orchestrator.proposal.reject.reasonPlaceholder')} confirmLabel={t('agent_orchestrator.proposal.reject.confirm')} onConfirm={() => { void confirmReject() }} busy={busy} /> ) } function MultiSelectPill({ allLabel, options, optionTitles, selected, onChange, }: { allLabel: string options: string[] /** Optional per-option tooltip (e.g. the raw action type behind a humanized label). */ optionTitles?: Map selected: string[] onChange: (next: string[]) => void }) { const t = useT() const label = selected.length === 0 ? allLabel : selected.length === 1 ? selected[0] : t('agent_orchestrator.caseload.bulk.selected', undefined, { count: selected.length }) const toggle = (value: string) => onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]) return (
{options.length === 0 ? (

—

) : ( options.map((value) => ( )) )}
) } function StatusTabs({ segment, counts, total, onSegmentChange, className, }: { segment: SegmentKey counts: { actionRequired: number; approved: number; rejected: number } total: number onSegmentChange: (segment: SegmentKey) => void className?: string }) { const t = useT() const tabs: Array<{ key: SegmentKey; label: string; count: number }> = [ { key: 'actionRequired', label: t('agent_orchestrator.caseload.status.actionRequired'), count: counts.actionRequired }, { key: 'approved', label: t('agent_orchestrator.caseload.status.approved'), count: counts.approved }, { key: 'rejected', label: t('agent_orchestrator.caseload.status.rejected'), count: counts.rejected }, { key: 'all', label: t('agent_orchestrator.caseload.filters.all'), count: total }, ] return (
{tabs.map((tab) => { const active = segment === tab.key return ( ) })}
) } /** * Inline shortcut hint + the `?`-triggered full legend. The popover content is * stamped `data-caseload-hotkey-modal` so the hotkey hook treats an open * legend as a modal layer (only Escape acts, handled natively by Radix). * Key cap letters (J/K/A/…) are physical key names, not translatable copy. */ function HotkeysHint({ legendOpen, onLegendOpenChange, }: { legendOpen: boolean onLegendOpenChange: (open: boolean) => void }) { const t = useT() const legendRows: Array<{ keys: string[]; label: string }> = [ { keys: ['J', 'K'], label: t('agent_orchestrator.caseload.hotkeys.navigate') }, { keys: ['Enter'], label: t('agent_orchestrator.caseload.hotkeys.open') }, { keys: ['A'], label: t('agent_orchestrator.caseload.hotkeys.approve') }, { keys: ['R'], label: t('agent_orchestrator.caseload.hotkeys.reject') }, { keys: ['E'], label: t('agent_orchestrator.caseload.hotkeys.edit') }, { keys: ['X'], label: t('agent_orchestrator.caseload.hotkeys.select') }, { keys: ['Esc'], label: t('agent_orchestrator.caseload.hotkeys.close') }, ] return (
J K {t('agent_orchestrator.caseload.hotkeys.navigate')} A {t('agent_orchestrator.caseload.hotkeys.approve')} R {t('agent_orchestrator.caseload.hotkeys.reject')} E {t('agent_orchestrator.caseload.hotkeys.edit')}

{t('agent_orchestrator.caseload.hotkeys.title')}

{legendRows.map((row) => (
{row.label}
{row.keys.map((key) => ( {key} ))}
))}
) } /** * Row-level guardrail signal (spec 4 Phase 3): warn verdicts get the warning * tone, any block/fail escalates the whole chip to the error tone — the first * thing scanned on a row, so speed never means blind approval. */ function GuardRiskChip({ warn, block, className }: { warn: number; block: number; className?: string }) { const t = useT() const total = warn + block if (total === 0) return null const label = t('agent_orchestrator.caseload.inbox.riskFlagged', undefined, { count: total }) return ( 0 ? 'bg-status-error-bg text-status-error-text' : 'bg-status-warning-bg text-status-warning-text', className, )} > {total} ) } /** * Inline undo affordance for deferred (warn-flagged) approves. The flash * primitive carries no action button, so the bar lives inside the queue panel * instead — `role="status"` announces it to screen readers. */ function UndoBar({ entries, onUndo }: { entries: QueueRow[]; onUndo: (id: string) => void }) { const t = useT() if (entries.length === 0) return null return (
{entries.map((row) => (
{t('agent_orchestrator.caseload.undo.approved', undefined, { summary: headlineOf(row) })}
))}
) } // "7d" on its own reads as a mystery number — the clock icon + tooltip make it // unmistakably "how long this has been waiting". function WaitingLabel({ value, className }: { value: string; className?: string }) { const t = useT() return ( {value} ) } function ExceptionsInbox({ toolbar, rows, details, counts, total, segment, onSegmentChange, busy, cursorId, onCursorChange, position, optionChoiceOf, onChooseOption, onApprove, onReject, onOpenDetail, hotkeysBar, undoBar, footer, }: { toolbar: React.ReactNode rows: QueueRow[] details: Map counts: { actionRequired: number; approved: number; rejected: number } total: number segment: SegmentKey onSegmentChange: (segment: SegmentKey) => void busy: boolean cursorId: string | null onCursorChange: (id: string) => void position: { current: number; total: number } | null optionChoiceOf: (row: QueueRow) => string | null onChooseOption: (proposalId: string, optionId: string) => void onApprove: (row: QueueRow) => void onReject: (row: QueueRow) => void onOpenDetail: (row: QueueRow) => void hotkeysBar?: React.ReactNode undoBar?: React.ReactNode footer?: React.ReactNode }) { const t = useT() const selected = rows.find((row) => row.id === cursorId) ?? null // When the queue empties (last item disposed), return focus to the tab bar // so the operator's next keystroke/tab lands on a segment switch. const tabsRef = React.useRef(null) const hadRowsRef = React.useRef(rows.length > 0) React.useEffect(() => { if (rows.length === 0 && hadRowsRef.current) { tabsRef.current?.querySelector('button')?.focus() } hadRowsRef.current = rows.length > 0 }, [rows.length]) // Roving focus: j/k keeps the cursor row visible, and when DOM focus was // already on a row (Tab or a previous move), it follows the cursor so // Enter/Space keep acting on what the operator sees highlighted. const listRef = React.useRef(null) React.useEffect(() => { if (!cursorId) return const list = listRef.current if (!list) return const active = list.querySelector(`[data-inbox-row="${CSS.escape(cursorId)}"]`) if (!active) return active.scrollIntoView({ block: 'nearest' }) const focused = document.activeElement if (focused instanceof HTMLElement && focused !== active && focused.hasAttribute('data-inbox-row')) { active.focus() } }, [cursorId]) return (
{toolbar}
{hotkeysBar} {rows.length === 0 ? (
{t('agent_orchestrator.caseload.empty')}
) : (
    {rows.map((row, index) => { const active = row.id === cursorId const face = row.confidencePct != null ? confidenceFace(row.confidencePct) : null return (
  • ) })}
)} {undoBar} {footer}
{selected ? ( ) : (

{t('agent_orchestrator.caseload.inbox.emptyTitle')}

{t('agent_orchestrator.caseload.inbox.emptyDescription')}

)}
) } function DecisionPane({ row, detail, busy, position, selectedOptionId, onChooseOption, onApprove, onReject, onOpenDetail, }: { row: QueueRow detail: DecisionDetail | null busy: boolean position: { current: number; total: number } | null selectedOptionId: string | null onChooseOption: (proposalId: string, optionId: string) => void onApprove: (row: QueueRow) => void onReject: (row: QueueRow) => void onOpenDetail: (row: QueueRow) => void }) { const t = useT() const face = row.confidencePct != null ? confidenceFace(row.confidencePct) : null const optionChosen = selectedOptionId != null return (
{row.claim}
{position ? ( {t('agent_orchestrator.caseload.inbox.position', undefined, { position: position.current, total: position.total, })} ) : null} {t(`agent_orchestrator.caseload.status.${row.status}`)}

{headlineOf(row)}

{detail ? ( ) : null}

{headlineOf(row)}

{t('agent_orchestrator.caseload.inbox.recommends', undefined, { agent: row.agentLabel })}

{row.confidencePct != null ? (
{face ? : null} {Math.round(row.confidencePct)}%
) : null}
{row.isPending ? (

{t('agent_orchestrator.caseload.inbox.needsDecision')}

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

) : null} {/* The alternatives the agent weighed, ranked, with the one selection control a verdict reads. Without this the operator sees a verdict and never the choice behind it. */} {detail ? (

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

onChooseOption(row.id, optionId) : undefined} disabled={busy} autoDispositionBlock={row.autoDispositionBlock} showChooseHint={row.isPending && row.optionCount > 1} />
) : null} {detail ? : null} {detail ? ( ) : null}
{row.isPending ? ( <> ) : ( )}
) }