her / ui /src /components /SessionBrowser.jsx
geekwrestler's picture
Squash history (purge pre-scrub demo session blobs)
5f43c7d
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 (
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(15,14,13,.72)", zIndex: 50, display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "6vh 20px" }}>
<div onClick={(e) => 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 */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: `1px solid ${C.borderSoft}` }}>
<FolderOpen size={18} color={C.orange} />
<div style={{ fontFamily: FD, fontWeight: 700, fontSize: 16 }}>Browse sessions</div>
<span style={{ fontFamily: FM, fontSize: 11, color: C.muted }}>
{state.data ? `${state.data.total} sessions · ${state.data.projectCount} projects · ~/.claude/projects` : ""}
</span>
<div style={{ flex: 1 }} />
<div className="row lift" onClick={onClose} style={{ cursor: "pointer", padding: 6, borderRadius: 7 }}><X size={16} color={C.text2} /></div>
</div>
{/* search */}
<div style={{ padding: "10px 16px", borderBottom: `1px solid ${C.borderSoft}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 8, padding: "7px 10px" }}>
<Search size={14} color={C.muted} />
<input value={q} onChange={(e) => 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 }} />
</div>
</div>
{/* body */}
<div style={{ overflowY: "auto", padding: "8px 10px 14px" }}>
{state.status === "loading" && <div style={{ padding: 24, color: C.muted, fontFamily: FM, fontSize: 12 }}>reading ~/.claude/projects…</div>}
{state.status === "error" && (
<div style={{ padding: 24, color: C.red, fontFamily: FM, fontSize: 12 }}>
could not reach the engine API — start it with: <br />
<span style={{ color: C.amber }}>python3 server/app.py</span> (then reload)
<div style={{ color: C.muted, marginTop: 6 }}>{state.error}</div>
</div>
)}
{state.status === "ready" && projects.map((p) => {
const isOpen = open[p.cwd] ?? false;
return (
<div key={p.cwd} style={{ marginBottom: 4 }}>
<div className="row" onClick={() => setOpen((o) => ({ ...o, [p.cwd]: !isOpen }))} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderRadius: 8, cursor: "pointer" }}>
{isOpen ? <ChevronDown size={15} color={C.muted} /> : <ChevronRight size={15} color={C.muted} />}
<HardDrive size={14} color={C.cyan} />
<span style={{ fontFamily: FM, fontSize: 12.5, color: C.text, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.cwd}</span>
{onOpenProject && (
<span className="lift" onClick={(ev) => { 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 →</span>
)}
<span style={{ fontFamily: FM, fontSize: 10.5, color: C.muted, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 20, padding: "1px 9px" }}>{p.count}</span>
</div>
{isOpen && p.sessions.map((s) => {
const active = current === s.path;
return (
<div key={s.path} className="row lift" onClick={() => 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}` }}>
<FileClock size={14} color={active ? C.orange : C.text2} />
<span style={{ fontFamily: FM, fontSize: 12, color: active ? C.text : C.text2 }}>{(s.sessionId || "?").slice(0, 8)}</span>
{/* real START datetime — the signal that tells sessions apart */}
{fmtDateTime(s.startedAt || (s.mtime ? s.mtime * 1000 : 0)) && (
<span style={{ fontFamily: FM, fontSize: 11, color: active ? C.orange : C.text2, fontWeight: 600 }}>
{fmtDateTime(s.startedAt || (s.mtime ? s.mtime * 1000 : 0))}
</span>
)}
<span style={{ flex: 1 }} />
<span style={{ fontFamily: FM, fontSize: 10, color: C.muted }}>{fmtBytes(s.sizeBytes)}</span>
<span style={{ fontFamily: FM, fontSize: 10, color: C.muted, minWidth: 56, textAlign: "right" }}>{fmtAge(s.mtime)}</span>
</div>
);
})}
</div>
);
})}
{state.status === "ready" && projects.length === 0 && (
<div style={{ padding: 24, color: C.muted, fontFamily: FM, fontSize: 12 }}>no sessions match “{q}”.</div>
)}
</div>
<div style={{ padding: "9px 16px", borderTop: `1px solid ${C.borderSoft}`, fontFamily: FM, fontSize: 10.5, color: C.muted }}>
cwd is read from inside each file, never the encoded folder name · pick a session to analyze it live
</div>
</div>
</div>
);
}