Spaces:
Running on Zero
Running on Zero
| import React, { useEffect, useState } from "react"; | |
| import { ClipboardList, Lightbulb, Map, FolderOpen, Sparkles, Bot, Boxes, Plug, Terminal } from "lucide-react"; | |
| import { C, FD, FB, FM, fmt, fmtWhen, intentOf, intentIcon, turnSeverity, shortPrompt } from "./theme.js"; | |
| import { useAnalysis, useApi, fetchAdvice, fetchOverview } from "./useAnalysis.js"; | |
| import { Chip, Stat, BinaryRow } from "./components/Primitives.jsx"; | |
| import Legend, { defaultVis } from "./components/Legend.jsx"; | |
| import SessionGraph from "./components/SessionGraph.jsx"; | |
| import TurnGraph from "./components/TurnGraph.jsx"; | |
| import { SessionDetail, TurnDetail, ToolDetail } from "./components/DetailPanel.jsx"; | |
| import SessionBrowser from "./components/SessionBrowser.jsx"; | |
| import ChatPanel from "./components/ChatPanel.jsx"; | |
| import ProjectView from "./components/ProjectView.jsx"; | |
| import ProjectsHome from "./components/ProjectsHome.jsx"; | |
| import SessionReport from "./components/SessionReport.jsx"; | |
| import DisclaimerModal, { needsDisclaimer } from "./components/DisclaimerModal.jsx"; | |
| // Session-level entity inventory (Mode A left rail): the skills, sub-agents, and | |
| // MCP servers this ONE session used, each traceable to the turn that launched it — | |
| // the same inventory the project view shows, scoped to the session. Renders nothing | |
| // when the session used none (honest silence). Sub-agents run in their OWN | |
| // transcripts, so this never changes the tool/causality counts in the Legend above. | |
| function SessionEntities({ entities, binaries, onOpen }) { | |
| const bins = binaries || []; | |
| const groups = [ | |
| ["subAgents", "Sub-agents", Bot, C.blue], | |
| ["skills", "Skills", Boxes, C.amber], | |
| ["mcpServers", "MCP servers", Plug, C.cyan], | |
| ]; | |
| const hasEntities = !!entities && groups.some(([k]) => (entities[k] || []).length); | |
| if (!hasEntities && !bins.length) return null; | |
| return ( | |
| <> | |
| {/* BINARIES — real tools run via Bash (npx remotion -> remotion, railway, …), | |
| a separate dimension from tool calls. Each row traces to its turn. */} | |
| {bins.length > 0 && ( | |
| <> | |
| <div style={{ padding: "12px 10px 2px" }}> | |
| <span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 1, color: C.muted }}>BINARIES RUN</span> | |
| </div> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 13px 3px" }}> | |
| <Terminal size={11} color={C.orange} /> | |
| <span style={{ fontFamily: FM, fontSize: 9.5, letterSpacing: 0.5, color: C.orange }}>via Bash</span> | |
| <span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>{bins.length}</span> | |
| </div> | |
| <div style={{ padding: "0 8px 6px" }}> | |
| {bins.map((b) => <BinaryRow key={b.binary} b={b} onOpen={onOpen} />)} | |
| <div style={{ fontFamily: FM, fontSize: 8.5, color: C.muted, padding: "4px 6px 0", lineHeight: 1.45 }}> | |
| extracted from the command (the real binary behind <code>npx</code>/<code>bash</code>) · click to jump to the turn it fired | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| {!hasEntities ? null : ( | |
| <> | |
| <div style={{ padding: "12px 10px 2px" }}> | |
| <span style={{ fontFamily: FM, fontSize: 10, letterSpacing: 1, color: C.muted }}>ENTITIES IN THIS SESSION</span> | |
| </div> | |
| <div style={{ padding: "0 8px 12px" }}> | |
| {groups.map(([k, label, Icon, color]) => { | |
| const rows = entities[k] || []; | |
| if (!rows.length) return null; | |
| return ( | |
| <div key={k} style={{ marginBottom: 6 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 5px 3px" }}> | |
| <Icon size={11} color={color} /> | |
| <span style={{ fontFamily: FM, fontSize: 9.5, letterSpacing: 0.5, color }}>{label}</span> | |
| <span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>{rows.length}</span> | |
| </div> | |
| {rows.map((e) => ( | |
| <div key={e.name} className="row lift" | |
| onClick={() => e.turns && e.turns.length && onOpen(e.turns[0])} | |
| title={`used in turn(s) ${(e.turns || []).join(", ")}${e.via ? " · via " + e.via : ""}${e.workflow ? " · workflow " + e.workflow : ""}`} | |
| style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 8px", borderRadius: 6, cursor: "pointer", borderLeft: `2px solid ${color}` }}> | |
| <span style={{ fontFamily: FM, fontSize: 11.5, color: C.text2, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.name}</span> | |
| <span style={{ fontFamily: FM, fontSize: 9, color: C.muted }}>×{e.count}</span> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| })} | |
| <div style={{ fontFamily: FM, fontSize: 8.5, color: C.muted, padding: "4px 6px 0", lineHeight: 1.45 }}> | |
| launched from the cited turn · each runs in its own transcript, so it doesn't change the tool or causality counts above | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </> | |
| ); | |
| } | |
| export default function App() { | |
| // sourcePath: null = no session loaded (welcome) · "__demo__" = the bundled demo | |
| // session (click-to-load, never a default) · else a real ~/.claude session, all | |
| // analyzed live by the API server. | |
| const [sourcePath, setSourcePath] = useState(null); | |
| const { status, data, narrated, error } = useAnalysis(sourcePath); | |
| const api = useApi(); | |
| // view: "report" = executive summary (DEFAULT) · "session" = Mode A journey graph | |
| // · "turn" = Mode B drill-in | |
| const [view, setView] = useState("report"); | |
| const [ti, setTi] = useState(0); // selected turn index | |
| const [tool, setTool] = useState(null); // selected tool index in Mode B | |
| const [vis, setVis] = useState(defaultVis); | |
| const [atHome, setAtHome] = useState(true); // top-level Projects landing is the default | |
| const [browserOpen, setBrowserOpen] = useState(false); | |
| const [chatOpen, setChatOpen] = useState(false); | |
| const [overview, setOverview] = useState(null); // plain-English "what happened" | |
| const [advice, setAdvice] = useState(null); // LLM "what could have been better", session-scoped | |
| const [projectCwd, setProjectCwd] = useState(null); // project scope (many sessions) | |
| const [showDisclaimer, setShowDisclaimer] = useState(needsDisclaimer()); // first-run opt-in | |
| const consentModal = showDisclaimer ? <DisclaimerModal onDone={() => setShowDisclaimer(false)} /> : null; | |
| const hasApiEarly = api?.hasApi; | |
| // The human "what happened" overview shown at the top of Mode A. For the bundled | |
| // fixture we reuse the curated narrator outcome; for any real session we ask the | |
| // local model to write one (grounded in the turns). Narrator prose only — the | |
| // numbers stay deterministic. | |
| useEffect(() => { | |
| setOverview(null); | |
| if (atHome || status !== "ready") return; | |
| let alive = true; | |
| const curated = narrated && (narrated.session_outcome || narrated.summary || narrated.outcome); | |
| const fromCurated = () => | |
| curated && setOverview({ text: String(curated).replace(/^Generated summary[^\n]*\n+/i, "").trim(), label: "narrator" }); | |
| // Prefer the freshly-generated "what happened" (plain English, doesn't dwell on | |
| // tokens) for ANY session; fall back to a curated narrated outcome if the API | |
| // or model is down. | |
| if (hasApiEarly) { | |
| (async () => { | |
| try { | |
| const j = await fetchOverview(sourcePath); | |
| if (!alive) return; | |
| if (j && j.overview) setOverview({ text: j.overview, label: j.model ? j.model.split("/").pop() : "narrator" }); | |
| else fromCurated(); | |
| } catch { | |
| if (alive) fromCurated(); | |
| } | |
| })(); | |
| } else { | |
| fromCurated(); | |
| } | |
| return () => { alive = false; }; | |
| }, [status, sourcePath, narrated, hasApiEarly, atHome]); | |
| // Session-scoped advice ("what could have been better") — the local model writes | |
| // it from the engine's fired signals + this session's objective + the Anthropic | |
| // codebook. Best-effort: the deterministic recommendations render instantly and | |
| // this upgrades them to scoped prose when the model answers. | |
| useEffect(() => { | |
| setAdvice(null); | |
| if (atHome || status !== "ready" || !hasApiEarly) return; | |
| let alive = true; | |
| (async () => { | |
| try { | |
| const j = await fetchAdvice(sourcePath); | |
| if (alive && j && Array.isArray(j.recommendations)) setAdvice(j); | |
| } catch { /* fall back to the deterministic recommendations */ } | |
| })(); | |
| return () => { alive = false; }; | |
| }, [status, sourcePath, hasApiEarly, atHome]); | |
| const openTurn = (i, toolIdx = null) => { setView("turn"); setTi(i); setTool(toolIdx); }; | |
| const pickSession = (path) => { | |
| setAtHome(false); | |
| setSourcePath(path); | |
| setView("report"); setTi(0); setTool(null); | |
| setBrowserOpen(false); setProjectCwd(null); | |
| }; | |
| const openProject = (cwd) => { setAtHome(false); setProjectCwd(cwd); setBrowserOpen(false); setChatOpen(false); }; | |
| const goHome = () => { setAtHome(true); setProjectCwd(null); setChatOpen(false); setBrowserOpen(false); }; | |
| // The bundled demo session — loads ONLY on this explicit click (the "__demo__" | |
| // sentinel the server resolves to fixtures/demo-session.jsonl). Never a default. | |
| const openDemo = () => pickSession("__demo__"); | |
| const hasApi = api?.hasApi; | |
| // DEFAULT LANDING — the top-level Projects list. From here: project -> sessions | |
| // (ProjectView) -> a session (the graph). ProjectsHome handles its own | |
| // loading/no-engine states, so we only wait while the API probe is in flight. | |
| if (atHome) { | |
| if (api == null) return <Splash text="connecting to the local engine…" />; | |
| return ( | |
| <> | |
| <Style /> | |
| {consentModal} | |
| <ProjectsHome onOpenProject={openProject} onOpenSession={pickSession} onDemo={openDemo} /> | |
| </> | |
| ); | |
| } | |
| // Project scope is session-independent — ProjectView loads its own data from `cwd`. | |
| // The welcome / loading / error screens below key off the loaded SESSION's status, so | |
| // scope them to session view only. Otherwise opening a project before any session is | |
| // analyzed (sourcePath still null → status "welcome") falls through to the welcome | |
| // screen, and only "works" once some session has been loaded. A project click must | |
| // never require a loaded session. | |
| const inProject = !!projectCwd; | |
| if (!inProject && status === "welcome") | |
| return ( | |
| <div style={{ fontFamily: FB, color: C.text, height: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, background: "radial-gradient(1200px 500px at 50% -10%, #3b3633 0%, #2b2927 60%)" }}> | |
| <Style /> | |
| <img src="/her-logo-light.png" alt="Her" style={{ height: 70 }} /> | |
| <div style={{ fontFamily: FM, fontSize: 13, color: C.text2 }}>हेर — a detective for your coding-agent sessions · 100% local</div> | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.muted, maxWidth: 460, textAlign: "center", lineHeight: 1.6 }}> | |
| No session loaded (traces are never bundled). Pick one of your <code style={{ color: C.text2 }}>~/.claude</code> sessions and Her will analyze it live. | |
| </div> | |
| {api == null ? ( | |
| <div style={{ fontFamily: FM, fontSize: 11, color: C.muted }}>connecting to the local engine…</div> | |
| ) : hasApiEarly ? ( | |
| <div className="lift" onClick={() => setBrowserOpen(true)} style={{ cursor: "pointer", display: "flex", alignItems: "center", gap: 8, background: C.orange, color: "#fff", fontFamily: FD, fontWeight: 600, fontSize: 13.5, borderRadius: 9, padding: "11px 20px" }}> | |
| <FolderOpen size={16} /> Browse your sessions | |
| </div> | |
| ) : ( | |
| <div style={{ fontFamily: FM, fontSize: 12, color: C.amber, border: `1px solid ${C.amber}`, borderRadius: 8, padding: "10px 14px" }}> | |
| Start the local engine: <b>./her</b> (then refresh) | |
| </div> | |
| )} | |
| {browserOpen && <SessionBrowser current={null} onPick={pickSession} onClose={() => setBrowserOpen(false)} />} | |
| </div> | |
| ); | |
| if (!inProject && status === "loading") | |
| return <Splash text={sourcePath ? "analyzing session…" : "loading trace…"} />; | |
| if (!inProject && status === "error") | |
| return ( | |
| <Splash bad text={`could not load engine output — ${error}`}> | |
| {hasApi && <button onClick={() => setBrowserOpen(true)} style={btnStyle}>browse sessions</button>} | |
| {sourcePath && <button onClick={goHome} style={btnStyle}>back to home</button>} | |
| {browserOpen && <SessionBrowser current={sourcePath} onPick={pickSession} onClose={() => setBrowserOpen(false)} />} | |
| </Splash> | |
| ); | |
| // Session-scope data — null/empty in project scope (ProjectView fetches its own from | |
| // `cwd`). The JSX that dereferences these only renders when !inProject, so guarding | |
| // here keeps a project click from throwing on the still-null session `data`. | |
| const S = inProject ? null : data.session; | |
| const turns = inProject ? [] : data.turns; | |
| const t = turns[Math.min(ti, turns.length - 1)]; | |
| // Sub-turns are SIZED BY COST (Anthropic token consumption) — what you pay for — | |
| // not by raw cacheRead. cacheRead stays as a secondary metric on hover. | |
| const maxCost = Math.max(1, ...turns.map((x) => x.tokens.cost ?? 0)); | |
| return ( | |
| <div style={{ fontFamily: FB, color: C.text, height: "100vh", display: "flex", flexDirection: "column", background: "radial-gradient(1200px 500px at 25% -8%, #3b3633 0%, #2b2927 55%)" }}> | |
| <Style /> | |
| {consentModal} | |
| {/* header */} | |
| <div style={{ height: 58, background: C.header, borderBottom: `1px solid ${C.borderSoft}`, boxShadow: `0 1px 0 0 ${C.orange}22`, display: "flex", alignItems: "center", padding: "0 18px", gap: 14, flexShrink: 0 }}> | |
| <div className="lift" onClick={goHome} style={{ display: "flex", alignItems: "center", gap: 9, cursor: "pointer" }} title="Her · हेर — back to all projects"> | |
| <img src="/her-logo-light.png" alt="Her" style={{ height: 28, display: "block" }} /> | |
| <span style={{ fontFamily: FD, fontWeight: 600, fontSize: 13, color: C.muted, letterSpacing: 1 }}>हेर</span> | |
| </div> | |
| {inProject ? ( | |
| <div style={{ fontFamily: FM, fontSize: 11, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| <span style={{ color: C.orange }}>project</span> · {projectCwd} | |
| </div> | |
| ) : ( | |
| <div onClick={() => hasApi && openProject(S.cwd)} title={hasApi ? "open the project view — all sessions in this folder" : undefined} style={{ fontFamily: FM, fontSize: 11, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: hasApi ? "pointer" : "default" }}> | |
| session {S.sessionId ? S.sessionId.slice(0, 8) : "?"} · <span style={{ color: hasApi ? C.text2 : C.muted, borderBottom: hasApi ? `1px dotted ${C.border}` : "none" }}>{S.cwd}</span>{S.startedAt ? <> · <span style={{ color: C.text2 }}>{fmtWhen(S.startedAt)}</span></> : null} · {S.gitBranch} · v{S.version} | |
| </div> | |
| )} | |
| <div style={{ flex: 1 }} /> | |
| {hasApi && ( | |
| <div className="row lift" onClick={() => setBrowserOpen(true)} style={{ cursor: "pointer", display: "flex", alignItems: "center", gap: 6, fontFamily: FM, fontSize: 11, color: C.text2, border: `1px solid ${C.border}`, borderRadius: 7, padding: "5px 10px" }}> | |
| <FolderOpen size={13} color={C.orange} /> {inProject ? "project" : sourcePath === "__demo__" ? "demo session" : sourcePath ? "live session" : "home"} · browse | |
| </div> | |
| )} | |
| <Chip dot={C.cyan} text="100% LOCAL" /> | |
| {!inProject && narrated && <Chip dot={C.amber} text="NARRATED" />} | |
| {!inProject && <Stat label="queries" v={S.turns} />} | |
| {!inProject && <Stat label="tools" v={S.tools} />} | |
| {!inProject && <Stat label="token cost" v={fmt(S.cost ?? S.tokens?.cost ?? 0)} grad />} | |
| {!inProject && <Stat label="cache re-reads" v={fmt(S.tokens.cacheRead)} />} | |
| {!inProject && S.context && <Stat label="peak ctx" v={`${Math.round((S.context.peakPct || 0) * 100)}%`} />} | |
| {!inProject && <Stat label="generated" v={fmt(S.tokens.out)} />} | |
| </div> | |
| {inProject ? ( | |
| <ProjectView cwd={projectCwd} llama={api?.llama} onOpenSession={pickSession} onBack={goHome} onBrowse={() => setBrowserOpen(true)} /> | |
| ) : view === "report" ? ( | |
| /* DEFAULT: the executive Session Report (full width). Buttons drill into the | |
| Journey Graph (Mode A) and turn-by-turn (Mode B); chat opens on the right. */ | |
| <div style={{ display: "flex", flex: 1, minHeight: 0 }}> | |
| <SessionReport | |
| session={S} turns={turns} binaries={data.binaries} entities={data.entities} | |
| impact={data.impact} recommendations={data.recommendations} advice={advice} | |
| overview={overview} narrated={narrated} | |
| onOpenTurn={openTurn} | |
| onOpenJourney={() => { setView("session"); setTool(null); }} | |
| onOpenRaw={() => openTurn(0)} | |
| onAskHer={() => setChatOpen((v) => !v)} | |
| chatOpen={chatOpen} | |
| /> | |
| {chatOpen && ( | |
| <div style={{ width: 392, background: C.panel, borderLeft: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}> | |
| <ChatPanel sessionPath={sourcePath} llama={api?.llama} onFocusTurn={openTurn} /> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div style={{ display: "flex", flex: 1, minHeight: 0 }}> | |
| {/* LEFT: mode switch + legend + query list */} | |
| <div style={{ width: 312, background: C.panel, borderRight: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}> | |
| <div style={{ padding: "10px 10px 4px", display: "flex", flexDirection: "column", gap: 5 }}> | |
| <ModeButton active={view === "report"} icon={ClipboardList} title="Session report" sub="the summary · default" onClick={() => { setView("report"); setTool(null); }} /> | |
| <ModeButton active={view === "session"} icon={Map} title="Session graph" sub="the journey · Mode A" onClick={() => { setView("session"); setTool(null); }} /> | |
| {hasApi && ( | |
| <div style={{ display: "flex", gap: 5 }}> | |
| <ModeButton small icon={FolderOpen} title="Sessions" sub="browse all" onClick={() => setBrowserOpen(true)} /> | |
| <ModeButton small active={chatOpen} icon={Sparkles} title="Ask Her" sub="this session" onClick={() => setChatOpen((v) => !v)} /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Mode A legend lives here when on the landing graph */} | |
| {view === "session" ? ( | |
| <div style={{ overflowY: "auto", flex: 1, minHeight: 0 }}> | |
| <SectionLabel text="LEGEND" /> | |
| <Legend turns={turns} vis={vis} setVis={setVis} /> | |
| <SessionEntities entities={data.entities} binaries={data.binaries} onOpen={openTurn} /> | |
| </div> | |
| ) : ( | |
| <> | |
| <SectionLabel text="QUERIES" /> | |
| <div style={{ overflowY: "auto", flex: 1, padding: "0 8px 12px" }}> | |
| {turns.map((x) => { | |
| const on = view === "turn" && x.i === ti; | |
| const heavy = x.heavy; // top-3 by cost (orange) | |
| const over = x.overBudget && !x.heavy; // over the 500k floor (amber) | |
| const II = intentIcon(intentOf(x)); | |
| return ( | |
| <div key={x.i} className="row" onClick={() => openTurn(x.i)} | |
| style={{ background: on ? C.card : "transparent", border: `1px solid ${on ? C.border : "transparent"}`, borderLeft: `3px solid ${heavy ? C.orange : over ? C.amber : on ? C.border : "transparent"}`, borderRadius: 7, padding: "8px 10px", marginBottom: 5, cursor: "pointer" }}> | |
| <div style={{ display: "flex", gap: 8, alignItems: "center" }}> | |
| <div style={{ width: 20, height: 20, borderRadius: 5, flexShrink: 0, background: C.elevated, display: "flex", alignItems: "center", justifyContent: "center" }}> | |
| <II size={11} color={heavy ? C.orange : C.text2} /> | |
| </div> | |
| <span style={{ fontFamily: FM, fontSize: 10, color: C.muted }}>{String(x.i).padStart(2, "0")}</span> | |
| <span style={{ fontSize: 12, fontWeight: on ? 600 : 500, color: on ? C.text : C.text2, lineHeight: 1.3, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{shortPrompt(x)}</span> | |
| {x.guide && <Lightbulb size={12} color={C.amber} style={{ flexShrink: 0 }} />} | |
| </div> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, paddingLeft: 28 }} | |
| title={`cost ${fmt(x.tokens.cost ?? 0)} (Anthropic token consumption) · ${fmt(x.tokens.cacheRead)} cache re-reads (cumulative) · ${fmt(x.tokens.out)} generated${x.ctxPeak ? ` · window peaked ${fmt(x.ctxPeak)}` : ""}`}> | |
| <div style={{ flex: 1, height: 4, background: C.black, borderRadius: 3, overflow: "hidden" }}> | |
| <div style={{ width: `${Math.max(3, (100 * (x.tokens.cost ?? 0)) / maxCost)}%`, height: "100%", background: heavy ? `linear-gradient(90deg,${C.orange},${C.amber})` : over ? C.amber : C.border }} /> | |
| </div> | |
| <span style={{ fontFamily: FM, fontSize: 9.5, color: heavy ? C.orange : over ? C.amber : C.muted }}>{fmt(x.tokens.cost ?? 0)}</span> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {/* CENTER */} | |
| {view === "session" ? ( | |
| <SessionGraph session={S} turns={turns} narrated={narrated} overview={overview} recommendations={data.recommendations} advice={advice} vis={vis} onOpen={openTurn} /> | |
| ) : ( | |
| <TurnGraph turn={t} selected={tool} onSelect={setTool} vis={vis} /> | |
| )} | |
| {/* RIGHT: chat (when open) else the detail panel */} | |
| <div style={{ width: chatOpen ? 392 : 368, background: C.panel, borderLeft: `1px solid ${C.borderSoft}`, display: "flex", flexDirection: "column", flexShrink: 0 }}> | |
| {chatOpen ? ( | |
| <ChatPanel sessionPath={sourcePath} llama={api?.llama} onFocusTurn={openTurn} /> | |
| ) : view === "session" ? ( | |
| <SessionDetail session={S} /> | |
| ) : tool === null ? ( | |
| <TurnDetail turn={t} narrated={narrated} binaries={data.binaries} /> | |
| ) : ( | |
| <ToolDetail tool={t.tools[tool]} onBack={() => setTool(null)} /> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {browserOpen && <SessionBrowser current={sourcePath} onPick={pickSession} onOpenProject={openProject} onClose={() => setBrowserOpen(false)} />} | |
| </div> | |
| ); | |
| } | |
| function ModeButton({ active, icon: Icon, title, sub, onClick, small }) { | |
| return ( | |
| <div className="row lift" onClick={onClick} style={{ flex: small ? 1 : "unset", display: "flex", alignItems: "center", gap: 9, padding: small ? "8px 9px" : "11px 11px", cursor: "pointer", borderRadius: 8, background: active ? `linear-gradient(135deg,${C.orangeMut},transparent)` : "transparent", border: `1px solid ${active ? C.orangeBd : C.borderSoft}` }}> | |
| <div style={{ width: small ? 24 : 30, height: small ? 24 : 30, borderRadius: 7, background: active ? C.orange : C.elevated, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> | |
| <Icon size={small ? 13 : 16} color={active ? "#fff" : C.text2} /> | |
| </div> | |
| <div style={{ minWidth: 0 }}> | |
| <div style={{ fontSize: small ? 12 : 13.5, fontWeight: 600, color: active ? C.text : C.text2 }}>{title}</div> | |
| <div style={{ fontFamily: FM, fontSize: 9.5, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{sub}</div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SectionLabel({ text }) { | |
| return ( | |
| <div style={{ padding: "8px 12px 7px", borderTop: `1px solid ${C.borderSoft}` }}> | |
| <span style={{ fontFamily: FM, fontSize: 10.5, letterSpacing: 0.8, color: C.text2, fontWeight: 600 }}>{text}</span> | |
| </div> | |
| ); | |
| } | |
| const btnStyle = { marginTop: 14, marginInline: 6, background: C.card, color: C.text2, border: `1px solid ${C.border}`, borderRadius: 7, padding: "8px 14px", fontFamily: FM, fontSize: 12, cursor: "pointer" }; | |
| function Splash({ text, bad, children }) { | |
| return ( | |
| <div style={{ fontFamily: FM, color: bad ? C.red : C.text2, height: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: C.bg, padding: 24, textAlign: "center", fontSize: 13 }}> | |
| <div>{text}</div> | |
| <div style={{ display: "flex", gap: 6 }}>{children}</div> | |
| </div> | |
| ); | |
| } | |
| function Style() { | |
| return ( | |
| <style>{` | |
| *{scrollbar-width:thin;scrollbar-color:${C.border} transparent} | |
| *::-webkit-scrollbar{width:8px;height:8px} | |
| *::-webkit-scrollbar-thumb{background:${C.border};border-radius:4px} | |
| *::-webkit-scrollbar-track{background:transparent} | |
| .row{transition:background .12s ease,border-color .12s ease} | |
| .row:hover{background:${C.card}} | |
| .lift{transition:transform .12s ease,box-shadow .12s ease} | |
| .lift:hover{transform:translateY(-1px)} | |
| .pop{animation:pop .32s ease both} | |
| @keyframes pop{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} | |
| .glow{animation:glow 2.6s ease-in-out infinite} | |
| @keyframes glow{0%,100%{box-shadow:0 0 0 0 rgba(240,106,23,.0),0 0 14px 0 rgba(240,106,23,.35)}50%{box-shadow:0 0 0 4px rgba(240,106,23,.06),0 0 22px 2px rgba(240,106,23,.5)}} | |
| .spin{animation:spin 1s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .hb{background:linear-gradient(90deg,${C.orange},${C.amber});-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent} | |
| `}</style> | |
| ); | |
| } | |