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) */}
{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.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 (
);
}