@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.
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +90 -16
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/digest-card.d.ts.map +1 -1
- package/dist/components/activity/digest-card.js +5 -1
- package/dist/components/activity/digest-card.js.map +1 -1
- package/dist/components/activity/push-toggle.d.ts.map +1 -1
- package/dist/components/activity/push-toggle.js +39 -0
- package/dist/components/activity/push-toggle.js.map +1 -1
- package/dist/components/activity/span-bits.d.ts +76 -1
- package/dist/components/activity/span-bits.d.ts.map +1 -1
- package/dist/components/activity/span-bits.js +141 -4
- package/dist/components/activity/span-bits.js.map +1 -1
- package/dist/components/chat/subagent-view.js +8 -4
- package/dist/components/chat/subagent-view.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts +15 -0
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +100 -21
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/lib/api-client.d.ts +29 -2
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +14 -1
- package/dist/lib/api-client.js.map +1 -1
- package/dist/stores/activity-store.d.ts +25 -0
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +82 -0
- package/dist/stores/activity-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +307 -32
- package/src/components/activity/digest-card.tsx +5 -1
- package/src/components/activity/push-toggle.tsx +29 -0
- package/src/components/activity/span-bits.tsx +184 -4
- package/src/components/chat/subagent-view.tsx +33 -23
- package/src/components/settings/models-tab.tsx +135 -21
- package/src/lib/api-client.ts +38 -2
- 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 {
|
|
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 {
|
|
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
|
|
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({
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
{
|
|
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
|
}, []);
|