@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,8 +1,20 @@
1
- import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
2
- import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
1
+ import { useEffect } from "react";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { isFailureOutcome, SPAN_TOOL_NAME_PREFIX } from "@schlessera/brain-ui-sdk/protocol";
4
+ import type {
5
+ ActivitySpan,
6
+ ActivitySpanEvent,
7
+ ActivitySpanOutcome,
8
+ BillingMode,
9
+ } from "@schlessera/brain-ui-sdk/protocol";
3
10
 
11
+ import {
12
+ useActivityStore,
13
+ payloadEventsFor,
14
+ loadSpanPayloads,
15
+ } from "../../stores/activity-store.js";
4
16
  import { cn } from "../../lib/utils.js";
5
- import { getToolLabel } from "../chat/tool-views.js";
17
+ import { getToolLabel, formatTokenCount } from "../chat/tool-views.js";
6
18
 
7
19
  /**
8
20
  * THE status dot for activity spans — one outcome→color mapping so every
@@ -52,7 +64,175 @@ export function spanToolLabel(span: ActivitySpan): string {
52
64
  if (span.toolName) return getToolLabel(span.toolName);
53
65
  if (span.kind === "turn") return "Turn";
54
66
  if (span.kind === "cron") return span.jobName ?? span.name;
55
- return getToolLabel(span.name.replace(/^execute_tool /, ""));
67
+ return getToolLabel(
68
+ span.name.startsWith(SPAN_TOOL_NAME_PREFIX)
69
+ ? span.name.slice(SPAN_TOOL_NAME_PREFIX.length)
70
+ : span.name
71
+ );
72
+ }
73
+
74
+ /**
75
+ * The recorded input/output of one tool span, expanded under its row — the
76
+ * ONE payload renderer shared by the subagent drill-in and the run detail.
77
+ * Mounted only while expanded (collapsed rows never subscribe). A span with
78
+ * no payload events (recorded before capture shipped) states so instead of
79
+ * offering an empty block (AE7); a payload clipped at persist time carries
80
+ * the wire `truncated` flag, rendered as a hint line under the block.
81
+ */
82
+ export function SpanPayload({ spanId }: { spanId: string }) {
83
+ const events = useActivityStore(useShallow((s) => payloadEventsFor(s, spanId)));
84
+ // History fetches skip payload bodies (they exist for duration badges);
85
+ // the first expand of a finished run backfills them over REST. A no-op for
86
+ // live runs (payloads ride the WS deltas) and once per run thereafter.
87
+ useEffect(() => {
88
+ if (events.length === 0) void loadSpanPayloads(spanId);
89
+ }, [spanId, events.length]);
90
+ if (events.length === 0) {
91
+ return (
92
+ <p className="px-2 py-1 text-[11px] text-muted-foreground/60">
93
+ No payload recorded for this run.
94
+ </p>
95
+ );
96
+ }
97
+ return (
98
+ <div className="space-y-1.5 px-2 py-1">
99
+ {events.map((event) => (
100
+ <SpanEventBlock key={`${event.spanId}:${event.eventIndex}`} event={event} />
101
+ ))}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Display label for a span event type. Known types get prose; an unknown one
108
+ * (a span-sink producer is free to invent them) prints its own type rather
109
+ * than being mislabelled as output.
110
+ */
111
+ export function eventTypeLabel(eventType: string): string {
112
+ if (eventType === "tool_input") return "Input";
113
+ if (eventType === "tool_output") return "Output";
114
+ if (eventType.startsWith("transcript_")) return `Transcript · ${eventType.slice("transcript_".length)}`;
115
+ if (eventType === "job_output") return "Job output";
116
+ return eventType.replace(/_/g, " ");
117
+ }
118
+
119
+ /**
120
+ * ONE labelled block per recorded event — the shared renderer behind the tool
121
+ * payload expander and the run detail's narrative stream, so every event type
122
+ * lands somewhere visible instead of only the two the expander knows.
123
+ */
124
+ export function SpanEventBlock({ event }: { event: ActivitySpanEvent }) {
125
+ const text =
126
+ typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload, null, 2);
127
+ return (
128
+ <div>
129
+ <div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">
130
+ {eventTypeLabel(event.eventType)}
131
+ </div>
132
+ <pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed text-muted-foreground">
133
+ {text}
134
+ </pre>
135
+ {event.truncated && (
136
+ <p className="mt-0.5 text-[10px] italic text-muted-foreground/60">… truncated</p>
137
+ )}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ /**
143
+ * A span's own token/model line, or null when the backend recorded no usage
144
+ * for it (every tool span, and any cron root without a span-sink producer).
145
+ * Cache reads are named separately: they are the cheap half of the bill and
146
+ * folding them into "in" would misstate what the run actually consumed.
147
+ */
148
+ export function formatSpanUsage(span: ActivitySpan): string | null {
149
+ const usage = span.usage;
150
+ if (!usage) return null;
151
+ const parts: string[] = [];
152
+ if (usage.model) parts.push(usage.model);
153
+ if (usage.inputTokens) parts.push(`${formatTokenCount(usage.inputTokens)} in`);
154
+ if (usage.outputTokens) parts.push(`${formatTokenCount(usage.outputTokens)} out`);
155
+ if (usage.cacheReadTokens) parts.push(`${formatTokenCount(usage.cacheReadTokens)} cached`);
156
+ if (usage.cacheCreationTokens)
157
+ parts.push(`${formatTokenCount(usage.cacheCreationTokens)} cache write`);
158
+ return parts.length > 0 ? parts.join(" · ") : null;
159
+ }
160
+
161
+ /**
162
+ * THE effective-cost glyph — one three-state rule so no surface ever renders
163
+ * an unknown cost as $0.00 (AE3): absent/NULL is "—" (we don't know), 0 is
164
+ * "subbed" when the run was subscription-billed and "free" otherwise (a
165
+ * genuinely zero-rate model, e.g. an OpenRouter free tier), positive is
166
+ * dollars, "~"-prefixed when computed from estimated rates. Sub-cent costs
167
+ * floor at "<$0.01" rather than rounding down to a zero look-alike.
168
+ *
169
+ * The zero split matters: a seat plan absorbing the run and a model that
170
+ * costs nothing are both $0 additive, but only one of them stays $0 once the
171
+ * subscription is cancelled.
172
+ */
173
+ export function formatEffectiveCost(
174
+ costUsd: number | null | undefined,
175
+ estimate?: boolean,
176
+ billingMode?: BillingMode | null
177
+ ): string {
178
+ if (costUsd === null || costUsd === undefined) return "—";
179
+ if (costUsd === 0) return billingMode === "subscription" ? "subbed" : "free";
180
+ const amount = costUsd < 0.005 ? "<$0.01" : `$${costUsd.toFixed(2)}`;
181
+ return estimate ? `~${amount}` : amount;
182
+ }
183
+
184
+ /**
185
+ * An aggregate's effective-cost sum. Sums exclude unknown-cost runs, so a
186
+ * nonzero `unpricedRuns` makes the number a floor ("≥ $X"), never a total.
187
+ * A known sub-cent sum floors at "<$0.01" like the per-run glyph — only an
188
+ * exact 0 (genuinely nothing to add up) renders "$0.00" (AE3).
189
+ */
190
+ export function formatAggregateCost(effectiveUsd: number, unpricedRuns: number): string {
191
+ const amount =
192
+ effectiveUsd > 0 && effectiveUsd < 0.005 ? "<$0.01" : `$${effectiveUsd.toFixed(2)}`;
193
+ return unpricedRuns > 0 ? `≥ ${amount}` : amount;
194
+ }
195
+
196
+ /**
197
+ * A run row's cost text, three-way on `effectiveCostUsd`: ABSENT means a
198
+ * pre-pricing server never sent the field — fall back to the original
199
+ * list-cost rendering (a positive `costUsd`, else nothing) instead of
200
+ * claiming unknown; explicit null means THIS server computed "unknown" and
201
+ * renders the em dash; a number goes through `formatEffectiveCost` (AE3).
202
+ */
203
+ export function runCostText(run: {
204
+ costUsd: number | null;
205
+ effectiveCostUsd?: number | null;
206
+ pricingEstimate?: boolean;
207
+ billingMode?: BillingMode | null;
208
+ }): string | null {
209
+ if (run.effectiveCostUsd === undefined) {
210
+ return run.costUsd !== null && run.costUsd > 0 ? `$${run.costUsd.toFixed(2)}` : null;
211
+ }
212
+ return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate, run.billingMode);
213
+ }
214
+
215
+ /**
216
+ * The digest card's cost clause (no leading separator), effective-only, or
217
+ * null for "say nothing". A digest persisted before pricing shipped carries
218
+ * neither new field and keeps its old list-cost clause.
219
+ */
220
+ export function digestCostClause(digest: {
221
+ costUsd: number;
222
+ effectiveCostUsd?: number;
223
+ unpricedRuns?: number;
224
+ }): string | null {
225
+ if (digest.effectiveCostUsd === undefined) {
226
+ return digest.costUsd > 0 ? `$${digest.costUsd.toFixed(2)} spent` : null;
227
+ }
228
+ const unpriced = digest.unpricedRuns ?? 0;
229
+ const qualifier = unpriced > 0 ? ` (${unpriced} unpriced)` : "";
230
+ if (digest.effectiveCostUsd > 0) {
231
+ return `${formatAggregateCost(digest.effectiveCostUsd, unpriced)} spent${qualifier}`;
232
+ }
233
+ // Known-zero spend: nothing to add up, but the unknowns still get named.
234
+ if (unpriced > 0) return `${unpriced} unpriced`;
235
+ return "free";
56
236
  }
57
237
 
58
238
  /** The "9+" unread-count bubble shared by the rail and the tab bar. Hidden at 0. */
@@ -1,5 +1,5 @@
1
- import { useMemo } from "react";
2
- import { ArrowLeft, Bot, Check, X } from "lucide-react";
1
+ import { useMemo, useState } from "react";
2
+ import { ArrowLeft, Bot, Check, ChevronRight, X } from "lucide-react";
3
3
  import { motion } from "framer-motion";
4
4
  import { useShallow } from "zustand/react/shallow";
5
5
  import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
@@ -10,7 +10,7 @@ import { useUIStore } from "../../stores/ui-store.js";
10
10
  import { useChatStore, activeChat } from "../../stores/chat-store.js";
11
11
  import { cn } from "../../lib/utils.js";
12
12
  import { getToolLabel, formatDuration } from "./tool-views.js";
13
- import { SpanStatusDot, spanToolLabel } from "../activity/span-bits.js";
13
+ import { SpanPayload, SpanStatusDot, spanToolLabel } from "../activity/span-bits.js";
14
14
 
15
15
  /**
16
16
  * Drill-in view of one subagent: the span tree under its Agent tool call,
@@ -171,33 +171,43 @@ function BackButton({ onClick }: { onClick: () => void }) {
171
171
  }
172
172
 
173
173
  function SpanRow({ span, onOpen }: { span: ActivitySpan; onOpen?: () => void }) {
174
+ // Tool rows expand to their recorded payload (AE7); a pre-feature span
175
+ // expands to the no-payload notice, so the affordance stays uniform.
176
+ const [expanded, setExpanded] = useState(false);
174
177
  const running = span.outcome === undefined;
175
178
  const failed = isFailureOutcome(span.outcome);
179
+ const expandable = !onOpen && span.kind === "tool";
176
180
  const duration =
177
181
  span.endedAt !== undefined
178
182
  ? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
179
183
  : null;
180
184
  return (
181
- <motion.div
182
- initial={{ opacity: 0, y: 2 }}
183
- animate={{ opacity: 1, y: 0 }}
184
- className={cn(
185
- "flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
186
- failed ? "text-destructive/80" : "text-muted-foreground",
187
- onOpen && "cursor-pointer hover:text-foreground"
188
- )}
189
- onClick={onOpen}
190
- >
191
- <SpanStatusDot span={span} className="h-2 w-2" />
192
- <span className="font-[family-name:var(--font-mono)] font-medium">
193
- {spanToolLabel(span)}
194
- </span>
195
- {span.outcome && span.outcome !== "success" && (
196
- <span className="text-[10px] uppercase">{span.outcome}</span>
197
- )}
198
- <span className="ml-auto font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
199
- {duration ?? (running ? "…" : "")}
200
- </span>
185
+ <motion.div initial={{ opacity: 0, y: 2 }} animate={{ opacity: 1, y: 0 }}>
186
+ <div
187
+ className={cn(
188
+ "flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
189
+ failed ? "text-destructive/80" : "text-muted-foreground",
190
+ (onOpen || expandable) && "cursor-pointer hover:text-foreground"
191
+ )}
192
+ onClick={onOpen ?? (expandable ? () => setExpanded((v) => !v) : undefined)}
193
+ >
194
+ <SpanStatusDot span={span} className="h-2 w-2" />
195
+ <span className="font-[family-name:var(--font-mono)] font-medium">
196
+ {spanToolLabel(span)}
197
+ </span>
198
+ {span.outcome && span.outcome !== "success" && (
199
+ <span className="text-[10px] uppercase">{span.outcome}</span>
200
+ )}
201
+ <span className="ml-auto font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
202
+ {duration ?? (running ? "…" : "")}
203
+ </span>
204
+ {expandable && (
205
+ <ChevronRight
206
+ className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
207
+ />
208
+ )}
209
+ </div>
210
+ {expanded && <SpanPayload spanId={span.spanId} />}
201
211
  </motion.div>
202
212
  );
203
213
  }
@@ -1,6 +1,7 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { Eye, EyeOff, Loader2, RefreshCw } from "lucide-react";
3
3
  import type {
4
+ BillingMode,
4
5
  ModelCatalogEntry,
5
6
  ModelCatalogResponse,
6
7
  } from "@schlessera/brain-ui-sdk/protocol";
@@ -21,6 +22,9 @@ export function ModelsTab({ active }: { active: boolean }) {
21
22
  const [refreshing, setRefreshing] = useState(false);
22
23
  const [error, setError] = useState<string | null>(null);
23
24
  const loadProviders = useProviderStore((s) => s.loadProviders);
25
+ const commitGate = useRef(createRequestGate());
26
+ /** Serializes full-record PUTs — see commitCatalog. */
27
+ const commitQueue = useRef<Promise<void>>(Promise.resolve());
24
28
 
25
29
  useEffect(() => {
26
30
  if (!active) return;
@@ -45,34 +49,86 @@ export function ModelsTab({ active }: { active: boolean }) {
45
49
  };
46
50
  }, [active]);
47
51
 
52
+ /**
53
+ * Optimistic-update skeleton shared by the hidden toggle and the billing
54
+ * select: the change is the user's own click, so reflect it immediately,
55
+ * commit, then reload the composer picker's own roster copy (fetched once
56
+ * on mount, it would otherwise lag until a page reload). A failed write
57
+ * rolls back and surfaces the error.
58
+ *
59
+ * Ordered through `createRequestGate`: two rows edited within one
60
+ * round-trip interleave, and without the guard the FIRST response (or its
61
+ * failure rollback) lands last and silently overwrites the newer edit. A
62
+ * superseded response/rollback is dropped — the newer request's payload
63
+ * was built on top of this one's optimistic state, so it already carries
64
+ * this change (and its own catch surfaces any error that still matters).
65
+ */
66
+ async function commitCatalog(
67
+ optimistic: ModelCatalogResponse,
68
+ commit: () => Promise<ModelCatalogResponse>
69
+ ) {
70
+ const isCurrent = commitGate.current.begin();
71
+ const previous = catalog;
72
+ setCatalog(optimistic);
73
+ setError(null);
74
+ // The gate drops superseded RESPONSES; this queue serializes the WRITES.
75
+ // Both matter: the server stores full records, so two concurrent PUTs
76
+ // could land older-last and silently clobber the newer record server-side
77
+ // even while the client looked right. Each commit waits for the previous
78
+ // one to settle; payloads are built on optimistic state, so the newest
79
+ // write already carries every earlier edit.
80
+ const run = commitQueue.current.then(async () => {
81
+ try {
82
+ const confirmed = await commit();
83
+ if (isCurrent()) setCatalog(confirmed);
84
+ void loadProviders();
85
+ } catch (err) {
86
+ if (isCurrent()) {
87
+ setCatalog(previous);
88
+ setError(err instanceof Error ? err.message : "Could not save");
89
+ }
90
+ }
91
+ });
92
+ commitQueue.current = run;
93
+ await run;
94
+ }
95
+
48
96
  async function toggleHidden(entry: ModelCatalogEntry) {
49
97
  if (!catalog) return;
50
- const previous = catalog;
51
98
  const hidden = catalog.models
52
99
  .filter((model) =>
53
100
  model.id === entry.id ? !entry.hidden : model.hidden
54
101
  )
55
102
  .map((model) => model.id);
56
103
 
57
- // Optimistic: the list is the user's own click, so reflect it immediately
58
- // and roll back if the write fails.
59
- setCatalog({
60
- ...catalog,
61
- models: catalog.models.map((model) =>
62
- model.id === entry.id ? { ...model, hidden: !model.hidden } : model
63
- ),
64
- });
65
- setError(null);
66
- try {
67
- setCatalog(await api.setHiddenModels(hidden));
68
- // The composer's picker holds its own copy of the roster, fetched once on
69
- // mount — without this it keeps offering a model the user just hid until
70
- // the page is reloaded.
71
- void loadProviders();
72
- } catch (err) {
73
- setCatalog(previous);
74
- setError(err instanceof Error ? err.message : "Could not save");
75
- }
104
+ await commitCatalog(
105
+ {
106
+ ...catalog,
107
+ models: catalog.models.map((model) =>
108
+ model.id === entry.id ? { ...model, hidden: !model.hidden } : model
109
+ ),
110
+ },
111
+ () => api.setHiddenModels(hidden)
112
+ );
113
+ }
114
+
115
+ async function changeBilling(entry: ModelCatalogEntry, next: BillingMode | "auto") {
116
+ if (!catalog) return;
117
+
118
+ // What "auto" resolves to is only known server-side, so switching back to
119
+ // auto keeps the current resolved mode until the confirmed catalog
120
+ // corrects it a beat later.
121
+ await commitCatalog(
122
+ {
123
+ ...catalog,
124
+ models: catalog.models.map((model) => {
125
+ if (model.id !== entry.id) return model;
126
+ const { billingOverride: _cleared, ...base } = model;
127
+ return next === "auto" ? base : { ...base, billingOverride: next, billingMode: next };
128
+ }),
129
+ },
130
+ () => api.setBillingOverrides(nextBillingOverrides(catalog.models, entry.id, next))
131
+ );
76
132
  }
77
133
 
78
134
  async function onRefresh() {
@@ -115,6 +171,7 @@ export function ModelsTab({ active }: { active: boolean }) {
115
171
  key={entry.id}
116
172
  entry={entry}
117
173
  onToggle={() => toggleHidden(entry)}
174
+ onBilling={(next) => changeBilling(entry, next)}
118
175
  />
119
176
  ))}
120
177
  {catalog?.models.length === 0 && (
@@ -162,12 +219,51 @@ export function ModelsTab({ active }: { active: boolean }) {
162
219
  );
163
220
  }
164
221
 
222
+ /**
223
+ * Request-ordering guard for optimistic commits: `begin()` claims a token
224
+ * and returns a predicate that holds only while no later request has begun.
225
+ * An older in-flight request must never write over a newer edit's state.
226
+ */
227
+ export function createRequestGate(): { begin: () => () => boolean } {
228
+ let seq = 0;
229
+ return {
230
+ begin() {
231
+ const token = ++seq;
232
+ return () => seq === token;
233
+ },
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The billing-override record PUT after changing one profile: every other
239
+ * profile keeps its stored override, the changed one is set — or, for "auto",
240
+ * REMOVED, never stored as a redundant explicit value.
241
+ */
242
+ export function nextBillingOverrides(
243
+ models: ModelCatalogEntry[],
244
+ id: string,
245
+ next: BillingMode | "auto"
246
+ ): Record<string, BillingMode> {
247
+ const billing: Record<string, BillingMode> = {};
248
+ for (const model of models) {
249
+ const value = model.id === id ? (next === "auto" ? undefined : next) : model.billingOverride;
250
+ if (value) billing[model.id] = value;
251
+ }
252
+ return billing;
253
+ }
254
+
255
+ function billingLabel(mode: BillingMode): string {
256
+ return mode === "api" ? "API" : "Subscription";
257
+ }
258
+
165
259
  function ModelRow({
166
260
  entry,
167
261
  onToggle,
262
+ onBilling,
168
263
  }: {
169
264
  entry: ModelCatalogEntry;
170
265
  onToggle: () => void;
266
+ onBilling: (next: BillingMode | "auto") => void;
171
267
  }) {
172
268
  const Icon = entry.hidden ? EyeOff : Eye;
173
269
  return (
@@ -185,6 +281,24 @@ function ModelRow({
185
281
  {entry.source === "declared" ? " · configured" : ""}
186
282
  </p>
187
283
  </div>
284
+ {/* Tri-state billing: the collapsed control always reads as the
285
+ RESOLVED mode — the Auto option carries what auto resolves to, so
286
+ "Auto (subscription)" and a forced "Subscription" are both legible
287
+ at a glance. */}
288
+ <select
289
+ value={entry.billingOverride ?? "auto"}
290
+ onChange={(e) => onBilling(e.target.value as BillingMode | "auto")}
291
+ aria-label={`Billing for ${entry.label}`}
292
+ className="h-8 shrink-0 rounded-lg border border-border-subtle bg-surface px-1.5 text-[11px] text-muted-foreground transition-colors hover:border-primary hover:text-foreground"
293
+ >
294
+ <option value="auto">
295
+ {!entry.billingOverride && entry.billingMode
296
+ ? `Auto (${billingLabel(entry.billingMode).toLowerCase()})`
297
+ : "Auto"}
298
+ </option>
299
+ <option value="subscription">Subscription</option>
300
+ <option value="api">API</option>
301
+ </select>
188
302
  <button
189
303
  onClick={onToggle}
190
304
  title={entry.hidden ? "Show in picker" : "Hide from picker"}
@@ -6,6 +6,7 @@ import type {
6
6
  PronunciationOverride,
7
7
  ProviderInfo,
8
8
  PasskeySummary,
9
+ BillingMode,
9
10
  ModelCatalogResponse,
10
11
  ActivityRunSummary,
11
12
  ActivityRunDetail,
@@ -52,6 +53,23 @@ export interface BrainSearchResponse {
52
53
  warnings: string[];
53
54
  }
54
55
 
56
+ /**
57
+ * Pricing-table freshness (GET /api/models/pricing) — mirrors the server
58
+ * pricing service's state. The route is additive: an older server 404s, and
59
+ * callers treat the rejection as "no freshness signal, show nothing".
60
+ */
61
+ export interface PricingState {
62
+ enabled: boolean;
63
+ /** When a refresh last succeeded (either source); null when none ever has. */
64
+ fetchedAt: number | null;
65
+ /** The current table is older than the TTL (or was never fetched). */
66
+ stale: boolean;
67
+ /** What the table is served from: remote data (cache included) or the bundled snapshot. */
68
+ source: "remote" | "snapshot";
69
+ /** Last refresh failure, if the current table is served despite one. */
70
+ error?: string;
71
+ }
72
+
55
73
  /** Backend id + capability flags the client renders behavior from. */
56
74
  export interface BackendInfo {
57
75
  id: string;
@@ -181,6 +199,16 @@ export const api = {
181
199
  body: JSON.stringify({ hidden }),
182
200
  }),
183
201
 
202
+ /** Replace the billing-override record (full record, not a delta); returns the new catalog. */
203
+ setBillingOverrides: (billing: Record<string, BillingMode>) =>
204
+ fetchJson<ModelCatalogResponse>("/models/billing", {
205
+ method: "PUT",
206
+ body: JSON.stringify({ billing }),
207
+ }),
208
+
209
+ /** Pricing-table freshness for the Activity staleness indicator (see `PricingState`). */
210
+ pricingState: () => fetchJson<PricingState>("/models/pricing"),
211
+
184
212
  /** Force a discovery refresh, bypassing the TTL. */
185
213
  refreshModels: () =>
186
214
  fetchJson<ModelCatalogResponse>("/models/refresh", { method: "POST" }),
@@ -261,8 +289,16 @@ export const api = {
261
289
  .join("&")
262
290
  ),
263
291
 
264
- activityRun: (runId: string) =>
265
- fetchJson<ActivityRunDetail>(`/activity/runs/${encodeURIComponent(runId)}`),
292
+ /**
293
+ * One run's detail. Payload bodies (tool_input/tool_output events) are
294
+ * excluded by default — the session-history fetch only needs span timings —
295
+ * and opted into by the drill-in views via `includePayloads`.
296
+ */
297
+ activityRun: (runId: string, opts?: { includePayloads?: boolean }) =>
298
+ fetchJson<ActivityRunDetail>(
299
+ `/activity/runs/${encodeURIComponent(runId)}` +
300
+ (opts?.includePayloads ? "?include=payloads" : "")
301
+ ),
266
302
 
267
303
  activityRollups: (days?: number) =>
268
304
  fetchJson<ActivityRollups>(`/activity/rollups${days ? `?days=${days}` : ""}`),
@@ -273,6 +273,42 @@ export async function loadSessionActivityHistory(sessionId: string): Promise<voi
273
273
  }
274
274
  }
275
275
 
276
+ /** Runs whose payload events have been backfilled over REST (per page lifetime). */
277
+ const payloadLoadedRuns = new Set<string>();
278
+
279
+ /**
280
+ * Backfill one finished run's tool payload events into the mirror. The
281
+ * history fetch above and the run-list default deliberately skip payload
282
+ * bodies — they exist for duration badges, not drill-ins — so the first
283
+ * expanded payload view of a history span pulls the full detail
284
+ * (`include=payloads`) and merges it. Live runs never need this: their
285
+ * payload events ride the delta stream. The merge is safe because a snapshot
286
+ * at the same high-water still applies and events insert-if-absent.
287
+ */
288
+ export async function loadSpanPayloads(spanId: string): Promise<void> {
289
+ const state = useActivityStore.getState();
290
+ const runId = state.spanRun[spanId];
291
+ if (!runId || payloadLoadedRuns.has(runId)) return;
292
+ const root = rootSpanOf(state.spans[runId] ?? {});
293
+ if (!root || root.outcome === undefined) return;
294
+ payloadLoadedRuns.add(runId);
295
+ try {
296
+ const detail = await api.activityRun(runId, { includePayloads: true });
297
+ if (detail.detailPruned || !detail.spans) return;
298
+ useActivityStore.getState().applySnapshot({
299
+ type: "activity_snapshot",
300
+ view: "run",
301
+ runId,
302
+ spans: detail.spans,
303
+ events: detail.events ?? [],
304
+ highWaterSeq: { [runId]: detail.highWaterSeq ?? 0 },
305
+ });
306
+ } catch {
307
+ // Payloads are an enhancement; the row keeps its no-payload notice.
308
+ payloadLoadedRuns.delete(runId);
309
+ }
310
+ }
311
+
276
312
  /** The four indexes that make up the mirror, mutated together during merges. */
277
313
  interface MirrorMaps {
278
314
  spans: Record<string, Record<string, ActivitySpan>>;
@@ -371,6 +407,52 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
371
407
  return state.events[spanId] ?? EMPTY_EVENTS;
372
408
  }
373
409
 
410
+ /** Payload event types the tool expander owns — every OTHER type belongs to
411
+ * the narrative stream (`narrativeEventsFor`) so nothing recorded is
412
+ * rendered nowhere. */
413
+ const TOOL_PAYLOAD_TYPES = new Set(["tool_input", "tool_output"]);
414
+
415
+ /**
416
+ * The recorded input/output payload events of a tool span (AE7). Returns a
417
+ * fresh array per call — subscribe through `useShallow` (like `childSpans`).
418
+ */
419
+ export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
420
+ const events = state.events[spanId];
421
+ if (!events) return EMPTY_EVENTS;
422
+ const payloads = events.filter((e) => TOOL_PAYLOAD_TYPES.has(e.eventType));
423
+ return payloads.length > 0 ? payloads : EMPTY_EVENTS;
424
+ }
425
+
426
+ /**
427
+ * Everything recorded against a span that is NOT a tool input/output payload:
428
+ * transcript excerpts, job output, and any span-sink event type a producer
429
+ * invents. Rendered as the span's narrative so an unknown type degrades to a
430
+ * labelled block rather than to invisibility.
431
+ */
432
+ export function narrativeEventsFor(
433
+ state: ActivityState,
434
+ spanId: string
435
+ ): ActivitySpanEvent[] {
436
+ const events = state.events[spanId];
437
+ if (!events) return EMPTY_EVENTS;
438
+ const rest = events.filter((e) => !TOOL_PAYLOAD_TYPES.has(e.eventType));
439
+ return rest.length > 0 ? rest : EMPTY_EVENTS;
440
+ }
441
+
442
+ /** Every event recorded under a run, ordered by time — the run detail's
443
+ * narrative stream and the raw-trace dump both read through this. */
444
+ export function runEvents(state: ActivityState, runId: string): ActivitySpanEvent[] {
445
+ const byId = state.spans[runId];
446
+ if (!byId) return EMPTY_EVENTS;
447
+ const out: ActivitySpanEvent[] = [];
448
+ for (const spanId of Object.keys(byId)) {
449
+ const events = state.events[spanId];
450
+ if (events) out.push(...events);
451
+ }
452
+ if (out.length === 0) return EMPTY_EVENTS;
453
+ return out.sort((a, b) => a.ts - b.ts || a.eventIndex - b.eventIndex);
454
+ }
455
+
374
456
  /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
375
457
  export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
376
458
  const runId = state.spanRun[toolUseId];