Spaces:
Running on Zero
Running on Zero
| 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 ( | |
| <div style={{ display: "flex", flex: 1, minHeight: 0 }}> | |
| {/* LEFT — nav + entity inventory (the traceability spine) */} | |
| <div style={{ width: 320, background: C.panel, borderRight: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}> | |
| <div style={{ padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}> | |
| <div className="row lift" onClick={onBack} style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer", fontFamily: FM, fontSize: 11.5, color: C.text2, border: `1px solid ${C.borderSoft}`, borderRadius: 7, padding: "7px 9px" }}> | |
| <ArrowLeft size={13} /> back to session | |
| </div> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| <div style={{ width: 30, height: 30, borderRadius: 7, background: C.orange, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> | |
| <FolderOpen size={16} color="#fff" /> | |
| </div> | |
| <div style={{ minWidth: 0 }}> | |
| <div style={{ fontFamily: FD, fontWeight: 700, fontSize: 14, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div> | |
| <div style={{ fontFamily: FM, fontSize: 9.5, color: C.muted }}>{d ? `${d.sessionCount} sessions${typeof d.totalCost === "number" ? ` · ${fmt(d.totalCost)} cost` : ""}` : "loading…"}</div> | |
| </div> | |
| </div> | |
| <div className="row lift" onClick={onBrowse} style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer", fontFamily: FM, fontSize: 10.5, color: C.muted, border: `1px solid ${C.borderSoft}`, borderRadius: 7, padding: "6px 9px" }}> | |
| <FolderOpen size={12} /> switch project / session | |
| </div> | |
| </div> | |
| <div style={{ padding: "6px 12px 4px", borderTop: `1px solid ${C.borderSoft}` }}> | |
| <span style={{ fontFamily: FM, fontSize: 10.5, letterSpacing: 0.7, color: C.text2, fontWeight: 600 }}>ENTITIES USED</span> | |
| <div style={{ fontFamily: FM, fontSize: 9.5, color: C.muted, marginTop: 2 }}>click to trace back to sessions</div> | |
| </div> | |
| <div style={{ overflowY: "auto", flex: 1, padding: "4px 8px 14px" }}> | |
| {d && d.binaries && d.binaries.length > 0 && ( | |
| <BinaryGroup rows={d.binaries} onOpenSession={onOpenSession} /> | |
| )} | |
| {d && ["skills", "subAgents", "mcpServers"].map((kind) => ( | |
| <EntityGroup key={kind} kind={kind} rows={d.entities[kind]} onOpenSession={onOpenSession} /> | |
| ))} | |
| {d && !(d.binaries && d.binaries.length) && ["skills", "subAgents", "mcpServers"].every((k) => !d.entities[k].length) && ( | |
| <div style={{ fontFamily: FM, fontSize: 11, color: C.muted, padding: "8px 6px" }}>No binaries, skills, sub-agents, or MCP servers detected.</div> | |
| )} | |
| </div> | |
| </div> | |
| {/* CENTER — changelog + sessions */} | |
| <div style={{ flex: 1, minWidth: 0, overflowY: "auto", padding: "20px 24px" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 10 }}> | |
| <span style={{ fontFamily: FD, fontWeight: 700, fontSize: 20 }}>Project Report</span> | |
| <span style={{ fontFamily: FM, fontSize: 11, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{cwd}</span> | |
| </div> | |
| {state.status === "loading" && <Note text="reading the project's sessions and asking the local model for a changelog…" spin />} | |
| {state.status === "analyzing" && <Note spin text="Just uploaded? Still analyzing this project's sessions — this can take a few seconds. Retrying…" />} | |
| {state.status === "error" && <Note bad text={`could not load project — ${state.error}`} />} | |
| {state.status === "ready" && d && ( | |
| <> | |
| <ProjectReportBand d={d} onOpenSession={onOpenSession} /> | |
| <SecHead icon={ScrollText} color={C.cyan} text="WHAT HAPPENED ACROSS SESSIONS" /> | |
| <div className="pop" style={{ marginTop: 8, fontSize: 14, color: C.text, lineHeight: 1.72, background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderLeft: `3px solid ${C.cyan}`, borderRadius: 9, padding: "14px 16px" }}> | |
| <div style={{ marginBottom: 8 }}> | |
| <span style={{ fontFamily: FM, fontSize: 9, letterSpacing: 0.5, color: C.amber, border: `1px solid ${C.amber}`, borderRadius: 4, padding: "1px 6px" }}> | |
| GENERATED{d.model ? " · " + d.model.split("/").pop().slice(0, 20) : ""} · reads {d.shown} sessions | |
| </span> | |
| </div> | |
| {renderNarrative(d.narrative, d.sessions, onOpenSession, narrStatus)} | |
| </div> | |
| <SecHead icon={FileClock} color={C.orange} text={`SESSIONS · ${d.sessions.length}`} /> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 2 }}> | |
| <span style={{ fontFamily: FM, fontSize: 10, color: C.muted, flex: 1 }}> | |
| {sortBy === "cost" ? "ranked by cost (Anthropic token consumption) — what you actually pay for, highest first" : "most recently active first"} | |
| </span> | |
| <SortToggle sortBy={sortBy} setSortBy={setSortBy} /> | |
| </div> | |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(280px,1fr))", gap: 10, marginTop: 8 }}> | |
| {sortedSessions.map((s) => ( | |
| <div key={s.path} className="row lift" onClick={() => onOpenSession(s.path)} style={{ cursor: "pointer", background: C.card, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "11px 13px" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| <span style={{ fontFamily: FM, fontSize: 11, color: C.orange }}>[{(s.sessionId || "?").slice(0, 8)}]</span> | |
| {typeof s.cost === "number" && <span title={`Anthropic token consumption (cost-weighted)${typeof s.cacheRead === "number" ? ` · ${fmt(s.cacheRead)} cache re-reads (cumulative)` : ""}`} style={{ fontFamily: FM, fontSize: 9.5, color: C.amber, border: `1px solid ${C.amber}55`, borderRadius: 4, padding: "1px 6px" }}>{fmt(s.cost)} cost</span>} | |
| <span style={{ flex: 1 }} /> | |
| <span title={fmtAge(s.mtime) ? "last active " + fmtAge(s.mtime) : ""} style={{ fontFamily: FM, fontSize: 9.5, color: C.muted }}>{s.turns} turns · {s.startedAt ? fmtWhen(s.startedAt) : fmtAge(s.mtime)}</span> | |
| </div> | |
| <div style={{ fontSize: 12.5, color: C.text2, lineHeight: 1.45, marginTop: 6, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{s.title}</div> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 5, marginTop: 8 }}> | |
| {(s.binaries || []).slice(0, 5).map((b) => ( | |
| <BinaryBadge key={"b" + b.binary} b={b} /> | |
| ))} | |
| {["skills", "subAgents", "mcpServers"].flatMap((k) => | |
| (s.entities[k] || []).slice(0, 3).map((e) => ( | |
| <span key={k + e.name} style={{ fontFamily: FM, fontSize: 9, color: ENTITY_META[k].color, border: `1px solid ${ENTITY_META[k].color}55`, borderRadius: 4, padding: "1px 6px" }}>{e.name}</span> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div style={{ height: 20 }} /> | |
| </> | |
| )} | |
| </div> | |
| {/* RIGHT — project chat */} | |
| <div style={{ width: 392, background: C.panel, borderLeft: `1px solid ${C.borderSoft}`, flexShrink: 0 }}> | |
| <ProjectChat cwd={cwd} llama={llama} onOpenSession={onOpenSession} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <> | |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(150px,1fr))", gap: 10, marginTop: 14 }}> | |
| <PStat label="SESSIONS" value={String(d.sessionCount ?? d.sessions?.length ?? 0)} sub={`${d.shown || 0} analyzed`} /> | |
| <PStat label="TOKEN COST" value={fmt(d.totalCost || 0)} valueColor={C.orange} grad sub="cost-weighted tokens · not $" /> | |
| <PStat label="RISK LEVEL" value={imp.riskLevel} valueColor={riskC} icon={ShieldAlert} iconColor={riskC} | |
| sub={imp.actions?.length ? `${imp.actions.length} action${imp.actions.length === 1 ? "" : "s"} to review` : "no high-impact actions"} /> | |
| <PStat label="TOOLS" value={String(toolCount)} icon={Wrench} iconColor={C.cyan} sub="binaries + MCP" /> | |
| <PStat label="ACTIVE" value={range === "—" ? "—" : ""} icon={Clock} iconColor={C.muted} sub={range} small /> | |
| </div> | |
| {imp.actions && imp.actions.length > 0 && ( | |
| <> | |
| <SecHead icon={AlertTriangle} color={C.amber} text="ACTIONS WORTH REVIEWING" /> | |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(320px,1fr))", gap: 8, marginTop: 8 }}> | |
| {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 ( | |
| <div key={i} className="row lift" onClick={() => 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" }}> | |
| <div style={{ width: 26, height: 26, borderRadius: 7, background: C.elevated, border: `1px solid ${tg.c}`, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> | |
| <Ic size={13} color={tg.c} /> | |
| </div> | |
| <div style={{ minWidth: 0, flex: 1 }}> | |
| <div style={{ fontSize: 12.5, fontWeight: 600, color: C.text }}>{a.title}</div> | |
| <div style={{ fontSize: 10.5, color: C.muted, lineHeight: 1.4, marginTop: 1 }}> | |
| {a.detail} · {(a.sessions || []).length} session{(a.sessions || []).length === 1 ? "" : "s"} | |
| </div> | |
| </div> | |
| <span style={{ fontFamily: FM, fontSize: 8.5, letterSpacing: 0.5, color: tg.c, border: `1px solid ${tg.c}`, borderRadius: 4, padding: "1px 6px", flexShrink: 0 }}>{a.tag}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </> | |
| )} | |
| {topSessions.length > 0 && ( | |
| <> | |
| <SecHead icon={Flame} color={C.orange} text="WHERE THE COST WENT" /> | |
| <div style={{ marginTop: 8 }}> | |
| {topSessions.map((s) => { | |
| const pct = Math.round((100 * (s.cost || 0)) / Math.max(1, d.totalCost || 1)); | |
| return ( | |
| <div key={s.path} className="row lift" onClick={() => onOpenSession(s.path)} | |
| style={{ display: "flex", gap: 10, alignItems: "center", cursor: "pointer", padding: "7px 9px", borderRadius: 7 }}> | |
| <span style={{ fontFamily: FM, fontSize: 10, color: C.orange, flexShrink: 0 }}>[{(s.sessionId || "?").slice(0, 8)}]</span> | |
| <span style={{ fontSize: 12, color: C.text2, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.title}</span> | |
| <div style={{ width: 90, height: 5, background: C.black, borderRadius: 3, overflow: "hidden", flexShrink: 0 }}> | |
| <div style={{ width: `${Math.max(3, pct)}%`, height: "100%", background: `linear-gradient(90deg,${C.orange},${C.amber})` }} /> | |
| </div> | |
| <span style={{ fontFamily: FM, fontSize: 10, color: C.orange, minWidth: 64, textAlign: "right", flexShrink: 0 }}>{fmt(s.cost || 0)} · {pct}%</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </> | |
| )} | |
| </> | |
| ); | |
| } | |
| function PStat({ label, value, valueColor, sub, grad, icon: Icon, iconColor, small }) { | |
| return ( | |
| <div style={{ background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: 10, padding: "11px 13px" }}> | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> | |
| <span style={{ fontFamily: FM, fontSize: 8.5, letterSpacing: 0.6, color: C.muted }}>{label}</span> | |
| {Icon && <Icon size={13} color={iconColor || C.muted} />} | |
| </div> | |
| {value && <div className={grad ? "hb" : ""} style={{ fontFamily: FD, fontWeight: 700, fontSize: small ? 13 : 22, color: grad ? undefined : (valueColor || C.text), marginTop: 5, lineHeight: 1.1 }}>{value}</div>} | |
| {sub && <div style={{ fontSize: small ? 10.5 : 10, color: C.muted, marginTop: value ? 4 : 6, lineHeight: 1.35 }}>{sub}</div>} | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <div style={{ marginBottom: 8 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 6px 4px" }}> | |
| <Terminal size={12} color={C.orange} /> | |
| <span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 0.5, color: C.orange }}>Binaries</span> | |
| <span style={{ fontFamily: FM, fontSize: 9.5, color: C.muted }}>{rows.length}</span> | |
| </div> | |
| {rows.map((b) => <BinaryEntityRow key={b.binary} b={b} onOpenSession={onOpenSession} />)} | |
| </div> | |
| ); | |
| } | |
| function BinaryEntityRow({ b, onOpenSession }) { | |
| const [open, setOpen] = useState(false); | |
| const label = b.product || b.name || b.binary; | |
| return ( | |
| <div style={{ marginBottom: 3 }}> | |
| <div className="row" onClick={() => setOpen((o) => !o)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 8px", borderRadius: 7, cursor: "pointer", borderLeft: `2px solid ${C.orange}` }}> | |
| {open ? <ChevronDown size={12} color={C.muted} /> : <ChevronRight size={12} color={C.muted} />} | |
| <BinaryLogo b={b} size={15} /> | |
| <span title={b.blurb || ""} style={{ fontFamily: FM, fontSize: 11.5, color: C.text, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {label}{!b.identified && <span style={{ color: C.muted }}> · {b.binary}</span>} | |
| </span> | |
| {b.security && <ShieldAlert size={11} color={C.amber} title={b.security} />} | |
| <span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>×{b.total} · {(b.sessions || []).length} sess</span> | |
| </div> | |
| {open && ( | |
| <div style={{ paddingLeft: 18, paddingTop: 3 }}> | |
| {b.blurb && <div style={{ fontFamily: FB, fontSize: 9.5, color: C.muted, padding: "2px 6px 4px", lineHeight: 1.45 }}>{b.blurb}</div>} | |
| {b.security && <div style={{ fontFamily: FM, fontSize: 9, color: C.amber, padding: "0 6px 4px", display: "flex", alignItems: "center", gap: 4 }}><ShieldAlert size={10} /> {b.security}</div>} | |
| {(b.sessions || []).map((s) => ( | |
| <div key={s.path} className="row lift" onClick={() => 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 }}> | |
| <CornerDownRight size={10} color={C.orange} /> | |
| <span style={{ color: C.orange }}>[{(s.sessionId || "?").slice(0, 8)}]</span> | |
| <span style={{ color: C.muted }}>×{s.count} · turns {(s.turns || []).slice(0, 6).join(",")}{(s.turns || []).length > 6 ? "…" : ""}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function EntityGroup({ kind, rows, onOpenSession }) { | |
| const meta = ENTITY_META[kind]; | |
| const Icon = meta.icon; | |
| if (!rows || !rows.length) return null; | |
| return ( | |
| <div style={{ marginBottom: 8 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 6px 4px" }}> | |
| <Icon size={12} color={meta.color} /> | |
| <span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 0.5, color: meta.color }}>{meta.label}</span> | |
| <span style={{ fontFamily: FM, fontSize: 9.5, color: C.muted }}>{rows.length}</span> | |
| </div> | |
| {rows.map((e) => <EntityRow key={e.name} e={e} meta={meta} onOpenSession={onOpenSession} />)} | |
| </div> | |
| ); | |
| } | |
| function EntityRow({ e, meta, onOpenSession }) { | |
| const [open, setOpen] = useState(false); | |
| return ( | |
| <div style={{ marginBottom: 3 }}> | |
| <div className="row" onClick={() => setOpen((o) => !o)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 8px", borderRadius: 7, cursor: "pointer", borderLeft: `2px solid ${meta.color}` }}> | |
| {open ? <ChevronDown size={12} color={C.muted} /> : <ChevronRight size={12} color={C.muted} />} | |
| <span style={{ fontFamily: FM, fontSize: 11.5, color: C.text, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.name}</span> | |
| <span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>×{e.total} · {e.sessions.length} sess</span> | |
| </div> | |
| {open && ( | |
| <div style={{ paddingLeft: 18, paddingTop: 3 }}> | |
| {e.tools && e.tools.length > 0 && ( | |
| <div style={{ fontFamily: FM, fontSize: 9, color: C.muted, padding: "2px 6px 4px" }}>tools: {e.tools.slice(0, 8).join(", ")}{e.tools.length > 8 ? "…" : ""}</div> | |
| )} | |
| {e.sessions.map((s) => ( | |
| <div key={s.path} className="row lift" onClick={() => 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 }}> | |
| <CornerDownRight size={10} color={meta.color} /> | |
| <span style={{ color: C.orange }}>[{(s.sessionId || "?").slice(0, 8)}]</span> | |
| <span style={{ color: C.muted }}>×{s.count} · turns {s.turns.slice(0, 6).join(",")}{s.turns.length > 6 ? "…" : ""}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <span style={{ color: C.muted, display: "inline-flex", alignItems: "center", gap: 7 }}> | |
| <Loader2 size={13} className="spin" /> Writing the cross-session summary — this can take up to ~15 seconds… | |
| </span> | |
| ); | |
| } | |
| return <span style={{ color: C.muted }}>No changelog available (model offline — give it a moment and reopen, or it may genuinely be down).</span>; | |
| } | |
| 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 ( | |
| <span key={i} onClick={() => 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}</span> | |
| ); | |
| } | |
| return <span key={i}>{p}</span>; | |
| }); | |
| } | |
| 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 ( | |
| <div style={{ display: "flex", flexDirection: "column", height: "100%" }}> | |
| <div style={{ padding: "13px 15px 11px", borderBottom: `1px solid ${C.borderSoft}` }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| <Sparkles size={15} color={C.orange} /> | |
| <span style={{ fontFamily: FD, fontWeight: 700, fontSize: 14 }}>Ask <span style={{ color: C.orange }}>Her</span> <span style={{ color: C.muted, fontWeight: 500, fontSize: 11 }}>· this project</span></span> | |
| <div style={{ flex: 1 }} /> | |
| <span style={{ fontFamily: FM, fontSize: 9, color: llama ? C.cyan : C.muted, border: `1px solid ${llama ? C.cyan : C.border}`, borderRadius: 5, padding: "1px 6px", display: "flex", alignItems: "center", gap: 3 }}> | |
| <ShieldCheck size={10} /> {llama ? "LOCAL MODEL" : "MODEL OFF"} | |
| </span> | |
| </div> | |
| <div style={{ fontFamily: FM, fontSize: 10, color: C.muted, marginTop: 6, lineHeight: 1.5 }}>Her finds WHICH session something happened in · names it · open to go deeper</div> | |
| </div> | |
| <div ref={scroller} style={{ flex: 1, overflowY: "auto", padding: "12px 13px" }}> | |
| {msgs.length === 0 && SUGG.map((s) => ( | |
| <div key={s} className="row lift" onClick={() => 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" }}> | |
| <CornerDownRight size={12} color={C.orange} /> {s} | |
| </div> | |
| ))} | |
| {msgs.map((m, i) => m.role === "user" ? ( | |
| <div key={i} style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}> | |
| <div style={{ maxWidth: "88%", background: C.orangeMut, border: `1px solid ${C.orangeBd}`, borderRadius: "10px 10px 2px 10px", padding: "8px 11px", fontSize: 12.5, color: C.text, lineHeight: 1.5 }}>{m.text}</div> | |
| </div> | |
| ) : ( | |
| <div key={i} style={{ marginBottom: 14 }}> | |
| <div style={{ display: "flex", gap: 7, alignItems: "center", marginBottom: 5 }}> | |
| <Sparkles size={11} color={m.error ? C.red : C.orange} /> | |
| <span style={{ fontFamily: FM, fontSize: 9, letterSpacing: 0.4, color: C.muted }}>{m.error ? "ERROR" : `GENERATED${m.model ? " · " + m.model.split("/").pop().slice(0, 18) : ""} · across sessions`}</span> | |
| </div> | |
| <div style={{ fontSize: 13, color: C.text2, lineHeight: 1.6, background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: "2px 10px 10px 10px", padding: "10px 12px" }}> | |
| {m.answer} | |
| {m.sessionHits && m.sessionHits.length > 0 && ( | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}> | |
| {m.sessionHits.map((h) => ( | |
| <span key={h.path} className="lift" onClick={() => 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)}] → | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| {busy && <div style={{ display: "flex", gap: 7, alignItems: "center", color: C.muted, fontFamily: FM, fontSize: 11 }}><Loader2 size={13} className="spin" /> searching across the project's sessions…</div>} | |
| </div> | |
| <div style={{ padding: "10px 12px", borderTop: `1px solid ${C.borderSoft}` }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "7px 9px 7px 12px" }}> | |
| <input value={q} onChange={(e) => 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 }} /> | |
| <div onClick={() => 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" }}> | |
| <Send size={14} color={q.trim() && !busy ? "#fff" : C.muted} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SecHead({ icon: Icon, color, text }) { | |
| return ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 7, marginTop: 20, marginBottom: 2 }}> | |
| <Icon size={15} color={color} /> | |
| <span style={{ fontFamily: FD, fontWeight: 700, fontSize: 13, letterSpacing: 0.4, color }}>{text}</span> | |
| </div> | |
| ); | |
| } | |
| function Note({ text, bad, spin }) { | |
| return ( | |
| <div style={{ marginTop: 18, display: "flex", alignItems: "center", gap: 8, fontFamily: FM, fontSize: 12, color: bad ? C.red : C.muted }}> | |
| {spin && <Loader2 size={14} className="spin" />} {text} | |
| </div> | |
| ); | |
| } | |
| function SortToggle({ sortBy, setSortBy }) { | |
| const Opt = ({ id, label }) => ( | |
| <span onClick={() => 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}</span> | |
| ); | |
| return ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| <span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>sort</span> | |
| <div style={{ display: "flex", gap: 2, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 7, padding: 2 }}> | |
| <Opt id="cost" label="by cost" /> | |
| <Opt id="recent" label="recent" /> | |
| </div> | |
| </div> | |
| ); | |
| } | |