"use client" /** * Live per-adapter narration of an agent's web searches. * * Subscribes to `agent_orchestrator.web_search.progress`, which the tools emit * from the MCP process and pg NOTIFY carries to the browser. The operator's * question is "which source is answering, and how is it doing" — so this shows * one row per adapter holding its latest state, rather than an append-only log * of every transition. Engine-level steps carry no adapter and describe the run * as a whole, so they drive the caption instead of adding nameless rows. */ import * as React from 'react' import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { useT } from '@open-mercato/shared/lib/i18n/context' export type WebSearchProgressEvent = { runId?: string | null agentId?: string | null sequence?: number phase?: string event?: string adapterId?: string | null detail?: string | null latencyMs?: number | null resultCount?: number | null query?: string } export type WebSearchActivityProps = { /** Only show steps for this run; omit to follow whatever is running. */ runId?: string | null /** Cap on adapter rows so a long run cannot grow the DOM without bound. */ maxSteps?: number className?: string } type AdapterRow = { adapterId: string event: string detail: string | null latencyMs: number | null resultCount: number | null seq: number } type ActivityState = { query: string stage: string | null adapters: readonly AdapterRow[] } const EMPTY_STATE: ActivityState = { query: '', stage: null, adapters: [] } const VARIANTS: Readonly> = { started: 'info', queued: 'neutral', ok: 'success', cached: 'success', completed: 'success', empty: 'neutral', unavailable: 'neutral', cancelled: 'neutral', blocked: 'warning', escalated: 'warning', timeout: 'warning', error: 'error', } /** Engine-level phases, in the order the scheduler reaches them. */ const STAGE_BY_PHASE: Readonly> = { fetch: 'reading', fusion: 'merging', done: 'done', } const FAILED_EVENTS = new Set(['blocked', 'timeout', 'error', 'unavailable', 'empty']) const DEFAULT_MAX_STEPS = 40 export function WebSearchActivity({ runId, maxSteps = DEFAULT_MAX_STEPS, className }: WebSearchActivityProps) { const t = useT() const [state, setState] = React.useState(EMPTY_STATE) useAppEvent('agent_orchestrator.web_search.progress', (event) => { const payload = event.payload as WebSearchProgressEvent | undefined if (!payload?.event) return if (runId && payload.runId && payload.runId !== runId) return setState((current) => { const query = payload.query ?? current.query const adapterId = payload.adapterId ?? null if (!adapterId) { const stage = payload.phase ? (STAGE_BY_PHASE[payload.phase] ?? current.stage) : current.stage return { ...current, query, stage } } const row: AdapterRow = { adapterId, event: payload.event ?? 'started', detail: payload.detail ?? null, latencyMs: payload.latencyMs ?? null, resultCount: payload.resultCount ?? null, seq: payload.sequence ?? current.adapters.length, } const existing = current.adapters.findIndex((entry) => entry.adapterId === adapterId) if (existing === -1) { return { ...current, query, adapters: [...current.adapters, row].slice(-maxSteps) } } // An adapter reports several times (started, then its outcome). Keeping the // row in place means the list stays a stable roster instead of reordering // under the operator every time a source progresses. const adapters = [...current.adapters] adapters[existing] = row return { ...current, query, adapters } }) }) if (state.adapters.length === 0 && state.stage === null) return null return (
{t('agent_orchestrator.webSearch.activity.title', 'Web search')} {state.stage ? ( {t(`agent_orchestrator.webSearch.activity.stage.${state.stage}`, state.stage)} ) : null}
{state.query ? (

{state.query}

) : null} {state.adapters.length > 0 ? (
    {state.adapters.map((row) => (
  • {t(`agent_orchestrator.webSearch.activity.event.${row.event}`, row.event)} {row.adapterId} {row.resultCount !== null ? ( {t('agent_orchestrator.webSearch.activity.results', '{count} results', { count: String(row.resultCount), })} ) : null} {row.latencyMs !== null ? ( {`${row.latencyMs}ms`} ) : null}
    {/* Only failures carry a reason worth the extra line; a healthy adapter's detail just repeats what the badge already says. */} {row.detail && FAILED_EVENTS.has(row.event) ? (

    {row.detail}

    ) : null}
  • ))}
) : null}
) } export default WebSearchActivity