diff --git a/README.md b/README.md index 3c13b76..9ae3367 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/design.md b/docs/design.md index 530a21e..a5dba7f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. diff --git a/docs/modules.md b/docs/modules.md index f93eeaa..0108177 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -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) diff --git a/examples/ops-agent/archive/modules/legacy-audit/index.md b/examples/ops-agent/archive/modules/legacy-audit/index.md index 748a4e2..2cb35fb 100644 --- a/examples/ops-agent/archive/modules/legacy-audit/index.md +++ b/examples/ops-agent/archive/modules/legacy-audit/index.md @@ -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 diff --git a/src/gcontext/cli.py b/src/gcontext/cli.py index 790e82f..ec94482 100644 --- a/src/gcontext/cli.py +++ b/src/gcontext/cli.py @@ -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/ diff --git a/src/gcontext/ledger.py b/src/gcontext/ledger.py index 95f7510..e981172 100644 --- a/src/gcontext/ledger.py +++ b/src/gcontext/ledger.py @@ -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" diff --git a/src/gcontext/prompts/tools/overview.md b/src/gcontext/prompts/tools/overview.md deleted file mode 100644 index 53550f4..0000000 --- a/src/gcontext/prompts/tools/overview.md +++ /dev/null @@ -1 +0,0 @@ -Show project info, all connections with per-secret fill status, and all modules with descriptions. diff --git a/src/gcontext/prompts/tools/read_file.md b/src/gcontext/prompts/tools/read_file.md index 7a6c98b..34dab99 100644 --- a/src/gcontext/prompts/tools/read_file.md +++ b/src/gcontext/prompts/tools/read_file.md @@ -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. diff --git a/src/gcontext/server.py b/src/gcontext/server.py index cb1e062..75cf3cd 100644 --- a/src/gcontext/server.py +++ b/src/gcontext/server.py @@ -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) diff --git a/src/gcontext/state.py b/src/gcontext/state.py index 0de9124..e66e44c 100644 --- a/src/gcontext/state.py +++ b/src/gcontext/state.py @@ -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. """ diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index bcc230f..4a7f3cc 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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: diff --git a/tests/test_server.py b/tests/test_server.py index d7f3971..b4a6167 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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): diff --git a/web/src/Activity.jsx b/web/src/Activity.jsx index cc64ddb..79c6bc2 100644 --- a/web/src/Activity.jsx +++ b/web/src/Activity.jsx @@ -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 {t.label}; +} + +function Bar({ frac, color, w }) { return ( -
-
- {fmtTime(e.ts)} - {e.error ? "error" : e.kind} - {e.name} - {e.detail} - {e.tokens_out ? `~${e.tokens_out} tk` : ""} -
- {open && e.preview && ( -
-          {e.preview}{e.preview.length >= 400 ? "\n┅ first 400 chars, the agent received the rest too" : ""}
-        
- )} + + + + ); +} + +// The one reading surface: light background, ink text, comfortable line height. +function Reader({ children, maxHeight }) { + return ( +
+      {children}
+    
+ ); +} + +// --- session rail item ------------------------------------------------------ +function SessionItem({ session, active, selected, onSelect, maxTk }) { + const [h, hp] = useHover(); + const st = session.startTs; + return ( + + ); +} + +// --- 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 ( +
+ {fmtTime(e.ts)} + + {e.name} + {e.detail}{e.error ? " · failed" : ""} + {ref && h && } + + + {nfmt(e.tokens_out)} tk + +
); } +// --- modal: reads a single crossing ----------------------------------------- +function Modal({ children, mobile, onClose }) { + return ( +
+
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} +
+
+ ); +} + +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 ( + <> +
+ + {e.name} + + {e.error && FAILED} + + +
+
+
+
+
Time
+
{new Date(e.ts).toLocaleTimeString()}
+
+
+
Duration
+
{e.duration_ms ? `${nfmt(e.duration_ms)} ms` : "n/a"}
+
+
+
Tokens in
+
{e.tokens_in > 0 ? nfmt(e.tokens_in) + " tk" : "n/a"}
+
+
+
Added to context
+
+ {nfmt(e.tokens_out)} tk + {w.label} +
+
+
+ + {e.detail && ( +
+
What it was about
+
+ {e.detail} + {ref && } +
+
+ )} + +
What the agent received
+ {hasPreview ? ( + <> + {e.preview} + {e.preview.length >= 400 &&
First 400 chars shown, the agent received the rest too.
} + + ) : ( +
+
+ {e.error ? "⚠" : "◌"} + {e.error ? "The call failed" : "No preview captured"} +
+
+ {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."} +
+
+ )} +
+ + ); +} + 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

{err}

; - if (!events) return

loading…

; - if (events.length === 0) { - return

no activity yet. Events appear here as harnesses connect and call tools. The feed empties on restart.

; - } + // 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
Couldn't load: {err}
; + + 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 (
-
activity · newest first · empties on restart
- {events.map((e) => ( - - {e.kind === "connect" && ( -
- session · {e.name} {e.detail} · {dayLabel(e.ts)} {fmtTime(e.ts)} +
+

Activity

+ + ↻ Refresh +
+

+ Everything that crossed from gcontext into your agent, grouped by session. The feed lives in server memory and empties on restart. +

+ + {!flow || flow.length === 0 ? ( + +
+
No activity yet
+
When a harness connects, a session opens here. Every tool call and command it makes lands under that session, in order.
+
+ ) : ( +
+ {/* session rail */} +
+
Sessions · {sessions.length}
+ {sessions.map((s, i) => ( + { setSelSession(i); setModal(null); }} /> + ))} +
+ + {/* session detail */} +
+
+ {selActive ? "Active session" : `${dayLabel(sel.startTs)} · ${fmtHM(sel.startTs)}`} + {selActive && live}
- )} - {e.kind !== "connect" && ( - setOpen(open === e.id ? null : e.id)} /> - )} - - ))} +
+ {selActive ? "Started" : dayLabel(sel.startTs) + " ·"} {fmtTime(sel.startTs)} · {sel.events.length} crossing{sel.events.length === 1 ? "" : "s"} · ~{nfmt(sel.tk)} tokens into context +
+ + {/* legend */} +
+ Crossings + + {[0, 1, 2].map((tier) => ( + + + {tier === 0 ? "pushed" : tier === 1 ? "agent pulled" : "you pulled"} + + ))} +
+ + {/* feed */} +
+ {sel.events.map((e, i) => ( + setModal({ e })} /> + ))} +
+
+
+ )} + + {modal && ( + setModal(null)}> + setModal(null)} /> + + )}
); } diff --git a/web/src/App.jsx b/web/src/App.jsx index 9534790..372aa9d 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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 ( + + ); +} + +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 ( + + ); +} + +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 ( - - ); -} - 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 = ; + return ( -
- -
-
- {err && ( -

- cannot reach the server: {err}. Is `gcontext up` running? -

- )} - {section === "overview" && } - {section === "files" && } - {section === "activity" && } -
-
-
+ +
+ {mobile ? ( + <> +
+ + {project?.name || "gcontext"} +
+ {navOpen && ( +
setNavOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(28,27,25,.32)", zIndex: 40 }}> +
e.stopPropagation()} style={{ height: "100%", width: "fit-content" }}>{sidebar}
+
+ )} + + ) : ( + sidebar + )} +
+
+ {err && ( +
+ Cannot reach the gcontext server: {err}. Is `gcontext up` running? +
+ )} + {section === "overview" && } + {section === "connections" && } + {section === "modules" && } + {section === "commands" && } + {section === "files" && } + {section === "activity" && } +
+
+
+
); } diff --git a/web/src/Commands.jsx b/web/src/Commands.jsx new file mode 100644 index 0000000..a50d5ef --- /dev/null +++ b/web/src/Commands.jsx @@ -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 ( +
+
+ /{cmd.kind} + {cmd.name} +
+ {cmd.error ? ( +

Malformed frontmatter: {cmd.error}

+ ) : cmd.description ? ( +

{cmd.description}

+ ) : ( +

No description yet.

+ )} + {(cmd.args || []).length > 0 && ( +
+ {cmd.args.map((a) => ( + {a.name}{a.required ? "*" : ""} + ))} +
+ )} +
+ {cmd.path} + +
+ {!cmd.error && ( +
+ {inv} + +
+ )} +
+ ); +} + +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
Couldn't load: {err}
; + if (!cmds) return
Loading…
; + + return ( +
+

Commands

+

+ Files under commands/ folders, served as MCP prompts. New files appear after a server restart. +

+ {cmds.length === 0 ? ( + +
No commands yet
+

+ Drop a .md (prompt) or .py (script) file into connections/<name>/commands/ or modules/<name>/commands/ and restart the server. +

+
+ ) : ( + <> +
Commands ({cmds.length})
+
+ {cmds.map((c) => )} +
+ + )} +
+ ); +} diff --git a/web/src/Connections.jsx b/web/src/Connections.jsx new file mode 100644 index 0000000..5f05fe1 --- /dev/null +++ b/web/src/Connections.jsx @@ -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 ( +
+ {path} + +
+ ); +} + +function ConnectionCard({ conn }) { + const [h, hp] = useHover(); + const folder = `connections/${conn.name}/`; + return ( +
+
+ {conn.name} + {conn.ready ? "ready" : "missing secrets"} +
+ {conn.description &&

{conn.description}

} + {conn.secrets.length > 0 && ( +
+
Secrets
+
+ {conn.secrets.map((s) => ( + {s.name} + ))} +
+
+ )} + {conn.deps.length > 0 && ( +
deps: {conn.deps.join(", ")}
+ )} + {conn.files.length > 0 && ( +
+
Context files
+ {conn.files.map((f) => )} +
+ )} +
+ +
+
+ ); +} + +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
Couldn't load: {err}
; + if (!conns) return
Loading…
; + + return ( +
+

Connections

+

+ Services the agent can reach. Each declares the secret names and Python deps it needs; secret values stay in secrets.env on this machine. +

+ {conns.length === 0 ? ( + +
No connections yet
+

+ Add one under connections/<service>/connection.yaml with the secret names and deps, plus an index.md describing the API in your words. +

+
+ ) : ( +
+ {conns.map((c) => )} +
+ )} +
+ ); +} diff --git a/web/src/Copy.jsx b/web/src/Copy.jsx new file mode 100644 index 0000000..9cc4e0c --- /dev/null +++ b/web/src/Copy.jsx @@ -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 -> (⧉ Copy prompt, terracotta) +// icon only -> (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 ( + + ); + } + return ( + + ); +} diff --git a/web/src/Files.jsx b/web/src/Files.jsx index 6a9fd05..e861769 100644 --- a/web/src/Files.jsx +++ b/web/src/Files.jsx @@ -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 ( <> - +
+ + {h && } +
{node.dir && open.has(node.path) && node.children.map((c) => ( ))} @@ -53,22 +64,35 @@ function Viewer({ path }) { getJSON(`/api/file?path=${encodeURIComponent(path)}`).then(setFile).catch((e) => setErr(e.message)); }, [path]); - if (!path) return

pick a file on the left

; - if (err) return

{path}: {err}

; - if (!file) return

loading…

; + if (!path) { + return ( + +
+
Pick a file on the left to read it.
+
+ ); + } + if (err) return
Couldn't read {path}: {err}
; + if (!file) return
Loading…
; + const isMd = path.endsWith(".md"); return ( -
-
- {file.path} · {file.size} B +
+
+ + {file.path} + {fileLabel(path)} · {file.size} B + +
+
+ {isMd ? ( +
+ {file.content} +
+ ) : ( +
{file.content}
+ )}
- {path.endsWith(".md") ? ( -
- {file.content} -
- ) : ( -
{file.content}
- )}
); } @@ -87,19 +111,25 @@ export default function Files() { return next; }); - if (err) return

{err}

; - if (!entries) return

loading…

; + if (err) return
Couldn't load: {err}
; + if (!entries) return
Loading…
; return ( -
-
-
project
- {roots.map((n) => ( - - ))} -
-
- +
+

Files

+

+ The project folder as the agent sees it. Hover a row to copy a prompt that points your agent at that file or folder. secrets.env and machine folders never appear here. +

+
+
+
Project
+ {roots.map((n) => ( + + ))} +
+
+ +
); diff --git a/web/src/Modules.jsx b/web/src/Modules.jsx new file mode 100644 index 0000000..1883efe --- /dev/null +++ b/web/src/Modules.jsx @@ -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 ( +
+ {path} + +
+ ); +} + +function ModuleCard({ mod }) { + const [h, hp] = useHover(); + const folder = `modules/${mod.name}/`; + return ( +
+
+ {mod.name} + v{mod.version} + {(mod.tags || []).map((t) => {t})} +
+ {mod.description &&

{mod.description}

} + {mod.files.length > 0 && ( +
+
Files
+ {mod.files.map((f) => )} +
+ )} +
+ +
+
+ ); +} + +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
Couldn't load: {err}
; + if (!mods) return
Loading…
; + + return ( +
+

Modules

+

+ Reusable folders of knowledge and scripts under modules/, read by the agent on demand. +

+ {mods.length === 0 ? ( + +
No modules yet
+

+ Create a folder under modules/<name>/ with the docs or scripts the agent should keep. +

+
+ ) : ( +
+ {mods.map((m) => )} +
+ )} +
+ ); +} diff --git a/web/src/Overview.jsx b/web/src/Overview.jsx index e536d88..3a6011c 100644 --- a/web/src/Overview.jsx +++ b/web/src/Overview.jsx @@ -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
No harness connected yet. Attach one with the snippets below.
; + } return ( - - ); -} - -function Section({ title, children }) { - return ( -
-
{title}
- {children} -
- ); -} - -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

loading…

; - - 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 ( -
-

- {project.description || "No description in gcontext.yaml."} -

-

{project.project_dir}

- -
- {sessions.length === 0 &&

none. Attach a harness with the command below

} - {sessions.map((s, i) => ( -
- {s.client} - {s.version} - - last activity {relSeen(s.last_seen)} -
- ))} -
- {connectCmd} - +
+ {sessions.map((s, i) => ( +
+ + {s.client} + {s.version} + + connected {s.connected} · last activity {s.last_seen}
-

any MCP client: {url}

-
+ ))} +
+ ); +} -
- {conns.length === 0 &&

none. Add connections/<service>/connection.yaml

} - {conns.map((c, i) => ( -
- {c.name} - - {c.ready ? "ready" : "missing " + c.secrets.filter((s) => !s.filled).map((s) => s.name).join(", ")} - - {c.description} -
- ))} -
+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 ( +
+ {snippets.map((s) => ( +
+ {s.label} + {s.text} + +
+ ))} +
+ ); +} -
- {mods.length === 0 &&

none

} - {mods.map((m, i) => ( -
- {m.name} - v{m.version}{m.tags?.length ? " · " + m.tags.join(", ") : ""} - {m.description} -
- ))} -
+function Ledger() { + const [ledger, setLedger] = useState([]); + useEffect(() => { getJSON("/api/ledger").then((d) => setLedger(d.ledger)).catch(() => {}); }, []); + return ( +
+ {ledger.map((p, i) => ( +
+ {p.id} + {p.label} + {p.status} + {p.detail} +
+ ))} +
+ ); +} -
- {cmds.length === 0 &&

none. Drop .md or .py files into a commands/ folder

} - {cmds.map((c, i) => ( -
- {c.name} - {c.error - ? malformed: {c.error} - : {c.description}} - {!c.error && } -
- ))} -
+export default function Overview({ project, sessions }) { + if (!project) return
Loading…
; + const archived = project.archived || {}; + const archivedParts = Object.entries(archived).map(([cat, items]) => `${items.length} ${cat}`); + return ( +
+

{project.name}

+

+ {project.description || "No description in gcontext.yaml yet."} +

+
{project.project_dir}
+ +
Connected harnesses
+ -
- {ledger.map((p, i) => ( -
- {p.id} - {p.label} - {p.status} - {p.detail} -
- ))} -
+
Connect a harness
+ -

- {project.has_instructions ? `instructions.md · ${project.instructions_lines} lines` : "no instructions.md"} - {archived ? ` · archive: ${archived}` : ""} - {` · gcontext ${project.version}`} +

Context ledger
+

+ Everything that enters the agent's context from this server, and how.

+ + + {(project.has_instructions || archivedParts.length > 0) && ( +
+ {project.has_instructions && ( + System prompt: instructions.md ({project.instructions_lines} lines) + )} + {archivedParts.length > 0 && ( + archive/: {archivedParts.join(", ")} (not scanned, readable by path) + )} +
+ )}
); } diff --git a/web/src/index.css b/web/src/index.css index 9b18093..ba95556 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; } } diff --git a/web/src/lib.js b/web/src/lib.js index 7972c54..587430a 100644 --- a/web/src/lib.js +++ b/web/src/lib.js @@ -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; diff --git a/web/src/ui.jsx b/web/src/ui.jsx index 3db86e9..47f3916 100644 --- a/web/src/ui.jsx +++ b/web/src/ui.jsx @@ -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 ( +
+ {tabs.map((t) => { + const on = t.key === active; + return ( + + ); + })} +
+ ); +} + +// 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 ( + {...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 ( + {children} + ); +} + +// The standard "← Back" button. Callers add margins via style. +export function BackBtn({ onClick, style }) { + const [h, hp] = useHover(); + return ( + + ); +} + +// Input whose border goes ink on focus. +export function Field({ style = {}, onFocus, onBlur, ...props }) { + const [f, setF] = useState(false); + return ( + { 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 ( + {children} + ); +} + +// The one empty-state treatment: dashed border, subtle bg, centered muted text. +export function EmptyState({ children, style }) { + return ( +
{children}
+ ); +} + +// The beige folder glyph (CSS shape, no asset). Scales with `w`. +export function FolderGlyph({ w = 30 }) { + const h = Math.round(w * 0.77); + return ( +
+
+
+
+ ); +} + +// 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 ( + + + + + + ); +} + +// --- 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 ( +
+
e.stopPropagation()} style={{ width: "100%", maxWidth: 400, background: "#fff", borderRadius: 11, padding: 24, boxShadow: "0 24px 60px -20px rgba(28,27,25,.4)" }}> +

{title}

+

{message}

+
+ Cancel + { onConfirm(); onClose(); }}>{action} +
+
+
+ ); +} + +function Toast({ msg, error }) { + return ( +
+ {error ? "⚠" : "✓"}{msg} +
+ ); +} + +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 ( + + {children} + {confirm && setConfirm(null)} />} + {toast && } + ); }