import React, { useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, FolderOpen, ScrollText, Sparkles, Send, Loader2, ShieldCheck, Boxes, Bot, Plug, ChevronRight, ChevronDown, FileClock, CornerDownRight, Terminal, ShieldAlert, AlertTriangle, Flame, Network, Code2, Clock, Wrench, Info, Rocket, Database, Server, } from "lucide-react"; import { C, FD, FB, FM, fmt, fmtWhen } from "../theme.js"; import { BinaryLogo, BinaryBadge } from "./Primitives.jsx"; import { fetchProjectChat, fetchProjectNarrative } from "../useAnalysis.js"; import { withClient } from "../client.js"; const PROJ_RISK = { High: C.red, Medium: C.amber, Low: C.cyan, None: C.muted }; const PROJ_TAG = { LIVE: { c: C.red, icon: Rocket }, PRODUCTION: { c: C.red, icon: Rocket }, SECURITY: { c: C.orange, icon: ShieldAlert }, DATA: { c: "#f472b6", icon: Database }, NETWORK: { c: C.cyan, icon: Network }, CONFIG: { c: C.amber, icon: Code2 }, DEV: { c: C.blue, icon: Server }, }; // PROJECT VIEW — one cwd, many sessions. A plain-English changelog of what // happened across sessions, an ENTITY INVENTORY (skills / sub-agents / MCP // servers, each traceable to the sessions that used it — so a bad skill can be // traced back), and a cross-session chat ("when did we add column X?") whose // answers name the exact session. Clicking any session opens its graph. const ENTITY_META = { skills: { label: "Skills", icon: Boxes, color: C.amber }, subAgents: { label: "Sub-agents", icon: Bot, color: C.blue }, mcpServers: { label: "MCP servers", icon: Plug, color: C.cyan }, }; 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"; } export default function ProjectView({ cwd, llama, onOpenSession, onBack, onBrowse }) { const [state, setState] = useState({ status: "loading", data: null, error: null }); const [sortBy, setSortBy] = useState("cost"); // "cost" (default) | "recent" const [narrStatus, setNarrStatus] = useState("idle"); // idle | loading | done useEffect(() => { let alive = true; setState({ status: "loading", data: null, error: null }); setNarrStatus("idle"); (async () => { // Cold-start race: right after an upload, discovery already lists the project // (sessionCount>0) but the per-session briefs may not be parsed yet (the bucket // is still syncing the just-written files), so `sessions` comes back empty. Don't // show a dead "no sessions" — keep an "analyzing" state and retry for ~15s. for (let attempt = 0; attempt < 7 && alive; attempt++) { try { const r = await fetch(`/api/project?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store", headers: withClient() }); const j = await r.json(); if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`); if (!alive) return; const cold = (j.sessionCount || 0) > 0 && (j.sessions || []).length === 0; if (cold && attempt < 6) { setState({ status: "analyzing", data: j, error: null }); await new Promise((res) => setTimeout(res, 2200)); continue; } setState({ status: "ready", data: j, error: null }); return; } catch (e) { if (alive) setState({ status: "error", data: null, error: String(e) }); return; } } })(); return () => { alive = false; }; }, [cwd]); // On the ZeroGPU Space /api/project is deterministic-only (the GPU changelog prose // must be invoked via @gradio/client so auth headers forward). Fetch the narrative // separately and merge it in. Locally this is a no-op — the prose already arrived. // Track the fetch so the changelog panel can say "generating…" instead of falsely // reporting "model offline" while the model is still writing it. useEffect(() => { if (state.status !== "ready" || !state.data || state.data.narrative) return; let alive = true; setNarrStatus("loading"); (async () => { try { const n = await fetchProjectNarrative(cwd); if (!alive) return; if (n && n.narrative) { setState((s) => (s.data ? { ...s, data: { ...s.data, narrative: n.narrative, model: n.model || s.data.model } } : s)); } } catch { /* leave the deterministic-only changelog */ } finally { if (alive) setNarrStatus("done"); } })(); return () => { alive = false; }; }, [state.status, cwd]); const d = state.data; const name = cwd.split("/").filter(Boolean).pop() || cwd; // API returns sessions cost-ranked; re-sort client-side when the user picks recent. const sortedSessions = useMemo(() => { if (!d) return []; const by = sortBy === "recent" ? (a, b) => (b.mtime || 0) - (a.mtime || 0) : (a, b) => (b.cost || 0) - (a.cost || 0); return [...d.sessions].sort(by); }, [d, sortBy]); return (
{/* LEFT — nav + entity inventory (the traceability spine) */}
back to session
{name}
{d ? `${d.sessionCount} sessions${typeof d.totalCost === "number" ? ` · ${fmt(d.totalCost)} cost` : ""}` : "loading…"}
switch project / session
ENTITIES USED
click to trace back to sessions
{d && d.binaries && d.binaries.length > 0 && ( )} {d && ["skills", "subAgents", "mcpServers"].map((kind) => ( ))} {d && !(d.binaries && d.binaries.length) && ["skills", "subAgents", "mcpServers"].every((k) => !d.entities[k].length) && (
No binaries, skills, sub-agents, or MCP servers detected.
)}
{/* CENTER — changelog + sessions */}
Project Report {cwd}
{state.status === "loading" && } {state.status === "analyzing" && } {state.status === "error" && } {state.status === "ready" && d && ( <>
GENERATED{d.model ? " · " + d.model.split("/").pop().slice(0, 20) : ""} · reads {d.shown} sessions
{renderNarrative(d.narrative, d.sessions, onOpenSession, narrStatus)}
{sortBy === "cost" ? "ranked by cost (Anthropic token consumption) — what you actually pay for, highest first" : "most recently active first"}
{sortedSessions.map((s) => (
onOpenSession(s.path)} style={{ cursor: "pointer", background: C.card, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "11px 13px" }}>
[{(s.sessionId || "?").slice(0, 8)}] {typeof s.cost === "number" && {fmt(s.cost)} cost} {s.turns} turns · {s.startedAt ? fmtWhen(s.startedAt) : fmtAge(s.mtime)}
{s.title}
{(s.binaries || []).slice(0, 5).map((b) => ( ))} {["skills", "subAgents", "mcpServers"].flatMap((k) => (s.entities[k] || []).slice(0, 3).map((e) => ( {e.name} )) )}
))}
)}
{/* RIGHT — project chat */}
); } // PROJECT REPORT BAND — the executive summary across the project's sessions: // stat cards (sessions / total cost / risk / tools / date range) and the high-impact // "actions worth reviewing" rolled up across every session, each traceable to the // session it happened in. Sessions-as-context, the same lens as the Session Report. function ProjectReportBand({ d, onOpenSession }) { const imp = d.impact || { riskLevel: "None", actions: [] }; const riskC = PROJ_RISK[imp.riskLevel] || C.muted; const toolCount = (d.binaries?.length || 0) + (d.entities?.mcpServers?.length || 0); const dates = (d.sessions || []).map((s) => s.startedAt).filter(Boolean).sort(); const range = dates.length ? (dates.length === 1 ? fmtWhen(dates[0]) : `${fmtWhen(dates[0])} → ${fmtWhen(dates[dates.length - 1])}`) : "—"; const topSessions = [...(d.sessions || [])].sort((a, b) => (b.cost || 0) - (a.cost || 0)).slice(0, 3); return ( <>
{imp.actions && imp.actions.length > 0 && ( <>
{imp.actions.map((a, i) => { const tg = PROJ_TAG[a.tag] || { c: C.muted, icon: Info }; const Ic = tg.icon; const sess = (a.sessions || [])[0]; return (
sess && onOpenSession(sess.path)} style={{ display: "flex", gap: 10, alignItems: "center", cursor: sess ? "pointer" : "default", background: C.card, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "10px 12px" }}>
{a.title}
{a.detail} · {(a.sessions || []).length} session{(a.sessions || []).length === 1 ? "" : "s"}
{a.tag}
); })}
)} {topSessions.length > 0 && ( <>
{topSessions.map((s) => { const pct = Math.round((100 * (s.cost || 0)) / Math.max(1, d.totalCost || 1)); return (
onOpenSession(s.path)} style={{ display: "flex", gap: 10, alignItems: "center", cursor: "pointer", padding: "7px 9px", borderRadius: 7 }}> [{(s.sessionId || "?").slice(0, 8)}] {s.title}
{fmt(s.cost || 0)} · {pct}%
); })}
)} ); } function PStat({ label, value, valueColor, sub, grad, icon: Icon, iconColor, small }) { return (
{label} {Icon && }
{value &&
{value}
} {sub &&
{sub}
}
); } // BINARIES — the real tools run via Bash across the project (npx remotion -> // remotion, railway, …), a separate dimension from tool calls. Each row traces // back to every session (and turns) that ran it, like the entity rows. function BinaryGroup({ rows, onOpenSession }) { if (!rows || !rows.length) return null; return (
Binaries {rows.length}
{rows.map((b) => )}
); } function BinaryEntityRow({ b, onOpenSession }) { const [open, setOpen] = useState(false); const label = b.product || b.name || b.binary; return (
setOpen((o) => !o)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 8px", borderRadius: 7, cursor: "pointer", borderLeft: `2px solid ${C.orange}` }}> {open ? : } {label}{!b.identified && · {b.binary}} {b.security && } ×{b.total} · {(b.sessions || []).length} sess
{open && (
{b.blurb &&
{b.blurb}
} {b.security &&
{b.security}
} {(b.sessions || []).map((s) => (
onOpenSession(s.path)} title="open this session" style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 8px", borderRadius: 6, cursor: "pointer", fontFamily: FM, fontSize: 10 }}> [{(s.sessionId || "?").slice(0, 8)}] ×{s.count} · turns {(s.turns || []).slice(0, 6).join(",")}{(s.turns || []).length > 6 ? "…" : ""}
))}
)}
); } function EntityGroup({ kind, rows, onOpenSession }) { const meta = ENTITY_META[kind]; const Icon = meta.icon; if (!rows || !rows.length) return null; return (
{meta.label} {rows.length}
{rows.map((e) => )}
); } function EntityRow({ e, meta, onOpenSession }) { const [open, setOpen] = useState(false); return (
setOpen((o) => !o)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 8px", borderRadius: 7, cursor: "pointer", borderLeft: `2px solid ${meta.color}` }}> {open ? : } {e.name} ×{e.total} · {e.sessions.length} sess
{open && (
{e.tools && e.tools.length > 0 && (
tools: {e.tools.slice(0, 8).join(", ")}{e.tools.length > 8 ? "…" : ""}
)} {e.sessions.map((s) => (
onOpenSession(s.path)} title="open this session" style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 8px", borderRadius: 6, cursor: "pointer", fontFamily: FM, fontSize: 10 }}> [{(s.sessionId || "?").slice(0, 8)}] ×{s.count} · turns {s.turns.slice(0, 6).join(",")}{s.turns.length > 6 ? "…" : ""}
))}
)}
); } // Make [sessionId] mentions in the narrative clickable -> open that session. function renderNarrative(text, sessions, onOpenSession, status) { if (!text) { // The prose is still being generated on the GPU — say so. Only call it "offline" // once the fetch has actually returned empty (status === "done"), never while waiting. if (status !== "done") { return ( Writing the cross-session summary — this can take up to ~15 seconds… ); } return No changelog available (model offline — give it a moment and reopen, or it may genuinely be down).; } const byShort = {}; sessions.forEach((s) => { if (s.sessionId) byShort[s.sessionId.slice(0, 8)] = s.path; }); const parts = text.split(/(\[[0-9a-f]{6,8}\])/gi); return parts.map((p, i) => { const m = p.match(/^\[([0-9a-f]{6,8})\]$/i); if (m && byShort[m[1]]) { return ( onOpenSession(byShort[m[1]])} style={{ cursor: "pointer", color: C.orange, fontFamily: FM, fontSize: 12.5, borderBottom: `1px dotted ${C.orange}` }} title="open this session">{p} ); } return {p}; }); } function ProjectChat({ cwd, llama, onOpenSession }) { const [msgs, setMsgs] = useState([]); const [q, setQ] = useState(""); const [busy, setBusy] = useState(false); const scroller = useRef(null); useEffect(() => { setMsgs([]); }, [cwd]); useEffect(() => { if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight; }, [msgs, busy]); async function ask(text) { const question = (text ?? q).trim(); if (!question || busy) return; setQ(""); setMsgs((m) => [...m, { role: "user", text: question }]); setBusy(true); try { const j = await fetchProjectChat(question, cwd); setMsgs((m) => [...m, { role: "assistant", ...j }]); } catch (e) { setMsgs((m) => [...m, { role: "assistant", answer: `Couldn't answer: ${String(e)}`, sessionHits: [], error: true }]); } finally { setBusy(false); } } const SUGG = ["What was built across these sessions?", "Which session changed prod config?", "Where was the smruti skill used?"]; return (
Ask Her · this project
{llama ? "LOCAL MODEL" : "MODEL OFF"}
Her finds WHICH session something happened in · names it · open to go deeper
{msgs.length === 0 && SUGG.map((s) => (
ask(s)} style={{ cursor: "pointer", fontFamily: FM, fontSize: 12, color: C.text2, border: `1px solid ${C.borderSoft}`, borderRadius: 8, padding: "9px 11px", marginBottom: 7, display: "flex", gap: 7, alignItems: "center" }}> {s}
))} {msgs.map((m, i) => m.role === "user" ? (
{m.text}
) : (
{m.error ? "ERROR" : `GENERATED${m.model ? " · " + m.model.split("/").pop().slice(0, 18) : ""} · across sessions`}
{m.answer} {m.sessionHits && m.sessionHits.length > 0 && (
{m.sessionHits.map((h) => ( onOpenSession(h.path)} title={h.title} style={{ cursor: "pointer", fontFamily: FM, fontSize: 10.5, color: C.orange, background: C.black, border: `1px solid ${C.orangeBd}`, borderRadius: 6, padding: "2px 8px" }}> open [{(h.sessionId || "?").slice(0, 8)}] → ))}
)}
))} {busy &&
searching across the project's sessions…
}
setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && ask()} placeholder="when did we add column x to sql?" style={{ flex: 1, background: "transparent", border: "none", outline: "none", color: C.text, fontFamily: FM, fontSize: 12.5 }} />
ask()} className="lift" style={{ cursor: busy ? "default" : "pointer", width: 30, height: 30, borderRadius: 7, background: q.trim() && !busy ? C.orange : C.elevated, display: "flex", alignItems: "center", justifyContent: "center" }}>
); } function SecHead({ icon: Icon, color, text }) { return (
{text}
); } function Note({ text, bad, spin }) { return (
{spin && } {text}
); } function SortToggle({ sortBy, setSortBy }) { const Opt = ({ id, label }) => ( setSortBy(id)} className="lift" style={{ cursor: "pointer", fontFamily: FM, fontSize: 9.5, padding: "2px 9px", borderRadius: 5, color: sortBy === id ? C.text : C.muted, background: sortBy === id ? C.elevated : "transparent", border: `1px solid ${sortBy === id ? C.border : "transparent"}` }}>{label} ); return (
sort
); }