/** * @file RunHistory.tsx * @description Past and live dashboard runs. Moved verbatim out of * `pages/Run.tsx` so the Run page and the Workspace page can both mount the * same run list. * * Two exported pieces, rendered exactly where the page rendered them before: * - `ActiveRunsSwitcher` — the header button (live count, or total when * nothing is running) that opens the modal; * - `RunsModal` — the merged list itself: live in-memory handles from * `activeRuns` deduped against persistent `runHistory`, newest first, with * status / mode chip filters, a free-text search, and the per-row Attach / * Resume / View actions. * * Props only: no API call of its own. The page passes `activeRuns` and * `runHistory` in and gets attach / resume / view / refresh back out through * callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`. * * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Play, RefreshCw, Info, ExternalLink, X, ListOrdered, Search, RotateCcw, Eye, } from "lucide-react"; import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api"; // Minimal StatusPill component (from deleted RunConsole) function StatusPill({ status, }: { status: RunStatus | "completed" | "error" | "killed" | "abandoned"; }) { const colors: Record = { running: "bg-status-success/10 text-status-success border-status-success/30", gone: "bg-surface-3 text-fg-secondary border-border", completed: "bg-sky-500/10 text-sky-300 border-sky-500/30", error: "bg-status-danger/10 text-status-danger border-status-danger/30", killed: "bg-surface-3 text-fg-secondary border-border", abandoned: "bg-surface-3 text-fg-secondary border-border", }; const color = colors[status] || colors.abandoned; return ( {status} ); } type RunStatusFilter = | "all" | "running" | "spawning" | "completed" | "error" | "killed" | "abandoned"; export interface UnifiedRunRow { id: string; sessionId: string | null; cwd: string; model: string | null; status: RunStatus | "completed" | "error" | "killed" | "abandoned"; promptPreview: string; startedAt: number; endedAt: number | null; isLive: boolean; } export function ActiveRunsSwitcher({ activeRuns, currentHandleId, onAttach, runHistory, onResumeFromHistory, onViewFromHistory, onRefresh, }: { activeRuns: RunListResponse | null; currentHandleId: string | null; onAttach: (id: string) => void; runHistory: DashboardRunHistoryItem[]; onResumeFromHistory: (item: DashboardRunHistoryItem) => void; onViewFromHistory: (item: DashboardRunHistoryItem) => void; onRefresh: () => void; }) { const { t } = useTranslation("run"); const [open, setOpen] = useState(false); // Lock body scroll while the modal is open and let Esc close it. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; }; }, [open]); // Merge live in-memory handles + persistent history into one row list. // Live entries dedupe past-history entries with the same id. const rows: UnifiedRunRow[] = useMemo(() => { const out: UnifiedRunRow[] = []; const seen = new Set(); if (activeRuns) { for (const r of activeRuns.items) { seen.add(r.id); out.push({ id: r.id, sessionId: r.sessionId, cwd: r.cwd || "", model: r.model, status: r.status, promptPreview: r.promptPreview || "", startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0, endedAt: null, isLive: r.status === "running", }); } } for (const h of runHistory) { if (seen.has(h.id)) continue; seen.add(h.id); const startedTs = new Date(h.started_at).getTime() || 0; const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null; out.push({ id: h.id, sessionId: h.session_id, cwd: h.cwd, model: h.model, status: h.status, promptPreview: h.prompt_preview || "", startedAt: startedTs, endedAt: endedTs, isLive: h.isLive, }); } out.sort((a, b) => b.startedAt - a.startedAt); return out; }, [activeRuns, runHistory]); const liveCount = rows.filter((r) => r.isLive).length; const totalCount = rows.length; return ( <> {open && ( { setOpen(false); onAttach(id); }} onResume={(item) => { setOpen(false); onResumeFromHistory(item); }} onView={(item) => { setOpen(false); onViewFromHistory(item); }} runHistory={runHistory} onClose={() => setOpen(false)} onRefresh={onRefresh} /> )} ); } export function RunsModal({ rows, currentHandleId, onAttach, onResume, onView, runHistory, onClose, onRefresh, }: { rows: UnifiedRunRow[]; currentHandleId: string | null; onAttach: (id: string) => void; onResume: (item: DashboardRunHistoryItem) => void; onView: (item: DashboardRunHistoryItem) => void; runHistory: DashboardRunHistoryItem[]; onClose: () => void; onRefresh: () => void; }) { const { t } = useTranslation("run"); const [statusFilter, setStatusFilter] = useState("all"); const [search, setSearch] = useState(""); // Snappy refresh while the modal is the foreground UI: pull immediately // on open + every 2 s after that. Combined with the page-level 5 s poll // and the WS run_status broadcasts, this guarantees that any state // change - lifecycle event, sibling tab, manual DB tweak, boot // reconciliation - surfaces here within a couple of seconds. useEffect(() => { onRefresh(); const tick = setInterval(onRefresh, 2000); return () => clearInterval(tick); }, [onRefresh]); const counts = useMemo(() => { const byStatus: Record = { all: rows.length }; for (const r of rows) { byStatus[r.status] = (byStatus[r.status] || 0) + 1; } return { byStatus }; }, [rows]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return rows.filter((r) => { if (statusFilter !== "all" && r.status !== statusFilter) return false; if (!q) return true; const hay = r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || ""); return hay.toLowerCase().includes(q); }); }, [rows, statusFilter, search]); const historyById = useMemo(() => { const m = new Map(); for (const h of runHistory) m.set(h.id, h); return m; }, [runHistory]); const STATUSES: RunStatusFilter[] = [ "all", "running", "completed", "error", "killed", "abandoned", ]; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}

{t("runs.modalTitle", "Dashboard runs")}

{t( "runs.modalSubtitle", "Every run started from this dashboard, regardless of status" )}

{t("runs.allSessionsLink")}
{/* Filter bar */}
setSearch(e.target.value)} placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")} className="flex-1 bg-transparent text-[12px] text-fg-primary placeholder:text-fg-muted focus:outline-none" /> {search && ( )}
({ value: s, label: s === "all" ? t("runs.allLabel", "All") : t(`status.${s}`), count: counts.byStatus[s] || 0, }))} onChange={(v) => setStatusFilter(v as RunStatusFilter)} />
{/* List */}
{filtered.length === 0 ? (
{rows.length === 0 ? t( "runs.modalEmpty", "No dashboard runs yet. Start one below to populate this list." ) : t("runs.modalEmptyFiltered", "No runs match these filters.")}
) : ( filtered.map((r) => { const isCurrent = r.id === currentHandleId; return ( onAttach(r.id)} onResume={() => { const h = historyById.get(r.id); if (h) onResume(h); }} onView={() => { const h = historyById.get(r.id); if (h) onView(h); }} /> ); }) )}
{/* Footer */}
{t("runs.scopeNote")} {filtered.length} / {rows.length}
); } function FilterChipGroup({ label, value, options, onChange, }: { label: string; value: T; options: { value: T; label: string; count: number }[]; onChange: (v: T) => void; }) { return (
{label} {options.map((opt) => { const active = value === opt.value; const dim = opt.count === 0 && opt.value !== "all"; return ( ); })}
); } function UnifiedRunRowView({ row, isCurrent, onAttach, onResume, onView, }: { row: UnifiedRunRow; isCurrent: boolean; onAttach: () => void; onResume: () => void; onView: () => void; }) { const { t } = useTranslation("run"); const startedDate = new Date(row.startedAt); const startedLabel = isNaN(startedDate.getTime()) ? "-" : startedDate.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); // Without mode distinction, offer resume for any finished run with a session const canResume = !!row.sessionId && !row.isLive; const canView = !!row.sessionId && !row.isLive; return (
{row.isLive && ( {t("runs.liveBadge", "live")} )} {isCurrent && ( {t("runs.currentBadge", "current")} )} {row.isLive && !isCurrent && ( )} {canResume && ( )} {canView && ( )}
{row.promptPreview && (
{row.promptPreview}
)}
{row.cwd}
{startedLabel} {row.model && · {row.model}} {row.sessionId && ( {row.sessionId.slice(0, 8)} )}
); }