Skip to content

Commit 75658e0

Browse files
paddymulclaude
andcommitted
feat(log): Log page — linear, filterable, expandable activity view
New /{project}/log page (+ nav link): a single chronological stream of mcp / alias / buckaroo events with category + per-session filter chips, session color badges, a newest/oldest toggle, and click-to-expand rows showing the prompt, code, full traceback (build failures), and Buckaroo load timing. Live-refreshes off the SSE version bump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c540256 commit 75658e0

6 files changed

Lines changed: 267 additions & 0 deletions

File tree

packages/app/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CatalogPage } from "./pages/CatalogPage";
77
import { NotebookPage } from "./pages/NotebookPage";
88
import { DiffPage } from "./pages/DiffPage";
99
import { CachePage } from "./pages/CachePage";
10+
import { LogPage } from "./pages/LogPage";
1011
import { EmptyStatePage } from "./pages/EmptyStatePage";
1112
import { ProjectsPage } from "./pages/ProjectsPage";
1213
import { api } from "./api";
@@ -51,6 +52,7 @@ export default function App() {
5152
<Route path="errors/:errorId" element={<CatalogPage />} />
5253
<Route path="notebook" element={<NotebookPage />} />
5354
<Route path="cache" element={<CachePage />} />
55+
<Route path="log" element={<LogPage />} />
5456
<Route path="diff/:alias" element={<DiffPage />} />
5557
<Route path="diff/:alias/:va/:vb" element={<DiffPage />} />
5658
<Route path="*" element={<Navigate to="catalog" replace />} />

packages/app/src/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
AppError,
44
EntryDetail,
55
EntryCache,
6+
ActivityLog,
67
SessionResult,
78
NotebookFull,
89
DiffData,
@@ -63,6 +64,14 @@ export const api = {
6364
entryCache: (project: string, hash: string): Promise<EntryCache> =>
6465
get(`/${project}/api/entry_cache/${hash}`),
6566

67+
log: (project: string, opts?: { categories?: string[]; sessions?: string[] }): Promise<ActivityLog> => {
68+
const p = new URLSearchParams();
69+
if (opts?.categories?.length) p.set("categories", opts.categories.join(","));
70+
if (opts?.sessions?.length) p.set("sessions", opts.sessions.join(","));
71+
const qs = p.toString();
72+
return get(`/${project}/api/log${qs ? `?${qs}` : ""}`);
73+
},
74+
6675
diskUsage: (project: string): Promise<{ formatted: Record<string, string> }> =>
6776
get(`/${project}/api/disk_usage`),
6877

packages/app/src/components/Header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function Header() {
8080
<Link to={`/${project}/catalog`}>catalog</Link>
8181
<Link to={`/${project}/notebook`}>notebook</Link>
8282
<Link to={`/${project}/cache`}>cache</Link>
83+
<Link to={`/${project}/log`}>log</Link>
8384
<Link to="/projects">projects</Link>
8485
</nav>
8586
)}

packages/app/src/pages/LogPage.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
import { Link, useParams } from "react-router-dom";
3+
import { api } from "../api";
4+
import { useSSE } from "../SSEContext";
5+
import type { ActivityLog, LogEvent } from "../types";
6+
7+
// The project activity log: a linear, cross-session feed of MCP build runs
8+
// (success/failure with the full traceback), alias creations, and Buckaroo grid
9+
// loads. Filterable by category (mcp/alias/buckaroo) and by the session that
10+
// produced the events — multiple Claude Code sessions write to one project.
11+
12+
const CATEGORIES = ["mcp", "alias", "buckaroo"] as const;
13+
type Category = (typeof CATEGORIES)[number];
14+
15+
const ICON: Record<string, string> = { build_error: "✗", build_ok: "✓", alias_set: "★", buckaroo: "▤" };
16+
const SESSION_COLORS = ["#6ea8fe", "#73ae80", "#e0915e", "#b39ddb", "#7fd1c4", "#d6b35e", "#e8849b", "#9ab07a"];
17+
18+
function sessionColor(s: string): string {
19+
let h = 0;
20+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
21+
return SESSION_COLORS[h % SESSION_COLORS.length];
22+
}
23+
24+
function fmtTime(ts: string): string {
25+
return ts.slice(11, 19); // HH:MM:SS (UTC, as stored)
26+
}
27+
28+
function summarize(e: LogEvent): string {
29+
switch (e.kind) {
30+
case "build_error":
31+
return `build failed · ${e.tool ?? "?"} · ${(e.message ?? "").split("\n")[0].slice(0, 120)}`;
32+
case "build_ok":
33+
return `ran · ${e.tool ?? "?"}${e.hash ? ` · ${e.hash.slice(0, 12)}` : ""}`;
34+
case "alias_set":
35+
return `${e.alias} V${e.version} · ${e.tool ?? "?"}`;
36+
case "buckaroo":
37+
return `grid ${e.status ?? "?"}${e.hash ? ` · ${e.hash.slice(0, 12)}` : ""}${e.load_ms != null ? ` · ${e.load_ms}ms` : ""}`;
38+
default:
39+
return e.kind;
40+
}
41+
}
42+
43+
function hasDetail(e: LogEvent): boolean {
44+
return Boolean(e.code || e.prompt || e.traceback || e.detail || e.load_ms != null || e.hash);
45+
}
46+
47+
export function LogPage() {
48+
const { project } = useParams<{ project: string }>();
49+
const { version } = useSSE();
50+
const [data, setData] = useState<ActivityLog | null>(null);
51+
const [cats, setCats] = useState<Set<Category>>(new Set(CATEGORIES));
52+
const [session, setSession] = useState<string | null>(null);
53+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
54+
const [oldestFirst, setOldestFirst] = useState(false);
55+
56+
useEffect(() => {
57+
if (!project) return;
58+
api
59+
.log(project)
60+
.then(setData)
61+
.catch(() => {});
62+
}, [project, version]); // SSE bumps `version` on new_entry/build_failed → live refresh
63+
64+
const events = useMemo(() => {
65+
if (!data) return [];
66+
let evs = data.events.filter((e) => cats.has(e.category as Category));
67+
if (session) evs = evs.filter((e) => e.session === session);
68+
if (oldestFirst) evs = [...evs].reverse();
69+
return evs;
70+
}, [data, cats, session, oldestFirst]);
71+
72+
if (!project) return null;
73+
74+
const toggleCat = (c: Category) =>
75+
setCats((s) => {
76+
const n = new Set(s);
77+
if (n.has(c)) n.delete(c);
78+
else n.add(c);
79+
return n;
80+
});
81+
const toggleExpand = (key: string) =>
82+
setExpanded((s) => {
83+
const n = new Set(s);
84+
if (n.has(key)) n.delete(key);
85+
else n.add(key);
86+
return n;
87+
});
88+
89+
return (
90+
<main style={{ padding: "8px 12px", overflow: "auto" }}>
91+
<div className="log-page">
92+
<div className="log-filters">
93+
<span className="meta">show</span>
94+
{CATEGORIES.map((c) => (
95+
<button key={c} type="button" className={`chip${cats.has(c) ? " on" : ""}`} onClick={() => toggleCat(c)}>
96+
{c}
97+
</button>
98+
))}
99+
<span className="log-sep" />
100+
<span className="meta">session</span>
101+
<button type="button" className={`chip${session === null ? " on" : ""}`} onClick={() => setSession(null)}>
102+
all
103+
</button>
104+
{data?.sessions.map((s) => (
105+
<button
106+
key={s.session}
107+
type="button"
108+
className={`chip${session === s.session ? " on" : ""}`}
109+
onClick={() => setSession(session === s.session ? null : s.session)}
110+
title={`${s.count} events · last ${fmtTime(s.last_ts)}`}
111+
>
112+
<span className="session-dot" style={{ background: sessionColor(s.session) }} /> {s.session}
113+
</button>
114+
))}
115+
<span className="log-sep" />
116+
<button type="button" className="chip" onClick={() => setOldestFirst((v) => !v)}>
117+
{oldestFirst ? "oldest first" : "newest first"}
118+
</button>
119+
</div>
120+
121+
{!data ? (
122+
<div className="meta">loading…</div>
123+
) : events.length === 0 ? (
124+
<div className="meta">{data.events.length ? "no events match the filters." : "no activity recorded yet."}</div>
125+
) : (
126+
<ol className="log-list">
127+
{events.map((e, i) => {
128+
const key = `${e.ts}-${i}`;
129+
const open = expanded.has(key);
130+
const detail = hasDetail(e);
131+
return (
132+
<li key={key} className={`log-row kind-${e.kind}`}>
133+
<button
134+
type="button"
135+
className="log-head"
136+
onClick={() => detail && toggleExpand(key)}
137+
style={{ cursor: detail ? "pointer" : "default" }}
138+
>
139+
<span className="log-time">{fmtTime(e.ts)}</span>
140+
<span className={`cat-badge cat-${e.category}`}>
141+
{ICON[e.kind] ?? "•"} {e.category}
142+
</span>
143+
{e.session ? (
144+
<span
145+
className="session-badge"
146+
style={{ borderColor: sessionColor(e.session), color: sessionColor(e.session) }}
147+
>
148+
{e.session}
149+
</span>
150+
) : e.origin === "companion" ? (
151+
<span className="session-badge muted">companion</span>
152+
) : null}
153+
<span className="log-summary">{summarize(e)}</span>
154+
{detail && <span className="log-caret">{open ? "▾" : "▸"}</span>}
155+
</button>
156+
{open && (
157+
<div className="log-detail">
158+
{e.prompt && (
159+
<div className="log-prompt">
160+
<strong>prompt:</strong> {e.prompt}
161+
</div>
162+
)}
163+
{e.hash && (
164+
<div className="meta">
165+
entry <Link to={`/${project}/catalog/${e.hash}`}>{e.hash.slice(0, 12)}</Link>
166+
</div>
167+
)}
168+
{e.detail && <div className="meta">{e.detail}</div>}
169+
{(e.load_ms != null || e.load_expr_ms != null) && (
170+
<div className="meta">
171+
timing:{e.load_ms != null && ` ${e.load_ms}ms total`}
172+
{e.load_expr_ms != null && ` · ${e.load_expr_ms}ms load_expr`}
173+
</div>
174+
)}
175+
{e.code && (
176+
<>
177+
<div className="meta">code</div>
178+
<pre className="code">{e.code}</pre>
179+
</>
180+
)}
181+
{e.traceback && (
182+
<>
183+
<div className="meta">traceback</div>
184+
<pre className="code log-traceback">{e.traceback}</pre>
185+
</>
186+
)}
187+
</div>
188+
)}
189+
</li>
190+
);
191+
})}
192+
</ol>
193+
)}
194+
</div>
195+
</main>
196+
);
197+
}

packages/app/src/styles.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,28 @@ table.cache-table .muted { color: var(--muted); }
562562
.cache-lineage h4 { margin: 0 0 4px; }
563563
.lineage-list { margin: 0; padding-left: 18px; }
564564
.lineage-list li { margin: 2px 0; }
565+
566+
/* project activity log */
567+
.log-page { max-width: 1100px; }
568+
.log-filters { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
569+
.log-filters .chip { background: var(--panel); border: 1px solid var(--line); color: var(--muted); border-radius: 12px; padding: 2px 10px; font: inherit; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }
570+
.log-filters .chip.on { color: var(--fg); border-color: var(--accent); background: var(--accent-soft); }
571+
.log-sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
572+
.session-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
573+
.log-list { list-style: none; margin: 0; padding: 0; }
574+
.log-row { border-bottom: 1px solid var(--line); }
575+
.log-head { width: 100%; background: none; border: none; color: inherit; font: inherit; text-align: left; display: flex; align-items: center; gap: 10px; padding: 5px 4px; }
576+
.log-head:hover { background: var(--accent-soft); }
577+
.log-time { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; flex-shrink: 0; }
578+
.cat-badge { font-size: 11px; border-radius: 3px; padding: 1px 6px; flex-shrink: 0; white-space: nowrap; }
579+
.cat-badge.cat-mcp { background: #2d3a4f; color: #9ec5ff; }
580+
.cat-badge.cat-alias { background: #2f4030; color: #9fe0a6; }
581+
.cat-badge.cat-buckaroo { background: #4a3a2a; color: #e0b07e; }
582+
.kind-build_error .cat-badge { background: #5a1e1e; color: #ffb4b4; }
583+
.session-badge { font-size: 11px; border: 1px solid; border-radius: 3px; padding: 0 5px; flex-shrink: 0; font-family: ui-monospace, monospace; }
584+
.session-badge.muted { color: var(--muted); border-color: var(--line); }
585+
.log-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; font-size: 13px; }
586+
.log-caret { color: var(--muted); flex-shrink: 0; }
587+
.log-detail { padding: 6px 12px 12px 28px; }
588+
.log-prompt { margin-bottom: 6px; }
589+
.log-traceback { color: #ffd9d9; background: #2a1818; max-height: 360px; overflow: auto; }

packages/app/src/types.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,39 @@ export interface SessionResult {
224224
detail: string;
225225
}
226226

227+
export interface LogEvent {
228+
ts: string;
229+
kind: "build_ok" | "build_error" | "alias_set" | "buckaroo" | string;
230+
category: "mcp" | "alias" | "buckaroo" | string;
231+
session?: string | null;
232+
origin?: string | null;
233+
tool?: string | null;
234+
alias?: string | null;
235+
version?: number | null;
236+
hash?: string | null;
237+
prompt?: string | null;
238+
code?: string | null;
239+
message?: string | null;
240+
traceback?: string | null;
241+
status?: string | null;
242+
detail?: string | null;
243+
error_id?: string | null;
244+
load_ms?: number | null;
245+
load_expr_ms?: number | null;
246+
}
247+
248+
export interface LogSession {
249+
session: string;
250+
count: number;
251+
last_ts: string;
252+
}
253+
254+
export interface ActivityLog {
255+
project: string;
256+
events: LogEvent[];
257+
sessions: LogSession[];
258+
}
259+
227260
export interface Projects {
228261
active: string | null;
229262
available: string[];

0 commit comments

Comments
 (0)