@tangle-network/sandbox-ui 2.0.20 → 2.0.22

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 (40) hide show
  1. package/README.md +18 -1
  2. package/dist/{record-Cwyo2oHb.js → AgentTree-KO1TJXBq.js} +203 -313
  3. package/dist/VirtualList-DLh2JE0F.js +205 -0
  4. package/dist/{WorkflowGraph-CADReIDl.js → WorkflowGraph-B8cRtVaw.js} +2 -2
  5. package/dist/event-stream-DNEbvx4s.d.ts +2 -0
  6. package/dist/globals.css +5910 -240
  7. package/dist/hooks.d.ts +2 -2
  8. package/dist/hooks.js +2 -2
  9. package/dist/{index-ByNsP4VC.d.ts → index-BoKSR0s9.d.ts} +62 -4
  10. package/dist/{index-BYkigpIM.d.ts → index-gXcuwPOX.d.ts} +13 -7
  11. package/dist/index.d.ts +4 -4
  12. package/dist/index.js +8 -6
  13. package/dist/record-BGcR48YY.js +47 -0
  14. package/dist/record.d.ts +66 -0
  15. package/dist/record.js +2329 -0
  16. package/dist/report-viewer.js +554 -0
  17. package/dist/run/live.d.ts +1 -1
  18. package/dist/run/live.js +26 -28
  19. package/dist/run/record.d.ts +2 -2
  20. package/dist/run/record.js +4 -1
  21. package/dist/run.d.ts +4 -4
  22. package/dist/run.js +5 -3
  23. package/dist/runtime-viewer/{AgentGraphCanvas-B74GN1o6-DC3EH_ss.js → AgentGraphCanvas-DDvaU9Rg-DlNZxJ20.js} +187 -168
  24. package/dist/runtime-viewer/{latest-events-iiWwpIck-BTK0vSlR.js → latest-events-CabhXynX-Dp_vs0Px.js} +705 -691
  25. package/dist/runtime-viewer/sandbox-ui-CUj4Q1P_.css +2 -0
  26. package/dist/styles.css +5911 -241
  27. package/dist/tailwind.css +5677 -5
  28. package/dist/tokens.css +7 -1
  29. package/dist/{trace-workspace-C8dHRBDV.js → trace-workspace-DkakKRLV.js} +64 -78
  30. package/dist/{tree-data-C8CNt4WE.js → tree-data-B_IXAIgg.js} +171 -121
  31. package/dist/use-follow-tail-Ckx5ZSV2.js +1 -0
  32. package/dist/vault-tree.js +1 -1
  33. package/dist/workflows.js +1 -1
  34. package/dist/{workspace-CzikYt_y.js → workspace--GvnZNpH.js} +1 -1
  35. package/dist/{workspace-files-pane-DB9Nvwh4.js → workspace-files-pane-C0PeY6rY.js} +12 -12
  36. package/dist/workspace-viewer.js +9693 -9462
  37. package/dist/workspace.js +2 -2
  38. package/package.json +27 -13
  39. package/dist/agent-stream-CBZ2fXJ2.d.ts +0 -66
  40. package/dist/runtime-viewer/sandbox-ui-z5RGk327.css +0 -2
package/README.md CHANGED
@@ -530,4 +530,21 @@ Retheming is absolutely supported, but the documentation was thinner than it sho
530
530
 
531
531
  Apache-2.0
532
532
 
533
- The native Runtime viewer is `LiveRun` from `@tangle-network/sandbox-ui/run/live`. It composes the same trace workspace, selected-agent conversation, retained files and capture coverage. Hosts may supply the existing fetch/EventSource transport for their authenticated session; an API or authenticated transport change clears the previous view, while frames within one stable transport preserve selection and the camera. Keep the transport object stable for that account. `@tangle-network/sandbox-ui/workspace-viewer.js` is the self-contained browser entry for Runtime's existing asset-directory contract, with `styles.css` beside it. Generic same-origin ETag document reads are shared as `useDocument` from `/hooks/use-document` without unrelated optional peers; producers retain their format validation and accounting.
533
+ The native Runtime viewer is `LiveRun` from `@tangle-network/sandbox-ui/run/live`. It composes the same trace workspace, selected-agent conversation, retained files and capture coverage. Hosts may supply the existing fetch/EventSource transport for their authenticated session; an API or authenticated transport change clears the previous view, while frames within one stable transport preserve selection and the camera. Keep the transport object stable for that account. `@tangle-network/sandbox-ui/workspace-viewer.js` is the self-contained browser entry for Runtime's existing asset-directory contract, with `styles.css` beside it. `useDocument` owns same-origin ETag reads. `useEventStream` from `/run` owns native reconnect, terminal end and stale callback cleanup; `useAgentStream` resumes retained rows by generation and sequence. Authenticated openers keep their session and reconnect policy. Producers own validation and accounting.
534
+
535
+ ## Retained records and reports
536
+
537
+ `@tangle-network/sandbox-ui/record` owns `AgentRecord`, `ResearchReport` and `RecordWorkGraph`. Install the optional `@drewstone/agent-record` peer for this feature; its public `/record` and `/report` entries own validation, models, provenance relationships and report exports. The generic `/run/record` transcript remains usable without that parser package.
538
+
539
+ ```tsx
540
+ import { parseRecord } from '@drewstone/agent-record/record'
541
+ import { AgentRecord } from '@tangle-network/sandbox-ui/record'
542
+ import '@tangle-network/sandbox-ui/styles'
543
+
544
+ const record = parseRecord(input)
545
+ <AgentRecord records={[record]} />
546
+ ```
547
+
548
+ Selection keeps original run, actor and event IDs, a recorded timestamp, and Messages, Event details or Usage. Charts owns the waterfall clock, playback and zoom. Usage distinguishes missing counters from zero and metered windows from individual responses. Capture gaps and original source references stay visible. The evidence graph keeps recorded entity kinds and relationships and uses the maintained graph camera.
549
+
550
+ Research reports retain source documents, exact citations, Markdown/math, print and JSON/LaTeX exports. Optional source search accepts only a same-origin path and displays only the selected play's retained bytes. `agent-record-report` continues to write a single offline HTML file using this package's report viewer and stylesheet.
@@ -1,217 +1,14 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
2
+ import { t as VirtualList } from "./VirtualList-DLh2JE0F.js";
3
+ import { t as useFollowTail } from "./use-follow-tail-Ckx5ZSV2.js";
2
4
  import { HoverCard, HoverCardContent, HoverCardTrigger } from "@tangle-network/ui/primitives";
3
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
6
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
7
  import { Brain, Settings, Timer } from "lucide-react";
6
- import { formatDuration, getToolCategory, getToolDisplayMetadata } from "@tangle-network/ui/utils";
8
+ import { getToolCategory, getToolDisplayMetadata } from "@tangle-network/ui/utils";
7
9
  import { AgentTimelineColumn, AgentTimelineStep, SpeakerHeader, agentTimelineStepKind } from "@tangle-network/ui/chat";
8
- import { ExpandedToolDetail, InlineThinkingItem, RunRowShell, RunStatusIcon, runStatus } from "@tangle-network/ui/run";
9
- // src/run/record/VirtualList.tsx
10
- /**
11
- * Renders only the rows near the viewport of `scroller`. Row heights are measured as they render; unmeasured
12
- * rows use `estimate`. Spacers keep the scroll height, so the browser's scroll anchoring holds the reading place.
13
- */
14
- function VirtualList({ items, keyOf, render, scroller, estimate = 96, overscan = 900, target }) {
15
- const heights = useRef(/* @__PURE__ */ new Map());
16
- const [version, setVersion] = useState(0);
17
- const [viewport, setViewport] = useState({
18
- top: 0,
19
- height: 900
20
- });
21
- const observer = useRef(null);
22
- const nodes = useRef(/* @__PURE__ */ new Map());
23
- const frame = useRef(0);
24
- const observe = (element) => {
25
- if (!observer.current && typeof ResizeObserver !== "undefined") observer.current = new ResizeObserver((entries) => {
26
- let changed = false;
27
- for (const entry of entries) {
28
- const key = nodes.current.get(entry.target);
29
- if (!key) continue;
30
- const height = Math.ceil(entry.borderBoxSize?.[0]?.blockSize ?? entry.target.offsetHeight);
31
- if (height > 0 && heights.current.get(key) !== height) {
32
- heights.current.set(key, height);
33
- changed = true;
34
- }
35
- }
36
- if (changed && !frame.current) frame.current = requestAnimationFrame(() => {
37
- frame.current = 0;
38
- setVersion((value) => value + 1);
39
- });
40
- });
41
- observer.current?.observe(element);
42
- };
43
- useEffect(() => () => {
44
- cancelAnimationFrame(frame.current);
45
- observer.current?.disconnect();
46
- observer.current = null;
47
- nodes.current.clear();
48
- }, []);
49
- useEffect(() => {
50
- const element = scroller.current;
51
- if (!element) return;
52
- let frame = 0;
53
- const update = () => {
54
- frame = 0;
55
- setViewport((current) => current.top === element.scrollTop && current.height === element.clientHeight ? current : {
56
- top: element.scrollTop,
57
- height: element.clientHeight
58
- });
59
- };
60
- const schedule = () => {
61
- if (!frame) frame = requestAnimationFrame(update);
62
- };
63
- update();
64
- element.addEventListener("scroll", schedule, { passive: true });
65
- const resize = new ResizeObserver(schedule);
66
- resize.observe(element);
67
- return () => {
68
- cancelAnimationFrame(frame);
69
- element.removeEventListener("scroll", schedule);
70
- resize.disconnect();
71
- };
72
- }, [scroller]);
73
- const offsets = useMemo(() => {
74
- const result = new Float64Array(items.length + 1);
75
- const guess = typeof estimate === "function" ? estimate : () => estimate;
76
- for (let i = 0; i < items.length; i++) result[i + 1] = result[i] + (heights.current.get(keyOf(items[i])) ?? guess(items[i]));
77
- return result;
78
- }, [
79
- items,
80
- keyOf,
81
- estimate,
82
- version
83
- ]);
84
- const find = (y) => {
85
- let low = 0;
86
- let high = items.length;
87
- while (low < high) {
88
- const mid = low + high >> 1;
89
- if (offsets[mid + 1] <= y) low = mid + 1;
90
- else high = mid;
91
- }
92
- return low;
93
- };
94
- const start = Math.max(0, find(viewport.top - overscan));
95
- const end = Math.min(items.length, find(viewport.top + viewport.height + overscan) + 1);
96
- const scrollTo = useCallback((key, align) => {
97
- const element = scroller.current;
98
- const index = items.findIndex((item) => keyOf(item) === key);
99
- if (!element || index < 0) return false;
100
- const top = offsets[index];
101
- const bottom = offsets[index + 1];
102
- if (align || top < element.scrollTop || bottom > element.scrollTop + element.clientHeight) element.scrollTop = Math.max(0, top - 8);
103
- return true;
104
- }, [
105
- items,
106
- keyOf,
107
- offsets,
108
- scroller
109
- ]);
110
- const pending = useRef(null);
111
- const rows = useRef(/* @__PURE__ */ new Map());
112
- const settle = () => {
113
- const goal = pending.current;
114
- if (!goal) return;
115
- if (performance.now() > goal.until) {
116
- pending.current = null;
117
- return;
118
- }
119
- if (!scrollTo(goal.key, true)) return;
120
- const row = rows.current.get(goal.key);
121
- if (row?.isConnected) {
122
- const box = row.getBoundingClientRect();
123
- if (box.top < 72 || box.top > window.innerHeight - 96) window.scrollBy({ top: box.top - 120 });
124
- }
125
- };
126
- useLayoutEffect(() => {
127
- pending.current = target ? {
128
- key: target,
129
- until: performance.now() + 2e3
130
- } : null;
131
- settle();
132
- }, [target]);
133
- useLayoutEffect(settle, [
134
- version,
135
- viewport,
136
- items
137
- ]);
138
- useEffect(() => {
139
- const element = scroller.current;
140
- if (!element) return;
141
- const release = (event) => {
142
- const key = event instanceof KeyboardEvent && event.target === element && !event.defaultPrevented ? event.key : "";
143
- pending.current = items.length && (key === "Home" || key === "End") ? {
144
- key: keyOf(items[key === "Home" ? 0 : items.length - 1]),
145
- until: performance.now() + 2e3
146
- } : null;
147
- if (pending.current) {
148
- event.preventDefault();
149
- settle();
150
- }
151
- };
152
- element.addEventListener("wheel", release, { passive: true });
153
- element.addEventListener("touchstart", release, { passive: true });
154
- element.addEventListener("pointerdown", release);
155
- element.addEventListener("keydown", release);
156
- return () => {
157
- element.removeEventListener("wheel", release);
158
- element.removeEventListener("touchstart", release);
159
- element.removeEventListener("pointerdown", release);
160
- element.removeEventListener("keydown", release);
161
- };
162
- }, [
163
- scroller,
164
- items,
165
- keyOf,
166
- offsets
167
- ]);
168
- const bind = (key) => (element) => {
169
- if (!element) {
170
- rows.current.delete(key);
171
- return;
172
- }
173
- rows.current.set(key, element);
174
- for (const [node, value] of nodes.current) if (value === key && node !== element) {
175
- observer.current?.unobserve(node);
176
- nodes.current.delete(node);
177
- }
178
- if (!nodes.current.has(element)) {
179
- nodes.current.set(element, key);
180
- observe(element);
181
- }
182
- };
183
- useEffect(() => {
184
- for (const node of [...nodes.current.keys()]) if (!node.isConnected) {
185
- observer.current?.unobserve(node);
186
- nodes.current.delete(node);
187
- }
188
- });
189
- return /* @__PURE__ */ jsxs(Fragment, { children: [
190
- /* @__PURE__ */ jsx("div", {
191
- style: {
192
- height: offsets[start],
193
- transitionProperty: "none"
194
- },
195
- "aria-hidden": "true"
196
- }),
197
- items.slice(start, end).map((item, i) => {
198
- const key = keyOf(item);
199
- return /* @__PURE__ */ jsx("div", {
200
- ref: bind(key),
201
- className: "virtual-row",
202
- style: { display: "flow-root" },
203
- children: render(item, start + i)
204
- }, key);
205
- }),
206
- /* @__PURE__ */ jsx("div", {
207
- style: {
208
- height: offsets[items.length] - offsets[end],
209
- transitionProperty: "none"
210
- },
211
- "aria-hidden": "true"
212
- })
213
- ] });
214
- }
10
+ import { ExpandedToolDetail, InlineThinkingItem, RunRowShell, RunStatusIcon, runStatus, statusMeta } from "@tangle-network/ui/run";
11
+ import { formatCompact, formatCompact as fmt, formatCost, formatSpanDuration, formatSpanDuration as interval, formatUtcTime as utcTime, reportedCount, sumReportedCounts } from "@tangle-network/charts";
215
12
  // src/run/record/model.ts
216
13
  var model_exports = /* @__PURE__ */ __exportAll({
217
14
  agentState: () => agentState,
@@ -228,6 +25,7 @@ var model_exports = /* @__PURE__ */ __exportAll({
228
25
  modelIdentity: () => modelIdentity,
229
26
  ms: () => ms,
230
27
  preview: () => preview,
28
+ recordFlowTrace: () => recordFlowTrace,
231
29
  roleOf: () => roleOf,
232
30
  textOf: () => textOf,
233
31
  toolKey: () => toolKey,
@@ -235,13 +33,6 @@ var model_exports = /* @__PURE__ */ __exportAll({
235
33
  utcTime: () => utcTime
236
34
  });
237
35
  const ms = (value) => Date.parse(value);
238
- /** Normalize labels to UTC without rewriting the source timestamp. */
239
- function utcTime(value, includeDate = false) {
240
- const date = new Date(value);
241
- if (!Number.isFinite(date.valueOf())) return "Unknown time";
242
- const iso = date.toISOString();
243
- return `${includeDate ? iso.slice(0, 19).replace("T", " ") : iso.slice(11, 19)} UTC`;
244
- }
245
36
  const compact = (value) => value.replace(/\s+/g, " ").trim();
246
37
  /**
247
38
  * Recorded text names the host folders an agent ran in (`/home/<user>/.cache/disco/host/<run>/sessions/<session>/inputs/x.md`).
@@ -276,11 +67,6 @@ function withoutHostPaths(event) {
276
67
  }
277
68
  };
278
69
  }
279
- const fmt = (value) => new Intl.NumberFormat("en-US", {
280
- notation: "compact",
281
- maximumFractionDigits: 1
282
- }).format(value);
283
- const interval = (value) => value < 1e3 ? `${Math.round(value)} ms` : value < 6e4 ? `${(value / 1e3).toFixed(1)} s` : `${(value / 6e4).toFixed(1)} min`;
284
70
  const roleOf = (node) => node?.role ?? (node?.kind === "session" ? "Session" : node?.kind === "finding" ? "Finding" : "Agent");
285
71
  const textOf = (event) => event.detail.publicText ?? event.detail.recordedClaim ?? "";
286
72
  const toolKey = (node, id) => JSON.stringify([node, id]);
@@ -361,8 +147,9 @@ function indexRecord(record) {
361
147
  results.set(key, matching);
362
148
  }
363
149
  }
364
- const times = events.map((event) => ms(event.at));
365
- const start = times[0] ?? 0;
150
+ const times = [...events.map((event) => ms(event.at)), ...actors.flatMap((node) => node.start ? [ms(node.start)] : [])].filter(Number.isFinite);
151
+ const start = times.length ? times.reduce((first, at) => Math.min(first, at)) : 0;
152
+ const observedEnd = events.reduce((latest, event) => Math.max(latest, event.end ? ms(event.end) : ms(event.at)), start);
366
153
  return {
367
154
  nodes,
368
155
  canonical,
@@ -374,7 +161,7 @@ function indexRecord(record) {
374
161
  calls,
375
162
  results,
376
163
  start,
377
- end: events.reduce((latest, event) => Math.max(latest, event.end ? ms(event.end) : ms(event.at)), times.at(-1) ?? start)
164
+ end: actors.reduce((latest, node) => typeof node.end === "string" && Number.isFinite(ms(node.end)) ? Math.max(latest, ms(node.end)) : latest, observedEnd)
378
165
  };
379
166
  }
380
167
  /** Whether any event of this agent recorded token usage or a list price: a status alone measures nothing. */
@@ -486,7 +273,7 @@ function tooltipSections(event, index, cutoff) {
486
273
  const duration = event.detail.durationMs;
487
274
  if (typeof duration === "number") sections.push({
488
275
  kind: "status",
489
- value: `⏱ ${duration < 1e3 ? `${duration}ms` : `${(duration / 1e3).toFixed(1)}s`}`,
276
+ value: `⏱ ${interval(duration)}`,
490
277
  tone: "muted"
491
278
  });
492
279
  return {
@@ -496,6 +283,181 @@ function tooltipSections(event, index, cutoff) {
496
283
  sections
497
284
  };
498
285
  }
286
+ const time = (value) => typeof value === "string" ? ms(value) : NaN;
287
+ /** Project retained identities and measured intervals; a message without a duration remains a point. */
288
+ function recordFlowTrace(record, live = false, observedAt = Date.now()) {
289
+ const index = indexRecord(record);
290
+ const first = index.events.length || index.actors.some((node) => Number.isFinite(time(node.start))) ? index.start : observedAt;
291
+ const last = Math.max(first, index.end, live ? observedAt : first);
292
+ const spans = [];
293
+ const actors = new Set(index.actors.map((node) => node.id));
294
+ const runningNodes = new Set(index.actors.filter((node) => live && !node.end && [
295
+ "running",
296
+ "waiting",
297
+ "needs-you"
298
+ ].includes(runStatus(node.status ?? "unknown"))).map((node) => node.id));
299
+ const offset = (at) => Math.max(0, at - first);
300
+ for (const node of index.actors) {
301
+ if (node.kind === "finding") continue;
302
+ const events = index.byActor.get(node.id) ?? [];
303
+ const start = Number.isFinite(time(node.start)) ? time(node.start) : events[0] ? ms(events[0].at) : NaN;
304
+ if (!Number.isFinite(start)) continue;
305
+ const end = runningNodes.has(node.id) ? last : Number.isFinite(time(node.end)) ? time(node.end) : events.at(-1) ? ms(events.at(-1).at) : start;
306
+ const parent = node.parent ? index.canonical(node.parent) : void 0;
307
+ spans.push({
308
+ id: node.id,
309
+ ...parent && actors.has(parent) ? { parentId: parent } : {},
310
+ kind: "pipeline",
311
+ name: node.label,
312
+ startMs: offset(start),
313
+ endMs: offset(Math.max(start, end)),
314
+ status: runStatus(node.status ?? "unknown"),
315
+ approx: !node.start || !node.end
316
+ });
317
+ if (parent && actors.has(parent)) spans.push({
318
+ id: `${node.id}:handoff`,
319
+ parentId: parent,
320
+ kind: "handoff",
321
+ name: `Handoff to ${node.label}`,
322
+ startMs: offset(start),
323
+ endMs: offset(start),
324
+ approx: true,
325
+ meta: { timing: "Recorded child start; handoff duration was not measured" }
326
+ });
327
+ }
328
+ const promptTokens = [];
329
+ const completionTokens = [];
330
+ let cost = 0;
331
+ let costKnown = false;
332
+ let costMissing = false;
333
+ const explicitTools = /* @__PURE__ */ new Set();
334
+ let toolCalls = 0;
335
+ for (const event of index.events) {
336
+ const detail = event.detail;
337
+ const response = detail.usageScope === void 0 && detail.role === "assistant";
338
+ const counters = [
339
+ detail.usage?.input,
340
+ detail.usage?.cacheRead,
341
+ detail.usage?.cacheWrite,
342
+ detail.usage?.output
343
+ ].map(reportedCount);
344
+ const prompt = sumReportedCounts(counters.slice(0, 3)), completion = counters[3];
345
+ const measured = counters.filter((value) => value !== void 0);
346
+ const facts = measured.length && measured.length < counters.length ? [{
347
+ label: "Tokens",
348
+ value: `≥ ${fmt(measured.reduce((sum, value) => sum + value, 0))} measured; incomplete counters`
349
+ }] : void 0;
350
+ if (detail.usageScope === void 0) {
351
+ if (response || detail.usage) {
352
+ promptTokens.push(prompt);
353
+ completionTokens.push(completion);
354
+ }
355
+ if (detail.usdKnown === false) costMissing = true;
356
+ else if (typeof detail.costListUsd === "number") {
357
+ cost += detail.costListUsd;
358
+ costKnown = true;
359
+ } else if (detail.usage && Object.values(detail.usage).some((value) => typeof value === "number" && value > 0)) costMissing = true;
360
+ }
361
+ const start = ms(event.at);
362
+ const parentId = index.canonical(event.node);
363
+ if (typeof detail.spanId === "string") {
364
+ const kind = event.kind.replace(/^trace-/, "").toLowerCase();
365
+ const end = time(detail.end);
366
+ const state = runStatus(typeof detail.status === "string" ? detail.status : "unknown");
367
+ const running = live && !Number.isFinite(end) && [
368
+ "running",
369
+ "waiting",
370
+ "needs-you"
371
+ ].includes(state);
372
+ const spanKind = ["llm", "model"].includes(kind) ? "model" : kind === "tool" ? "tool" : kind === "waiting" ? "waiting" : kind === "handoff" ? "handoff" : "pipeline";
373
+ const traceId = String(detail.traceId ?? event.node);
374
+ spans.push({
375
+ id: `${traceId}:${detail.spanId}`,
376
+ parentId: typeof detail.parentSpanId === "string" ? `${traceId}:${detail.parentSpanId}` : parentId,
377
+ kind: spanKind,
378
+ name: event.label,
379
+ startMs: offset(start),
380
+ endMs: offset(Number.isFinite(end) ? Math.max(start, end) : running ? last : start),
381
+ status: detail.isError ? "failed" : state,
382
+ meta: { eventId: event.id }
383
+ });
384
+ if (spanKind === "tool") {
385
+ toolCalls++;
386
+ if (typeof detail.toolCallId === "string") explicitTools.add(toolKey(event.node, detail.toolCallId));
387
+ }
388
+ } else if (response) spans.push({
389
+ id: event.id,
390
+ parentId,
391
+ kind: "model",
392
+ name: event.label,
393
+ startMs: offset(start),
394
+ endMs: offset(start),
395
+ approx: true,
396
+ status: runStatus(typeof detail.responseStatus === "string" ? detail.responseStatus : "unknown"),
397
+ promptTokens: prompt,
398
+ completionTokens: completion,
399
+ facts,
400
+ meta: {
401
+ eventId: event.id,
402
+ timing: "Response timestamp; generation duration was not recorded"
403
+ }
404
+ });
405
+ }
406
+ for (const key of /* @__PURE__ */ new Set([...index.calls.keys(), ...index.results.keys()])) {
407
+ if (explicitTools.has(key)) continue;
408
+ const calls = index.calls.get(key) ?? [], results = index.results.get(key) ?? [];
409
+ const result = calls.length === 1 && results.length === 1 && ms(results[0].at) >= ms(calls[0].event.at) ? results[0] : void 0;
410
+ const timing = calls.length > 1 || results.length > 1 ? "Ambiguous retained call/result identity; no duration measured" : calls.length && results.length ? "Recorded result timestamp precedes call; no interval measured" : "Unmatched retained call or result; no interval measured";
411
+ for (const { event, call } of calls) {
412
+ toolCalls++;
413
+ const start = ms(event.at), parentId = index.canonical(event.node);
414
+ spans.push({
415
+ id: `${event.id}:tool:${call.id}`,
416
+ parentId,
417
+ kind: "tool",
418
+ name: call.name,
419
+ startMs: offset(start),
420
+ endMs: offset(result ? ms(result.at) : start),
421
+ approx: !result || event.detail.atBasis === "carried" || result.detail.atBasis === "carried",
422
+ status: result?.detail.isError ? "failed" : result ? "done" : "unknown",
423
+ facts: result ? void 0 : [{
424
+ label: "Timing",
425
+ value: timing
426
+ }],
427
+ meta: {
428
+ eventId: event.id,
429
+ ...!result ? { timing } : {}
430
+ }
431
+ });
432
+ }
433
+ if (!result) for (const event of results) spans.push({
434
+ id: `${event.id}:result`,
435
+ parentId: index.canonical(event.node),
436
+ kind: "tool",
437
+ name: event.label,
438
+ startMs: offset(ms(event.at)),
439
+ endMs: offset(ms(event.at)),
440
+ approx: true,
441
+ status: event.detail.isError ? "failed" : "unknown",
442
+ facts: [{
443
+ label: "Timing",
444
+ value: timing
445
+ }],
446
+ meta: { eventId: event.id }
447
+ });
448
+ }
449
+ return {
450
+ spans,
451
+ totalMs: last - first,
452
+ promptTokens: sumReportedCounts(promptTokens),
453
+ completionTokens: sumReportedCounts(completionTokens),
454
+ toolCalls,
455
+ ...costKnown && !costMissing ? {
456
+ costUsd: cost,
457
+ costBasis: "list"
458
+ } : {}
459
+ };
460
+ }
499
461
  // src/run/record/conversation-rows.ts
500
462
  /**
501
463
  * A key that survives a live record growing. Event ids hash the whole source file, so every append renames every
@@ -720,11 +682,7 @@ function pollSummary(events) {
720
682
  measured++;
721
683
  tokens += parts.reduce((sum, value) => sum + value, 0);
722
684
  }
723
- const count = new Intl.NumberFormat("en-US", {
724
- notation: "compact",
725
- maximumFractionDigits: 1
726
- });
727
- const tokenText = !measured ? "tokens unknown" : measured < events.length ? `~${count.format(tokens)} tokens (${measured} of ${events.length} measured)` : `~${count.format(tokens)} tokens`;
685
+ const tokenText = !measured ? "tokens unknown" : measured < events.length ? `~${formatCompact(tokens)} tokens (${measured} of ${events.length} measured)` : `~${formatCompact(tokens)} tokens`;
728
686
  return {
729
687
  polls: events.length,
730
688
  spanMs: span,
@@ -755,11 +713,6 @@ function promptText(text) {
755
713
  // src/run/record/ConversationRowView.tsx
756
714
  /** A run-lifecycle event (start, stop, restart), drawn as a thin marker row. */
757
715
  const isLifecycle = (event) => event.category === "lifecycle" || event.detail.lifecycle !== void 0;
758
- const tokenLabel = (value) => new Intl.NumberFormat("en-US", {
759
- notation: "compact",
760
- maximumFractionDigits: 1
761
- }).format(value);
762
- const spanLabel = (value) => value < 1e3 ? `${Math.round(value)} ms` : formatDuration(value);
763
716
  function ToolStep({ item, previous }) {
764
717
  const part = item.part, cost = part.state.metadata?.costUsd, time = part.state.time, category = getToolCategory(part.tool);
765
718
  const tone = part.state.status === "error" ? "var(--surface-danger-text)" : [
@@ -792,12 +745,12 @@ function ToolStep({ item, previous }) {
792
745
  /* @__PURE__ */ jsx("dt", { children: "Duration" }),
793
746
  /* @__PURE__ */ jsx("dd", {
794
747
  className: "m-0 text-right",
795
- children: time?.end != null && time.start != null ? spanLabel(time.end - time.start) : "Not recorded"
748
+ children: time?.end != null && time.start != null ? formatSpanDuration(time.end - time.start) : "Not recorded"
796
749
  }),
797
750
  /* @__PURE__ */ jsx("dt", { children: "Cost" }),
798
751
  /* @__PURE__ */ jsx("dd", {
799
752
  className: "m-0 text-right",
800
- children: typeof cost === "number" ? `$${cost.toFixed(4)}` : "Not recorded"
753
+ children: typeof cost === "number" ? formatCost(cost) : "Not recorded"
801
754
  })
802
755
  ]
803
756
  }),
@@ -926,7 +879,7 @@ function toolItems(id, call, result, state, durationMs, live, opener) {
926
879
  }
927
880
  const toolNote = (part) => {
928
881
  const note = part.state.metadata?.note, time = part.state.time;
929
- const duration = time?.end != null && time.start != null ? spanLabel(time.end - time.start) : "Not recorded";
882
+ const duration = time?.end != null && time.start != null ? formatSpanDuration(time.end - time.start) : "Not recorded";
930
883
  const result = typeof note === "string" ? note : part.state.status === "error" ? "Failed" : part.state.status === "running" ? "Running" : "Returned";
931
884
  return /* @__PURE__ */ jsxs("span", {
932
885
  "data-record-tool-summary": "",
@@ -1074,7 +1027,7 @@ function rowItems(row, context) {
1074
1027
  if (withheld.length) {
1075
1028
  const output = outputTokens(event.detail);
1076
1029
  const span = thinkingSpan.get(event.id);
1077
- const label = output !== null ? `${span !== void 0 && span >= 1e3 ? `for ${spanLabel(span)} · ` : ""}${tokenLabel(output)} output tokens · ${withheld.join(" · ")}` : withheld.join(" · ");
1030
+ const label = output !== null ? `${span !== void 0 && span >= 1e3 ? `for ${formatSpanDuration(span)} · ` : ""}${formatCompact(output)} output tokens · ${withheld.join(" · ")}` : withheld.join(" · ");
1078
1031
  items.push({
1079
1032
  id: `${event.id}:thinking`,
1080
1033
  kind: "custom",
@@ -1330,27 +1283,12 @@ function Conversation({ index, actor, cutoff, query, category, selected, openSou
1330
1283
  return map;
1331
1284
  }, [rows]);
1332
1285
  const retained = (index.byActor.get(actor) ?? []).some((item) => !isLifecycle(item) && (item.detail.role || textOf(item) || callsOf(item).length));
1333
- const targetRef = useRef(target);
1334
- targetRef.current = target;
1335
- const following = useRef(false);
1336
- const followRef = useRef(follow);
1337
- followRef.current = follow;
1338
- useLayoutEffect(() => {
1339
- following.current = follow && !targetRef.current;
1340
- if (scroll.current && !targetRef.current) scroll.current.scrollTop = follow ? scroll.current.scrollHeight : 0;
1341
- }, [actor]);
1342
- useLayoutEffect(() => {
1343
- if (!follow) following.current = false;
1344
- }, [follow]);
1345
- useEffect(() => {
1346
- const element = scroll.current;
1347
- if (!element) return;
1348
- const track = () => {
1349
- following.current = followRef.current && element.scrollHeight - element.scrollTop - element.clientHeight < 24;
1350
- };
1351
- element.addEventListener("scroll", track, { passive: true });
1352
- return () => element.removeEventListener("scroll", track);
1353
- }, []);
1286
+ const { following } = useFollowTail(scroll, {
1287
+ identity: actor,
1288
+ content: rows,
1289
+ follow,
1290
+ target
1291
+ });
1354
1292
  const arrival = useRef({ actor });
1355
1293
  useLayoutEffect(() => {
1356
1294
  const tail = rows.at(-1), event = tail?.kind === "event" ? tail.event : tail?.events.at(-1), entry = tail?.kind === "event" ? tail.event.id : tail?.events[0]?.id;
@@ -1361,11 +1299,8 @@ function Conversation({ index, actor, cutoff, query, category, selected, openSou
1361
1299
  };
1362
1300
  const element = scroll.current;
1363
1301
  if (!element || !following.current) return;
1364
- element.scrollTop = element.scrollHeight;
1365
- let frames = 0;
1366
- let frame = requestAnimationFrame(function pin() {
1302
+ const frame = requestAnimationFrame(() => {
1367
1303
  if (!following.current) return;
1368
- element.scrollTop = element.scrollHeight;
1369
1304
  const row = entry && element.querySelector(`[data-entry="${CSS.escape(entry)}"]`);
1370
1305
  if (animate && row) {
1371
1306
  animate = false;
@@ -1375,7 +1310,6 @@ function Conversation({ index, actor, cutoff, query, category, selected, openSou
1375
1310
  easing: style.getPropertyValue("--ease-expo").trim() || "linear"
1376
1311
  });
1377
1312
  }
1378
- if (++frames < 20) frame = requestAnimationFrame(pin);
1379
1313
  });
1380
1314
  return () => cancelAnimationFrame(frame);
1381
1315
  }, [rows]);
@@ -1472,50 +1406,6 @@ function Conversation({ index, actor, cutoff, query, category, selected, openSou
1472
1406
  })]
1473
1407
  }) });
1474
1408
  }
1475
- // src/run/record/prompts.ts
1476
- /** The conversation needs an opening prompt even when the provider did not retain its first user turn. */
1477
- function withPrompts(record) {
1478
- const events = record.events.map((event) => ({
1479
- ...event,
1480
- detail: { ...event.detail }
1481
- }));
1482
- const owners = new Map(record.nodes.map((node) => [node.id, node.agentId ?? node.id]));
1483
- for (const node of record.nodes.filter((item) => item.kind === "agent")) {
1484
- const own = events.filter((event) => (owners.get(event.node) ?? event.node) === node.id);
1485
- const firstReply = own.find((event) => event.detail.role === "assistant")?.at;
1486
- const userTurns = own.filter((event) => event.detail.role === "user" && typeof event.detail.publicText === "string" && !event.detail.publicText.trimStart().startsWith("<task-notification>"));
1487
- const initial = userTurns.find((event) => event.detail.promptKind !== "steering" && (!firstReply || event.at <= firstReply));
1488
- if (initial) initial.detail.promptKind = "initial";
1489
- else if (node.assignment?.trim()) {
1490
- const at = node.start ?? own[0]?.at;
1491
- if (at) events.push({
1492
- id: `prompt:${node.id}`,
1493
- node: node.id,
1494
- at,
1495
- kind: "message",
1496
- category: "other",
1497
- label: "Prompt",
1498
- source: null,
1499
- detail: {
1500
- role: "user",
1501
- publicText: node.assignment,
1502
- promptKind: "initial",
1503
- promptReconstructed: true,
1504
- promptSource: "node.assignment"
1505
- }
1506
- });
1507
- }
1508
- for (const event of userTurns) {
1509
- if (event === initial || event.detail.promptKind === "initial") continue;
1510
- event.detail.promptKind ??= "steering";
1511
- }
1512
- }
1513
- events.sort((a, b) => a.at.localeCompare(b.at) || Number(b.detail.promptKind === "initial") - Number(a.detail.promptKind === "initial"));
1514
- return {
1515
- ...record,
1516
- events
1517
- };
1518
- }
1519
1409
  // src/run/record/AgentTree.tsx
1520
1410
  /** Retained identity and recorded time define the hierarchy; labels never join sessions or invent parents. */
1521
1411
  function AgentTree({ index, actor, cutoff, select }) {
@@ -1547,9 +1437,9 @@ function AgentTree({ index, actor, cutoff, select }) {
1547
1437
  children: entries.map(({ node, children }) => {
1548
1438
  const firstEvent = index.byActor.get(node.id)?.[0];
1549
1439
  const begin = node.start ? ms(node.start) : firstEvent ? ms(firstEvent.at) : void 0;
1550
- const end = typeof node.end === "string" ? node.end : void 0;
1551
- const future = begin !== void 0 && begin > cutoff, ended = end !== void 0 && ms(end) <= cutoff;
1552
- const state = future ? "Not yet started" : ended ? node.status === "done" ? "Finished" : node.status === "down" ? "Stopped" : node.status ?? "Ended" : end ? "Not yet settled" : "No terminal record";
1440
+ const future = begin !== void 0 && begin > cutoff, pending = typeof node.end === "string" && ms(node.end) > cutoff;
1441
+ const status = runStatus(future || pending ? "unknown" : node.status ?? "unknown");
1442
+ const state = future ? "Not yet started" : pending ? "Not yet settled" : statusMeta(status).label;
1553
1443
  return /* @__PURE__ */ jsxs("li", {
1554
1444
  className: "min-w-0",
1555
1445
  children: [/* @__PURE__ */ jsxs("button", {
@@ -1562,7 +1452,7 @@ function AgentTree({ index, actor, cutoff, select }) {
1562
1452
  title: node.label + (node.assignment ? " · " + node.assignment : "") + " · " + state,
1563
1453
  onClick: () => select(node.id),
1564
1454
  children: [/* @__PURE__ */ jsx(RunStatusIcon, {
1565
- status: runStatus(future ? "unknown" : node.status === "down" || node.status === "no-winner" ? "stopped" : node.status ?? "unknown"),
1455
+ status,
1566
1456
  className: "mt-0.5 size-3.5 shrink-0",
1567
1457
  "aria-hidden": true
1568
1458
  }), /* @__PURE__ */ jsxs("span", {
@@ -1594,4 +1484,4 @@ function AgentTree({ index, actor, cutoff, select }) {
1594
1484
  })
1595
1485
  });
1596
1486
  }
1597
- export { model_exports as a, indexRecord as i, withPrompts as n, VirtualList as o, Conversation as r, AgentTree as t };
1487
+ export { fmt as a, modelIdentity as c, recordFlowTrace as d, roleOf as f, utcTime as h, eventSource as i, model_exports as l, toolKey as m, Conversation as n, indexRecord as o, textOf as p, eventMatches as r, interval as s, AgentTree as t, ms as u };