@schlessera/brain-ui-react 0.20.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.
@@ -1,7 +1,12 @@
1
1
  import { useEffect } from "react";
2
2
  import { useShallow } from "zustand/react/shallow";
3
3
  import { isFailureOutcome, SPAN_TOOL_NAME_PREFIX } from "@schlessera/brain-ui-sdk/protocol";
4
- import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
4
+ import type {
5
+ ActivitySpan,
6
+ ActivitySpanEvent,
7
+ ActivitySpanOutcome,
8
+ BillingMode,
9
+ } from "@schlessera/brain-ui-sdk/protocol";
5
10
 
6
11
  import {
7
12
  useActivityStore,
@@ -9,7 +14,7 @@ import {
9
14
  loadSpanPayloads,
10
15
  } from "../../stores/activity-store.js";
11
16
  import { cn } from "../../lib/utils.js";
12
- import { getToolLabel } from "../chat/tool-views.js";
17
+ import { getToolLabel, formatTokenCount } from "../chat/tool-views.js";
13
18
 
14
19
  /**
15
20
  * THE status dot for activity spans — one outcome→color mapping so every
@@ -92,35 +97,86 @@ export function SpanPayload({ spanId }: { spanId: string }) {
92
97
  return (
93
98
  <div className="space-y-1.5 px-2 py-1">
94
99
  {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>
100
+ <SpanEventBlock key={`${event.spanId}:${event.eventIndex}`} event={event} />
106
101
  ))}
107
102
  </div>
108
103
  );
109
104
  }
110
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
+
111
161
  /**
112
162
  * THE effective-cost glyph — one three-state rule so no surface ever renders
113
163
  * 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
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
115
166
  * dollars, "~"-prefixed when computed from estimated rates. Sub-cent costs
116
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.
117
172
  */
118
173
  export function formatEffectiveCost(
119
174
  costUsd: number | null | undefined,
120
- estimate?: boolean
175
+ estimate?: boolean,
176
+ billingMode?: BillingMode | null
121
177
  ): string {
122
178
  if (costUsd === null || costUsd === undefined) return "—";
123
- if (costUsd === 0) return "free";
179
+ if (costUsd === 0) return billingMode === "subscription" ? "subbed" : "free";
124
180
  const amount = costUsd < 0.005 ? "<$0.01" : `$${costUsd.toFixed(2)}`;
125
181
  return estimate ? `~${amount}` : amount;
126
182
  }
@@ -148,11 +204,12 @@ export function runCostText(run: {
148
204
  costUsd: number | null;
149
205
  effectiveCostUsd?: number | null;
150
206
  pricingEstimate?: boolean;
207
+ billingMode?: BillingMode | null;
151
208
  }): string | null {
152
209
  if (run.effectiveCostUsd === undefined) {
153
210
  return run.costUsd !== null && run.costUsd > 0 ? `$${run.costUsd.toFixed(2)}` : null;
154
211
  }
155
- return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate);
212
+ return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate, run.billingMode);
156
213
  }
157
214
 
158
215
  /**
@@ -407,6 +407,11 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
407
407
  return state.events[spanId] ?? EMPTY_EVENTS;
408
408
  }
409
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
+
410
415
  /**
411
416
  * The recorded input/output payload events of a tool span (AE7). Returns a
412
417
  * fresh array per call — subscribe through `useShallow` (like `childSpans`).
@@ -414,12 +419,40 @@ export function eventsFor(state: ActivityState, spanId: string): ActivitySpanEve
414
419
  export function payloadEventsFor(state: ActivityState, spanId: string): ActivitySpanEvent[] {
415
420
  const events = state.events[spanId];
416
421
  if (!events) return EMPTY_EVENTS;
417
- const payloads = events.filter(
418
- (e) => e.eventType === "tool_input" || e.eventType === "tool_output"
419
- );
422
+ const payloads = events.filter((e) => TOOL_PAYLOAD_TYPES.has(e.eventType));
420
423
  return payloads.length > 0 ? payloads : EMPTY_EVENTS;
421
424
  }
422
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
+
423
456
  /** The span behind one tool call (span ids ARE toolUseIds), if streamed. */
424
457
  export function spanForTool(state: ActivityState, toolUseId: string): ActivitySpan | null {
425
458
  const runId = state.spanRun[toolUseId];