Spaces:
Running on Zero
Running on Zero
| 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> | |
| ); | |
| } | |