import React, { useEffect, useMemo, useState } from "react"; import { FolderOpen, FileClock, X, Search, ChevronRight, ChevronDown, HardDrive } from "lucide-react"; import { C, FD, FM } from "../theme.js"; import { fetchSessions } from "../useAnalysis.js"; // Multi-session browser. Walks the REAL ~/.claude/projects via the local engine // API (cwd read from inside each file, never the lossy folder name — NN#5) and // lets the user open ANY session, not just the bundled POC. Deepest-folder-wins // grouping is by the true cwd the engine reports. function fmtBytes(n) { if (!n) return "0B"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "MB"; if (n >= 1e3) return Math.round(n / 1e3) + "KB"; return n + "B"; } 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"; } // Real session START datetime — the whole point of Shripal's ask: "couldn't tell // which session was which." Renders local like "Jun 04, 21:30". Accepts an ISO // string (session.startedAt, read from inside the file) OR epoch-ms (the mtime // fallback). Invalid/empty input renders nothing rather than "Invalid Date". 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 SessionBrowser({ current, onPick, onOpenProject, onClose }) { const [state, setState] = useState({ status: "loading", data: null, error: null }); const [open, setOpen] = useState({}); const [q, setQ] = useState(""); useEffect(() => { let alive = true; (async () => { try { const data = await fetchSessions(); if (alive) { setState({ status: "ready", data, error: null }); // auto-expand the project containing the current session if (data.projects && data.projects.length) { const first = {}; data.projects.slice(0, 1).forEach((p) => (first[p.cwd] = true)); setOpen(first); } } } catch (e) { if (alive) setState({ status: "error", data: null, error: String(e) }); } })(); return () => { alive = false; }; }, []); const projects = useMemo(() => { const ps = state.data?.projects || []; if (!q.trim()) return ps; const needle = q.toLowerCase(); return ps .map((p) => ({ ...p, sessions: p.sessions.filter( (s) => p.cwd.toLowerCase().includes(needle) || (s.sessionId || "").toLowerCase().includes(needle) ), })) .filter((p) => p.cwd.toLowerCase().includes(needle) || p.sessions.length); }, [state.data, q]); return (
e.stopPropagation()} style={{ width: "min(820px,94vw)", maxHeight: "84vh", background: C.panel, border: `1px solid ${C.border}`, borderRadius: 12, display: "flex", flexDirection: "column", boxShadow: "0 24px 60px rgba(0,0,0,.5)" }}> {/* header */}
Browse sessions
{state.data ? `${state.data.total} sessions · ${state.data.projectCount} projects · ~/.claude/projects` : ""}
{/* search */}
setQ(e.target.value)} placeholder="filter by path or session id…" style={{ flex: 1, background: "transparent", border: "none", outline: "none", color: C.text, fontFamily: FM, fontSize: 12.5 }} />
{/* body */}
{state.status === "loading" &&
reading ~/.claude/projects…
} {state.status === "error" && (
could not reach the engine API — start it with:
python3 server/app.py (then reload)
{state.error}
)} {state.status === "ready" && projects.map((p) => { const isOpen = open[p.cwd] ?? false; return (
setOpen((o) => ({ ...o, [p.cwd]: !isOpen }))} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderRadius: 8, cursor: "pointer" }}> {isOpen ? : } {p.cwd} {onOpenProject && ( { ev.stopPropagation(); onOpenProject(p.cwd); }} title="open the project view (changelog, entities, all sessions)" style={{ fontFamily: FM, fontSize: 9.5, color: C.orange, border: `1px solid ${C.orangeBd}`, borderRadius: 5, padding: "2px 8px" }}>project → )} {p.count}
{isOpen && p.sessions.map((s) => { const active = current === s.path; return (
onPick(s.path)} style={{ display: "flex", alignItems: "center", gap: 9, margin: "3px 0 3px 26px", padding: "8px 11px", borderRadius: 8, cursor: "pointer", background: active ? C.card : "transparent", border: `1px solid ${active ? C.orangeBd : "transparent"}`, borderLeft: `3px solid ${active ? C.orange : C.borderSoft}` }}> {(s.sessionId || "?").slice(0, 8)} {/* real START datetime — the signal that tells sessions apart */} {fmtDateTime(s.startedAt || (s.mtime ? s.mtime * 1000 : 0)) && ( {fmtDateTime(s.startedAt || (s.mtime ? s.mtime * 1000 : 0))} )} {fmtBytes(s.sizeBytes)} {fmtAge(s.mtime)}
); })}
); })} {state.status === "ready" && projects.length === 0 && (
no sessions match “{q}”.
)}
cwd is read from inside each file, never the encoded folder name · pick a session to analyze it live
); }