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
+1 -1
View File
@@ -46,7 +46,7 @@ my-agent/
Markdown holds the context, YAML holds the config. Edit any of it with a text editor; the server reads the files on demand, so changes apply immediately. Two exceptions load at server start and need a restart to pick up edits: `instructions.md` (pushed in the MCP handshake) and command files.
Connected clients get six tools: `overview`, `read_file`, `write_file`, `list_dir`, `grep`, `run_script`.
Connected clients get five tools: `read_file`, `write_file`, `list_dir`, `grep`, `run_script`.
`run_script` runs either ad-hoc code or a saved script by path (`scripts/` folders hold proven procedures, so they are reused instead of rewritten). Files under `connections/*/commands/` and `modules/*/commands/` register as MCP prompts, which Claude Code shows as slash commands; see "Commands" below.
+2 -2
View File
@@ -54,11 +54,11 @@ The accepted tradeoff: something must be running.
## The context ledger: everything pushed is declared
Every pipe that inserts context into the agent is enumerated in one ledger, computed live from the folder so it cannot go stale. Each pipe is marked `loaded` (pushed at connect), `on demand` (agent pulls it via a visible tool call), `skipped` (nothing to push), or `uncontrolled` (runtime-owned, outside gcontext's view). The ledger appears in `gcontext context`, in `overview()`, after `connect`, and in the dashboard.
Every pipe that inserts context into the agent is enumerated in one ledger, computed live from the folder so it cannot go stale. Each pipe is marked `loaded` (pushed at connect), `on demand` (agent pulls it via a visible tool call), `skipped` (nothing to push), or `uncontrolled` (runtime-owned, outside gcontext's view). The ledger appears in `gcontext context`, after `connect`, and in the dashboard.
The one thing gcontext pushes at connect is `instructions.md`, through the MCP handshake's `instructions` field, declared as ledger pipe G0. This is the design's answer to "what does the agent receive when it attaches": one file, in the folder, versioned with git, and nothing else. Edit it and you have edited what every future session starts with.
This position was reached in two steps. The handshake push was first rejected outright, on the argument that content arriving through a side channel is invisible in the conversation. Living with the alternative showed the real cost: the agent started blind, had to be told (via `overview()`) to read its own instructions, and a runtime that never asked never saw them. The rejection was aimed at the wrong target. The problem was never pushing at connect; it was pushing without declaring. So the invariant is: everything pushed is declared in the ledger, and everything declared is a file you control.
This position was reached in two steps. The handshake push was first rejected outright, on the argument that content arriving through a side channel is invisible in the conversation. Living with the alternative showed the real cost: the agent started blind, had to be told to read its own instructions, and a runtime that never asked never saw them. The rejection was aimed at the wrong target. The problem was never pushing at connect; it was pushing without declaring. So the invariant is: everything pushed is declared in the ledger, and everything declared is a file you control.
The ledger is also honest about its limits. When a runtime keeps pipes gcontext cannot close (its own system prompt, its config files, other MCP servers), the ledger marks them UNCONTROLLED instead of pretending the session is cleaner than it is.
+2 -2
View File
@@ -50,13 +50,13 @@ There is no enforced schema beyond `index.md`. Different modules have different
1. Download the module folder (or copy it)
2. Drop it into `modules/` in your agent folder
3. That's it. The agent discovers it via `overview()` and can read all its files.
3. That's it. The agent discovers it with `list_dir("modules")` and can read all its files.
No installation step, no config to edit, no dependencies to resolve. It's just files.
## How the agent interacts with a module
The agent sees modules listed in `overview()`. When a task matches a module's purpose, the agent:
The agent sees modules with `list_dir("modules")`. When a task matches a module's purpose, the agent:
1. Reads `index.md` to understand what the module does
2. Reads any additional files (steps, playbooks, references)
@@ -1,7 +1,7 @@
# Legacy audit module (archived)
Example of an archived module. It sits under archive/modules/, so it is never
scanned into overview(), status, or the ledger counts. It stays readable by
scanned into status, the dashboard, or the ledger counts. It stays readable by
path: read_file("archive/modules/legacy-audit/index.md").
To bring it back, move the folder to modules/legacy-audit/. Archiving is a
+2 -1
View File
@@ -51,7 +51,8 @@ You are the agent for this gcontext project. Your state lives in this folder:
read it with read_file, keep it current with write_file, find things with
list_dir and grep.
- Call overview() first to see connections, modules, and the context ledger.
- Start with list_dir(".") to see connections and modules, and read the
index.md of whatever you are about to use.
- Use run_script for anything that needs an API: secrets are injected as env
vars (you only ever see their names), deps are preinstalled.
- When a script proves itself, save it with write_file under a scripts/
+1 -2
View File
@@ -25,8 +25,7 @@ def build(root: Path) -> list[dict]:
else:
ledger.append({"id": "G0", "label": "instructions.md", "detail": "file missing, nothing pushed at connect", "status": "skipped"})
ledger.append({"id": "G1", "label": "tool descriptions", "detail": "6 gcontext tools, pushed at connect", "status": "loaded"})
ledger.append({"id": "G2", "label": "overview()", "detail": "project map, secret status", "status": "on demand"})
ledger.append({"id": "G1", "label": "tool descriptions", "detail": "5 gcontext tools, pushed at connect", "status": "loaded"})
g3_detail = f"{n_files} files in connections/ + modules/"
if state.archived(root):
g3_detail += "; archive/ not scanned, readable by path"
-1
View File
@@ -1 +0,0 @@
Show project info, all connections with per-secret fill status, and all modules with descriptions.
+1 -1
View File
@@ -1,3 +1,3 @@
Read a file from the project. Use overview() or list_dir first to see available files.
Read a file from the project. Use list_dir or grep first to find the file.
Cannot read secrets.env: secret values never enter the context window.
+1 -68
View File
@@ -1,6 +1,6 @@
"""The MCP surface: everything an attached agent can reach, in one file.
Six tools (defined below, their agent-facing text in prompts/tools/*.md),
Five tools (defined below, their agent-facing text in prompts/tools/*.md),
commands registered as prompts, a /status route, and session tracking.
The actual work lives in the per-concern modules:
@@ -30,7 +30,6 @@ from starlette.responses import JSONResponse
from . import commands as commands_mod
from . import exec as exec_mod
from . import fs
from . import ledger as ledger_mod
from . import secrets as secrets_mod
from . import state
@@ -194,72 +193,6 @@ def load_instructions() -> int:
return len(text.splitlines())
@mcp.tool(description=_tool_doc("overview"))
def overview() -> str:
root = PROJECT_DIR
config = state.load_gcontext_yaml(root)
connections = state.load_connections(root)
secrets = secrets_mod.load(root)
modules = state.discover_modules(root)
lines = []
name = config.get("name", root.name)
desc = config.get("description", "")
lines.append(f"# {name}")
if desc:
lines.append(desc)
lines.append("")
lines.append("## Context ledger")
lines.append("Everything that enters your context from this server, and how:")
lines.extend(ledger_mod.render_plain(root))
lines.append("")
instructions = root / "instructions.md"
if instructions.exists():
lines.append(f"System prompt: instructions.md ({len(instructions.read_text().splitlines())} lines)")
lines.append("")
lines.append("## Connections")
if not connections:
lines.append("No connections defined.")
for cname, conn in connections.items():
filled = sum(1 for s in conn.secrets if s in secrets and secrets[s])
total = len(conn.secrets)
status = "ready" if filled == total else f"missing {total - filled} secret(s)"
lines.append(f"- **{cname}**: {status}")
if conn.description:
lines.append(f" {conn.description}")
for s in conn.secrets:
has_value = s in secrets and bool(secrets[s])
lines.append(f" - {s}: {'filled' if has_value else 'MISSING'}")
if conn.deps:
lines.append(f" Deps: {', '.join(conn.deps)}")
for f in state.connection_files(root, cname):
lines.append(f" - {f}")
lines.append("")
if modules:
lines.append("## Modules")
for mname, mod in modules.items():
tag_str = f" [{', '.join(mod.tags)}]" if mod.tags else ""
lines.append(f"- **{mname}** (v{mod.version}){tag_str}")
if mod.description:
lines.append(f" {mod.description}")
for f in state.module_files(root, mname):
lines.append(f" - {f}")
lines.append("")
archived = state.archived(root)
if archived:
lines.append("## Archive")
for cat, items in archived.items():
lines.append(f"- archive/{cat}/: {', '.join(items)}")
lines.append("Archived items are never scanned or listed above; read them by path if needed.")
return "\n".join(lines).rstrip()
@mcp.tool(description=_tool_doc("read_file"))
def read_file(path: str) -> str:
return fs.read_file(PROJECT_DIR, path)
+1 -1
View File
@@ -82,7 +82,7 @@ def module_files(root: Path, name: str) -> list[str]:
def archived(root: Path) -> dict[str, list[str]]:
"""Names of archived items per category, from archive/{connections,modules}/.
Anything under archive/ is never scanned into overview or the ledger
Anything under archive/ is never scanned into the dashboard or the ledger
counts. It stays readable by path via read_file. Archiving is a plain
folder move; there is no metadata and no automatic behavior.
"""
+1 -1
View File
@@ -130,7 +130,7 @@ def test_middleware_records_scrubbed_tool_event(project):
def test_middleware_records_error_and_reraises(project):
class Msg:
name = "overview"
name = "read_file"
arguments = {}
class Ctx:
+47 -3
View File
@@ -42,6 +42,52 @@ def test_write_then_read_roundtrip(project):
assert server.read_file("modules/notes/index.md") == "hello"
def test_list_dir_lists_entries_and_blocks_traversal(project):
(project / "modules" / "notes").mkdir(parents=True)
(project / "modules" / "notes" / "index.md").write_text("x")
out = server.list_dir("modules")
assert "notes/" in out
out = server.list_dir("modules/notes")
assert "index.md" in out
assert "outside the project" in server.list_dir("..")
def test_list_dir_hides_machine_folders(project):
(project / ".git").mkdir()
(project / "kept.md").write_text("x")
out = server.list_dir(".")
assert ".git" not in out
assert "kept.md" in out
def test_grep_finds_lines_and_respects_glob(project):
(project / "modules" / "m").mkdir(parents=True)
(project / "modules" / "m" / "index.md").write_text("refund policy\nother\n")
(project / "modules" / "m" / "notes.txt").write_text("refund notes\n")
out = server.grep("refund")
assert "modules/m/index.md:1: refund policy" in out
assert "notes.txt" in out
out = server.grep("refund", glob="*.md")
assert "index.md" in out
assert "notes.txt" not in out
def test_grep_never_reads_secrets_env(project):
(project / "secrets.env").write_text("API_KEY=sk-verysecret\n")
out = server.grep("verysecret")
assert "sk-verysecret" not in out
assert "No matches" in out
def test_grep_invalid_regex(project):
assert "invalid regex" in server.grep("[unclosed")
def test_removed_tools_are_gone():
assert not hasattr(server, "list_connections")
assert not hasattr(server, "overview")
def test_archive_not_scanned_but_reported(project):
(project / "modules" / "active").mkdir(parents=True)
(project / "modules" / "active" / "index.md").write_text("x")
@@ -52,9 +98,7 @@ def test_archive_not_scanned_but_reported(project):
assert "active" in modules and "old" not in modules
assert state.archived(project) == {"modules": ["old"]}
overview = server.overview()
assert "## Archive" in overview
assert "old" in overview
assert "archive/" in server.list_dir(".")
def test_archive_readable_by_path(project):
+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>
);
}