@oh-my-pi/omp-stats 18.0.10 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +101 -101
  4. package/dist/client/styles.css +265 -0
  5. package/dist/types/client/api.d.ts +7 -1
  6. package/dist/types/client/app/routes.d.ts +1 -1
  7. package/dist/types/client/data/useHashRoute.d.ts +2 -0
  8. package/dist/types/client/routes/TracesRoute.d.ts +11 -0
  9. package/dist/types/client/routes/index.d.ts +1 -0
  10. package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
  11. package/dist/types/client/traces/Minimap.d.ts +14 -0
  12. package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
  13. package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
  14. package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
  15. package/dist/types/client/traces/TraceView.d.ts +11 -0
  16. package/dist/types/client/traces/TranscriptList.d.ts +24 -0
  17. package/dist/types/client/traces/time-scale.d.ts +40 -0
  18. package/dist/types/client/traces/trace-colors.d.ts +31 -0
  19. package/dist/types/db.d.ts +24 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/parser.d.ts +8 -0
  22. package/dist/types/server.d.ts +3 -2
  23. package/dist/types/shared-types.d.ts +130 -0
  24. package/dist/types/trace.d.ts +33 -0
  25. package/dist/types/types.d.ts +35 -1
  26. package/package.json +8 -8
  27. package/src/client/App.tsx +11 -1
  28. package/src/client/api.ts +23 -0
  29. package/src/client/app/routes.ts +7 -0
  30. package/src/client/data/useHashRoute.ts +28 -10
  31. package/src/client/routes/ProvidersRoute.tsx +1 -0
  32. package/src/client/routes/TracesRoute.tsx +182 -0
  33. package/src/client/routes/index.ts +1 -0
  34. package/src/client/styles.css +316 -0
  35. package/src/client/traces/AggregatesPanel.tsx +88 -0
  36. package/src/client/traces/Minimap.tsx +177 -0
  37. package/src/client/traces/SpanDrawer.tsx +309 -0
  38. package/src/client/traces/SummaryStrip.tsx +37 -0
  39. package/src/client/traces/TimelineCanvas.tsx +768 -0
  40. package/src/client/traces/TraceView.tsx +344 -0
  41. package/src/client/traces/TranscriptList.tsx +166 -0
  42. package/src/client/traces/time-scale.ts +250 -0
  43. package/src/client/traces/trace-colors.ts +77 -0
  44. package/src/db.ts +77 -0
  45. package/src/index.ts +1 -6
  46. package/src/parser.ts +55 -2
  47. package/src/server.ts +69 -17
  48. package/src/shared-types.ts +138 -0
  49. package/src/trace.ts +1002 -0
  50. package/src/types.ts +45 -1
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Detail drawer for a selected trace span. Fetches the full journal entry on
3
+ * demand (`/api/session/entry`) and renders per-kind detail: model usage grid
4
+ * + text blocks, tool args/result, user text, subagent task text.
5
+ */
6
+
7
+ import { Check, Copy, X } from "lucide-react";
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { getSessionEntryDetail } from "../api";
10
+ import { formatDurationMs, formatInteger } from "../data/formatters";
11
+ import type { TraceSpan, TraceTrack } from "../types";
12
+ import { JsonBlock } from "../ui/JsonBlock";
13
+ import { Skeleton } from "../ui/Skeleton";
14
+ import { StatusPill } from "../ui/StatusPill";
15
+
16
+ export interface SpanDrawerProps {
17
+ span: TraceSpan | null;
18
+ track: TraceTrack | null;
19
+ onClose: () => void;
20
+ /** Selects the first span of the given child track ("open child track"). */
21
+ onOpenChildTrack?: (childTrackId: string) => void;
22
+ }
23
+
24
+ /** Loose structural view of a fetched journal entry (validated field-by-field). */
25
+ interface EntryDetailView {
26
+ message?: {
27
+ role?: string;
28
+ content?: unknown;
29
+ usage?: {
30
+ input?: number;
31
+ output?: number;
32
+ cacheRead?: number;
33
+ cacheWrite?: number;
34
+ totalTokens?: number;
35
+ cost?: { total?: number };
36
+ };
37
+ model?: string;
38
+ provider?: string;
39
+ stopReason?: string;
40
+ errorMessage?: string;
41
+ duration?: number;
42
+ ttft?: number;
43
+ toolName?: string;
44
+ isError?: boolean;
45
+ details?: unknown;
46
+ };
47
+ }
48
+
49
+ function textBlocks(content: unknown): Array<{ kind: string; text: string }> {
50
+ const blocks: Array<{ kind: string; text: string }> = [];
51
+ if (typeof content === "string") {
52
+ if (content.trim()) blocks.push({ kind: "text", text: content });
53
+ return blocks;
54
+ }
55
+ if (!Array.isArray(content)) return blocks;
56
+ for (const block of content) {
57
+ if (!block || typeof block !== "object" || !("type" in block)) continue;
58
+ if ((block.type === "text" || block.type === "thinking") && "text" in block && typeof block.text === "string") {
59
+ blocks.push({ kind: String(block.type), text: block.text });
60
+ }
61
+ }
62
+ return blocks;
63
+ }
64
+
65
+ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawerProps) {
66
+ const [entry, setEntry] = useState<unknown>(null);
67
+ const [loading, setLoading] = useState(false);
68
+ const [error, setError] = useState<Error | null>(null);
69
+ const [copied, setCopied] = useState(false);
70
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
71
+ const previousActiveElement = useRef<HTMLElement | null>(null);
72
+
73
+ useEffect(() => {
74
+ setEntry(null);
75
+ setError(null);
76
+ setCopied(false);
77
+ if (!span || !track || !span.entryId) return;
78
+ previousActiveElement.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
79
+ setLoading(true);
80
+ const controller = new AbortController();
81
+ getSessionEntryDetail(track.file, span.entryId, controller.signal)
82
+ .then(data => {
83
+ if (controller.signal.aborted) return;
84
+ setEntry(data.entry);
85
+ setTimeout(() => closeButtonRef.current?.focus(), 50);
86
+ })
87
+ .catch(err => {
88
+ if (controller.signal.aborted) return;
89
+ setError(err instanceof Error ? err : new Error(String(err)));
90
+ })
91
+ .finally(() => {
92
+ if (!controller.signal.aborted) setLoading(false);
93
+ });
94
+ return () => controller.abort();
95
+ }, [span, track]);
96
+
97
+ useEffect(() => {
98
+ if (!span) return;
99
+ const handleKeyDown = (event: KeyboardEvent) => {
100
+ if (event.key === "Escape") onClose();
101
+ };
102
+ window.addEventListener("keydown", handleKeyDown);
103
+ return () => {
104
+ window.removeEventListener("keydown", handleKeyDown);
105
+ previousActiveElement.current?.focus();
106
+ };
107
+ }, [span, onClose]);
108
+
109
+ if (!span) return null;
110
+
111
+ const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
112
+ if (event.target === event.currentTarget) onClose();
113
+ };
114
+
115
+ const handleCopy = async () => {
116
+ try {
117
+ await navigator.clipboard.writeText(JSON.stringify(entry ?? span, null, 2));
118
+ setCopied(true);
119
+ setTimeout(() => setCopied(false), 1500);
120
+ } catch {
121
+ // Clipboard unavailable (permissions); leave the button state as-is.
122
+ }
123
+ };
124
+
125
+ // Boundary view: server returns opaque journal JSON, fields re-checked below.
126
+ const view: EntryDetailView = entry && typeof entry === "object" ? (entry as EntryDetailView) : {};
127
+ const msg = view.message;
128
+ const usage = msg?.usage;
129
+
130
+ return (
131
+ <div className="stats-drawer-overlay" onClick={handleOverlayClick} role="presentation">
132
+ <div className="stats-drawer" role="dialog" aria-modal="true" aria-label="Span details">
133
+ <div className="stats-drawer-header">
134
+ <div className="stats-drawer-header-left">
135
+ <h2 className="stats-drawer-title truncate" style={{ maxWidth: 320 }}>
136
+ {span.label}
137
+ </h2>
138
+ <StatusPill variant={span.isError ? "danger" : "success"}>{span.kind}</StatusPill>
139
+ </div>
140
+ <div style={{ display: "flex", gap: 8 }}>
141
+ <button
142
+ type="button"
143
+ onClick={handleCopy}
144
+ className="stats-drawer-close-btn"
145
+ aria-label="Copy raw JSON"
146
+ title="Copy raw JSON"
147
+ >
148
+ {copied ? <Check size={16} /> : <Copy size={16} />}
149
+ </button>
150
+ <button
151
+ ref={closeButtonRef}
152
+ type="button"
153
+ onClick={onClose}
154
+ className="stats-drawer-close-btn"
155
+ aria-label="Close span details"
156
+ >
157
+ <X size={18} />
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <div className="stats-drawer-body">
163
+ <div className="stats-drawer-content">
164
+ <div className="stats-drawer-metrics-grid">
165
+ <div className="stats-drawer-metric-card">
166
+ <div className="stats-drawer-metric-label">Duration</div>
167
+ <div className="stats-drawer-metric-value">{formatDurationMs(span.end - span.start)}</div>
168
+ {span.unterminated && <div className="stats-drawer-metric-sub">unterminated</div>}
169
+ </div>
170
+ <div className="stats-drawer-metric-card">
171
+ <div className="stats-drawer-metric-label">Start</div>
172
+ <div className="stats-drawer-metric-value" style={{ fontSize: 13 }}>
173
+ {new Date(span.start).toLocaleTimeString()}
174
+ </div>
175
+ </div>
176
+ {span.kind === "model" && (
177
+ <>
178
+ <div className="stats-drawer-metric-card">
179
+ <div className="stats-drawer-metric-label">Tokens</div>
180
+ <div className="stats-drawer-metric-value">{formatInteger(span.tokens ?? 0)}</div>
181
+ {usage && (
182
+ <div className="stats-drawer-metric-sub">
183
+ {formatInteger(usage.input ?? 0)} in · {formatInteger(usage.output ?? 0)} out ·{" "}
184
+ {formatInteger(usage.cacheRead ?? 0)} cached
185
+ </div>
186
+ )}
187
+ </div>
188
+ <div className="stats-drawer-metric-card">
189
+ <div className="stats-drawer-metric-label">Cost</div>
190
+ <div className="stats-drawer-metric-value">${(span.cost ?? 0).toFixed(4)}</div>
191
+ </div>
192
+ <div className="stats-drawer-metric-card">
193
+ <div className="stats-drawer-metric-label">TTFT</div>
194
+ <div className="stats-drawer-metric-value">{formatDurationMs(span.ttft ?? null)}</div>
195
+ </div>
196
+ <div className="stats-drawer-metric-card">
197
+ <div className="stats-drawer-metric-label">Model</div>
198
+ <div className="stats-drawer-metric-value" style={{ fontSize: 12 }}>
199
+ {msg?.model ?? span.model ?? "-"}
200
+ </div>
201
+ {msg?.provider && <div className="stats-drawer-metric-sub">{msg.provider}</div>}
202
+ {msg?.stopReason && <div className="stats-drawer-metric-sub">stop: {msg.stopReason}</div>}
203
+ </div>
204
+ </>
205
+ )}
206
+ </div>
207
+
208
+ {msg?.errorMessage && (
209
+ <div className="stats-drawer-error-block">
210
+ <div className="stats-drawer-error-label">Error Message</div>
211
+ <div className="stats-drawer-error-text">{msg.errorMessage}</div>
212
+ </div>
213
+ )}
214
+
215
+ {span.kind === "subagent" && (
216
+ <div>
217
+ {span.detail && (
218
+ <pre
219
+ style={{
220
+ whiteSpace: "pre-wrap",
221
+ wordBreak: "break-word",
222
+ fontSize: 12,
223
+ background: "var(--surface-2)",
224
+ borderRadius: 6,
225
+ padding: 10,
226
+ }}
227
+ >
228
+ {span.detail}
229
+ </pre>
230
+ )}
231
+ {span.childTrackId && onOpenChildTrack && (
232
+ <button
233
+ type="button"
234
+ className="stats-segmented-control-btn"
235
+ style={{ marginTop: 8, border: "1px solid var(--border)", borderRadius: 6 }}
236
+ onClick={() => onOpenChildTrack(span.childTrackId ?? "")}
237
+ >
238
+ Open child track {span.childTrackId}
239
+ </button>
240
+ )}
241
+ </div>
242
+ )}
243
+
244
+ {span.kind === "tool" && span.detail && (
245
+ <div>
246
+ <div className="stats-drawer-metric-label">Args</div>
247
+ <pre
248
+ style={{
249
+ whiteSpace: "pre-wrap",
250
+ wordBreak: "break-word",
251
+ fontSize: 12,
252
+ background: "var(--surface-2)",
253
+ borderRadius: 6,
254
+ padding: 10,
255
+ }}
256
+ >
257
+ {span.detail}
258
+ </pre>
259
+ </div>
260
+ )}
261
+
262
+ {loading && (
263
+ <div>
264
+ <Skeleton variant="rect" width="100%" height={80} className="mb-4" />
265
+ <Skeleton variant="rect" width="100%" height={160} />
266
+ </div>
267
+ )}
268
+ {error && (
269
+ <div className="stats-drawer-error">
270
+ <p className="stats-drawer-error-title">Failed to load entry</p>
271
+ <p className="stats-drawer-error-message">{error.message}</p>
272
+ </div>
273
+ )}
274
+
275
+ {!loading &&
276
+ entry !== null &&
277
+ textBlocks(msg?.content).map((block, index) => (
278
+ <div key={`${block.kind}-${index}`}>
279
+ <div className="stats-drawer-metric-label" style={{ textTransform: "capitalize" }}>
280
+ {block.kind}
281
+ </div>
282
+ <pre
283
+ style={{
284
+ whiteSpace: "pre-wrap",
285
+ wordBreak: "break-word",
286
+ fontSize: 12,
287
+ background: "var(--surface-2)",
288
+ borderRadius: 6,
289
+ padding: 10,
290
+ maxHeight: 360,
291
+ overflowY: "auto",
292
+ }}
293
+ >
294
+ {block.text}
295
+ </pre>
296
+ </div>
297
+ ))}
298
+
299
+ {!loading && entry !== null && (
300
+ <div className="stats-drawer-json-blocks">
301
+ <JsonBlock data={entry} title="Raw Entry" initialCollapsed={true} />
302
+ </div>
303
+ )}
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Headline chips for one trace: wall/model/tool/idle time, turns, requests,
3
+ * tool calls, agents, tokens, cost.
4
+ */
5
+
6
+ import { formatCompact, formatCost, formatDurationMs, formatInteger } from "../data/formatters";
7
+ import type { TraceSummary } from "../types";
8
+
9
+ export interface SummaryStripProps {
10
+ summary: TraceSummary;
11
+ }
12
+
13
+ export function SummaryStrip({ summary }: SummaryStripProps) {
14
+ const chips: Array<{ label: string; value: string }> = [
15
+ { label: "Wall", value: formatDurationMs(summary.wallMs) },
16
+ { label: "Model", value: formatDurationMs(summary.modelMs) },
17
+ { label: "Tools", value: formatDurationMs(summary.toolMs) },
18
+ { label: "Idle", value: formatDurationMs(summary.idleMs) },
19
+ { label: "Turns", value: formatInteger(summary.turns) },
20
+ { label: "Requests", value: formatInteger(summary.requests) },
21
+ { label: "Tool Calls", value: formatInteger(summary.toolCalls) },
22
+ { label: "Agents", value: formatInteger(summary.subagents) },
23
+ { label: "Tokens", value: formatCompact(summary.totalTokens) },
24
+ { label: "Cost", value: formatCost(summary.costTotal) },
25
+ ];
26
+
27
+ return (
28
+ <div className="stats-trace-summary">
29
+ {chips.map(chip => (
30
+ <div key={chip.label} className="stats-trace-summary-cell">
31
+ <span className="stats-trace-summary-label">{chip.label}</span>
32
+ <span className="stats-trace-summary-value">{chip.value}</span>
33
+ </div>
34
+ ))}
35
+ </div>
36
+ );
37
+ }