import { Fragment, lazy, Suspense, useCallback, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useHive } from "../store"; import { deleteSession, selectSessionScope, setActiveTab, confirmAction } from "../store/raw"; import { ensureTopologyDetail } from "../store/wiring"; import { useFrozenOrder } from "../hooks/useFrozenOrder"; import RelTime from "../hooks/RelTime"; import { absTime, fmtCost, fmtNum, sessionSlug } from "../lib/format"; import type { SessionView } from "../types"; // Lazy like Overview does, so the d3-hierarchy chunk only loads when the // topology-version modal is actually opened (keeps the code-split intact). const TopologyGraph = lazy(() => import("../components/TopologyGraph")); type Key = "project" | "first_ts" | "last_ts" | "running" | "event_count" | "tokens" | "cost"; export default function Sessions(props: { search: string }) { const sessions = useHive((s) => s.sessions); const scope = useHive((s) => s.scope); const liveSet = useHive((s) => s.liveSet); const projectGroups = useHive((s) => s.projectGroups); const sessionSummaries = useHive((s) => s.sessionSummaries); const topologiesByCwd = useHive((s) => s.topologiesByCwd); // The topology whose versioned tree is open in the modal (K2), or null. const [openTopoHash, setOpenTopoHash] = useState(null); // Server-authoritative topology hash for a session (K2). const hashOf = useCallback((s: SessionView): string | undefined => sessionSummaries.get(s.session_id)?.topologyHash, [sessionSummaries]); // The DB's true "events ever ingested" count. `s.event_count` only counts the // events currently loaded in the ~1000-row window (derive.ts), so both the // Events column and the destructive delete-confirmation would understate the // real row count. Prefer the server summary; fall back to the window count // only until the summary arrives. const countOf = useCallback((s: SessionView): number => sessionSummaries.get(s.session_id)?.event_count ?? s.event_count, [sessionSummaries]); // "vN" rank of a hash within its cwd — the 1-based index of the version by // first_seen_at (topologiesByCwd is already ordered that way). undefined if the // versions for that cwd aren't loaded yet or the hash isn't among them. const versionRankOf = useCallback((s: SessionView): number | undefined => { const hash = hashOf(s); if (!hash || !s.cwd) return undefined; const versions = topologiesByCwd.get(s.cwd); if (!versions) return undefined; const idx = versions.findIndex((v) => v.hash === hash); return idx >= 0 ? idx + 1 : undefined; }, [hashOf, topologiesByCwd]); function openTopology(hash: string) { void ensureTopologyDetail(hash); setOpenTopoHash(hash); } // The Sessions tab lists every session for the CURRENT project (or all // projects at fleet scope). It never collapses to a single row when one // session is drilled into — the active session is highlighted instead, so you // can pick another. Session scope still narrows the OTHER tabs. const listSessions = useMemo(() => { if (scope.level === "fleet") return sessions; return sessions.filter((s) => s.project === scope.project); }, [sessions, scope]); const selectedId = scope.level === "session" ? scope.sessionId : ""; // Derived project name → display label (honors renames from settings). const labelOf = useMemo(() => { const m = new Map(); for (const g of projectGroups) m.set(g.name, g.label); return (project: string) => m.get(project) || project; }, [projectGroups]); const [sortKey, setSortKey] = useState("last_ts"); const [dir, setDir] = useState<1 | -1>(-1); function clickSort(k: Key) { if (sortKey === k) setDir((d) => (d === 1 ? -1 : 1)); else { setSortKey(k); setDir(k === "project" ? 1 : -1); } } function askDelete(s: SessionView, e: React.MouseEvent) { e.stopPropagation(); confirmAction({ title: "Delete session telemetry?", danger: true, confirmLabel: "Delete session", message: <>This permanently removes telemetry for {s.project} · {sessionSlug(s.session_id)} ({countOf(s)} events). The project's own logs on disk are not touched., onConfirm: () => deleteSession(s.session_id), }); } const filtered = useMemo(() => { const q = props.search.toLowerCase(); return listSessions.filter((s) => !q || s.project.toLowerCase().includes(q) || s.session_id.toLowerCase().includes(q) || (s.cwd || "").toLowerCase().includes(q)); }, [listSessions, props.search]); // Click a session → scope to it AND jump to its Overview. function openSession(id: string) { selectSessionScope(id); setActiveTab("overview"); } const resortKey = sortKey + ":" + dir + "|" + filtered.map((s) => s.session_id).slice().sort().join(","); const idOf = useCallback((s: SessionView) => s.session_id, []); const sorter = useCallback((a: SessionView, b: SessionView) => { // Sort the Events column by the same authoritative count the cell renders, // not the window-local count on the row object. const va = sortKey === "event_count" ? countOf(a) : (a[sortKey] as any); const vb = sortKey === "event_count" ? countOf(b) : (b[sortKey] as any); const cmp = typeof va === "string" ? String(va).localeCompare(String(vb)) : (va - vb); return cmp * dir; }, [sortKey, dir, countOf]); const rows = useFrozenOrder(filtered, idOf, resortKey, sorter); const arrow = (k: Key) => (sortKey === k ? {dir === 1 ? "↑" : "↓"} : null); return (
{rows.map((s, i) => { const rank = versionRankOf(s); const hash = hashOf(s); // "topology changed" divider between adjacent rows in the SAME cwd // whose topology hash differs (K2). Only meaningful when both hashes // are known and the rows belong to the same project working dir. const prev = rows[i - 1]; const prevHash = prev ? hashOf(prev) : undefined; const showDivider = !!prev && !!hash && !!prevHash && hash !== prevHash && prev.cwd === s.cwd && !!s.cwd; return ( {showDivider && ( )} openSession(s.session_id)}> ); })}
clickSort("project")}>Project {arrow("project")} Session clickSort("first_ts")}>Started {arrow("first_ts")} clickSort("running")} className="num">Running {arrow("running")} clickSort("event_count")} className="num">Events {arrow("event_count")} clickSort("tokens")} className="num">Tokens {arrow("tokens")} clickSort("cost")} className="num">Cost {arrow("cost")} clickSort("last_ts")} className="num">Updated {arrow("last_ts")}
{labelOf(s.project)} {sessionSlug(s.session_id)} {rank != null && hash && ( )} {absTime(s.first_ts)} {s.running} {countOf(s)} {fmtNum(s.tokens)} {fmtCost(s.cost)} e.stopPropagation()}>
{!rows.length &&
No sessions match.
} {openTopoHash && setOpenTopoHash(null)} />}
); } // K2: modal showing one session's versioned topology tree, reusing TopologyGraph // with live statuses off (this is a historical version, not a running session). function TopologyVersionModal({ hash, onClose }: { hash: string; onClose: () => void }) { const detail = useHive((s) => s.topologyByHash.get(hash)); // Reassemble the reassembled tree into the TopoSource TopologyGraph consumes. const source = useMemo(() => { if (!detail) return undefined; // The version modal renders a topology HASH, not a session — a hash is shared // across sessions and both modes, so there is no single authoritative active // team here (unlike the live/replay paths, which read the persisted flag in // Phase 2.4). The structural guess just picks which team to foreground; both // trees are available in the graph regardless. const active: "hive" | "planning" = detail.hive?.orchestrator || detail.hive?.agents?.length ? "hive" : "planning"; return { session_id: "", topologies: { active, hive: detail.hive, planning: detail.planning } as any, agents: new Map() }; }, [detail]); return createPortal(
e.stopPropagation()}>
Topology version {hash.slice(0, 10)}
{!detail || !source ?
Loading topology…
: Loading topology…
}>}
, document.body, ); }