mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(accumulator): vite+React lab UI at crates/buzz-accumulator/ui
The ergonomics lab for the exploration client: a standalone SPA that talks to the daemon through vite's dev proxy (/api → 127.0.0.1:4640), so the daemon stays loopback-pure and zero-config. One screen, three panes: - Selection composer (channels/authors/kinds + window presets), with URL-shareable state (?channels=…&since=… and ?fold=…&v=…). - Explore: live zero-spend preview (count/chars/est tokens), the daily rhythm strip, and a paged browser of the matching events. - Folds board: create-from-current-selection, and per-fold cards with the priced loop — preflight ($0) → run pinned to the exact priced window → outcome (refusals salvage the model output visibly). - Artifact reader: Working Context / Log rendering with [event:id] citation chips that pin the source message into an evidence rail, plus a version scrubber. Deliberately its own pnpm workspace root: not in the repo workspace, invisible to just ci. Run: cd crates/buzz-accumulator/ui && pnpm install && pnpm dev (daemon running on :4640). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Riley Crane <rileycrane@squareup.com>
This commit is contained in:
2
crates/buzz-accumulator/ui/.gitignore
vendored
Normal file
2
crates/buzz-accumulator/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
12
crates/buzz-accumulator/ui/index.html
Normal file
12
crates/buzz-accumulator/ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>accumulator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
crates/buzz-accumulator/ui/package.json
Normal file
24
crates/buzz-accumulator/ui/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "accumulator-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"check": "tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
1163
crates/buzz-accumulator/ui/pnpm-lock.yaml
generated
Normal file
1163
crates/buzz-accumulator/ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
crates/buzz-accumulator/ui/pnpm-workspace.yaml
Normal file
6
crates/buzz-accumulator/ui/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Deliberately its own workspace root: the lab is standalone tooling and must
|
||||
# not join the repo-level pnpm workspace (or `just ci`'s desktop/web checks).
|
||||
packages:
|
||||
- "."
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
119
crates/buzz-accumulator/ui/src/App.tsx
Normal file
119
crates/buzz-accumulator/ui/src/App.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Selection, type TimeWindow } from "./api";
|
||||
import { ArtifactReader } from "./components/ArtifactReader";
|
||||
import { Explore } from "./components/Explore";
|
||||
import { FoldsBoard } from "./components/FoldsBoard";
|
||||
import { SelectionComposer } from "./components/SelectionComposer";
|
||||
|
||||
export type View =
|
||||
| { kind: "explore" }
|
||||
| { kind: "artifact"; fold: string; version: number };
|
||||
|
||||
/** Initial selection/window from URL query params, so a selection is
|
||||
* shareable: `?channels=<uuid,uuid>&authors=<hex>&kinds=9&since=<ts>`. */
|
||||
function fromUrl(): { selection: Selection; window: TimeWindow } {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const list = (key: string) =>
|
||||
(q.get(key) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const num = (key: string) => {
|
||||
const n = Number.parseInt(q.get(key) ?? "", 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
return {
|
||||
selection: {
|
||||
channels: list("channels"),
|
||||
authors: list("authors"),
|
||||
kinds: list("kinds")
|
||||
.map((k) => Number.parseInt(k, 10))
|
||||
.filter((k) => Number.isFinite(k) && k >= 0),
|
||||
},
|
||||
window: { since: num("since"), until_exclusive: num("until") },
|
||||
};
|
||||
}
|
||||
|
||||
/** Initial view from `?fold=<name>&v=<version>`, so artifacts are shareable. */
|
||||
function viewFromUrl(): View {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const fold = q.get("fold");
|
||||
const v = Number.parseInt(q.get("v") ?? "1", 10);
|
||||
return fold && Number.isFinite(v) && v >= 1
|
||||
? { kind: "artifact", fold, version: v }
|
||||
: { kind: "explore" };
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const initial = fromUrl();
|
||||
const [selection, setSelection] = useState<Selection>(initial.selection);
|
||||
const [window, setWindow] = useState<TimeWindow>(initial.window);
|
||||
const [view, setView] = useState<View>(viewFromUrl);
|
||||
|
||||
const status = useQuery({
|
||||
queryKey: ["status"],
|
||||
queryFn: api.status,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const s = status.data;
|
||||
const dotClass = status.isError
|
||||
? "error"
|
||||
: s?.connection === "connected"
|
||||
? "connected"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
<h1>🐝 accumulator</h1>
|
||||
<span className="status-pill">
|
||||
<span className={`status-dot ${dotClass}`} />
|
||||
{status.isError
|
||||
? "daemon unreachable — is it running on :4640?"
|
||||
: s
|
||||
? `${s.connection} · ${s.total_events.toLocaleString()} events · ${
|
||||
s.folds
|
||||
} folds · ${s.artifacts} artifacts${
|
||||
s.backfill_complete ? "" : " · backfilling…"
|
||||
}`
|
||||
: "connecting…"}
|
||||
</span>
|
||||
{view.kind === "artifact" && (
|
||||
<button onClick={() => setView({ kind: "explore" })}>
|
||||
← back to explore
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="columns">
|
||||
<aside className="pane left">
|
||||
<SelectionComposer
|
||||
selection={selection}
|
||||
onChange={setSelection}
|
||||
window={window}
|
||||
onWindowChange={setWindow}
|
||||
/>
|
||||
</aside>
|
||||
<main className="pane">
|
||||
{view.kind === "explore" ? (
|
||||
<Explore selection={selection} window={window} />
|
||||
) : (
|
||||
<ArtifactReader
|
||||
fold={view.fold}
|
||||
version={view.version}
|
||||
onVersion={(v) =>
|
||||
setView({ kind: "artifact", fold: view.fold, version: v })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<aside className="pane right">
|
||||
<FoldsBoard
|
||||
selection={selection}
|
||||
onOpenArtifact={(fold, version) =>
|
||||
setView({ kind: "artifact", fold, version })
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
245
crates/buzz-accumulator/ui/src/api.ts
Normal file
245
crates/buzz-accumulator/ui/src/api.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// Typed client for the daemon's loopback HTTP API (proxied at /api by vite).
|
||||
|
||||
export type Selection = {
|
||||
channels: string[];
|
||||
authors: string[];
|
||||
kinds: number[];
|
||||
};
|
||||
|
||||
/** Half-open run window; omitted bounds mean "everything up to now". */
|
||||
export type TimeWindow = { since?: number; until_exclusive?: number };
|
||||
|
||||
export type Status = {
|
||||
relay: string;
|
||||
pubkey: string;
|
||||
started_at: number;
|
||||
connection: string;
|
||||
connected_at: number | null;
|
||||
reconnect_attempts: number;
|
||||
last_error: string | null;
|
||||
last_event_at: number | null;
|
||||
backfill_complete: boolean;
|
||||
channels: Record<string, unknown>;
|
||||
total_events: number;
|
||||
folds: number;
|
||||
artifacts: number;
|
||||
db_path: string;
|
||||
};
|
||||
|
||||
export type Channel = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
channel_type: string;
|
||||
backfill_cursor: number | null;
|
||||
backfill_done: boolean;
|
||||
active: boolean;
|
||||
discovered_at: number;
|
||||
};
|
||||
|
||||
export type Bucket = { day: number; count: number };
|
||||
|
||||
export type Preview = {
|
||||
selection: Selection;
|
||||
window: { since: number; until_exclusive: number };
|
||||
count: number;
|
||||
total_chars: number;
|
||||
oldest_ts: number | null;
|
||||
newest_ts: number | null;
|
||||
buckets: Bucket[];
|
||||
};
|
||||
|
||||
export type EventItem = {
|
||||
id: string;
|
||||
channel: string | null;
|
||||
pubkey: string;
|
||||
author_name: string | null;
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type PageCursor = { created_at: number; id: string };
|
||||
|
||||
export type EventsPage = {
|
||||
window: { since: number; until_exclusive: number };
|
||||
count: number;
|
||||
next: PageCursor | null;
|
||||
events: EventItem[];
|
||||
};
|
||||
|
||||
export type FoldSpec = {
|
||||
name: string;
|
||||
selection: Selection;
|
||||
schema: string;
|
||||
model: string;
|
||||
instructions: string;
|
||||
meta?: unknown;
|
||||
};
|
||||
|
||||
export type FoldRow = {
|
||||
name: string;
|
||||
spec: FoldSpec;
|
||||
versions: number;
|
||||
latest_version: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type WindowFit = {
|
||||
model_window: number | null;
|
||||
est_input_tokens: number;
|
||||
fits: boolean | null;
|
||||
headroom_tokens: number | null;
|
||||
};
|
||||
|
||||
export type Estimate = { est_input_tokens: number; window_fit: WindowFit };
|
||||
|
||||
export type Preflight =
|
||||
| { plan: "cached" }
|
||||
| { plan: "stalled"; reason: string; pending: number }
|
||||
| {
|
||||
plan: "ready";
|
||||
shown: number;
|
||||
pending: number;
|
||||
truncated: boolean;
|
||||
estimate: Estimate;
|
||||
window: [number, number];
|
||||
};
|
||||
|
||||
export type Artifact = {
|
||||
fold: string;
|
||||
version: number;
|
||||
output: string;
|
||||
shown_ids: string[];
|
||||
coverage_since: number | null;
|
||||
coverage_until: number | null;
|
||||
selection: Selection;
|
||||
channels: string[];
|
||||
model: string;
|
||||
schema: string;
|
||||
prompt_sha256: string;
|
||||
truncated: boolean;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type ArtifactSummary = {
|
||||
version: number;
|
||||
created_at: number;
|
||||
shown: number;
|
||||
coverage_since: number | null;
|
||||
coverage_until: number | null;
|
||||
channels: string[];
|
||||
model: string;
|
||||
schema: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type RunOutcome =
|
||||
| { status: "cached" }
|
||||
| { status: "stalled"; reason: string; pending: number }
|
||||
| { status: "refused"; reason: string; model_output: string }
|
||||
| { status: "unpublished"; reason: string; model_output: string }
|
||||
| {
|
||||
status: "folded";
|
||||
artifact: Artifact;
|
||||
shown: number;
|
||||
pending: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, init);
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
if (body.error) message = body.error;
|
||||
} catch {
|
||||
// non-JSON error body; keep the status line
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function json(body: unknown): Pick<RequestInit, "headers" | "body"> {
|
||||
return {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function post(body: unknown): RequestInit {
|
||||
return { method: "POST", ...json(body) };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status: () => request<Status>("/status"),
|
||||
channels: () => request<{ channels: Channel[] }>("/channels"),
|
||||
event: (id: string) => request<EventItem>(`/events/${id}`),
|
||||
preview: (selection: Selection, window: TimeWindow) =>
|
||||
request<Preview>("/select/preview", post({ selection, ...window })),
|
||||
events: (
|
||||
selection: Selection,
|
||||
window: TimeWindow,
|
||||
limit: number,
|
||||
after: PageCursor | null,
|
||||
) =>
|
||||
request<EventsPage>(
|
||||
"/select/events",
|
||||
post({ selection, ...window, limit, after }),
|
||||
),
|
||||
folds: () => request<{ folds: FoldRow[] }>("/folds"),
|
||||
putFold: (
|
||||
name: string,
|
||||
body: {
|
||||
selection: Selection;
|
||||
model: string;
|
||||
instructions?: string;
|
||||
meta?: unknown;
|
||||
},
|
||||
) => request<{ saved: FoldSpec }>(`/folds/${name}`, { method: "PUT", ...json(body) }),
|
||||
deleteFold: (name: string) =>
|
||||
request<{ deleted: string }>(`/folds/${name}`, { method: "DELETE" }),
|
||||
preflight: (name: string, window: TimeWindow) =>
|
||||
request<Preflight>(`/folds/${name}/preflight`, post(window)),
|
||||
run: (name: string, window: TimeWindow) =>
|
||||
request<RunOutcome>(`/folds/${name}/run`, post(window)),
|
||||
artifacts: (name: string) =>
|
||||
request<{ fold: string; artifacts: ArtifactSummary[] }>(
|
||||
`/folds/${name}/artifacts`,
|
||||
),
|
||||
artifact: (name: string, version: number) =>
|
||||
request<Artifact>(`/folds/${name}/artifacts/${version}`),
|
||||
};
|
||||
|
||||
export function selectionIsRunnable(s: Selection): boolean {
|
||||
return s.channels.length > 0 || s.authors.length > 0;
|
||||
}
|
||||
|
||||
export function shortId(id: string): string {
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
|
||||
export function fmtTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtDay(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString(undefined, {
|
||||
timeZone: "UTC",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`;
|
||||
}
|
||||
182
crates/buzz-accumulator/ui/src/components/ArtifactReader.tsx
Normal file
182
crates/buzz-accumulator/ui/src/components/ArtifactReader.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { api, fmtTime, shortId } from "../api";
|
||||
|
||||
type Props = {
|
||||
fold: string;
|
||||
version: number;
|
||||
onVersion: (v: number) => void;
|
||||
};
|
||||
|
||||
/** Renders one artifact version: Working Context + Log with citation chips,
|
||||
* a version scrubber, and a pinned-evidence rail. */
|
||||
export function ArtifactReader({ fold, version, onVersion }: Props) {
|
||||
const [pinned, setPinned] = useState<string[]>([]);
|
||||
|
||||
const chain = useQuery({
|
||||
queryKey: ["artifacts", fold],
|
||||
queryFn: () => api.artifacts(fold),
|
||||
});
|
||||
const artifact = useQuery({
|
||||
queryKey: ["artifact", fold, version],
|
||||
queryFn: () => api.artifact(fold, version),
|
||||
});
|
||||
|
||||
const pin = (id: string) =>
|
||||
setPinned((p) => (p.includes(id) ? p : [id, ...p]));
|
||||
|
||||
const a = artifact.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
<strong className="mono">{fold}</strong>
|
||||
<div className="version-strip">
|
||||
{chain.data?.artifacts.map((s) => (
|
||||
<button
|
||||
key={s.version}
|
||||
className={s.version === version ? "active" : ""}
|
||||
onClick={() => onVersion(s.version)}
|
||||
title={`${fmtTime(s.created_at)} · ${s.shown} shown${
|
||||
s.truncated ? " · chunked" : ""
|
||||
}`}
|
||||
>
|
||||
v{s.version}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{a && (
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 10 }}>
|
||||
{fmtTime(a.created_at)} · model {a.model} · {a.shown_ids.length} shown
|
||||
{a.truncated ? " · chunked window" : ""}
|
||||
{a.coverage_since !== null && a.coverage_until !== null && (
|
||||
<>
|
||||
{" "}
|
||||
· covers {fmtTime(a.coverage_since)} → {fmtTime(a.coverage_until)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{artifact.isError && (
|
||||
<div className="error-box">{(artifact.error as Error).message}</div>
|
||||
)}
|
||||
<div className="artifact">
|
||||
<div className="artifact-body">
|
||||
{a ? renderOutput(a.output, pin) : <span className="faint">loading…</span>}
|
||||
</div>
|
||||
<div className="evidence">
|
||||
<h2>Evidence</h2>
|
||||
{pinned.length === 0 && (
|
||||
<div className="faint" style={{ fontSize: 12 }}>
|
||||
Click a citation chip to pin the source message here — every
|
||||
claim is one click from its evidence.
|
||||
</div>
|
||||
)}
|
||||
{pinned.map((id) => (
|
||||
<EvidenceCard
|
||||
key={id}
|
||||
id={id}
|
||||
onClose={() => setPinned((p) => p.filter((x) => x !== id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceCard({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const ev = useQuery({
|
||||
queryKey: ["event", id],
|
||||
queryFn: () => api.event(id),
|
||||
retry: false,
|
||||
});
|
||||
return (
|
||||
<div className="evidence-card">
|
||||
<div className="head">
|
||||
<span>
|
||||
{ev.data ? (
|
||||
<>
|
||||
<strong>{ev.data.author_name ?? shortId(ev.data.pubkey)}</strong>{" "}
|
||||
· {fmtTime(ev.data.created_at)}
|
||||
</>
|
||||
) : (
|
||||
<span className="mono">{shortId(id)}…</span>
|
||||
)}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ padding: "0 6px" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="body">
|
||||
{ev.isError
|
||||
? `not in the mirror: ${(ev.error as Error).message}`
|
||||
: (ev.data?.content ?? "loading…")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CITATION = /\[event:([0-9a-f]{64})\]/g;
|
||||
|
||||
/** Minimal renderer for the artifact's markdown-shaped output: headings,
|
||||
* list items, paragraphs — with `[event:<id>]` turned into citation chips. */
|
||||
function renderOutput(output: string, pin: (id: string) => void): ReactNode {
|
||||
const blocks: ReactNode[] = [];
|
||||
let list: ReactNode[] = [];
|
||||
let key = 0;
|
||||
|
||||
const flushList = () => {
|
||||
if (list.length > 0) {
|
||||
blocks.push(<ul key={key++}>{list}</ul>);
|
||||
list = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
||||
list.push(<li key={key++}>{inline(trimmed.slice(2), pin)}</li>);
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
if (trimmed.startsWith("## ")) {
|
||||
blocks.push(<h4 key={key++}>{inline(trimmed.slice(3), pin)}</h4>);
|
||||
} else if (trimmed.startsWith("# ")) {
|
||||
blocks.push(<h3 key={key++}>{inline(trimmed.slice(2), pin)}</h3>);
|
||||
} else if (trimmed.length > 0) {
|
||||
blocks.push(<p key={key++}>{inline(trimmed, pin)}</p>);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function inline(text: string, pin: (id: string) => void): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let key = 0;
|
||||
CITATION.lastIndex = 0;
|
||||
let m = CITATION.exec(text);
|
||||
while (m !== null) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
const id = m[1];
|
||||
if (id) {
|
||||
out.push(
|
||||
<button
|
||||
key={key++}
|
||||
className="chip"
|
||||
title={id}
|
||||
onClick={() => pin(id)}
|
||||
>
|
||||
{shortId(id)}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
m = CITATION.exec(text);
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
198
crates/buzz-accumulator/ui/src/components/Explore.tsx
Normal file
198
crates/buzz-accumulator/ui/src/components/Explore.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
api,
|
||||
fmtDay,
|
||||
fmtTime,
|
||||
fmtTokens,
|
||||
selectionIsRunnable,
|
||||
shortId,
|
||||
type Bucket,
|
||||
type PageCursor,
|
||||
type Selection,
|
||||
type TimeWindow,
|
||||
} from "../api";
|
||||
|
||||
type Props = { selection: Selection; window: TimeWindow };
|
||||
|
||||
/** Selection preview: stats, daily rhythm, and the actual matching events. */
|
||||
export function Explore({ selection, window }: Props) {
|
||||
const runnable = selectionIsRunnable(selection);
|
||||
|
||||
const preview = useQuery({
|
||||
queryKey: ["preview", selection, window],
|
||||
queryFn: () => api.preview(selection, window),
|
||||
enabled: runnable,
|
||||
});
|
||||
|
||||
const events = useInfiniteQuery({
|
||||
queryKey: ["events", selection, window],
|
||||
queryFn: ({ pageParam }) => api.events(selection, window, 50, pageParam),
|
||||
initialPageParam: null as PageCursor | null,
|
||||
getNextPageParam: (last) => last.next,
|
||||
enabled: runnable,
|
||||
});
|
||||
|
||||
if (!runnable) {
|
||||
return (
|
||||
<div className="center-note">
|
||||
Pick at least one channel or author on the left to preview a selection.
|
||||
<br />
|
||||
Everything here is free — no model is called until you press Run on a
|
||||
fold.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const p = preview.data;
|
||||
const allEvents = events.data?.pages.flatMap((page) => page.events) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{preview.isError && (
|
||||
<div className="error-box">{(preview.error as Error).message}</div>
|
||||
)}
|
||||
{p && (
|
||||
<div className="stat-row">
|
||||
<div className="stat">
|
||||
<div className="num">{p.count.toLocaleString()}</div>
|
||||
<div className="cap">events</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="num">{p.total_chars.toLocaleString()}</div>
|
||||
<div className="cap">chars</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="num">~{fmtTokens(Math.ceil(p.total_chars / 4))}</div>
|
||||
<div className="cap">est. input tokens</div>
|
||||
</div>
|
||||
{p.oldest_ts !== null && p.newest_ts !== null && (
|
||||
<div className="stat">
|
||||
<div className="num" style={{ fontSize: 14, paddingTop: 5 }}>
|
||||
{fmtTime(p.oldest_ts)} → {fmtTime(p.newest_ts)}
|
||||
</div>
|
||||
<div className="cap">span</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p && p.buckets.length > 0 && <RhythmStrip buckets={p.buckets} />}
|
||||
|
||||
{allEvents.map((ev) => (
|
||||
<EventRow
|
||||
key={ev.id}
|
||||
id={ev.id}
|
||||
author={ev.author_name ?? shortId(ev.pubkey)}
|
||||
time={ev.created_at}
|
||||
content={ev.content}
|
||||
/>
|
||||
))}
|
||||
{events.hasNextPage && (
|
||||
<button
|
||||
onClick={() => events.fetchNextPage()}
|
||||
disabled={events.isFetchingNextPage}
|
||||
>
|
||||
{events.isFetchingNextPage ? "loading…" : "load more"}
|
||||
</button>
|
||||
)}
|
||||
{events.data && allEvents.length === 0 && (
|
||||
<div className="center-note">No events match this selection.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
id,
|
||||
author,
|
||||
time,
|
||||
content,
|
||||
}: {
|
||||
id: string;
|
||||
author: string;
|
||||
time: number;
|
||||
content: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={`event-row${expanded ? " expanded" : ""}`}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<div className="head">
|
||||
<strong>{author}</strong>
|
||||
<span>{fmtTime(time)}</span>
|
||||
<span className="mono faint">{shortId(id)}</span>
|
||||
</div>
|
||||
<div className="body">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Daily activity histogram. Single series: one hue, gaps filled with
|
||||
* zero-height days so rhythm (and silence) is visible. */
|
||||
function RhythmStrip({ buckets }: { buckets: Bucket[] }) {
|
||||
const DAY = 86_400;
|
||||
const first = buckets[0];
|
||||
const last = buckets[buckets.length - 1];
|
||||
if (!first || !last) return null;
|
||||
|
||||
const byDay = new Map(buckets.map((b) => [b.day, b.count]));
|
||||
const days: Bucket[] = [];
|
||||
for (let d = first.day; d <= last.day; d += DAY) {
|
||||
days.push({ day: d, count: byDay.get(d) ?? 0 });
|
||||
}
|
||||
// Beyond ~a year of days, group whole weeks so bars stay visible.
|
||||
const perWeek = days.length > 366;
|
||||
const cells: Bucket[] = perWeek
|
||||
? Array.from({ length: Math.ceil(days.length / 7) }, (_, i) => ({
|
||||
day: days[i * 7]?.day ?? 0,
|
||||
count: days
|
||||
.slice(i * 7, i * 7 + 7)
|
||||
.reduce((acc, b) => acc + b.count, 0),
|
||||
}))
|
||||
: days;
|
||||
|
||||
const w = 800;
|
||||
const h = 64;
|
||||
const gap = 2;
|
||||
const bw = Math.max(1, w / cells.length - gap);
|
||||
const max = Math.max(1, ...cells.map((c) => c.count));
|
||||
|
||||
return (
|
||||
<div className="rhythm">
|
||||
<div className="cap faint" style={{ marginBottom: 6, fontSize: 11 }}>
|
||||
ACTIVITY BY {perWeek ? "WEEK" : "DAY"} · {fmtDay(first.day)} –{" "}
|
||||
{fmtDay(last.day)}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
width="100%"
|
||||
height={h}
|
||||
role="img"
|
||||
aria-label="activity histogram"
|
||||
>
|
||||
{cells.map((c, i) => {
|
||||
const bh = Math.max(c.count > 0 ? 2 : 0, (c.count / max) * (h - 4));
|
||||
return (
|
||||
<rect
|
||||
key={c.day}
|
||||
className="bar"
|
||||
x={(i * w) / cells.length}
|
||||
y={h - bh}
|
||||
width={bw}
|
||||
height={bh}
|
||||
rx={Math.min(2, bw / 2)}
|
||||
fill="var(--accent)"
|
||||
>
|
||||
<title>
|
||||
{fmtDay(c.day)}: {c.count} events
|
||||
</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
crates/buzz-accumulator/ui/src/components/FoldsBoard.tsx
Normal file
262
crates/buzz-accumulator/ui/src/components/FoldsBoard.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
api,
|
||||
fmtTime,
|
||||
fmtTokens,
|
||||
selectionIsRunnable,
|
||||
type FoldRow,
|
||||
type Preflight,
|
||||
type RunOutcome,
|
||||
type Selection,
|
||||
} from "../api";
|
||||
|
||||
type Props = {
|
||||
selection: Selection;
|
||||
onOpenArtifact: (fold: string, version: number) => void;
|
||||
};
|
||||
|
||||
export function FoldsBoard({ selection, onOpenArtifact }: Props) {
|
||||
const folds = useQuery({ queryKey: ["folds"], queryFn: api.folds });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Folds</h2>
|
||||
<CreateFold selection={selection} />
|
||||
{folds.isError && (
|
||||
<div className="error-box">{(folds.error as Error).message}</div>
|
||||
)}
|
||||
{folds.data?.folds.length === 0 && (
|
||||
<div className="faint">
|
||||
No folds yet. Compose a selection, then create one above — runs are
|
||||
always priced before they spend.
|
||||
</div>
|
||||
)}
|
||||
{folds.data?.folds.map((f) => (
|
||||
<FoldCard key={f.name} fold={f} onOpenArtifact={onOpenArtifact} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFold({ selection }: { selection: Selection }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [model, setModel] = useState("haiku");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api.putFold(name.trim(), {
|
||||
selection,
|
||||
model,
|
||||
...(instructions.trim() ? { instructions } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setInstructions("");
|
||||
qc.invalidateQueries({ queryKey: ["folds"] });
|
||||
},
|
||||
});
|
||||
|
||||
const ready = name.trim().length > 0 && selectionIsRunnable(selection);
|
||||
|
||||
return (
|
||||
<div className="create-fold">
|
||||
<div className="field">
|
||||
<label>New fold (uses the current selection)</label>
|
||||
<input
|
||||
className="mono"
|
||||
placeholder="name — [a-z0-9-], e.g. digest--2026-w36"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Model</label>
|
||||
<select value={model} onChange={(e) => setModel(e.target.value)}>
|
||||
<option value="haiku">haiku</option>
|
||||
<option value="sonnet">sonnet</option>
|
||||
<option value="opus">opus</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Instructions</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="empty = built-in channel-digest prompt"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!ready || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
{create.isPending ? "saving…" : "create fold"}
|
||||
</button>
|
||||
{!selectionIsRunnable(selection) && (
|
||||
<span className="faint" style={{ marginLeft: 8 }}>
|
||||
needs a selection
|
||||
</span>
|
||||
)}
|
||||
{create.isError && (
|
||||
<div className="plan-box err">{(create.error as Error).message}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FoldCard({
|
||||
fold,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
fold: FoldRow;
|
||||
onOpenArtifact: (fold: string, version: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [plan, setPlan] = useState<Preflight | null>(null);
|
||||
const [outcome, setOutcome] = useState<RunOutcome | null>(null);
|
||||
|
||||
const preflight = useMutation({
|
||||
mutationFn: () => api.preflight(fold.name, {}),
|
||||
onSuccess: (p) => {
|
||||
setPlan(p);
|
||||
setOutcome(null);
|
||||
},
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => {
|
||||
if (plan?.plan !== "ready") throw new Error("preflight first");
|
||||
// Pin the run to the exact window that was priced.
|
||||
const [since, until_exclusive] = plan.window;
|
||||
return api.run(fold.name, { since, until_exclusive });
|
||||
},
|
||||
onSuccess: (o) => {
|
||||
setOutcome(o);
|
||||
setPlan(null);
|
||||
qc.invalidateQueries({ queryKey: ["folds"] });
|
||||
qc.invalidateQueries({ queryKey: ["artifacts", fold.name] });
|
||||
if (o.status === "folded") {
|
||||
onOpenArtifact(fold.name, o.artifact.version);
|
||||
}
|
||||
},
|
||||
onError: () => setPlan(null),
|
||||
});
|
||||
|
||||
const sel = fold.spec.selection;
|
||||
const selDesc = [
|
||||
sel.channels.length > 0 ? `${sel.channels.length} channel(s)` : null,
|
||||
sel.authors.length > 0 ? `${sel.authors.length} author(s)` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<div className="fold-card">
|
||||
<div className="name mono">{fold.name}</div>
|
||||
<div className="desc">
|
||||
{selDesc || "—"} · {fold.spec.model} ·{" "}
|
||||
{fold.versions > 0
|
||||
? `v${fold.latest_version} · updated ${fmtTime(fold.updated_at)}`
|
||||
: "never run"}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
onClick={() => preflight.mutate()}
|
||||
disabled={preflight.isPending || run.isPending}
|
||||
>
|
||||
{preflight.isPending ? "pricing…" : "preflight ($0)"}
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={plan?.plan !== "ready" || run.isPending}
|
||||
onClick={() => run.mutate()}
|
||||
>
|
||||
{run.isPending ? "folding…" : "run"}
|
||||
</button>
|
||||
{fold.latest_version !== null && (
|
||||
<button onClick={() => onOpenArtifact(fold.name, fold.latest_version ?? 1)}>
|
||||
read v{fold.latest_version}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{preflight.isError && (
|
||||
<div className="plan-box err">{(preflight.error as Error).message}</div>
|
||||
)}
|
||||
{run.isError && (
|
||||
<div className="plan-box err">{(run.error as Error).message}</div>
|
||||
)}
|
||||
{plan && <PlanBox plan={plan} />}
|
||||
{outcome && <OutcomeBox outcome={outcome} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanBox({ plan }: { plan: Preflight }) {
|
||||
if (plan.plan === "cached") {
|
||||
return <div className="plan-box ok">Up to date — nothing new to fold.</div>;
|
||||
}
|
||||
if (plan.plan === "stalled") {
|
||||
return (
|
||||
<div className="plan-box warn">
|
||||
Stalled: {plan.reason} ({plan.pending} pending)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const est = plan.estimate;
|
||||
const fits = est.window_fit.fits;
|
||||
return (
|
||||
<div className="plan-box ok">
|
||||
Ready: {plan.shown} shown / {plan.pending} pending
|
||||
{plan.truncated ? " (chunked)" : ""} · ~
|
||||
{fmtTokens(est.est_input_tokens)} input tokens
|
||||
{fits === false ? " · OVER the model window" : ""}
|
||||
<br />
|
||||
<span className="faint">
|
||||
Run is pinned to this priced window; press run to spend.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeBox({ outcome }: { outcome: RunOutcome }) {
|
||||
switch (outcome.status) {
|
||||
case "cached":
|
||||
return <div className="plan-box ok">Already up to date.</div>;
|
||||
case "stalled":
|
||||
return (
|
||||
<div className="plan-box warn">
|
||||
Stalled: {outcome.reason} ({outcome.pending} pending)
|
||||
</div>
|
||||
);
|
||||
case "refused":
|
||||
return (
|
||||
<div className="plan-box err">
|
||||
Model output refused ({outcome.reason}). Nothing persisted; raw
|
||||
output:
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{outcome.model_output.slice(0, 2000)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
case "unpublished":
|
||||
return (
|
||||
<div className="plan-box err">
|
||||
Not persisted: {outcome.reason}
|
||||
</div>
|
||||
);
|
||||
case "folded":
|
||||
return (
|
||||
<div className="plan-box ok">
|
||||
Folded v{outcome.artifact.version} — {outcome.shown} shown
|
||||
{outcome.truncated
|
||||
? ` (chunked; ${outcome.pending - outcome.shown} remain)`
|
||||
: ""}
|
||||
.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
189
crates/buzz-accumulator/ui/src/components/SelectionComposer.tsx
Normal file
189
crates/buzz-accumulator/ui/src/components/SelectionComposer.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Selection, type TimeWindow } from "../api";
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
const PRESETS: { label: string; days: number | null }[] = [
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "30d", days: 30 },
|
||||
{ label: "90d", days: 90 },
|
||||
{ label: "all", days: null },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
selection: Selection;
|
||||
onChange: (s: Selection) => void;
|
||||
window: TimeWindow;
|
||||
onWindowChange: (w: TimeWindow) => void;
|
||||
};
|
||||
|
||||
export function SelectionComposer({
|
||||
selection,
|
||||
onChange,
|
||||
window,
|
||||
onWindowChange,
|
||||
}: Props) {
|
||||
const channels = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||
|
||||
const toggleChannel = (id: string) => {
|
||||
const has = selection.channels.includes(id);
|
||||
onChange({
|
||||
...selection,
|
||||
channels: has
|
||||
? selection.channels.filter((c) => c !== id)
|
||||
: [...selection.channels, id],
|
||||
});
|
||||
};
|
||||
|
||||
const activePreset = (days: number | null) => {
|
||||
if (days === null) return window.since === undefined;
|
||||
if (window.since === undefined) return false;
|
||||
const target = Math.floor(Date.now() / 1000) - days * DAY;
|
||||
// A preset stays highlighted for the minute it was clicked in.
|
||||
return Math.abs(window.since - target) < 60;
|
||||
};
|
||||
|
||||
const list = channels.data?.channels.filter((c) => c.active) ?? [];
|
||||
const sorted = [...list].sort((a, b) =>
|
||||
(a.name ?? a.id).localeCompare(b.name ?? b.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Selection</h2>
|
||||
|
||||
<div className="field">
|
||||
<label>
|
||||
Channels{" "}
|
||||
<span className="faint">
|
||||
({selection.channels.length || "none"} selected)
|
||||
</span>
|
||||
</label>
|
||||
{channels.isLoading ? (
|
||||
<div className="faint">loading…</div>
|
||||
) : (
|
||||
<div className="channel-list">
|
||||
{sorted.map((c) => (
|
||||
<label key={c.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.channels.includes(c.id)}
|
||||
onChange={() => toggleChannel(c.id)}
|
||||
/>
|
||||
<span>{c.name ?? `${c.id.slice(0, 8)}…`}</span>
|
||||
{!c.backfill_done && <span className="faint">(syncing)</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => onChange({ ...selection, channels: [] })}
|
||||
disabled={selection.channels.length === 0}
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Authors (64-hex pubkeys, one per line)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="mono"
|
||||
placeholder="optional — person-across-channels"
|
||||
value={selection.authors.join("\n")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...selection,
|
||||
authors: e.target.value
|
||||
.split(/[\s,]+/)
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kinds (comma-separated; empty = 9, chat)</label>
|
||||
<input
|
||||
className="mono"
|
||||
placeholder="9"
|
||||
value={selection.kinds.join(",")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...selection,
|
||||
kinds: e.target.value
|
||||
.split(",")
|
||||
.map((k) => Number.parseInt(k.trim(), 10))
|
||||
.filter((k) => Number.isFinite(k) && k >= 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2>Window</h2>
|
||||
<div className="field">
|
||||
<div className="preset-row">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
className={activePreset(p.days) ? "active" : ""}
|
||||
onClick={() =>
|
||||
onWindowChange(
|
||||
p.days === null
|
||||
? {}
|
||||
: {
|
||||
since:
|
||||
Math.floor(Date.now() / 1000) - (p.days ?? 0) * DAY,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Since</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(window.since)}
|
||||
onChange={(e) =>
|
||||
onWindowChange({ ...window, since: fromLocalInput(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Until (exclusive)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(window.until_exclusive)}
|
||||
onChange={(e) =>
|
||||
onWindowChange({
|
||||
...window,
|
||||
until_exclusive: fromLocalInput(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toLocalInput(ts: number | undefined): string {
|
||||
if (ts === undefined) return "";
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n: number) => `${n}`.padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromLocalInput(v: string): number | undefined {
|
||||
if (!v) return undefined;
|
||||
const ms = new Date(v).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
22
crates/buzz-accumulator/ui/src/main.tsx
Normal file
22
crates/buzz-accumulator/ui/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
468
crates/buzz-accumulator/ui/src/styles.css
Normal file
468
crates/buzz-accumulator/ui/src/styles.css
Normal file
@@ -0,0 +1,468 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a21;
|
||||
--surface-2: #1e222c;
|
||||
--border: #2a2f3b;
|
||||
--ink: #e6e9ef;
|
||||
--ink-2: #9aa3b2;
|
||||
--ink-3: #6b7484;
|
||||
--accent: #e3ab47;
|
||||
--accent-ink: #1a1405;
|
||||
--good: #6fbf85;
|
||||
--bad: #e07a6b;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--ink-3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Outranks the `.field input { width: 100% }` block-input default. */
|
||||
input[type="checkbox"],
|
||||
.field input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
flex: none;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.columns {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr 360px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane {
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane.left {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane.right {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane h2 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-3);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.channel-list label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 3px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channel-list label:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.preset-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-row button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat .num {
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat .cap {
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.rhythm {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rhythm .bar:hover {
|
||||
fill: var(--ink);
|
||||
}
|
||||
|
||||
.event-row {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-row .head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.event-row .body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.event-row.expanded .body {
|
||||
display: block;
|
||||
-webkit-line-clamp: none;
|
||||
}
|
||||
|
||||
.fold-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.fold-card .name {
|
||||
font-weight: 650;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fold-card .desc {
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fold-card .actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-box {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.plan-box.err {
|
||||
border-left: 3px solid var(--bad);
|
||||
}
|
||||
|
||||
.plan-box.ok {
|
||||
border-left: 3px solid var(--good);
|
||||
}
|
||||
|
||||
.plan-box.warn {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.create-fold {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
margin: 0 2px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
font: 11.5px/1.6 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
cursor: pointer;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.artifact {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.artifact-body {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.artifact-body h3 {
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 6px;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.artifact-body h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.artifact-body h4 {
|
||||
font-size: 14px;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
|
||||
.artifact-body ul {
|
||||
margin: 6px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.artifact-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.artifact-body p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.version-strip {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.version-strip button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.evidence {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.evidence-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.evidence-card .head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-card .body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--bad);
|
||||
border-radius: 8px;
|
||||
color: var(--bad);
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.center-note {
|
||||
color: var(--ink-3);
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
code,
|
||||
.mono {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
18
crates/buzz-accumulator/ui/tsconfig.json
Normal file
18
crates/buzz-accumulator/ui/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
16
crates/buzz-accumulator/ui/vite.config.ts
Normal file
16
crates/buzz-accumulator/ui/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// The lab talks to the daemon through this dev proxy: same origin, no CORS,
|
||||
// and the daemon's HTTP API stays loopback-pure with zero UI knowledge.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:4640",
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user