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:
bernatsampera
2026-08-01 13:11:46 +02:00
parent 730b02197a
commit 0f3062ff39
23 changed files with 1119 additions and 348 deletions
+284 -44
View File
@@ -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
View File
@@ -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>
);
}
+79
View File
@@ -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/&lt;name&gt;/commands/</span> or <span style={{ fontFamily: mono }}>modules/&lt;name&gt;/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>
);
}
+85
View File
@@ -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/&lt;service&gt;/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>
);
}
+30
View File
@@ -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
View File
@@ -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>
);
+72
View File
@@ -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/&lt;name&gt;/</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
View File
@@ -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/&lt;service&gt;/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>
);
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}