@oh-my-pi/omp-stats 18.13.3 → 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.
@@ -0,0 +1,32 @@
1
+ import type { Database, SQLQueryBindings } from "bun:sqlite";
2
+ /** Shape of one ingest table write; see {@link ChunkedInsert}. */
3
+ export interface ChunkedInsertSpec {
4
+ /** `INSERT` or `INSERT OR IGNORE`. */
5
+ verb: "INSERT" | "INSERT OR IGNORE";
6
+ table: string;
7
+ columns: readonly string[];
8
+ /** Columns naming one transcript entry; fork copies repeat them under another `session_file`. */
9
+ identity: readonly string[];
10
+ /** Trailing `ON CONFLICT … DO UPDATE` clause, if any. */
11
+ upsert?: string;
12
+ }
13
+ /**
14
+ * Multi-row writer for one ingest table: buffers rows and writes them as
15
+ * `INSERT … VALUES (…), (…)` chunks, so the per-statement costs (trigger
16
+ * program setup, statement journal, the temp b-tree SQLite stages trigger
17
+ * inserts through) are paid per chunk instead of per row.
18
+ *
19
+ * Every row passes a first-writer-wins fork guard: forked transcripts copy
20
+ * their parent's entries verbatim, so a row whose identity another
21
+ * `session_file` already stored is skipped. Buffered rows are invisible to
22
+ * that lookup, so a row colliding with a buffered row of another transcript
23
+ * flushes the buffer first; the stored outcome equals writing row by row.
24
+ */
25
+ export declare class ChunkedInsert {
26
+ #private;
27
+ constructor(database: Database, spec: ChunkedInsertSpec);
28
+ /** Buffer one row unless another transcript already owns `identity`. */
29
+ add(sessionFile: string, identity: readonly SQLQueryBindings[], values: readonly SQLQueryBindings[]): void;
30
+ /** Write the remaining rows; returns how many stored rows the writer inserted or updated. */
31
+ finish(): number;
32
+ }
@@ -1,14 +1,20 @@
1
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
2
+ * Detail drawer for a selected trace span. Loads the full journal entry on
3
+ * demand (live API or embedded snapshot) and renders per-kind detail: model usage grid
4
4
  * + text blocks, tool args/result, user text, subagent task text.
5
5
  */
6
6
  import type { TraceSpan, TraceTrack } from "../types.js";
7
+ export type TraceEntryLoader = (file: string, entryId: string, signal: AbortSignal) => Promise<{
8
+ entry: unknown;
9
+ }>;
7
10
  export interface SpanDrawerProps {
8
11
  span: TraceSpan | null;
9
12
  track: TraceTrack | null;
10
13
  onClose: () => void;
11
14
  /** Selects the first span of the given child track ("open child track"). */
12
15
  onOpenChildTrack?: (childTrackId: string) => void;
16
+ loadEntry?: TraceEntryLoader;
17
+ inline?: boolean;
18
+ onNavigate?: (trackId: string, entryId: string) => void;
13
19
  }
14
- export declare function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawerProps): import("react").JSX.Element | null;
20
+ export declare function SpanDrawer({ span, track, onClose, onOpenChildTrack, loadEntry, inline, onNavigate, }: SpanDrawerProps): import("react").JSX.Element | null;
@@ -27,6 +27,8 @@ export interface TimelineCanvasProps {
27
27
  collapsed: ReadonlySet<string>;
28
28
  onToggleCollapse: (trackId: string) => void;
29
29
  traceStart: number;
30
+ /** Live route focuses immediately; embedded viewers wait for user interaction. */
31
+ autoFocus?: boolean;
30
32
  }
31
33
  /** Per-lane span positions on the virtual axis, parallel to `LaneRow.spans`. */
32
34
  interface LaneAxis {
@@ -42,5 +44,5 @@ export declare function buildLaneAxes(layout: {
42
44
  }, scale: Pick<TraceScale, "toU">): LaneAxis[];
43
45
  /** First index whose running-max end reaches `u0`: every earlier span ends before the viewport. */
44
46
  export declare function firstVisible(maxEndU: Float64Array, u0: number): number;
45
- export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selected, onSelect, matchIds, collapsed, onToggleCollapse, traceStart, }: TimelineCanvasProps): import("react").JSX.Element;
47
+ export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selected, onSelect, matchIds, collapsed, onToggleCollapse, traceStart, autoFocus, }: TimelineCanvasProps): import("react").JSX.Element;
46
48
  export {};
@@ -3,9 +3,22 @@
3
3
  * flamegraph canvas, transcript list, tool aggregates, and the span drawer.
4
4
  * Owns the shared viewport/selection state so all pieces stay in sync.
5
5
  */
6
+ import type { SessionTrace } from "../types.js";
7
+ import { type TraceEntryLoader } from "./SpanDrawer.js";
6
8
  export interface TraceViewProps {
7
9
  file: string;
8
10
  active: boolean;
9
11
  onBack: () => void;
10
12
  }
11
13
  export declare function TraceView({ file, active, onBack }: TraceViewProps): import("react").JSX.Element;
14
+ /** Data and host integration supplied to the shared live/offline trace viewer. */
15
+ export interface SessionTraceViewProps {
16
+ trace: SessionTrace;
17
+ view?: "timeline" | "full";
18
+ loadEntry?: TraceEntryLoader;
19
+ /** Embedded viewers keep details inside their own layout, without a modal. */
20
+ embedded?: boolean;
21
+ onNavigate?: (trackId: string, entryId: string) => void;
22
+ }
23
+ /** Data-driven viewer shared by the live route and self-contained HTML exports. */
24
+ export declare function SessionTraceView({ trace, view, loadEntry, embedded, onNavigate, }: SessionTraceViewProps): import("react").JSX.Element;
@@ -0,0 +1,5 @@
1
+ /** Shared trace UI for live dashboards and embedded offline viewers. */
2
+ export type { SessionTrace } from "../types.js";
3
+ export type { TraceEntryLoader } from "./SpanDrawer.js";
4
+ export { SessionTraceView, type SessionTraceViewProps } from "./TraceView.js";
5
+ export { TraceThemeRoot } from "./trace-colors.js";
@@ -3,6 +3,8 @@
3
3
  * design tokens so canvas drawing follows the active theme.
4
4
  */
5
5
  import type { TraceSpanKind } from "../types.js";
6
+ /** Embedded viewers resolve canvas colors from their scoped style root. */
7
+ export declare const TraceThemeRoot: import("react").Context<HTMLElement | null>;
6
8
  export interface TraceTheme {
7
9
  /** Span fill per category. */
8
10
  category: Record<TraceSpanKind, string>;
@@ -32,6 +34,6 @@ export interface TraceTheme {
32
34
  export declare const CATEGORY_VARS: Record<TraceSpanKind, string>;
33
35
  /**
34
36
  * Current trace palette. Re-reads the tokens whenever the resolved theme
35
- * changes (store or a direct `data-theme` attribute write).
37
+ * changes, without loading the live dashboard's theme preference store.
36
38
  */
37
39
  export declare function useTraceTheme(): TraceTheme;
@@ -2,7 +2,8 @@ export interface SearchInputProps {
2
2
  value: string;
3
3
  onChange: (value: string) => void;
4
4
  placeholder?: string;
5
+ label?: string;
5
6
  width?: number;
6
7
  }
7
8
  /** Text filter with a leading search glyph. */
8
- export declare function SearchInput({ value, onChange, placeholder, width }: SearchInputProps): import("react").JSX.Element;
9
+ export declare function SearchInput({ value, onChange, placeholder, label, width }: SearchInputProps): import("react").JSX.Element;
@@ -77,7 +77,7 @@ export declare function completeSessionSync(reconcile: boolean): void;
77
77
  * `provider`, token counts, and `responseId`. The `UNIQUE(session_file,
78
78
  * entry_id)` constraint alone keys each row by file, so without the guard
79
79
  * below the same provider request would land twice and inflate every
80
- * aggregate. The `WHERE NOT EXISTS` clause skips inserts whose
80
+ * aggregate. The {@link ChunkedInsert} fork guard skips inserts whose
81
81
  * `(entry_id, timestamp)` already exists under a different `session_file` —
82
82
  * first-write-wins across the lineage. Same-file re-syncs still hit the
83
83
  * `ON CONFLICT(session_file, entry_id)` upsert below, which re-derives the
@@ -111,9 +111,9 @@ export declare function getDailyActivity(days?: number): Promise<DailyActivityPo
111
111
  export declare function markSessionBackfillsComplete(): void;
112
112
  /**
113
113
  * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
114
- * The `WHERE NOT EXISTS` clause matches {@link insertMessageStats}: forks
115
- * copy user entries verbatim into the child JSONL, so the same
116
- * `(entry_id, timestamp)` must not land twice across different session files.
114
+ * The fork guard matches {@link insertMessageStats}: forks copy user entries
115
+ * verbatim into the child JSONL, so the same `(entry_id, timestamp)` must not
116
+ * land twice across different session files.
117
117
  */
118
118
  export declare function insertUserMessageStats(stats: Iterable<UserMessageStats>): number;
119
119
  /**
@@ -172,7 +172,7 @@ export declare function getPendingFrustrationTotals(cutoff?: number | null): {
172
172
  export declare function upsertFrustrationVerdicts(verdicts: readonly FrustrationVerdict[]): void;
173
173
  /**
174
174
  * Insert tool-call rows. Idempotent via UNIQUE(session_file, tool_call_id);
175
- * the `WHERE NOT EXISTS` guard mirrors {@link insertMessageStats}: forked
175
+ * the fork guard mirrors {@link insertMessageStats}: forked
176
176
  * sessions deep-copy assistant entries (same `entry_id`, `timestamp`, and
177
177
  * tool-call ids under a new file), so first-write-wins across the lineage
178
178
  * keeps aggregates from double counting. Keyed on the assistant entry
@@ -7,10 +7,16 @@ export declare const STATS_DASHBOARD_SECURITY_VERSION = "3";
7
7
  /** IPv4 loopback address shared by the dashboard server and reuse probe. */
8
8
  export declare const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
9
9
  /**
10
- * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding.
10
+ * How {@link startServer} proceeds with the requested port: bind it, reuse the
11
+ * dashboard already serving it, or fall back to an ephemeral port because the
12
+ * holder is neither reusable nor safe to stop.
13
+ */
14
+ export type StatsPortClaim = "bind" | "reuse" | "fallback";
15
+ /**
16
+ * Reuse a current dashboard, or upgrade an older one, before binding.
11
17
  * The preflight is needed on platforms that permit wildcard and loopback-specific
12
18
  * listeners to coexist on one port.
13
19
  */
14
- export declare function prepareStatsPort(port: number, hostname?: string): Promise<"retry" | "reuse">;
20
+ export declare function prepareStatsPort(port: number, hostname?: string): Promise<StatsPortClaim>;
15
21
  /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
16
- export declare function recoverStatsPort(port: number, hostname?: string): Promise<"retry" | "reuse">;
22
+ export declare function recoverStatsPort(port: number, hostname?: string): Promise<StatsPortClaim>;
@@ -5,9 +5,7 @@ import { type StatsJudgeProvider } from "./frustration.js";
5
5
  export declare function handleApi(req: Request): Promise<Response>;
6
6
  /** Format a dashboard origin, including brackets required by IPv6 literals. */
7
7
  export declare function formatStatsDashboardUrl(hostname: string, port: number): string;
8
- /**
9
- * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
10
- */
8
+ /** A dashboard this process started or found; `port` is where it listens, which may differ from the one requested. */
11
9
  export interface StatsServerHandle {
12
10
  hostname: string;
13
11
  port: number;
@@ -17,4 +15,10 @@ export interface StartServerOptions {
17
15
  /** Host judge for the Frustration dashboard's judge runs; replaces any previously registered one. */
18
16
  judge?: StatsJudgeProvider;
19
17
  }
18
+ /**
19
+ * Start the dashboard on `port`, or reuse the current dashboard already serving it.
20
+ * An older dashboard there is stopped and replaced; any other holder (a foreign
21
+ * program, or a dashboard too busy to answer the identity probe) is left running
22
+ * and the dashboard binds an ephemeral port instead.
23
+ */
20
24
  export declare function startServer(port?: number, hostname?: string, options?: StartServerOptions): Promise<StatsServerHandle>;
@@ -566,7 +566,7 @@ export interface TraceTrack {
566
566
  agent: string | null;
567
567
  /** session_init.resolvedModel ?? first assistant model. */
568
568
  model: string | null;
569
- /** Absolute transcript path (for entry fetch). */
569
+ /** Absolute transcript path for live entry fetch, or the embedded snapshot key. */
570
570
  file: string;
571
571
  /** Sorted by start. */
572
572
  spans: TraceSpan[];
@@ -607,12 +607,13 @@ export interface SessionTrace {
607
607
  cwd: string | null;
608
608
  startedAt: number;
609
609
  endedAt: number;
610
- /** Root transcript mtime; doubles as the ETag. */
610
+ /** Root transcript mtime, or recorded root activity/header time for offline snapshots. */
611
611
  mtimeMs: number;
612
612
  /**
613
613
  * Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
614
614
  * traceFingerprintForEtag): the server ETag is built from this, so a
615
615
  * subagent-only append changes the ETag even when the root is untouched.
616
+ * Offline snapshots use "snapshot" and do not support HTTP revalidation.
616
617
  */
617
618
  etag: string;
618
619
  /** DFS order, main first. */
@@ -17,7 +17,7 @@ export declare class TracePathError extends Error {
17
17
  * span assembly changes so browsers don't revalidate stale cached bodies
18
18
  * against an unchanged transcript mtime.
19
19
  */
20
- export declare const TRACE_ETAG_VERSION = 3;
20
+ export declare const TRACE_ETAG_VERSION = 4;
21
21
  /** Freshness of a root trace: root mtime plus the child-transcript set fingerprint. */
22
22
  export interface TraceFingerprint {
23
23
  rootMtimeMs: number;
@@ -42,6 +42,32 @@ export declare function traceMemoForTests(): {
42
42
  * file (its ETag pre-check), sparing a second walk.
43
43
  */
44
44
  export declare function buildSessionTrace(fileParam: string, fingerprint?: TraceFingerprint): Promise<SessionTrace>;
45
+ /** Header metadata used by snapshot traces, including journals without a schema version. */
46
+ export interface TraceSnapshotHeader {
47
+ timestamp?: string;
48
+ cwd?: string;
49
+ title?: string;
50
+ }
51
+ /** The journal data for one embedded transcript; its header may be stored separately. */
52
+ export interface SessionTraceSnapshot {
53
+ header?: TraceSnapshotHeader | null;
54
+ entries: readonly SessionEntry[];
55
+ }
56
+ /** A collected child transcript, keyed by its slash-joined path from the root. */
57
+ export interface SubSessionTraceSnapshot extends SessionTraceSnapshot {
58
+ parent: string | null;
59
+ }
60
+ /**
61
+ * Assemble an offline trace using the same timing joins and rollups as the live
62
+ * filesystem builder. Only supplied snapshots participate: no filesystem, DB,
63
+ * network, or wall-clock reads. Track files are snapshot keys, never disk paths.
64
+ *
65
+ * Missing parent transcripts attach their readable descendants to main. Unlike
66
+ * filesystem discovery, already-collected snapshots need no depth limit.
67
+ * `mtimeMs` is the root's last activity (or header time, then 0); the constant
68
+ * `etag` identifies a snapshot, not a conditional HTTP response.
69
+ */
70
+ export declare function buildSessionTraceFromSnapshots(root: SessionTraceSnapshot, subSessions?: Readonly<Record<string, SubSessionTraceSnapshot>>): SessionTrace;
45
71
  /** Fetch one full journal entry by id from a transcript, for the span drawer. */
46
72
  export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
47
73
  /**
@@ -216,11 +216,16 @@ export interface ToolCallStats {
216
216
  callsInTurn: number;
217
217
  /** Serialized argument characters */
218
218
  argsChars: number;
219
+ /** Result text characters, when the same parse also read the call's `toolResult` */
220
+ resultChars?: number;
221
+ /** Result error flag, set together with `resultChars` */
222
+ isError?: boolean;
219
223
  }
220
224
  /**
221
225
  * Result linkage emitted when the parser sees a `toolResult` message entry.
222
226
  * Applied as an UPDATE on the persisted tool-call row — results can land in a
223
- * later incremental sync pass than the call that produced them.
227
+ * later incremental sync pass than the call that produced them (calls parsed
228
+ * in the same pass already carry the result, so their UPDATE is a no-op).
224
229
  */
225
230
  export interface ToolResultLink {
226
231
  sessionFile: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "18.13.3",
4
+ "version": "18.14.0",
5
5
  "description": "Local observability dashboard for omp AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,9 +42,9 @@
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.13.3",
46
- "@oh-my-pi/pi-catalog": "18.13.3",
47
- "@oh-my-pi/pi-utils": "18.13.3",
45
+ "@oh-my-pi/pi-ai": "18.14.0",
46
+ "@oh-my-pi/pi-catalog": "18.14.0",
47
+ "@oh-my-pi/pi-utils": "18.14.0",
48
48
  "lucide-react": "^1.24.0",
49
49
  "react": "19.2.7",
50
50
  "react-dom": "19.2.7"
@@ -63,7 +63,6 @@
63
63
  "README.md",
64
64
  "CHANGELOG.md",
65
65
  "LICENSE",
66
- "THIRD-PARTY-NOTICES.txt",
67
66
  "dist/types",
68
67
  "dist/client"
69
68
  ],
@@ -80,6 +79,12 @@
80
79
  "types": "./dist/types/client/index.d.ts",
81
80
  "import": "./src/client/index.tsx"
82
81
  },
82
+ "./client/traces": {
83
+ "types": "./dist/types/client/traces/index.d.ts",
84
+ "import": "./src/client/traces/index.ts"
85
+ },
86
+ "./client/styles.css": "./src/client/styles.css",
87
+ "./client/traces/traces.css": "./src/client/traces/traces.css",
83
88
  "./client/*": {
84
89
  "types": "./dist/types/client/*.d.ts",
85
90
  "import": "./src/client/*.ts"
@@ -0,0 +1,89 @@
1
+ import type { Database, SQLQueryBindings } from "bun:sqlite";
2
+
3
+ /** Bind parameters per statement; SQLite builds older than 3.32 cap a statement at 999. */
4
+ const MAX_BINDS = 999;
5
+
6
+ /** Shape of one ingest table write; see {@link ChunkedInsert}. */
7
+ export interface ChunkedInsertSpec {
8
+ /** `INSERT` or `INSERT OR IGNORE`. */
9
+ verb: "INSERT" | "INSERT OR IGNORE";
10
+ table: string;
11
+ columns: readonly string[];
12
+ /** Columns naming one transcript entry; fork copies repeat them under another `session_file`. */
13
+ identity: readonly string[];
14
+ /** Trailing `ON CONFLICT … DO UPDATE` clause, if any. */
15
+ upsert?: string;
16
+ }
17
+
18
+ /**
19
+ * Multi-row writer for one ingest table: buffers rows and writes them as
20
+ * `INSERT … VALUES (…), (…)` chunks, so the per-statement costs (trigger
21
+ * program setup, statement journal, the temp b-tree SQLite stages trigger
22
+ * inserts through) are paid per chunk instead of per row.
23
+ *
24
+ * Every row passes a first-writer-wins fork guard: forked transcripts copy
25
+ * their parent's entries verbatim, so a row whose identity another
26
+ * `session_file` already stored is skipped. Buffered rows are invisible to
27
+ * that lookup, so a row colliding with a buffered row of another transcript
28
+ * flushes the buffer first; the stored outcome equals writing row by row.
29
+ */
30
+ export class ChunkedInsert {
31
+ readonly #database: Database;
32
+ readonly #spec: ChunkedInsertSpec;
33
+ readonly #chunkRows: number;
34
+ readonly #chunkSql: string;
35
+ readonly #claimed: string;
36
+ #params: SQLQueryBindings[] = [];
37
+ #rows = 0;
38
+ /** Identity → `session_file` of each buffered row. */
39
+ #buffered = new Map<string, string>();
40
+ #changes = 0;
41
+
42
+ constructor(database: Database, spec: ChunkedInsertSpec) {
43
+ this.#database = database;
44
+ this.#spec = spec;
45
+ this.#chunkRows = Math.floor(MAX_BINDS / spec.columns.length);
46
+ this.#chunkSql = this.#sql(this.#chunkRows);
47
+ this.#claimed = `SELECT 1 FROM ${spec.table} WHERE ${spec.identity.map(column => `${column} = ?`).join(" AND ")} AND session_file <> ? LIMIT 1`;
48
+ }
49
+
50
+ /** Buffer one row unless another transcript already owns `identity`. */
51
+ add(sessionFile: string, identity: readonly SQLQueryBindings[], values: readonly SQLQueryBindings[]): void {
52
+ const key = identity.join("\0");
53
+ const owner = this.#buffered.get(key);
54
+ if (owner !== sessionFile) {
55
+ if (owner !== undefined) this.#flush();
56
+ if (this.#database.query(this.#claimed).get(...identity, sessionFile)) return;
57
+ this.#buffered.set(key, sessionFile);
58
+ }
59
+ for (const value of values) this.#params.push(value);
60
+ if (++this.#rows === this.#chunkRows) this.#flush();
61
+ }
62
+
63
+ /** Write the remaining rows; returns how many stored rows the writer inserted or updated. */
64
+ finish(): number {
65
+ this.#flush();
66
+ return this.#changes;
67
+ }
68
+
69
+ #flush(): void {
70
+ if (this.#rows === 0) return;
71
+ if (this.#rows === this.#chunkRows) {
72
+ this.#changes += this.#database.query(this.#chunkSql).run(...this.#params).changes;
73
+ } else {
74
+ // Partial chunks vary in size; keep them out of the statement cache.
75
+ using statement = this.#database.prepare(this.#sql(this.#rows));
76
+ this.#changes += statement.run(...this.#params).changes;
77
+ }
78
+ this.#params = [];
79
+ this.#rows = 0;
80
+ this.#buffered.clear();
81
+ }
82
+
83
+ #sql(rows: number): string {
84
+ const { verb, table, columns, upsert } = this.#spec;
85
+ const row = `(${columns.map(() => "?").join(", ")})`;
86
+ const values = Array.from({ length: rows }, () => row).join(", ");
87
+ return `${verb} INTO ${table} (${columns.join(", ")}) VALUES ${values}${upsert ? ` ${upsert}` : ""}`;
88
+ }
89
+ }
@@ -1,15 +1,17 @@
1
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
2
+ * Detail drawer for a selected trace span. Loads the full journal entry on
3
+ * demand (live API or embedded snapshot) and renders per-kind detail: model usage grid
4
4
  * + text blocks, tool args/result, user text, subagent task text.
5
5
  */
6
6
 
7
- import { Check, Copy } from "lucide-react";
7
+ import { Check, Copy, X } from "lucide-react";
8
8
  import { type ReactNode, useEffect, useRef, useState } from "react";
9
9
  import { getSessionEntryDetail } from "../api";
10
10
  import { formatDurationMs, formatInteger } from "../data/formatters";
11
11
  import type { TraceSpan, TraceTrack } from "../types";
12
- import { Badge, Drawer, ErrorState, JsonBlock, KeyValues, Skeleton } from "../ui";
12
+ import { Badge, Drawer, type DrawerProps, ErrorState, JsonBlock, KeyValues, Skeleton } from "../ui";
13
+
14
+ export type TraceEntryLoader = (file: string, entryId: string, signal: AbortSignal) => Promise<{ entry: unknown }>;
13
15
 
14
16
  export interface SpanDrawerProps {
15
17
  span: TraceSpan | null;
@@ -17,6 +19,9 @@ export interface SpanDrawerProps {
17
19
  onClose: () => void;
18
20
  /** Selects the first span of the given child track ("open child track"). */
19
21
  onOpenChildTrack?: (childTrackId: string) => void;
22
+ loadEntry?: TraceEntryLoader;
23
+ inline?: boolean;
24
+ onNavigate?: (trackId: string, entryId: string) => void;
20
25
  }
21
26
 
22
27
  /** Loose structural view of a fetched journal entry (validated field-by-field). */
@@ -60,7 +65,33 @@ function textBlocks(content: unknown): Array<{ kind: string; text: string }> {
60
65
  return blocks;
61
66
  }
62
67
 
63
- export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawerProps) {
68
+ function InlineSpanDetail({ title, subtitle, actions, onClose, children }: DrawerProps) {
69
+ return (
70
+ <section className="traces-inline-detail" aria-label="Selected span details">
71
+ <header className="drawer-header">
72
+ <div className="drawer-titles">
73
+ <h2 className="drawer-title">{title}</h2>
74
+ {subtitle !== undefined && <div className="drawer-subtitle">{subtitle}</div>}
75
+ </div>
76
+ {actions}
77
+ <button type="button" className="btn" data-icon="true" onClick={onClose} aria-label="Close span details">
78
+ <X size={16} />
79
+ </button>
80
+ </header>
81
+ <div className="drawer-body">{children}</div>
82
+ </section>
83
+ );
84
+ }
85
+
86
+ export function SpanDrawer({
87
+ span,
88
+ track,
89
+ onClose,
90
+ onOpenChildTrack,
91
+ loadEntry = getSessionEntryDetail,
92
+ inline = false,
93
+ onNavigate,
94
+ }: SpanDrawerProps) {
64
95
  const [entry, setEntry] = useState<unknown>(null);
65
96
  const [loading, setLoading] = useState(false);
66
97
  const [error, setError] = useState<Error | null>(null);
@@ -75,7 +106,7 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
75
106
  if (!span || !track || !span.entryId) return;
76
107
  setLoading(true);
77
108
  const controller = new AbortController();
78
- getSessionEntryDetail(track.file, span.entryId, controller.signal)
109
+ loadEntry(track.file, span.entryId, controller.signal)
79
110
  .then(data => {
80
111
  if (!controller.signal.aborted) setEntry(data.entry);
81
112
  })
@@ -87,7 +118,7 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
87
118
  if (!controller.signal.aborted) setLoading(false);
88
119
  });
89
120
  return () => controller.abort();
90
- }, [span, track]);
121
+ }, [span, track, loadEntry]);
91
122
 
92
123
  useEffect(() => () => window.clearTimeout(copyResetRef.current), []);
93
124
 
@@ -151,8 +182,10 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
151
182
  metrics.push({ key: "stop", label: "Stop reason", value: <span className="mono">{msg.stopReason}</span> });
152
183
  }
153
184
 
185
+ const DetailShell = inline ? InlineSpanDetail : Drawer;
186
+
154
187
  return (
155
- <Drawer
188
+ <DetailShell
156
189
  open
157
190
  onClose={onClose}
158
191
  title={span.label}
@@ -179,6 +212,20 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
179
212
  >
180
213
  <div className="stack traces-drawer">
181
214
  <KeyValues items={metrics} />
215
+ {onNavigate && track && span.entryId && (
216
+ <div>
217
+ <button
218
+ type="button"
219
+ className="btn"
220
+ data-size="sm"
221
+ onClick={() => {
222
+ if (span.entryId) onNavigate(track.id, span.entryId);
223
+ }}
224
+ >
225
+ Jump to transcript
226
+ </button>
227
+ </div>
228
+ )}
182
229
 
183
230
  {msg?.errorMessage && (
184
231
  <div className="stack" style={{ gap: 6 }}>
@@ -238,6 +285,6 @@ export function SpanDrawer({ span, track, onClose, onOpenChildTrack }: SpanDrawe
238
285
 
239
286
  {!loading && entry !== null && <JsonBlock data={entry} title="Raw entry" initialCollapsed={true} />}
240
287
  </div>
241
- </Drawer>
288
+ </DetailShell>
242
289
  );
243
290
  }
@@ -32,6 +32,8 @@ export interface TimelineCanvasProps {
32
32
  collapsed: ReadonlySet<string>;
33
33
  onToggleCollapse: (trackId: string) => void;
34
34
  traceStart: number;
35
+ /** Live route focuses immediately; embedded viewers wait for user interaction. */
36
+ autoFocus?: boolean;
35
37
  }
36
38
 
37
39
  const RULER_H = 28;
@@ -222,6 +224,7 @@ export function TimelineCanvas({
222
224
  collapsed,
223
225
  onToggleCollapse,
224
226
  traceStart,
227
+ autoFocus = true,
225
228
  }: TimelineCanvasProps) {
226
229
  const colors = useTraceTheme();
227
230
  const containerRef = useRef<HTMLDivElement>(null);
@@ -463,12 +466,12 @@ export function TimelineCanvas({
463
466
  return;
464
467
  }
465
468
  event.preventDefault();
469
+ event.stopPropagation();
466
470
  };
467
471
 
468
- // Focus the canvas on mount so keyboard navigation works immediately.
469
472
  useEffect(() => {
470
- canvasRef.current?.focus({ preventScroll: true });
471
- }, []);
473
+ if (autoFocus) canvasRef.current?.focus({ preventScroll: true });
474
+ }, [autoFocus]);
472
475
 
473
476
  // Scene: everything except hover/selection, so pointer moves never repaint it.
474
477
  useEffect(() => {
@@ -603,7 +606,13 @@ export function TimelineCanvas({
603
606
  )}
604
607
  </div>
605
608
 
606
- {tooltip && createPortal(tooltip, document.body)}
609
+ {tooltip &&
610
+ createPortal(
611
+ tooltip,
612
+ containerRef.current?.getRootNode() instanceof ShadowRoot
613
+ ? (containerRef.current.getRootNode() as ShadowRoot)
614
+ : document.body,
615
+ )}
607
616
  </div>
608
617
  );
609
618
  }