her / ui /src /components /ProjectView.jsx
geekwrestler's picture
Squash history (purge pre-scrub demo session blobs)
5f43c7d
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>
);
}