her / ui /src /components /ProjectsHome.jsx
geekwrestler's picture
Deploy Her (Gradio Server / ZeroGPU + bucket + per-client isolation + enrichment)
c6bf731 verified
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 &amp; 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?&nbsp;<b style={{ color: C.cyan, fontWeight: 600 }}>explore a real demo session</b>&nbsp;
</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?&nbsp;<b style={{ color: C.cyan, fontWeight: 600 }}>see the demo video</b>&nbsp;
</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/&lt;encoded-folder&gt;/&lt;session-id&gt;.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>
);
}