@schlessera/brain-ui-react 0.18.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 (86) hide show
  1. package/dist/components/activity/activity-page.d.ts +11 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -0
  3. package/dist/components/activity/activity-page.js +243 -0
  4. package/dist/components/activity/activity-page.js.map +1 -0
  5. package/dist/components/activity/digest-card.d.ts +10 -0
  6. package/dist/components/activity/digest-card.d.ts.map +1 -0
  7. package/dist/components/activity/digest-card.js +55 -0
  8. package/dist/components/activity/digest-card.js.map +1 -0
  9. package/dist/components/activity/push-toggle.d.ts +2 -0
  10. package/dist/components/activity/push-toggle.d.ts.map +1 -0
  11. package/dist/components/activity/push-toggle.js +121 -0
  12. package/dist/components/activity/push-toggle.js.map +1 -0
  13. package/dist/components/activity/span-bits.d.ts +76 -0
  14. package/dist/components/activity/span-bits.d.ts.map +1 -0
  15. package/dist/components/activity/span-bits.js +127 -0
  16. package/dist/components/activity/span-bits.js.map +1 -0
  17. package/dist/components/chat/chat-page.d.ts.map +1 -1
  18. package/dist/components/chat/chat-page.js +4 -1
  19. package/dist/components/chat/chat-page.js.map +1 -1
  20. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  21. package/dist/components/chat/session-drawer.js +1 -15
  22. package/dist/components/chat/session-drawer.js.map +1 -1
  23. package/dist/components/chat/subagent-view.d.ts +18 -0
  24. package/dist/components/chat/subagent-view.d.ts.map +1 -0
  25. package/dist/components/chat/subagent-view.js +83 -0
  26. package/dist/components/chat/subagent-view.js.map +1 -0
  27. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  28. package/dist/components/chat/tool-call-timeline.js +40 -4
  29. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  30. package/dist/components/chat/tool-views.d.ts +4 -0
  31. package/dist/components/chat/tool-views.d.ts.map +1 -1
  32. package/dist/components/chat/tool-views.js +26 -0
  33. package/dist/components/chat/tool-views.js.map +1 -1
  34. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  35. package/dist/components/layout/mobile-tab-bar.js +10 -4
  36. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  37. package/dist/components/layout/side-rail.d.ts.map +1 -1
  38. package/dist/components/layout/side-rail.js +7 -4
  39. package/dist/components/layout/side-rail.js.map +1 -1
  40. package/dist/components/settings/models-tab.d.ts +15 -0
  41. package/dist/components/settings/models-tab.d.ts.map +1 -1
  42. package/dist/components/settings/models-tab.js +100 -21
  43. package/dist/components/settings/models-tab.js.map +1 -1
  44. package/dist/hooks/use-now.d.ts +8 -0
  45. package/dist/hooks/use-now.d.ts.map +1 -0
  46. package/dist/hooks/use-now.js +32 -0
  47. package/dist/hooks/use-now.js.map +1 -0
  48. package/dist/hooks/use-websocket.d.ts.map +1 -1
  49. package/dist/hooks/use-websocket.js +46 -0
  50. package/dist/hooks/use-websocket.js.map +1 -1
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/api-client.d.ts +67 -1
  56. package/dist/lib/api-client.d.ts.map +1 -1
  57. package/dist/lib/api-client.js +41 -0
  58. package/dist/lib/api-client.js.map +1 -1
  59. package/dist/stores/activity-store.d.ts +108 -0
  60. package/dist/stores/activity-store.d.ts.map +1 -0
  61. package/dist/stores/activity-store.js +359 -0
  62. package/dist/stores/activity-store.js.map +1 -0
  63. package/dist/stores/ui-store.d.ts +9 -1
  64. package/dist/stores/ui-store.d.ts.map +1 -1
  65. package/dist/stores/ui-store.js +3 -0
  66. package/dist/stores/ui-store.js.map +1 -1
  67. package/dist/styles.css +1 -1
  68. package/package.json +2 -2
  69. package/src/components/activity/activity-page.tsx +623 -0
  70. package/src/components/activity/digest-card.tsx +105 -0
  71. package/src/components/activity/push-toggle.tsx +157 -0
  72. package/src/components/activity/span-bits.tsx +194 -0
  73. package/src/components/chat/chat-page.tsx +17 -0
  74. package/src/components/chat/session-drawer.tsx +1 -12
  75. package/src/components/chat/subagent-view.tsx +239 -0
  76. package/src/components/chat/tool-call-timeline.tsx +61 -3
  77. package/src/components/chat/tool-views.tsx +22 -0
  78. package/src/components/layout/mobile-tab-bar.tsx +25 -5
  79. package/src/components/layout/side-rail.tsx +17 -0
  80. package/src/components/settings/models-tab.tsx +135 -21
  81. package/src/hooks/use-now.ts +32 -0
  82. package/src/hooks/use-websocket.ts +44 -0
  83. package/src/index.ts +1 -0
  84. package/src/lib/api-client.ts +111 -0
  85. package/src/stores/activity-store.ts +451 -0
  86. package/src/stores/ui-store.ts +13 -1
@@ -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"}
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** All subscribed setters, driven by ONE shared interval. */
4
+ const listeners = new Set<(now: number) => void>();
5
+ let ticker: ReturnType<typeof setInterval> | null = null;
6
+
7
+ /**
8
+ * A ticking `Date.now()` for live elapsed labels. Every consumer shares a
9
+ * single setInterval (created on the first subscriber's cadence, torn down
10
+ * with the last), so a screen full of running rows costs one timer, and all
11
+ * their labels tick in the same frame.
12
+ */
13
+ export function useNow(intervalMs = 1000): number {
14
+ const [now, setNow] = useState(() => Date.now());
15
+ useEffect(() => {
16
+ listeners.add(setNow);
17
+ if (!ticker) {
18
+ ticker = setInterval(() => {
19
+ const t = Date.now();
20
+ for (const listener of listeners) listener(t);
21
+ }, intervalMs);
22
+ }
23
+ return () => {
24
+ listeners.delete(setNow);
25
+ if (listeners.size === 0 && ticker) {
26
+ clearInterval(ticker);
27
+ ticker = null;
28
+ }
29
+ };
30
+ }, [intervalMs]);
31
+ return now;
32
+ }
@@ -4,6 +4,7 @@ import { useConnectionStore } from "../stores/connection-store.js";
4
4
  import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
5
5
  import { useProviderStore } from "../stores/provider-store.js";
6
6
  import { useMaskStore } from "../stores/mask-store.js";
7
+ import { useActivityStore, loadSessionActivityHistory } from "../stores/activity-store.js";
7
8
  import { getWsUrl } from "../lib/backend.js";
8
9
  import type {
9
10
  ServerMessage,
@@ -121,9 +122,44 @@ function isOurDraftAnnouncement(
121
122
  return true;
122
123
  }
123
124
 
125
+ /**
126
+ * Lazily open a session-scoped activity subscription (live subagent rows,
127
+ * server-stamped tool timing). Idempotent per connection; the subscribed set
128
+ * resets when a new socket's server_hello arrives.
129
+ */
130
+ function ensureActivitySubscription(sessionId: string | null | undefined): void {
131
+ if (!sessionId) return;
132
+ const activity = useActivityStore.getState();
133
+ if (!activity.supported || activity.subscribed[sessionId]) return;
134
+ activity.markSubscribed(sessionId);
135
+ wsClient?.send({ type: "activity_subscribe", view: "session", sessionId });
136
+ // The subscription snapshot carries OPEN runs; finished runs (history
137
+ // timing, past fan-outs) come from the REST side.
138
+ void loadSessionActivityHistory(sessionId);
139
+ }
140
+
124
141
  export function handleServerMessage(msg: ServerMessage) {
125
142
  const state = useChatStore.getState();
126
143
 
144
+ // Activity stream frames feed their own store and never touch chat state.
145
+ if (msg.type === "server_hello") {
146
+ useActivityStore.getState().setSupported(msg.capabilities?.activity === true);
147
+ // A new hello means a new connection: server-side subscriptions are gone.
148
+ useActivityStore.getState().resetSubscriptions();
149
+ // …and view-owned subscriptions (the Activity index) must re-send too.
150
+ useActivityStore.getState().bumpConnectionEpoch();
151
+ ensureActivitySubscription(state.activeSessionId);
152
+ return;
153
+ }
154
+ if (msg.type === "activity_snapshot") {
155
+ useActivityStore.getState().applySnapshot(msg);
156
+ return;
157
+ }
158
+ if (msg.type === "activity_delta") {
159
+ useActivityStore.getState().applyDelta(msg);
160
+ return;
161
+ }
162
+
127
163
  // Parallel-session demux (per-session buffers). Every frame is scoped by
128
164
  // sessionId; a scoped frame lands in ITS session's buffer, so a background
129
165
  // session's transcript keeps accumulating while another one is in view.
@@ -186,6 +222,10 @@ export function handleServerMessage(msg: ServerMessage) {
186
222
  break;
187
223
 
188
224
  case "tool_use_start":
225
+ // A tool call INSIDE a subagent nests under its Agent entry, rendered
226
+ // from the activity stream — putting it in the flat transcript would
227
+ // interleave fan-out work with the main turn's steps.
228
+ if (msg.parentToolUseId) break;
189
229
  state.startToolCall(key, msg.toolUseId, msg.toolName);
190
230
  break;
191
231
 
@@ -194,6 +234,9 @@ export function handleServerMessage(msg: ServerMessage) {
194
234
  break;
195
235
 
196
236
  case "tool_use_complete":
237
+ // Subagent tool calls render nested (activity stream), and the flat
238
+ // store's name-based fallback match must never see them.
239
+ if (msg.parentToolUseId) break;
197
240
  state.completeToolCall(key, msg.toolUseId, msg.toolName, msg.input);
198
241
  break;
199
242
 
@@ -237,6 +280,7 @@ export function handleServerMessage(msg: ServerMessage) {
237
280
  break;
238
281
 
239
282
  case "session_info": {
283
+ ensureActivitySubscription(msg.sessionId);
240
284
  // bindDraftSession above handled draft adoption; an info frame may still
241
285
  // re-pin the provider picker when it concerns the session in view.
242
286
  const current = useChatStore.getState();
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export { ConnectionGate } from "./components/connectivity/connection-gate.js";
16
16
  export { AppShell } from "./components/layout/app-shell.js";
17
17
  export { ChatPage } from "./components/chat/chat-page.js";
18
18
  export { GraphPage } from "./components/graph/graph-page.js";
19
+ export { ActivityPage } from "./components/activity/activity-page.js";
19
20
 
20
21
  // Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
21
22
  export { BrainMarkdown } from "./components/chat/brain-markdown.js";
@@ -6,7 +6,25 @@ import type {
6
6
  PronunciationOverride,
7
7
  ProviderInfo,
8
8
  PasskeySummary,
9
+ BillingMode,
9
10
  ModelCatalogResponse,
11
+ ActivityRunSummary,
12
+ ActivityRunDetail,
13
+ ActivityRollups,
14
+ ActivityDigest,
15
+ ActivityIntent,
16
+ } from "@schlessera/brain-ui-sdk/protocol";
17
+
18
+ // Activity REST types live in the SDK protocol (shared with the server);
19
+ // re-exported here so existing importers keep working.
20
+ export type {
21
+ ActivityRunSummary,
22
+ ActivityRunDetail,
23
+ ActivityRunRollup,
24
+ ActivityAggregate,
25
+ ActivityRollups,
26
+ ActivityDigest,
27
+ ActivityIntent,
10
28
  } from "@schlessera/brain-ui-sdk/protocol";
11
29
  import type {
12
30
  AuthenticationResponseJSON,
@@ -35,6 +53,23 @@ export interface BrainSearchResponse {
35
53
  warnings: string[];
36
54
  }
37
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
+
38
73
  /** Backend id + capability flags the client renders behavior from. */
39
74
  export interface BackendInfo {
40
75
  id: string;
@@ -164,6 +199,16 @@ export const api = {
164
199
  body: JSON.stringify({ hidden }),
165
200
  }),
166
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
+
167
212
  /** Force a discovery refresh, bypassing the TTL. */
168
213
  refreshModels: () =>
169
214
  fetchJson<ModelCatalogResponse>("/models/refresh", { method: "POST" }),
@@ -219,4 +264,70 @@ export const api = {
219
264
  fetchJson<{ ok: true }>(`/auth/passkey/${encodeURIComponent(id)}`, {
220
265
  method: "DELETE",
221
266
  }),
267
+
268
+ // --- Activity record (read side; live updates ride the WebSocket) ---
269
+
270
+ activityRuns: (opts?: {
271
+ origin?: "session" | "cron";
272
+ job?: string;
273
+ session?: string;
274
+ status?: string;
275
+ limit?: number;
276
+ before?: number;
277
+ }) =>
278
+ fetchJson<{ live: ActivityRunSummary[]; history: ActivityRunSummary[] }>(
279
+ "/activity/runs?" +
280
+ [
281
+ opts?.origin && `origin=${opts.origin}`,
282
+ opts?.job && `job=${encodeURIComponent(opts.job)}`,
283
+ opts?.session && `session=${encodeURIComponent(opts.session)}`,
284
+ opts?.status && `status=${encodeURIComponent(opts.status)}`,
285
+ opts?.limit && `limit=${opts.limit}`,
286
+ opts?.before && `before=${opts.before}`,
287
+ ]
288
+ .filter(Boolean)
289
+ .join("&")
290
+ ),
291
+
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
+ ),
302
+
303
+ activityRollups: (days?: number) =>
304
+ fetchJson<ActivityRollups>(`/activity/rollups${days ? `?days=${days}` : ""}`),
305
+
306
+ activityInbox: () => fetchJson<{ intents: ActivityIntent[] }>("/activity/inbox"),
307
+
308
+ activityInboxAck: (id: number) =>
309
+ fetchJson<{ ok: true }>(`/activity/inbox/${id}/ack`, { method: "POST" }),
310
+
311
+ activityInboxAckAll: () =>
312
+ fetchJson<{ acknowledged: number }>("/activity/inbox/ack-all", { method: "POST" }),
313
+
314
+ activityDigest: () =>
315
+ fetchJson<{ digest: ActivityDigest | null; dismissedAt: number }>("/activity/digest"),
316
+
317
+ activityDigestDismiss: () =>
318
+ fetchJson<{ ok: true }>("/activity/digest/dismiss", { method: "POST" }),
319
+
320
+ pushPublicKey: () => fetchJson<{ publicKey: string }>("/push/public-key"),
321
+
322
+ pushSubscribe: (subscription: unknown, label?: string) =>
323
+ fetchJson<{ ok: true }>("/push/subscribe", {
324
+ method: "POST",
325
+ body: JSON.stringify({ subscription, label }),
326
+ }),
327
+
328
+ pushUnsubscribe: (endpoint: string) =>
329
+ fetchJson<{ removed: boolean }>("/push/unsubscribe", {
330
+ method: "POST",
331
+ body: JSON.stringify({ endpoint }),
332
+ }),
222
333
  };