@oh-my-pi/omp-stats 18.13.2 → 18.14.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.
@@ -12,7 +12,7 @@ import type { SessionTrace, TraceSpan, TraceSpanKind, TraceTrack } from "../type
12
12
  import { Card, ChartSkeleton, PageHeader, QueryView, SearchInput, Segmented } from "../ui";
13
13
  import { AggregatesPanel } from "./AggregatesPanel";
14
14
  import { Minimap } from "./Minimap";
15
- import { SpanDrawer } from "./SpanDrawer";
15
+ import { SpanDrawer, type TraceEntryLoader } from "./SpanDrawer";
16
16
  import { SummaryStrip } from "./SummaryStrip";
17
17
  import { TimelineCanvas, type TimelineViewport } from "./TimelineCanvas";
18
18
  import { TranscriptList } from "./TranscriptList";
@@ -48,6 +48,56 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
48
48
  // A previous session's trace must never render under this file's header.
49
49
  const trace = query.stale ? null : query.data;
50
50
 
51
+ return (
52
+ <div className="stack traces-view">
53
+ <div>
54
+ <button type="button" className="btn" data-variant="ghost" data-size="sm" onClick={onBack}>
55
+ <ArrowLeft size={14} aria-hidden="true" />
56
+ Sessions
57
+ </button>
58
+ </div>
59
+ <PageHeader
60
+ title={<span className="traces-title truncate">{trace?.title ?? file.split("/").pop()}</span>}
61
+ description={trace?.cwd ? <span className="mono">{trace.cwd}</span> : undefined}
62
+ actions={
63
+ <button
64
+ type="button"
65
+ className="btn"
66
+ data-variant="ghost"
67
+ data-icon="true"
68
+ onClick={query.refetch}
69
+ aria-label="Refresh trace"
70
+ title="Refresh trace"
71
+ >
72
+ <RefreshCw size={14} className={query.refreshing ? "traces-spin" : undefined} />
73
+ </button>
74
+ }
75
+ />
76
+ <QueryView query={{ ...query, data: trace }} skeleton={<ChartSkeleton height={420} />}>
77
+ {loaded => <SessionTraceView trace={loaded} />}
78
+ </QueryView>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /** Data and host integration supplied to the shared live/offline trace viewer. */
84
+ export interface SessionTraceViewProps {
85
+ trace: SessionTrace;
86
+ view?: "timeline" | "full";
87
+ loadEntry?: TraceEntryLoader;
88
+ /** Embedded viewers keep details inside their own layout, without a modal. */
89
+ embedded?: boolean;
90
+ onNavigate?: (trackId: string, entryId: string) => void;
91
+ }
92
+
93
+ /** Data-driven viewer shared by the live route and self-contained HTML exports. */
94
+ export function SessionTraceView({
95
+ trace,
96
+ view = "full",
97
+ loadEntry,
98
+ embedded = false,
99
+ onNavigate,
100
+ }: SessionTraceViewProps) {
51
101
  const [mode, setMode] = useState<AxisMode>("time");
52
102
  const [compressIdle, setCompressIdle] = useState(true);
53
103
  const [selection, setSelection] = useState<string | null>(null);
@@ -219,190 +269,147 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
219
269
  );
220
270
  const closeDrawer = useCallback(() => setDrawerOpen(false), []);
221
271
 
222
- const traceStart = trace?.startedAt ?? 0;
223
-
224
- const traceQuery = { ...query, data: trace };
272
+ const traceStart = trace.startedAt;
225
273
 
226
274
  return (
227
- <div className="stack traces-view">
228
- <div>
229
- <button type="button" className="btn" data-variant="ghost" data-size="sm" onClick={onBack}>
230
- <ArrowLeft size={14} aria-hidden="true" />
231
- Sessions
232
- </button>
233
- </div>
234
- <PageHeader
235
- title={<span className="traces-title truncate">{trace?.title ?? file.split("/").pop()}</span>}
236
- description={trace?.cwd ? <span className="mono">{trace.cwd}</span> : undefined}
237
- actions={
238
- <button
239
- type="button"
240
- className="btn"
241
- data-variant="ghost"
242
- data-icon="true"
243
- onClick={query.refetch}
244
- aria-label="Refresh trace"
245
- title="Refresh trace"
246
- >
247
- <RefreshCw size={14} className={query.refreshing ? "traces-spin" : undefined} />
248
- </button>
249
- }
250
- />
275
+ <div className="stack traces-view" data-view={view}>
276
+ {view === "full" && <SummaryStrip summary={trace.summary} />}
277
+ {allSpans.length === 0 && <p className="muted">No spans recorded in this session.</p>}
251
278
 
252
- <QueryView query={traceQuery} skeleton={<ChartSkeleton height={420} />}>
253
- {loaded => (
279
+ <Card
280
+ index={1}
281
+ title="Timeline"
282
+ description="W/S zoom · A/D pan · drag pan · wheel zoom · 0 fit · F focus selection · dbl-click focus · Esc deselect"
283
+ actions={
254
284
  <>
255
- <SummaryStrip summary={loaded.summary} />
256
-
257
- <Card
258
- index={1}
259
- title="Timeline"
260
- description="W/S zoom · A/D pan · drag pan · wheel zoom · 0 fit · F focus selection · dbl-click focus · Esc deselect"
261
- actions={
285
+ <button
286
+ type="button"
287
+ className="btn"
288
+ data-variant="ghost"
289
+ data-size="sm"
290
+ onClick={() => collapseAll(false)}
291
+ >
292
+ Expand all
293
+ </button>
294
+ <button
295
+ type="button"
296
+ className="btn"
297
+ data-variant="ghost"
298
+ data-size="sm"
299
+ onClick={() => collapseAll(true)}
300
+ >
301
+ Collapse all
302
+ </button>
303
+ </>
304
+ }
305
+ >
306
+ <div className="stack">
307
+ <div className="traces-toolbar">
308
+ <Segmented options={MODE_OPTIONS} value={mode} onChange={setMode} size="sm" aria-label="Axis mode" />
309
+ {mode === "time" && (
310
+ <label className="traces-check">
311
+ <input
312
+ type="checkbox"
313
+ checked={compressIdle}
314
+ onChange={event => setCompressIdle(event.target.checked)}
315
+ />
316
+ Compress idle
317
+ </label>
318
+ )}
319
+ <div className="traces-search">
320
+ <SearchInput
321
+ value={search}
322
+ onChange={value => {
323
+ setSearch(value);
324
+ setMatchIndex(0);
325
+ }}
326
+ label="Search spans"
327
+ placeholder="Search spans…"
328
+ width={200}
329
+ />
330
+ {search.trim() && (
262
331
  <>
332
+ <span className="num muted traces-match-count">
333
+ {matches.length === 0
334
+ ? "0"
335
+ : `${(matchIndex % Math.max(matches.length, 1)) + 1}/${matches.length}`}
336
+ </span>
263
337
  <button
264
338
  type="button"
265
339
  className="btn"
266
340
  data-variant="ghost"
267
341
  data-size="sm"
268
- onClick={() => collapseAll(false)}
342
+ data-icon="true"
343
+ onClick={() => cycleMatch(-1)}
344
+ aria-label="Previous match"
345
+ disabled={matches.length === 0}
269
346
  >
270
- Expand all
347
+ <ChevronLeft size={14} />
271
348
  </button>
272
349
  <button
273
350
  type="button"
274
351
  className="btn"
275
352
  data-variant="ghost"
276
353
  data-size="sm"
277
- onClick={() => collapseAll(true)}
354
+ data-icon="true"
355
+ onClick={() => cycleMatch(1)}
356
+ aria-label="Next match"
357
+ disabled={matches.length === 0}
278
358
  >
279
- Collapse all
359
+ <ChevronRight size={14} />
280
360
  </button>
281
361
  </>
282
- }
283
- >
284
- <div className="stack">
285
- <div className="traces-toolbar">
286
- <Segmented
287
- options={MODE_OPTIONS}
288
- value={mode}
289
- onChange={setMode}
290
- size="sm"
291
- aria-label="Axis mode"
292
- />
293
- {mode === "time" && (
294
- <label className="traces-check">
295
- <input
296
- type="checkbox"
297
- checked={compressIdle}
298
- onChange={event => setCompressIdle(event.target.checked)}
299
- />
300
- Compress idle
301
- </label>
302
- )}
303
- <div className="traces-search">
304
- <SearchInput
305
- value={search}
306
- onChange={value => {
307
- setSearch(value);
308
- setMatchIndex(0);
309
- }}
310
- placeholder="Search spans…"
311
- width={200}
312
- />
313
- {search.trim() && (
314
- <>
315
- <span className="num muted traces-match-count">
316
- {matches.length === 0
317
- ? "0"
318
- : `${(matchIndex % Math.max(matches.length, 1)) + 1}/${matches.length}`}
319
- </span>
320
- <button
321
- type="button"
322
- className="btn"
323
- data-variant="ghost"
324
- data-size="sm"
325
- data-icon="true"
326
- onClick={() => cycleMatch(-1)}
327
- aria-label="Previous match"
328
- disabled={matches.length === 0}
329
- >
330
- <ChevronLeft size={14} />
331
- </button>
332
- <button
333
- type="button"
334
- className="btn"
335
- data-variant="ghost"
336
- data-size="sm"
337
- data-icon="true"
338
- onClick={() => cycleMatch(1)}
339
- aria-label="Next match"
340
- disabled={matches.length === 0}
341
- >
342
- <ChevronRight size={14} />
343
- </button>
344
- </>
345
- )}
346
- </div>
347
- <div className="traces-legend">
348
- {LEGEND.map(item => (
349
- <span key={item.kind} className="traces-legend-item">
350
- <span
351
- className="swatch"
352
- style={{ background: `var(${CATEGORY_VARS[item.kind]})` }}
353
- />
354
- {item.label}
355
- </span>
356
- ))}
357
- </div>
358
- </div>
359
- <Minimap
360
- tracks={tracks}
361
- scale={scale}
362
- viewport={effectiveViewport}
363
- onViewportChange={setViewport}
364
- />
365
- <TimelineCanvas
366
- tracks={tracks}
367
- scale={scale}
368
- viewport={effectiveViewport}
369
- onViewportChange={setViewport}
370
- selected={selected}
371
- onSelect={selectAndOpen}
372
- matchIds={matchIds}
373
- collapsed={collapsed}
374
- onToggleCollapse={toggleCollapse}
375
- traceStart={traceStart}
376
- />
377
- </div>
378
- </Card>
379
-
380
- <Card
381
- index={2}
382
- title="Transcript"
383
- description="Every span and marker in order; click to inspect"
384
- flush
385
- >
386
- <TranscriptList
387
- tracks={tracks}
388
- selection={selection}
389
- onSelect={openFromTranscript}
390
- search={search}
391
- matchIds={matchIds}
392
- traceStart={traceStart}
393
- />
394
- </Card>
395
- <AggregatesPanel toolStats={loaded.summary.toolStats} index={3} />
396
- </>
397
- )}
398
- </QueryView>
362
+ )}
363
+ </div>
364
+ <div className="traces-legend">
365
+ {LEGEND.map(item => (
366
+ <span key={item.kind} className="traces-legend-item">
367
+ <span className="swatch" style={{ background: `var(${CATEGORY_VARS[item.kind]})` }} />
368
+ {item.label}
369
+ </span>
370
+ ))}
371
+ </div>
372
+ </div>
373
+ <Minimap tracks={tracks} scale={scale} viewport={effectiveViewport} onViewportChange={setViewport} />
374
+ <TimelineCanvas
375
+ tracks={tracks}
376
+ scale={scale}
377
+ viewport={effectiveViewport}
378
+ onViewportChange={setViewport}
379
+ selected={selected}
380
+ onSelect={selectAndOpen}
381
+ matchIds={matchIds}
382
+ collapsed={collapsed}
383
+ onToggleCollapse={toggleCollapse}
384
+ traceStart={traceStart}
385
+ autoFocus={!embedded}
386
+ />
387
+ </div>
388
+ </Card>
399
389
 
400
390
  <SpanDrawer
401
391
  span={drawerOpen ? (selected?.span ?? null) : null}
402
392
  track={selected?.track ?? null}
403
393
  onClose={closeDrawer}
404
394
  onOpenChildTrack={openChildTrack}
395
+ loadEntry={loadEntry}
396
+ inline={embedded}
397
+ onNavigate={onNavigate}
405
398
  />
399
+
400
+ {view === "full" && (
401
+ <Card index={2} title="Transcript" description="Every span and marker in order; click to inspect" flush>
402
+ <TranscriptList
403
+ tracks={tracks}
404
+ selection={selection}
405
+ onSelect={openFromTranscript}
406
+ search={search}
407
+ matchIds={matchIds}
408
+ traceStart={traceStart}
409
+ />
410
+ </Card>
411
+ )}
412
+ {view === "full" && <AggregatesPanel toolStats={trace.summary.toolStats} index={3} />}
406
413
  </div>
407
414
  );
408
415
  }
@@ -0,0 +1,5 @@
1
+ /** Shared trace UI for live dashboards and embedded offline viewers. */
2
+ export type { SessionTrace } from "../types";
3
+ export type { TraceEntryLoader } from "./SpanDrawer";
4
+ export { SessionTraceView, type SessionTraceViewProps } from "./TraceView";
5
+ export { TraceThemeRoot } from "./trace-colors";
@@ -3,9 +3,11 @@
3
3
  * design tokens so canvas drawing follows the active theme.
4
4
  */
5
5
 
6
- import { useEffect, useMemo, useState } from "react";
6
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
7
7
  import type { TraceSpanKind } from "../types";
8
- import { useSystemTheme } from "../useSystemTheme";
8
+
9
+ /** Embedded viewers resolve canvas colors from their scoped style root. */
10
+ export const TraceThemeRoot = createContext<HTMLElement | null>(null);
9
11
 
10
12
  export interface TraceTheme {
11
13
  /** Span fill per category. */
@@ -42,8 +44,8 @@ export const CATEGORY_VARS: Record<TraceSpanKind, string> = {
42
44
  background: "--ink-4",
43
45
  };
44
46
 
45
- function readTheme(): TraceTheme {
46
- const style = getComputedStyle(document.documentElement);
47
+ function readTheme(root: HTMLElement): TraceTheme {
48
+ const style = getComputedStyle(root);
47
49
  const v = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
48
50
  return {
49
51
  category: {
@@ -69,16 +71,18 @@ function readTheme(): TraceTheme {
69
71
 
70
72
  /**
71
73
  * Current trace palette. Re-reads the tokens whenever the resolved theme
72
- * changes (store or a direct `data-theme` attribute write).
74
+ * changes, without loading the live dashboard's theme preference store.
73
75
  */
74
76
  export function useTraceTheme(): TraceTheme {
75
- const theme = useSystemTheme();
77
+ const root = useContext(TraceThemeRoot) ?? document.documentElement;
76
78
  const [attrVersion, setAttrVersion] = useState(0);
77
79
  useEffect(() => {
78
80
  const observer = new MutationObserver(() => setAttrVersion(n => n + 1));
79
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
81
+ const options = { attributes: true, attributeFilter: ["data-theme", "style", "class"] };
82
+ observer.observe(document.documentElement, options);
83
+ if (root !== document.documentElement) observer.observe(root, options);
80
84
  return () => observer.disconnect();
81
- }, []);
82
- // biome-ignore lint/correctness/useExhaustiveDependencies: theme/attrVersion are re-read triggers.
83
- return useMemo(readTheme, [theme, attrVersion]);
85
+ }, [root]);
86
+ // biome-ignore lint/correctness/useExhaustiveDependencies: attrVersion triggers a CSS token re-read.
87
+ return useMemo(() => readTheme(root), [root, attrVersion]);
84
88
  }
@@ -4,17 +4,19 @@ export interface SearchInputProps {
4
4
  value: string;
5
5
  onChange: (value: string) => void;
6
6
  placeholder?: string;
7
+ label?: string;
7
8
  width?: number;
8
9
  }
9
10
 
10
11
  /** Text filter with a leading search glyph. */
11
- export function SearchInput({ value, onChange, placeholder = "Filter…", width }: SearchInputProps) {
12
+ export function SearchInput({ value, onChange, placeholder = "Filter…", label = "Filter", width }: SearchInputProps) {
12
13
  return (
13
14
  <label className="search">
14
15
  <Search size={14} />
15
16
  <input
16
17
  className="input"
17
18
  type="search"
19
+ aria-label={label}
18
20
  value={value}
19
21
  placeholder={placeholder}
20
22
  onChange={e => onChange(e.target.value)}