import React, { useEffect, useMemo, useRef, useState } from "react";
import { FolderOpen, Search, ArrowRight, FileClock, FlaskConical, Upload, UploadCloud, Trash2, HelpCircle, X, Lock, Fingerprint, ShieldAlert, Lightbulb, MessageSquare, Cpu, Database, Server, Eraser, ShieldCheck, PlayCircle } from "lucide-react";
import { C, FD, FM, FB } from "../theme.js";
import { fetchSessions, useApi } from "../useAnalysis.js";
import { withClient, clearMyData } from "../client.js";
import SessionBrowser from "./SessionBrowser.jsx";
import DisclaimerModal from "./DisclaimerModal.jsx";
// TOP-LEVEL LANDING — the projects in ~/.claude (or, on the hosted Space, the sessions
// you've uploaded), grouped by the TRUE cwd the engine reads from inside each file
// (never the lossy encoded folder name, NN#5). Pick a project -> its sessions
// (ProjectView) -> a session (the graph). Nothing is analyzed here; pure navigation.
function fmtAge(sec) {
if (!sec) return "";
const d = Date.now() / 1000 - sec;
if (d < 3600) return Math.max(1, Math.round(d / 60)) + "m ago";
if (d < 86400) return Math.round(d / 3600) + "h ago";
return Math.round(d / 86400) + "d ago";
}
const _MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function fmtDateTime(iso_or_ms) {
if (!iso_or_ms) return "";
const dt = new Date(iso_or_ms);
if (isNaN(dt.getTime())) return "";
const mon = _MONTHS[dt.getMonth()];
const day = String(dt.getDate()).padStart(2, "0");
const hh = String(dt.getHours()).padStart(2, "0");
const mm = String(dt.getMinutes()).padStart(2, "0");
return `${mon} ${day}, ${hh}:${mm}`;
}
export default function ProjectsHome({ onOpenProject, onOpenSession, onDemo }) {
const [state, setState] = useState({ status: "loading", data: null, error: null });
const [q, setQ] = useState("");
const [browse, setBrowse] = useState(false);
const fileRef = useRef(null);
const [uploading, setUploading] = useState(false);
const [uploadErr, setUploadErr] = useState(null);
const [progress, setProgress] = useState(null); // {done,total} during a multi-upload
const [dragOver, setDragOver] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const [clearing, setClearing] = useState(false);
async function refresh() {
try {
const data = await fetchSessions();
setState({ status: "ready", data, error: null });
} catch (e) {
setState({ status: "error", data: null, error: String(e) });
}
}
// Upload one or many .jsonl session exports → stored under YOUR namespace on the
// Space → grouped into the Projects view. The deterministic /api/upload route is
// plain REST (no GPU); the X-Her-Client header scopes the upload to this browser.
async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter((f) => f.name.toLowerCase().endsWith(".jsonl"));
if (!files.length) { setUploadErr("Please choose .jsonl session export(s)."); return; }
setUploading(true); setUploadErr(null);
setProgress({ done: 0, total: files.length });
let lastPath = null, ok = 0;
for (const f of files) {
try {
const fd = new FormData();
fd.append("file", f);
fd.append("project", "uploads");
// withClient() sets only X-Her-Client — NOT Content-Type, so the browser keeps
// the multipart boundary it generates for FormData.
const r = await fetch("/api/upload", { method: "POST", body: fd, headers: withClient() });
const j = await r.json();
if (r.ok && j.path) { lastPath = j.path; ok++; }
else if (j && j.error) setUploadErr(j.error);
} catch (err) {
setUploadErr(String(err && err.message ? err.message : err));
}
setProgress((p) => ({ done: (p?.done || 0) + 1, total: files.length }));
}
setUploading(false); setProgress(null);
if (ok === 0) { setUploadErr((e) => e || "Upload failed."); return; }
setUploadErr(null);
if (files.length === 1 && lastPath) onOpenSession(lastPath); // single → open it
else await refresh(); // many → land on the populated Projects view
}
function onInputChange(e) {
// SNAPSHOT first: e.target.files is a LIVE FileList tied to the input. Resetting
// value (to allow re-selecting the same file) empties that same list in place, so
// reading it after the reset yields 0 files — the "Please choose .jsonl…" bug on
// the Upload button (drag-drop was unaffected; it uses a separate FileList).
const fl = Array.from(e.target.files || []);
if (e.target) e.target.value = ""; // allow re-selecting the same file(s)
uploadFiles(fl);
}
async function onClear() {
if (clearing) return;
setClearing(true);
try { await clearMyData(); await refresh(); } finally { setClearing(false); }
}
// drag-and-drop over the whole landing body
function onDragOver(e) { e.preventDefault(); if (!dragOver) setDragOver(true); }
function onDragLeave(e) { e.preventDefault(); if (e.currentTarget === e.target) setDragOver(false); }
function onDrop(e) { e.preventDefault(); setDragOver(false); uploadFiles(e.dataTransfer && e.dataTransfer.files); }
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
const projects = useMemo(() => {
const ps = (state.data?.projects || []).map((p) => ({
...p,
name: p.cwd.split("/").filter(Boolean).pop() || p.cwd,
lastMtime: Math.max(0, ...p.sessions.map((s) => s.mtime || 0)),
}));
ps.sort((a, b) => b.lastMtime - a.lastMtime);
if (!q.trim()) return ps;
const n = q.toLowerCase();
return ps.filter((p) => p.cwd.toLowerCase().includes(n) || p.name.toLowerCase().includes(n));
}, [state.data, q]);
const hasSessions = (state.data?.total || 0) > 0;
const btn = (uploading) => ({ cursor: uploading ? "default" : "pointer", display: "inline-flex", alignItems: "center", gap: 7, background: C.orange, color: "#fff", fontFamily: FD, fontWeight: 600, borderRadius: 9, whiteSpace: "nowrap" });
return (
{/* header */}
हेर
· a detective for your coding-agent sessions
setShowPrivacy(true)} title="How your data is handled (we never store your sessions)"
style={{ cursor: "pointer", display: "flex", alignItems: "center", gap: 6, fontFamily: FM, fontSize: 11, color: C.text2, border: `1px solid ${C.border}`, borderRadius: 7, padding: "5px 10px" }}>
privacy
setShowHelp(true)} style={{ cursor: "pointer", display: "flex", alignItems: "center", gap: 6, fontFamily: FM, fontSize: 11, color: C.text2, border: `1px solid ${C.border}`, borderRadius: 7, padding: "5px 10px" }}>
how to get my sessions
{hasSessions && (
{clearing ? "clearing…" : "clear my data"}
)}
PRIVATE · auto-clears 24h
{/* body */}
Projects
{state.data && (
{state.data.projectCount} projects · {state.data.total} sessions
)}
Pick a project to see its sessions, then open a session to trace it. Grouped by the real working directory read from inside each file.
{/* search + upload */}
{uploadErr &&
{uploadErr}
}
{/* empty inventory → drop zone (left) + "what is this" (fills the blank space on
the right). Both gated to the same empty state, so they vanish the moment a
session/project loads. Wraps to stacked on narrow widths. */}
{state.status === "ready" && (state.data?.projectCount || 0) === 0 && (
!uploading && fileRef.current && fileRef.current.click()}
style={{ cursor: "pointer", background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1.5px dashed ${C.orangeBd}`, borderRadius: 12, padding: "26px 22px", textAlign: "center" }}>
Drag & drop your sessions here
Drop one or many .jsonl exports (or click to choose). Her analyzes them live — the journey,
the dataflow, the cost, what to do next time. Your data is private and auto-clears in 24h.
{uploading ? "Uploading…" : "Choose .jsonl files"}
{ e.stopPropagation(); setShowHelp(true); }}
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, border: `1px solid ${C.border}`, color: C.text2, fontFamily: FM, fontSize: 12.5, borderRadius: 9, padding: "10px 16px" }}>
Where are my sessions?
{onDemo && (
no session handy? explore a real demo session →
)}
setShowVideo(true)} title="A short walkthrough of what Her does"
style={{ cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, background: C.card, border: `1px dashed ${C.cyan}66`, borderRadius: 10, padding: "11px 14px", fontFamily: FM, fontSize: 12, color: C.text2 }}>
prefer to watch?
see the demo video →
)}
{state.status === "loading" && (
loading your sessions…
)}
{state.status === "error" && (
Couldn't load sessions. {state.error}
)}
{/* project grid */}
{state.status === "ready" && (
{projects.map((p) => {
const latest = p.sessions[0];
return (
onOpenProject(p.cwd)}
style={{ cursor: "pointer", background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: 11, padding: "14px 15px", display: "flex", flexDirection: "column", gap: 9 }}>
{p.count} session{p.count === 1 ? "" : "s"}
{latest && fmtDateTime(latest.startedAt || (latest.mtime ? latest.mtime * 1000 : 0)) && (
{fmtDateTime(latest.startedAt || (latest.mtime ? latest.mtime * 1000 : 0))}
)}
{p.lastMtime > 0 && active {fmtAge(p.lastMtime)}}
{latest && (
{ e.stopPropagation(); onOpenSession(latest.path); }} title="open the most recent session directly"
style={{ marginLeft: "auto", fontFamily: FM, fontSize: 9.5, color: C.orange, border: `1px solid ${C.orangeBd}`, background: C.orangeMut, borderRadius: 5, padding: "2px 8px" }}>
latest →
)}
);
})}
{projects.length === 0 && hasSessions && (
no projects match “{q}”.
)}
)}
{/* When projects exist there's no drop zone, so keep a demo affordance at the
foot of the list. In the empty state the prominent one lives under the drop
zone instead (above), so this is gated to the populated state. */}
{(state.data?.projectCount || 0) > 0 && (
{onDemo && (
no session handy? explore a real demo session →
)}
setShowVideo(true)} title="A short walkthrough of what Her does" style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8, fontFamily: FM, fontSize: 11.5, color: C.text2, border: `1px dashed ${C.border}`, borderRadius: 8, padding: "8px 13px" }}>
watch the demo video →
)}
{/* drag overlay */}
{dragOver && (
Drop .jsonl sessions to analyze
one or many — they’ll group into your Projects view
)}
{showHelp &&
setShowHelp(false)} />}
{showPrivacy && setShowPrivacy(false)} />}
{showVideo && setShowVideo(false)} />}
{browse && setBrowse(false)} />}
);
}
// The empty-landing "what is this" — only renders before anything is uploaded. Mostly
// scannable labels (one short paragraph), themed to Tactical Grey: orange stays brand-
// only (the हेर wordmark), cyan/grey carry the rest. Don't oversell.
function LandingExplainer() {
const Eyebrow = ({ children }) => (
{children}
);
const features = [
{ icon: Fingerprint, t: "Trace any action", d: "why it ran · which tools fired · what it touched" },
{ icon: ShieldAlert, t: "Risky moves, surfaced", d: "deploys, prod & config changes, secrets" },
{ icon: Lightbulb, t: "Do it better", d: "tips grounded in Anthropic & community practice" },
{ icon: MessageSquare, t: "Ask Her", d: "chat about one session — or a whole project" },
];
const build = [
{ icon: Cpu, t: "Local Nemotron", d: "writes summaries & enrichment" },
{ icon: Database, t: "Local identifier DB", d: "names your tools & binaries" },
{ icon: Server, t: "Temporary bucket", d: "holds your data only while analyzing" },
{ icon: Eraser, t: "Best-effort scrubbers", d: "redact likely secrets on the way in" },
{ icon: ShieldCheck, t: "Yours alone", d: "only you can read it · cleared on demand or after 24h" },
];
return (
{/* what is Her */}
WHAT IS THIS
हेर — a detective for your coding-agent sessions.
her (हेर) is Marathi for detective. Drop a Claude Code session export
(.jsonl) and Her reads the whole trace — so you can see
what actually happened, and what to do better next time.
{/* features */}
{features.map((f) => (
))}
{/* two ways in */}
One file opens a session view; the uploader script builds a{" "}
project view across many — both with summaries and deep dives.
{/* under the hood + trust */}
UNDER THE HOOD
{build.map((b) => (
{b.t}
· {b.d}
))}
{/* legend */}
Built for a couple of wizards who couldn’t quite harness their magic staff. 🪄
);
}
// One grayscale brand mark. The source SVGs carry their own brand colours, so we render
// them as a single-tint silhouette via a CSS mask (alpha → fill) — uniform on the dark
// skin regardless of each logo's palette, and genuinely "grayscale". Sized by height,
// width follows the artwork's aspect ratio. Decorative (a credit), so not a link.
function Logo({ src, h, ar, title }) {
return (
);
}
// "built on" credit — the stack Her runs on, in grayscale. Nemotron is NVIDIA's model,
// so its mark is the NVIDIA wordmark. Heights are tuned per-logo for optical balance
// (a square icon next to two wordmarks of different aspect ratios).
function BuiltOn() {
return (
BUILT ON
);
}
// The recorded product demo — served by GET /api/demo-video (the bucket copy on the
// Space, the repo's demo/ copy locally). FileResponse honours Range, so the player can
// seek. Click-outside / X to close, matching the other modals (no client-side router).
function VideoModal({ onClose }) {
const [err, setErr] = useState(false);
return (
e.stopPropagation()} style={{ width: 980, maxWidth: "100%", background: C.panel, border: `1px solid ${C.border}`, borderRadius: 14, overflow: "hidden", boxShadow: "0 20px 64px rgba(0,0,0,0.5)" }}>
{err ? (
The demo video isn’t available right now.
) : (
);
}
// "How do I get my sessions?" — where Claude Code stores them, how to drop one, and the
// one-shot uploader script that brings in ALL your projects (scan → scrub → upload).
function HelpModal({ onClose }) {
// Point the download at THIS Space (server-reported SPACE_ID), so a visitor pulls
// the copy whose DEFAULT_SPACE already self-references — never the author's Space.
const api = useApi();
const space = api?.space || "/";
const dl = `hf download ${space} scripts/her_upload.py --repo-type space --local-dir .`;
const run = "python scripts/her_upload.py";
const Code = ({ children }) => (
{children}
);
return (
e.stopPropagation()} style={{ width: 640, maxWidth: "100%", maxHeight: "86vh", overflowY: "auto", background: C.panel, border: `1px solid ${C.border}`, borderRadius: 14, padding: "22px 24px" }}>
Get your Claude Code sessions in
Where they live
Claude Code writes one .jsonl per session under
~/.claude/projects/<encoded-folder>/<session-id>.jsonl
Quickest: drag one in
Open that folder, grab any .jsonl, and drag it onto this page (or use “Upload .jsonl”). Drop several at once to build a Projects view.
All your projects at once (recommended)
Run our uploader — it copies the sessions you pick into a staging folder, scrubs secrets,
and uploads them — each step waits for your approval. Then it prints a link that opens your Projects view here.
{dl}{"\n"}{run}
(You can also grab scripts/her_upload.py from this Space’s Files tab.)
Your uploads are private to your browser and auto-deleted after 24 hours (or instantly with “clear my data”, or when you close the tab).
);
}