@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,8 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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(
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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"}
|
package/src/lib/api-client.ts
CHANGED
|
@@ -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
|
-
|
|
265
|
-
|
|
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];
|