feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @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, RunMode, RunStatus } from "../../lib/api";
|
||||
import { ModeBadge, StatusPill } from "./RunConsole";
|
||||
|
||||
type RunStatusFilter =
|
||||
| "all"
|
||||
| "running"
|
||||
| "spawning"
|
||||
| "completed"
|
||||
| "error"
|
||||
| "killed"
|
||||
| "abandoned";
|
||||
type RunModeFilter = "all" | "conversation" | "headless";
|
||||
|
||||
export interface UnifiedRunRow {
|
||||
id: string;
|
||||
sessionId: string | null;
|
||||
mode: RunMode;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
status: RunStatus;
|
||||
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,
|
||||
mode: r.mode,
|
||||
cwd: r.cwd,
|
||||
model: r.model,
|
||||
status: r.status,
|
||||
promptPreview: r.prompt || "",
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
isLive: r.status === "running" || r.status === "spawning",
|
||||
});
|
||||
}
|
||||
}
|
||||
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,
|
||||
mode: h.mode,
|
||||
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 = activeRuns?.activeCount ?? 0;
|
||||
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-emerald-500/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/15"
|
||||
: "border-border bg-surface-2 text-gray-300 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-emerald-400 animate-pulse" />
|
||||
{t("runs.viewActive_other", { count: liveCount })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("runs.switcher")}
|
||||
{totalCount > 0 && <span className="text-gray-500 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 [modeFilter, setModeFilter] = useState<RunModeFilter>("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 };
|
||||
const byMode: Record<string, number> = { all: rows.length };
|
||||
for (const r of rows) {
|
||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
||||
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
|
||||
}
|
||||
return { byStatus, byMode };
|
||||
}, [rows]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||
if (modeFilter !== "all" && r.mode !== modeFilter) 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, modeFilter, 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",
|
||||
];
|
||||
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
|
||||
|
||||
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-gray-100">
|
||||
{t("runs.modalTitle", "Dashboard runs")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{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-gray-500 hover:text-gray-200 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-gray-500 hover:text-gray-200 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-gray-500 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-gray-100 placeholder:text-gray-600 focus:outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-gray-500 hover:text-gray-200 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)}
|
||||
/>
|
||||
<FilterChipGroup
|
||||
label={t("runs.filterMode", "Mode")}
|
||||
value={modeFilter}
|
||||
options={MODES.map((m) => ({
|
||||
value: m,
|
||||
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
|
||||
count: counts.byMode[m] || 0,
|
||||
}))}
|
||||
onChange={(v) => setModeFilter(v as RunModeFilter)}
|
||||
/>
|
||||
</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-gray-500">
|
||||
{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-gray-500 flex-shrink-0" />
|
||||
<span className="text-[10.5px] text-gray-500 leading-relaxed flex-1">
|
||||
{t("runs.scopeNote")}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-gray-500 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-gray-500 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-gray-300 hover:bg-surface-3 hover:border-border-strong"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
<span className="ml-1 text-gray-500 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",
|
||||
});
|
||||
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive;
|
||||
// Headless runs are single-shot, so resume doesn't apply - but the captured
|
||||
// transcript is still worth viewing. Link to the Session detail page.
|
||||
const canView = row.mode === "headless" && !!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} />
|
||||
<ModeBadge mode={row.mode} />
|
||||
{row.isLive && (
|
||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/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-emerald-400 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-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-200 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-gray-300 hover:text-gray-100 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-gray-300 line-clamp-2 leading-snug">
|
||||
{row.promptPreview}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate mt-1">{row.cwd}</div>
|
||||
<div className="text-[10px] text-gray-600 mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<span>{startedLabel}</span>
|
||||
{row.model && <span className="font-mono text-gray-500">· {row.model}</span>}
|
||||
{row.sessionId && (
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(row.sessionId)}`}
|
||||
className="inline-flex items-center gap-1 text-gray-500 hover:text-gray-300 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* @file RunSetup.tsx
|
||||
* @description The pre-run setup panel: everything the user picks before a run
|
||||
* exists. Moved verbatim out of `pages/Run.tsx` (where it was `ConfigCard`) so
|
||||
* the Run page and the Workspace page can both mount the same panel.
|
||||
*
|
||||
* What lives here:
|
||||
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the
|
||||
* prompt editor, and the cwd / model / permission-mode / effort fields,
|
||||
* plus the concurrency hint and the Start button. Its disabled state is
|
||||
* driven by the `binaryFound` prop, so a missing `claude` binary is a
|
||||
* surfaced state here rather than a probe of its own.
|
||||
* above the panel, with its own localStorage-persisted minimized state.
|
||||
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
||||
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers.
|
||||
*
|
||||
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
||||
* lifecycle — the page owns `api.run.start` and hands the result back through
|
||||
* `onStart`. `SessionPicker` keeps the one API call it already owned (listing
|
||||
* past sessions to resume) and `PromptEditor` keeps its `@`-file lookup.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ShieldAlert,
|
||||
X,
|
||||
FolderOpen,
|
||||
Home,
|
||||
History as HistoryIcon,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES } from "../../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
DashboardRunHistoryItem,
|
||||
RunListResponse,
|
||||
EffortLevel,
|
||||
PermissionMode,
|
||||
RunMode,
|
||||
} from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { PromptEditor } from "./RunConsole";
|
||||
import type { SlashCommand } from "./RunConsole";
|
||||
|
||||
// ── Limitations banner (above the config card) ────────────────────────
|
||||
|
||||
|
||||
interface RunSetupProps {
|
||||
mode: RunMode;
|
||||
onModeChange: (m: RunMode) => void;
|
||||
prompt: string;
|
||||
onPromptChange: (s: string) => void;
|
||||
cwd: string;
|
||||
onCwdChange: (s: string) => void;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
model: string;
|
||||
onModelChange: (s: string) => void;
|
||||
permissionMode: PermissionMode;
|
||||
onPermissionModeChange: (m: PermissionMode) => void;
|
||||
effort: EffortLevel;
|
||||
onEffortChange: (e: EffortLevel) => void;
|
||||
binaryFound: boolean;
|
||||
busy: boolean;
|
||||
onStart: () => void;
|
||||
activeRuns: RunListResponse | null;
|
||||
resumeSession: Session | null;
|
||||
onResumeSessionChange: (s: Session | null) => void;
|
||||
/** The selected lane's cwd - narrows the resume picker to that lane's own
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
}
|
||||
|
||||
export function RunSetup(props: RunSetupProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const atCap =
|
||||
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
|
||||
const isResume = !!props.resumeSession;
|
||||
const [resumePicked, setResumePicked] = useState(isResume);
|
||||
// Keep "resume picked" in sync with the parent. Two cases:
|
||||
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
|
||||
// modal) - flip the radio so the picker is shown and the selection
|
||||
// is visible.
|
||||
// 2. Parent cleared the session and mode flipped to headless - clear
|
||||
// the radio so the form is honest.
|
||||
useEffect(() => {
|
||||
if (isResume && !resumePicked) setResumePicked(true);
|
||||
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
|
||||
}, [isResume, resumePicked, props.mode]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Mode and source on one line. Both are two-way choices made once at
|
||||
spawn time, so a segmented row carries them; the longer explanations
|
||||
live in each button's title rather than in a paragraph. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={props.mode === "conversation"}
|
||||
label={t("mode.conversation")}
|
||||
title={t("mode.conversationHint")}
|
||||
onClick={() => props.onModeChange("conversation")}
|
||||
/>
|
||||
<Seg
|
||||
active={props.mode === "headless"}
|
||||
label={t("mode.headless")}
|
||||
title={`${t("mode.headlessHint")} — ${t("hint.headlessExplain")}`}
|
||||
onClick={() => {
|
||||
props.onModeChange("headless");
|
||||
setResumePicked(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.mode === "conversation" && (
|
||||
<>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
onSelect={props.onResumeSessionChange}
|
||||
cwd={props.laneCwd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||
{t("fields.prompt")}
|
||||
</label>
|
||||
<PromptEditor
|
||||
value={props.prompt}
|
||||
onChange={props.onPromptChange}
|
||||
onSubmit={props.onStart}
|
||||
placeholder={t("fields.promptPlaceholder")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-gray-600">
|
||||
{t("hint.shortcut")} · / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-300 flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-gray-500 flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} />
|
||||
</Field>
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.permissionMode === "bypassPermissions" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[11px] text-red-200 flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("hint.permissionWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: contextual run-state hint + run button */}
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 text-[11px] min-w-0">
|
||||
{atCap ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-amber-300">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
|
||||
</span>
|
||||
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{t("concurrency.active", { count: props.activeRuns.activeCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onStart}
|
||||
disabled={
|
||||
!props.binaryFound ||
|
||||
!props.prompt.trim() ||
|
||||
props.busy ||
|
||||
atCap ||
|
||||
(resumePicked && !props.resumeSession) ||
|
||||
// Resume locks cwd to the original session, so allow it then;
|
||||
// otherwise require a non-empty cwd so we never spawn at an
|
||||
// invisible default.
|
||||
(!props.resumeSession && !props.cwd.trim())
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{props.busy ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{props.busy ? t("actions.starting") : t("actions.start")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice. The explanation rides on `title`
|
||||
* instead of a hint line, which is what keeps the row to one line. */
|
||||
function Seg({
|
||||
active,
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-pressed={active}
|
||||
className={`rounded px-2 py-0.5 font-medium transition-colors ${
|
||||
active ? "bg-accent/20 text-accent" : "text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CWD autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
export function CwdAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
inputId,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
suggestions: CwdSuggestion[];
|
||||
/** Sets the input's `id` so an external `<label htmlFor>` can target it. */
|
||||
inputId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = value.toLowerCase().trim();
|
||||
const out = suggestions.filter(
|
||||
(s) => !q || s.path.toLowerCase().includes(q) || s.label.toLowerCase().includes(q)
|
||||
);
|
||||
return out;
|
||||
}, [value, suggestions]);
|
||||
|
||||
// Group suggestions by kind preserving fixed order — home first, matching
|
||||
// the neutral default the page pre-fills (issue #202).
|
||||
const groups = useMemo(() => {
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
|
||||
return order
|
||||
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [filtered]);
|
||||
|
||||
// Flat index for keyboard navigation
|
||||
const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
||||
|
||||
// Keep `active` clamped within bounds
|
||||
useEffect(() => {
|
||||
if (active >= flat.length) setActive(Math.max(0, flat.length - 1));
|
||||
}, [flat.length, active]);
|
||||
|
||||
const choose = (s: CwdSuggestion) => {
|
||||
onChange(s.path);
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
setOpen(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(flat.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
if (open && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
setActive(0);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t("fields.cwdPlaceholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-gray-500">{t("fields.cwdNoMatches")}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.kind}>
|
||||
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-500 flex items-center gap-1.5">
|
||||
{g.kind === "dashboard" ? (
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
) : g.kind === "home" ? (
|
||||
<Home className="w-3 h-3" />
|
||||
) : (
|
||||
<HistoryIcon className="w-3 h-3" />
|
||||
)}
|
||||
{t(`fields.cwdGroups.${g.kind}`)}
|
||||
</div>
|
||||
{g.items.map((s) => {
|
||||
const idx = flat.indexOf(s);
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={s.path}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault() /* keep input focused */}
|
||||
onClick={() => choose(s)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||||
isActive ? "bg-accent/15" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] text-gray-200 truncate">{s.label}</div>
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate">{s.path}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Model picker ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Session picker (for resume) ───────────────────────────────────────
|
||||
|
||||
function SessionPicker({
|
||||
selected,
|
||||
onSelect,
|
||||
cwd,
|
||||
}: {
|
||||
selected: Session | null;
|
||||
onSelect: (s: Session | null) => void;
|
||||
/** Restricts the list to sessions under this exact working directory - the
|
||||
* lane a resume is started from should only offer that lane's own history. */
|
||||
cwd?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Lazily load sessions when the picker opens, and again whenever the lane
|
||||
// (and so its cwd filter) changes - otherwise switching lanes would leave
|
||||
// the picker showing the PREVIOUS lane's sessions until closed and reopened.
|
||||
const fetchedForCwd = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
||||
fetchedForCwd.current = cwd;
|
||||
api.sessions
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [open, cwd, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return sessions;
|
||||
return sessions.filter(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(q) ||
|
||||
(s.cwd || "").toLowerCase().includes(q) ||
|
||||
(s.status || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [sessions, query]);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-accent/30 bg-accent/5 px-3 py-2 flex items-start gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
|
||||
{t("resume.selectedBadge")}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-gray-200 truncate">{selected.id}</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate mt-0.5">{selected.cwd}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-gray-300 inline-flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t("resume.clear")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative mt-2">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-gray-400 inline-flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("resume.pickSession")}
|
||||
<ChevronDown className="w-3 h-3 opacity-70 ml-auto" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("resume.search")}
|
||||
className="bg-transparent text-[11px] text-gray-100 placeholder:text-gray-500 focus:outline-none w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto py-1">
|
||||
{sessions === null ? (
|
||||
<div className="px-3 py-2 text-[11px] text-gray-500">…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-gray-500">{t("resume.noSessions")}</div>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
onSelect(s);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
s.status === "active"
|
||||
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30"
|
||||
: s.status === "completed"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
: s.status === "error"
|
||||
? "bg-red-500/10 text-red-300 border-red-500/30"
|
||||
: "bg-surface-3 text-gray-400 border-border"
|
||||
}`}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
{s.name?.trim() && (
|
||||
<span className="text-[11px] text-gray-200 truncate">{s.name.trim()}</span>
|
||||
)}
|
||||
<span className="font-mono text-[11px] text-gray-400 truncate flex-shrink-0">
|
||||
{s.id.slice(0, 12)}…
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 ml-auto flex-shrink-0">
|
||||
{new Date(s.started_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-gray-500 truncate">{s.cwd}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The custom Select dropdown now lives in ../components/Select (shared with the
|
||||
// webhook settings form). Imported at the top of this file.
|
||||
|
||||
// Sentinel option value for "Custom model…". Empty string is already taken by
|
||||
// the "inherit from settings" choice, so use a non-empty marker.
|
||||
const MODEL_CUSTOM = "__custom__";
|
||||
|
||||
function ModelPicker({ value, onChange }: { value: string; onChange: (s: string) => void }) {
|
||||
const { t } = useTranslation("run");
|
||||
// "Custom" is selected when the value isn't one of our curated IDs.
|
||||
const knownIds = useMemo(() => RUN_MODEL_CHOICES.map((c) => c.id), []);
|
||||
const isCustom = value !== "" && !knownIds.includes(value);
|
||||
const [showCustom, setShowCustom] = useState(isCustom);
|
||||
|
||||
// Reuse the shared Select so the Model dropdown renders identically to the
|
||||
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
|
||||
// a browser-native <select>.
|
||||
const options = useMemo(
|
||||
() => [
|
||||
...RUN_MODEL_CHOICES.map((c) => ({
|
||||
value: c.id === "" ? "" : c.id,
|
||||
label: c.id === "" ? t("fields.modelInheritLabel") : c.label,
|
||||
hint: c.hint,
|
||||
})),
|
||||
{ value: MODEL_CUSTOM, label: t("fields.modelCustom") },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const onSelect = (v: string) => {
|
||||
if (v === MODEL_CUSTOM) {
|
||||
setShowCustom(true);
|
||||
return;
|
||||
}
|
||||
setShowCustom(false);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
const selectValue = showCustom || isCustom ? MODEL_CUSTOM : value;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Select<string> value={selectValue} onChange={onSelect} options={options} />
|
||||
{(showCustom || isCustom) && (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("fields.modelCustomPlaceholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-gray-100 placeholder:text-gray-500 focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file RunConsole.test.tsx
|
||||
* @description Pins the props-only boundary of `RunConsole` after its move out
|
||||
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
|
||||
* (no stream subscription of its own), the token meter rolls up usage from
|
||||
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
|
||||
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
|
||||
* send and stop controls.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RunConsole, type SlashCommand } from "../RunConsole";
|
||||
import type { Envelope } from "../../../hooks/useRunStream";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
const HANDLE: RunHandle = {
|
||||
id: "run-1",
|
||||
pid: 4242,
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-5",
|
||||
permissionMode: "acceptEdits",
|
||||
effort: "",
|
||||
prompt: "hi",
|
||||
argv: [],
|
||||
resumeSessionId: null,
|
||||
status: "running",
|
||||
startedAt: 1,
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
sessionId: null,
|
||||
envelopeCount: 0,
|
||||
stdoutTail: "",
|
||||
stderrTail: "",
|
||||
};
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{ name: "code-review", description: "Review the working diff", source: "project" },
|
||||
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
|
||||
{ name: "logout", description: "Sign out", source: "builtin" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mount the console with the parent-owned follow-up state it expects, so the
|
||||
* autocomplete assertions exercise the real controlled-input round trip.
|
||||
*/
|
||||
function renderConsole(
|
||||
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
|
||||
onFollowUp?: (s: string) => void
|
||||
) {
|
||||
const seen = { followUp: "" };
|
||||
function Harness() {
|
||||
const [followUp, setFollowUp] = useState("");
|
||||
seen.followUp = followUp;
|
||||
return (
|
||||
<RunConsole
|
||||
handle={HANDLE}
|
||||
envelopes={[]}
|
||||
mode="conversation"
|
||||
isLive
|
||||
hasFinished={false}
|
||||
followUp={followUp}
|
||||
onFollowUpChange={(s) => {
|
||||
setFollowUp(s);
|
||||
onFollowUp?.(s);
|
||||
}}
|
||||
busy={null}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
onNewRun={() => {}}
|
||||
slashCommands={COMMANDS}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness />
|
||||
</MemoryRouter>
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("RunConsole", () => {
|
||||
it("renders assistant text from the envelopes prop", () => {
|
||||
const envelopes: Envelope[] = [
|
||||
{ type: "user", message: { content: "explain this repo" } },
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
expect(screen.getByText("explain this repo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty-stream placeholder when there are no envelopes", () => {
|
||||
renderConsole({ isLive: false });
|
||||
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the token totals computed from the envelopes", () => {
|
||||
// Transcript-shaped assistant envelope (no `message.id`), which is the
|
||||
// branch computeTokens folds into the running totals.
|
||||
const envelopes: Envelope[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
|
||||
},
|
||||
},
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
// Context gauge: (input + cache read) / default 200k window.
|
||||
// The CLI-style meter is one status line: context usage as a single label,
|
||||
// then output and cache-hit figures with terminal glyphs. Input is implied
|
||||
// by the context total rather than listed separately.
|
||||
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
|
||||
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
|
||||
});
|
||||
|
||||
it("filters slash commands as the user types and fills the prompt on pick", () => {
|
||||
const seen = renderConsole();
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/co" } });
|
||||
|
||||
expect(screen.getByText("/code-review")).toBeInTheDocument();
|
||||
expect(screen.getByText("/compact")).toBeInTheDocument();
|
||||
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("/code-review"));
|
||||
|
||||
expect(seen.followUp).toBe("/code-review");
|
||||
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
|
||||
});
|
||||
|
||||
it("fires onSend from the send button with the prompt the parent holds", () => {
|
||||
const onSend = vi.fn();
|
||||
const seen = renderConsole({ onSend });
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(seen.followUp).toBe("follow up please");
|
||||
});
|
||||
|
||||
it("fires onStop from the stop control while live, and hides it when not", () => {
|
||||
const onStop = vi.fn();
|
||||
renderConsole({ onStop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the stop control and the follow-up editor once the run is not live", () => {
|
||||
renderConsole({ isLive: false, hasFinished: true });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file RunHistory.test.tsx
|
||||
* @description Pins the props-only boundary of `ActiveRunsSwitcher` / `RunsModal`
|
||||
* after their move out of `pages/Run.tsx`: the switcher counts live runs and
|
||||
* opens the list, the list merges live in-memory handles with persistent history
|
||||
* (live entries winning on a shared id) newest first, marks the live and current
|
||||
* rows, filters by status / mode / free text, and fires attach / resume / view
|
||||
* with the right run — attach by run id, resume and view with the matching
|
||||
* history item.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||
|
||||
const LIVE_ID = "run-live";
|
||||
const PAST_ID = "run-past";
|
||||
const HEADLESS_ID = "run-headless";
|
||||
|
||||
const activeRuns = {
|
||||
activeCount: 1,
|
||||
maxConcurrent: 2,
|
||||
items: [
|
||||
{
|
||||
id: LIVE_ID,
|
||||
sessionId: "sess-live",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/live",
|
||||
model: "claude-opus-5",
|
||||
status: "running",
|
||||
prompt: "the live prompt",
|
||||
startedAt: 3000,
|
||||
endedAt: null,
|
||||
},
|
||||
],
|
||||
} as unknown as RunListResponse;
|
||||
|
||||
function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistoryItem {
|
||||
return {
|
||||
id: PAST_ID,
|
||||
session_id: "sess-past",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/past",
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
prompt_preview: "the past prompt",
|
||||
started_at: new Date(2000).toISOString(),
|
||||
ended_at: new Date(2500).toISOString(),
|
||||
exit_code: 0,
|
||||
permission_mode: "acceptEdits",
|
||||
effort: "",
|
||||
resume_session_id: null,
|
||||
isLive: false,
|
||||
...over,
|
||||
} as DashboardRunHistoryItem;
|
||||
}
|
||||
|
||||
const PAST = historyItem({});
|
||||
const HEADLESS = historyItem({
|
||||
id: HEADLESS_ID,
|
||||
session_id: "sess-headless",
|
||||
mode: "headless",
|
||||
cwd: "/tmp/headless",
|
||||
prompt_preview: "the headless prompt",
|
||||
started_at: new Date(1000).toISOString(),
|
||||
});
|
||||
|
||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
currentHandleId={null}
|
||||
runHistory={[PAST, HEADLESS]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
||||
return {
|
||||
id,
|
||||
sessionId: `sess-${id}`,
|
||||
mode: "conversation",
|
||||
cwd: `/tmp/${id}`,
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
promptPreview: `prompt of ${id}`,
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
isLive: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
rows: UnifiedRunRow[],
|
||||
overrides: Partial<React.ComponentProps<typeof RunsModal>> = {}
|
||||
) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunsModal
|
||||
rows={rows}
|
||||
currentHandleId={null}
|
||||
runHistory={[PAST, HEADLESS]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** The nth "All" chip — index 0 is the status group, 1 is the mode group. */
|
||||
function allChip(nth: number): HTMLElement {
|
||||
const hits = screen.getAllByText(i18n.t("run:runs.allLabel", "All"));
|
||||
const hit = hits[nth];
|
||||
if (!hit) throw new Error(`no "All" chip at index ${nth}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** A filter chip, told apart from the same word appearing in a row's status
|
||||
* pill or mode badge by being a `<button>`. */
|
||||
function chip(label: string): HTMLElement {
|
||||
const hit = screen.getAllByText(label).find((el) => el.tagName === "BUTTON");
|
||||
if (!hit) throw new Error(`no filter chip labelled ${label}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const openModal = () =>
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ActiveRunsSwitcher", () => {
|
||||
it("labels the button with the live count and opens the list", () => {
|
||||
renderSwitcher();
|
||||
openModal();
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.getByText("the past prompt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the total count when nothing is live, and disables at zero", () => {
|
||||
const { unmount } = renderSwitcher({ activeRuns: null });
|
||||
expect(screen.getByText(i18n.t("run:runs.switcher"))).toBeTruthy();
|
||||
expect(screen.getByText("2")).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
renderSwitcher({ activeRuns: null, runHistory: [] });
|
||||
const button = screen.getByText(i18n.t("run:runs.switcher")).closest("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("lists live runs first and marks the live one", () => {
|
||||
renderSwitcher();
|
||||
openModal();
|
||||
const prompts = screen
|
||||
.getAllByText(/the (live|past|headless) prompt/)
|
||||
.map((el) => el.textContent);
|
||||
expect(prompts).toEqual(["the live prompt", "the past prompt", "the headless prompt"]);
|
||||
expect(screen.getAllByText("live")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers the live handle over a history row with the same id", () => {
|
||||
renderSwitcher({ runHistory: [historyItem({ id: LIVE_ID, prompt_preview: "stale copy" })] });
|
||||
openModal();
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.queryByText("stale copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
// Attaching closes the list, which is what the page relies on.
|
||||
expect(screen.queryByText("the past prompt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunsModal", () => {
|
||||
it("offers Attach only for a live row that is not the current one", () => {
|
||||
const { unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })], {
|
||||
currentHandleId: LIVE_ID,
|
||||
});
|
||||
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||
expect(screen.getByText(i18n.t("run:runs.currentBadge", "current"))).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
const { spies } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished conversation row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
||||
expect(spies.onView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires view — not resume — for a finished headless row", () => {
|
||||
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
|
||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
||||
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
||||
expect(spies.onResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by status, by mode and by free text", () => {
|
||||
const rows = [
|
||||
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
||||
row("b", { status: "error", promptPreview: "bravo" }),
|
||||
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
|
||||
];
|
||||
renderModal(rows);
|
||||
|
||||
fireEvent.click(chip(i18n.t("run:status.error")));
|
||||
expect(screen.getByText("bravo")).toBeTruthy();
|
||||
expect(screen.queryByText("alpha")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(0));
|
||||
fireEvent.click(chip(i18n.t("run:mode.headless")));
|
||||
expect(screen.getByText("charlie")).toBeTruthy();
|
||||
expect(screen.queryByText("bravo")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(1));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
||||
),
|
||||
{ target: { value: "alpha" } }
|
||||
);
|
||||
expect(screen.getByText("alpha")).toBeTruthy();
|
||||
expect(screen.queryByText("charlie")).toBeNull();
|
||||
});
|
||||
|
||||
it("polls onRefresh while it is the foreground UI", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { spies } = renderModal([row("a")]);
|
||||
expect(spies.onRefresh).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(spies.onRefresh).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @file RunSetup.test.tsx
|
||||
* @description Pins the props-only boundary of `RunSetup` after its move out of
|
||||
* `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns —
|
||||
* mode, prompt, cwd, model, permission mode, effort — reports its selection
|
||||
* through the matching callback and nowhere else, `onStart` fires from the Run
|
||||
* button, and a missing `claude` binary is surfaced purely from the
|
||||
* `binaryFound` prop (the panel runs no probe of its own).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SUGGESTIONS: CwdSuggestion[] = [
|
||||
{ kind: "home", path: "/Users/tester", label: "Home" },
|
||||
{ kind: "recent", path: "/Users/tester/projects/other", label: "other" },
|
||||
];
|
||||
|
||||
type Spies = ReturnType<typeof renderSetup>["spies"];
|
||||
|
||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||
const spies = {
|
||||
onModeChange: vi.fn(),
|
||||
onPromptChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onPermissionModeChange: vi.fn(),
|
||||
onEffortChange: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onResumeSessionChange: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** Open the `Select` whose trigger currently shows `currentLabel`, then pick
|
||||
* the option labelled `optionLabel`. */
|
||||
function pickFromSelect(currentLabel: string, optionLabel: string) {
|
||||
fireEvent.click(screen.getByText(currentLabel));
|
||||
fireEvent.click(screen.getByText(optionLabel));
|
||||
}
|
||||
|
||||
/** Every callback except the named ones must stay untouched — a selection that
|
||||
* leaks into a sibling prop is exactly the wiring bug a move can introduce. */
|
||||
function onlyCalled(spies: Spies, ...called: (keyof Spies)[]) {
|
||||
for (const [name, spy] of Object.entries(spies)) {
|
||||
if (called.includes(name as keyof Spies)) continue;
|
||||
expect(spy, `${name} should not have fired`).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RunSetup — selections report through callbacks", () => {
|
||||
it("reports the mode from the one-shot / conversation options", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
|
||||
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
|
||||
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
|
||||
onlyCalled(spies, "onModeChange");
|
||||
});
|
||||
|
||||
it("reports the prompt from the editor", () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||
fireEvent.change(box, { target: { value: "review the diff" } });
|
||||
expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff");
|
||||
onlyCalled(spies, "onPromptChange");
|
||||
});
|
||||
|
||||
it("reports the cwd from typing and from a suggestion", () => {
|
||||
const { spies } = renderSetup();
|
||||
const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder"));
|
||||
fireEvent.change(input, { target: { value: "/tmp/pro" } });
|
||||
expect(spies.onCwdChange).toHaveBeenCalledWith("/tmp/pro");
|
||||
fireEvent.click(screen.getByText("/Users/tester/projects/other"));
|
||||
expect(spies.onCwdChange).toHaveBeenLastCalledWith("/Users/tester/projects/other");
|
||||
onlyCalled(spies, "onCwdChange");
|
||||
});
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 4.6");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
|
||||
expect(spies.onModelChange).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
|
||||
target: { value: "claude-opus-5" },
|
||||
});
|
||||
expect(spies.onModelChange).toHaveBeenLastCalledWith("claude-opus-5");
|
||||
onlyCalled(spies, "onModelChange");
|
||||
});
|
||||
|
||||
it("reports the permission mode and the effort level", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.permissionAcceptEdits"), i18n.t("run:fields.permissionPlan"));
|
||||
expect(spies.onPermissionModeChange).toHaveBeenCalledWith("plan");
|
||||
pickFromSelect("Default (model decides)", "Medium");
|
||||
expect(spies.onEffortChange).toHaveBeenCalledWith("medium");
|
||||
onlyCalled(spies, "onPermissionModeChange", "onEffortChange");
|
||||
});
|
||||
|
||||
it("fires onStart from the Run button", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledTimes(1);
|
||||
onlyCalled(spies, "onStart");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — missing binary and other blocked states", () => {
|
||||
/** The Run button, found by its label rather than by DOM position. */
|
||||
function runButton(): HTMLButtonElement {
|
||||
return screen.getByText(i18n.t("run:actions.start")).closest("button") as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("disables Run when the claude binary was not found", () => {
|
||||
renderSetup({ binaryFound: false });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables Run when the binary is found and the form is complete", () => {
|
||||
renderSetup();
|
||||
expect(runButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
|
||||
const { unmount } = renderSetup({ prompt: " " });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
unmount();
|
||||
|
||||
const noCwd = renderSetup({ cwd: "" });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
noCwd.unmount();
|
||||
|
||||
renderSetup({
|
||||
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
|
||||
});
|
||||
expect(runButton().disabled).toBe(true);
|
||||
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the Starting… label while busy", () => {
|
||||
renderSetup({ busy: true });
|
||||
expect(screen.getByText(i18n.t("run:actions.starting"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — resume picker scopes sessions to the selected lane", () => {
|
||||
it("passes the lane's cwd as a filter and lists only that directory's sessions", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// The API call itself is what enforces the scope - the server filters by
|
||||
// cwd, so the picker must never fetch without one when a lane is selected.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-a" })
|
||||
);
|
||||
});
|
||||
|
||||
it("lists everything when no lane is selected", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ cwd: undefined }));
|
||||
});
|
||||
|
||||
it("re-fetches with the new cwd when the selected lane changes", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
const { rerender } = renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
onModeChange={vi.fn()}
|
||||
onPromptChange={vi.fn()}
|
||||
onCwdChange={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPermissionModeChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onResumeSessionChange={vi.fn()}
|
||||
onResumeFromHistory={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-b" })
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user