@schlessera/brain-ui-react 0.34.0 → 0.34.1

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 (157) hide show
  1. package/README.md +5 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -1
  3. package/dist/components/activity/activity-page.js +8 -149
  4. package/dist/components/activity/activity-page.js.map +1 -1
  5. package/dist/components/activity/activity-rollups.d.ts +5 -0
  6. package/dist/components/activity/activity-rollups.d.ts.map +1 -0
  7. package/dist/components/activity/activity-rollups.js +39 -0
  8. package/dist/components/activity/activity-rollups.js.map +1 -0
  9. package/dist/components/activity/activity-run-detail.d.ts +11 -0
  10. package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
  11. package/dist/components/activity/activity-run-detail.js +111 -0
  12. package/dist/components/activity/activity-run-detail.js.map +1 -0
  13. package/dist/components/activity/activity-run-list.d.ts +15 -0
  14. package/dist/components/activity/activity-run-list.d.ts.map +1 -0
  15. package/dist/components/activity/activity-run-list.js +25 -0
  16. package/dist/components/activity/activity-run-list.js.map +1 -0
  17. package/dist/components/chat/brain-markdown-code.d.ts +4 -0
  18. package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
  19. package/dist/components/chat/brain-markdown-code.js +47 -0
  20. package/dist/components/chat/brain-markdown-code.js.map +1 -0
  21. package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
  22. package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
  23. package/dist/components/chat/brain-markdown-entities.js +93 -0
  24. package/dist/components/chat/brain-markdown-entities.js.map +1 -0
  25. package/dist/components/chat/brain-markdown-links.d.ts +36 -0
  26. package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
  27. package/dist/components/chat/brain-markdown-links.js +176 -0
  28. package/dist/components/chat/brain-markdown-links.js.map +1 -0
  29. package/dist/components/chat/brain-markdown-share.d.ts +15 -0
  30. package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
  31. package/dist/components/chat/brain-markdown-share.js +40 -0
  32. package/dist/components/chat/brain-markdown-share.js.map +1 -0
  33. package/dist/components/chat/brain-markdown.d.ts +1 -34
  34. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  35. package/dist/components/chat/brain-markdown.js +9 -402
  36. package/dist/components/chat/brain-markdown.js.map +1 -1
  37. package/dist/components/chat/composer.d.ts.map +1 -1
  38. package/dist/components/chat/composer.js +14 -1
  39. package/dist/components/chat/composer.js.map +1 -1
  40. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  41. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  42. package/dist/components/chat/use-markdown-highlight.js +56 -0
  43. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  44. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  45. package/dist/components/connectivity/connection-gate.js +41 -7
  46. package/dist/components/connectivity/connection-gate.js.map +1 -1
  47. package/dist/components/connectivity/connection-state.d.ts +12 -0
  48. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  49. package/dist/components/connectivity/connection-state.js +23 -0
  50. package/dist/components/connectivity/connection-state.js.map +1 -0
  51. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  52. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  53. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  54. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  55. package/dist/components/graph/graph-controls.js +4 -1
  56. package/dist/components/graph/graph-controls.js.map +1 -1
  57. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  58. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  59. package/dist/components/graph/graph-maintenance.js +115 -0
  60. package/dist/components/graph/graph-maintenance.js.map +1 -0
  61. package/dist/components/graph/graph-page.d.ts.map +1 -1
  62. package/dist/components/graph/graph-page.js +11 -306
  63. package/dist/components/graph/graph-page.js.map +1 -1
  64. package/dist/components/graph/graph-scene.d.ts +8 -0
  65. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  66. package/dist/components/graph/graph-scene.js +208 -0
  67. package/dist/components/graph/graph-scene.js.map +1 -0
  68. package/dist/components/graph/graph-spinner.d.ts +2 -0
  69. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  70. package/dist/components/graph/graph-spinner.js +6 -0
  71. package/dist/components/graph/graph-spinner.js.map +1 -0
  72. package/dist/hooks/use-vpn-status.d.ts +5 -0
  73. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  74. package/dist/hooks/use-vpn-status.js +61 -29
  75. package/dist/hooks/use-vpn-status.js.map +1 -1
  76. package/dist/hooks/use-websocket.d.ts +3 -2
  77. package/dist/hooks/use-websocket.d.ts.map +1 -1
  78. package/dist/hooks/use-websocket.js +61 -252
  79. package/dist/hooks/use-websocket.js.map +1 -1
  80. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  81. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  82. package/dist/hooks/websocket-handlers/activity.js +10 -0
  83. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  84. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  85. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  86. package/dist/hooks/websocket-handlers/chat.js +175 -0
  87. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  88. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  89. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  90. package/dist/hooks/websocket-handlers/connection.js +27 -0
  91. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  92. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  93. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  94. package/dist/hooks/websocket-handlers/index.js +19 -0
  95. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  96. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  97. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  98. package/dist/hooks/websocket-handlers/mask.js +14 -0
  99. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  100. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  101. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  102. package/dist/hooks/websocket-handlers/provider.js +31 -0
  103. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  104. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  105. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  106. package/dist/hooks/websocket-handlers/types.js +2 -0
  107. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  108. package/dist/stores/activity-store-helpers.d.ts +50 -0
  109. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  110. package/dist/stores/activity-store-helpers.js +143 -0
  111. package/dist/stores/activity-store-helpers.js.map +1 -0
  112. package/dist/stores/activity-store-types.d.ts +48 -0
  113. package/dist/stores/activity-store-types.d.ts.map +1 -0
  114. package/dist/stores/activity-store-types.js +2 -0
  115. package/dist/stores/activity-store-types.js.map +1 -0
  116. package/dist/stores/activity-store.d.ts +2 -78
  117. package/dist/stores/activity-store.d.ts.map +1 -1
  118. package/dist/stores/activity-store.js +22 -142
  119. package/dist/stores/activity-store.js.map +1 -1
  120. package/dist/stores/connection-store.d.ts +14 -2
  121. package/dist/stores/connection-store.d.ts.map +1 -1
  122. package/dist/stores/connection-store.js +17 -0
  123. package/dist/stores/connection-store.js.map +1 -1
  124. package/dist/styles.css +1 -1
  125. package/package.json +2 -2
  126. package/src/components/activity/activity-page.tsx +6 -477
  127. package/src/components/activity/activity-rollups.tsx +94 -0
  128. package/src/components/activity/activity-run-detail.tsx +301 -0
  129. package/src/components/activity/activity-run-list.tsx +97 -0
  130. package/src/components/chat/brain-markdown-code.tsx +60 -0
  131. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  132. package/src/components/chat/brain-markdown-links.tsx +247 -0
  133. package/src/components/chat/brain-markdown-share.ts +47 -0
  134. package/src/components/chat/brain-markdown.tsx +27 -535
  135. package/src/components/chat/composer.tsx +14 -1
  136. package/src/components/chat/use-markdown-highlight.ts +63 -0
  137. package/src/components/connectivity/connection-gate.tsx +64 -13
  138. package/src/components/connectivity/connection-state.ts +37 -0
  139. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  140. package/src/components/graph/graph-controls.tsx +4 -1
  141. package/src/components/graph/graph-maintenance.tsx +304 -0
  142. package/src/components/graph/graph-page.tsx +11 -840
  143. package/src/components/graph/graph-scene.tsx +548 -0
  144. package/src/components/graph/graph-spinner.tsx +9 -0
  145. package/src/hooks/use-vpn-status.ts +64 -31
  146. package/src/hooks/use-websocket.ts +70 -273
  147. package/src/hooks/websocket-handlers/activity.ts +13 -0
  148. package/src/hooks/websocket-handlers/chat.ts +210 -0
  149. package/src/hooks/websocket-handlers/connection.ts +30 -0
  150. package/src/hooks/websocket-handlers/index.ts +59 -0
  151. package/src/hooks/websocket-handlers/mask.ts +17 -0
  152. package/src/hooks/websocket-handlers/provider.ts +35 -0
  153. package/src/hooks/websocket-handlers/types.ts +27 -0
  154. package/src/stores/activity-store-helpers.ts +164 -0
  155. package/src/stores/activity-store-types.ts +55 -0
  156. package/src/stores/activity-store.ts +21 -217
  157. package/src/stores/connection-store.ts +33 -2
@@ -1,51 +1,27 @@
1
- import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  Activity as ActivityIcon,
4
4
  AlertTriangle,
5
- ArrowLeft,
6
- Bot,
7
- ChevronRight,
8
- Clock,
9
5
  RefreshCw,
10
6
  Timer,
11
7
  } from "lucide-react";
12
- import { useShallow } from "zustand/react/shallow";
13
- import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
14
8
  import type { ActivitySpan } from "@schlessera/brain-ui-sdk/protocol";
15
9
 
16
10
  import {
17
11
  api,
18
12
  type ActivityIntent,
19
13
  type ActivityRollups,
20
- type ActivityRunRollup,
21
14
  type ActivityRunSummary,
22
15
  } from "../../lib/api-client.js";
23
16
  import { sendClientMessage } from "../../hooks/use-websocket.js";
24
- import { useNow } from "../../hooks/use-now.js";
25
- import {
26
- useActivityStore,
27
- narrativeEventsFor,
28
- runEvents,
29
- runSpans,
30
- } from "../../stores/activity-store.js";
17
+ import { useActivityStore } from "../../stores/activity-store.js";
31
18
  import { useChatStore } from "../../stores/chat-store.js";
32
19
  import { useUIStore } from "../../stores/ui-store.js";
33
20
  import { cn } from "../../lib/utils.js";
34
- import {
35
- formatDuration,
36
- formatRelativeTime,
37
- formatTokenCount,
38
- } from "../chat/tool-views.js";
39
- import {
40
- SpanEventBlock,
41
- SpanPayload,
42
- SpanStatusDot,
43
- formatAggregateCost,
44
- formatEffectiveCost,
45
- formatSpanUsage,
46
- runCostText,
47
- spanToolLabel,
48
- } from "./span-bits.js";
21
+ import { formatRelativeTime } from "../chat/tool-views.js";
22
+ import { RollupCards } from "./activity-rollups.js";
23
+ import { LiveRow, RunRow } from "./activity-run-list.js";
24
+ import { RunDetail } from "./activity-run-detail.js";
49
25
  import { PushToggle } from "./push-toggle.js";
50
26
  import { SettingsPanel } from "../settings/settings-panel.js";
51
27
 
@@ -321,450 +297,3 @@ export function ActivityPage() {
321
297
  </div>
322
298
  );
323
299
  }
324
-
325
- /** Today's key in the server's configured zone (mirrors its day formatter). */
326
- function todayKey(timeZone: string): string {
327
- let fmt: Intl.DateTimeFormat;
328
- try {
329
- fmt = new Intl.DateTimeFormat("en-CA", { timeZone, dateStyle: "short" });
330
- } catch {
331
- fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", dateStyle: "short" });
332
- }
333
- return fmt.format(new Date());
334
- }
335
-
336
- function RollupCards({ rollups }: { rollups: ActivityRollups }) {
337
- // days[0] is merely the newest day WITH runs — on a quiet day that is
338
- // yesterday, so look today up by its actual key (0 when absent).
339
- const today = rollups.days.find((d) => d.day === todayKey(rollups.timeZone));
340
- const week = rollups.days.reduce(
341
- (acc, d) => ({
342
- runs: acc.runs + d.runs,
343
- failures: acc.failures + d.failures,
344
- costUsd: acc.costUsd + d.costUsd,
345
- effectiveCostUsd: acc.effectiveCostUsd + (d.effectiveCostUsd ?? 0),
346
- unpricedRuns: acc.unpricedRuns + (d.unpricedRuns ?? 0),
347
- tokens: acc.tokens + d.inputTokens + d.outputTokens,
348
- }),
349
- { runs: 0, failures: 0, costUsd: 0, effectiveCostUsd: 0, unpricedRuns: 0, tokens: 0 }
350
- );
351
- // A server that predates pricing omits the effective fields entirely; the
352
- // card then keeps its single list-price number instead of claiming an
353
- // effective $0.00 it never computed.
354
- const hasEffective = rollups.days.some((d) => d.effectiveCostUsd !== undefined);
355
- return (
356
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
357
- <StatCard label="Runs today" value={String(today?.runs ?? 0)} />
358
- <StatCard
359
- label="Failures (7d)"
360
- value={String(week.failures)}
361
- alert={week.failures > 0}
362
- />
363
- <StatCard
364
- label="Spend (7d)"
365
- value={
366
- hasEffective
367
- ? formatAggregateCost(week.effectiveCostUsd, week.unpricedRuns)
368
- : `$${week.costUsd.toFixed(2)}`
369
- }
370
- secondary={
371
- hasEffective && (
372
- <>
373
- list ${week.costUsd.toFixed(2)}
374
- {week.unpricedRuns > 0 && (
375
- <>
376
- {/* Two cards per row below `sm`: the qualifier compacts. */}
377
- <span className="hidden sm:inline"> · {week.unpricedRuns} unpriced</span>
378
- <span className="sm:hidden"> · {week.unpricedRuns}?</span>
379
- </>
380
- )}
381
- </>
382
- )
383
- }
384
- />
385
- <StatCard label="Tokens (7d)" value={formatTokenCount(week.tokens)} />
386
- </div>
387
- );
388
- }
389
-
390
- function StatCard({
391
- label,
392
- value,
393
- secondary,
394
- alert,
395
- }: {
396
- label: string;
397
- value: string;
398
- /** Muted small line between the value and the label (e.g. the list-cost qualifier). */
399
- secondary?: ReactNode;
400
- alert?: boolean;
401
- }) {
402
- return (
403
- <div className="rounded-lg border border-border-subtle bg-surface p-3">
404
- <div className={cn("text-lg font-semibold", alert && "text-destructive")}>{value}</div>
405
- {secondary && (
406
- <div className="truncate text-[10px] text-muted-foreground/70">{secondary}</div>
407
- )}
408
- <div className="text-[11px] text-muted-foreground">{label}</div>
409
- </div>
410
- );
411
- }
412
-
413
- function LiveRow({
414
- span,
415
- onOpen,
416
- }: {
417
- span: ActivitySpan;
418
- onOpen: (row: { runId: string; origin: string; sessionId?: string | null }) => void;
419
- }) {
420
- const children = useActivityStore(
421
- useShallow((s) => Object.values(s.spans[span.runId] ?? {}).filter((x) => x.parentSpanId))
422
- );
423
- const now = useNow();
424
- const current = children.filter((c) => c.outcome === undefined).at(-1);
425
- const currentLabel = current ? spanToolLabel(current) : null;
426
- return (
427
- <button
428
- type="button"
429
- onClick={() => onOpen({ runId: span.runId, origin: span.origin, sessionId: span.sessionId })}
430
- className="flex w-full items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised"
431
- >
432
- <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-primary" />
433
- {span.origin === "cron" ? (
434
- <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
435
- ) : (
436
- <Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
437
- )}
438
- <span className="truncate font-medium">
439
- {span.jobName ?? span.name}
440
- </span>
441
- {/* A span-only run (plain cron job) has no current step — name+elapsed
442
- is its whole live story. */}
443
- {currentLabel && (
444
- <span className="truncate text-muted-foreground">· {currentLabel}</span>
445
- )}
446
- <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
447
- {formatDuration(now - span.startedAt)}
448
- </span>
449
- </button>
450
- );
451
- }
452
-
453
- function RunRow({
454
- run,
455
- onOpen,
456
- }: {
457
- run: ActivityRunSummary;
458
- onOpen: (row: ActivityRunSummary) => void;
459
- }) {
460
- const failed = isFailureOutcome(run.outcome);
461
- // Effective cost only — list price lives on the Spend card and the detail
462
- // view. "—" is unknown, never $0.00 (AE3); a pre-pricing server that never
463
- // sent the field keeps the original list-cost span instead (see runCostText).
464
- const cost = runCostText(run);
465
- return (
466
- <button
467
- type="button"
468
- onClick={() => onOpen(run)}
469
- className={cn(
470
- "flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised",
471
- failed && "border border-destructive/30 bg-destructive/5"
472
- )}
473
- >
474
- {run.origin === "cron" ? (
475
- <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
476
- ) : (
477
- <Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
478
- )}
479
- <span className="truncate">{run.jobName ?? run.name}</span>
480
- {failed && <AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />}
481
- {run.outcome && run.outcome !== "success" && (
482
- <span className={cn("text-[10px] uppercase", failed ? "text-destructive" : "text-muted-foreground")}>
483
- {run.outcome}
484
- </span>
485
- )}
486
- <span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
487
- {cost !== null && <span>{cost}</span>}
488
- {run.durationMs !== null && (
489
- <span className="flex items-center gap-0.5">
490
- <Timer className="h-3 w-3" />
491
- {formatDuration(run.durationMs)}
492
- </span>
493
- )}
494
- <span>{formatRelativeTime(run.startedAt)}</span>
495
- </span>
496
- </button>
497
- );
498
- }
499
-
500
- /**
501
- * Chat-less run detail (cron runs; pruned runs show their rollup) — the full
502
- * view of one run: its rollup header, the span tree with per-span usage,
503
- * attributes and recorded events, and a raw-trace escape hatch for anything
504
- * the rendered view does not have an opinion about.
505
- */
506
- function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
507
- const streamed = useActivityStore(useShallow((s) => runSpans(s, runId)));
508
- const events = useActivityStore(useShallow((s) => runEvents(s, runId)));
509
- const applySnapshot = useActivityStore((s) => s.applySnapshot);
510
- const [pruned, setPruned] = useState<object | null>(null);
511
- const [rollup, setRollup] = useState<ActivityRunRollup | null>(null);
512
- const [missing, setMissing] = useState(false);
513
- const [rawOpen, setRawOpen] = useState(false);
514
-
515
- useEffect(() => {
516
- api
517
- // Payload bodies are excluded from run detail by default; this view's
518
- // rows expand to them, so opt in.
519
- .activityRun(runId, { includePayloads: true })
520
- .then((detail) => {
521
- if (detail.rollup) setRollup(detail.rollup);
522
- if (detail.detailPruned) {
523
- setPruned(detail.rollup ?? {});
524
- } else if (detail.spans) {
525
- applySnapshot({
526
- type: "activity_snapshot",
527
- view: "run",
528
- runId,
529
- spans: detail.spans,
530
- events: detail.events ?? [],
531
- highWaterSeq: { [runId]: detail.highWaterSeq ?? 0 },
532
- });
533
- }
534
- })
535
- .catch(() => setMissing(true));
536
- }, [runId, applySnapshot]);
537
-
538
- const root = streamed.find((s) => !s.parentSpanId);
539
- const title = rollup?.jobName ?? rollup?.name ?? (root ? spanToolLabel(root) : runId);
540
- // The root carries the run's usage for backend-recorded turns; a run whose
541
- // usage only exists on children (the span-sink shape) sums them instead.
542
- // Never both, so nothing is double-counted.
543
- const totalUsage = useMemo(() => {
544
- if (root?.usage) return formatSpanUsage(root);
545
- const children = streamed.filter((s) => s.parentSpanId && s.usage);
546
- if (children.length === 0) return null;
547
- const sum = children.reduce(
548
- (acc, s) => ({
549
- inputTokens: acc.inputTokens + (s.usage?.inputTokens ?? 0),
550
- outputTokens: acc.outputTokens + (s.usage?.outputTokens ?? 0),
551
- cacheReadTokens: acc.cacheReadTokens + (s.usage?.cacheReadTokens ?? 0),
552
- cacheCreationTokens: acc.cacheCreationTokens + (s.usage?.cacheCreationTokens ?? 0),
553
- }),
554
- { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }
555
- );
556
- return formatSpanUsage({ ...children[0]!, usage: { ...sum, model: undefined } });
557
- }, [root, streamed]);
558
-
559
- return (
560
- <div className="flex h-full flex-col overflow-y-auto">
561
- <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
562
- <button
563
- type="button"
564
- onClick={onBack}
565
- className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
566
- aria-label="Back"
567
- >
568
- <ArrowLeft className="h-4 w-4" />
569
- </button>
570
- <div className="min-w-0 flex-1">
571
- <h1 className="truncate text-sm font-medium">{title}</h1>
572
- <p className="truncate font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
573
- {runId}
574
- </p>
575
- </div>
576
- </div>
577
- <div className="mx-auto w-full max-w-3xl space-y-1 p-4">
578
- {missing && <p className="text-xs text-muted-foreground">Unknown run.</p>}
579
- {rollup && (
580
- <div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-[11px] text-muted-foreground">
581
- <span className="text-foreground/80">{rollup.origin}</span>
582
- {rollup.outcome && (
583
- <span className={cn(isFailureOutcome(rollup.outcome) && "text-destructive")}>
584
- {rollup.outcome}
585
- </span>
586
- )}
587
- <span>{formatRelativeTime(rollup.startedAt)}</span>
588
- {rollup.durationMs !== null && <span>{formatDuration(rollup.durationMs)}</span>}
589
- <span>
590
- list{" "}
591
- <span className="font-[family-name:var(--font-mono)] text-foreground/80">
592
- {formatEffectiveCost(rollup.costUsd)}
593
- </span>
594
- </span>
595
- <span>
596
- effective{" "}
597
- <span className="font-[family-name:var(--font-mono)] text-foreground/80">
598
- {formatEffectiveCost(
599
- rollup.effectiveCostUsd,
600
- rollup.pricingEstimate,
601
- rollup.billingMode
602
- )}
603
- </span>
604
- </span>
605
- {rollup.billingMode && (
606
- <span>{rollup.billingMode === "api" ? "API billed" : "subscription billed"}</span>
607
- )}
608
- {rollup.pricingEstimate && <span>~ estimated rates</span>}
609
- {totalUsage && (
610
- <span className="font-[family-name:var(--font-mono)] text-foreground/80">
611
- {totalUsage}
612
- </span>
613
- )}
614
- {rollup.failureReason && (
615
- <span className="w-full whitespace-pre-wrap break-words pt-1 text-destructive/80">
616
- {rollup.failureReason}
617
- </span>
618
- )}
619
- </div>
620
- )}
621
- {pruned && (
622
- <div className="rounded-lg border border-border-subtle bg-surface p-3 text-xs text-muted-foreground">
623
- Detail pruned — only the rollup remains.
624
- <pre className="mt-2 overflow-x-auto text-[11px]">{JSON.stringify(pruned, null, 2)}</pre>
625
- </div>
626
- )}
627
- {streamed.map((span) => (
628
- <DetailSpanRow key={span.spanId} span={span} depth={depthOf(span, streamed)} />
629
- ))}
630
- {!pruned && !missing && streamed.length === 0 && (
631
- <p className="text-xs text-muted-foreground">No spans recorded for this run.</p>
632
- )}
633
- {/* The escape hatch: whatever the rendered tree has no opinion about
634
- is still readable here, so the view is never LESS than the trace. */}
635
- {streamed.length > 0 && (
636
- <div className="pt-3">
637
- <button
638
- type="button"
639
- onClick={() => setRawOpen((v) => !v)}
640
- className="flex items-center gap-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
641
- >
642
- <ChevronRight
643
- className={cn("h-3 w-3 transition-transform", rawOpen && "rotate-90")}
644
- />
645
- Raw trace
646
- </button>
647
- {rawOpen && (
648
- <pre className="mt-1 max-h-[60vh] overflow-auto rounded-lg border border-border-subtle bg-surface p-2 font-[family-name:var(--font-mono)] text-[10px] leading-relaxed text-muted-foreground">
649
- {JSON.stringify({ runId, rollup, spans: streamed, events }, null, 2)}
650
- </pre>
651
- )}
652
- </div>
653
- )}
654
- </div>
655
- </div>
656
- );
657
- }
658
-
659
- /**
660
- * One span tree row. EVERY span expands — a tool span to its recorded payload
661
- * (AE7), any span to its usage, attributes and recorded events. The uniform
662
- * affordance is the point: a cron root or a turn root used to be a dead dot
663
- * with a duration, which is strictly less than the trace it was summarizing.
664
- */
665
- function DetailSpanRow({ span, depth }: { span: ActivitySpan; depth: number }) {
666
- const [expanded, setExpanded] = useState(false);
667
- const usage = formatSpanUsage(span);
668
- // Approval wait is not execution — same boundary the subagent drill-in uses.
669
- const duration =
670
- span.endedAt !== undefined
671
- ? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
672
- : "…";
673
- return (
674
- <div style={{ paddingLeft: `${depth * 16}px` }}>
675
- <div
676
- className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
677
- onClick={() => setExpanded((v) => !v)}
678
- >
679
- <SpanStatusDot span={span} />
680
- <span className="truncate font-[family-name:var(--font-mono)]">
681
- {spanToolLabel(span)}
682
- </span>
683
- {span.kind !== "tool" && (
684
- <span className="shrink-0 rounded bg-surface-raised px-1 text-[9px] uppercase text-muted-foreground/70">
685
- {span.kind}
686
- </span>
687
- )}
688
- {span.outcome && span.outcome !== "success" && (
689
- <span className="text-[10px] uppercase">{span.outcome}</span>
690
- )}
691
- {span.outcomeReason && (
692
- <span className="truncate text-[10px] text-muted-foreground/60">
693
- {span.outcomeReason}
694
- </span>
695
- )}
696
- <span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
697
- {usage && <span className="hidden sm:inline">{usage}</span>}
698
- <span>{duration}</span>
699
- </span>
700
- <ChevronRight
701
- className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
702
- />
703
- </div>
704
- {expanded && <DetailSpanBody span={span} usage={usage} />}
705
- </div>
706
- );
707
- }
708
-
709
- /** Everything recorded against one span, under its row. */
710
- function DetailSpanBody({ span, usage }: { span: ActivitySpan; usage: string | null }) {
711
- const narrative = useActivityStore(useShallow((s) => narrativeEventsFor(s, span.spanId)));
712
- const attrs = Object.entries(span.attrs ?? {});
713
- return (
714
- <div className="mb-1 ml-1 space-y-1.5 border-l border-border-subtle pl-2 pt-1">
715
- <div className="flex flex-wrap gap-x-3 gap-y-0.5 px-2 text-[10px] text-muted-foreground/70">
716
- <span>started {new Date(span.startedAt).toLocaleString()}</span>
717
- {span.waitUntil !== undefined && (
718
- <span>approval wait {formatDuration(span.waitUntil - span.startedAt)}</span>
719
- )}
720
- {usage && <span className="font-[family-name:var(--font-mono)]">{usage}</span>}
721
- {span.usage?.costUsd !== undefined && (
722
- <span className="font-[family-name:var(--font-mono)]">
723
- {formatEffectiveCost(span.usage.costUsd)} list
724
- </span>
725
- )}
726
- </div>
727
- {span.subagent?.summary && (
728
- <p className="px-2 text-[11px] text-muted-foreground">{span.subagent.summary}</p>
729
- )}
730
- {attrs.length > 0 && (
731
- <dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-2 gap-y-0.5 px-2 text-[10px]">
732
- {attrs.map(([key, value]) => (
733
- <Fragment key={key}>
734
- <dt className="truncate font-[family-name:var(--font-mono)] text-muted-foreground/60">
735
- {key}
736
- </dt>
737
- <dd className="min-w-0 break-words font-[family-name:var(--font-mono)] text-muted-foreground">
738
- {typeof value === "string" ? value : JSON.stringify(value)}
739
- </dd>
740
- </Fragment>
741
- ))}
742
- </dl>
743
- )}
744
- {narrative.length > 0 && (
745
- <div className="space-y-1.5 px-2">
746
- {narrative.map((event) => (
747
- <SpanEventBlock key={`${event.spanId}:${event.eventIndex}`} event={event} />
748
- ))}
749
- </div>
750
- )}
751
- {span.kind === "tool" && <SpanPayload spanId={span.spanId} />}
752
- {span.kind !== "tool" && narrative.length === 0 && attrs.length === 0 && (
753
- <p className="px-2 text-[11px] text-muted-foreground/60">
754
- Nothing else recorded for this step.
755
- </p>
756
- )}
757
- </div>
758
- );
759
- }
760
-
761
- function depthOf(span: ActivitySpan, all: ActivitySpan[]): number {
762
- let depth = 0;
763
- let current: ActivitySpan | undefined = span;
764
- while (current?.parentSpanId) {
765
- depth += 1;
766
- current = all.find((s) => s.spanId === current!.parentSpanId);
767
- if (depth > 6) break;
768
- }
769
- return depth;
770
- }
@@ -0,0 +1,94 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { ActivityRollups } from "../../lib/api-client.js";
4
+ import { cn } from "../../lib/utils.js";
5
+ import { formatTokenCount } from "../chat/tool-views.js";
6
+ import { formatAggregateCost } from "./span-bits.js";
7
+
8
+ /** Today's key in the server's configured zone (mirrors its day formatter). */
9
+ function todayKey(timeZone: string): string {
10
+ let fmt: Intl.DateTimeFormat;
11
+ try {
12
+ fmt = new Intl.DateTimeFormat("en-CA", { timeZone, dateStyle: "short" });
13
+ } catch {
14
+ fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", dateStyle: "short" });
15
+ }
16
+ return fmt.format(new Date());
17
+ }
18
+
19
+ export function RollupCards({ rollups }: { rollups: ActivityRollups }) {
20
+ // days[0] is merely the newest day WITH runs — on a quiet day that is
21
+ // yesterday, so look today up by its actual key (0 when absent).
22
+ const today = rollups.days.find((d) => d.day === todayKey(rollups.timeZone));
23
+ const week = rollups.days.reduce(
24
+ (acc, d) => ({
25
+ runs: acc.runs + d.runs,
26
+ failures: acc.failures + d.failures,
27
+ costUsd: acc.costUsd + d.costUsd,
28
+ effectiveCostUsd: acc.effectiveCostUsd + (d.effectiveCostUsd ?? 0),
29
+ unpricedRuns: acc.unpricedRuns + (d.unpricedRuns ?? 0),
30
+ tokens: acc.tokens + d.inputTokens + d.outputTokens,
31
+ }),
32
+ { runs: 0, failures: 0, costUsd: 0, effectiveCostUsd: 0, unpricedRuns: 0, tokens: 0 }
33
+ );
34
+ // A server that predates pricing omits the effective fields entirely; the
35
+ // card then keeps its single list-price number instead of claiming an
36
+ // effective $0.00 it never computed.
37
+ const hasEffective = rollups.days.some((d) => d.effectiveCostUsd !== undefined);
38
+ return (
39
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
40
+ <StatCard label="Runs today" value={String(today?.runs ?? 0)} />
41
+ <StatCard
42
+ label="Failures (7d)"
43
+ value={String(week.failures)}
44
+ alert={week.failures > 0}
45
+ />
46
+ <StatCard
47
+ label="Spend (7d)"
48
+ value={
49
+ hasEffective
50
+ ? formatAggregateCost(week.effectiveCostUsd, week.unpricedRuns)
51
+ : `$${week.costUsd.toFixed(2)}`
52
+ }
53
+ secondary={
54
+ hasEffective && (
55
+ <>
56
+ list ${week.costUsd.toFixed(2)}
57
+ {week.unpricedRuns > 0 && (
58
+ <>
59
+ {/* Two cards per row below `sm`: the qualifier compacts. */}
60
+ <span className="hidden sm:inline"> · {week.unpricedRuns} unpriced</span>
61
+ <span className="sm:hidden"> · {week.unpricedRuns}?</span>
62
+ </>
63
+ )}
64
+ </>
65
+ )
66
+ }
67
+ />
68
+ <StatCard label="Tokens (7d)" value={formatTokenCount(week.tokens)} />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function StatCard({
74
+ label,
75
+ value,
76
+ secondary,
77
+ alert,
78
+ }: {
79
+ label: string;
80
+ value: string;
81
+ /** Muted small line between the value and the label (e.g. the list-cost qualifier). */
82
+ secondary?: ReactNode;
83
+ alert?: boolean;
84
+ }) {
85
+ return (
86
+ <div className="rounded-lg border border-border-subtle bg-surface p-3">
87
+ <div className={cn("text-lg font-semibold", alert && "text-destructive")}>{value}</div>
88
+ {secondary && (
89
+ <div className="truncate text-[10px] text-muted-foreground/70">{secondary}</div>
90
+ )}
91
+ <div className="text-[11px] text-muted-foreground">{label}</div>
92
+ </div>
93
+ );
94
+ }