/** * dashboard-client/src/components/RepoContextStack.tsx — Stacked per-repo context bar (S40). * * Horizontal stacked bar: one colored segment per active repo, aggregating * that repo's active sessions' token counts. Renders a combined total line * and a legend mapping each repo (basename of repoRoot) to its segment color * + token count + % of total. * * Stable per-repo colors: the repoRoot basename is hashed to a deterministic * index into a fixed palette array, so the same repo always gets the same * color across renders/tabs (mirrors SessionsMemoryChart's stable-color * contract from S39). * * PREVENT-PI-004: this is a pure presentational component — no fetch, no * network. Data is passed in by the parent (OverviewTab) via * useApi(fetchSessions) + useSSE() for real-time updates. */ import type React from "react"; import { useRef } from "react"; import type { ActiveSession, SessionsResponse } from "@contracts"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; export interface RepoContextStackProps { /** Active sessions from /api/sessions (may be null while loading). */ sessions: SessionsResponse | null; /** Whether the initial fetch is in flight. */ loading: boolean; /** Fetch error, if any. */ error: Error | null; } // ─── Stable color palette ───────────────────────────────────────────────────── // Fixed set of hex colors used for stable per-repo coloring. The hash function // maps a repoRoot basename to a deterministic palette index so the same repo // always renders the same color. const REPO_PALETTE: ReadonlyArray = [ "#58a6ff", // blue "#3fb950", // green "#d29922", // yellow "#f85149", // red "#bc8cff", // purple "#ff7b72", // salmon "#79c0ff", // light blue "#56d4dd", // cyan "#e3b341", // gold "#a5d6ff", // pale blue "#7ee787", // mint "#ffa657", // orange ]; /** * Simple deterministic string hash (djb2 variant). Returns a non-negative * 32-bit integer. */ function hashString(str: string): number { let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0; } return hash; } /** * Pick a stable color for a repoRoot basename. Falls back to "(unknown)" if * the basename is empty. */ function colorForRepo(basename: string): string { if (!basename) return REPO_PALETTE[0]; return REPO_PALETTE[hashString(basename) % REPO_PALETTE.length]; } /** * Extract the basename from a repoRoot path. Uses `"/"` as the universal * separator on all OSes (repoRoot is an absolute path from the dashboard * server; on Windows pi actually uses forward-slash normalized paths anyway). */ function repoBasename(repoRoot: string | null): string { if (!repoRoot) return "(unknown)"; const trimmed = repoRoot.replace(/[\\/]+$/, ""); if (!trimmed) return "(unknown)"; const parts = trimmed.split(/[\\/]/); const last = parts[parts.length - 1]; return last || trimmed; } // ─── Aggregation ───────────────────────────────────────────────────────────── interface RepoAggregate { repoRoot: string | null; basename: string; tokens: number; color: string; } function aggregateRepos(sessions: ReadonlyArray): { repos: RepoAggregate[]; totalTokens: number; maxWindow: number; sessionCount: number; } { const map = new Map(); for (const s of sessions) { const key = s.repoRoot ?? "__null__"; const tokens = s.tokens ?? 0; const existing = map.get(key); if (existing) { existing.tokens += tokens; } else { const basename = repoBasename(s.repoRoot); map.set(key, { repoRoot: s.repoRoot, basename, tokens, color: colorForRepo(basename), }); } } const repos = [...map.values()]; const totalTokens = repos.reduce((sum, r) => sum + r.tokens, 0); // Combined max window: the largest ctxWindow among the active sessions. // This mirrors SessionsTab's SummaryTiles combined-% logic. const maxWindow = sessions.reduce( (max, s) => Math.max(max, s.ctxWindow), 0, ); return { repos, totalTokens, maxWindow, sessionCount: sessions.length, }; } function fmtTokens(n: number): string { if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`; return n.toLocaleString(); } function StackCard({ children }: { children: React.ReactNode }): React.ReactElement { return ( Context per Repo (live) {children} ); } export function RepoContextStack({ sessions, loading, error, }: RepoContextStackProps): React.ReactElement | null { // Hysteresis: retain the last non-empty aggregates so a transiently-empty // /api/sessions poll (prune-vs-heartbeat race) doesn't unmount/remount the // chart (the visible "blinking"). We only fall back to "No active sessions." // when there has NEVER been data (initial mount) or after a sustained empty. const lastNonEmpty = useRef<{ repos: RepoAggregate[]; totalTokens: number; maxWindow: number; sessionCount: number; } | null>(null); if (error) { return ( Error loading sessions: {error.message} ); } if (loading && !sessions) { return ( Loading sessions… ); } const activeSessions = sessions?.sessions ?? []; const { repos, totalTokens, maxWindow, sessionCount } = aggregateRepos(activeSessions); if (repos.length > 0) { lastNonEmpty.current = { repos, totalTokens, maxWindow, sessionCount }; } // Use last-known-good data when the current poll is transiently empty. const displayed = repos.length > 0 ? { repos, totalTokens, maxWindow, sessionCount } : lastNonEmpty.current; if (!displayed) { return ( No active sessions. ); } const combinedPct = displayed.maxWindow > 0 ? Math.round((displayed.totalTokens / displayed.maxWindow) * 100) : 0; return (
{displayed.repos.map((r) => { const pctOfTotal = displayed.totalTokens > 0 ? (r.tokens / displayed.totalTokens) * 100 : 0; const widthPct = displayed.totalTokens > 0 ? pctOfTotal : 0; const tooltip = `${r.basename}: ${fmtTokens(r.tokens)} tokens ` + `(${pctOfTotal.toFixed(1)}% of total)`; return (
); })}

{fmtTokens(displayed.totalTokens)} tokens across {displayed.repos.length}{" "} {displayed.repos.length === 1 ? "repo" : "repos"} /{" "} {displayed.sessionCount}{" "} {displayed.sessionCount === 1 ? "session" : "sessions"}{" "} — {combinedPct}% of combined max window

{displayed.repos.map((r) => { const pctOfTotal = displayed.totalTokens > 0 ? (r.tokens / displayed.totalTokens) * 100 : 0; return (
{r.basename} {fmtTokens(r.tokens)} ({pctOfTotal.toFixed(0)}%)
); })}
); }