@dbx-tools/ui-mastra 0.1.9

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.
@@ -0,0 +1,883 @@
1
+ import { usePluginClientConfig } from "@dbx-tools/ui-appkit/react";
2
+ import {
3
+ feedback,
4
+ override,
5
+ routes,
6
+ thread,
7
+ wire,
8
+ type Chart,
9
+ type MastraClearHistoryResponse,
10
+ type MastraClientConfig,
11
+ type MastraDeleteThreadResponse,
12
+ type MastraFeedbackRequest,
13
+ type MastraFeedbackResponse,
14
+ type MastraHistoryResponse,
15
+ type MastraThread,
16
+ type MastraThreadsResponse,
17
+ type MastraUpdateThreadResponse,
18
+ type StatementData,
19
+ } from "@dbx-tools/shared-mastra";
20
+ import { error as errorUtil } from "@dbx-tools/shared-core";
21
+ import type { ServingEndpointSummary } from "@dbx-tools/shared-model";
22
+ import { MastraClient } from "@mastra/client-js";
23
+ import { useCallback, useEffect, useMemo, useState } from "react";
24
+ import { asMastraStreamResponse, type MastraStreamResponse } from "./mastra-stream";
25
+
26
+ /**
27
+ * `@mastra/client-js` `MastraClient` extended with the Mastra plugin's
28
+ * custom routes. One client drives everything the chat UI needs:
29
+ *
30
+ * - Conversation streaming via the inherited
31
+ * `getAgent(id).stream()` (the standard Mastra agent route).
32
+ * - Thread history (`history` / `clearHistory`), the conversation
33
+ * list (`threads` / `removeThread` / `renameThread`), the model
34
+ * catalogue (`models`),
35
+ * Genie starter prompts (`suggestions`), MLflow feedback logging
36
+ * (`feedback`), and inline embed resolution (`chart` / `statement`)
37
+ * over the plugin's own routes - all derived from `basePath` +
38
+ * {@link routes.MASTRA_ROUTES}.
39
+ *
40
+ * Built from the plugin's published `clientConfig()` payload
41
+ * ({@link MastraClientConfig}). `credentials: "include"` is set once at
42
+ * construction so the session cookie (which pins the server-side
43
+ * thread id) travels with every request, streaming included.
44
+ */
45
+ export class MastraPluginClient extends MastraClient {
46
+ /** Plugin mount prefix (`/api/<plugin-name>`); all custom routes derive from it. */
47
+ readonly basePath: string;
48
+ /** Agent the bare (un-suffixed) routes resolve to. */
49
+ readonly defaultAgent: string;
50
+ /** Registered agent ids, surfaced for pickers. */
51
+ readonly agents: readonly string[];
52
+ /**
53
+ * Whether the server can log feedback to MLflow. When `false`, the
54
+ * chat UI hides thumbs / comment controls and {@link feedback} would
55
+ * be rejected server-side, so callers gate on this before offering
56
+ * feedback affordances.
57
+ */
58
+ readonly feedbackEnabled: boolean;
59
+
60
+ constructor(config: MastraClientConfig) {
61
+ super({
62
+ baseUrl:
63
+ typeof window !== "undefined" ? window.location.origin : "http://localhost",
64
+ apiPrefix: config.basePath,
65
+ credentials: "include",
66
+ headers: {},
67
+ });
68
+ this.basePath = config.basePath;
69
+ this.defaultAgent = config.defaultAgent;
70
+ this.agents = config.agents;
71
+ this.feedbackEnabled = config.feedbackEnabled;
72
+ }
73
+
74
+ /**
75
+ * Set (or clear) the per-request model override sent on every
76
+ * streaming call as `X-Mastra-Model`. The Mastra plugin's middleware
77
+ * reads it and overrides the resolved model for that request without
78
+ * an agent redeploy. `model` is either a concrete endpoint name
79
+ * (fuzzy-matched server-side) or a model class slug
80
+ * (`ModelClass.ChatFast` / `"chat-thinking"`); pass `undefined` to
81
+ * fall back to the agent's configured default.
82
+ *
83
+ * Mutates the shared `options.headers` in place (rather than
84
+ * rebuilding the client) so the client identity stays stable across
85
+ * model changes - hooks can depend on it without refiring history
86
+ * loads when only the model changes.
87
+ */
88
+ setModelOverride(model?: string): void {
89
+ const headers = (this.options.headers ??= {});
90
+ if (model) headers[override.MODEL_OVERRIDE_HEADER] = model;
91
+ else delete headers[override.MODEL_OVERRIDE_HEADER];
92
+ }
93
+
94
+ /**
95
+ * Select (or clear) the conversation thread every subsequent request
96
+ * targets, sent as the thread-selection header (`THREAD_ID_HEADER`).
97
+ * The plugin's middleware pins `RequestContext`'s thread id to it, so
98
+ * the agent stream persists into - and `history()` reads from - the
99
+ * chosen thread instead of the default per-session one. Pass
100
+ * `undefined` to fall back to the session thread.
101
+ *
102
+ * Mutates the shared `options.headers` in place (like
103
+ * {@link setModelOverride}) so the client identity stays stable; the
104
+ * inherited `agent.stream()` and the custom routes
105
+ * ({@link history} / {@link clearHistory} / {@link threads}) all read
106
+ * these headers, so selecting a thread routes every call at once.
107
+ */
108
+ setThreadId(threadId?: string): void {
109
+ const headers = (this.options.headers ??= {});
110
+ if (threadId) headers[thread.THREAD_ID_HEADER] = threadId;
111
+ else delete headers[thread.THREAD_ID_HEADER];
112
+ }
113
+
114
+ /**
115
+ * Resume a suspended `requireApproval` tool via `approve-tool-call`.
116
+ * Reads SSE directly instead of `agent.approveToolCall()` so the
117
+ * stock client's internal `processChatResponse_vNext` tee does not
118
+ * throw when the resume stream emits `tool-result` without a new
119
+ * `tool-call`.
120
+ */
121
+ async approveToolCallStream(
122
+ agentId: string,
123
+ params: { runId: string; toolCallId: string },
124
+ ): Promise<MastraStreamResponse> {
125
+ return this.#toolApprovalStream(agentId, "approve-tool-call", params);
126
+ }
127
+
128
+ /**
129
+ * Deny a suspended `requireApproval` tool via `decline-tool-call`.
130
+ * Same direct SSE reader as {@link approveToolCallStream}.
131
+ */
132
+ async declineToolCallStream(
133
+ agentId: string,
134
+ params: { runId: string; toolCallId: string },
135
+ ): Promise<MastraStreamResponse> {
136
+ return this.#toolApprovalStream(agentId, "decline-tool-call", params);
137
+ }
138
+
139
+ async #toolApprovalStream(
140
+ agentId: string,
141
+ route: "approve-tool-call" | "decline-tool-call",
142
+ params: { runId: string; toolCallId: string },
143
+ ): Promise<MastraStreamResponse> {
144
+ const response = (await this.request(
145
+ `/agents/${encodeURIComponent(agentId)}/${route}`,
146
+ {
147
+ method: "POST",
148
+ body: params,
149
+ stream: true,
150
+ },
151
+ )) as Response;
152
+ if (!response.body) throw new Error("No response body");
153
+ return asMastraStreamResponse(
154
+ new Response(response.body, {
155
+ status: response.status,
156
+ statusText: response.statusText,
157
+ headers: response.headers,
158
+ }),
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Fetch the cached Model Serving endpoint catalogue from
164
+ * `GET ${basePath}/models`. Returns every endpoint the plugin
165
+ * publishes (server-cached for ~5 minutes); callers filter to
166
+ * chat-capable models for a picker.
167
+ */
168
+ async models(signal?: AbortSignal): Promise<ServingEndpointSummary[]> {
169
+ const payload = await this.#getJson(
170
+ `${this.basePath}${routes.MASTRA_ROUTES.models}`,
171
+ wire.ServingEndpointsResponseSchema,
172
+ signal,
173
+ );
174
+ return payload.endpoints;
175
+ }
176
+
177
+ /**
178
+ * Fetch the curated starter questions for `agentId`'s Genie space
179
+ * from `GET ${basePath}/suggestions`. Empty when the agent has no
180
+ * Genie space (or it defines none).
181
+ */
182
+ async suggestions(agentId?: string, signal?: AbortSignal): Promise<string[]> {
183
+ const payload = await this.#getJson(
184
+ this.#agentScoped(routes.MASTRA_ROUTES.suggestions, agentId),
185
+ wire.MastraSuggestionsResponseSchema,
186
+ signal,
187
+ );
188
+ return payload.questions;
189
+ }
190
+
191
+ /**
192
+ * Fetch one page of thread history from `GET ${basePath}/history`.
193
+ * Messages come back oldest -> newest so the caller can prepend them
194
+ * to a live transcript.
195
+ */
196
+ async history(
197
+ options: {
198
+ agentId?: string;
199
+ page?: number;
200
+ perPage?: number;
201
+ signal?: AbortSignal;
202
+ } = {},
203
+ ): Promise<MastraHistoryResponse> {
204
+ const params = new URLSearchParams();
205
+ if (options.page !== undefined) params.set("page", String(options.page));
206
+ if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
207
+ const qs = params.toString();
208
+ const base = this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId);
209
+ return this.#getJson(
210
+ qs ? `${base}?${qs}` : base,
211
+ wire.MastraHistoryResponseSchema,
212
+ options.signal,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Wipe the caller's thread history (`DELETE ${basePath}/history`).
218
+ * The session cookie that anchors the thread id is preserved - only
219
+ * the messages go away. Idempotent: a fresh thread reports
220
+ * `cleared: 0` without erroring.
221
+ */
222
+ async clearHistory(
223
+ options: { agentId?: string; signal?: AbortSignal } = {},
224
+ ): Promise<MastraClearHistoryResponse> {
225
+ return this.#mutateJson(
226
+ this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId),
227
+ "DELETE",
228
+ wire.MastraClearHistoryResponseSchema,
229
+ options.signal ? { signal: options.signal } : {},
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Fetch one page of the caller's conversation threads from
235
+ * `GET ${basePath}/threads`, newest first. Used to render the
236
+ * conversation list / sidebar so the user can switch between the
237
+ * threads they own for this resource. Scoped server-side to the
238
+ * caller's resource id, so it only ever returns the user's own
239
+ * conversations.
240
+ */
241
+ async threads(
242
+ options: {
243
+ agentId?: string;
244
+ page?: number;
245
+ perPage?: number;
246
+ signal?: AbortSignal;
247
+ } = {},
248
+ ): Promise<MastraThreadsResponse> {
249
+ const params = new URLSearchParams();
250
+ if (options.page !== undefined) params.set("page", String(options.page));
251
+ if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
252
+ const qs = params.toString();
253
+ const base = this.#agentScoped(routes.MASTRA_ROUTES.threads, options.agentId);
254
+ return this.#getJson(
255
+ qs ? `${base}?${qs}` : base,
256
+ wire.MastraThreadsResponseSchema,
257
+ options.signal,
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Delete a single conversation thread by id via the plugin's own
263
+ * `DELETE ${basePath}/threads` route (named `removeThread` to avoid
264
+ * clashing with the inherited `MastraClient.deleteThread`, which hits
265
+ * Mastra's stock thread route rather than our OBO-scoped, custom
266
+ * mount). The id is sent as the thread-selection header for this one
267
+ * call (without disturbing the client's currently-selected thread,
268
+ * set via {@link setThreadId}), so the sidebar can remove any thread
269
+ * while the user stays on another. Idempotent: deleting an unknown /
270
+ * already-removed thread reports `deleted: false` without erroring.
271
+ */
272
+ async removeThread(
273
+ threadId: string,
274
+ options: { agentId?: string; signal?: AbortSignal } = {},
275
+ ): Promise<MastraDeleteThreadResponse> {
276
+ return this.#mutateJson(
277
+ this.#agentScoped(routes.MASTRA_ROUTES.threads, options.agentId),
278
+ "DELETE",
279
+ wire.MastraDeleteThreadResponseSchema,
280
+ {
281
+ headers: { [thread.THREAD_ID_HEADER]: threadId },
282
+ ...(options.signal ? { signal: options.signal } : {}),
283
+ },
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Rename a single conversation thread via the plugin's own
289
+ * `PATCH ${basePath}/threads` route. The id travels as the
290
+ * thread-selection header for this one call (mirroring
291
+ * {@link removeThread}, so the sidebar can rename any thread without
292
+ * disturbing the client's currently-selected thread) and the new
293
+ * `title` rides in the JSON body. The server enforces ownership and
294
+ * echoes back the updated thread, so the caller can reflect the new
295
+ * title immediately. Throws on an unknown / unowned thread (HTTP 404).
296
+ */
297
+ async renameThread(
298
+ threadId: string,
299
+ title: string,
300
+ options: { agentId?: string; signal?: AbortSignal } = {},
301
+ ): Promise<MastraUpdateThreadResponse> {
302
+ return this.#mutateJson(
303
+ this.#agentScoped(routes.MASTRA_ROUTES.threads, options.agentId),
304
+ "PATCH",
305
+ wire.MastraUpdateThreadResponseSchema,
306
+ {
307
+ body: { title },
308
+ headers: { [thread.THREAD_ID_HEADER]: threadId },
309
+ ...(options.signal ? { signal: options.signal } : {}),
310
+ },
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Log user feedback for a turn to `POST ${basePath}/route/feedback`.
316
+ * `traceId` is the `tr-<hex>` value the server sent on the stream
317
+ * response (via `MLFLOW_TRACE_ID_HEADER`) for that assistant message;
318
+ * `value` carries a thumbs (boolean) / rating, and/or `comment`
319
+ * carries freeform text. The server logs it as a HUMAN assessment on
320
+ * the trace, attributed to the signed-in user.
321
+ *
322
+ * Resolves with `{ ok: false }` (not a throw) when the assessment
323
+ * couldn't be recorded yet - most often because the trace is still
324
+ * exporting to MLflow - so callers can prompt a retry. Throws only on
325
+ * a transport / HTTP-level failure.
326
+ */
327
+ async feedback(input: MastraFeedbackRequest): Promise<MastraFeedbackResponse> {
328
+ return this.#mutateJson(
329
+ `${this.basePath}${routes.MASTRA_ROUTES.feedback}`,
330
+ "POST",
331
+ feedback.MastraFeedbackResponseSchema,
332
+ { body: input },
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Resolve a `[chart:<id>]` marker from
338
+ * `GET ${basePath}/embed/chart/:id`. The chart planner runs in the
339
+ * background, so the server long-polls the cache and returns the
340
+ * settled entry in one response. A 404 (unknown / expired id)
341
+ * resolves to `undefined` so the slot renders nothing.
342
+ */
343
+ chart(id: string, signal?: AbortSignal): Promise<Chart | undefined> {
344
+ return this.#embed("chart", id, wire.ChartSchema.parse, signal);
345
+ }
346
+
347
+ /**
348
+ * Resolve a `[data:<id>]` marker from
349
+ * `GET ${basePath}/embed/data/:id`. Returns the same coerced rows the
350
+ * `get_statement` tool produced for the model. A 404 resolves to
351
+ * `undefined`.
352
+ */
353
+ statement(id: string, signal?: AbortSignal): Promise<StatementData | undefined> {
354
+ return this.#embed("data", id, wire.StatementDataSchema.parse, signal);
355
+ }
356
+
357
+ /**
358
+ * Compose an agent-scoped URL: `${basePath}${segment}` for the
359
+ * default agent (the mount that does not require an `:agentId`), or
360
+ * `${basePath}${segment}/<encoded id>` for any other agent.
361
+ */
362
+ #agentScoped(segment: string, agentId: string | undefined): string {
363
+ const path = `${this.basePath}${segment}`;
364
+ const id = agentId ?? this.defaultAgent;
365
+ return !id || id === this.defaultAgent ? path : `${path}/${encodeURIComponent(id)}`;
366
+ }
367
+
368
+ /**
369
+ * Snapshot of the client's per-request override headers (model +
370
+ * thread selection) for the custom-route fetches that don't go
371
+ * through `@mastra/client-js`'s own request pipeline. Returns a fresh
372
+ * object each call so a caller can safely add one-off headers without
373
+ * mutating the shared `options.headers`. The thread-selection header
374
+ * here is what routes `history()` / `clearHistory()` / `threads()` to
375
+ * the currently-selected thread (see {@link setThreadId}).
376
+ */
377
+ #defaultHeaders(): Record<string, string> {
378
+ return { ...((this.options.headers as Record<string, string>) ?? {}) };
379
+ }
380
+
381
+ /** GET + JSON-parse + schema-validate against a route that always 200s. */
382
+ async #getJson<T>(
383
+ url: string,
384
+ schema: { parse: (raw: unknown) => T },
385
+ signal?: AbortSignal,
386
+ ): Promise<T> {
387
+ const init: RequestInit = {
388
+ credentials: "include",
389
+ headers: this.#defaultHeaders(),
390
+ };
391
+ if (signal) init.signal = signal;
392
+ const res = await fetch(url, init);
393
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
394
+ return schema.parse(await res.json());
395
+ }
396
+
397
+ /**
398
+ * `POST` / `DELETE` / `PATCH` + JSON-parse + schema-validate for the
399
+ * mutating routes (`clearHistory` / `removeThread` / `renameThread` /
400
+ * `feedback`). A JSON body, when present, sets `Content-Type`;
401
+ * `options.headers` add one-off headers (e.g. the thread-selection
402
+ * header for a targeted delete / rename) over the client's default
403
+ * override headers.
404
+ */
405
+ async #mutateJson<T>(
406
+ url: string,
407
+ method: "POST" | "DELETE" | "PATCH",
408
+ schema: { parse: (raw: unknown) => T },
409
+ options: {
410
+ body?: unknown;
411
+ headers?: Record<string, string>;
412
+ signal?: AbortSignal;
413
+ } = {},
414
+ ): Promise<T> {
415
+ const headers = { ...this.#defaultHeaders(), ...options.headers };
416
+ const init: RequestInit = { method, credentials: "include", headers };
417
+ if (options.body !== undefined) {
418
+ headers["Content-Type"] = "application/json";
419
+ init.body = JSON.stringify(options.body);
420
+ }
421
+ if (options.signal) init.signal = options.signal;
422
+ const res = await fetch(url, init);
423
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
424
+ return schema.parse(await res.json());
425
+ }
426
+
427
+ /**
428
+ * Single-shot fetch of an embed marker of kind `type`. A 404
429
+ * (unknown embed type, or unknown / expired id) resolves to
430
+ * `undefined` so the host treats it as a missing slot. Caching /
431
+ * long-poll retry policy is the caller's concern.
432
+ */
433
+ async #embed<T>(
434
+ type: string,
435
+ id: string,
436
+ parse: (raw: unknown) => T,
437
+ signal?: AbortSignal,
438
+ ): Promise<T | undefined> {
439
+ const url = `${this.basePath}${routes.MASTRA_ROUTES.embed}/${encodeURIComponent(
440
+ type,
441
+ )}/${encodeURIComponent(id)}`;
442
+ const init: RequestInit = { credentials: "include" };
443
+ if (signal) init.signal = signal;
444
+ const res = await fetch(url, init);
445
+ if (res.status === 404) return undefined;
446
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
447
+ return parse(await res.json());
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Read the Mastra plugin's `clientConfig()` payload (`basePath`,
453
+ * default agent, registered agent list). One call per render; values
454
+ * are cached at boot by `usePluginClientConfig`.
455
+ */
456
+ export const useMastraConfig = (): MastraClientConfig =>
457
+ usePluginClientConfig<MastraClientConfig>("mastra");
458
+
459
+ /**
460
+ * The single {@link MastraPluginClient} for the plugin, built once from
461
+ * the published `basePath`. Use it for everything: stream a turn with
462
+ * `client.getAgent(agentId).stream(...)`, page history with
463
+ * `client.history()`, resolve embeds with `client.chart(id)`, etc.
464
+ * Rebuilt only when `basePath` / `defaultAgent` change (e.g. a custom
465
+ * mount), so it's safe as a `useMemo` / `useCallback` / `useEffect`
466
+ * dependency; the per-request model override is applied in place via
467
+ * {@link MastraPluginClient.setModelOverride} without changing identity.
468
+ */
469
+ export const useMastraClient = (): MastraPluginClient => {
470
+ const config = useMastraConfig();
471
+ return useMemo(
472
+ () => new MastraPluginClient(config),
473
+ // `config` identity can churn across renders; the route layout is
474
+ // fully determined by these two scalars.
475
+ [config.basePath, config.defaultAgent],
476
+ );
477
+ };
478
+
479
+ /**
480
+ * Fetch the cached Model Serving endpoint catalogue exposed by the
481
+ * Mastra plugin (`client.models()`). Filters out non-LLM endpoints
482
+ * (anything without a `llm/v1/*` task) so the dropdown doesn't surface
483
+ * embedding / vision / agent-bricks endpoints. The response itself is
484
+ * server-cached for 5 minutes so polling cost is negligible.
485
+ *
486
+ * Pass `enabled: false` to skip the fetch entirely (e.g. when the
487
+ * model picker is hidden), in which case `models` stays empty.
488
+ */
489
+ export const useMastraModels = (
490
+ enabled = true,
491
+ ): {
492
+ models: ServingEndpointSummary[];
493
+ loading: boolean;
494
+ error: Error | null;
495
+ } => {
496
+ const client = useMastraClient();
497
+ const [models, setModels] = useState<ServingEndpointSummary[]>([]);
498
+ const [loading, setLoading] = useState(enabled);
499
+ const [error, setError] = useState<Error | null>(null);
500
+
501
+ useEffect(() => {
502
+ if (!enabled) {
503
+ setLoading(false);
504
+ return;
505
+ }
506
+ const controller = new AbortController();
507
+ setLoading(true);
508
+ setError(null);
509
+ client
510
+ .models(controller.signal)
511
+ .then((endpoints) => {
512
+ if (controller.signal.aborted) return;
513
+ // Filter to chat-capable endpoints, dropping embeddings (which
514
+ // carry the `llm/v1/embeddings` task) so the chat picker never
515
+ // lists a vectoriser. If the server didn't tag tasks at all,
516
+ // pass everything through so we don't show an empty list.
517
+ const llms = endpoints.filter(
518
+ (e) =>
519
+ e.task !== "llm/v1/embeddings" && (!e.task || e.task.startsWith("llm/v1/")),
520
+ );
521
+ setModels(llms.length > 0 ? llms : endpoints);
522
+ })
523
+ .catch((e: unknown) => {
524
+ if (controller.signal.aborted) return;
525
+ setError(errorUtil.toError(e));
526
+ })
527
+ .finally(() => {
528
+ if (!controller.signal.aborted) setLoading(false);
529
+ });
530
+ return () => controller.abort();
531
+ }, [client, enabled]);
532
+
533
+ return { models, loading, error };
534
+ };
535
+
536
+ /**
537
+ * Fetch the curated starter questions for an agent's Genie space via
538
+ * `client.suggestions(agentId)`. These are the `sample_questions` an
539
+ * author configured on the space, surfaced as one-tap prompts on the
540
+ * chat empty state. The server returns an empty list when the agent
541
+ * has no Genie space, so `questions` stays empty in that case and the
542
+ * UI shows a bare empty state rather than built-in example prompts.
543
+ *
544
+ * Pass `enabled: false` to skip the fetch (e.g. when the caller
545
+ * supplies its own explicit suggestion list), in which case
546
+ * `questions` stays empty. Any fetch error degrades silently to no
547
+ * suggestions - they're a non-critical enhancement.
548
+ */
549
+ export const useMastraSuggestions = (
550
+ agentId?: string,
551
+ enabled = true,
552
+ ): { questions: string[]; loading: boolean } => {
553
+ const client = useMastraClient();
554
+ const [questions, setQuestions] = useState<string[]>([]);
555
+ const [loading, setLoading] = useState(enabled);
556
+
557
+ useEffect(() => {
558
+ if (!enabled) {
559
+ setLoading(false);
560
+ setQuestions([]);
561
+ return;
562
+ }
563
+ const controller = new AbortController();
564
+ setLoading(true);
565
+ client
566
+ .suggestions(agentId, controller.signal)
567
+ .then((qs) => {
568
+ if (controller.signal.aborted) return;
569
+ setQuestions(Array.isArray(qs) ? qs : []);
570
+ })
571
+ .catch(() => {
572
+ // Non-critical: a failed lookup just means no starter prompts.
573
+ if (!controller.signal.aborted) setQuestions([]);
574
+ })
575
+ .finally(() => {
576
+ if (!controller.signal.aborted) setLoading(false);
577
+ });
578
+ return () => controller.abort();
579
+ }, [client, agentId, enabled]);
580
+
581
+ return { questions, loading };
582
+ };
583
+
584
+ /**
585
+ * Fetch (and re-fetch) the caller's conversation threads for an agent
586
+ * via `client.threads(agentId)`. Drives the conversation list / sidebar
587
+ * so the user can switch between the threads they own. Newest first
588
+ * (`updatedAt` DESC), scoped server-side to the caller's resource.
589
+ *
590
+ * Returns a `refresh()` the chat driver calls after a turn completes
591
+ * (a new thread appears, or its auto-generated title lands) and after a
592
+ * clear / delete, so the list stays in sync without polling. Pass
593
+ * `enabled: false` to skip the fetch entirely (e.g. when the sidebar is
594
+ * hidden), in which case `threads` stays empty. A failed fetch degrades
595
+ * to an empty list - the conversation list is a non-critical
596
+ * enhancement layered over the always-available default thread.
597
+ */
598
+ export const useMastraThreads = (
599
+ agentId?: string,
600
+ enabled = true,
601
+ ): {
602
+ threads: MastraThread[];
603
+ loading: boolean;
604
+ error: Error | null;
605
+ refresh: () => void;
606
+ } => {
607
+ const client = useMastraClient();
608
+ const [threads, setThreads] = useState<MastraThread[]>([]);
609
+ const [loading, setLoading] = useState(enabled);
610
+ const [error, setError] = useState<Error | null>(null);
611
+ // Bumped by `refresh()` to force a re-fetch without changing any of
612
+ // the natural deps (client / agentId / enabled).
613
+ const [nonce, setNonce] = useState(0);
614
+ const refresh = useCallback(() => setNonce((n) => n + 1), []);
615
+
616
+ useEffect(() => {
617
+ if (!enabled) {
618
+ setLoading(false);
619
+ setThreads([]);
620
+ return;
621
+ }
622
+ const controller = new AbortController();
623
+ setLoading(true);
624
+ client
625
+ .threads({ agentId, signal: controller.signal })
626
+ .then((res) => {
627
+ if (controller.signal.aborted) return;
628
+ setThreads(res.threads);
629
+ setError(null);
630
+ })
631
+ .catch((e: unknown) => {
632
+ if (controller.signal.aborted || (e as { name?: string }).name === "AbortError")
633
+ return;
634
+ setError(errorUtil.toError(e));
635
+ setThreads([]);
636
+ })
637
+ .finally(() => {
638
+ if (!controller.signal.aborted) setLoading(false);
639
+ });
640
+ return () => controller.abort();
641
+ }, [client, agentId, enabled, nonce]);
642
+
643
+ return { threads, loading, error, refresh };
644
+ };
645
+
646
+ /**
647
+ * Hook state for {@link useByIdFetch}. Generic over the
648
+ * resource's payload shape; consumers read `data` / `loading` /
649
+ * `error` exactly as the chart and statement slots do. A 404 on
650
+ * the server resolves as `data === undefined` with `error ===
651
+ * null` so the slot renders nothing (matches "expired /
652
+ * unknown id" semantics across all by-id resources).
653
+ */
654
+ export interface ByIdFetchState<T> {
655
+ data: T | undefined;
656
+ loading: boolean;
657
+ error: Error | null;
658
+ }
659
+
660
+ /**
661
+ * Module-level by-key cache for `/<resource>/:id` fetches.
662
+ *
663
+ * Statements are immutable by construction (a `statement_id`
664
+ * always materializes the same rows), and a settled chart entry
665
+ * (`result` or `error` set) never changes either, so caching for
666
+ * the tab's lifetime is safe. The cache solves two problems the
667
+ * chat UI hits constantly:
668
+ *
669
+ * 1. Re-mount churn: streaming assistant text re-renders the
670
+ * markdown on every chunk, React StrictMode double-mounts
671
+ * effects in dev, and any parent re-render can shift slot
672
+ * identity. Without a cache, each remount cancels the
673
+ * in-flight fetch and starts another. With it, remounts hit
674
+ * the cache synchronously - zero network, zero spinner flash.
675
+ * 2. Concurrent consumers: two slots pointing at the same id
676
+ * (e.g. user toggles a thread that already rendered the
677
+ * same chart in history) dedupe to a single in-flight
678
+ * promise instead of racing parallel fetches.
679
+ *
680
+ * Two-state entry shape: `pending` while a fetch is in flight,
681
+ * `ready` once it settles (including 404 -> `data: undefined`).
682
+ * Errors are NOT cached - the entry is deleted on rejection so
683
+ * the next consumer retries. Per-consumer cancellation only
684
+ * detaches that consumer from the promise; the fetch keeps
685
+ * running for any other live consumer. Page reload clears
686
+ * everything. Keyed by `<type>:<id>` (e.g. `chart:abc123`).
687
+ */
688
+ type FetchCacheEntry<T> =
689
+ | { kind: "pending"; promise: Promise<T | undefined> }
690
+ | { kind: "ready"; data: T | undefined };
691
+
692
+ const fetchCache = new Map<string, FetchCacheEntry<unknown>>();
693
+
694
+ /**
695
+ * Resolve `key` through the by-id cache. See {@link fetchCache}
696
+ * for the lifecycle. `fetcher` performs the underlying single-shot
697
+ * request ({@link MastraPluginClient.chart} /
698
+ * {@link MastraPluginClient.statement}), resolving to `undefined` on a
699
+ * 404. `isTerminal` lets callers opt out of caching responses that
700
+ * aren't actually settled yet (e.g. a chart whose planner is still
701
+ * working): only `true` answers land as `kind: "ready"`, everything
702
+ * else is dropped so the next mount refetches.
703
+ *
704
+ * 404s always cache (as `data: undefined`) - unknown ids stay
705
+ * unknown for the tab's lifetime, mirroring the slot semantics
706
+ * ("expired / never minted -> render nothing").
707
+ */
708
+ async function fetchByIdCached<T>(
709
+ key: string,
710
+ fetcher: () => Promise<T | undefined>,
711
+ isTerminal: (data: T) => boolean,
712
+ ): Promise<T | undefined> {
713
+ const existing = fetchCache.get(key) as FetchCacheEntry<T> | undefined;
714
+ if (existing?.kind === "ready") return existing.data;
715
+ if (existing?.kind === "pending") return existing.promise;
716
+
717
+ const promise = (async (): Promise<T | undefined> => {
718
+ const data = await fetcher();
719
+ if (data === undefined) {
720
+ fetchCache.set(key, { kind: "ready", data: undefined });
721
+ return undefined;
722
+ }
723
+ if (isTerminal(data)) {
724
+ fetchCache.set(key, { kind: "ready", data });
725
+ } else {
726
+ fetchCache.delete(key);
727
+ }
728
+ return data;
729
+ })().catch((err: unknown) => {
730
+ fetchCache.delete(key);
731
+ throw err;
732
+ });
733
+
734
+ fetchCache.set(key, { kind: "pending", promise } as FetchCacheEntry<unknown>);
735
+ return promise;
736
+ }
737
+
738
+ /** Synchronous cache peek used to seed initial state on mount. */
739
+ function readFetchCache<T>(key: string | undefined): T | undefined {
740
+ if (!key) return undefined;
741
+ const e = fetchCache.get(key) as FetchCacheEntry<T> | undefined;
742
+ return e?.kind === "ready" ? e.data : undefined;
743
+ }
744
+
745
+ /**
746
+ * React hook for any resource the Mastra plugin exposes as
747
+ * `/<resource>/:id`. Powers both the chart and statement slots
748
+ * in the chat UI; the slot-specific hooks below are thin
749
+ * adapters that pick the right cache `key`, `fetcher`, and
750
+ * terminal check.
751
+ *
752
+ * Cached through {@link fetchByIdCached} - re-mounts (StrictMode,
753
+ * markdown re-parses during streaming, parent re-renders) hit
754
+ * the cache synchronously instead of re-firing the network call.
755
+ * Per-consumer unmount only detaches that consumer from the
756
+ * shared promise; the underlying fetch keeps running for any
757
+ * other live consumer.
758
+ *
759
+ * `key`, `fetcher`, and `isTerminal` must be referentially stable
760
+ * (memoized by the caller) so the effect doesn't refire every render.
761
+ */
762
+ function useByIdFetch<T>(
763
+ key: string | undefined,
764
+ fetcher: () => Promise<T | undefined>,
765
+ isTerminal: (data: T) => boolean,
766
+ ): ByIdFetchState<T> {
767
+ const [data, setData] = useState<T | undefined>(() => readFetchCache<T>(key));
768
+ const [loading, setLoading] = useState(
769
+ () => key !== undefined && readFetchCache<T>(key) === undefined,
770
+ );
771
+ const [error, setError] = useState<Error | null>(null);
772
+
773
+ useEffect(() => {
774
+ if (!key) {
775
+ setData(undefined);
776
+ setLoading(false);
777
+ setError(null);
778
+ return;
779
+ }
780
+ const cached = readFetchCache<T>(key);
781
+ if (cached !== undefined) {
782
+ setData(cached);
783
+ setLoading(false);
784
+ setError(null);
785
+ return;
786
+ }
787
+ let cancelled = false;
788
+ setData(undefined);
789
+ setError(null);
790
+ setLoading(true);
791
+
792
+ fetchByIdCached<T>(key, fetcher, isTerminal)
793
+ .then((payload) => {
794
+ if (cancelled) return;
795
+ setData(payload);
796
+ setLoading(false);
797
+ })
798
+ .catch((e: unknown) => {
799
+ if (cancelled) return;
800
+ setError(errorUtil.toError(e));
801
+ setLoading(false);
802
+ });
803
+
804
+ return () => {
805
+ cancelled = true;
806
+ };
807
+ }, [key, fetcher, isTerminal]);
808
+
809
+ return { data, loading, error };
810
+ }
811
+
812
+ /**
813
+ * `isTerminal` for {@link useChartFetch}. A chart entry is
814
+ * "ready to cache forever" only once the planner has either
815
+ * landed an Echarts spec (`result`) or surfaced a failure
816
+ * (`error`). Entries with neither field are pathologically slow
817
+ * planners that exceeded the server's long-poll budget; leaving
818
+ * them uncached means a re-mount can retry instead of being
819
+ * stuck on a stale "in-flight" snapshot for the tab's lifetime.
820
+ *
821
+ * Module-level so the {@link useByIdFetch} effect's dep array
822
+ * stays stable - inlining a fresh closure would refire the
823
+ * effect on every render.
824
+ */
825
+ const chartIsTerminal = (c: Chart): boolean =>
826
+ c.result !== undefined || c.error !== undefined;
827
+
828
+ /**
829
+ * `isTerminal` for {@link useStatementFetch}. A statement
830
+ * response is always terminal - the Statement Execution API
831
+ * returns the rows in one shot, so any 200 is cacheable.
832
+ */
833
+ const statementIsTerminal = (_: StatementData): boolean => true;
834
+
835
+ /**
836
+ * Fetch a chart by id from the Mastra plugin's generic
837
+ * `/embed/chart/:id` endpoint via {@link MastraPluginClient.chart}.
838
+ * Used by the chat UI to resolve `[chart:<chartId>]` markers the agent
839
+ * embeds in prose.
840
+ *
841
+ * The chart planner runs in the background after `prepare_chart`
842
+ * mints the id, so the server long-polls the cache until the
843
+ * entry settles (`result` or `error` set) and returns the final
844
+ * payload in a single response. The hook performs ONE cached
845
+ * fetch and surfaces:
846
+ *
847
+ * - `data.result` set → render the Echarts spec.
848
+ * - `data.error` set → surface via `data.error`.
849
+ * - `data` with neither field (planner exceeded the server's
850
+ * long-poll budget) → render nothing; this answer is NOT
851
+ * cached, so a re-mount will retry.
852
+ * - `data === undefined` (404) → unknown / expired id; render
853
+ * nothing.
854
+ */
855
+ export const useChartFetch = (chartId: string | undefined): ByIdFetchState<Chart> => {
856
+ const client = useMastraClient();
857
+ const key = chartId ? `chart:${chartId}` : undefined;
858
+ const fetcher = useCallback(() => client.chart(chartId as string), [client, chartId]);
859
+ return useByIdFetch<Chart>(key, fetcher, chartIsTerminal);
860
+ };
861
+
862
+ /**
863
+ * Fetch the rows of a Databricks statement by id from the Mastra
864
+ * plugin's generic `/embed/data/:id` endpoint via
865
+ * {@link MastraPluginClient.statement}. Used by the chat UI to resolve
866
+ * `[data:<statement_id>]` markers the agent embeds in prose.
867
+ * Server-side, the route reuses the `get_statement` tool's fetch +
868
+ * coercion pipeline so the shape matches what the LLM saw for the same
869
+ * statement; `data.truncated` signals the server clipped to its row
870
+ * cap. Cached for the tab's lifetime because a `statement_id`
871
+ * materializes the same rows forever.
872
+ */
873
+ export const useStatementFetch = (
874
+ statementId: string | undefined,
875
+ ): ByIdFetchState<StatementData> => {
876
+ const client = useMastraClient();
877
+ const key = statementId ? `data:${statementId}` : undefined;
878
+ const fetcher = useCallback(
879
+ () => client.statement(statementId as string),
880
+ [client, statementId],
881
+ );
882
+ return useByIdFetch<StatementData>(key, fetcher, statementIsTerminal);
883
+ };