1894 lines
64 KiB
TypeScript
1894 lines
64 KiB
TypeScript
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>
|
||
);
|
||
}
|