b951f64321
- pty-run.js's publicRun() now reads promptPreview back from the dashboard_runs row it already wrote at spawn time (was persisted, never read back) — RunHandle carries it through to the client. - Workspace.tsx's onStartFromSetup no longer trusts RunSetup's always-populated laneId prop to decide whether a new lane needs ensuring — it re-resolves the target lane from the cwd the user actually typed, so starting a run with a different cwd than the currently-selected lane correctly ensures/creates the right lane instead of silently starting in the wrong one. Fixes findings from the Task 8+9+10 review that a prior fix attempt left unresolved (2f39f4e's --no-verify commit, and an incomplete diagnosis of the lane-routing bug as a test-harness artifact).
545 lines
18 KiB
TypeScript
545 lines
18 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.
|
|
*
|
|
* 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ĩ <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";
|
|
|
|
// 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;
|
|
}
|
|
|
|
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<string>();
|
|
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 (
|
|
<>
|
|
<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={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<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
|
|
const canResume = !!row.sessionId && !row.isLive;
|
|
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>
|
|
)}
|
|
{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 && !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}
|
|
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>
|
|
);
|
|
}
|