@schlessera/brain-ui-react 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/components/activity/activity-page.d.ts.map +1 -1
  2. package/dist/components/activity/activity-page.js +90 -16
  3. package/dist/components/activity/activity-page.js.map +1 -1
  4. package/dist/components/activity/digest-card.d.ts.map +1 -1
  5. package/dist/components/activity/digest-card.js +5 -1
  6. package/dist/components/activity/digest-card.js.map +1 -1
  7. package/dist/components/activity/push-toggle.d.ts.map +1 -1
  8. package/dist/components/activity/push-toggle.js +39 -0
  9. package/dist/components/activity/push-toggle.js.map +1 -1
  10. package/dist/components/activity/span-bits.d.ts +76 -1
  11. package/dist/components/activity/span-bits.d.ts.map +1 -1
  12. package/dist/components/activity/span-bits.js +141 -4
  13. package/dist/components/activity/span-bits.js.map +1 -1
  14. package/dist/components/chat/subagent-view.js +8 -4
  15. package/dist/components/chat/subagent-view.js.map +1 -1
  16. package/dist/components/settings/models-tab.d.ts +15 -0
  17. package/dist/components/settings/models-tab.d.ts.map +1 -1
  18. package/dist/components/settings/models-tab.js +100 -21
  19. package/dist/components/settings/models-tab.js.map +1 -1
  20. package/dist/lib/api-client.d.ts +29 -2
  21. package/dist/lib/api-client.d.ts.map +1 -1
  22. package/dist/lib/api-client.js +14 -1
  23. package/dist/lib/api-client.js.map +1 -1
  24. package/dist/stores/activity-store.d.ts +25 -0
  25. package/dist/stores/activity-store.d.ts.map +1 -1
  26. package/dist/stores/activity-store.js +82 -0
  27. package/dist/stores/activity-store.js.map +1 -1
  28. package/dist/styles.css +1 -1
  29. package/package.json +2 -2
  30. package/src/components/activity/activity-page.tsx +307 -32
  31. package/src/components/activity/digest-card.tsx +5 -1
  32. package/src/components/activity/push-toggle.tsx +29 -0
  33. package/src/components/activity/span-bits.tsx +184 -4
  34. package/src/components/chat/subagent-view.tsx +33 -23
  35. package/src/components/settings/models-tab.tsx +135 -21
  36. package/src/lib/api-client.ts +38 -2
  37. package/src/stores/activity-store.ts +82 -0
@@ -1,9 +1,10 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
2
2
  import {
3
3
  Activity as ActivityIcon,
4
4
  AlertTriangle,
5
5
  ArrowLeft,
6
6
  Bot,
7
+ ChevronRight,
7
8
  Clock,
8
9
  RefreshCw,
9
10
  Timer,
@@ -16,11 +17,17 @@ import {
16
17
  api,
17
18
  type ActivityIntent,
18
19
  type ActivityRollups,
20
+ type ActivityRunRollup,
19
21
  type ActivityRunSummary,
20
22
  } from "../../lib/api-client.js";
21
23
  import { sendClientMessage } from "../../hooks/use-websocket.js";
22
24
  import { useNow } from "../../hooks/use-now.js";
23
- import { useActivityStore, runSpans } from "../../stores/activity-store.js";
25
+ import {
26
+ useActivityStore,
27
+ narrativeEventsFor,
28
+ runEvents,
29
+ runSpans,
30
+ } from "../../stores/activity-store.js";
24
31
  import { useChatStore } from "../../stores/chat-store.js";
25
32
  import { useUIStore } from "../../stores/ui-store.js";
26
33
  import { cn } from "../../lib/utils.js";
@@ -29,8 +36,18 @@ import {
29
36
  formatRelativeTime,
30
37
  formatTokenCount,
31
38
  } from "../chat/tool-views.js";
32
- import { SpanStatusDot, spanToolLabel } from "./span-bits.js";
39
+ import {
40
+ SpanEventBlock,
41
+ SpanPayload,
42
+ SpanStatusDot,
43
+ formatAggregateCost,
44
+ formatEffectiveCost,
45
+ formatSpanUsage,
46
+ runCostText,
47
+ spanToolLabel,
48
+ } from "./span-bits.js";
33
49
  import { PushToggle } from "./push-toggle.js";
50
+ import { SettingsPanel } from "../settings/settings-panel.js";
34
51
 
35
52
  /**
36
53
  * The Activity surface: an INDEX of all agent activity — live runs first,
@@ -46,11 +63,15 @@ export function ActivityPage() {
46
63
  const connectionEpoch = useActivityStore((s) => s.connectionEpoch);
47
64
  const liveSpans = useActivityStore((s) => s.spans);
48
65
  const setActiveView = useUIStore((s) => s.setActiveView);
66
+ const openSettings = useUIStore((s) => s.openSettings);
67
+ const settingsPanelOpen = useUIStore((s) => s.settingsPanelOpen);
68
+ const setSettingsPanelOpen = useUIStore((s) => s.setSettingsPanelOpen);
49
69
  const setActiveSession = useChatStore((s) => s.setActiveSession);
50
70
  const [runs, setRuns] = useState<{ live: ActivityRunSummary[]; history: ActivityRunSummary[] } | null>(null);
51
71
  const [rollups, setRollups] = useState<ActivityRollups | null>(null);
52
72
  const [error, setError] = useState<string | null>(null);
53
73
  const [detailRunId, setDetailRunId] = useState<string | null>(null);
74
+ const [pricingStale, setPricingStale] = useState(false);
54
75
 
55
76
  const inbox = useActivityStore((s) => s.inbox);
56
77
  const loadInbox = useActivityStore((s) => s.loadInbox);
@@ -63,6 +84,13 @@ export function ActivityPage() {
63
84
  .then(setRuns)
64
85
  .catch((err) => setError(err instanceof Error ? err.message : String(err)));
65
86
  api.activityRollups(7).then(setRollups).catch(() => {});
87
+ // Surface the pricing warning only when the table is stale AND refreshes
88
+ // are failing — a merely-aging table heals itself. A rejection (including
89
+ // 404 from a server that predates the route) means no signal: stay quiet.
90
+ api
91
+ .pricingState()
92
+ .then((s) => setPricingStale(Boolean(s.stale && s.error)))
93
+ .catch(() => setPricingStale(false));
66
94
  void loadInbox();
67
95
  };
68
96
 
@@ -156,10 +184,29 @@ export function ActivityPage() {
156
184
 
157
185
  return (
158
186
  <div className="flex h-full flex-col overflow-y-auto">
187
+ {/* Hosted here like GraphPage does: the chat page (the usual host) is
188
+ hidden while this view is active, so the staleness deep-link below
189
+ needs its own panel mount. */}
190
+ <SettingsPanel
191
+ open={settingsPanelOpen}
192
+ onClose={() => setSettingsPanelOpen(false)}
193
+ />
159
194
  <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
160
195
  <ActivityIcon className="h-4 w-4 text-muted-foreground" />
161
196
  <h1 className="text-sm font-medium">Activity</h1>
162
197
  <div className="ml-auto flex items-center gap-2">
198
+ {pricingStale && (
199
+ <button
200
+ type="button"
201
+ onClick={() => openSettings("models")}
202
+ className="flex items-center gap-1 rounded-md p-1.5 text-amber-500 transition-colors hover:bg-surface-raised hover:text-amber-400"
203
+ aria-label="Pricing refresh is failing — costs may use stale rates. Open Settings"
204
+ title="Pricing refresh is failing — costs may use stale rates. Open Settings"
205
+ >
206
+ <AlertTriangle className="h-3.5 w-3.5" />
207
+ <span className="hidden text-[10px] sm:inline">Pricing stale</span>
208
+ </button>
209
+ )}
163
210
  <PushToggle />
164
211
  </div>
165
212
  <button
@@ -295,10 +342,16 @@ function RollupCards({ rollups }: { rollups: ActivityRollups }) {
295
342
  runs: acc.runs + d.runs,
296
343
  failures: acc.failures + d.failures,
297
344
  costUsd: acc.costUsd + d.costUsd,
345
+ effectiveCostUsd: acc.effectiveCostUsd + (d.effectiveCostUsd ?? 0),
346
+ unpricedRuns: acc.unpricedRuns + (d.unpricedRuns ?? 0),
298
347
  tokens: acc.tokens + d.inputTokens + d.outputTokens,
299
348
  }),
300
- { runs: 0, failures: 0, costUsd: 0, tokens: 0 }
349
+ { runs: 0, failures: 0, costUsd: 0, effectiveCostUsd: 0, unpricedRuns: 0, tokens: 0 }
301
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);
302
355
  return (
303
356
  <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
304
357
  <StatCard label="Runs today" value={String(today?.runs ?? 0)} />
@@ -307,16 +360,51 @@ function RollupCards({ rollups }: { rollups: ActivityRollups }) {
307
360
  value={String(week.failures)}
308
361
  alert={week.failures > 0}
309
362
  />
310
- <StatCard label="Spend (7d)" value={`$${week.costUsd.toFixed(2)}`} />
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
+ />
311
385
  <StatCard label="Tokens (7d)" value={formatTokenCount(week.tokens)} />
312
386
  </div>
313
387
  );
314
388
  }
315
389
 
316
- function StatCard({ label, value, alert }: { label: string; value: string; alert?: boolean }) {
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
+ }) {
317
402
  return (
318
403
  <div className="rounded-lg border border-border-subtle bg-surface p-3">
319
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
+ )}
320
408
  <div className="text-[11px] text-muted-foreground">{label}</div>
321
409
  </div>
322
410
  );
@@ -370,6 +458,10 @@ function RunRow({
370
458
  onOpen: (row: ActivityRunSummary) => void;
371
459
  }) {
372
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);
373
465
  return (
374
466
  <button
375
467
  type="button"
@@ -392,7 +484,7 @@ function RunRow({
392
484
  </span>
393
485
  )}
394
486
  <span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
395
- {run.costUsd !== null && run.costUsd > 0 && <span>${run.costUsd.toFixed(2)}</span>}
487
+ {cost !== null && <span>{cost}</span>}
396
488
  {run.durationMs !== null && (
397
489
  <span className="flex items-center gap-0.5">
398
490
  <Timer className="h-3 w-3" />
@@ -405,17 +497,28 @@ function RunRow({
405
497
  );
406
498
  }
407
499
 
408
- /** Chat-less run detail (cron runs; pruned runs show their rollup). */
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
+ */
409
506
  function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
410
507
  const streamed = useActivityStore(useShallow((s) => runSpans(s, runId)));
508
+ const events = useActivityStore(useShallow((s) => runEvents(s, runId)));
411
509
  const applySnapshot = useActivityStore((s) => s.applySnapshot);
412
510
  const [pruned, setPruned] = useState<object | null>(null);
511
+ const [rollup, setRollup] = useState<ActivityRunRollup | null>(null);
413
512
  const [missing, setMissing] = useState(false);
513
+ const [rawOpen, setRawOpen] = useState(false);
414
514
 
415
515
  useEffect(() => {
416
516
  api
417
- .activityRun(runId)
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 })
418
520
  .then((detail) => {
521
+ if (detail.rollup) setRollup(detail.rollup);
419
522
  if (detail.detailPruned) {
420
523
  setPruned(detail.rollup ?? {});
421
524
  } else if (detail.spans) {
@@ -432,6 +535,27 @@ function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
432
535
  .catch(() => setMissing(true));
433
536
  }, [runId, applySnapshot]);
434
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
+
435
559
  return (
436
560
  <div className="flex h-full flex-col overflow-y-auto">
437
561
  <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
@@ -443,10 +567,57 @@ function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
443
567
  >
444
568
  <ArrowLeft className="h-4 w-4" />
445
569
  </button>
446
- <h1 className="truncate text-sm font-medium">{runId}</h1>
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>
447
576
  </div>
448
577
  <div className="mx-auto w-full max-w-3xl space-y-1 p-4">
449
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
+ )}
450
621
  {pruned && (
451
622
  <div className="rounded-lg border border-border-subtle bg-surface p-3 text-xs text-muted-foreground">
452
623
  Detail pruned — only the rollup remains.
@@ -454,35 +625,139 @@ function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
454
625
  </div>
455
626
  )}
456
627
  {streamed.map((span) => (
457
- <div
458
- key={span.spanId}
459
- className="flex items-center gap-2 text-xs text-muted-foreground"
460
- style={{ paddingLeft: `${depthOf(span, streamed) * 16}px` }}
461
- >
462
- <SpanStatusDot span={span} />
463
- <span className="truncate font-[family-name:var(--font-mono)]">
464
- {spanToolLabel(span)}
465
- </span>
466
- {span.outcome && span.outcome !== "success" && (
467
- <span className="text-[10px] uppercase">{span.outcome}</span>
468
- )}
469
- {span.outcomeReason && (
470
- <span className="truncate text-[10px] text-muted-foreground/60">
471
- {span.outcomeReason}
472
- </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>
473
651
  )}
474
- <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
475
- {span.endedAt !== undefined
476
- ? formatDuration(span.endedAt - span.startedAt)
477
- : "…"}
478
- </span>
479
652
  </div>
480
- ))}
653
+ )}
481
654
  </div>
482
655
  </div>
483
656
  );
484
657
  }
485
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
+
486
761
  function depthOf(span: ActivitySpan, all: ActivitySpan[]): number {
487
762
  let depth = 0;
488
763
  let current: ActivitySpan | undefined = span;
@@ -4,6 +4,7 @@ import { AlertTriangle, Newspaper, X } from "lucide-react";
4
4
  import { api, type ActivityDigest } from "../../lib/api-client.js";
5
5
  import { useUIStore } from "../../stores/ui-store.js";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { digestCostClause } from "./span-bits.js";
7
8
 
8
9
  /**
9
10
  * The while-you-were-away card: presented PROACTIVELY on app open when a
@@ -40,6 +41,9 @@ export function DigestCard() {
40
41
  }
41
42
 
42
43
  const windowLabel = `${formatDay(digest.windowStart)} – ${formatDay(digest.windowEnd)}`;
44
+ // Effective-only spend clause; a digest persisted before pricing shipped
45
+ // falls back to its old list-cost clause inside the helper.
46
+ const costClause = digestCostClause(digest);
43
47
 
44
48
  return (
45
49
  <div
@@ -68,7 +72,7 @@ export function DigestCard() {
68
72
  {digest.failures > 0 && (
69
73
  <span className="text-destructive"> · {digest.failures} failed</span>
70
74
  )}
71
- {digest.costUsd > 0 && ` · $${digest.costUsd.toFixed(2)} spent`}
75
+ {costClause && ` · ${costClause}`}
72
76
  </div>
73
77
  {digest.notable.length > 0 && (
74
78
  <div className="mt-1.5 space-y-0.5">
@@ -13,6 +13,14 @@ import { cn } from "../../lib/utils.js";
13
13
  * nothing. Where the platform has no push at all (iOS Safari in-browser,
14
14
  * pre-16.4), the control renders disabled-with-explanation, not hidden.
15
15
  */
16
+ /** The subscription's bound applicationServerKey, in the base64url form the
17
+ * server hands out — comparable against `pushPublicKey()` directly. */
18
+ function keyToBase64Url(key: ArrayBuffer): string {
19
+ let bin = "";
20
+ for (const b of new Uint8Array(key)) bin += String.fromCharCode(b);
21
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
22
+ }
23
+
16
24
  type PushState =
17
25
  | "unsupported"
18
26
  | "not-asked"
@@ -40,6 +48,27 @@ export function PushToggle() {
40
48
  }
41
49
  const registration = await navigator.serviceWorker.ready;
42
50
  const subscription = await registration.pushManager.getSubscription();
51
+ if (subscription) {
52
+ // The browser having a subscription doesn't mean the SERVER can
53
+ // still use it. Two disagreement cases: the server pruned/lost the
54
+ // row (a dead-endpoint send, a DB restore) — healed by re-asserting,
55
+ // an idempotent upsert; or the server's VAPID keypair changed, which
56
+ // makes this subscription permanently unsendable — its
57
+ // applicationServerKey no longer matches, so drop it and surface the
58
+ // re-enable button. Offline, trust the browser's own state.
59
+ try {
60
+ const { publicKey } = await api.pushPublicKey();
61
+ const boundKey = subscription.options.applicationServerKey;
62
+ if (boundKey && keyToBase64Url(boundKey) !== publicKey) {
63
+ await subscription.unsubscribe().catch(() => {});
64
+ setState("unsubscribed");
65
+ return;
66
+ }
67
+ await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
68
+ } catch {
69
+ // Server unreachable — keep the browser's answer.
70
+ }
71
+ }
43
72
  setState(subscription ? "subscribed" : "unsubscribed");
44
73
  })();
45
74
  }, []);