Spaces:
Running on Zero
Running on Zero
| 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 ( | |
| <div onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} | |
| style={{ fontFamily: FB, color: C.text, height: "100vh", display: "flex", flexDirection: "column", background: "radial-gradient(1200px 520px at 25% -8%, #3b3633 0%, #2b2927 55%)", position: "relative" }}> | |
| {/* header */} | |
| <div style={{ height: 58, background: C.header, borderBottom: `1px solid ${C.borderSoft}`, boxShadow: `0 1px 0 0 ${C.orange}22`, display: "flex", alignItems: "center", padding: "0 18px", gap: 12, flexShrink: 0 }}> | |
| <img src="/her-logo-light.png" alt="Her" style={{ height: 28, display: "block" }} /> | |
| <span style={{ fontFamily: FD, fontWeight: 600, fontSize: 13, color: C.muted, letterSpacing: 1 }}>हेर</span> | |
| <span style={{ fontFamily: FM, fontSize: 11, color: C.muted }}>· a detective for your coding-agent sessions</span> | |
| <div style={{ flex: 1 }} /> | |
| <div className="row lift" onClick={() => 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" }}> | |
| <Lock size={13} color={C.cyan} /> privacy | |
| </div> | |
| <div className="row lift" onClick={() => 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" }}> | |
| <HelpCircle size={13} color={C.cyan} /> how to get my sessions | |
| </div> | |
| {hasSessions && ( | |
| <div className="row lift" onClick={onClear} title="Delete the sessions you've uploaded (also auto-cleared after 24h)" | |
| style={{ cursor: clearing ? "default" : "pointer", display: "flex", alignItems: "center", gap: 6, fontFamily: FM, fontSize: 11, color: C.amber, border: `1px solid ${C.amber}55`, borderRadius: 7, padding: "5px 10px" }}> | |
| <Trash2 size={13} /> {clearing ? "clearing…" : "clear my data"} | |
| </div> | |
| )} | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: FM, fontSize: 11, color: C.cyan, border: `1px solid ${C.cyan}33`, borderRadius: 20, padding: "3px 10px" }}> | |
| <span style={{ width: 7, height: 7, borderRadius: "50%", background: C.cyan }} /> PRIVATE · auto-clears 24h | |
| </div> | |
| </div> | |
| {/* body */} | |
| <div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "26px 30px 40px" }}> | |
| <div style={{ display: "flex", alignItems: "baseline", gap: 12 }}> | |
| <span style={{ fontFamily: FD, fontWeight: 700, fontSize: 22, letterSpacing: 0.3 }}>Projects</span> | |
| {state.data && ( | |
| <span style={{ fontFamily: FM, fontSize: 12, color: C.muted }}> | |
| {state.data.projectCount} projects · {state.data.total} sessions | |
| </span> | |
| )} | |
| </div> | |
| <div style={{ fontFamily: FM, fontSize: 11.5, color: C.muted, marginTop: 4 }}> | |
| 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. | |
| </div> | |
| {/* search + upload */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 16, flexWrap: "wrap" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "8px 12px", flex: 1, minWidth: 240, maxWidth: 460 }}> | |
| <Search size={14} color={C.muted} /> | |
| <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter projects…" style={{ flex: 1, background: "transparent", border: "none", outline: "none", color: C.text, fontFamily: FM, fontSize: 12.5 }} /> | |
| </div> | |
| <input ref={fileRef} type="file" multiple accept=".jsonl,application/x-ndjson,application/jsonl" onChange={onInputChange} style={{ display: "none" }} /> | |
| <div className="row lift" onClick={() => !uploading && fileRef.current && fileRef.current.click()} | |
| style={{ ...btn(uploading), fontSize: 12.5, padding: "9px 15px" }} title="Upload Claude Code session export(s) (.jsonl)"> | |
| <Upload size={14} /> {uploading ? (progress ? `Uploading ${progress.done}/${progress.total}…` : "Uploading…") : "Upload .jsonl"} | |
| </div> | |
| </div> | |
| {uploadErr && <div style={{ marginTop: 8, fontFamily: FM, fontSize: 11, color: C.amber }}>{uploadErr}</div>} | |
| {/* 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 && ( | |
| <div style={{ display: "flex", gap: 24, marginTop: 22, alignItems: "flex-start", flexWrap: "wrap" }}> | |
| <div style={{ flex: "1 1 340px", minWidth: 300, maxWidth: 520, display: "flex", flexDirection: "column", gap: 12 }}> | |
| <div className="pop" onClick={() => !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" }}> | |
| <UploadCloud size={30} color={C.orange} /> | |
| <div style={{ fontFamily: FD, fontWeight: 700, fontSize: 16, color: C.text, marginTop: 8 }}>Drag & drop your sessions here</div> | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.muted, marginTop: 7, lineHeight: 1.6 }}> | |
| Drop one or many <code style={{ color: C.text2 }}>.jsonl</code> 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. | |
| </div> | |
| <div style={{ display: "flex", gap: 10, justifyContent: "center", marginTop: 14, flexWrap: "wrap" }}> | |
| <span style={{ ...btn(uploading), fontSize: 13, padding: "10px 18px" }}> | |
| <Upload size={15} /> {uploading ? "Uploading…" : "Choose .jsonl files"} | |
| </span> | |
| <span className="lift" onClick={(e) => { 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" }}> | |
| <HelpCircle size={14} color={C.cyan} /> Where are my sessions? | |
| </span> | |
| </div> | |
| </div> | |
| {onDemo && ( | |
| <div className="row lift" onClick={onDemo} title="A real coding-agent session, credential-scanned & identity-sanitized" | |
| 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 }}> | |
| <FlaskConical size={14} color={C.cyan} /> no session handy? <b style={{ color: C.cyan, fontWeight: 600 }}>explore a real demo session</b> → | |
| </div> | |
| )} | |
| <div className="row lift" onClick={() => 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 }}> | |
| <PlayCircle size={14} color={C.cyan} /> prefer to watch? <b style={{ color: C.cyan, fontWeight: 600 }}>see the demo video</b> → | |
| </div> | |
| <BuiltOn /> | |
| </div> | |
| <div style={{ flex: "1 1 460px", minWidth: 320 }}> | |
| <LandingExplainer /> | |
| </div> | |
| </div> | |
| )} | |
| {state.status === "loading" && ( | |
| <div style={{ marginTop: 28, color: C.muted, fontFamily: FM, fontSize: 12 }}>loading your sessions…</div> | |
| )} | |
| {state.status === "error" && ( | |
| <div style={{ marginTop: 28, color: C.amber, fontFamily: FM, fontSize: 12.5, border: `1px solid ${C.amber}`, borderRadius: 9, padding: "14px 16px", maxWidth: 520 }}> | |
| Couldn't load sessions. {state.error} | |
| </div> | |
| )} | |
| {/* project grid */} | |
| {state.status === "ready" && ( | |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(330px, 1fr))", gap: 12, marginTop: 18 }}> | |
| {projects.map((p) => { | |
| const latest = p.sessions[0]; | |
| return ( | |
| <div key={p.cwd} className="lift pop" onClick={() => 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 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 9 }}> | |
| <div style={{ width: 30, height: 30, borderRadius: 8, flexShrink: 0, background: C.elevated, display: "flex", alignItems: "center", justifyContent: "center", border: `1px solid ${C.orangeBd}` }}> | |
| <FolderOpen size={15} color={C.orange} /> | |
| </div> | |
| <div style={{ minWidth: 0, flex: 1 }}> | |
| <div style={{ fontFamily: FD, fontWeight: 600, fontSize: 14, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</div> | |
| <div style={{ fontFamily: FM, fontSize: 10, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={p.cwd}>{p.cwd}</div> | |
| </div> | |
| <ArrowRight size={15} color={C.muted} /> | |
| </div> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}> | |
| <span style={{ fontFamily: FM, fontSize: 10, color: C.text2, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 20, padding: "2px 9px" }}> | |
| {p.count} session{p.count === 1 ? "" : "s"} | |
| </span> | |
| {latest && fmtDateTime(latest.startedAt || (latest.mtime ? latest.mtime * 1000 : 0)) && ( | |
| <span style={{ fontFamily: FM, fontSize: 10, color: C.text2, fontWeight: 600 }}> | |
| {fmtDateTime(latest.startedAt || (latest.mtime ? latest.mtime * 1000 : 0))} | |
| </span> | |
| )} | |
| {p.lastMtime > 0 && <span style={{ fontFamily: FM, fontSize: 10, color: C.muted }}>active {fmtAge(p.lastMtime)}</span>} | |
| {latest && ( | |
| <span className="lift" onClick={(e) => { 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 → | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {projects.length === 0 && hasSessions && ( | |
| <div style={{ color: C.muted, fontFamily: FM, fontSize: 12 }}>no projects match “{q}”.</div> | |
| )} | |
| </div> | |
| )} | |
| {/* 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 && ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 22, flexWrap: "wrap" }}> | |
| {onDemo && ( | |
| <div className="row lift" onClick={onDemo} title="A real coding-agent session, credential-scanned & identity-sanitized" 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" }}> | |
| <FlaskConical size={13} color={C.cyan} /> no session handy? explore a real demo session → | |
| </div> | |
| )} | |
| <div className="row lift" onClick={() => 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" }}> | |
| <PlayCircle size={13} color={C.cyan} /> watch the demo video → | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* drag overlay */} | |
| {dragOver && ( | |
| <div style={{ position: "absolute", inset: 0, zIndex: 60, background: "rgba(20,18,16,0.86)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, border: `2px dashed ${C.orange}`, pointerEvents: "none" }}> | |
| <UploadCloud size={48} color={C.orange} /> | |
| <div style={{ fontFamily: FD, fontWeight: 700, fontSize: 20, color: C.text }}>Drop .jsonl sessions to analyze</div> | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.muted }}>one or many — they’ll group into your Projects view</div> | |
| </div> | |
| )} | |
| {showHelp && <HelpModal onClose={() => setShowHelp(false)} />} | |
| {showPrivacy && <DisclaimerModal onDone={() => setShowPrivacy(false)} />} | |
| {showVideo && <VideoModal onClose={() => setShowVideo(false)} />} | |
| {browse && <SessionBrowser current={null} onPick={onOpenSession} onOpenProject={onOpenProject} onClose={() => setBrowse(false)} />} | |
| </div> | |
| ); | |
| } | |
| // 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 }) => ( | |
| <div style={{ fontFamily: FM, fontSize: 10, letterSpacing: 1.5, color: C.cyan, fontWeight: 600 }}>{children}</div> | |
| ); | |
| 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 ( | |
| <div style={{ maxWidth: 760 }}> | |
| {/* what is Her */} | |
| <Eyebrow>WHAT IS THIS</Eyebrow> | |
| <div style={{ display: "flex", alignItems: "center", gap: 11, marginTop: 9 }}> | |
| <img src="/her-mark-light.png" alt="Her" style={{ width: 38, height: 38, objectFit: "contain", flexShrink: 0 }} /> | |
| <div style={{ fontFamily: FD, fontWeight: 700, fontSize: 21, color: C.text, lineHeight: 1.25 }}> | |
| <span style={{ color: C.orange }}>हेर</span> — a detective for your coding-agent sessions. | |
| </div> | |
| </div> | |
| <div style={{ fontFamily: FB, fontSize: 13, color: C.text2, marginTop: 9, lineHeight: 1.65, maxWidth: 700 }}> | |
| <b style={{ color: C.text }}>her</b> (हेर) is Marathi for <i>detective</i>. Drop a Claude Code session export | |
| (<code style={{ fontFamily: FM, color: C.text2 }}>.jsonl</code>) and Her reads the whole trace — so you can see | |
| what actually happened, and what to do better next time. | |
| </div> | |
| {/* features */} | |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(220px,1fr))", gap: 10, marginTop: 18 }}> | |
| {features.map((f) => ( | |
| <div key={f.t} style={{ background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: 10, padding: "12px 13px" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| <f.icon size={15} color={C.cyan} /> | |
| <span style={{ fontFamily: FD, fontWeight: 600, fontSize: 13, color: C.text }}>{f.t}</span> | |
| </div> | |
| <div style={{ fontFamily: FM, fontSize: 10.5, color: C.muted, marginTop: 6, lineHeight: 1.55 }}>{f.d}</div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* two ways in */} | |
| <div style={{ fontFamily: FM, fontSize: 11, color: C.muted, marginTop: 14, lineHeight: 1.6 }}> | |
| One file opens a <b style={{ color: C.text2 }}>session view</b>; the uploader script builds a{" "} | |
| <b style={{ color: C.text2 }}>project view</b> across many — both with summaries and deep dives. | |
| </div> | |
| {/* under the hood + trust */} | |
| <div style={{ marginTop: 22, borderTop: `1px solid ${C.borderSoft}`, paddingTop: 14 }}> | |
| <Eyebrow>UNDER THE HOOD</Eyebrow> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: "9px 20px", marginTop: 9 }}> | |
| {build.map((b) => ( | |
| <div key={b.t} style={{ display: "flex", alignItems: "center", gap: 7 }}> | |
| <b.icon size={13} color={C.text2} /> | |
| <span style={{ fontFamily: FM, fontSize: 10.5, color: C.text2 }}>{b.t}</span> | |
| <span style={{ fontFamily: FM, fontSize: 10.5, color: C.muted }}>· {b.d}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* legend */} | |
| <div style={{ fontFamily: FM, fontSize: 10.5, fontStyle: "italic", color: C.muted, marginTop: 20, opacity: 0.85 }}> | |
| Built for a couple of wizards who couldn’t quite harness their magic staff. 🪄 | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <span role="img" aria-label={title} title={title} | |
| style={{ | |
| display: "inline-block", height: h, width: Math.round(h * ar), flexShrink: 0, | |
| backgroundColor: C.text2, opacity: 0.6, | |
| WebkitMaskImage: `url(${src})`, maskImage: `url(${src})`, | |
| WebkitMaskRepeat: "no-repeat", maskRepeat: "no-repeat", | |
| WebkitMaskPosition: "center", maskPosition: "center", | |
| WebkitMaskSize: "contain", maskSize: "contain", | |
| }} /> | |
| ); | |
| } | |
| // "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 ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap", marginTop: 2, paddingTop: 13, borderTop: `1px solid ${C.borderSoft}` }}> | |
| <span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 1.5, color: C.muted, fontWeight: 600 }}>BUILT ON</span> | |
| <Logo src="/brand/gradio.svg" h={15} ar={2.868} title="Gradio" /> | |
| <Logo src="/brand/huggingface.svg" h={20} ar={1.08} title="Hugging Face" /> | |
| <Logo src="/brand/nvidia.png" h={21} ar={1.4675} title="NVIDIA Nemotron" /> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 100, background: "rgba(0,0,0,0.8)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}> | |
| <div onClick={(e) => 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)" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 9, padding: "12px 16px", borderBottom: `1px solid ${C.borderSoft}` }}> | |
| <PlayCircle size={17} color={C.cyan} /> | |
| <span style={{ fontFamily: FD, fontWeight: 700, fontSize: 14.5, color: C.text }}>Her — demo walkthrough</span> | |
| <div style={{ flex: 1 }} /> | |
| <X size={18} color={C.muted} style={{ cursor: "pointer" }} onClick={onClose} /> | |
| </div> | |
| {err ? ( | |
| <div style={{ fontFamily: FM, fontSize: 12.5, color: C.amber, padding: "40px 20px", textAlign: "center", lineHeight: 1.6 }}> | |
| The demo video isn’t available right now. | |
| </div> | |
| ) : ( | |
| <video src="/api/demo-video" controls autoPlay playsInline onError={() => setErr(true)} | |
| style={{ display: "block", width: "100%", maxHeight: "78vh", background: "#000" }} /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // "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 || "<owner>/<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 }) => ( | |
| <div style={{ fontFamily: FM, fontSize: 11.5, color: C.text, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 7, padding: "9px 11px", marginTop: 6, overflowX: "auto", whiteSpace: "pre" }}>{children}</div> | |
| ); | |
| return ( | |
| <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 100, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}> | |
| <div onClick={(e) => e.stopPropagation()} style={{ width: 640, maxWidth: "100%", maxHeight: "86vh", overflowY: "auto", background: C.panel, border: `1px solid ${C.border}`, borderRadius: 14, padding: "22px 24px" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 9 }}> | |
| <HelpCircle size={18} color={C.cyan} /> | |
| <span style={{ fontFamily: FD, fontWeight: 700, fontSize: 17 }}>Get your Claude Code sessions in</span> | |
| <div style={{ flex: 1 }} /> | |
| <X size={18} color={C.muted} style={{ cursor: "pointer" }} onClick={onClose} /> | |
| </div> | |
| <div style={{ marginTop: 16, fontFamily: FD, fontWeight: 600, fontSize: 13.5, color: C.text }}>Where they live</div> | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.muted, marginTop: 4, lineHeight: 1.6 }}> | |
| Claude Code writes one <code style={{ color: C.text2 }}>.jsonl</code> per session under | |
| <Code>~/.claude/projects/<encoded-folder>/<session-id>.jsonl</Code> | |
| </div> | |
| <div style={{ marginTop: 16, fontFamily: FD, fontWeight: 600, fontSize: 13.5, color: C.text }}>Quickest: drag one in</div> | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.muted, marginTop: 4, lineHeight: 1.6 }}> | |
| Open that folder, grab any <code style={{ color: C.text2 }}>.jsonl</code>, and <b style={{ color: C.text2 }}>drag it onto this page</b> (or use “Upload .jsonl”). Drop several at once to build a Projects view. | |
| </div> | |
| <div style={{ marginTop: 16, fontFamily: FD, fontWeight: 600, fontSize: 13.5, color: C.text }}>All your projects at once (recommended)</div> | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.muted, marginTop: 4, lineHeight: 1.6 }}> | |
| Run our uploader — it <b style={{ color: C.text2 }}>copies</b> the sessions you pick into a staging folder, <b style={{ color: C.text2 }}>scrubs secrets</b>, | |
| and <b style={{ color: C.text2 }}>uploads</b> them — each step waits for your approval. Then it prints a link that opens your Projects view here. | |
| <Code>{dl}{"\n"}{run}</Code> | |
| </div> | |
| <div style={{ fontFamily: FM, fontSize: 11, color: C.muted, marginTop: 8, lineHeight: 1.6 }}> | |
| (You can also grab <code style={{ color: C.text2 }}>scripts/her_upload.py</code> from this Space’s <b style={{ color: C.text2 }}>Files</b> tab.) | |
| </div> | |
| <div style={{ marginTop: 16, display: "flex", alignItems: "center", gap: 8, fontFamily: FM, fontSize: 11.5, color: C.cyan, background: `${C.cyan}11`, border: `1px solid ${C.cyan}33`, borderRadius: 8, padding: "10px 12px" }}> | |
| <Trash2 size={14} /> 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). | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |