(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 (
+
+
{fold.name}
+
+ {selDesc || "—"} · {fold.spec.model} ·{" "}
+ {fold.versions > 0
+ ? `v${fold.latest_version} · updated ${fmtTime(fold.updated_at)}`
+ : "never run"}
+
+
+
+
+ {fold.latest_version !== null && (
+
+ )}
+
+ {preflight.isError && (
+
{(preflight.error as Error).message}
+ )}
+ {run.isError && (
+
{(run.error as Error).message}
+ )}
+ {plan &&
}
+ {outcome &&
}
+
+ );
+}
+
+function PlanBox({ plan }: { plan: Preflight }) {
+ if (plan.plan === "cached") {
+ return Up to date — nothing new to fold.
;
+ }
+ if (plan.plan === "stalled") {
+ return (
+
+ Stalled: {plan.reason} ({plan.pending} pending)
+
+ );
+ }
+ const est = plan.estimate;
+ const fits = est.window_fit.fits;
+ return (
+
+ Ready: {plan.shown} shown / {plan.pending} pending
+ {plan.truncated ? " (chunked)" : ""} · ~
+ {fmtTokens(est.est_input_tokens)} input tokens
+ {fits === false ? " · OVER the model window" : ""}
+
+
+ Run is pinned to this priced window; press run to spend.
+
+
+ );
+}
+
+function OutcomeBox({ outcome }: { outcome: RunOutcome }) {
+ switch (outcome.status) {
+ case "cached":
+ return Already up to date.
;
+ case "stalled":
+ return (
+
+ Stalled: {outcome.reason} ({outcome.pending} pending)
+
+ );
+ case "refused":
+ return (
+
+ Model output refused ({outcome.reason}). Nothing persisted; raw
+ output:
+
+ {outcome.model_output.slice(0, 2000)}
+
+
+ );
+ case "unpublished":
+ return (
+
+ Not persisted: {outcome.reason}
+
+ );
+ case "folded":
+ return (
+
+ Folded v{outcome.artifact.version} — {outcome.shown} shown
+ {outcome.truncated
+ ? ` (chunked; ${outcome.pending - outcome.shown} remain)`
+ : ""}
+ .
+
+ );
+ }
+}
diff --git a/crates/buzz-accumulator/ui/src/components/SelectionComposer.tsx b/crates/buzz-accumulator/ui/src/components/SelectionComposer.tsx
new file mode 100644
index 0000000000..42932a3daf
--- /dev/null
+++ b/crates/buzz-accumulator/ui/src/components/SelectionComposer.tsx
@@ -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 (
+
+
Selection
+
+
+
+ {channels.isLoading ? (
+
loading…
+ ) : (
+
+ {sorted.map((c) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ onChange({
+ ...selection,
+ kinds: e.target.value
+ .split(",")
+ .map((k) => Number.parseInt(k.trim(), 10))
+ .filter((k) => Number.isFinite(k) && k >= 0),
+ })
+ }
+ />
+
+
+
Window
+
+
+ {PRESETS.map((p) => (
+
+ ))}
+
+
+
+
+
+ onWindowChange({ ...window, since: fromLocalInput(e.target.value) })
+ }
+ />
+
+
+
+
+ onWindowChange({
+ ...window,
+ until_exclusive: fromLocalInput(e.target.value),
+ })
+ }
+ />
+
+
+ );
+}
+
+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;
+}
diff --git a/crates/buzz-accumulator/ui/src/main.tsx b/crates/buzz-accumulator/ui/src/main.tsx
new file mode 100644
index 0000000000..73d002c284
--- /dev/null
+++ b/crates/buzz-accumulator/ui/src/main.tsx
@@ -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(
+
+
+
+
+ ,
+ );
+}
diff --git a/crates/buzz-accumulator/ui/src/styles.css b/crates/buzz-accumulator/ui/src/styles.css
new file mode 100644
index 0000000000..42331ec620
--- /dev/null
+++ b/crates/buzz-accumulator/ui/src/styles.css
@@ -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;
+}
diff --git a/crates/buzz-accumulator/ui/tsconfig.json b/crates/buzz-accumulator/ui/tsconfig.json
new file mode 100644
index 0000000000..9bc00968d7
--- /dev/null
+++ b/crates/buzz-accumulator/ui/tsconfig.json
@@ -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"]
+}
diff --git a/crates/buzz-accumulator/ui/vite.config.ts b/crates/buzz-accumulator/ui/vite.config.ts
new file mode 100644
index 0000000000..2bb6b0c040
--- /dev/null
+++ b/crates/buzz-accumulator/ui/vite.config.ts
@@ -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/, ""),
+ },
+ },
+ },
+});