@schlessera/brain-ui-react 0.19.0 → 0.20.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 +48 -13
  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 +48 -0
  11. package/dist/components/activity/span-bits.d.ts.map +1 -1
  12. package/dist/components/activity/span-bits.js +87 -3
  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 +15 -0
  25. package/dist/stores/activity-store.d.ts.map +1 -1
  26. package/dist/stores/activity-store.js +49 -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 +158 -30
  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 +125 -2
  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 +49 -0
@@ -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
  }, []);
@@ -1,6 +1,13 @@
1
- import { isFailureOutcome } 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";
2
4
  import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
3
5
 
6
+ import {
7
+ useActivityStore,
8
+ payloadEventsFor,
9
+ loadSpanPayloads,
10
+ } from "../../stores/activity-store.js";
4
11
  import { cn } from "../../lib/utils.js";
5
12
  import { getToolLabel } from "../chat/tool-views.js";
6
13
 
@@ -52,7 +59,123 @@ export function spanToolLabel(span: ActivitySpan): string {
52
59
  if (span.toolName) return getToolLabel(span.toolName);
53
60
  if (span.kind === "turn") return "Turn";
54
61
  if (span.kind === "cron") return span.jobName ?? span.name;
55
- return getToolLabel(span.name.replace(/^execute_tool /, ""));
62
+ return getToolLabel(
63
+ span.name.startsWith(SPAN_TOOL_NAME_PREFIX)
64
+ ? span.name.slice(SPAN_TOOL_NAME_PREFIX.length)
65
+ : span.name
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The recorded input/output of one tool span, expanded under its row — the
71
+ * ONE payload renderer shared by the subagent drill-in and the run detail.
72
+ * Mounted only while expanded (collapsed rows never subscribe). A span with
73
+ * no payload events (recorded before capture shipped) states so instead of
74
+ * offering an empty block (AE7); a payload clipped at persist time carries
75
+ * the wire `truncated` flag, rendered as a hint line under the block.
76
+ */
77
+ export function SpanPayload({ spanId }: { spanId: string }) {
78
+ const events = useActivityStore(useShallow((s) => payloadEventsFor(s, spanId)));
79
+ // History fetches skip payload bodies (they exist for duration badges);
80
+ // the first expand of a finished run backfills them over REST. A no-op for
81
+ // live runs (payloads ride the WS deltas) and once per run thereafter.
82
+ useEffect(() => {
83
+ if (events.length === 0) void loadSpanPayloads(spanId);
84
+ }, [spanId, events.length]);
85
+ if (events.length === 0) {
86
+ return (
87
+ <p className="px-2 py-1 text-[11px] text-muted-foreground/60">
88
+ No payload recorded for this run.
89
+ </p>
90
+ );
91
+ }
92
+ return (
93
+ <div className="space-y-1.5 px-2 py-1">
94
+ {events.map((event) => (
95
+ <div key={`${event.spanId}:${event.eventIndex}`}>
96
+ <div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">
97
+ {event.eventType === "tool_input" ? "Input" : "Output"}
98
+ </div>
99
+ <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">
100
+ {typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload)}
101
+ </pre>
102
+ {event.truncated && (
103
+ <p className="mt-0.5 text-[10px] italic text-muted-foreground/60">… truncated</p>
104
+ )}
105
+ </div>
106
+ ))}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ /**
112
+ * THE effective-cost glyph — one three-state rule so no surface ever renders
113
+ * an unknown cost as $0.00 (AE3): absent/NULL is "—" (we don't know), 0 is
114
+ * "free" (we know — subscription-billed or genuinely zero), positive is
115
+ * dollars, "~"-prefixed when computed from estimated rates. Sub-cent costs
116
+ * floor at "<$0.01" rather than rounding down to a zero look-alike.
117
+ */
118
+ export function formatEffectiveCost(
119
+ costUsd: number | null | undefined,
120
+ estimate?: boolean
121
+ ): string {
122
+ if (costUsd === null || costUsd === undefined) return "—";
123
+ if (costUsd === 0) return "free";
124
+ const amount = costUsd < 0.005 ? "<$0.01" : `$${costUsd.toFixed(2)}`;
125
+ return estimate ? `~${amount}` : amount;
126
+ }
127
+
128
+ /**
129
+ * An aggregate's effective-cost sum. Sums exclude unknown-cost runs, so a
130
+ * nonzero `unpricedRuns` makes the number a floor ("≥ $X"), never a total.
131
+ * A known sub-cent sum floors at "<$0.01" like the per-run glyph — only an
132
+ * exact 0 (genuinely nothing to add up) renders "$0.00" (AE3).
133
+ */
134
+ export function formatAggregateCost(effectiveUsd: number, unpricedRuns: number): string {
135
+ const amount =
136
+ effectiveUsd > 0 && effectiveUsd < 0.005 ? "<$0.01" : `$${effectiveUsd.toFixed(2)}`;
137
+ return unpricedRuns > 0 ? `≥ ${amount}` : amount;
138
+ }
139
+
140
+ /**
141
+ * A run row's cost text, three-way on `effectiveCostUsd`: ABSENT means a
142
+ * pre-pricing server never sent the field — fall back to the original
143
+ * list-cost rendering (a positive `costUsd`, else nothing) instead of
144
+ * claiming unknown; explicit null means THIS server computed "unknown" and
145
+ * renders the em dash; a number goes through `formatEffectiveCost` (AE3).
146
+ */
147
+ export function runCostText(run: {
148
+ costUsd: number | null;
149
+ effectiveCostUsd?: number | null;
150
+ pricingEstimate?: boolean;
151
+ }): string | null {
152
+ if (run.effectiveCostUsd === undefined) {
153
+ return run.costUsd !== null && run.costUsd > 0 ? `$${run.costUsd.toFixed(2)}` : null;
154
+ }
155
+ return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate);
156
+ }
157
+
158
+ /**
159
+ * The digest card's cost clause (no leading separator), effective-only, or
160
+ * null for "say nothing". A digest persisted before pricing shipped carries
161
+ * neither new field and keeps its old list-cost clause.
162
+ */
163
+ export function digestCostClause(digest: {
164
+ costUsd: number;
165
+ effectiveCostUsd?: number;
166
+ unpricedRuns?: number;
167
+ }): string | null {
168
+ if (digest.effectiveCostUsd === undefined) {
169
+ return digest.costUsd > 0 ? `$${digest.costUsd.toFixed(2)} spent` : null;
170
+ }
171
+ const unpriced = digest.unpricedRuns ?? 0;
172
+ const qualifier = unpriced > 0 ? ` (${unpriced} unpriced)` : "";
173
+ if (digest.effectiveCostUsd > 0) {
174
+ return `${formatAggregateCost(digest.effectiveCostUsd, unpriced)} spent${qualifier}`;
175
+ }
176
+ // Known-zero spend: nothing to add up, but the unknowns still get named.
177
+ if (unpriced > 0) return `${unpriced} unpriced`;
178
+ return "free";
56
179
  }
57
180
 
58
181
  /** 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,19 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
371
407
  return state.events[spanId] ?? EMPTY_EVENTS;
372
408
  }
373
409
 
410
+ /**
411
+ * The recorded input/output payload events of a tool span (AE7). Returns a
412
+ * fresh array per call — subscribe through `useShallow` (like `childSpans`).
413
+ */
414
+ export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
415
+ const events = state.events[spanId];
416
+ if (!events) return EMPTY_EVENTS;
417
+ const payloads = events.filter(
418
+ (e) => e.eventType === "tool_input" || e.eventType === "tool_output"
419
+ );
420
+ return payloads.length > 0 ? payloads : EMPTY_EVENTS;
421
+ }
422
+
374
423
  /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
375
424
  export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
376
425
  const runId = state.spanRun[toolUseId];