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 */}
Her हेर · 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 */}
setQ(e.target.value)} placeholder="filter projects…" style={{ flex: 1, background: "transparent", border: "none", outline: "none", color: C.text, fontFamily: FM, fontSize: 12.5 }} />
!uploading && fileRef.current && fileRef.current.click()} style={{ ...btn(uploading), fontSize: 12.5, padding: "9px 15px" }} title="Upload Claude Code session export(s) (.jsonl)"> {uploading ? (progress ? `Uploading ${progress.done}/${progress.total}…` : "Uploading…") : "Upload .jsonl"}
{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.name}
{p.cwd}
{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
Her
हेर — 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) => (
{f.t}
{f.d}
))}
{/* 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)" }}>
Her — demo walkthrough
{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).
); }