Files
hive/core/frontend/src/components/ColonyWorkersPanel.tsx
T
2026-04-20 10:49:37 -07:00

1894 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
X,
Users,
RefreshCw,
Wrench,
Database,
ChevronRight,
ChevronDown,
ArrowLeft,
Square,
Play,
Clock,
Webhook,
Zap,
Activity,
Loader2,
} from "lucide-react";
import {
colonyWorkersApi,
type ColonySkill,
type ColonyTool,
type ProgressSnapshot,
type ProgressStep,
type WorkerSummary,
} from "@/api/colonyWorkers";
import {
colonyDataApi,
type CellValue,
type TableOverview,
type TableRowsResponse,
} from "@/api/colonyData";
import { workersApi } from "@/api/workers";
import { sessionsApi } from "@/api/sessions";
import { cronToLabel } from "@/lib/graphUtils";
import type { GraphNode } from "@/components/graph-types";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
import { DataGrid, type SortDir } from "@/components/data-grid";
interface ColonyWorkersPanelProps {
sessionId: string;
/** Colony directory name (e.g. ``linkedin_honeycomb_messaging``) for
* the colony-scoped progress + data endpoints. ``null`` when the
* attached session isn't bound to a colony — those tabs render
* empty rather than fire requests with an invalid name. */
colonyName: string | null;
onClose: () => void;
}
type TabKey = "skills" | "tools" | "sessions" | "triggers" | "data";
function statusClasses(status: string): string {
const s = status.toLowerCase();
if (s === "running" || s === "pending" || s === "claimed" || s === "in_progress")
return "bg-primary/15 text-primary";
if (s === "completed" || s === "done") return "bg-emerald-500/15 text-emerald-500";
if (s === "failed") return "bg-destructive/15 text-destructive";
if (s === "stopped") return "bg-muted text-muted-foreground";
return "bg-muted text-muted-foreground";
}
function shortId(worker_id: string): string {
return worker_id.length > 8 ? worker_id.slice(0, 8) : worker_id;
}
function fmtStarted(ts: number): string {
if (!ts) return "";
try {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return "";
}
}
function fmtIso(ts: string | null | undefined): string {
if (!ts) return "";
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return ts;
}
}
export default function ColonyWorkersPanel({
sessionId,
colonyName,
onClose,
}: ColonyWorkersPanelProps) {
const [tab, setTab] = useState<TabKey>("sessions");
const { focusWorkerId } = useColonyWorkers();
// When an external caller (e.g. clicking a worker avatar in chat)
// requests focus on a specific worker, jump to the Sessions tab so
// the pre-select in SessionsTab is visible. The actual select +
// focus-clear happens inside SessionsTab.
useEffect(() => {
if (focusWorkerId) setTab("sessions");
}, [focusWorkerId]);
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
const MIN_WIDTH = 280;
const MAX_WIDTH = 600;
const [width, setWidth] = useState(380);
const dragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
const onDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startWidth.current = width;
const onMove = (ev: MouseEvent) => {
if (!dragging.current) return;
const delta = startX.current - ev.clientX;
setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)));
};
const onUp = () => {
dragging.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[width],
);
return (
<aside
className="flex-shrink-0 border-l border-border/60 bg-card overflow-hidden relative flex flex-col"
style={{ width }}
>
<div
onMouseDown={onDragStart}
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
/>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Users className="w-4 h-4 text-primary" />
COLONY WORKERS
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab bar */}
<div className="flex border-b border-border/60 flex-shrink-0">
<TabButton active={tab === "sessions"} onClick={() => setTab("sessions")} label="Sessions" />
<TabButton active={tab === "triggers"} onClick={() => setTab("triggers")} label="Triggers" />
<TabButton active={tab === "skills"} onClick={() => setTab("skills")} label="Skills" />
<TabButton active={tab === "tools"} onClick={() => setTab("tools")} label="Tools" />
<TabButton active={tab === "data"} onClick={() => setTab("data")} label="Data" />
</div>
<div className="flex-1 overflow-y-auto">
{tab === "sessions" && (
<SessionsTab sessionId={sessionId} colonyName={colonyName} />
)}
{tab === "triggers" && <TriggersTab sessionId={sessionId} />}
{tab === "skills" && <SkillsTab sessionId={sessionId} />}
{tab === "tools" && <ToolsTab sessionId={sessionId} />}
{tab === "data" && <DataTab colonyName={colonyName} />}
</div>
</aside>
);
}
function TabButton({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
onClick={onClick}
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors border-b-2 ${
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
>
{label}
</button>
);
}
// ── Skills tab ─────────────────────────────────────────────────────────
function SkillsTab({ sessionId }: { sessionId: string }) {
const [skills, setSkills] = useState<ColonySkill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.listSkills(sessionId)
.then((r) => setSkills(r.skills))
.catch((e) => setError(e?.message ?? "Failed to load skills"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
// Group by source_scope: user + project are shown expanded; framework
// is folded by default to keep the tab scannable (framework skills are
// the long list of built-ins that rarely change).
const groups = useMemo(() => {
const byScope: Record<string, ColonySkill[]> = { user: [], project: [], framework: [] };
for (const s of skills) {
const bucket = byScope[s.source_scope] ?? (byScope[s.source_scope] = []);
bucket.push(s);
}
return [
{ key: "user", label: "User skills", items: byScope.user, defaultOpen: true },
{ key: "project", label: "Project skills", items: byScope.project, defaultOpen: true },
{ key: "framework", label: "Framework skills", items: byScope.framework, defaultOpen: false },
].filter((g) => g.items.length > 0);
}, [skills]);
return (
<TabShell loading={loading} error={error} onRefresh={refresh} empty={skills.length === 0 ? "No skills loaded." : null}>
<div className="flex flex-col gap-3">
{groups.map((g) => (
<SkillGroup key={g.key} label={g.label} items={g.items} defaultOpen={g.defaultOpen} />
))}
</div>
</TabShell>
);
}
function SkillGroup({
label,
items,
defaultOpen,
}: {
label: string;
items: ColonySkill[];
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 mb-1.5 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground hover:text-foreground"
>
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<span>{label}</span>
<span className="text-muted-foreground/60">({items.length})</span>
</button>
{open && (
<ul className="flex flex-col gap-1.5">
{items.map((s) => (
<li
key={s.name}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
>
<code className="text-xs font-mono text-foreground block mb-1 truncate">
{s.name}
</code>
{s.description && (
<p className="text-xs text-foreground/75 line-clamp-3">{s.description}</p>
)}
</li>
))}
</ul>
)}
</section>
);
}
// ── Tools tab ──────────────────────────────────────────────────────────
function ToolsTab({ sessionId }: { sessionId: string }) {
const [tools, setTools] = useState<ColonyTool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.listTools(sessionId)
.then((r) => setTools(r.tools))
.catch((e) => setError(e?.message ?? "Failed to load tools"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
const groups = useMemo(() => groupTools(tools), [tools]);
return (
<TabShell loading={loading} error={error} onRefresh={refresh} empty={tools.length === 0 ? "No tools configured." : null}>
<div className="flex flex-col gap-3">
{groups.map((g) => (
<ToolGroup key={g.key} label={g.label} items={g.items} />
))}
</div>
</TabShell>
);
}
/** Display-label overrides for provider keys and framework-prefix
* groups that don't titlecase nicely. Anything not listed here gets
* a snake_case → Title Case conversion. */
const _LABEL_OVERRIDES: Record<string, string> = {
hubspot: "HubSpot",
github: "GitHub",
gitlab: "GitLab",
openai: "OpenAI",
aws_s3: "AWS S3",
azure_sql: "Azure SQL",
bigquery: "BigQuery",
microsoft_graph: "Microsoft Graph",
browser: "Browser",
bash: "Bash",
system: "System",
};
/** Framework/core tools don't have a credential provider, so they fall
* through to this map. Authoritative names for multi-file core tool
* groups; unmatched names fall through to a first-underscore prefix
* grouping. Keeping this small is deliberate — the credential system
* owns the rest. */
const _FRAMEWORK_GROUPS: Record<string, string> = {
read_file: "Filesystem",
write_file: "Filesystem",
edit_file: "Filesystem",
list_files: "Filesystem",
list_dir: "Filesystem",
list_directory: "Filesystem",
search_files: "Filesystem",
grep_search: "Filesystem",
hashline_edit: "Filesystem",
replace_file_content: "Filesystem",
apply_diff: "File edits",
apply_patch: "File edits",
web_scrape: "Web & research",
search_wikipedia: "Web & research",
search_papers: "Web & research",
download_paper: "Web & research",
pdf_read: "Web & research",
send_email: "Email",
dns_security_scan: "Security scans",
http_headers_scan: "Security scans",
port_scan: "Security scans",
ssl_tls_scan: "Security scans",
subdomain_enumerate: "Security scans",
tech_stack_detect: "Security scans",
risk_score: "Security scans",
query_runtime_log_raw: "Runtime logs",
query_runtime_log_details: "Runtime logs",
query_runtime_logs: "Runtime logs",
};
interface ToolGroupData {
key: string;
label: string;
items: ColonyTool[];
}
function labelFor(raw: string): string {
const override = _LABEL_OVERRIDES[raw];
if (override) return override;
return raw
.split("_")
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join(" ");
}
function groupTools(tools: ColonyTool[]): ToolGroupData[] {
const buckets = new Map<string, ColonyTool[]>();
const put = (label: string, t: ColonyTool) => {
const arr = buckets.get(label) ?? [];
arr.push(t);
buckets.set(label, arr);
};
for (const t of tools) {
// Preferred: backend-provided credential provider key. This is the
// authoritative grouping — it comes from the same CredentialSpec
// table that declares which tools need which credentials.
if (t.provider) {
put(labelFor(t.provider), t);
continue;
}
const explicit = _FRAMEWORK_GROUPS[t.name];
if (explicit) {
put(explicit, t);
continue;
}
// Last-resort: first-underscore prefix. Keeps e.g. all browser_*
// and bash_* tools together even though they have no credential.
const underscore = t.name.indexOf("_");
if (underscore > 0) {
put(labelFor(t.name.slice(0, underscore)), t);
continue;
}
put("Other", t);
}
// Collapse any single-item group into "Other" so the panel isn't
// full of one-entry sections.
const result: ToolGroupData[] = [];
const other: ColonyTool[] = buckets.get("Other") ?? [];
for (const [label, items] of buckets) {
if (label === "Other") continue;
if (items.length < 2) {
other.push(...items);
continue;
}
items.sort((a, b) => a.name.localeCompare(b.name));
result.push({ key: label, label, items });
}
result.sort((a, b) => a.label.localeCompare(b.label));
if (other.length) {
other.sort((a, b) => a.name.localeCompare(b.name));
result.push({ key: "Other", label: "Other", items: other });
}
return result;
}
function ToolGroup({ label, items }: { label: string; items: ColonyTool[] }) {
// Default folded — 100+ tools across ~15 groups is only readable when
// the user picks the one they want to inspect.
const [open, setOpen] = useState(false);
return (
<section>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 mb-1.5 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground hover:text-foreground"
>
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<span>{label}</span>
<span className="text-muted-foreground/60">({items.length})</span>
</button>
{open && (
<ul className="flex flex-col gap-1.5">
{items.map((t) => (
<li
key={t.name}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
>
<div className="flex items-center gap-1.5 min-w-0 mb-1">
<Wrench className="w-3 h-3 text-primary flex-shrink-0" />
<code className="text-xs font-mono text-foreground truncate">{t.name}</code>
</div>
{t.description && (
<p className="text-xs text-foreground/75 line-clamp-3">{t.description}</p>
)}
</li>
))}
</ul>
)}
</section>
);
}
// ── Sessions tab ───────────────────────────────────────────────────────
function SessionsTab({
sessionId,
colonyName,
}: {
sessionId: string;
colonyName: string | null;
}) {
const [workers, setWorkers] = useState<WorkerSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [stoppingId, setStoppingId] = useState<string | null>(null);
const [stoppingAll, setStoppingAll] = useState(false);
const { focusWorkerId, setFocusWorkerId } = useColonyWorkers();
// Consume focus requests from avatar clicks in chat. Wait for the
// initial fetch before deciding so a click that arrives before the
// workers list has loaded still resolves. If the requested id is
// present we drill into its detail view; if it's aged out we swallow
// the request silently. Either way we clear the focus so it isn't
// re-applied on every re-render.
useEffect(() => {
if (!focusWorkerId || loading) return;
if (workers.some((w) => w.worker_id === focusWorkerId)) {
setSelected(focusWorkerId);
}
setFocusWorkerId(null);
}, [focusWorkerId, workers, loading, setFocusWorkerId]);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.list(sessionId)
.then((r) => setWorkers(r.workers))
.catch((e) => setError(e?.message ?? "Failed to load workers"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
// Light poll so live workers tick their duration/status without the
// user hitting refresh. 2s matches the cadence of the standalone
// WorkersPanel this tab replaces.
useEffect(() => {
const id = setInterval(() => {
colonyWorkersApi
.list(sessionId)
.then((r) => setWorkers(r.workers))
.catch(() => {
/* swallow poll-time errors; the next tick retries. */
});
}, 2000);
return () => clearInterval(id);
}, [sessionId]);
const selectedWorker = useMemo(
() => (selected ? workers.find((w) => w.worker_id === selected) : null),
[selected, workers],
);
const stopOne = useCallback(
async (workerId: string) => {
setStoppingId(workerId);
try {
await workersApi.stopLive(sessionId, workerId);
} catch {
/* next poll reflects truth */
} finally {
setStoppingId(null);
refresh();
}
},
[sessionId, refresh],
);
const stopAll = useCallback(async () => {
setStoppingAll(true);
try {
await workersApi.stopAllLive(sessionId);
} catch {
/* ignore */
} finally {
setStoppingAll(false);
refresh();
}
}, [sessionId, refresh]);
// Split into active / history buckets — active workers are hoisted
// to the top and rendered with a primary-tinted card so the user's
// attention lands there first. History stays visible but muted so
// prior runs stay auditable without competing for focus.
//
// NB: this useMemo MUST run on every render (no conditional
// early-return before it) — React's Rules of Hooks require a
// stable hook order. Previously we returned early on `selected`
// BEFORE calling useMemo, which produced React error #300 in
// the minified prod build the moment the user drilled into a
// worker detail view.
const { activeWorkers, historyWorkers } = useMemo(() => {
const act: WorkerSummary[] = [];
const hist: WorkerSummary[] = [];
for (const w of workers) {
(isWorkerActive(w) ? act : hist).push(w);
}
const byRecent = (a: WorkerSummary, b: WorkerSummary) =>
(b.started_at || 0) - (a.started_at || 0);
act.sort(byRecent);
hist.sort(byRecent);
return { activeWorkers: act, historyWorkers: hist };
}, [workers]);
if (selected) {
return (
<WorkerDetail
colonyName={colonyName}
worker={selectedWorker}
workerId={selected}
onBack={() => setSelected(null)}
/>
);
}
const activeCount = activeWorkers.length;
const renderCard = (w: WorkerSummary, active: boolean) => (
<li key={w.worker_id}>
<div
className={`rounded-lg border transition-colors ${
active
? "border-primary/40 bg-primary/[0.06] ring-1 ring-primary/20 hover:bg-primary/10"
: "border-border/40 bg-background/20 opacity-80 hover:bg-muted/20 hover:opacity-100"
}`}
>
<button
onClick={() => setSelected(w.worker_id)}
className="w-full text-left px-3 py-2.5"
>
<div className="flex items-center justify-between mb-1 gap-2">
<code
className={`text-xs font-mono ${active ? "text-foreground" : "text-foreground/70"}`}
>
{shortId(w.worker_id)}
</code>
<div className="flex items-center gap-1">
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(w.status)}`}
>
{w.status}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</div>
</div>
{w.task && (
<p
className={`text-xs line-clamp-2 mb-1 ${
active ? "text-foreground/85" : "text-foreground/60"
}`}
>
{w.task}
</p>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{fmtStarted(w.started_at)}</span>
{w.result && (
<span>
{w.result.duration_seconds ? `${w.result.duration_seconds.toFixed(1)}s` : ""}
{w.result.tokens_used
? ` · ${w.result.tokens_used.toLocaleString()} tok`
: ""}
</span>
)}
</div>
</button>
{active && (
<div className="border-t border-primary/20 px-3 py-1.5 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
stopOne(w.worker_id);
}}
disabled={stoppingId === w.worker_id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-destructive/40 text-destructive text-[10px] hover:bg-destructive/10 disabled:opacity-50 transition-colors"
title="Stop this worker"
>
<Square className="w-2.5 h-2.5" />
{stoppingId === w.worker_id ? "Stopping…" : "Stop"}
</button>
</div>
)}
</div>
</li>
);
return (
<TabShell
loading={loading}
error={error}
onRefresh={refresh}
empty={workers.length === 0 ? "No workers spawned yet." : null}
headerRight={
activeCount > 0 ? (
<button
onClick={stopAll}
disabled={stoppingAll}
className="text-[10px] px-2 py-0.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
title={`Stop ${activeCount} active worker${activeCount === 1 ? "" : "s"}`}
>
{stoppingAll ? "Stopping…" : `Stop all (${activeCount})`}
</button>
) : null
}
>
<div className="flex flex-col gap-3">
{activeWorkers.length > 0 && (
<section>
<h4 className="text-[10px] uppercase tracking-wide font-semibold text-primary mb-1.5 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
Active ({activeWorkers.length})
</h4>
<ul className="flex flex-col gap-1.5">
{activeWorkers.map((w) => renderCard(w, true))}
</ul>
</section>
)}
{historyWorkers.length > 0 && (
<section>
<h4 className="text-[10px] uppercase tracking-wide font-semibold text-muted-foreground mb-1.5">
History ({historyWorkers.length})
</h4>
<ul className="flex flex-col gap-1.5">
{historyWorkers.map((w) => renderCard(w, false))}
</ul>
</section>
)}
</div>
</TabShell>
);
}
function isWorkerActive(w: WorkerSummary): boolean {
const s = (w.status || "").toLowerCase();
return s === "pending" || s === "running";
}
// ── Triggers tab ───────────────────────────────────────────────────────
function TriggersTab({ sessionId }: { sessionId: string }) {
const { triggers } = useColonyWorkers();
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = useMemo(
() => (selectedId ? triggers.find((t) => t.id === selectedId) ?? null : null),
[selectedId, triggers],
);
if (selected) {
return (
<TriggerDetail
sessionId={sessionId}
trigger={selected}
onBack={() => setSelectedId(null)}
/>
);
}
return (
<TabShell
loading={false}
error={null}
onRefresh={() => {
/* triggers come from SSE in the colony page — no pull-refresh needed */
}}
empty={
triggers.length === 0
? "No triggers configured. Ask the queen to set a schedule or webhook."
: null
}
>
<ul className="flex flex-col gap-1.5">
{triggers.map((t) => (
<li key={t.id}>
<TriggerCard trigger={t} onClick={() => setSelectedId(t.id)} />
</li>
))}
</ul>
</TabShell>
);
}
function triggerIsActive(t: GraphNode): boolean {
return t.status === "running" || t.status === "complete";
}
function TriggerIcon({ type }: { type?: string }) {
const cls = "w-3.5 h-3.5";
switch (type) {
case "webhook":
return <Webhook className={cls} />;
case "timer":
return <Clock className={cls} />;
case "api":
return <ChevronRight className={cls} />;
case "event":
return <Activity className={cls} />;
default:
return <Zap className={cls} />;
}
}
function scheduleLabel(config: Record<string, unknown> | undefined): string | null {
if (!config) return null;
const cron = config.cron as string | undefined;
if (cron) return cronToLabel(cron);
const interval = config.interval_minutes as number | undefined;
if (interval != null) {
if (interval >= 60) return `Every ${interval / 60}h`;
return `Every ${interval}m`;
}
return null;
}
function countdownLabel(nextFireIn: number | undefined): string | null {
if (nextFireIn == null || nextFireIn <= 0) return null;
const h = Math.floor(nextFireIn / 3600);
const m = Math.floor((nextFireIn % 3600) / 60);
const s = Math.floor(nextFireIn % 60);
return h > 0
? `next in ${h}h ${String(m).padStart(2, "0")}m`
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
}
/** Tick a live countdown against the server-provided absolute `next_fire_at`
* (epoch ms). Falls back to converting `next_fire_in` (seconds delta) if
* the absolute form is absent. Rolls forward by interval_minutes when
* zero is crossed so the UI keeps counting between server pushes. */
function useLiveCountdown(
nextFireAt: number | undefined,
nextFireIn: number | undefined,
isActive: boolean,
intervalMinutes: number | undefined,
): { remainingSec: number | null; firesAtMs: number | null } {
const [firesAtMs, setFiresAtMs] = useState<number | null>(null);
const [remainingSec, setRemainingSec] = useState<number | null>(null);
useEffect(() => {
if (typeof nextFireAt === "number" && nextFireAt > 0) {
setFiresAtMs(nextFireAt);
} else if (typeof nextFireIn === "number" && nextFireIn >= 0) {
setFiresAtMs(Date.now() + nextFireIn * 1000);
} else {
setFiresAtMs(null);
}
}, [nextFireAt, nextFireIn]);
useEffect(() => {
if (!isActive || firesAtMs == null) {
setRemainingSec(null);
return;
}
const tick = () => {
const diff = (firesAtMs - Date.now()) / 1000;
if (diff > 0) {
setRemainingSec(diff);
} else if (intervalMinutes) {
setFiresAtMs((prev) => (prev != null ? prev + intervalMinutes * 60 * 1000 : prev));
} else {
setRemainingSec(0);
}
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [firesAtMs, isActive, intervalMinutes]);
return { remainingSec, firesAtMs };
}
function TriggerCard({ trigger, onClick }: { trigger: GraphNode; onClick: () => void }) {
const isActive = triggerIsActive(trigger);
const schedule = scheduleLabel(trigger.triggerConfig);
const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined;
const nextFireAt = trigger.triggerConfig?.next_fire_at as number | undefined;
const interval = trigger.triggerConfig?.interval_minutes as number | undefined;
const fireCount = trigger.triggerConfig?.fire_count as number | undefined;
const lastFiredAt = trigger.triggerConfig?.last_fired_at as number | undefined;
const { remainingSec } = useLiveCountdown(nextFireAt, nextFireIn, isActive, interval);
const now = useNow(1000);
const countdown = isActive && remainingSec != null ? countdownLabel(remainingSec) : null;
const agoLabel = lastFiredAt ? formatAgo(lastFiredAt, now) : null;
return (
<button
onClick={onClick}
className="w-full text-left rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center ${
isActive ? "bg-primary/15 text-primary" : "bg-muted/60 text-muted-foreground"
}`}
>
<TriggerIcon type={trigger.triggerType} />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">{trigger.label}</p>
{schedule && schedule !== trigger.label && (
<p className="text-[10.5px] text-muted-foreground truncate mt-0.5">{schedule}</p>
)}
</div>
<span
className={`flex-shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isActive ? "bg-emerald-500/15 text-emerald-400" : "bg-muted/60 text-muted-foreground"
}`}
>
{isActive ? "active" : "inactive"}
</span>
</div>
{countdown && (
<p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p>
)}
{(fireCount != null && fireCount > 0) || agoLabel ? (
<p className="text-[10px] text-muted-foreground mt-0.5 pl-8">
{fireCount != null && fireCount > 0 ? `fired ${fireCount}×` : null}
{fireCount != null && fireCount > 0 && agoLabel ? " · " : null}
{agoLabel ? `last ${agoLabel}` : null}
</p>
) : null}
</button>
);
}
function formatCountdown(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
/** Human-readable "X ago" for a wall-clock epoch ms. */
function formatAgo(epochMs: number, nowMs: number): string {
const diff = Math.max(0, Math.floor((nowMs - epochMs) / 1000));
if (diff < 5) return "just now";
if (diff < 60) return `${diff}s ago`;
const m = Math.floor(diff / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
/** Reactive Date.now() that re-renders on an interval. 1s default keeps
* countdowns smooth; consumers that only need "ago" can pass a coarser
* interval. */
function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(id);
}, [intervalMs]);
return now;
}
function TriggerDetail({
sessionId,
trigger,
onBack,
}: {
sessionId: string;
trigger: GraphNode;
onBack: () => void;
}) {
const [busy, setBusy] = useState(false);
const [runBusy, setRunBusy] = useState(false);
const [runNotice, setRunNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const isActive = triggerIsActive(trigger);
const config = (trigger.triggerConfig || {}) as Record<string, unknown>;
const cron = config.cron as string | undefined;
const interval = config.interval_minutes as number | undefined;
const nextFireIn = config.next_fire_in as number | undefined;
const nextFireAt = config.next_fire_at as number | undefined;
const fireCount = config.fire_count as number | undefined;
const lastFiredAt = config.last_fired_at as number | undefined;
const triggerId = trigger.id.replace(/^__trigger_/, "");
const { remainingSec, firesAtMs } = useLiveCountdown(nextFireAt, nextFireIn, isActive, interval);
const now = useNow(1000);
const lastFiredAgo = lastFiredAt ? formatAgo(lastFiredAt, now) : null;
const handleToggle = async () => {
if (!sessionId || busy) return;
setBusy(true);
setError(null);
try {
if (isActive) {
await sessionsApi.deactivateTrigger(sessionId, triggerId);
} else {
await sessionsApi.activateTrigger(sessionId, triggerId);
}
// SSE TRIGGER_ACTIVATED / TRIGGER_DEACTIVATED flips the card
// state in the context; we don't set local state.
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const handleForceRun = async () => {
if (!sessionId || runBusy) return;
setRunBusy(true);
setError(null);
setRunNotice(null);
try {
await sessionsApi.runTrigger(sessionId, triggerId);
setRunNotice("Trigger fired");
// Clear the notice after a few seconds so it doesn't linger.
setTimeout(() => setRunNotice(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunBusy(false);
}
};
const schedule = cron
? cronToLabel(cron)
: interval != null
? interval >= 60
? `Every ${interval / 60}h`
: `Every ${interval}m`
: null;
// Hide UI-synthesised fields so the user sees only real operator config.
const displayEntries = Object.entries(config).filter(
([k]) =>
k !== "next_fire_in" &&
k !== "next_fire_at" &&
k !== "fire_count" &&
k !== "last_fired_at" &&
k !== "entry_node",
);
return (
<div className="px-4 py-3">
<button
onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-3"
>
<ArrowLeft className="w-3 h-3" />
All triggers
</button>
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 mb-3">
<div className="flex items-start gap-2.5 mb-2">
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${
isActive ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground"
}`}
>
<TriggerIcon type={trigger.triggerType} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{trigger.label}
</p>
<div className="flex items-center gap-2 mt-1">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isActive
? "bg-emerald-500/15 text-emerald-400"
: "bg-muted/60 text-muted-foreground"
}`}
>
{isActive ? "active" : "inactive"}
</span>
{trigger.triggerType && (
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
{trigger.triggerType}
</span>
)}
</div>
</div>
</div>
</div>
{schedule && (
<Section label="Schedule">
<p className="text-xs text-foreground">{schedule}</p>
{cron && <p className="text-[10px] text-muted-foreground mt-1 font-mono">{cron}</p>}
</Section>
)}
{isActive && remainingSec != null && remainingSec > 0 && (
<Section label="Next fire">
<p className="text-xs text-foreground italic">in {formatCountdown(remainingSec)}</p>
{firesAtMs != null && (
<p className="text-[10px] text-muted-foreground mt-1">
at {new Date(firesAtMs).toLocaleTimeString()}
</p>
)}
</Section>
)}
{(fireCount != null && fireCount > 0) || lastFiredAgo ? (
<Section label="Last fire">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-foreground">{lastFiredAgo ?? "—"}</span>
{fireCount != null && fireCount > 0 && (
<span className="text-[10px] text-muted-foreground">fired {fireCount}×</span>
)}
</div>
{lastFiredAt && (
<p className="text-[10px] text-muted-foreground mt-1">
at {new Date(lastFiredAt).toLocaleTimeString()}
</p>
)}
</Section>
) : null}
{displayEntries.length > 0 && (
<Section label="Config">
<div className="space-y-1">
{displayEntries.map(([k, v]) => (
<div key={k} className="flex items-start justify-between gap-3 text-[11px]">
<span className="text-muted-foreground font-mono">{k}</span>
<span className="text-foreground font-mono text-right truncate">
{typeof v === "object" ? JSON.stringify(v) : String(v)}
</span>
</div>
))}
</div>
</Section>
)}
<Section label="Trigger ID">
<p className="text-[11px] text-muted-foreground font-mono break-all">{triggerId}</p>
</Section>
{error && (
<p className="text-[10.5px] text-destructive leading-snug mb-2">{error}</p>
)}
{runNotice && (
<p className="text-[10.5px] text-emerald-400 leading-snug mb-2">{runNotice}</p>
)}
<button
type="button"
onClick={handleForceRun}
disabled={runBusy || !sessionId}
title="Fire this trigger once, bypassing the schedule"
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 mb-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-amber-500/15 text-amber-400 hover:bg-amber-500/25 border border-amber-500/30"
>
{runBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
{runBusy ? "Firing…" : "Force Run"}
</button>
<button
type="button"
onClick={handleToggle}
disabled={busy || !sessionId}
className={`w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isActive
? "bg-muted/50 text-foreground hover:bg-muted/70 border border-border/30"
: "bg-primary/15 text-primary hover:bg-primary/25 border border-primary/30"
}`}
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : isActive ? (
<Square className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{busy ? "Working…" : isActive ? "Stop trigger" : "Start trigger"}
</button>
</div>
);
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mb-3">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
{label}
</p>
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5">
{children}
</div>
</div>
);
}
// ── Data tab (airtable-style view of progress.db tables) ──────────────
/** Table-list refresh cadence. Slower than the row poll because the
* overview only drives the row-count chips; the operator doesn't care
* if the count lags the live data by a few seconds. */
const TABLES_POLL_MS = 5000;
function DataTab({ colonyName }: { colonyName: string | null }) {
const [tables, setTables] = useState<TableOverview[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [loadingTables, setLoadingTables] = useState(true);
const [tablesError, setTablesError] = useState<string | null>(null);
const refreshTables = useCallback(
(opts: { silent?: boolean } = {}) => {
if (!colonyName) {
setTables([]);
setLoadingTables(false);
return Promise.resolve();
}
if (!opts.silent) {
setLoadingTables(true);
setTablesError(null);
}
return colonyDataApi
.listTables(colonyName)
.then((r) => {
setTables(r.tables);
// Auto-select the first table when none chosen yet so the user
// lands on data instead of an empty picker.
setSelected((cur) => cur ?? r.tables[0]?.name ?? null);
if (opts.silent) setTablesError(null);
})
.catch((e) => {
// Only surface errors on user-initiated loads; silent polls
// stay quiet and the next tick retries.
if (!opts.silent) setTablesError(e?.message ?? "Failed to load tables");
})
.finally(() => {
if (!opts.silent) setLoadingTables(false);
});
},
[colonyName],
);
useEffect(() => {
refreshTables();
}, [refreshTables]);
// Background poll for row-count freshness. Skipped when the browser
// tab is hidden — there's no point burning DB reads for a view the
// user isn't watching.
useEffect(() => {
const id = setInterval(() => {
if (typeof document !== "undefined" && document.hidden) return;
void refreshTables({ silent: true });
}, TABLES_POLL_MS);
return () => clearInterval(id);
}, [refreshTables]);
if (!colonyName) {
return (
<p className="text-xs text-muted-foreground text-center py-8 px-4">
This session isn't bound to a colony yet — no progress.db to view.
</p>
);
}
return (
<div className="px-4 py-3">
{tablesError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
{tablesError}
</div>
)}
{loadingTables && tables.length === 0 ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : tables.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">
No tables in progress.db (or the colony has no DB yet).
</p>
) : (
<>
{/* Table picker — chips so we avoid a heavier select dropdown
in the narrow sidebar. Row counts hint at scale before the
user clicks in. */}
<div className="flex flex-wrap gap-1.5 mb-3">
{tables.map((t) => (
<button
key={t.name}
onClick={() => setSelected(t.name)}
className={`text-[10.5px] font-mono px-2 py-1 rounded border transition-colors ${
selected === t.name
? "border-primary/60 bg-primary/10 text-foreground"
: "border-border/50 bg-background/40 text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
title={`${t.row_count.toLocaleString()} rows · ${t.columns.length} columns`}
>
{t.name}
<span className="ml-1 text-muted-foreground/70">
({t.row_count.toLocaleString()})
</span>
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground mb-2 italic">
Live view — edits write directly to progress.db. A running worker
may not notice until its next DB read.
</p>
{selected && (
<TableView
key={selected}
colonyName={colonyName}
table={selected}
onAnyEdit={() => {
// Row counts can change via cascading triggers or NULL→value
// edits; re-pull so the chip stays truthful.
void refreshTables();
}}
/>
)}
</>
)}
</div>
);
}
/** Page size for the Data tab grid. 100 is a sweet spot for the narrow
* sidebar — big enough that most real-world tables render in one page,
* small enough to keep edits responsive. */
const DATA_PAGE_SIZE = 100;
/** Row-poll cadence. 2.5s balances "feels live" against server load
* and our edit/poll race window. Shorter intervals amplify the
* chance of a poll landing during a PATCH roundtrip. */
const ROWS_POLL_MS = 2500;
/** Returns true if the user is actively editing any cell inside the
* grid — we sniff for a focused textarea. The alternative (bubbling
* editing state up from every EditableCell) would force the grid
* prop to track a counter. DOM inspection is simpler and — since the
* grid is self-contained under `root` — equally reliable. */
function isEditingInside(root: HTMLElement | null): boolean {
if (!root) return false;
const active = document.activeElement;
return !!active && root.contains(active) && active.tagName === "TEXTAREA";
}
/** Shallow-merge new rows on top of the previous page *by primary
* key*. Reuses unchanged row-object references so React can skip
* re-rendering those `<tr>`s — important when the user has the grid
* scrolled horizontally and we don't want jank at every poll. */
function mergeRowsByPk(
prev: TableRowsResponse,
next: TableRowsResponse,
): TableRowsResponse {
if (prev.primary_key.length === 0) return next;
const prevByKey = new Map<string, Record<string, CellValue>>();
for (const r of prev.rows) {
prevByKey.set(prev.primary_key.map((p) => String(r[p] ?? "")).join("|"), r);
}
const rows = next.rows.map((r) => {
const key = next.primary_key.map((p) => String(r[p] ?? "")).join("|");
const old = prevByKey.get(key);
if (!old) return r;
// Same key AND all columns identical → reuse the previous object
// so React's reference check skips re-rendering.
for (const col of Object.keys(r)) {
if (r[col] !== old[col]) return r;
}
return old;
});
return { ...next, rows };
}
function TableView({
colonyName,
table,
onAnyEdit,
}: {
colonyName: string;
table: string;
onAnyEdit: () => void;
}) {
const [data, setData] = useState<TableRowsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [orderBy, setOrderBy] = useState<string | null>(null);
const [orderDir, setOrderDir] = useState<SortDir>("asc");
// Request-id guard. Any in-flight request with a stale id is
// discarded on return. Bumped on (a) every new request-start and
// (b) successful edits, so a poll that started *before* a PATCH
// cannot land *after* it and rollback the new value.
const reqIdRef = useRef(0);
const gridRef = useRef<HTMLDivElement | null>(null);
const fetchOnce = useCallback(
(opts: { silent: boolean }) => {
const myId = ++reqIdRef.current;
if (!opts.silent) {
setLoading(true);
setError(null);
}
colonyDataApi
.listRows(colonyName, table, {
limit: DATA_PAGE_SIZE,
offset,
orderBy,
orderDir,
})
.then((next) => {
// Discard stale responses — sort/offset changed, edit
// happened, or a subsequent poll started.
if (myId !== reqIdRef.current) return;
setData((prev) => (prev ? mergeRowsByPk(prev, next) : next));
if (opts.silent) setError(null);
})
.catch((e) => {
if (myId !== reqIdRef.current) return;
// Silent polls swallow errors; the next tick retries. User-
// initiated loads surface so the operator sees the failure.
if (!opts.silent) setError(e?.message ?? "Failed to load rows");
})
.finally(() => {
if (!opts.silent && myId === reqIdRef.current) setLoading(false);
});
},
[colonyName, table, offset, orderBy, orderDir],
);
// Initial + on-parameter-change load (user-initiated, shows spinner).
useEffect(() => {
fetchOnce({ silent: false });
}, [fetchOnce]);
// Background polling. Pauses when (a) the browser tab is hidden —
// no point spending DB reads on an unwatched panel, and (b) the
// user is mid-edit — a silent re-fetch would reorder rows or reset
// the draft under their cursor.
useEffect(() => {
const id = setInterval(() => {
if (typeof document !== "undefined" && document.hidden) return;
if (isEditingInside(gridRef.current)) return;
fetchOnce({ silent: true });
}, ROWS_POLL_MS);
return () => clearInterval(id);
}, [fetchOnce]);
// Reset paging when switching tables (key prop on TableView takes care
// of full unmount; this covers the sort-change case).
useEffect(() => {
setOffset(0);
}, [orderBy, orderDir]);
const handleSort = useCallback((col: string | null, dir: SortDir) => {
setOrderBy(col);
setOrderDir(dir);
}, []);
const handleEdit = useCallback(
async (pk: Record<string, CellValue>, column: string, newValue: CellValue) => {
await colonyDataApi.updateRow(colonyName, table, {
pk,
updates: { [column]: newValue },
});
// Bump the request-id so any poll that started before the PATCH
// (and is about to return with pre-edit data) is discarded —
// otherwise the grid would briefly revert the cell.
reqIdRef.current++;
// Optimistic patch of the local cache so the grid reflects the
// edit instantly without a full re-fetch flash.
setData((prev) => {
if (!prev) return prev;
const rows = prev.rows.map((r) => {
const matches = prev.primary_key.every((p) => r[p] === pk[p]);
return matches ? { ...r, [column]: newValue } : r;
});
return { ...prev, rows };
});
onAnyEdit();
},
[colonyName, table, onAnyEdit],
);
if (error) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{error}
</div>
);
}
if (!data) {
return (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
);
}
const pageEnd = Math.min(data.offset + data.rows.length, data.total);
const canPrev = data.offset > 0;
const canNext = pageEnd < data.total;
return (
<div className="flex flex-col gap-2" ref={gridRef}>
<DataGrid
columns={data.columns}
rows={data.rows}
primaryKey={data.primary_key}
orderBy={orderBy}
orderDir={orderDir}
onSortChange={handleSort}
onCellEdit={handleEdit}
loading={loading}
emptyMessage="Table is empty."
/>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full bg-emerald-500/80 animate-pulse"
title={`Auto-refreshing every ${ROWS_POLL_MS / 1000}s (paused while editing)`}
/>
<span>
{data.total === 0
? "0 rows"
: `${data.offset + 1}${pageEnd} of ${data.total.toLocaleString()}`}
</span>
</span>
<div className="flex gap-1">
<button
onClick={() => setOffset(Math.max(0, offset - DATA_PAGE_SIZE))}
disabled={!canPrev || loading}
className="px-2 py-0.5 rounded border border-border/50 disabled:opacity-40 hover:bg-muted/30"
>
Prev
</button>
<button
onClick={() => setOffset(offset + DATA_PAGE_SIZE)}
disabled={!canNext || loading}
className="px-2 py-0.5 rounded border border-border/50 disabled:opacity-40 hover:bg-muted/30"
>
Next
</button>
</div>
</div>
</div>
);
}
// ── Worker detail view (inside Sessions tab) ───────────────────────────
function WorkerDetail({
colonyName,
worker,
workerId,
onBack,
}: {
colonyName: string | null;
worker: WorkerSummary | null | undefined;
workerId: string;
onBack: () => void;
}) {
// Historical workers (loaded from disk rather than live memory) have
// no live progress.db stream to attach to — opening the SSE just
// renders "No progress rows yet." forever, which is what the user
// was calling "middle of nowhere". Skip the stream and show the
// result summary + an archived-conversation hint instead.
const isHistorical =
worker?.status === "historical" ||
(worker != null && !isWorkerActive(worker) && worker.result == null);
return (
<div className="px-4 py-3">
<button
onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-3"
>
<ArrowLeft className="w-3 h-3" />
All sessions
</button>
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 mb-3">
<div className="flex items-center justify-between mb-1 gap-2">
<code className="text-xs font-mono text-foreground">{shortId(workerId)}</code>
{worker && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(worker.status)}`}
>
{worker.status}
</span>
)}
</div>
{worker?.task && <p className="text-xs text-foreground/80 mb-1">{worker.task}</p>}
<div className="text-[10px] text-muted-foreground">
{worker ? fmtStarted(worker.started_at) : ""}
{worker?.result?.duration_seconds
? ` · ${worker.result.duration_seconds.toFixed(1)}s`
: ""}
{worker?.result?.tokens_used
? ` · ${worker.result.tokens_used.toLocaleString()} tok`
: ""}
</div>
{worker?.result?.summary && (
<p className="mt-2 text-xs text-foreground/90 border-t border-border/40 pt-2">
{worker.result.summary}
</p>
)}
{worker?.result?.error && (
<p className="mt-2 text-xs text-destructive border-t border-destructive/30 pt-2">
{worker.result.error}
</p>
)}
</div>
{isHistorical ? (
<HistoricalWorkerPlaceholder workerId={workerId} />
) : (
<LiveWorkerProgress colonyName={colonyName} workerId={workerId} />
)}
</div>
);
}
function LiveWorkerProgress({
colonyName,
workerId,
}: {
colonyName: string | null;
workerId: string;
}) {
const { snapshot, streamState, error } = useProgressStream(colonyName, workerId);
return (
<>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5 text-xs font-semibold text-foreground/90">
<Database className="w-3.5 h-3.5 text-primary" />
Progress (progress.db)
</div>
<StreamBadge state={streamState} />
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-2">
{error}
</div>
)}
<ProgressView snapshot={snapshot} />
</>
);
}
function HistoricalWorkerPlaceholder({ workerId }: { workerId: string }) {
return (
<div className="rounded-lg border border-border/40 bg-background/30 px-3 py-4 text-xs text-muted-foreground space-y-1.5">
<p className="text-foreground/80">This worker has finished.</p>
<p>
Live progress is no longer streaming. The worker's full conversation is
archived under{" "}
<code className="text-[11px] font-mono text-foreground/80">
workers/{shortId(workerId)}/conversations/
</code>{" "}
in the session data folder — use the{" "}
<span className="text-foreground/80 font-medium">Data</span> button in
the header to open it.
</p>
</div>
);
}
function StreamBadge({ state }: { state: "connecting" | "open" | "closed" | "error" }) {
const cls =
state === "open"
? "bg-emerald-500/15 text-emerald-500"
: state === "connecting"
? "bg-primary/15 text-primary"
: state === "error"
? "bg-destructive/15 text-destructive"
: "bg-muted text-muted-foreground";
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${cls}`}>{state}</span>
);
}
function ProgressView({ snapshot }: { snapshot: ProgressSnapshot }) {
const stepsByTask = useMemo(() => {
const m = new Map<string, ProgressStep[]>();
for (const step of snapshot.steps) {
const arr = m.get(step.task_id) ?? [];
arr.push(step);
m.set(step.task_id, arr);
}
for (const arr of m.values()) arr.sort((a, b) => a.seq - b.seq);
return m;
}, [snapshot.steps]);
if (snapshot.tasks.length === 0 && snapshot.steps.length === 0) {
return (
<p className="text-xs text-muted-foreground text-center py-6">
No progress rows yet.
</p>
);
}
return (
<ul className="flex flex-col gap-2">
{snapshot.tasks.map((t) => (
<li
key={t.id}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2"
>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-xs text-foreground/90 break-words flex-1">{t.goal}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0 ${statusClasses(t.status)}`}
>
{t.status}
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<code className="font-mono">{t.id.slice(0, 8)}</code>
{t.updated_at && <span>· upd {fmtIso(t.updated_at)}</span>}
{t.retry_count > 0 && (
<span>
· retry {t.retry_count}/{t.max_retries}
</span>
)}
</div>
{(() => {
const steps = stepsByTask.get(t.id) ?? [];
if (steps.length === 0) return null;
return (
<ul className="mt-2 pl-2 border-l border-border/40 flex flex-col gap-1">
{steps.map((s) => (
<li key={s.id} className="flex items-start gap-1.5 text-[11px]">
<span
className={`mt-0.5 w-1.5 h-1.5 rounded-full flex-shrink-0 ${
s.status === "completed" || s.status === "done"
? "bg-emerald-500"
: s.status === "failed"
? "bg-destructive"
: s.status === "in_progress" || s.status === "running"
? "bg-primary animate-pulse"
: "bg-muted-foreground/40"
}`}
/>
<span className="text-foreground/80 flex-1 break-words">{s.title}</span>
{s.completed_at && (
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{fmtIso(s.completed_at)}
</span>
)}
</li>
))}
</ul>
);
})()}
</li>
))}
</ul>
);
}
// ── Hook: live progress via SSE ────────────────────────────────────────
function useProgressStream(colonyName: string | null, workerId: string) {
const [snapshot, setSnapshot] = useState<ProgressSnapshot>({ tasks: [], steps: [] });
const [streamState, setStreamState] = useState<"connecting" | "open" | "closed" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSnapshot({ tasks: [], steps: [] });
setError(null);
setStreamState("connecting");
// Skip the SSE connection entirely if the session isn't bound to a
// colony — we'd just hit a 400 on every reconnect attempt.
if (!colonyName) {
setStreamState("closed");
return;
}
const url = colonyWorkersApi.progressStreamUrl(colonyName, workerId);
const es = new EventSource(url);
es.addEventListener("open", () => setStreamState("open"));
es.addEventListener("snapshot", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ProgressSnapshot;
setSnapshot(data);
setStreamState("open");
} catch (err) {
setError(`snapshot parse failed: ${String(err)}`);
}
});
es.addEventListener("upsert", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ProgressSnapshot;
setSnapshot((prev) => mergeSnapshot(prev, data));
} catch (err) {
setError(`upsert parse failed: ${String(err)}`);
}
});
es.addEventListener("error", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as { message?: string };
if (data.message) setError(data.message);
} catch {
/* EventSource raw error — state below handles it. */
}
});
es.onerror = () => {
// EventSource auto-retries; surface the transient state so the
// badge reflects reality.
setStreamState((s) => (s === "open" ? "error" : s));
};
return () => {
es.close();
setStreamState("closed");
};
}, [colonyName, workerId]);
return { snapshot, streamState, error };
}
function mergeSnapshot(prev: ProgressSnapshot, upsert: ProgressSnapshot): ProgressSnapshot {
const taskMap = new Map(prev.tasks.map((t) => [t.id, t]));
for (const t of upsert.tasks) taskMap.set(t.id, t);
const tasks = Array.from(taskMap.values()).sort((a, b) =>
b.updated_at.localeCompare(a.updated_at),
);
const stepMap = new Map(prev.steps.map((s) => [s.id, s]));
for (const s of upsert.steps) stepMap.set(s.id, s);
const steps = Array.from(stepMap.values()).sort((a, b) => {
if (a.task_id !== b.task_id) return a.task_id.localeCompare(b.task_id);
return a.seq - b.seq;
});
return { tasks, steps };
}
// ── Shared tab shell: loading / error / empty / refresh button ─────────
function TabShell({
loading,
error,
onRefresh,
empty,
headerRight,
children,
}: {
loading: boolean;
error: string | null;
onRefresh: () => void;
empty: string | null;
headerRight?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div>{headerRight}</div>
<button
onClick={onRefresh}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="Refresh"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
{error}
</div>
)}
{loading && !error ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : empty ? (
<p className="text-xs text-muted-foreground text-center py-8">{empty}</p>
) : (
children
)}
</div>
);
}