@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.
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +48 -13
- 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 +48 -0
- package/dist/components/activity/span-bits.d.ts.map +1 -1
- package/dist/components/activity/span-bits.js +87 -3
- 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 +15 -0
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +49 -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 +158 -30
- 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 +125 -2
- 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 +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
|
-
{
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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,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];
|