mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
Update documentation and refine tool descriptions
- Corrected the number of tools available to connected clients in README.md from six to five. - Updated design.md to clarify the context ledger and its components. - Revised modules.md to reflect changes in how modules are discovered and interacted with. - Adjusted references to the `overview()` tool, replacing it with `list_dir()` in various documentation files. - Removed the now obsolete `overview` tool from the codebase and updated related tests accordingly. - Enhanced user interface components in the web application, including the addition of new sections for commands and connections. - Introduced a new CopyPrompt component for easier prompt copying across the application. These changes improve clarity and usability for developers interacting with the gcontext framework.
This commit is contained in:
+284
-44
@@ -1,51 +1,224 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { getJSON } from "./lib.js";
|
||||
import { C, mono, label } from "./ui.jsx";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getJSON, filePrompt } from "./lib.js";
|
||||
import { C, mono, Chip, GhostBtn, sectionLabel, useHover, useIsMobile, pageTitle, EmptyState } from "./ui.jsx";
|
||||
import CopyPrompt from "./Copy.jsx";
|
||||
|
||||
// Activity = what crossed from gcontext into the agent, newest first.
|
||||
// A flat feed from /api/events (in-memory ring buffer, empties on restart);
|
||||
// a `connect` event starts a session, drawn as a separator. Click a row to
|
||||
// expand the recorded preview inline. Polls every 3s while visible.
|
||||
// Activity = "what crossed from gcontext into my agent, and when". The flat
|
||||
// /api/events feed (an in-memory ring buffer on the server, newest last) is
|
||||
// grouped into SESSIONS (a `connect` event opens each one); pick a session on
|
||||
// the left, skim its crossings, click any to read the recorded preview.
|
||||
// Polls every 3s while the tab is visible; the buffer empties on restart.
|
||||
|
||||
const nfmt = (v) => (v || 0).toLocaleString();
|
||||
const kfmt = (v) => (v >= 1000 ? (v / 1000).toFixed(1).replace(/\.0$/, "") + "k" : String(v));
|
||||
const fmtTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const fmtHM = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
function dayLabel(ts) {
|
||||
const d = new Date(ts), now = new Date();
|
||||
const day = (a) => new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
|
||||
const diff = Math.round((day(now) - day(d)) / 86400000);
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "yesterday";
|
||||
if (diff === 0) return "Today";
|
||||
if (diff === 1) return "Yesterday";
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function Row({ e, open, onToggle }) {
|
||||
const kindColor = e.error ? C.danger : e.kind === "prompt" ? C.ok : C.t3;
|
||||
// The detail's first token, when it reads as a project-relative file path,
|
||||
// e.g. "connections/x/index.md" or "a.md (12 bytes)" -> "a.md".
|
||||
function pathRef(detail) {
|
||||
const first = (detail || "").split(" ")[0];
|
||||
return /^[\w.\-/]+\.\w+$/.test(first) || /^[\w.\-]+\/[\w.\-/]*$/.test(first) ? first : null;
|
||||
}
|
||||
|
||||
// One tier chip per crossing origin: pushed (connect), agent pulled, user pulled.
|
||||
const TIERS = {
|
||||
0: { label: "pushed", color: "#a8492a", bg: "rgba(194,96,58,.10)" },
|
||||
1: { label: "agent", color: "#8f7c5f", bg: "rgba(176,154,125,.14)" },
|
||||
2: { label: "you", color: "#4a7c59", bg: "rgba(74,124,89,.10)" },
|
||||
};
|
||||
const tierOf = (t) => TIERS[t] || TIERS[1];
|
||||
|
||||
// Bucket "how much context this call added" so heavy calls are skimmable.
|
||||
function weight(tokensOut) {
|
||||
if (tokensOut >= 5200) return { key: "heavy", color: C.accent, frac: 1, label: "heavy" };
|
||||
if (tokensOut >= 2000) return { key: "med", color: "#cf8a63", frac: 0.6, label: "medium" };
|
||||
if (tokensOut >= 420) return { key: "light", color: "#c3b7a0", frac: 0.32, label: "light" };
|
||||
return { key: "tiny", color: "#d8cfbd", frac: 0.14, label: "minimal" };
|
||||
}
|
||||
|
||||
const label = sectionLabel;
|
||||
const monoNum = { fontFamily: mono, fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
function TierChip({ tier, style }) {
|
||||
const t = tierOf(tier);
|
||||
return <Chip style={{ color: t.color, background: t.bg, border: "1px solid transparent", minWidth: 46, justifyContent: "center", flexShrink: 0, ...style }}>{t.label}</Chip>;
|
||||
}
|
||||
|
||||
function Bar({ frac, color, w }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${C.borderInner}` }}>
|
||||
<div onClick={onToggle} style={{ display: "flex", alignItems: "baseline", gap: 10, padding: "6px 0", cursor: e.preview ? "pointer" : "default", fontSize: 12.5, flexWrap: "wrap" }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 11, color: C.t3, width: 84, flexShrink: 0, whiteSpace: "nowrap" }}>{fmtTime(e.ts)}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 10.5, color: kindColor, width: 44, flexShrink: 0 }}>{e.error ? "error" : e.kind}</span>
|
||||
<span style={{ fontFamily: mono, fontWeight: 600, color: e.error ? C.danger : C.ink, flexShrink: 0 }}>{e.name}</span>
|
||||
<span style={{ color: C.tMuted, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.detail}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 11, color: C.t3, flexShrink: 0 }}>{e.tokens_out ? `~${e.tokens_out} tk` : ""}</span>
|
||||
</div>
|
||||
{open && e.preview && (
|
||||
<pre className="gc-scroll" style={{ margin: "0 0 10px 70px", padding: "10px 12px", background: C.subtle, border: `1px solid ${C.borderInner}`, borderRadius: 6, fontFamily: mono, fontSize: 11.5, lineHeight: 1.7, whiteSpace: "pre-wrap", overflow: "auto", maxHeight: 300 }}>
|
||||
{e.preview}{e.preview.length >= 400 ? "\n┅ first 400 chars, the agent received the rest too" : ""}
|
||||
</pre>
|
||||
)}
|
||||
<span style={{ display: "block", width: w, height: 4, borderRadius: 3, background: C.soft, overflow: "hidden" }}>
|
||||
<span style={{ display: "block", width: Math.max(frac * w, 3), height: "100%", borderRadius: 3, background: color }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The one reading surface: light background, ink text, comfortable line height.
|
||||
function Reader({ children, maxHeight }) {
|
||||
return (
|
||||
<pre className="gc-scroll" style={{ margin: 0, padding: "16px 18px", background: C.subtle, color: C.ink, border: `1px solid ${C.borderInner}`, fontFamily: mono, fontSize: 12.5, lineHeight: 1.8, borderRadius: 11, whiteSpace: "pre-wrap", overflow: "auto", maxHeight }}>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// --- session rail item ------------------------------------------------------
|
||||
function SessionItem({ session, active, selected, onSelect, maxTk }) {
|
||||
const [h, hp] = useHover();
|
||||
const st = session.startTs;
|
||||
return (
|
||||
<button {...hp} onClick={onSelect}
|
||||
style={{ display: "flex", alignItems: "center", gap: 11, width: "100%", padding: "11px 13px", borderRadius: 10, cursor: "pointer", transition: "all .12s", fontFamily: "inherit",
|
||||
border: `1px solid ${selected ? C.borderStrong : (h ? C.borderStrong : C.border)}`, background: "#fff",
|
||||
boxShadow: selected ? "0 1px 2px rgba(28,27,25,.06)" : (h ? "0 6px 18px -14px rgba(28,27,25,.4)" : "none") }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", flexShrink: 0, background: active ? "#4a7c59" : (selected ? C.ink : "#c7c0af"), animation: active ? "gcpulse 2s infinite" : "none" }} />
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 3, minWidth: 0, flex: 1, textAlign: "left" }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: C.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{active ? "Active session" : `${dayLabel(st)} ${fmtHM(st)}`}</span>
|
||||
<span style={{ ...monoNum, fontSize: 11, color: C.t3 }}>{session.events.length} crossing{session.events.length === 1 ? "" : "s"}</span>
|
||||
</span>
|
||||
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 }}>
|
||||
<span style={{ ...monoNum, fontSize: 11, fontWeight: 600, color: selected ? C.ink : C.t3 }}>~{kfmt(session.tk)}</span>
|
||||
<Bar frac={session.tk / maxTk} color={selected ? C.accent : "#cbb8a4"} w={54} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- one crossing row -------------------------------------------------------
|
||||
function Row({ e, first, onOpen }) {
|
||||
const [h, hp] = useHover();
|
||||
const w = weight(e.tokens_out);
|
||||
const heavy = e.tokens_out >= 1200;
|
||||
const ref = pathRef(e.detail);
|
||||
return (
|
||||
<div {...hp} onClick={onOpen}
|
||||
style={{ display: "flex", gap: 11, padding: "10px 14px", alignItems: "center", cursor: "pointer", transition: "background .12s",
|
||||
borderTop: first ? "none" : `1px solid ${C.borderInner}`, background: h ? C.rowHover : e.error ? C.missFill : "transparent" }}>
|
||||
<span style={{ ...monoNum, fontSize: 11, color: C.t3, flexShrink: 0, width: 74, whiteSpace: "nowrap", overflow: "hidden" }}>{fmtTime(e.ts)}</span>
|
||||
<TierChip tier={e.tier} />
|
||||
<span style={{ fontFamily: mono, fontSize: 12, fontWeight: 600, color: e.error ? C.danger : C.ink, flexShrink: 0 }}>{e.name}</span>
|
||||
<span style={{ fontSize: 12, color: C.tMuted, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.detail}{e.error ? " · failed" : ""}</span>
|
||||
{ref && h && <CopyPrompt icon text={filePrompt(ref)} title={`Copy a prompt to read ${ref}`} style={{ width: 22, height: 22 }} />}
|
||||
<span title={`${w.label}: ${nfmt(e.tokens_out)} tokens added to context`} style={{ display: "inline-flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
|
||||
<Bar frac={w.frac} color={w.color} w={44} />
|
||||
<span style={{ ...monoNum, fontSize: 11, color: heavy ? C.accent : C.t3, width: 56, textAlign: "right", fontWeight: heavy ? 600 : 400 }}>{nfmt(e.tokens_out)} tk</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "rgba(0,0,0,.3)", flexShrink: 0, width: 12, textAlign: "center" }}>›</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- modal: reads a single crossing -----------------------------------------
|
||||
function Modal({ children, mobile, onClose }) {
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(28,27,25,.34)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: mobile ? "20px 12px" : "48px 24px", zIndex: 50, overflow: "auto" }}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="gc-scroll"
|
||||
style={{ width: "min(760px, 100%)", maxHeight: "calc(100vh - 96px)", overflow: "auto", background: "#fff", border: `1px solid ${C.borderStrong}`, borderRadius: 14, boxShadow: "0 30px 70px -24px rgba(28,27,25,.5)", display: "flex", flexDirection: "column", animation: "gcpop .16s ease-out" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventModal({ e, onClose }) {
|
||||
const t = tierOf(e.tier);
|
||||
const w = weight(e.tokens_out);
|
||||
const hasPreview = !!(e.preview && e.preview.length);
|
||||
const ref = pathRef(e.detail);
|
||||
const stat = { padding: "12px 15px", borderRight: `1px solid ${C.borderInner}` };
|
||||
const statLabel = { ...label, fontSize: 9.5, marginBottom: 4 };
|
||||
const statVal = { ...monoNum, fontSize: 14, color: C.ink };
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: "sticky", top: 0, background: "#fff", borderBottom: `1px solid ${C.borderInner}`, padding: "16px 20px", display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", zIndex: 1 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", background: t.color, flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: mono, fontSize: 15.5, fontWeight: 700, color: e.error ? C.danger : C.ink }}>{e.name}</span>
|
||||
<TierChip tier={e.tier} />
|
||||
{e.error && <Chip tone="miss" style={{ letterSpacing: ".06em" }}>FAILED</Chip>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<button onClick={onClose} title="Close (Esc)" style={{ width: 30, height: 30, display: "flex", alignItems: "center", justifyContent: "center", border: `1px solid ${C.border}`, borderRadius: 8, background: "#fff", color: C.tMuted, fontSize: 15, cursor: "pointer", flexShrink: 0 }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: 20 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", border: `1px solid ${C.borderInner}`, borderRadius: 11, overflow: "hidden", marginBottom: 16 }}>
|
||||
<div style={{ ...stat, flex: "1 1 110px" }}>
|
||||
<div style={statLabel}>Time</div>
|
||||
<div style={statVal}>{new Date(e.ts).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div style={{ ...stat, flex: "1 1 90px" }}>
|
||||
<div style={statLabel}>Duration</div>
|
||||
<div style={statVal}>{e.duration_ms ? `${nfmt(e.duration_ms)} ms` : "n/a"}</div>
|
||||
</div>
|
||||
<div style={{ ...stat, flex: "1 1 90px" }}>
|
||||
<div style={statLabel}>Tokens in</div>
|
||||
<div style={statVal}>{e.tokens_in > 0 ? nfmt(e.tokens_in) + " tk" : "n/a"}</div>
|
||||
</div>
|
||||
<div style={{ ...stat, flex: "1 1 90px", borderRight: "none" }}>
|
||||
<div style={statLabel}>Added to context</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={statVal}>{nfmt(e.tokens_out)} tk</span>
|
||||
<Chip style={{ color: w.color, background: w.key === "heavy" ? "rgba(194,96,58,.10)" : "rgba(176,154,125,.14)", border: "1px solid transparent", textTransform: "uppercase", letterSpacing: ".04em" }}>{w.label}</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{e.detail && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ ...label, marginBottom: 6 }}>What it was about</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", background: C.subtle, border: `1px solid ${C.borderInner}`, borderRadius: 9 }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 13, color: C.ink, lineHeight: 1.6, wordBreak: "break-word", flex: 1 }}>{e.detail}</span>
|
||||
{ref && <CopyPrompt icon text={filePrompt(ref)} title={`Copy a prompt to read ${ref}`} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...label, marginBottom: 7 }}>What the agent received</div>
|
||||
{hasPreview ? (
|
||||
<>
|
||||
<Reader maxHeight="46vh">{e.preview}</Reader>
|
||||
{e.preview.length >= 400 && <div style={{ marginTop: 9, display: "flex", alignItems: "center", gap: 7, fontSize: 11.5, color: C.t3 }}><span style={{ fontFamily: mono, color: "#8f7c5f" }}>┅</span>First 400 chars shown, the agent received the rest too.</div>}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: "15px 16px", border: `1px dashed ${e.error ? C.missBorder : C.borderStrong}`, borderRadius: 11, background: e.error ? C.missFill : C.subtle }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 7 }}>
|
||||
<span style={{ fontSize: 15 }}>{e.error ? "⚠" : "◌"}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5, color: C.ink }}>{e.error ? "The call failed" : "No preview captured"}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: C.tMuted, lineHeight: 1.6 }}>
|
||||
{e.error
|
||||
? "This call errored; the message above is what came back."
|
||||
: e.kind === "connect"
|
||||
? "A harness connected. Its context comes from the tool descriptions and whatever it pulls next."
|
||||
: e.kind === "prompt"
|
||||
? "A command was invoked. The rendered command text went straight into the conversation."
|
||||
: "Only the size of this crossing was recorded."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Activity() {
|
||||
const [events, setEvents] = useState(null); // newest first
|
||||
const mobile = useIsMobile();
|
||||
const [flow, setFlow] = useState(null); // newest-first list; null = loading
|
||||
const [err, setErr] = useState(null);
|
||||
const [open, setOpen] = useState(null); // event id expanded
|
||||
const [selSession, setSelSession] = useState(0);
|
||||
const [modal, setModal] = useState(null); // {e} | null
|
||||
const timer = useRef(null);
|
||||
|
||||
// /api/events returns oldest-first; the grouping below wants newest-first.
|
||||
const load = () => getJSON("/api/events?limit=300")
|
||||
.then((d) => { setEvents(d.events.slice().reverse()); setErr(null); })
|
||||
.then((d) => { setFlow(d.events.slice().reverse()); setErr(null); })
|
||||
.catch((e) => setErr(e.message));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,27 +229,94 @@ export default function Activity() {
|
||||
return () => { clearInterval(timer.current); window.removeEventListener("focus", onFocus); };
|
||||
}, []);
|
||||
|
||||
if (err) return <p style={{ fontFamily: mono, fontSize: 12, color: C.danger }}>{err}</p>;
|
||||
if (!events) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3 }}>loading…</p>;
|
||||
if (events.length === 0) {
|
||||
return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3 }}>no activity yet. Events appear here as harnesses connect and call tools. The feed empties on restart.</p>;
|
||||
}
|
||||
// Escape closes the modal.
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === "Escape") setModal(null); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// Split the flat, newest-first feed into sessions (a `connect` event closes one).
|
||||
const sessions = useMemo(() => {
|
||||
const startTs = (g) => { const c = g.find((e) => e.kind === "connect"); return c ? c.ts : g[g.length - 1].ts; };
|
||||
const groups = []; let cur = [];
|
||||
for (const e of flow || []) { cur.push(e); if (e.kind === "connect") { groups.push(cur); cur = []; } }
|
||||
if (cur.length) groups.push(cur);
|
||||
return groups.map((events) => ({ events, startTs: startTs(events), tk: events.reduce((n, e) => n + e.tokens_in + e.tokens_out, 0) }));
|
||||
}, [flow]);
|
||||
|
||||
if (err) return <div style={{ color: C.danger, fontSize: 13.5, padding: 20 }}>Couldn't load: {err}</div>;
|
||||
|
||||
const selIdx = Math.min(selSession, Math.max(0, sessions.length - 1));
|
||||
const sel = sessions[selIdx];
|
||||
const selActive = selIdx === 0;
|
||||
const maxTk = Math.max(...sessions.map((s) => s.tk), 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ ...label, marginBottom: 10 }}>activity · newest first · empties on restart</div>
|
||||
{events.map((e) => (
|
||||
<React.Fragment key={e.id}>
|
||||
{e.kind === "connect" && (
|
||||
<div style={{ fontFamily: mono, fontSize: 10.5, color: C.t3, padding: "14px 0 4px", letterSpacing: ".06em" }}>
|
||||
session · {e.name} {e.detail} · {dayLabel(e.ts)} {fmtTime(e.ts)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginBottom: 5 }}>
|
||||
<h1 style={pageTitle}>Activity</h1>
|
||||
<span style={{ flex: 1 }} />
|
||||
<GhostBtn onClick={load}>↻ Refresh</GhostBtn>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 20px", color: C.tMuted, fontSize: 13.5, lineHeight: 1.55, maxWidth: 640 }}>
|
||||
Everything that crossed from gcontext into your agent, grouped by session. The feed lives in server memory and empties on restart.
|
||||
</p>
|
||||
|
||||
{!flow || flow.length === 0 ? (
|
||||
<EmptyState style={{ padding: "48px 28px" }}>
|
||||
<div style={{ fontFamily: mono, fontSize: 22, color: C.faint, marginBottom: 12 }}>◌</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: C.ink, marginBottom: 6 }}>No activity yet</div>
|
||||
<div style={{ fontSize: 12.5, color: C.tMuted, lineHeight: 1.6, maxWidth: 360, margin: "0 auto" }}>When a harness connects, a session opens here. Every tool call and command it makes lands under that session, in order.</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start", flexWrap: "wrap" }}>
|
||||
{/* session rail */}
|
||||
<div style={{ flex: "0 1 248px", minWidth: 220, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ ...label, padding: "0 2px 2px" }}>Sessions · {sessions.length}</div>
|
||||
{sessions.map((s, i) => (
|
||||
<SessionItem key={s.startTs + "-" + i} session={s} active={i === 0} selected={i === selIdx} maxTk={maxTk}
|
||||
onSelect={() => { setSelSession(i); setModal(null); }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* session detail */}
|
||||
<div style={{ flex: "1 1 460px", minWidth: 340 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap", marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 19, fontWeight: 600, letterSpacing: "-.01em", color: C.ink }}>{selActive ? "Active session" : `${dayLabel(sel.startTs)} · ${fmtHM(sel.startTs)}`}</span>
|
||||
{selActive && <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 10.5, fontWeight: 600, color: "#4a7c59" }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4a7c59", animation: "gcpulse 2s infinite" }} />live</span>}
|
||||
</div>
|
||||
)}
|
||||
{e.kind !== "connect" && (
|
||||
<Row e={e} open={open === e.id} onToggle={() => setOpen(open === e.id ? null : e.id)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ ...monoNum, fontSize: 12, color: C.t3, marginBottom: 16 }}>
|
||||
{selActive ? "Started" : dayLabel(sel.startTs) + " ·"} {fmtTime(sel.startTs)} · {sel.events.length} crossing{sel.events.length === 1 ? "" : "s"} · ~{nfmt(sel.tk)} tokens into context
|
||||
</div>
|
||||
|
||||
{/* legend */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap", margin: "0 2px 9px" }}>
|
||||
<span style={label}>Crossings</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{[0, 1, 2].map((tier) => (
|
||||
<span key={tier} style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11, color: C.tMuted }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 2, background: tierOf(tier).color }} />
|
||||
{tier === 0 ? "pushed" : tier === 1 ? "agent pulled" : "you pulled"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* feed */}
|
||||
<div style={{ borderRadius: 11, border: `1px solid ${C.border}`, background: "#fff", overflow: "hidden" }}>
|
||||
{sel.events.map((e, i) => (
|
||||
<Row key={`${e.id}-${i}`} e={e} first={i === 0} onOpen={() => setModal({ e })} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<Modal mobile={mobile} onClose={() => setModal(null)}>
|
||||
<EventModal e={modal.e} onClose={() => setModal(null)} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+101
-41
@@ -1,45 +1,83 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getJSON } from "./lib.js";
|
||||
import { C, mono } from "./ui.jsx";
|
||||
import { getJSON, relSeen } from "./lib.js";
|
||||
import { C, mono, UiProvider, useHover, useIsMobile } from "./ui.jsx";
|
||||
import Overview from "./Overview.jsx";
|
||||
import Connections from "./Connections.jsx";
|
||||
import Modules from "./Modules.jsx";
|
||||
import Commands from "./Commands.jsx";
|
||||
import Files from "./Files.jsx";
|
||||
import Activity from "./Activity.jsx";
|
||||
|
||||
// Read-only local dashboard for one gcontext project: a plain sidebar and
|
||||
// three views. Every view fetches fresh from /api/*; refetch on tab focus.
|
||||
// Read-only local dashboard for one gcontext project. The server holds no UI
|
||||
// state: every section fetches fresh from /api/* and refetches on tab focus.
|
||||
|
||||
const SECTIONS = ["overview", "files", "activity"];
|
||||
function NavItem({ active, label, onClick }) {
|
||||
const [h, hp] = useHover();
|
||||
return (
|
||||
<button
|
||||
{...hp}
|
||||
onClick={onClick}
|
||||
style={{ display: "flex", alignItems: "center", width: "100%", height: 36, padding: "0 10px", border: active ? `1px solid ${C.borderStrong}` : "1px solid transparent", borderRadius: 7, fontSize: 13, fontWeight: active ? 600 : 500, cursor: "pointer", marginBottom: 3, textAlign: "left", transition: "background .12s,color .12s,box-shadow .12s", background: active ? "#fff" : h ? C.rowHover : "transparent", color: active ? C.ink : C.t2, boxShadow: active ? "0 1px 2px rgba(28,27,25,.06)" : "none" }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ section, setSection, project, sessions }) {
|
||||
const lastSeen = (sessions || []).reduce((m, s) => (s.last_seen && s.last_seen > m ? s.last_seen : m), "");
|
||||
const connected = (sessions || []).length > 0;
|
||||
const nav = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "connections", label: "Connections" },
|
||||
{ key: "modules", label: "Modules" },
|
||||
{ key: "commands", label: "Commands" },
|
||||
{ key: "files", label: "Files" },
|
||||
{ key: "activity", label: "Activity" },
|
||||
];
|
||||
return (
|
||||
<nav className="gc-scroll" style={{ width: 228, height: "100%", flexShrink: 0, background: C.sidebar, borderRight: `1px solid ${C.divider}`, display: "flex", flexDirection: "column", padding: "16px 13px", overflowY: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 10px", border: `1px solid ${C.borderStrong}`, borderRadius: 8, background: "#fff", marginBottom: 10, boxShadow: "0 1px 2px rgba(28,27,25,.06)" }}>
|
||||
<img src="/icon-light-48x48.png" alt="gcontext" style={{ width: 22, height: 22, display: "block", flexShrink: 0 }} />
|
||||
<span title={project?.project_dir} style={{ fontFamily: mono, fontWeight: 600, fontSize: 13, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{project?.name || "gcontext"}</span>
|
||||
</div>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, alignSelf: "flex-start", fontFamily: mono, fontSize: 10.5, fontWeight: 600, padding: "5px 10px", borderRadius: 20, border: `1px solid ${connected ? C.okBorder : C.inputBorder}`, color: connected ? C.ok : C.t3, background: connected ? C.okBg : "#fff", marginBottom: 8 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "currentColor", display: "inline-block" }} />
|
||||
{connected ? `${sessions.length} connected` : lastSeen ? `Last seen ${relSeen(lastSeen)}` : "Not connected"}
|
||||
</span>
|
||||
|
||||
{nav.map((it) => (
|
||||
<NavItem key={it.key} active={section === it.key} label={it.label} onClick={() => setSection(it.key)} />
|
||||
))}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ padding: "10px 11px", border: `1px solid ${C.border}`, borderRadius: 8, background: C.subtle, marginBottom: 10 }}>
|
||||
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: C.tMuted }}>
|
||||
This dashboard only shows the project. Your <strong style={{ color: C.ink, fontWeight: 600 }}>agent</strong> makes the changes; secret values never appear here.
|
||||
</p>
|
||||
</div>
|
||||
{project && (
|
||||
<div style={{ padding: "0 4px", fontFamily: mono, fontSize: 10.5, color: C.t3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={project.project_dir}>
|
||||
gcontext {project.version}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTIONS = ["overview", "connections", "modules", "commands", "files", "activity"];
|
||||
const savedSection = () => {
|
||||
const s = localStorage.getItem("gc.section");
|
||||
return SECTIONS.includes(s) ? s : "overview";
|
||||
};
|
||||
|
||||
function Sidebar({ section, setSection, project, sessions }) {
|
||||
return (
|
||||
<nav style={{ width: 190, flexShrink: 0, borderRight: `1px solid ${C.border}`, padding: "28px 20px", display: "flex", flexDirection: "column", gap: 4, position: "sticky", top: 0, height: "100vh", boxSizing: "border-box" }}>
|
||||
<div style={{ fontFamily: mono, fontSize: 14, fontWeight: 600, marginBottom: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={project?.project_dir}>
|
||||
{project?.name || "gcontext"}
|
||||
</div>
|
||||
<div style={{ fontFamily: mono, fontSize: 11, color: sessions.length ? C.ok : C.t3, marginBottom: 20 }}>
|
||||
{sessions.length ? `● ${sessions.length} connected` : "○ not connected"}
|
||||
</div>
|
||||
{SECTIONS.map((s) => (
|
||||
<button key={s} onClick={() => setSection(s)}
|
||||
style={{ all: "unset", cursor: "pointer", fontFamily: mono, fontSize: 12.5, padding: "3px 0", color: section === s ? C.ink : C.t3, fontWeight: section === s ? 600 : 400 }}>
|
||||
{section === s ? "› " : " "}{s}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ flex: 1 }} />
|
||||
{project && <div style={{ fontFamily: mono, fontSize: 10.5, color: C.t3 }}>gcontext {project.version}</div>}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [section, setSection] = useState(savedSection);
|
||||
const [project, setProject] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const mobile = useIsMobile();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
useEffect(() => { localStorage.setItem("gc.section", section); }, [section]);
|
||||
|
||||
const refresh = () => {
|
||||
@@ -54,21 +92,43 @@ export default function App() {
|
||||
return () => { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onFocus); };
|
||||
}, []);
|
||||
|
||||
const go = (key) => { setSection(key); setNavOpen(false); };
|
||||
const sidebar = <Sidebar section={section} setSection={go} project={project} sessions={sessions} />;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: C.bg, color: C.ink, display: "flex" }}>
|
||||
<Sidebar section={section} setSection={setSection} project={project} sessions={sessions} />
|
||||
<main style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ maxWidth: 860, padding: "28px 32px 80px" }}>
|
||||
{err && (
|
||||
<p style={{ fontFamily: mono, fontSize: 12.5, color: C.danger, marginBottom: 20 }}>
|
||||
cannot reach the server: {err}. Is `gcontext up` running?
|
||||
</p>
|
||||
)}
|
||||
{section === "overview" && <Overview project={project} sessions={sessions} />}
|
||||
{section === "files" && <Files />}
|
||||
{section === "activity" && <Activity />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<UiProvider>
|
||||
<div style={{ position: "fixed", inset: 0, background: C.bg, display: "flex", flexDirection: mobile ? "column" : "row", overflow: "hidden", color: C.ink }}>
|
||||
{mobile ? (
|
||||
<>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 12px", background: C.sidebar, borderBottom: `1px solid ${C.divider}`, flexShrink: 0 }}>
|
||||
<button onClick={() => setNavOpen(true)} aria-label="Open menu" style={{ height: 36, padding: "0 12px", border: `1px solid ${C.border}`, borderRadius: 7, background: "#fff", color: C.ink, fontSize: 16, cursor: "pointer" }}>☰</button>
|
||||
<span style={{ fontFamily: mono, fontWeight: 600, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{project?.name || "gcontext"}</span>
|
||||
</header>
|
||||
{navOpen && (
|
||||
<div onClick={() => setNavOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(28,27,25,.32)", zIndex: 40 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ height: "100%", width: "fit-content" }}>{sidebar}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
sidebar
|
||||
)}
|
||||
<main className="gc-scroll" style={{ flex: 1, overflowY: "auto", minWidth: 0 }}>
|
||||
<div style={{ maxWidth: 1160, margin: "0 auto", padding: mobile ? "16px 14px 60px" : "22px 30px 60px" }}>
|
||||
{err && (
|
||||
<div style={{ marginBottom: 22, background: C.missFill, border: `1px solid ${C.missBorder}`, color: C.missText, borderRadius: 7, padding: "11px 13px", fontSize: 13 }}>
|
||||
Cannot reach the gcontext server: {err}. Is `gcontext up` running?
|
||||
</div>
|
||||
)}
|
||||
{section === "overview" && <Overview project={project} sessions={sessions} />}
|
||||
{section === "connections" && <Connections />}
|
||||
{section === "modules" && <Modules />}
|
||||
{section === "commands" && <Commands />}
|
||||
{section === "files" && <Files />}
|
||||
{section === "activity" && <Activity />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</UiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getJSON, filePrompt } from "./lib.js";
|
||||
import { C, mono, Chip, cardBase, cardHover, pageTitle, sectionLabel, EmptyState, useHover } from "./ui.jsx";
|
||||
import CopyPrompt from "./Copy.jsx";
|
||||
|
||||
// Commands = files under connections/*/commands/ and modules/*/commands/,
|
||||
// registered as MCP prompts. In Claude Code each one is a slash command.
|
||||
|
||||
const invocationFor = (name) => `/mcp__gcontext__${name}`;
|
||||
|
||||
function CommandCard({ cmd }) {
|
||||
const [h, hp] = useHover();
|
||||
const inv = invocationFor(cmd.name);
|
||||
return (
|
||||
<div {...hp} style={{ ...cardBase, ...(h ? cardHover : null), padding: 14, display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ width: 30, height: 23, flexShrink: 0, display: "inline-flex", alignItems: "center", justifyContent: "center", background: C.codeBg, color: C.onDark, border: `1px solid ${C.faint}`, borderRadius: 5, fontFamily: mono, fontSize: 11, fontWeight: 600 }}>/{cmd.kind}</span>
|
||||
<span title={cmd.path} style={{ flex: 1, minWidth: 0, fontFamily: mono, fontSize: 13.5, fontWeight: 600, color: C.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{cmd.name}</span>
|
||||
</div>
|
||||
{cmd.error ? (
|
||||
<p style={{ margin: 0, fontSize: 12, color: C.danger }}>Malformed frontmatter: {cmd.error}</p>
|
||||
) : cmd.description ? (
|
||||
<p style={{ margin: 0, fontSize: 12, lineHeight: 1.5, color: C.tMuted }}>{cmd.description}</p>
|
||||
) : (
|
||||
<p style={{ margin: 0, fontSize: 12, color: C.faint, fontStyle: "italic" }}>No description yet.</p>
|
||||
)}
|
||||
{(cmd.args || []).length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{cmd.args.map((a) => (
|
||||
<Chip key={a.name} tone={a.required ? "stat" : "none"} title={a.description}>{a.name}{a.required ? "*" : ""}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 10.5, color: C.t3, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={cmd.path}>{cmd.path}</span>
|
||||
<CopyPrompt icon text={filePrompt(cmd.path)} title={`Copy a prompt to read ${cmd.path}`} />
|
||||
</div>
|
||||
{!cmd.error && (
|
||||
<div style={{ marginTop: "auto", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<code style={{ flex: 1, minWidth: 0, fontFamily: mono, fontSize: 11, color: C.tMuted, background: C.subtle, border: `1px solid ${C.inputBorder}`, borderRadius: 8, padding: "6px 10px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{inv}</code>
|
||||
<CopyPrompt icon text={inv} title={`Copy ${inv}`} toast="Copied, paste it into your agent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Commands() {
|
||||
const [cmds, setCmds] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
useEffect(() => { getJSON("/api/commands").then(setCmds).catch((e) => setErr(e.message)); }, []);
|
||||
|
||||
if (err) return <div style={{ color: C.danger, fontSize: 13.5, padding: 20 }}>Couldn't load: {err}</div>;
|
||||
if (!cmds) return <div style={{ padding: "60px 0", textAlign: "center", color: C.t3, fontSize: 14 }}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ ...pageTitle, marginBottom: 5 }}>Commands</h1>
|
||||
<p style={{ margin: "0 0 20px", color: C.tMuted, fontSize: 13.5, lineHeight: 1.55, maxWidth: 640 }}>
|
||||
Files under <span style={{ fontFamily: mono }}>commands/</span> folders, served as MCP prompts. New files appear after a server restart.
|
||||
</p>
|
||||
{cmds.length === 0 ? (
|
||||
<EmptyState>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: C.ink, marginBottom: 7 }}>No commands yet</div>
|
||||
<p style={{ margin: "0 auto", maxWidth: 480, fontSize: 12.5, lineHeight: 1.6, color: C.tMuted }}>
|
||||
Drop a <span style={{ fontFamily: mono, color: C.accent }}>.md</span> (prompt) or <span style={{ fontFamily: mono, color: C.accent }}>.py</span> (script) file into <span style={{ fontFamily: mono }}>connections/<name>/commands/</span> or <span style={{ fontFamily: mono }}>modules/<name>/commands/</span> and restart the server.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ ...sectionLabel, marginBottom: 11 }}>Commands ({cmds.length})</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 13 }}>
|
||||
{cmds.map((c) => <CommandCard key={c.path} cmd={c} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getJSON, filePrompt, folderPrompt } from "./lib.js";
|
||||
import { C, mono, Chip, cardBase, cardHover, cardGrid, pageTitle, sectionLabel, EmptyState, useHover } from "./ui.jsx";
|
||||
import CopyPrompt from "./Copy.jsx";
|
||||
|
||||
// Connections = the services this agent can reach. Secret NAMES with a
|
||||
// filled/missing state; the values live in secrets.env on this machine.
|
||||
// Every file row copies an agent-ready prompt pointing at the path inside
|
||||
// the gcontext MCP server.
|
||||
|
||||
function FileRow({ path }) {
|
||||
const [h, hp] = useHover();
|
||||
return (
|
||||
<div {...hp} style={{ display: "flex", alignItems: "center", gap: 8, padding: "3px 0" }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 11.5, color: h ? C.ink : C.t2, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", transition: "color .12s" }} title={path}>{path}</span>
|
||||
<CopyPrompt icon text={filePrompt(path)} title={`Copy a prompt to read ${path}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionCard({ conn }) {
|
||||
const [h, hp] = useHover();
|
||||
const folder = `connections/${conn.name}/`;
|
||||
return (
|
||||
<div {...hp} style={{ ...cardBase, ...(h ? cardHover : null), padding: 15, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 14.5, fontWeight: 600, color: C.ink, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{conn.name}</span>
|
||||
<Chip tone={conn.ready ? "ok" : "miss"}>{conn.ready ? "ready" : "missing secrets"}</Chip>
|
||||
</div>
|
||||
{conn.description && <p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: C.tMuted }}>{conn.description}</p>}
|
||||
{conn.secrets.length > 0 && (
|
||||
<div>
|
||||
<div style={{ ...sectionLabel, fontSize: 9.5, marginBottom: 5 }}>Secrets</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{conn.secrets.map((s) => (
|
||||
<Chip key={s.name} tone={s.filled ? "ok" : "miss"}>{s.name}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{conn.deps.length > 0 && (
|
||||
<div style={{ fontFamily: mono, fontSize: 11, color: C.t3 }}>deps: {conn.deps.join(", ")}</div>
|
||||
)}
|
||||
{conn.files.length > 0 && (
|
||||
<div>
|
||||
<div style={{ ...sectionLabel, fontSize: 9.5, marginBottom: 5 }}>Context files</div>
|
||||
{conn.files.map((f) => <FileRow key={f} path={f} />)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: "auto", paddingTop: 4 }}>
|
||||
<CopyPrompt text={folderPrompt(folder)} title={`Copy a prompt to explore ${folder}`} style={{ width: "100%", justifyContent: "center" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Connections() {
|
||||
const [conns, setConns] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
useEffect(() => { getJSON("/api/connections").then(setConns).catch((e) => setErr(e.message)); }, []);
|
||||
|
||||
if (err) return <div style={{ color: C.danger, fontSize: 13.5, padding: 20 }}>Couldn't load: {err}</div>;
|
||||
if (!conns) return <div style={{ padding: "60px 0", textAlign: "center", color: C.t3, fontSize: 14 }}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ ...pageTitle, marginBottom: 5 }}>Connections</h1>
|
||||
<p style={{ margin: "0 0 20px", color: C.tMuted, fontSize: 13.5, lineHeight: 1.55, maxWidth: 640 }}>
|
||||
Services the agent can reach. Each declares the secret names and Python deps it needs; secret values stay in <span style={{ fontFamily: mono }}>secrets.env</span> on this machine.
|
||||
</p>
|
||||
{conns.length === 0 ? (
|
||||
<EmptyState>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: C.ink, marginBottom: 7 }}>No connections yet</div>
|
||||
<p style={{ margin: "0 auto", maxWidth: 460, fontSize: 12.5, lineHeight: 1.6, color: C.tMuted }}>
|
||||
Add one under <span style={{ fontFamily: mono, color: C.accent }}>connections/<service>/connection.yaml</span> with the secret names and deps, plus an <span style={{ fontFamily: mono }}>index.md</span> describing the API in your words.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div style={{ ...cardGrid, gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))" }}>
|
||||
{conns.map((c) => <ConnectionCard key={c.name} conn={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { C, mono, useHover, useUi } from "./ui.jsx";
|
||||
import { copyText } from "./lib.js";
|
||||
|
||||
// The one action surface in the app: copy a prompt for the agent, fire a
|
||||
// toast. The dashboard SEES the project; the agent (via MCP) USES it, so
|
||||
// every action hands an agent-ready prompt to the clipboard.
|
||||
// full pill -> <CopyPrompt text=… /> (⧉ Copy prompt, terracotta)
|
||||
// icon only -> <CopyPrompt text=… icon /> (26x26 ⧉, list rows)
|
||||
export default function CopyPrompt({ text, label = "Copy prompt", toast = "Copied, paste it into your agent", title, icon, style }) {
|
||||
const ui = useUi();
|
||||
const [h, hp] = useHover();
|
||||
const copy = (e) => {
|
||||
e?.stopPropagation?.();
|
||||
copyText(text);
|
||||
ui.toast(toast);
|
||||
};
|
||||
if (icon) {
|
||||
return (
|
||||
<button {...hp} onClick={copy} title={title || label}
|
||||
style={{ width: 26, height: 26, flexShrink: 0, display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 13, lineHeight: 1, borderRadius: 7, border: `1px solid ${C.accentBorder}`, background: h ? C.accentBgHover : C.accentBg, color: C.accent, cursor: "pointer", transition: "all .12s", ...style }}>⧉</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button {...hp} onClick={copy} title={title}
|
||||
style={{ flexShrink: 0, display: "inline-flex", alignItems: "center", gap: 7, fontFamily: mono, fontSize: 12.5, fontWeight: 600, lineHeight: 1, padding: "9px 15px", borderRadius: 9, border: `1px solid ${h ? C.accent : C.accentBorderStrong}`, background: h ? C.accentBgHover : C.accentBg, color: C.accent, cursor: "pointer", transition: "all .12s", whiteSpace: "nowrap", ...style }}>
|
||||
<span style={{ fontSize: 13, lineHeight: 1 }}>⧉</span> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+63
-33
@@ -2,12 +2,15 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { getJSON } from "./lib.js";
|
||||
import { C, mono, label } from "./ui.jsx";
|
||||
import { getJSON, fileLabel, refPrompt } from "./lib.js";
|
||||
import { C, mono, pageTitle, sectionLabel, EmptyState, useHover, FileGlyph } from "./ui.jsx";
|
||||
import CopyPrompt from "./Copy.jsx";
|
||||
|
||||
// Files = read-only browser over the project folder. Left: the tree from
|
||||
// /api/tree (secrets.env and machine folders are excluded server-side).
|
||||
// /api/tree (secrets.env and machine folders are already excluded server-side).
|
||||
// Right: the selected file, markdown rendered, everything else plain text.
|
||||
// Every row and the reading pane can copy an agent-ready prompt pointing at
|
||||
// the path inside the gcontext MCP server.
|
||||
|
||||
function buildTree(entries) {
|
||||
const roots = [];
|
||||
@@ -28,15 +31,23 @@ function buildTree(entries) {
|
||||
}
|
||||
|
||||
function TreeRow({ node, depth, selected, open, onToggle, onSelect }) {
|
||||
const [h, hp] = useHover();
|
||||
const isSel = selected === node.path;
|
||||
const ref = node.dir ? `${node.path}/` : node.path;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => (node.dir ? onToggle(node.path) : onSelect(node.path))}
|
||||
title={node.path}
|
||||
style={{ all: "unset", cursor: "pointer", display: "block", width: "100%", boxSizing: "border-box", padding: "2px 0", paddingLeft: depth * 14, fontFamily: mono, fontSize: 12, lineHeight: 1.7, color: node.dir ? C.ink : isSel ? C.accent : C.t2, fontWeight: node.dir || isSel ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{node.dir ? (open.has(node.path) ? "▾ " : "▸ ") : " "}{node.name}{node.dir ? "/" : ""}
|
||||
</button>
|
||||
<div {...hp} style={{ display: "flex", alignItems: "center", gap: 6, borderRadius: 6, background: isSel ? "#fff" : h ? C.rowHover : "transparent", boxShadow: isSel ? "0 1px 2px rgba(28,27,25,.06)" : "none", transition: "background .1s", paddingRight: 6 }}>
|
||||
<button
|
||||
onClick={() => (node.dir ? onToggle(node.path) : onSelect(node.path))}
|
||||
title={node.path}
|
||||
style={{ all: "unset", cursor: "pointer", flex: 1, minWidth: 0, boxSizing: "border-box", padding: "5px 0 5px 8px", paddingLeft: 8 + depth * 14, display: "flex", alignItems: "center", gap: 7 }}>
|
||||
{node.dir
|
||||
? <span style={{ fontSize: 9, color: C.t3, width: 10, flexShrink: 0 }}>{open.has(node.path) ? "▾" : "▸"}</span>
|
||||
: <span style={{ width: 10, flexShrink: 0 }} />}
|
||||
<span style={{ fontFamily: mono, fontSize: 12, fontWeight: node.dir ? 600 : 400, color: node.dir ? C.tFolder : C.t2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>{node.name}{node.dir ? "/" : ""}</span>
|
||||
</button>
|
||||
{h && <CopyPrompt icon text={refPrompt(ref, node.dir)} title={`Copy a prompt for ${ref}`} style={{ width: 22, height: 22 }} />}
|
||||
</div>
|
||||
{node.dir && open.has(node.path) && node.children.map((c) => (
|
||||
<TreeRow key={c.path} node={c} depth={depth + 1} selected={selected} open={open} onToggle={onToggle} onSelect={onSelect} />
|
||||
))}
|
||||
@@ -53,22 +64,35 @@ function Viewer({ path }) {
|
||||
getJSON(`/api/file?path=${encodeURIComponent(path)}`).then(setFile).catch((e) => setErr(e.message));
|
||||
}, [path]);
|
||||
|
||||
if (!path) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, margin: 0 }}>pick a file on the left</p>;
|
||||
if (err) return <p style={{ fontFamily: mono, fontSize: 12, color: C.danger, margin: 0 }}>{path}: {err}</p>;
|
||||
if (!file) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, margin: 0 }}>loading…</p>;
|
||||
if (!path) {
|
||||
return (
|
||||
<EmptyState style={{ padding: "56px 28px" }}>
|
||||
<div style={{ fontFamily: mono, fontSize: 22, color: C.faint, marginBottom: 12 }}>◌</div>
|
||||
<div style={{ fontSize: 13.5 }}>Pick a file on the left to read it.</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
if (err) return <div style={{ color: C.danger, fontSize: 13, padding: 18 }}>Couldn't read {path}: {err}</div>;
|
||||
if (!file) return <div style={{ padding: "40px 0", textAlign: "center", color: C.t3, fontSize: 13 }}>Loading…</div>;
|
||||
|
||||
const isMd = path.endsWith(".md");
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, marginBottom: 12, borderBottom: `1px solid ${C.borderInner}`, paddingBottom: 8 }}>
|
||||
{file.path} · {file.size} B
|
||||
<div style={{ border: `1px solid ${C.border}`, borderRadius: 11, background: "#fff", overflow: "hidden", animation: "gcpop .16s ease-out" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "11px 16px", borderBottom: `1px solid ${C.borderInner}`, background: C.subtle }}>
|
||||
<FileGlyph w={16} />
|
||||
<span style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, color: C.ink, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.path}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 10.5, color: C.t3, flexShrink: 0 }}>{fileLabel(path)} · {file.size} B</span>
|
||||
<CopyPrompt text={refPrompt(file.path, false)} title={`Copy a prompt to read ${file.path}`} style={{ padding: "6px 12px" }} />
|
||||
</div>
|
||||
<div style={{ padding: "18px 20px" }}>
|
||||
{isMd ? (
|
||||
<div className="gc-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{file.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="gc-scroll" style={{ margin: 0, fontFamily: mono, fontSize: 12, lineHeight: 1.7, color: C.ink, whiteSpace: "pre-wrap", overflow: "auto", maxHeight: "70vh" }}>{file.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
{path.endsWith(".md") ? (
|
||||
<div className="gc-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{file.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="gc-scroll" style={{ margin: 0, fontFamily: mono, fontSize: 12, lineHeight: 1.7, whiteSpace: "pre-wrap", overflow: "auto" }}>{file.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,19 +111,25 @@ export default function Files() {
|
||||
return next;
|
||||
});
|
||||
|
||||
if (err) return <p style={{ fontFamily: mono, fontSize: 12, color: C.danger }}>{err}</p>;
|
||||
if (!entries) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3 }}>loading…</p>;
|
||||
if (err) return <div style={{ color: C.danger, fontSize: 13.5, padding: 20 }}>Couldn't load: {err}</div>;
|
||||
if (!entries) return <div style={{ padding: "60px 0", textAlign: "center", color: C.t3, fontSize: 14 }}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 32, alignItems: "flex-start", flexWrap: "wrap" }}>
|
||||
<div className="gc-scroll" style={{ flex: "0 1 220px", minWidth: 180, maxHeight: "75vh", overflowY: "auto" }}>
|
||||
<div style={{ ...label, marginBottom: 8 }}>project</div>
|
||||
{roots.map((n) => (
|
||||
<TreeRow key={n.path} node={n} depth={0} selected={selected} open={open} onToggle={toggle} onSelect={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: "1 1 420px", minWidth: 300 }}>
|
||||
<Viewer path={selected} />
|
||||
<div>
|
||||
<h1 style={{ ...pageTitle, marginBottom: 5 }}>Files</h1>
|
||||
<p style={{ margin: "0 0 20px", color: C.tMuted, fontSize: 13.5, lineHeight: 1.55, maxWidth: 640 }}>
|
||||
The project folder as the agent sees it. Hover a row to copy a prompt that points your agent at that file or folder. <span style={{ fontFamily: mono }}>secrets.env</span> and machine folders never appear here.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start", flexWrap: "wrap" }}>
|
||||
<div className="gc-scroll" style={{ flex: "0 1 280px", minWidth: 230, maxHeight: "72vh", overflowY: "auto", border: `1px solid ${C.border}`, borderRadius: 11, background: C.sidebar, padding: 8 }}>
|
||||
<div style={{ ...sectionLabel, padding: "4px 8px 8px" }}>Project</div>
|
||||
{roots.map((n) => (
|
||||
<TreeRow key={n.path} node={n} depth={0} selected={selected} open={open} onToggle={toggle} onSelect={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: "1 1 460px", minWidth: 320 }}>
|
||||
<Viewer path={selected} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getJSON, filePrompt, folderPrompt } from "./lib.js";
|
||||
import { C, mono, Chip, cardBase, cardHover, cardGrid, pageTitle, sectionLabel, EmptyState, useHover } from "./ui.jsx";
|
||||
import CopyPrompt from "./Copy.jsx";
|
||||
|
||||
// Modules = reusable folders of knowledge (docs, scripts, commands) the agent
|
||||
// reads on demand. Every file row copies an agent-ready prompt pointing at
|
||||
// the path inside the gcontext MCP server.
|
||||
|
||||
function FileRow({ path }) {
|
||||
const [h, hp] = useHover();
|
||||
return (
|
||||
<div {...hp} style={{ display: "flex", alignItems: "center", gap: 8, padding: "3px 0" }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 11.5, color: h ? C.ink : C.t2, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", transition: "color .12s" }} title={path}>{path}</span>
|
||||
<CopyPrompt icon text={filePrompt(path)} title={`Copy a prompt to read ${path}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleCard({ mod }) {
|
||||
const [h, hp] = useHover();
|
||||
const folder = `modules/${mod.name}/`;
|
||||
return (
|
||||
<div {...hp} style={{ ...cardBase, ...(h ? cardHover : null), padding: 15, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap" }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 14.5, fontWeight: 600, color: C.ink }}>{mod.name}</span>
|
||||
<Chip>v{mod.version}</Chip>
|
||||
{(mod.tags || []).map((t) => <Chip key={t} tone="stat">{t}</Chip>)}
|
||||
</div>
|
||||
{mod.description && <p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: C.tMuted }}>{mod.description}</p>}
|
||||
{mod.files.length > 0 && (
|
||||
<div>
|
||||
<div style={{ ...sectionLabel, fontSize: 9.5, marginBottom: 5 }}>Files</div>
|
||||
{mod.files.map((f) => <FileRow key={f} path={f} />)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: "auto", paddingTop: 4 }}>
|
||||
<CopyPrompt text={folderPrompt(folder)} title={`Copy a prompt to explore ${folder}`} style={{ width: "100%", justifyContent: "center" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Modules() {
|
||||
const [mods, setMods] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
useEffect(() => { getJSON("/api/modules").then(setMods).catch((e) => setErr(e.message)); }, []);
|
||||
|
||||
if (err) return <div style={{ color: C.danger, fontSize: 13.5, padding: 20 }}>Couldn't load: {err}</div>;
|
||||
if (!mods) return <div style={{ padding: "60px 0", textAlign: "center", color: C.t3, fontSize: 14 }}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ ...pageTitle, marginBottom: 5 }}>Modules</h1>
|
||||
<p style={{ margin: "0 0 20px", color: C.tMuted, fontSize: 13.5, lineHeight: 1.55, maxWidth: 640 }}>
|
||||
Reusable folders of knowledge and scripts under <span style={{ fontFamily: mono }}>modules/</span>, read by the agent on demand.
|
||||
</p>
|
||||
{mods.length === 0 ? (
|
||||
<EmptyState>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: C.ink, marginBottom: 7 }}>No modules yet</div>
|
||||
<p style={{ margin: "0 auto", maxWidth: 460, fontSize: 12.5, lineHeight: 1.6, color: C.tMuted }}>
|
||||
Create a folder under <span style={{ fontFamily: mono, color: C.accent }}>modules/<name>/</span> with the docs or scripts the agent should keep.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div style={{ ...cardGrid, gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))" }}>
|
||||
{mods.map((m) => <ModuleCard key={m.name} mod={m} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+90
-115
@@ -1,129 +1,104 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getJSON, copyText, relSeen } from "./lib.js";
|
||||
import { C, mono, label } from "./ui.jsx";
|
||||
import { getJSON, copyText } from "./lib.js";
|
||||
import { C, mono, Chip, sectionLabel, pageTitle, useUi } from "./ui.jsx";
|
||||
import CopyPrompt from "./Copy.jsx";
|
||||
|
||||
// Overview = the whole project on one page: sessions, how to connect,
|
||||
// connections, modules, commands, and the context ledger. Plain lists.
|
||||
// Overview = what this project is, who is attached, and the context ledger:
|
||||
// every pipe that inserts context into the agent, in load order.
|
||||
|
||||
function CopyLink({ text }) {
|
||||
const [done, setDone] = useState(false);
|
||||
const STATUS_TONE = { loaded: "ok", "on demand": "none", skipped: "none", uncontrolled: "stat" };
|
||||
|
||||
function Sessions({ sessions }) {
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return <div style={{ fontSize: 12.5, color: C.t3 }}>No harness connected yet. Attach one with the snippets below.</div>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={() => { copyText(text); setDone(true); setTimeout(() => setDone(false), 1200); }}
|
||||
style={{ all: "unset", cursor: "pointer", fontFamily: mono, fontSize: 11, color: done ? C.ok : C.accent, flexShrink: 0 }}>
|
||||
{done ? "copied" : "copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 30 }}>
|
||||
<div style={{ ...label, marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const row = { display: "flex", alignItems: "baseline", gap: 10, padding: "6px 0", borderTop: `1px solid ${C.borderInner}`, fontSize: 13, flexWrap: "wrap" };
|
||||
const dim = { fontFamily: mono, fontSize: 11.5, color: C.t3 };
|
||||
|
||||
export default function Overview({ project, sessions }) {
|
||||
const [conns, setConns] = useState([]);
|
||||
const [mods, setMods] = useState([]);
|
||||
const [cmds, setCmds] = useState([]);
|
||||
const [ledger, setLedger] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getJSON("/api/connections").then(setConns).catch(() => {});
|
||||
getJSON("/api/modules").then(setMods).catch(() => {});
|
||||
getJSON("/api/commands").then(setCmds).catch(() => {});
|
||||
getJSON("/api/ledger").then((d) => setLedger(d.ledger)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!project) return <p style={{ ...dim }}>loading…</p>;
|
||||
|
||||
const url = `${location.origin}/mcp`;
|
||||
const connectCmd = `claude mcp add --transport http ${project.name} ${url}`;
|
||||
const archived = Object.entries(project.archived || {}).map(([cat, items]) => `${items.length} ${cat}`).join(", ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: "0 0 4px", fontSize: 13.5, color: C.tMuted, maxWidth: 620, lineHeight: 1.6 }}>
|
||||
{project.description || "No description in gcontext.yaml."}
|
||||
</p>
|
||||
<p style={{ ...dim, margin: "0 0 30px" }}>{project.project_dir}</p>
|
||||
|
||||
<Section title="sessions">
|
||||
{sessions.length === 0 && <p style={{ ...dim, margin: 0 }}>none. Attach a harness with the command below</p>}
|
||||
{sessions.map((s, i) => (
|
||||
<div key={s.id || i} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
|
||||
<span style={{ fontFamily: mono, fontWeight: 600 }}>{s.client}</span>
|
||||
<span style={dim}>{s.version}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={dim}>last activity {relSeen(s.last_seen)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, marginTop: 12 }}>
|
||||
<code style={{ fontFamily: mono, fontSize: 11.5, color: C.t2, overflowX: "auto", whiteSpace: "nowrap" }}>{connectCmd}</code>
|
||||
<CopyLink text={connectCmd} />
|
||||
<div style={{ border: `1px solid ${C.border}`, borderRadius: 10, background: "#fff", overflow: "hidden" }}>
|
||||
{sessions.map((s, i) => (
|
||||
<div key={s.id || i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderTop: i ? `1px solid ${C.borderInner}` : "none", flexWrap: "wrap" }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4a7c59", flexShrink: 0, animation: "gcpulse 2s infinite" }} />
|
||||
<span style={{ fontFamily: mono, fontSize: 13, fontWeight: 600, color: C.ink }}>{s.client}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 11, color: C.t3 }}>{s.version}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontFamily: mono, fontSize: 11, color: C.t3 }}>connected {s.connected} · last activity {s.last_seen}</span>
|
||||
</div>
|
||||
<p style={{ ...dim, margin: "6px 0 0" }}>any MCP client: {url}</p>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Section title={`connections · ${conns.length}`}>
|
||||
{conns.length === 0 && <p style={{ ...dim, margin: 0 }}>none. Add connections/<service>/connection.yaml</p>}
|
||||
{conns.map((c, i) => (
|
||||
<div key={c.name} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
|
||||
<span style={{ fontFamily: mono, fontWeight: 600 }}>{c.name}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 11.5, color: c.ready ? C.ok : C.danger }}>
|
||||
{c.ready ? "ready" : "missing " + c.secrets.filter((s) => !s.filled).map((s) => s.name).join(", ")}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: C.tMuted, flex: 1 }}>{c.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
function ConnectSnippets({ name }) {
|
||||
const ui = useUi();
|
||||
const url = `${location.origin}/mcp`;
|
||||
const snippets = [
|
||||
{ label: "Claude Code", text: `claude mcp add --transport http ${name} ${url}` },
|
||||
{ label: "Cursor (~/.cursor/mcp.json)", text: JSON.stringify({ mcpServers: { [name]: { url } } }, null, 2) },
|
||||
{ label: "Codex (~/.codex/config.toml)", text: `[mcp_servers.${name}]\nurl = "${url}"` },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
{snippets.map((s) => (
|
||||
<div key={s.label} style={{ display: "flex", alignItems: "center", gap: 10, border: `1px solid ${C.border}`, borderRadius: 9, background: "#fff", padding: "9px 12px", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: C.t2, width: 220, flexShrink: 0 }}>{s.label}</span>
|
||||
<code className="gc-scroll" style={{ fontFamily: mono, fontSize: 11.5, color: C.ink, flex: 1, minWidth: 200, overflow: "auto", whiteSpace: "pre" }}>{s.text}</code>
|
||||
<CopyPrompt icon text={s.text} title="Copy" toast="Copied, run it in your terminal" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Section title={`modules · ${mods.length}`}>
|
||||
{mods.length === 0 && <p style={{ ...dim, margin: 0 }}>none</p>}
|
||||
{mods.map((m, i) => (
|
||||
<div key={m.name} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
|
||||
<span style={{ fontFamily: mono, fontWeight: 600 }}>{m.name}</span>
|
||||
<span style={dim}>v{m.version}{m.tags?.length ? " · " + m.tags.join(", ") : ""}</span>
|
||||
<span style={{ fontSize: 12.5, color: C.tMuted, flex: 1 }}>{m.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
function Ledger() {
|
||||
const [ledger, setLedger] = useState([]);
|
||||
useEffect(() => { getJSON("/api/ledger").then((d) => setLedger(d.ledger)).catch(() => {}); }, []);
|
||||
return (
|
||||
<div style={{ border: `1px solid ${C.border}`, borderRadius: 10, background: "#fff", overflow: "hidden" }}>
|
||||
{ledger.map((p, i) => (
|
||||
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 14px", borderTop: i ? `1px solid ${C.borderInner}` : "none", flexWrap: "wrap" }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 11, fontWeight: 600, color: C.t3, width: 24, flexShrink: 0 }}>{p.id}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, color: C.ink, width: 210, flexShrink: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.label}</span>
|
||||
<Chip tone={STATUS_TONE[p.status] || "none"}>{p.status}</Chip>
|
||||
<span style={{ fontSize: 12, color: C.tMuted, flex: 1, minWidth: 180 }}>{p.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Section title={`commands · ${cmds.length}`}>
|
||||
{cmds.length === 0 && <p style={{ ...dim, margin: 0 }}>none. Drop .md or .py files into a commands/ folder</p>}
|
||||
{cmds.map((c, i) => (
|
||||
<div key={c.path} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
|
||||
<span style={{ fontFamily: mono, fontWeight: 600 }}>{c.name}</span>
|
||||
{c.error
|
||||
? <span style={{ fontSize: 12, color: C.danger }}>malformed: {c.error}</span>
|
||||
: <span style={{ fontSize: 12.5, color: C.tMuted, flex: 1 }}>{c.description}</span>}
|
||||
{!c.error && <CopyLink text={`/mcp__gcontext__${c.name}`} />}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
export default function Overview({ project, sessions }) {
|
||||
if (!project) return <div style={{ padding: "60px 0", textAlign: "center", color: C.t3, fontSize: 14 }}>Loading…</div>;
|
||||
const archived = project.archived || {};
|
||||
const archivedParts = Object.entries(archived).map(([cat, items]) => `${items.length} ${cat}`);
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ ...pageTitle, marginBottom: 5 }}>{project.name}</h1>
|
||||
<p style={{ margin: "0 0 6px", color: C.tMuted, fontSize: 13.5, lineHeight: 1.55, maxWidth: 640 }}>
|
||||
{project.description || "No description in gcontext.yaml yet."}
|
||||
</p>
|
||||
<div style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, marginBottom: 24 }}>{project.project_dir}</div>
|
||||
|
||||
<div style={{ ...sectionLabel, marginBottom: 9 }}>Connected harnesses</div>
|
||||
<Sessions sessions={sessions} />
|
||||
|
||||
<Section title="context ledger">
|
||||
{ledger.map((p, i) => (
|
||||
<div key={p.id} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
|
||||
<span style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, width: 22, flexShrink: 0 }}>{p.id}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 12.5, width: 200, flexShrink: 0 }}>{p.label}</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 11.5, flexShrink: 0, color: p.status === "loaded" ? C.ok : p.status === "uncontrolled" ? C.amber : C.t3 }}>{p.status}</span>
|
||||
<span style={{ fontSize: 12, color: C.tMuted, flex: 1, minWidth: 160 }}>{p.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
<div style={{ ...sectionLabel, margin: "26px 0 9px" }}>Connect a harness</div>
|
||||
<ConnectSnippets name={project.name} />
|
||||
|
||||
<p style={{ ...dim, margin: 0 }}>
|
||||
{project.has_instructions ? `instructions.md · ${project.instructions_lines} lines` : "no instructions.md"}
|
||||
{archived ? ` · archive: ${archived}` : ""}
|
||||
{` · gcontext ${project.version}`}
|
||||
<div style={{ ...sectionLabel, margin: "26px 0 0" }}>Context ledger</div>
|
||||
<p style={{ margin: "6px 0 10px", fontSize: 12.5, color: C.tMuted, maxWidth: 620, lineHeight: 1.55 }}>
|
||||
Everything that enters the agent's context from this server, and how.
|
||||
</p>
|
||||
<Ledger />
|
||||
|
||||
{(project.has_instructions || archivedParts.length > 0) && (
|
||||
<div style={{ marginTop: 22, display: "flex", flexDirection: "column", gap: 6, fontSize: 12.5, color: C.tMuted }}>
|
||||
{project.has_instructions && (
|
||||
<span>System prompt: <span style={{ fontFamily: mono }}>instructions.md</span> ({project.instructions_lines} lines)</span>
|
||||
)}
|
||||
{archivedParts.length > 0 && (
|
||||
<span><span style={{ fontFamily: mono }}>archive/</span>: {archivedParts.join(", ")} (not scanned, readable by path)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,3 +43,13 @@ textarea, input, button { font-family: inherit; }
|
||||
.gc-md th { background: #F7F5F1; font-weight: 600; }
|
||||
.gc-md img { max-width: 100%; }
|
||||
.gc-md hr { border: none; border-top: 1px solid #ECE8E1; margin: 1.4em 0; }
|
||||
|
||||
@keyframes gcpulse { 0%,100% { box-shadow: 0 0 0 0 rgba(74,124,89,.45); } 50% { box-shadow: 0 0 0 4px rgba(74,124,89,0); } }
|
||||
@keyframes gcpop { from { opacity: 0; transform: translateY(8px) scale(.99); } to { opacity: 1; transform: none; } }
|
||||
@keyframes cbNew { 0%,100% { opacity: .85; } 50% { opacity: .2; } }
|
||||
@keyframes gcMapIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
/* FolderView reading pane swap: two identical names, alternated to restart on each selection */
|
||||
@keyframes sheetInA { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes sheetInB { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes subsIn { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes gcPulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(.65); opacity: .55; } }
|
||||
|
||||
+14
-1
@@ -1,4 +1,4 @@
|
||||
// The whole data seam: every view reads the local server's /api/* routes.
|
||||
// The whole data seam: every page reads the local server's /api/* routes.
|
||||
// The dashboard is read-only; the agent (via MCP) is what changes the project.
|
||||
|
||||
export async function getJSON(path) {
|
||||
@@ -21,6 +21,19 @@ export function copyText(text) {
|
||||
ta.remove();
|
||||
}
|
||||
|
||||
// Agent-ready prompts for a file or folder reference. The copied text names
|
||||
// the gcontext MCP server, so any agent can locate the path without guessing
|
||||
// which server or tool it belongs to.
|
||||
export const filePrompt = (path) =>
|
||||
`From the gcontext MCP server, read "${path}" and use it as context for this task.`;
|
||||
export const folderPrompt = (path) =>
|
||||
`From the gcontext MCP server, explore the folder "${path.replace(/\/$/, "")}/": check its files and read the relevant ones.`;
|
||||
export const refPrompt = (path, isDir) => (isDir ? folderPrompt(path) : filePrompt(path));
|
||||
|
||||
// File-card label: "notes.md" -> "md", extensionless -> "file". Dotfiles (".env")
|
||||
// stay "file" (lastIndexOf > 0), so the label never repeats the whole name.
|
||||
export const fileLabel = (name) => { const i = (name || "").lastIndexOf("."); return i > 0 ? name.slice(i + 1).toLowerCase() : "file"; };
|
||||
|
||||
// "3h ago" / "2d ago": the one relative-time format for last-seen surfaces.
|
||||
export const relSeen = (iso) => {
|
||||
const d = iso ? (Date.now() - new Date(iso).getTime()) / 86400000 : Infinity;
|
||||
|
||||
+231
-30
@@ -1,40 +1,241 @@
|
||||
// Minimal design tokens: warm paper background, ink text, IBM Plex.
|
||||
// Everything else is plain elements styled inline where they are used.
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { copyText } from "./lib.js";
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Warm-paper / monospace-accent design tokens (see design handoff README).
|
||||
export const C = {
|
||||
bg: "#efece8",
|
||||
panel: "#fff",
|
||||
subtle: "#faf8f3",
|
||||
ink: "#1f1d1a",
|
||||
t2: "#4A4842",
|
||||
tMuted: "rgba(0,0,0,.55)",
|
||||
t3: "rgba(0,0,0,.45)",
|
||||
border: "#e6e1d6",
|
||||
borderInner: "#eee7da",
|
||||
accent: "#c2603a",
|
||||
ok: "#3d6b4a",
|
||||
danger: "#a8492a",
|
||||
amber: "#8a6d2e",
|
||||
// surfaces
|
||||
bg: "#efece8", panel: "#fff", subtle: "#faf8f3", sidebar: "#f6f3ec",
|
||||
soft: "#efe9dd", rowHover: "#f7f3ec",
|
||||
// ink + text
|
||||
ink: "#1f1d1a", inkHover: "#000",
|
||||
tFolder: "#33312c", t2: "#4A4842", tMuted: "rgba(0,0,0,.55)", t3: "rgba(0,0,0,.45)", tLabel: "rgba(0,0,0,.4)",
|
||||
faint: "#c7c0af", disabled: "#cdc6b8",
|
||||
// borders
|
||||
border: "#e6e1d6", borderStrong: "#d9d4c8", borderInner: "#eee7da", borderRow: "#f1ece1", inputBorder: "#ddd7cb",
|
||||
divider: "#e4ded2",
|
||||
// terracotta accent (primary "copy a prompt" action)
|
||||
accent: "#c2603a", accentHover: "#ad5230", accentBg: "#fff6ef", accentBg2: "#fffaf4", accentBorder: "#e0b89f", accentSoft: "#f7f1ea",
|
||||
accentBgHover: "#fbeadf", accentBorderStrong: "#d99a7a",
|
||||
// success / present / connected
|
||||
ok: "#3d6b4a", okBg: "#eef5ef", okBorder: "#9cbfa6",
|
||||
// error / missing
|
||||
danger: "#a8492a", dangerHover: "#8f3a20", dangerFill: "#fbf2ee", dangerBorder: "#d3a896",
|
||||
missFill: "#fbf2ee", missBorder: "#d3a896", missText: "#a8492a",
|
||||
// status (in progress / needs reply)
|
||||
amber: "#8a6d2e", amberBg: "#f6efdf", amberBorder: "#c9b48a",
|
||||
// code block
|
||||
onDark: "#e7dfd1", codeBg: "#2c2825", codeText: "#e7dfd1",
|
||||
// markdown presentation (peek panel)
|
||||
factBg: "#fdfcf9", calloutDangerBorder: "#ecd3c6", stepNextBorder: "#ecd0bc",
|
||||
codeDim: "rgba(231,223,209,.5)", codeRule: "rgba(231,223,209,.12)",
|
||||
// folder glyph
|
||||
glyph: "#c7c0af",
|
||||
};
|
||||
|
||||
export const mono = "'IBM Plex Mono', ui-monospace, Menlo, monospace";
|
||||
|
||||
// Uppercase section label.
|
||||
export const label = { fontFamily: mono, fontSize: 11, fontWeight: 600, letterSpacing: ".09em", textTransform: "uppercase", color: C.t3 };
|
||||
// Canonical card + section styling. Workspace is the reference; every card grid
|
||||
// points at these so radius/hover/label never drift per-view.
|
||||
export const sectionLabel = { fontFamily: mono, fontSize: 11, fontWeight: 600, letterSpacing: ".09em", textTransform: "uppercase", color: C.tLabel };
|
||||
// The one page-title style. Callers add their own margin.
|
||||
export const pageTitle = { margin: 0, fontSize: 24, fontWeight: 600, letterSpacing: "-.02em", color: C.ink };
|
||||
export const cardBase = { borderRadius: 10, border: `1px solid ${C.border}`, background: "#fff", transition: "all .12s" };
|
||||
// Full `border` shorthand, NOT the borderColor longhand: React's style diffing mishandles
|
||||
// a longhand overriding a shorthand — on unhover it drops borderColor without re-expanding
|
||||
// cardBase's border, leaving a colorless (= currentColor, ink) 1px border on the card.
|
||||
export const cardHover = { border: `1px solid ${C.borderStrong}`, boxShadow: "0 6px 18px -14px rgba(28,27,25,.4)" };
|
||||
export const cardGrid = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(232px, 1fr))", gap: 13 };
|
||||
|
||||
// The one copy affordance: a small text link that flips to "copied".
|
||||
// Used for connect commands, slash commands, and file/folder references.
|
||||
export function CopyLink({ text, children, style }) {
|
||||
const [done, setDone] = useState(false);
|
||||
// Underline tab bar shared by the instance modal and Setup
|
||||
// (Connection/Secrets) destinations. tabs: [{ key, label }].
|
||||
export function Tabs({ tabs, active, onChange, style }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 26, borderBottom: `1px solid ${C.border}`, marginBottom: 22, ...style }}>
|
||||
{tabs.map((t) => {
|
||||
const on = t.key === active;
|
||||
return (
|
||||
<button key={t.key} onClick={() => onChange(t.key)}
|
||||
style={{ all: "unset", cursor: "pointer", display: "inline-flex", alignItems: "center", padding: "0 1px 10px", marginBottom: -1, fontSize: 14, fontWeight: 600, color: on ? C.ink : C.t3, borderBottom: `2px solid ${on ? C.accent : "transparent"}` }}>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phone-width check for the inline-style layout (no CSS classes to media-query).
|
||||
const MQ = "(max-width: 760px)";
|
||||
export function useIsMobile() {
|
||||
const [m, setM] = useState(() => window.matchMedia(MQ).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(MQ);
|
||||
const fn = (e) => setM(e.matches);
|
||||
mq.addEventListener("change", fn);
|
||||
return () => mq.removeEventListener("change", fn);
|
||||
}, []);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Inline :hover for elements that can't use a CSS class cleanly.
|
||||
export function useHover() {
|
||||
const [h, setH] = useState(false);
|
||||
return [h, { onMouseEnter: () => setH(true), onMouseLeave: () => setH(false) }];
|
||||
}
|
||||
|
||||
// Expand a `border: "1px solid X"` shorthand into longhands. React's style diffing
|
||||
// mishandles a longhand (hover's borderColor) overriding a shorthand (base's border):
|
||||
// on unhover it drops borderColor without re-expanding the shorthand, leaving a
|
||||
// colorless (= currentColor, ink) border. All-longhand styles diff cleanly.
|
||||
function expandBorder(s) {
|
||||
if (!s || !s.border) return s;
|
||||
const m = String(s.border).match(/^(\S+)\s+(\S+)\s+(.+)$/);
|
||||
if (!m) return s; // e.g. border: "none" — leave as-is
|
||||
const { border, ...rest } = s;
|
||||
return { borderWidth: m[1], borderStyle: m[2], borderColor: m[3], ...rest };
|
||||
}
|
||||
|
||||
// Button with base/hover style objects merged (hover suppressed while disabled).
|
||||
export function HBtn({ base = {}, hover = {}, disabled, style, ...props }) {
|
||||
const [h, hp] = useHover();
|
||||
return (
|
||||
<button
|
||||
title={`copy ${text}`}
|
||||
onClick={(e) => { e.stopPropagation(); copyText(text); setDone(true); setTimeout(() => setDone(false), 1200); }}
|
||||
style={{ all: "unset", cursor: "pointer", fontFamily: mono, fontSize: 11, color: done ? C.ok : C.accent, flexShrink: 0, ...style }}>
|
||||
{done ? "copied" : children || "copy"}
|
||||
</button>
|
||||
{...hp}
|
||||
disabled={disabled}
|
||||
style={{ ...expandBorder(base), ...(h && !disabled ? expandBorder(hover) : null), ...expandBorder(style) }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Quiet secondary action (Refresh, Clear feed, Overview). `danger` tints it red.
|
||||
export function GhostBtn({ children, onClick, danger, style }) {
|
||||
return (
|
||||
<HBtn
|
||||
base={{ height: 31, padding: "0 13px", borderRadius: 7, fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "all .15s", whiteSpace: "nowrap", display: "inline-flex", alignItems: "center", gap: 7, border: `1px solid ${danger ? C.dangerBorder : C.border}`, background: danger ? C.dangerFill : C.subtle, color: danger ? C.danger : C.tMuted, ...style }}
|
||||
hover={{ background: danger ? C.missFill : C.soft, borderColor: danger ? C.danger : C.borderStrong, color: danger ? C.dangerHover : C.ink }}
|
||||
onClick={onClick}
|
||||
>{children}</HBtn>
|
||||
);
|
||||
}
|
||||
|
||||
// The standard "← Back" button. Callers add margins via style.
|
||||
export function BackBtn({ onClick, style }) {
|
||||
const [h, hp] = useHover();
|
||||
return (
|
||||
<button {...hp} onClick={onClick} title="Back"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0, padding: "6px 11px", border: `1px solid ${h ? C.borderStrong : C.border}`, background: h ? C.soft : C.subtle, borderRadius: 7, fontSize: 13, fontWeight: 500, color: h ? C.ink : C.tMuted, cursor: "pointer", transition: "all .12s", ...style }}>← Back</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Input whose border goes ink on focus.
|
||||
export function Field({ style = {}, onFocus, onBlur, ...props }) {
|
||||
const [f, setF] = useState(false);
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
onFocus={(e) => { setF(true); onFocus?.(e); }}
|
||||
onBlur={(e) => { setF(false); onBlur?.(e); }}
|
||||
style={{ outline: "none", border: `1px solid ${f ? C.ink : C.inputBorder}`, borderRadius: 7, background: "#fff", transition: "border-color .12s", ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Small monospace status pill. tone: rend (green) | miss (red) | stat (amber) | none (neutral).
|
||||
const CHIP = {
|
||||
rend: { color: C.ok, background: C.okBg, border: C.okBorder },
|
||||
ok: { color: C.ok, background: C.okBg, border: C.okBorder },
|
||||
miss: { color: C.danger, background: C.missFill, border: C.missBorder },
|
||||
stat: { color: C.amber, background: C.amberBg, border: C.amberBorder },
|
||||
none: { color: C.t3, background: C.subtle, border: C.inputBorder },
|
||||
};
|
||||
export function Chip({ tone = "none", children, style }) {
|
||||
const s = CHIP[tone] || CHIP.none;
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, fontFamily: mono, fontSize: 9.5, fontWeight: 600, padding: "2px 7px", borderRadius: 20, color: s.color, background: s.background, border: `1px solid ${s.border}`, whiteSpace: "nowrap", ...style }}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The one empty-state treatment: dashed border, subtle bg, centered muted text.
|
||||
export function EmptyState({ children, style }) {
|
||||
return (
|
||||
<div style={{ padding: "40px 28px", textAlign: "center", border: `1px dashed ${C.borderStrong}`, borderRadius: 12, background: C.subtle, color: C.t3, fontSize: 13.5, lineHeight: 1.6, ...style }}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The beige folder glyph (CSS shape, no asset). Scales with `w`.
|
||||
export function FolderGlyph({ w = 30 }) {
|
||||
const h = Math.round(w * 0.77);
|
||||
return (
|
||||
<div style={{ position: "relative", width: w, height: h, flexShrink: 0, marginTop: 1 }}>
|
||||
<div style={{ position: "absolute", top: -Math.round(h * 0.22), left: 0, width: Math.round(w * 0.43), height: Math.round(h * 0.26), background: C.glyph, borderRadius: "3px 3px 0 0" }} />
|
||||
<div style={{ position: "absolute", inset: 0, top: 0, background: C.glyph, borderRadius: 3 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Document glyph: a page with a folded top-right corner + a few text lines.
|
||||
export function FileGlyph({ w = 24 }) {
|
||||
const h = Math.round(w * 1.24);
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 24 30" fill="none" style={{ flexShrink: 0 }}>
|
||||
<path d="M4 2h11l6 6v18a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
|
||||
fill="#fff" stroke={C.glyph} strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M15 2v6h6" stroke={C.glyph} strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M6 15h9M6 19h9M6 23h6" stroke={C.glyph} strokeWidth="1.4" strokeLinecap="round" opacity="0.55" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Confirm modal + toast, exposed via useUi() ------------------------------
|
||||
const UiCtx = createContext(null);
|
||||
export function useUi() { return useContext(UiCtx); }
|
||||
|
||||
function ConfirmModal({ title, message, action, onConfirm, onClose }) {
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(28,27,25,.32)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, zIndex: 50 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: "100%", maxWidth: 400, background: "#fff", borderRadius: 11, padding: 24, boxShadow: "0 24px 60px -20px rgba(28,27,25,.4)" }}>
|
||||
<h3 style={{ margin: "0 0 8px", fontSize: 17, fontWeight: 600, letterSpacing: "-.01em" }}>{title}</h3>
|
||||
<p style={{ margin: "0 0 22px", color: C.tMuted, fontSize: 14, lineHeight: 1.55 }}>{message}</p>
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||
<HBtn base={{ height: 40, padding: "0 16px", border: `1px solid ${C.borderStrong}`, background: "#fff", color: C.tMuted, borderRadius: 7, fontSize: 14, fontWeight: 500, cursor: "pointer" }} hover={{ color: C.ink, borderColor: "#c7c2b9" }} onClick={onClose}>Cancel</HBtn>
|
||||
<HBtn base={{ height: 40, padding: "0 18px", border: "none", background: C.danger, color: "#fff", borderRadius: 7, fontSize: 14, fontWeight: 600, cursor: "pointer", transition: "background .12s" }} hover={{ background: C.dangerHover }} onClick={() => { onConfirm(); onClose(); }}>{action}</HBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toast({ msg, error }) {
|
||||
return (
|
||||
<div style={{ position: "fixed", bottom: 84, left: "50%", transform: "translateX(-50%)", background: error ? C.danger : C.ink, color: "#fff", padding: "11px 18px", borderRadius: 8, fontSize: 13.5, fontWeight: 500, boxShadow: "0 12px 30px -10px rgba(28,27,25,.5)", zIndex: 60, display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<span style={{ fontWeight: 700 }}>{error ? "⚠" : "✓"}</span>{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UiProvider({ children }) {
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
const tRef = useRef();
|
||||
const show = (msg, ms, error) => {
|
||||
clearTimeout(tRef.current);
|
||||
setToast({ msg, error });
|
||||
tRef.current = setTimeout(() => setToast(null), ms);
|
||||
};
|
||||
const api = {
|
||||
confirm: (opts) => setConfirm(opts),
|
||||
toast: (msg, ms = 1900) => show(msg, ms, false),
|
||||
error: (msg, ms = 3500) => show(msg, ms, true),
|
||||
};
|
||||
return (
|
||||
<UiCtx.Provider value={api}>
|
||||
{children}
|
||||
{confirm && <ConfirmModal {...confirm} onClose={() => setConfirm(null)} />}
|
||||
{toast && <Toast msg={toast.msg} error={toast.error} />}
|
||||
</UiCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user