lvwerra HF Staff commited on
Commit
604c3c1
·
verified ·
1 Parent(s): 91768fa

Trace analytics on Usage page (turns/tools/web/tokens per agent), quiet loading states, X for sidebar delete, compact files header

Browse files
server/src/index.js CHANGED
@@ -14,6 +14,7 @@ import * as groups from './groups.js';
14
  import * as order from './order.js';
15
  import { attach, agentInfo, deriveState, stop } from './runner.js';
16
  import { buildUsage } from './usage.js';
 
17
  import { startVisibilityWatch, isPublic, visibility } from './visibility.js';
18
 
19
  ensureDirs();
@@ -90,6 +91,8 @@ app.get('/api/clis', (_req, res) => res.json(cliCatalog()));
90
 
91
  app.get('/api/usage', async (req, res) => res.json(await buildUsage(req.query.debug === '1')));
92
 
 
 
93
  const hfToken = () => process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || process.env.HF_API_TOKEN || null;
94
 
95
  // Env var names that existed at build time (baked in by the Dockerfile). Names
 
14
  import * as order from './order.js';
15
  import { attach, agentInfo, deriveState, stop } from './runner.js';
16
  import { buildUsage } from './usage.js';
17
+ import { buildTraces } from './traces.js';
18
  import { startVisibilityWatch, isPublic, visibility } from './visibility.js';
19
 
20
  ensureDirs();
 
91
 
92
  app.get('/api/usage', async (req, res) => res.json(await buildUsage(req.query.debug === '1')));
93
 
94
+ app.get('/api/traces', async (_req, res) => res.json(await buildTraces()));
95
+
96
  const hfToken = () => process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || process.env.HF_API_TOKEN || null;
97
 
98
  // Env var names that existed at build time (baked in by the Dockerfile). Names
server/src/traces.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import * as store from './sessions.js';
5
+
6
+ // Workspace-wide trace analytics: parse every Claude transcript and Codex
7
+ // rollout on the Space into per-conversation stats (turns, tool calls, web
8
+ // searches, tokens), attribute them to Agent Manager sessions where possible
9
+ // (Claude: transcript filename == sessionUuid; Codex: pinned codexSessionId),
10
+ // and aggregate the rest as "other".
11
+ //
12
+ // Parsing is memoized per file by (mtime, size), so repeat calls only re-read
13
+ // files that actually changed — important on the bucket mount.
14
+
15
+ const fileCache = new Map(); // path -> { key, stats }
16
+ let resultMemo = { ts: 0, val: null };
17
+ const TTL = 30_000;
18
+
19
+ function emptyStats() {
20
+ return { turns: 0, prompts: 0, toolCalls: 0, tools: {}, web: 0, tokensIn: 0, tokensOut: 0, cacheRead: 0, firstTs: 0, lastTs: 0, files: 0 };
21
+ }
22
+
23
+ function addTs(st, iso) {
24
+ const t = Date.parse(iso);
25
+ if (!t) return;
26
+ if (!st.firstTs || t < st.firstTs) st.firstTs = t;
27
+ if (t > st.lastTs) st.lastTs = t;
28
+ }
29
+
30
+ function mergeInto(a, b) {
31
+ a.turns += b.turns; a.prompts += b.prompts; a.toolCalls += b.toolCalls; a.web += b.web;
32
+ a.tokensIn += b.tokensIn; a.tokensOut += b.tokensOut; a.cacheRead += b.cacheRead; a.files += b.files;
33
+ for (const [k, v] of Object.entries(b.tools)) a.tools[k] = (a.tools[k] || 0) + v;
34
+ if (b.firstTs && (!a.firstTs || b.firstTs < a.firstTs)) a.firstTs = b.firstTs;
35
+ if (b.lastTs > a.lastTs) a.lastTs = b.lastTs;
36
+ }
37
+
38
+ // ---------- Claude transcripts (CLAUDE_CONFIG_DIR/projects/**/<uuid>.jsonl) ----------
39
+ // Multi-block assistant messages repeat the same message.id AND usage across
40
+ // several lines — dedupe both turns/usage (by message id) and tool_use blocks
41
+ // (by block id) or everything double-counts.
42
+ function parseClaude(txt) {
43
+ const st = emptyStats();
44
+ st.files = 1;
45
+ const seenMsg = new Set();
46
+ const seenTool = new Set();
47
+ for (const line of txt.split('\n')) {
48
+ if (!line) continue;
49
+ let j; try { j = JSON.parse(line); } catch { continue; }
50
+ if (j.timestamp) addTs(st, j.timestamp);
51
+ if (j.type === 'assistant' && j.message) {
52
+ const m = j.message;
53
+ const id = m.id || `${j.uuid || Math.random()}`;
54
+ if (!seenMsg.has(id)) {
55
+ seenMsg.add(id);
56
+ st.turns++;
57
+ const u = m.usage;
58
+ if (u) {
59
+ st.tokensIn += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
60
+ st.cacheRead += u.cache_read_input_tokens || 0;
61
+ st.tokensOut += u.output_tokens || 0;
62
+ const w = u.server_tool_use;
63
+ if (w) st.web += (w.web_search_requests || 0) + (w.web_fetch_requests || 0);
64
+ }
65
+ }
66
+ if (Array.isArray(m.content)) {
67
+ for (const c of m.content) {
68
+ if (c && c.type === 'tool_use' && !seenTool.has(c.id)) {
69
+ seenTool.add(c.id);
70
+ st.toolCalls++;
71
+ const name = c.name || 'tool';
72
+ st.tools[name] = (st.tools[name] || 0) + 1;
73
+ if (/^web(search|fetch)$/i.test(name)) st.web++;
74
+ }
75
+ }
76
+ }
77
+ } else if (j.type === 'user' && !j.toolUseResult) {
78
+ st.prompts++;
79
+ }
80
+ }
81
+ return st;
82
+ }
83
+
84
+ // ---------- Codex rollouts (CODEX_HOME/sessions/**/rollout-*-<uuid>.jsonl) ----------
85
+ function parseCodex(txt) {
86
+ const st = emptyStats();
87
+ st.files = 1;
88
+ let tok = null; // token_count events are cumulative per run — keep the last
89
+ for (const line of txt.split('\n')) {
90
+ if (!line) continue;
91
+ let j; try { j = JSON.parse(line); } catch { continue; }
92
+ if (j.timestamp) addTs(st, j.timestamp);
93
+ const p = j.payload || {};
94
+ if (j.type === 'response_item') {
95
+ switch (p.type) {
96
+ case 'message':
97
+ if (p.role === 'assistant') st.turns++;
98
+ else if (p.role === 'user') st.prompts++;
99
+ break;
100
+ case 'function_call':
101
+ case 'custom_tool_call':
102
+ case 'local_shell_call': {
103
+ st.toolCalls++;
104
+ const name = p.name || p.type;
105
+ st.tools[name] = (st.tools[name] || 0) + 1;
106
+ break;
107
+ }
108
+ case 'web_search_call':
109
+ st.web++;
110
+ break;
111
+ default:
112
+ }
113
+ } else if (j.type === 'event_msg' && p.type === 'token_count' && p.info && p.info.total_token_usage) {
114
+ tok = p.info.total_token_usage;
115
+ }
116
+ }
117
+ if (tok) {
118
+ const cached = tok.cached_input_tokens || 0;
119
+ st.tokensIn = Math.max(0, (tok.input_tokens || 0) - cached); // align with Claude: fresh input only
120
+ st.cacheRead = cached;
121
+ st.tokensOut = tok.output_tokens || 0;
122
+ }
123
+ return st;
124
+ }
125
+
126
+ async function statsFor(p, parser) {
127
+ let m;
128
+ try { m = await fsp.stat(p); } catch { return null; }
129
+ const key = `${m.mtimeMs}:${m.size}`;
130
+ const c = fileCache.get(p);
131
+ if (c && c.key === key) return c.stats;
132
+ let stats;
133
+ try { stats = parser(await fsp.readFile(p, 'utf8')); } catch { return null; }
134
+ fileCache.set(p, { key, stats });
135
+ return stats;
136
+ }
137
+
138
+ async function claudeFiles() {
139
+ const home = process.env.HOME || '';
140
+ const dirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')]
141
+ .filter(Boolean).filter((d, i, a) => a.indexOf(d) === i);
142
+ const out = [];
143
+ for (const d of dirs) {
144
+ const proj = path.join(d, 'projects');
145
+ let projects = [];
146
+ try { projects = await fsp.readdir(proj, { withFileTypes: true }); } catch { continue; }
147
+ for (const e of projects) {
148
+ if (!e.isDirectory()) continue;
149
+ let files = [];
150
+ try { files = await fsp.readdir(path.join(proj, e.name)); } catch { continue; }
151
+ for (const f of files) if (f.endsWith('.jsonl')) out.push(path.join(proj, e.name, f));
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ async function codexFiles() {
158
+ const home = process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex');
159
+ const root = path.join(home, 'sessions');
160
+ const out = [];
161
+ const walk = async (dir, depth) => {
162
+ if (depth > 5) return;
163
+ let ents = [];
164
+ try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
165
+ for (const e of ents) {
166
+ const p = path.join(dir, e.name);
167
+ if (e.isDirectory()) await walk(p, depth + 1);
168
+ else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) out.push(p);
169
+ }
170
+ };
171
+ await walk(root, 0);
172
+ return out;
173
+ }
174
+
175
+ const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.jsonl)?$/;
176
+
177
+ async function build() {
178
+ const sessions = store.list();
179
+ const byClaudeUuid = new Map(sessions.filter((s) => s.cli === 'claude' && s.sessionUuid).map((s) => [s.sessionUuid, s]));
180
+ const byCodexId = new Map(sessions.filter((s) => s.codexSessionId).map((s) => [s.codexSessionId, s]));
181
+
182
+ const perSession = new Map(); // session id -> stats
183
+ const other = emptyStats();
184
+ const totals = emptyStats();
185
+
186
+ const attribute = (session, stats) => {
187
+ if (!stats) return;
188
+ mergeInto(totals, stats);
189
+ if (session) {
190
+ if (!perSession.has(session.id)) perSession.set(session.id, emptyStats());
191
+ mergeInto(perSession.get(session.id), stats);
192
+ } else {
193
+ mergeInto(other, stats);
194
+ }
195
+ };
196
+
197
+ for (const p of await claudeFiles()) {
198
+ const m = path.basename(p).match(UUID_RE);
199
+ attribute(m ? byClaudeUuid.get(m[1]) : null, await statsFor(p, parseClaude));
200
+ }
201
+ for (const p of await codexFiles()) {
202
+ const m = path.basename(p).match(UUID_RE);
203
+ attribute(m ? byCodexId.get(m[1]) : null, await statsFor(p, parseCodex));
204
+ }
205
+
206
+ return {
207
+ sessions: sessions
208
+ .filter((s) => perSession.has(s.id))
209
+ .map((s) => ({ id: s.id, name: s.name, cli: s.cli, path: s.path, ...perSession.get(s.id) }))
210
+ .sort((a, b) => b.lastTs - a.lastTs),
211
+ other: other.files ? other : null,
212
+ totals,
213
+ generatedAt: new Date().toISOString(),
214
+ };
215
+ }
216
+
217
+ export function buildTraces() {
218
+ if (resultMemo.val && Date.now() - resultMemo.ts < TTL) return resultMemo.val;
219
+ const val = build().catch(() => ({ sessions: [], other: null, totals: emptyStats(), generatedAt: new Date().toISOString() }));
220
+ resultMemo = { ts: Date.now(), val };
221
+ return val;
222
+ }
web/src/api.ts CHANGED
@@ -60,6 +60,16 @@ export interface ProviderUsage {
60
  export interface Usage { providers: Record<string, ProviderUsage>; generatedAt: string; }
61
  export const getUsage = (): Promise<Usage> => fetch('/api/usage').then(json);
62
 
 
 
 
 
 
 
 
 
 
 
63
  // ---- files ----
64
  export interface FileEntry { name: string; dir: boolean; size: number; }
65
  export interface FileListing { path: string; root: string; entries: FileEntry[]; }
 
60
  export interface Usage { providers: Record<string, ProviderUsage>; generatedAt: string; }
61
  export const getUsage = (): Promise<Usage> => fetch('/api/usage').then(json);
62
 
63
+ // ---- trace analytics ----
64
+ export interface TraceStats {
65
+ turns: number; prompts: number; toolCalls: number; tools: Record<string, number>;
66
+ web: number; tokensIn: number; tokensOut: number; cacheRead: number;
67
+ firstTs: number; lastTs: number; files: number;
68
+ }
69
+ export interface SessionTraces extends TraceStats { id: string; name: string; cli: string; path: string | null; }
70
+ export interface Traces { sessions: SessionTraces[]; other: TraceStats | null; totals: TraceStats; generatedAt: string; }
71
+ export const getTraces = (): Promise<Traces> => fetch('/api/traces').then(json);
72
+
73
  // ---- files ----
74
  export interface FileEntry { name: string; dir: boolean; size: number; }
75
  export interface FileListing { path: string; root: string; entries: FileEntry[]; }
web/src/components/FilesPane.tsx CHANGED
@@ -139,21 +139,14 @@ export default function FilesPane({
139
 
140
  return (
141
  <div className={`slot${focused ? ' focused' : ''}`} onMouseDown={onFocus}>
 
142
  <div
143
- className={`pane-head${dragId ? ' draggable' : ''}`}
144
  draggable={!!dragId}
145
  onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
146
  onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
147
  >
148
- <div className="ph-left">
149
- <Logo cli="files" size={16} tint="#d99a2b" />
150
- <span className="status idle" />
151
- </div>
152
- <span className="ph-title">{session.name}</span>
153
- <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
154
- </div>
155
-
156
- <div className="files-bar">
157
  <button className="mini-btn" title="Up" disabled={!root} onClick={up}><UpGlyph /></button>
158
  <div className="crumbs">
159
  {crumbs.map((c, i) => (
@@ -168,6 +161,7 @@ export default function FilesPane({
168
  <UploadGlyph /> Upload
169
  <input type="file" multiple hidden onChange={(e) => { if (e.target.files) upload(e.target.files); e.target.value = ''; }} />
170
  </label>
 
171
  </div>
172
 
173
  <div
 
139
 
140
  return (
141
  <div className={`slot${focused ? ' focused' : ''}`} onMouseDown={onFocus}>
142
+ {/* One compact bar: logo, navigation, upload, close — no title. */}
143
  <div
144
+ className={`pane-head files-head${dragId ? ' draggable' : ''}`}
145
  draggable={!!dragId}
146
  onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
147
  onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
148
  >
149
+ <Logo cli="files" size={16} tint="#d99a2b" />
 
 
 
 
 
 
 
 
150
  <button className="mini-btn" title="Up" disabled={!root} onClick={up}><UpGlyph /></button>
151
  <div className="crumbs">
152
  {crumbs.map((c, i) => (
 
161
  <UploadGlyph /> Upload
162
  <input type="file" multiple hidden onChange={(e) => { if (e.target.files) upload(e.target.files); e.target.value = ''; }} />
163
  </label>
164
+ <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
165
  </div>
166
 
167
  <div
web/src/components/Sidebar.tsx CHANGED
@@ -4,7 +4,7 @@ import { STATE_LABEL } from '../types';
4
  import Logo from './Logo';
5
  import NewSession from './NewSession';
6
  import FolderPicker from './FolderPicker';
7
- import { SlidersGlyph, SunGlyph, MoonGlyph, TrashGlyph, PencilGlyph, StopGlyph } from './icons';
8
 
9
  type Zone = 'before' | 'after' | 'on';
10
 
@@ -132,7 +132,7 @@ export default function Sidebar({
132
  <Logo cli={s.cli} size={11} tint={colorOf[s.cli]} />
133
  <span className="row-actions">
134
  {s.running && <button className="mini-btn" title="Stop" onClick={(e) => { e.stopPropagation(); onStopSession(s.id); }}><StopGlyph /></button>}
135
- <button className="mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); onDeleteSession(s.id); }}><TrashGlyph /></button>
136
  </span>
137
  </div>
138
  );
@@ -167,7 +167,7 @@ export default function Sidebar({
167
  <span className="count">{g.sessionIds.length}</span>
168
  <span className="row-actions">
169
  <button className="mini-btn" title="Rename" onClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }}><PencilGlyph /></button>
170
- <button className="mini-btn" title="Delete group" onClick={(e) => { e.stopPropagation(); onDeleteGroup(g.id); }}><TrashGlyph /></button>
171
  </span>
172
  </>
173
  )}
 
4
  import Logo from './Logo';
5
  import NewSession from './NewSession';
6
  import FolderPicker from './FolderPicker';
7
+ import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph } from './icons';
8
 
9
  type Zone = 'before' | 'after' | 'on';
10
 
 
132
  <Logo cli={s.cli} size={11} tint={colorOf[s.cli]} />
133
  <span className="row-actions">
134
  {s.running && <button className="mini-btn" title="Stop" onClick={(e) => { e.stopPropagation(); onStopSession(s.id); }}><StopGlyph /></button>}
135
+ <button className="mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); onDeleteSession(s.id); }}><CloseGlyph /></button>
136
  </span>
137
  </div>
138
  );
 
167
  <span className="count">{g.sessionIds.length}</span>
168
  <span className="row-actions">
169
  <button className="mini-btn" title="Rename" onClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }}><PencilGlyph /></button>
170
+ <button className="mini-btn" title="Delete group" onClick={(e) => { e.stopPropagation(); onDeleteGroup(g.id); }}><CloseGlyph /></button>
171
  </span>
172
  </>
173
  )}
web/src/components/UsagePanel.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useEffect, useState } from 'react';
2
  import * as api from '../api';
3
- import type { Usage, QuotaWindow } from '../api';
 
4
 
5
  const PROVS = [
6
  { id: 'claude', label: 'Claude Code', color: '#d97757' },
@@ -17,6 +18,16 @@ const resetStr = (s?: number) => {
17
  if (mins < 60) return `resets in ${mins}m`;
18
  return `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`;
19
  };
 
 
 
 
 
 
 
 
 
 
20
 
21
  function Bar({ label, q }: { label: string; q?: QuotaWindow }) {
22
  if (!q || q.usedPercent == null) return null;
@@ -30,13 +41,36 @@ function Bar({ label, q }: { label: string; q?: QuotaWindow }) {
30
  );
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  export default function UsagePanel() {
34
  const [u, setU] = useState<Usage | null>(null);
 
35
  const [err, setErr] = useState(false);
36
- useEffect(() => { api.getUsage().then(setU).catch(() => setErr(true)); }, []);
 
 
 
37
 
38
- if (err) return <div className="placeholder"><p>Usage data unavailable (is <span className="mono">ccusage</span> installed?).</p></div>;
39
- if (!u) return <div className="placeholder"><p>Loading…</p></div>;
40
 
41
  return (
42
  <div className="usage">
@@ -67,6 +101,29 @@ export default function UsagePanel() {
67
  </div>
68
  );
69
  })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  <div className="s-help">
71
  Token counts and quota are read from each agent's local logs on the Space. They reflect the state as of that agent's <em>last model call here</em> — running a session updates them; activity outside the Space won't.
72
  </div>
 
1
  import { useEffect, useState } from 'react';
2
  import * as api from '../api';
3
+ import type { Usage, QuotaWindow, Traces, TraceStats } from '../api';
4
+ import Logo from './Logo';
5
 
6
  const PROVS = [
7
  { id: 'claude', label: 'Claude Code', color: '#d97757' },
 
18
  if (mins < 60) return `resets in ${mins}m`;
19
  return `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`;
20
  };
21
+ const fmtAgo = (ts: number) => {
22
+ if (!ts) return '—';
23
+ const m = Math.round((Date.now() - ts) / 60000);
24
+ if (m < 1) return 'now';
25
+ if (m < 60) return `${m}m ago`;
26
+ if (m < 48 * 60) return `${Math.round(m / 60)}h ago`;
27
+ return `${Math.round(m / 1440)}d ago`;
28
+ };
29
+ const topTools = (tools: Record<string, number>) =>
30
+ Object.entries(tools).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k} ${v}`).join(' · ');
31
 
32
  function Bar({ label, q }: { label: string; q?: QuotaWindow }) {
33
  if (!q || q.usedPercent == null) return null;
 
41
  );
42
  }
43
 
44
+ function TraceRow({ label, cli, path, st, strong }: { label: string; cli?: string; path?: string | null; st: TraceStats; strong?: boolean }) {
45
+ const cachePct = st.tokensIn + st.cacheRead > 0 ? Math.round((st.cacheRead / (st.tokensIn + st.cacheRead)) * 100) : 0;
46
+ return (
47
+ <tr className={strong ? 'tr-total' : undefined}>
48
+ <td className="tr-agent" title={path || undefined}>
49
+ {cli && <Logo cli={cli} size={12} />}
50
+ <span>{label}</span>
51
+ </td>
52
+ <td>{st.turns}</td>
53
+ <td>{st.prompts}</td>
54
+ <td title={topTools(st.tools) || undefined}>{st.toolCalls}</td>
55
+ <td>{st.web}</td>
56
+ <td title={`${cachePct}% served from cache (${fmtTok(st.cacheRead)} cached)`}>{fmtTok(st.tokensIn)}</td>
57
+ <td>{fmtTok(st.tokensOut)}</td>
58
+ <td className="tr-when">{fmtAgo(st.lastTs)}</td>
59
+ </tr>
60
+ );
61
+ }
62
+
63
  export default function UsagePanel() {
64
  const [u, setU] = useState<Usage | null>(null);
65
+ const [t, setT] = useState<Traces | null>(null);
66
  const [err, setErr] = useState(false);
67
+ useEffect(() => {
68
+ api.getUsage().then(setU).catch(() => setErr(true));
69
+ api.getTraces().then(setT).catch(() => {});
70
+ }, []);
71
 
72
+ if (err) return <div className="usage-msg mono">usage unavailable is ccusage installed?</div>;
73
+ if (!u) return <div className="usage-msg mono">reading usage…<span className="et-cursor" /></div>;
74
 
75
  return (
76
  <div className="usage">
 
101
  </div>
102
  );
103
  })}
104
+
105
+ <h3>Traces</h3>
106
+ <div className="s-help">
107
+ Parsed from every Claude Code transcript and Codex rollout stored on this Space —
108
+ hover the tools count for the breakdown, tokens-in for the cache share.
109
+ </div>
110
+ {!t ? (
111
+ <div className="usage-msg mono">analyzing traces…<span className="et-cursor" /></div>
112
+ ) : t.totals.files === 0 ? (
113
+ <div className="usage-msg mono">no traces yet — run a Claude or Codex session.</div>
114
+ ) : (
115
+ <table className="traces-table">
116
+ <thead>
117
+ <tr><th>agent</th><th>turns</th><th>prompts</th><th>tools</th><th>web</th><th>tok in</th><th>tok out</th><th>last active</th></tr>
118
+ </thead>
119
+ <tbody>
120
+ {t.sessions.map((s) => <TraceRow key={s.id} label={s.name} cli={s.cli} path={s.path} st={s} />)}
121
+ {t.other && <TraceRow label={`other traces (${t.other.files} files)`} st={t.other} />}
122
+ <TraceRow label={`total (${t.totals.files} files)`} st={t.totals} strong />
123
+ </tbody>
124
+ </table>
125
+ )}
126
+
127
  <div className="s-help">
128
  Token counts and quota are read from each agent's local logs on the Space. They reflect the state as of that agent's <em>last model call here</em> — running a session updates them; activity outside the Space won't.
129
  </div>
web/src/styles.css CHANGED
@@ -308,9 +308,9 @@ body {
308
 
309
  .brand-actions { display: flex; gap: 6px; }
310
 
311
- /* files pane */
312
- .files-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; }
313
- .files-bar .spacer { flex: 1; }
314
  .crumbs { display: flex; align-items: center; gap: 1px; overflow: hidden; white-space: nowrap; }
315
  .crumb { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; font-size: 12px; padding: 1px 4px; border-radius: var(--r-sm); }
316
  .crumb:hover { background: var(--panel-2); }
@@ -446,6 +446,20 @@ body {
446
 
447
  /* usage page */
448
  .usage { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  .usage-top { display: flex; align-items: center; gap: 8px; }
450
  .usage-top .spacer { flex: 1; }
451
  .usage-top .s-muted { font-size: 12px; }
 
308
 
309
  .brand-actions { display: flex; gap: 6px; }
310
 
311
+ /* files pane — one compact header bar (logo · nav · upload · close) */
312
+ .pane-head.files-head { display: flex; align-items: center; gap: 7px; }
313
+ .files-head .spacer { flex: 1; }
314
  .crumbs { display: flex; align-items: center; gap: 1px; overflow: hidden; white-space: nowrap; }
315
  .crumb { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; font-size: 12px; padding: 1px 4px; border-radius: var(--r-sm); }
316
  .crumb:hover { background: var(--panel-2); }
 
446
 
447
  /* usage page */
448
  .usage { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
449
+ .usage-msg { color: var(--muted); font-size: 12.5px; padding: 14px 2px; }
450
+ .usage-msg .et-cursor { height: 12px; width: 6px; margin-left: 6px; }
451
+ .usage h3 { margin: 14px 0 0; font-size: 14px; }
452
+
453
+ /* trace analytics: hairline rows, mono numbers, no card-cage */
454
+ .traces-table { width: 100%; border-collapse: collapse; font-size: 12px; }
455
+ .traces-table th { text-align: right; font-weight: 500; color: var(--muted); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 10px; border-bottom: 1px solid var(--border); }
456
+ .traces-table th:first-child { text-align: left; }
457
+ .traces-table td { text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
458
+ .traces-table tbody tr:hover { background: var(--panel-2); }
459
+ .traces-table .tr-agent { text-align: left; display: flex; align-items: center; gap: 7px; overflow: hidden; }
460
+ .traces-table .tr-agent span { overflow: hidden; text-overflow: ellipsis; }
461
+ .traces-table .tr-when { color: var(--muted); font-size: 11px; }
462
+ .traces-table .tr-total td { font-weight: 600; border-top: 1px solid var(--border-strong); border-bottom: none; }
463
  .usage-top { display: flex; align-items: center; gap: 8px; }
464
  .usage-top .spacer { flex: 1; }
465
  .usage-top .s-muted { font-size: 12px; }