import React, { useEffect, useState } from "react";
import { ClipboardList, Lightbulb, Map, FolderOpen, Sparkles, Bot, Boxes, Plug, Terminal } from "lucide-react";
import { C, FD, FB, FM, fmt, fmtWhen, intentOf, intentIcon, turnSeverity, shortPrompt } from "./theme.js";
import { useAnalysis, useApi, fetchAdvice, fetchOverview } from "./useAnalysis.js";
import { Chip, Stat, BinaryRow } from "./components/Primitives.jsx";
import Legend, { defaultVis } from "./components/Legend.jsx";
import SessionGraph from "./components/SessionGraph.jsx";
import TurnGraph from "./components/TurnGraph.jsx";
import { SessionDetail, TurnDetail, ToolDetail } from "./components/DetailPanel.jsx";
import SessionBrowser from "./components/SessionBrowser.jsx";
import ChatPanel from "./components/ChatPanel.jsx";
import ProjectView from "./components/ProjectView.jsx";
import ProjectsHome from "./components/ProjectsHome.jsx";
import SessionReport from "./components/SessionReport.jsx";
import DisclaimerModal, { needsDisclaimer } from "./components/DisclaimerModal.jsx";
// Session-level entity inventory (Mode A left rail): the skills, sub-agents, and
// MCP servers this ONE session used, each traceable to the turn that launched it —
// the same inventory the project view shows, scoped to the session. Renders nothing
// when the session used none (honest silence). Sub-agents run in their OWN
// transcripts, so this never changes the tool/causality counts in the Legend above.
function SessionEntities({ entities, binaries, onOpen }) {
const bins = binaries || [];
const groups = [
["subAgents", "Sub-agents", Bot, C.blue],
["skills", "Skills", Boxes, C.amber],
["mcpServers", "MCP servers", Plug, C.cyan],
];
const hasEntities = !!entities && groups.some(([k]) => (entities[k] || []).length);
if (!hasEntities && !bins.length) return null;
return (
<>
{/* BINARIES — real tools run via Bash (npx remotion -> remotion, railway, …),
a separate dimension from tool calls. Each row traces to its turn. */}
{bins.length > 0 && (
<>
BINARIES RUN
via Bash{bins.length}
{bins.map((b) => )}
extracted from the command (the real binary behind npx/bash) · click to jump to the turn it fired
launched from the cited turn · each runs in its own transcript, so it doesn't change the tool or causality counts above
>
)}
>
);
}
export default function App() {
// sourcePath: null = no session loaded (welcome) · "__demo__" = the bundled demo
// session (click-to-load, never a default) · else a real ~/.claude session, all
// analyzed live by the API server.
const [sourcePath, setSourcePath] = useState(null);
const { status, data, narrated, error } = useAnalysis(sourcePath);
const api = useApi();
// view: "report" = executive summary (DEFAULT) · "session" = Mode A journey graph
// · "turn" = Mode B drill-in
const [view, setView] = useState("report");
const [ti, setTi] = useState(0); // selected turn index
const [tool, setTool] = useState(null); // selected tool index in Mode B
const [vis, setVis] = useState(defaultVis);
const [atHome, setAtHome] = useState(true); // top-level Projects landing is the default
const [browserOpen, setBrowserOpen] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const [overview, setOverview] = useState(null); // plain-English "what happened"
const [advice, setAdvice] = useState(null); // LLM "what could have been better", session-scoped
const [projectCwd, setProjectCwd] = useState(null); // project scope (many sessions)
const [showDisclaimer, setShowDisclaimer] = useState(needsDisclaimer()); // first-run opt-in
const consentModal = showDisclaimer ? setShowDisclaimer(false)} /> : null;
const hasApiEarly = api?.hasApi;
// The human "what happened" overview shown at the top of Mode A. For the bundled
// fixture we reuse the curated narrator outcome; for any real session we ask the
// local model to write one (grounded in the turns). Narrator prose only — the
// numbers stay deterministic.
useEffect(() => {
setOverview(null);
if (atHome || status !== "ready") return;
let alive = true;
const curated = narrated && (narrated.session_outcome || narrated.summary || narrated.outcome);
const fromCurated = () =>
curated && setOverview({ text: String(curated).replace(/^Generated summary[^\n]*\n+/i, "").trim(), label: "narrator" });
// Prefer the freshly-generated "what happened" (plain English, doesn't dwell on
// tokens) for ANY session; fall back to a curated narrated outcome if the API
// or model is down.
if (hasApiEarly) {
(async () => {
try {
const j = await fetchOverview(sourcePath);
if (!alive) return;
if (j && j.overview) setOverview({ text: j.overview, label: j.model ? j.model.split("/").pop() : "narrator" });
else fromCurated();
} catch {
if (alive) fromCurated();
}
})();
} else {
fromCurated();
}
return () => { alive = false; };
}, [status, sourcePath, narrated, hasApiEarly, atHome]);
// Session-scoped advice ("what could have been better") — the local model writes
// it from the engine's fired signals + this session's objective + the Anthropic
// codebook. Best-effort: the deterministic recommendations render instantly and
// this upgrades them to scoped prose when the model answers.
useEffect(() => {
setAdvice(null);
if (atHome || status !== "ready" || !hasApiEarly) return;
let alive = true;
(async () => {
try {
const j = await fetchAdvice(sourcePath);
if (alive && j && Array.isArray(j.recommendations)) setAdvice(j);
} catch { /* fall back to the deterministic recommendations */ }
})();
return () => { alive = false; };
}, [status, sourcePath, hasApiEarly, atHome]);
const openTurn = (i, toolIdx = null) => { setView("turn"); setTi(i); setTool(toolIdx); };
const pickSession = (path) => {
setAtHome(false);
setSourcePath(path);
setView("report"); setTi(0); setTool(null);
setBrowserOpen(false); setProjectCwd(null);
};
const openProject = (cwd) => { setAtHome(false); setProjectCwd(cwd); setBrowserOpen(false); setChatOpen(false); };
const goHome = () => { setAtHome(true); setProjectCwd(null); setChatOpen(false); setBrowserOpen(false); };
// The bundled demo session — loads ONLY on this explicit click (the "__demo__"
// sentinel the server resolves to fixtures/demo-session.jsonl). Never a default.
const openDemo = () => pickSession("__demo__");
const hasApi = api?.hasApi;
// DEFAULT LANDING — the top-level Projects list. From here: project -> sessions
// (ProjectView) -> a session (the graph). ProjectsHome handles its own
// loading/no-engine states, so we only wait while the API probe is in flight.
if (atHome) {
if (api == null) return ;
return (
<>
{consentModal}
>
);
}
// Project scope is session-independent — ProjectView loads its own data from `cwd`.
// The welcome / loading / error screens below key off the loaded SESSION's status, so
// scope them to session view only. Otherwise opening a project before any session is
// analyzed (sourcePath still null → status "welcome") falls through to the welcome
// screen, and only "works" once some session has been loaded. A project click must
// never require a loaded session.
const inProject = !!projectCwd;
if (!inProject && status === "welcome")
return (
हेर — a detective for your coding-agent sessions · 100% local
No session loaded (traces are never bundled). Pick one of your ~/.claude sessions and Her will analyze it live.
);
if (!inProject && status === "loading")
return ;
if (!inProject && status === "error")
return (
{hasApi && }
{sourcePath && }
{browserOpen && setBrowserOpen(false)} />}
);
// Session-scope data — null/empty in project scope (ProjectView fetches its own from
// `cwd`). The JSX that dereferences these only renders when !inProject, so guarding
// here keeps a project click from throwing on the still-null session `data`.
const S = inProject ? null : data.session;
const turns = inProject ? [] : data.turns;
const t = turns[Math.min(ti, turns.length - 1)];
// Sub-turns are SIZED BY COST (Anthropic token consumption) — what you pay for —
// not by raw cacheRead. cacheRead stays as a secondary metric on hover.
const maxCost = Math.max(1, ...turns.map((x) => x.tokens.cost ?? 0));
return (