Files
clawmates/frontend/src/components/dashboard/HerdrSessions.tsx
T
Omar Sobh d8c8793c4a
ci / gates (push) Successful in 8s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m25s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m46s
ci fixes: cargo fmt, eslint entities, max-lines split
CI on 6ffbe97 failed on two auto-fixable gates. Both fixed:

  * cargo fmt --all — rustfmt applied across the surface touched
    by the last ~20 commits (world.rs, security_scan.rs,
    routes/{missions,nodes,terminal}.rs, fleet_herdr.rs,
    mission_workspace.rs, benchmark_runner.rs, mission_refiner.rs,
    lib.rs, tests/mission_orchestrator.rs, cm-db/repo/{missions,teams}.rs,
    bins/clawmates-node/src/main.rs)
  * eslint apostrophe escapes in HerdrSessions + MissionWizard
  * eslint max-lines: extracted EditMissionModal + RefineDiffModal
    (each ~200 LoC) into their own files. MissionCanvas drops from
    1424 to 1026, comfortably under both the 1250 eslint cap and the
    1500 CI budget.

New files:
  frontend/src/components/dashboard/EditMissionModal.tsx  (211 LoC)
  frontend/src/components/dashboard/RefineDiffModal.tsx   (208 LoC)

Verified locally: cargo fmt --check clean, cargo check clean,
mission_orchestrator test 3/3 pass, tsc + eslint --quiet both silent.
2026-07-20 12:03:43 -07:00

341 lines
11 KiB
TypeScript

"use client";
// INFRA → Herdr sessions: browse the Herdr session snapshot from every
// online node. One card per node with per-workspace agent-state pills
// + a "Open in browser" button that spawns an xterm attached to that
// node's Herdr TUI (same LivePane mechanism the MissionCanvas uses).
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw, Sparkles, TerminalSquare } from "lucide-react";
import type { FleetNode } from "./fleet/FleetPanels";
import {
nodeHerdrConnector,
useResilientTerminal,
type TermMode,
} from "@/components/computer/apps/terminal/core";
import "@xterm/xterm/css/xterm.css";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type AgentStatus = "working" | "blocked" | "done" | "idle" | "unknown";
interface Workspace {
workspace_id: string;
label?: string;
number?: number;
agent_status?: AgentStatus;
pane_count?: number;
tab_count?: number;
}
interface Snapshot {
workspaces?: Workspace[];
error?: string;
}
const STATUS_COLOR: Record<AgentStatus, string> = {
working: "#5ec8d8",
blocked: "#e8b465",
done: "#5fd08a",
idle: "#8a8a92",
unknown: "#6a6a72",
};
export function HerdrSessions() {
const [nodes, setNodes] = useState<FleetNode[]>([]);
const [snapshots, setSnapshots] = useState<Record<string, Snapshot | "loading" | "error">>({});
const [openNode, setOpenNode] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
let alive = true;
(async () => {
try {
const r = await fetch("/api/nodes");
if (!r.ok) return;
const data = (await r.json()) as { nodes?: FleetNode[] };
if (alive) setNodes((data.nodes ?? []).filter((n) => n.status === "online"));
} catch {
/* non-fatal */
}
})();
return () => {
alive = false;
};
}, [tick]);
useEffect(() => {
let alive = true;
(async () => {
const results = await Promise.all(
nodes.map(async (n) => {
try {
const r = await fetch(`/api/nodes/${n.id}/herdr/session`);
if (!r.ok) return [n.id, "error" as const] as const;
const raw = (await r.json()) as {
error?: string;
result?: { workspaces?: Workspace[] };
workspaces?: Workspace[];
};
if (raw.error) return [n.id, { error: raw.error }] as const;
const workspaces = raw.result?.workspaces ?? raw.workspaces ?? [];
return [n.id, { workspaces }] as const;
} catch {
return [n.id, "error" as const] as const;
}
}),
);
if (!alive) return;
const next: Record<string, Snapshot | "loading" | "error"> = {};
for (const [id, s] of results) next[id] = s;
setSnapshots(next);
})();
return () => {
alive = false;
};
}, [nodes, tick]);
const refresh = useCallback(() => setTick((n) => n + 1), []);
if (nodes.length === 0) {
return (
<div style={{ padding: 32, color: "#8a8a92" }}>
No online nodes. Connect a host from the Local hardware panel first.
</div>
);
}
return (
<div style={{ height: "100%", overflow: "auto", padding: "24px 28px" }}>
<div style={{ maxWidth: 1000, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
<Sparkles size={16} style={{ color: "#c9a0ff" }} />
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#c9a0ff",
textTransform: "uppercase",
}}
>
Herdr sessions · {nodes.length} online node{nodes.length === 1 ? "" : "s"}
</span>
<button
type="button"
onClick={refresh}
title="Refresh"
aria-label="Refresh"
style={{
marginLeft: "auto",
width: 30,
height: 30,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<RefreshCw size={14} />
</button>
</div>
<p style={{ fontSize: 13, color: "#8a8a92", margin: "6px 0 22px", lineHeight: 1.5 }}>
Every online fleet node is running a persistent Herdr daemon. This view
shows their live workspaces + agent states. Click Open to render the
node&apos;s Herdr TUI in your browser (WebRTC direct where possible).
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 14 }}>
{nodes.map((n) => {
const snap = snapshots[n.id];
return (
<div
key={n.id}
style={{
borderRadius: 12,
background: "#0f0f13",
border: "1px solid rgba(255,255,255,.08)",
padding: 14,
display: "flex",
flexDirection: "column",
gap: 8,
minHeight: 160,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontSize: 14,
fontWeight: 700,
color: "#f3f3f5",
flex: 1,
}}
>
{n.hostname ?? n.name}
</span>
<button
type="button"
onClick={() => setOpenNode(openNode === n.id ? null : n.id)}
title="Open Herdr TUI in browser"
style={{
padding: "4px 10px",
borderRadius: 6,
border: "1px solid rgba(201,160,255,.4)",
background: openNode === n.id ? "rgba(201,160,255,.15)" : "transparent",
color: "#c9a0ff",
fontSize: 11,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<TerminalSquare size={11} />
{openNode === n.id ? "Close" : "Open"}
</button>
</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72" }}>
{n.localIp ?? n.tailscaleIp ?? ""}
</div>
<SessionSummary snap={snap} />
</div>
);
})}
</div>
{openNode && (
<div style={{ marginTop: 22 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#c9a0ff",
textTransform: "uppercase",
marginBottom: 8,
}}
>
{nodes.find((n) => n.id === openNode)?.hostname ??
nodes.find((n) => n.id === openNode)?.name ??
openNode}{" "}
· Herdr TUI
</div>
<HerdrTerminal nodeId={openNode} />
</div>
)}
</div>
</div>
);
}
function SessionSummary({ snap }: { snap: Snapshot | "loading" | "error" | undefined }) {
if (snap === undefined) return <div style={hint}>Loading…</div>;
if (snap === "loading") return <div style={hint}>Loading…</div>;
if (snap === "error") return <div style={{ ...hint, color: "#ff8a7a" }}>Snapshot failed</div>;
if (typeof snap === "object" && snap.error)
return <div style={{ ...hint, color: "#ff8a7a" }}>{snap.error}</div>;
const workspaces = (snap as Snapshot).workspaces ?? [];
if (workspaces.length === 0)
return <div style={hint}>No active workspaces on this node.</div>;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5, marginTop: 4 }}>
{workspaces.slice(0, 8).map((w) => {
const status = (w.agent_status ?? "unknown") as AgentStatus;
return (
<div
key={w.workspace_id}
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: STATUS_COLOR[status] ?? "#6a6a72",
flex: "none",
}}
/>
<span style={{ color: "#cfcfd5", flex: 1, minWidth: 0 }}>
{w.label ?? `w${w.number}`}
</span>
<span
style={{
fontFamily: mono,
fontSize: 10,
color: STATUS_COLOR[status] ?? "#6a6a72",
letterSpacing: ".08em",
textTransform: "uppercase",
}}
>
{status}
</span>
<span style={{ color: "#6a6a72", fontFamily: mono, fontSize: 10 }}>
{w.pane_count ?? 0}p
</span>
</div>
);
})}
{workspaces.length > 8 && (
<div style={{ ...hint, marginTop: 2 }}>
+{workspaces.length - 8} more workspaces
</div>
)}
</div>
);
}
function HerdrTerminal({ nodeId }: { nodeId: string }) {
const [mode, setMode] = useState<TermMode>("connecting");
const connector = useMemo(() => nodeHerdrConnector(nodeId, setMode), [nodeId]);
const { hostRef } = useResilientTerminal({ connect: connector, autoFocus: true }, [nodeId]);
return (
<div
style={{
position: "relative",
height: "65vh",
minHeight: 460,
background: "#0a0a0d",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.06)",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 5,
padding: "2px 8px",
borderRadius: 6,
fontFamily: mono,
fontSize: 10,
letterSpacing: ".1em",
textTransform: "uppercase",
color:
mode === "direct" ? "#5fd08a" : mode === "relayed" ? "#8a8a92" : "#e8b465",
background:
mode === "direct" ? "rgba(95,208,138,.12)" : "rgba(255,255,255,.05)",
border: `1px solid ${
mode === "direct" ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.1)"
}`,
}}
>
{mode === "direct" ? "direct" : mode === "relayed" ? "relayed" : "connecting…"}
</div>
<div ref={hostRef} style={{ position: "absolute", inset: 0, padding: 8 }} />
</div>
);
}
const hint: React.CSSProperties = {
fontSize: 12,
color: "#6a6a72",
marginTop: 4,
};