import { Popover as DesignSystemPopover, Spinner as DesignSystemSpinner, Tooltip as DesignSystemTooltip, } from "@agent-native/toolkit/design-system"; import { IconAlertCircle, IconCheck, IconClock, IconExternalLink, IconLoader2, IconPlayerStop, IconSubtask, IconX, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentRun, ProgressStatus } from "../../progress/types.js"; import { agentNativePath } from "../api-path.js"; import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, } from "../components/ui/dropdown-menu.js"; import { useFormatters, useT } from "../i18n.js"; import { useChangeVersion } from "../use-change-version.js"; import { usePausingInterval } from "../use-pausing-interval.js"; import { cn } from "../utils.js"; type AgentRunDto = AgentRun; type RunsTrayTriggerVariant = "icon" | "pill"; const RUN_CHANGE_SETTLE_MS = 250; interface RunsTrayProps { /** Poll interval in ms. 0 disables. Default 3000. */ pollMs?: number; /** Max runs to show in the dropdown. Default 5. */ limit?: number; /** Hide the trigger entirely when no active runs. Default true. */ hideWhenIdle?: boolean; /** Include recent terminal runs instead of active runs only. Defaults to !hideWhenIdle. */ showRecent?: boolean; /** Compact icon for app headers, or a labeled pill for the agent panel. */ triggerVariant?: RunsTrayTriggerVariant; /** Called when a run can open a related agent chat thread. */ onOpenThread?: (threadId: string, run: AgentRunDto) => void; align?: "start" | "center" | "end"; className?: string; } interface RunsTrayState { runs: AgentRunDto[]; hasRuns: boolean; activeCount: number; failedCount: number; terminalCount: number; triggerLabel: string; TriggerIcon: typeof IconLoader2; triggerTone: string; dismissRun: (runId: string) => void; stopRun: (runId: string) => void; } function useRunsTrayState({ pollMs = 3000, limit = 5, hideWhenIdle = true, showRecent, }: Pick< RunsTrayProps, "pollMs" | "limit" | "hideWhenIdle" | "showRecent" >): RunsTrayState { const t = useT(); const [runs, setRuns] = useState([]); const includeRecent = showRecent ?? !hideWhenIdle; const runsVersion = useChangeVersion("runs"); const refresh = useCallback(async () => { try { const query = new URLSearchParams({ limit: String(limit) }); if (!includeRecent) query.set("active", "true"); const res = await fetch( agentNativePath(`/_agent-native/runs?${query.toString()}`), ); if (!res.ok) return; const rows = (await res.json()) as AgentRunDto[]; setRuns(rows); } catch { // best-effort } }, [includeRecent, limit]); useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { if (runsVersion <= 0) return; const timeout = window.setTimeout( () => void refresh(), RUN_CHANGE_SETTLE_MS, ); return () => window.clearTimeout(timeout); }, [refresh, runsVersion]); usePausingInterval(refresh, pollMs); const dismissRun = useCallback( async (runId: string) => { setRuns((current) => current.filter((run) => run.id !== runId)); try { const res = await fetch( agentNativePath(`/_agent-native/runs/${runId}`), { method: "DELETE", headers: { "X-Agent-Native-CSRF": "1" }, }, ); if (!res.ok) throw new Error(`Dismiss failed (${res.status})`); } catch { refresh(); } }, [refresh], ); const stopRun = useCallback( async (runId: string) => { // Optimistic: mark as cancelled immediately so the UI is responsive. setRuns((current) => current.map((run) => run.id === runId ? { ...run, status: "cancelled" as ProgressStatus } : run, ), ); try { const res = await fetch( agentNativePath(`/_agent-native/agent-chat/runs/${runId}/stop`), { method: "POST", headers: { "X-Agent-Native-CSRF": "1" }, }, ); if (!res.ok) throw new Error(`Stop failed (${res.status})`); } catch { // Reconcile from server on failure refresh(); } }, [refresh], ); const hasRuns = runs.length > 0; const activeCount = useMemo( () => runs.filter((run) => run.status === "running").length, [runs], ); const failedCount = useMemo( () => runs.filter((run) => run.status === "failed").length, [runs], ); const triggerLabel = activeCount > 0 ? t("runsTray.activeRun", { count: activeCount }) : failedCount > 0 ? t("runsTray.failedRun", { count: failedCount }) : hasRuns ? t("runsTray.recentRuns") : t("runsTray.noRecentRuns"); const TriggerIcon = activeCount > 0 ? IconLoader2 : failedCount > 0 ? IconAlertCircle : IconSubtask; const triggerTone = activeCount > 0 ? "text-primary" : failedCount > 0 ? "text-destructive" : "text-muted-foreground"; const terminalCount = runs.length - activeCount; return { runs, hasRuns, activeCount, failedCount, terminalCount, triggerLabel, TriggerIcon, triggerTone, dismissRun, stopRun, }; } /** * Header-bar progress indicator. Shows a spinner icon or labeled Runs pill * with a count badge when runs are active; opens a popover with live progress. * Same inline-header pattern as — drop it into the * header, no floating overlay over the main content. */ export function RunsTray({ pollMs = 3000, limit = 5, hideWhenIdle = true, showRecent, triggerVariant = "icon", onOpenThread, align = "end", className, }: RunsTrayProps) { const t = useT(); const [open, setOpen] = useState(false); const { runs, hasRuns, activeCount, failedCount, terminalCount, triggerLabel, TriggerIcon, triggerTone, dismissRun, stopRun, } = useRunsTrayState({ pollMs, limit, hideWhenIdle, showRecent, }); if (!hasRuns && hideWhenIdle) return null; const trigger = ( ); return ( } align={align} placement="bottom" className="an-runs-tray__menu w-80 max-w-[calc(100vw-24px)] p-0" > ); } export function RunsTrayMenuItem({ pollMs = 3000, limit = 5, hideWhenIdle = false, showRecent = true, onOpenThread, }: Pick< RunsTrayProps, "pollMs" | "limit" | "hideWhenIdle" | "showRecent" | "onOpenThread" >) { const t = useT(); const [submenuOpen, setSubmenuOpen] = useState(false); const { runs, hasRuns, activeCount, failedCount, terminalCount, triggerLabel, TriggerIcon, triggerTone, dismissRun, stopRun, } = useRunsTrayState({ pollMs, limit, hideWhenIdle, showRecent, }); if (!hasRuns && hideWhenIdle) return null; return ( { event.preventDefault(); setSubmenuOpen(true); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSubmenuOpen(true); } }} > 0 && "animate-spin")} aria-hidden /> {t("runsTray.agentRuns")} {activeCount > 0 ? ( {activeCount > 9 ? "9+" : activeCount} ) : failedCount > 0 ? ( {failedCount > 9 ? "9+" : failedCount} ) : null} ); } function RunsTrayContent({ runs, hasRuns, activeCount, terminalCount, onDismiss, onStop, onOpenThread, }: { runs: AgentRunDto[]; hasRuns: boolean; activeCount: number; terminalCount: number; onDismiss: (runId: string) => void; onStop: (runId: string) => void; onOpenThread?: (threadId: string, run: AgentRunDto) => void; }) { const t = useT(); return ( <>
{t("runsTray.agentRuns")}
{activeCount > 0 ? terminalCount > 0 ? t("runsTray.summaryRunningRecent", { activeCount, terminalCount, }) : t("runsTray.summaryRunning", { activeCount }) : hasRuns ? t("runsTray.summaryRecent", { count: runs.length }) : t("runsTray.noTrackedWorkYet")}
{hasRuns ? (
{runs.map((run) => ( ))}
) : (
{t("runsTray.noRecentRuns")}

{t("runsTray.emptyDescription")}

)} ); } function getRunThreadId(run: AgentRunDto): string | undefined { const metadata = run.metadata ?? {}; const direct = typeof metadata.threadId === "string" ? metadata.threadId : typeof metadata.thread_id === "string" ? metadata.thread_id : undefined; if (direct?.trim()) return direct.trim(); const surfaceUrl = typeof metadata.surfaceUrl === "string" ? metadata.surfaceUrl : undefined; const match = surfaceUrl?.match(/^agent-native:\/\/threads\/(.+)$/); return match?.[1] ? decodeURIComponent(match[1]) : undefined; } function formatRunTime( run: AgentRunDto, t: ReturnType, formatDate: ReturnType["formatDate"], ): string { const when = run.completedAt ?? run.updatedAt ?? run.startedAt; const timestamp = Date.parse(when); if (!Number.isFinite(timestamp)) return ""; const diffMs = Date.now() - timestamp; const isRunning = run.status === "running"; if (diffMs < 30_000) { return t( isRunning ? "runsTray.updatedJustNow" : "runsTray.finishedJustNow", ); } const minutes = Math.floor(diffMs / 60_000); if (minutes < 60) { return t( isRunning ? "runsTray.updatedMinutes" : "runsTray.finishedMinutes", { count: minutes, }, ); } const hours = Math.floor(minutes / 60); if (hours < 24) { return t(isRunning ? "runsTray.updatedHours" : "runsTray.finishedHours", { count: hours, }); } return t(isRunning ? "runsTray.updatedDate" : "runsTray.finishedDate", { date: formatDate(timestamp, { month: "short", day: "numeric" }), }); } function isAgentTeamRun(run: AgentRunDto): boolean { return ( typeof run.metadata === "object" && run.metadata !== null && (run.metadata as Record).kind === "agent-team" ); } function RunRow({ run, onDismiss, onStop, onOpenThread, }: { run: AgentRunDto; onDismiss: (runId: string) => void; onStop: (runId: string) => void; onOpenThread?: (threadId: string, run: AgentRunDto) => void; }) { const t = useT(); const { formatDate } = useFormatters(); const threadId = getRunThreadId(run); const isRunning = run.status === "running"; const canStop = isRunning && isAgentTeamRun(run); return (
{run.title}
{run.step ? (
{run.step}
) : null}
{run.percent != null || isRunning ? (
{run.percent != null ? (
) : (
)}
) : null}
{formatRunTime(run, t, formatDate)}
{threadId && onOpenThread ? ( ) : null} {canStop ? ( ) : null} {!isRunning ? ( ) : null}
); } const STATUS_COPY_KEYS: Record = { running: "runsTray.statusRunning", succeeded: "runsTray.statusDone", failed: "runsTray.statusFailed", cancelled: "runsTray.statusStopped", }; const STATUS_PILL_STYLES: Record = { running: "bg-primary/10 text-primary", succeeded: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", failed: "bg-destructive/10 text-destructive", cancelled: "bg-muted text-muted-foreground", }; function StatusPill({ status }: { status: ProgressStatus }) { const t = useT(); const { Icon, className } = STATUS_GLYPHS[status]; const spinClass = status === "running" ? " animate-spin" : ""; return ( {t(STATUS_COPY_KEYS[status])} ); } // dark: variants only where there's no semantic token for the colour // (e.g. success green isn't in shadcn's default palette). const STATUS_GLYPHS: Record< ProgressStatus, { Icon: typeof IconLoader2; className: string } > = { running: { Icon: IconLoader2, className: "text-primary" }, succeeded: { Icon: IconCheck, className: "text-emerald-600 dark:text-emerald-400", }, failed: { Icon: IconAlertCircle, className: "text-destructive" }, cancelled: { Icon: IconClock, className: "text-muted-foreground" }, };