Files
Claude-Code-Monitor/client/src/components/run/RunHistory.tsx
T
nntrivi2001 174c650624 feat(run): show externally started Claude sessions in the active-runs list
The Workspace active-runs list only knew about runs this dashboard spawned,
so two `claude` sessions started by hand in terminal tabs showed up nowhere —
the list read "no active runs" while two agents were working.

Poll GET /api/sessions?status=active alongside the run list and merge those
sessions in as live rows, deduped against dashboard runs by session_id and
filtered to local sources with a cwd (a remote-source or cwd-less session
cannot be resumed on this machine).

External rows get no Attach action: the dashboard owns no tmux session for
them, so there is no PTY to bridge. They offer Resume, which reuses the
existing ensure-lane + start-with-resumeSessionId path to spawn a new
tmux-backed `claude --resume` in that folder — a second process on the same
transcript, not a view of the original terminal.
2026-08-18 09:49:03 +07:00

617 lines
21 KiB
TypeScript

/**
* @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.
*
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd.
*
* Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` 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ĩ <vinnt@smartgift.vn>
*/
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";
import type { Session } from "../../lib/types";
// Minimal StatusPill component (from deleted RunConsole)
function StatusPill({
status,
}: {
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
}) {
const colors: Record<string, string> = {
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 (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{status}</span>
);
}
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;
/** Live Claude Code session this dashboard did not spawn — no tmux session to
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
* action. */
external?: boolean;
}
export function ActiveRunsSwitcher({
activeRuns,
currentHandleId,
onAttach,
runHistory,
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onRefresh,
}: {
activeRuns: RunListResponse | null;
currentHandleId: string | null;
onAttach: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
* cwd-less sessions are ignored — neither can be resumed on this machine. */
externalSessions?: Session[];
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 + externally started
// sessions into one row list. Live entries dedupe past-history entries with
// the same id; a session id already covered by a run row is never repeated as
// an external row.
const { rows, historyItems } = useMemo(() => {
const out: UnifiedRunRow[] = [];
const seen = new Set<string>();
const seenSessions = new Set<string>();
if (activeRuns) {
for (const r of activeRuns.items) {
seen.add(r.id);
if (r.sessionId) seenSessions.add(r.sessionId);
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);
if (h.session_id) seenSessions.add(h.session_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,
});
}
// Externally started sessions: shown as live rows, and mirrored as
// synthetic history items so the existing resume path (which only reads
// session_id / cwd / model) works on them unchanged.
const synthetic: DashboardRunHistoryItem[] = [];
for (const s of externalSessions) {
if (!s.cwd) continue;
if (s.source && s.source !== "local") continue;
if (seenSessions.has(s.id)) continue;
seenSessions.add(s.id);
synthetic.push({
id: `session:${s.id}`,
session_id: s.id,
cwd: s.cwd,
model: s.model,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: s.name,
status: "running",
exit_code: null,
started_at: s.started_at,
ended_at: null,
isLive: true,
});
out.push({
id: `session:${s.id}`,
sessionId: s.id,
cwd: s.cwd,
model: s.model,
status: "running",
promptPreview: s.name || "",
startedAt: new Date(s.started_at).getTime() || 0,
endedAt: null,
isLive: true,
external: true,
});
}
out.sort((a, b) => b.startedAt - a.startedAt);
return {
rows: out,
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
};
}, [activeRuns, runHistory, externalSessions]);
const liveCount = rows.filter((r) => r.isLive).length;
const totalCount = rows.length;
return (
<>
<button
onClick={() => setOpen(true)}
disabled={totalCount === 0}
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
liveCount > 0
? "border-status-success/40 bg-status-success/10 text-status-success hover:bg-status-success/15"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
}`}
>
<ListOrdered className="w-3.5 h-3.5" />
{liveCount > 0 ? (
<>
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.viewActive_other", { count: liveCount })}
</>
) : (
<>
{t("runs.switcher")}
{totalCount > 0 && <span className="text-fg-muted font-mono">{totalCount}</span>}
</>
)}
</button>
{open && (
<RunsModal
rows={rows}
currentHandleId={currentHandleId}
onAttach={(id) => {
setOpen(false);
onAttach(id);
}}
onResume={(item) => {
setOpen(false);
onResumeFromHistory(item);
}}
onView={(item) => {
setOpen(false);
onViewFromHistory(item);
}}
runHistory={historyItems}
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<RunStatusFilter>("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<string, number> = { 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<string, DashboardRunHistoryItem>();
for (const h of runHistory) m.set(h.id, h);
return m;
}, [runHistory]);
const STATUSES: RunStatusFilter[] = [
"all",
"running",
"completed",
"error",
"killed",
"abandoned",
];
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center px-4 py-10 overflow-y-auto bg-black/60 backdrop-blur-sm animate-fade-in"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="w-full max-w-4xl rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/60 flex flex-col max-h-[85vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-3 border-b border-border flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-accent/15 inline-flex items-center justify-center">
<ListOrdered className="w-4 h-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg-primary">
{t("runs.modalTitle", "Dashboard runs")}
</h2>
<p className="text-[11px] text-fg-muted">
{t(
"runs.modalSubtitle",
"Every run started from this dashboard, regardless of status"
)}
</p>
</div>
<button
onClick={onRefresh}
className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
aria-label={t("runs.refresh", "Refresh")}
title={t("runs.refresh", "Refresh")}
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<Link
to="/sessions"
onClick={onClose}
className="text-[11px] text-accent hover:text-accent/80 inline-flex items-center gap-1 mr-1"
>
{t("runs.allSessionsLink")}
</Link>
<button
onClick={onClose}
className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
aria-label={t("limitations.dismiss")}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Filter bar */}
<div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0">
<div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5">
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input
autoFocus
value={search}
onChange={(e) => 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 && (
<button
onClick={() => setSearch("")}
className="text-fg-muted hover:text-fg-secondary text-[10px]"
aria-label="Clear"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div className="flex flex-wrap gap-3 items-center">
<FilterChipGroup
label={t("runs.filterStatus", "Status")}
value={statusFilter}
options={STATUSES.map((s) => ({
value: s,
label: s === "all" ? t("runs.allLabel", "All") : t(`status.${s}`),
count: counts.byStatus[s] || 0,
}))}
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
/>
</div>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
{filtered.length === 0 ? (
<div className="px-5 py-12 text-center text-[12px] text-fg-muted">
{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.")}
</div>
) : (
filtered.map((r) => {
const isCurrent = r.id === currentHandleId;
return (
<UnifiedRunRowView
key={r.id}
row={r}
isCurrent={isCurrent}
onAttach={() => 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);
}}
/>
);
})
)}
</div>
{/* Footer */}
<div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0">
<Info className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10.5px] text-fg-muted leading-relaxed flex-1">
{t("runs.scopeNote")}
</span>
<span className="text-[10.5px] text-fg-muted font-mono">
{filtered.length} / {rows.length}
</span>
</div>
</div>
</div>
);
}
function FilterChipGroup<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: string; count: number }[];
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] uppercase tracking-wider font-semibold text-fg-muted mr-1">
{label}
</span>
{options.map((opt) => {
const active = value === opt.value;
const dim = opt.count === 0 && opt.value !== "all";
return (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
disabled={dim}
className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${
active
? "bg-accent/15 border-accent/50 text-accent"
: "bg-surface-2 border-border text-fg-secondary hover:bg-surface-3 hover:border-border-strong"
}`}
>
{opt.label}
<span className="ml-1 text-fg-muted font-mono">{opt.count}</span>
</button>
);
})}
</div>
);
}
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.
// An external session is live but has no attachable tmux session, so Resume
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
const canView = !!row.sessionId && !row.isLive;
return (
<div
className={`px-5 py-3 transition-colors ${
isCurrent ? "bg-accent/[0.06]" : "hover:bg-surface-2/50"
}`}
>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<StatusPill status={row.status} />
{row.isLive && (
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.liveBadge", "live")}
</span>
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
</span>
)}
{isCurrent && (
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
{t("runs.currentBadge", "current")}
</span>
)}
<span className="ml-auto inline-flex items-center gap-1.5">
{row.isLive && !row.external && !isCurrent && (
<button
onClick={onAttach}
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<Play className="w-3 h-3" />
{t("runs.attachLabel", "Attach")}
</button>
)}
{canResume && (
<button
onClick={onResume}
title={row.external ? t("runs.externalHint") : undefined}
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<RotateCcw className="w-3 h-3" />
{t("resume.resumeOption", "Resume")}
</button>
)}
{canView && (
<button
onClick={onView}
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<Eye className="w-3 h-3" />
{t("runs.viewLabel", "View")}
</button>
)}
</span>
</div>
{row.promptPreview && (
<div className="text-[12px] text-fg-secondary line-clamp-2 leading-snug">
{row.promptPreview}
</div>
)}
<div className="font-mono text-[10px] text-fg-muted truncate mt-1">{row.cwd}</div>
<div className="text-[10px] text-fg-muted mt-0.5 flex items-center gap-2 flex-wrap">
<span>{startedLabel}</span>
{row.model && <span className="font-mono text-fg-muted">· {row.model}</span>}
{row.sessionId && (
<Link
to={`/sessions/${encodeURIComponent(row.sessionId)}`}
className="inline-flex items-center gap-1 text-fg-muted hover:text-fg-secondary transition-colors"
title={t("actions.viewSession")}
>
<ExternalLink className="w-2.5 h-2.5" />
<span className="font-mono">{row.sessionId.slice(0, 8)}</span>
</Link>
)}
</div>
</div>
);
}