@dbx-tools/ui-mastra 0.9.128 → 0.9.131

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.
@@ -67,6 +67,7 @@ export interface ExportBrand {
67
67
  export interface EmbedResolver {
68
68
  chart(id: string): Promise<Chart | undefined>;
69
69
  statement(id: string): Promise<StatementData | undefined>;
70
+ visualization(id: string): Promise<StatementData | undefined>;
70
71
  }
71
72
 
72
73
  /** Options accepted by {@link exportChat}. */
@@ -185,14 +186,15 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
185
186
 
186
187
  /** One slice of a message: prose, a chart embed, or a data embed. */
187
188
  type Segment =
188
- { kind: "text"; text: string } | { kind: "chart"; id: string } | { kind: "data"; id: string };
189
+ | { kind: "text"; text: string }
190
+ | { kind: "chart"; id: string }
191
+ | { kind: "data"; id: string }
192
+ | { kind: "visualization"; id: string };
189
193
 
190
194
  /**
191
- * Split message text into prose / chart / data segments at
192
- * `[chart:<uuid>]` / `[data:<uuid>]` marker positions. Mirrors the live
193
- * `MarkdownWithEmbeds` splitter so an export matches what's on screen:
194
- * non-UUID (fabricated) ids and unknown marker types collapse away so no
195
- * literal `[type:...]` leaks into the output.
195
+ * Split message text into prose, chart, and data segments. A native Genie
196
+ * visualization exports as its backing table because the interactive AppKit
197
+ * chart has no serializable ECharts spec.
196
198
  */
197
199
  function splitSegments(text: string): Segment[] {
198
200
  const segments: Segment[] = [];
@@ -201,8 +203,12 @@ function splitSegments(text: string): Segment[] {
201
203
  if (marker.start > last) {
202
204
  segments.push({ kind: "text", text: text.slice(last, marker.start) });
203
205
  }
204
- if (markers.isUuid(marker.id) && (marker.type === "chart" || marker.type === "data")) {
205
- segments.push({ kind: marker.type, id: marker.id });
206
+ if (markers.isUuid(marker.id) && marker.type === "chart") {
207
+ segments.push({ kind: "chart", id: marker.id });
208
+ } else if (markers.isUuid(marker.id) && marker.type === "data") {
209
+ segments.push({ kind: "data", id: marker.id });
210
+ } else if (markers.isUuid(marker.id) && marker.type === "visualization") {
211
+ segments.push({ kind: "visualization", id: marker.id });
206
212
  }
207
213
  last = marker.end;
208
214
  }
@@ -281,7 +287,7 @@ async function messageBodyHtml(message: UIMessage, resolver: EmbedResolver): Pro
281
287
  if (svg) parts.push(`<div class="embed embed-chart">${svg}</div>`);
282
288
  continue;
283
289
  }
284
- const table = await dataTableHtml(resolver, seg.id);
290
+ const table = await dataTableHtml(resolver, seg.id, seg.kind === "visualization");
285
291
  if (table) parts.push(table);
286
292
  }
287
293
  return parts.join("\n");
@@ -332,8 +338,12 @@ async function chartSvg(resolver: EmbedResolver, id: string): Promise<string | n
332
338
  }
333
339
 
334
340
  /** Resolve a data id and render its rows to an HTML table. `null` on miss. */
335
- async function dataTableHtml(resolver: EmbedResolver, id: string): Promise<string | null> {
336
- const data = await safeStatement(resolver, id);
341
+ async function dataTableHtml(
342
+ resolver: EmbedResolver,
343
+ id: string,
344
+ visualization = false,
345
+ ): Promise<string | null> {
346
+ const data = await safeStatement(resolver, id, visualization);
337
347
  if (!data || data.rows.length === 0) return null;
338
348
  const head = data.columns.map((c) => `<th>${escapeHtml(stringUtils.toLabel(c))}</th>`).join("");
339
349
  const body = data.rows
@@ -383,7 +393,7 @@ async function messageBodyMarkdown(message: UIMessage, resolver: EmbedResolver):
383
393
  if (chart?.result) parts.push(`> **Chart:** ${chartTitle(chart)}`);
384
394
  continue;
385
395
  }
386
- const table = await dataTableMarkdown(resolver, seg.id);
396
+ const table = await dataTableMarkdown(resolver, seg.id, seg.kind === "visualization");
387
397
  if (table) parts.push(table);
388
398
  }
389
399
  return parts.join("\n\n");
@@ -400,8 +410,12 @@ function chartTitle(chart: Chart): string {
400
410
  }
401
411
 
402
412
  /** Render statement rows to a GFM table. `null` on miss / empty. */
403
- async function dataTableMarkdown(resolver: EmbedResolver, id: string): Promise<string | null> {
404
- const data = await safeStatement(resolver, id);
413
+ async function dataTableMarkdown(
414
+ resolver: EmbedResolver,
415
+ id: string,
416
+ visualization = false,
417
+ ): Promise<string | null> {
418
+ const data = await safeStatement(resolver, id, visualization);
405
419
  if (!data || data.rows.length === 0) return null;
406
420
  const header = `| ${data.columns.map((c) => mdCell(stringUtils.toLabel(c))).join(" | ")} |`;
407
421
  const sep = `| ${data.columns.map(() => "---").join(" | ")} |`;
@@ -427,9 +441,10 @@ async function safeChart(resolver: EmbedResolver, id: string): Promise<Chart | u
427
441
  async function safeStatement(
428
442
  resolver: EmbedResolver,
429
443
  id: string,
444
+ visualization = false,
430
445
  ): Promise<StatementData | undefined> {
431
446
  try {
432
- return await resolver.statement(id);
447
+ return await (visualization ? resolver.visualization(id) : resolver.statement(id));
433
448
  } catch {
434
449
  return undefined;
435
450
  }
@@ -0,0 +1,220 @@
1
+ import { routes, wire, type MastraClientConfig } from "@dbx-tools/shared-mastra";
2
+ import type {
3
+ ListAgentSuspendedRunsResponse,
4
+ ListMemoryThreadMessagesResponse,
5
+ ListMemoryThreadsResponse,
6
+ } from "@mastra/client-js";
7
+ import { validateUIMessages, type UIMessage, type UIMessageChunk } from "ai";
8
+ import { MastraPluginClient, type MastraStreamResponse } from "./mastra-client.ts";
9
+
10
+ /**
11
+ * {@link MastraPluginClient} that talks to one Genie space directly through
12
+ * the Mastra plugin's `/genie/:alias` routes instead of an agent and Mastra
13
+ * Memory. Genie owns all state: its conversations are the threads (the
14
+ * conversation id is the thread id), its messages are the history, and each
15
+ * turn streams the same AI SDK chunks as an agent's `ask_genie` call, so the
16
+ * Mastra chat renders it unchanged.
17
+ *
18
+ * A brand-new thread has no Genie conversation until its first turn; the
19
+ * stream announces the conversation id, which {@link MastraStreamResponse.threadId}
20
+ * reports so the chat adopts it.
21
+ */
22
+ export class GenieChatClient extends MastraPluginClient {
23
+ /** Genie space alias every route is scoped to. */
24
+ readonly alias: string;
25
+ override readonly backgroundTurns = false;
26
+ override readonly feedbackEnabled = false;
27
+ override readonly threadRename = false;
28
+ override readonly attachments = false;
29
+ /** Thread ids known to be Genie conversations (listed, loaded, or announced). */
30
+ readonly #conversations = new Set<string>();
31
+ /** Transcript message id to Genie message id, for regenerate cleanup. */
32
+ readonly #genieMessageIds = new Map<string, string>();
33
+
34
+ constructor(config: MastraClientConfig, alias: string) {
35
+ super(config);
36
+ this.alias = alias;
37
+ }
38
+
39
+ #url(...parts: string[]): string {
40
+ return `${this.basePath}${routes.genieRoutePath(this.alias, ...parts)}`;
41
+ }
42
+
43
+ async #delete(url: string, signal?: AbortSignal): Promise<void> {
44
+ const res = await fetch(url, {
45
+ method: "DELETE",
46
+ credentials: "include",
47
+ ...(signal ? { signal } : {}),
48
+ });
49
+ if (!res.ok && res.status !== 404) throw new Error(`HTTP ${res.status}`);
50
+ }
51
+
52
+ /** Stream one Genie turn, continuing the thread's conversation when it has one. */
53
+ override async streamAgent(params: {
54
+ messages: UIMessage[];
55
+ runId: string;
56
+ threadId?: string;
57
+ signal?: AbortSignal;
58
+ }): Promise<MastraStreamResponse> {
59
+ const userMessage = [...params.messages].reverse().find((message) => message.role === "user");
60
+ const question = (userMessage?.parts ?? [])
61
+ .map((part) => (part.type === "text" ? part.text : ""))
62
+ .join("")
63
+ .trim();
64
+ const conversationId =
65
+ params.threadId && this.#conversations.has(params.threadId) ? params.threadId : undefined;
66
+ const response = await this.uiStream({
67
+ path: this.#url(routes.MASTRA_ROUTES.chat),
68
+ chatId: params.threadId ?? params.runId,
69
+ messages: userMessage ? [userMessage] : [],
70
+ body: { question, ...(conversationId ? { conversationId } : {}) },
71
+ routing: {},
72
+ signal: params.signal,
73
+ });
74
+ let resolved: string | undefined;
75
+ const stream = response.stream.pipeThrough(
76
+ new TransformStream<UIMessageChunk, UIMessageChunk>({
77
+ transform: (chunk, controller) => {
78
+ if (chunk.type === wire.GENIE_CONVERSATION_PART_TYPE) {
79
+ const data = wire.GenieConversationPartDataSchema.safeParse(chunk.data);
80
+ if (data.success) {
81
+ resolved = data.data.conversationId;
82
+ this.#conversations.add(resolved);
83
+ if (userMessage && data.data.messageId) {
84
+ this.#genieMessageIds.set(userMessage.id, data.data.messageId);
85
+ }
86
+ }
87
+ }
88
+ controller.enqueue(chunk);
89
+ },
90
+ }),
91
+ );
92
+ return { headers: response.headers, stream, threadId: () => resolved };
93
+ }
94
+
95
+ /** Genie turns are request-scoped; there is no background run to observe. */
96
+ override async observeAgentStream(): Promise<MastraStreamResponse> {
97
+ throw new Error("Direct Genie chat has no background runs");
98
+ }
99
+
100
+ /** Genie turns stop when their stream is aborted. */
101
+ override async abortAgentRun(): Promise<boolean> {
102
+ return false;
103
+ }
104
+
105
+ /** Genie has no approval-gated tools. */
106
+ override async suspendedRuns(): Promise<ListAgentSuspendedRunsResponse> {
107
+ return { runs: [], total: 0 };
108
+ }
109
+
110
+ /** Starter questions for this client's Genie space. */
111
+ override async suggestions(_agentId?: string, signal?: AbortSignal): Promise<string[]> {
112
+ const payload = await this.getJson(
113
+ this.#url(routes.MASTRA_ROUTES.suggestions),
114
+ wire.MastraSuggestionsResponseSchema,
115
+ signal,
116
+ );
117
+ return payload.questions;
118
+ }
119
+
120
+ /** List the caller's Genie conversations as chat threads. */
121
+ override async threads(
122
+ options: { signal?: AbortSignal } = {},
123
+ ): Promise<ListMemoryThreadsResponse> {
124
+ const payload = await this.getJson(
125
+ this.#url(routes.MASTRA_ROUTES.threads),
126
+ wire.GenieThreadsResponseSchema,
127
+ options.signal,
128
+ );
129
+ for (const thread of payload.threads) this.#conversations.add(thread.id);
130
+ const threads = payload.threads.map((thread) => ({
131
+ id: thread.id,
132
+ ...(thread.title ? { title: thread.title } : {}),
133
+ resourceId: "",
134
+ createdAt: thread.createdAt,
135
+ updatedAt: thread.createdAt,
136
+ metadata: {},
137
+ }));
138
+ return {
139
+ threads,
140
+ total: threads.length,
141
+ page: 0,
142
+ perPage: threads.length,
143
+ hasMore: false,
144
+ } satisfies ListMemoryThreadsResponse;
145
+ }
146
+
147
+ /**
148
+ * Load a Genie conversation's full transcript. Genie returns the whole
149
+ * conversation at once, so only the first page carries messages.
150
+ */
151
+ override async history(options: {
152
+ threadId: string;
153
+ page?: number;
154
+ signal?: AbortSignal;
155
+ }): Promise<ListMemoryThreadMessagesResponse> {
156
+ const page = options.page ?? 0;
157
+ let messages: UIMessage[] = [];
158
+ if (page === 0) {
159
+ const payload = await this.getJson(
160
+ this.#url(routes.MASTRA_ROUTES.threads, options.threadId, routes.MASTRA_ROUTES.messages),
161
+ wire.GenieThreadMessagesResponseSchema,
162
+ options.signal,
163
+ );
164
+ messages = await validateUIMessages({ messages: payload.messages });
165
+ this.#conversations.add(options.threadId);
166
+ // History ids already address their Genie message; the delete route
167
+ // resolves a question id to the message that owns it.
168
+ for (const message of messages) this.#genieMessageIds.set(message.id, message.id);
169
+ }
170
+ return {
171
+ messages: [],
172
+ uiMessages: [...messages].reverse(),
173
+ total: messages.length,
174
+ page,
175
+ perPage: messages.length,
176
+ hasMore: false,
177
+ } satisfies ListMemoryThreadMessagesResponse;
178
+ }
179
+
180
+ /** Delete the Genie conversation behind a thread. */
181
+ override async removeThread(
182
+ threadId: string,
183
+ options: { signal?: AbortSignal } = {},
184
+ ): Promise<void> {
185
+ if (!this.#conversations.has(threadId)) return;
186
+ await this.#delete(this.#url(routes.MASTRA_ROUTES.threads, threadId), options.signal);
187
+ this.#conversations.delete(threadId);
188
+ }
189
+
190
+ /** Clearing a Genie thread deletes its conversation; the next turn starts a new one. */
191
+ override async clearHistory(options: { threadId: string; signal?: AbortSignal }): Promise<void> {
192
+ await this.removeThread(options.threadId, options);
193
+ }
194
+
195
+ /** Genie conversations cannot be renamed. */
196
+ override async renameThread(): Promise<void> {
197
+ throw new Error("Genie conversations cannot be renamed");
198
+ }
199
+
200
+ /** Delete the Genie messages behind the given transcript messages. */
201
+ override async deleteMessages(
202
+ threadId: string,
203
+ messageIds: string[],
204
+ options: { signal?: AbortSignal } = {},
205
+ ): Promise<void> {
206
+ if (!this.#conversations.has(threadId)) return;
207
+ const genieIds = new Set(
208
+ messageIds.flatMap((id) => {
209
+ const genieId = this.#genieMessageIds.get(id);
210
+ return genieId ? [genieId] : [];
211
+ }),
212
+ );
213
+ for (const genieId of genieIds) {
214
+ await this.#delete(
215
+ this.#url(routes.MASTRA_ROUTES.threads, threadId, routes.MASTRA_ROUTES.messages, genieId),
216
+ options.signal,
217
+ );
218
+ }
219
+ }
220
+ }
@@ -8,10 +8,12 @@ export * as chartTheme from "./chart-theme.ts";
8
8
  export * as clipboard from "./clipboard.ts";
9
9
  export * as download from "./download.ts";
10
10
  export * as exportModule from "./export.ts";
11
+ export * as genieClient from "./genie-client.ts";
11
12
  export * as mastraClient from "./mastra-client.ts";
12
13
  export * as modelSelection from "./model-selection.ts";
13
14
  export * as requestContext from "./request-context.ts";
14
15
  export * as threadLabels from "./thread-labels.ts";
16
+ export * as threadPersistence from "./thread-persistence.ts";
15
17
  export * as threadSessions from "./thread-sessions.ts";
16
18
  export * as threadTabs from "./thread-tabs.ts";
17
19
  export * as toolEvents from "./tool-events.ts";
@@ -20,7 +22,8 @@ export type { StoredActiveRun } from "./active-runs.ts";
20
22
  export { LIGHT_CHART_CHROME } from "./chart-theme.ts";
21
23
  export type { ChartChrome } from "./chart-theme.ts";
22
24
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./export.ts";
23
- export { MastraChatRequestError, MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./mastra-client.ts";
25
+ export { GenieChatClient } from "./genie-client.ts";
26
+ export { MastraChatRequestError, MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch, useVisualizationFetch } from "./mastra-client.ts";
24
27
  export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from "./mastra-client.ts";
25
28
  export { defaultReasoningEffort, modelStorageKey, readStoredModel, storeSelectedModel } from "./model-selection.ts";
26
29
  export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./request-context.ts";
@@ -31,6 +31,12 @@ type NativeAgentClient = ReturnType<MastraClient["getAgent"]>;
31
31
  export interface MastraStreamResponse {
32
32
  headers: Headers;
33
33
  stream: ReadableStream<UIMessageChunk>;
34
+ /**
35
+ * Thread id the server assigned to this turn, read after the stream drains.
36
+ * Set by backends whose threads are server-minted (a Genie conversation);
37
+ * the chat adopts it in place of the client-minted id it streamed under.
38
+ */
39
+ threadId?: () => string | undefined;
34
40
  }
35
41
 
36
42
  /** Terminal HTTP failure returned before a chat stream was established. */
@@ -56,7 +62,8 @@ export type MastraMemoryThread = ListMemoryThreadsResponse["threads"][number];
56
62
  * - Native Mastra memory history and thread management, plus the model
57
63
  * catalogue (`models`),
58
64
  * Genie starter prompts (`suggestions`), MLflow feedback logging
59
- * (`feedback`), and inline embed resolution (`chart` / `statement`)
65
+ * (`feedback`), and inline embed resolution (`chart`, `statement`,
66
+ * `visualization`)
60
67
  * over the plugin's own routes - all derived from `basePath` +
61
68
  * {@link routes.MASTRA_ROUTES}.
62
69
  *
@@ -89,6 +96,10 @@ export class MastraPluginClient extends MastraClient {
89
96
  readonly chatAlwaysAvailable: boolean;
90
97
  /** Whether turns continue server-side after the initiating stream disconnects. */
91
98
  readonly backgroundTurns: boolean;
99
+ /** Whether {@link renameThread} is supported by this client's thread store. */
100
+ readonly threadRename: boolean = true;
101
+ /** Whether a chat may attach files to a turn. */
102
+ readonly attachments: boolean = true;
92
103
 
93
104
  constructor(config: MastraClientConfig) {
94
105
  super({
@@ -124,7 +135,7 @@ export class MastraPluginClient extends MastraClient {
124
135
  return headers;
125
136
  }
126
137
 
127
- async #uiStream(options: {
138
+ protected async uiStream(options: {
128
139
  path: string;
129
140
  chatId: string;
130
141
  messages: UIMessage[];
@@ -219,7 +230,7 @@ export class MastraPluginClient extends MastraClient {
219
230
  requestContext?: MastraRequestContextSnapshot;
220
231
  signal?: AbortSignal;
221
232
  }): Promise<MastraStreamResponse> {
222
- return this.#uiStream({
233
+ return this.uiStream({
223
234
  path: `${this.basePath}${routes.MASTRA_ROUTES.chat}/${encodeURIComponent(params.agentId)}`,
224
235
  chatId: params.threadId ?? params.runId,
225
236
  messages: params.messages,
@@ -320,7 +331,7 @@ export class MastraPluginClient extends MastraClient {
320
331
  signal?: AbortSignal;
321
332
  },
322
333
  ): Promise<MastraStreamResponse> {
323
- return this.#uiStream({
334
+ return this.uiStream({
324
335
  path: `${this.basePath}${routes.MASTRA_ROUTES.chat}/${encodeURIComponent(agentId)}`,
325
336
  chatId: params.threadId ?? params.runId,
326
337
  messages: [],
@@ -345,7 +356,7 @@ export class MastraPluginClient extends MastraClient {
345
356
  * chat-capable models for a picker.
346
357
  */
347
358
  async models(signal?: AbortSignal): Promise<ServingEndpointSummary[]> {
348
- const payload = await this.#getJson(
359
+ const payload = await this.getJson(
349
360
  `${this.basePath}${routes.MASTRA_ROUTES.models}`,
350
361
  wire.ServingEndpointsResponseSchema,
351
362
  signal,
@@ -369,7 +380,7 @@ export class MastraPluginClient extends MastraClient {
369
380
 
370
381
  /** Fetch the complete resolved default-model descriptor for an agent. */
371
382
  async defaultModelDetails(agentId?: string, signal?: AbortSignal): Promise<DefaultModelResponse> {
372
- const payload = await this.#getJson(
383
+ const payload = await this.getJson(
373
384
  this.#agentScoped(routes.MASTRA_ROUTES.defaultModel, agentId),
374
385
  wire.DefaultModelResponseSchema,
375
386
  signal,
@@ -383,7 +394,7 @@ export class MastraPluginClient extends MastraClient {
383
394
  * Genie space (or it defines none).
384
395
  */
385
396
  async suggestions(agentId?: string, signal?: AbortSignal): Promise<string[]> {
386
- const payload = await this.#getJson(
397
+ const payload = await this.getJson(
387
398
  this.#agentScoped(routes.MASTRA_ROUTES.suggestions, agentId),
388
399
  wire.MastraSuggestionsResponseSchema,
389
400
  signal,
@@ -513,7 +524,7 @@ export class MastraPluginClient extends MastraClient {
513
524
  * `CAN_MANAGE`; inability to determine access resolves to `null`.
514
525
  */
515
526
  async mlflowExperiment(signal?: AbortSignal): Promise<MastraMlflowExperimentResponse> {
516
- return this.#getJson(
527
+ return this.getJson(
517
528
  `${this.basePath}${routes.MASTRA_ROUTES.mlflowExperiment}`,
518
529
  feedback.MastraMlflowExperimentResponseSchema,
519
530
  signal,
@@ -541,6 +552,11 @@ export class MastraPluginClient extends MastraClient {
541
552
  return this.#embed("data", id, wire.StatementDataSchema.parse, signal);
542
553
  }
543
554
 
555
+ /** Resolve AppKit UI visualization data for a direct Genie statement. */
556
+ visualization(id: string, signal?: AbortSignal): Promise<StatementData | undefined> {
557
+ return this.#embed("visualization", id, wire.StatementDataSchema.parse, signal);
558
+ }
559
+
544
560
  /**
545
561
  * Compose an agent-scoped URL: `${basePath}${segment}` for the
546
562
  * default agent (the mount that does not require an `:agentId`), or
@@ -566,7 +582,7 @@ export class MastraPluginClient extends MastraClient {
566
582
  }
567
583
 
568
584
  /** GET + JSON-parse + schema-validate against a route that always 200s. */
569
- async #getJson<T>(
585
+ protected async getJson<T>(
570
586
  url: string,
571
587
  schema: { parse: (raw: unknown) => T },
572
588
  signal?: AbortSignal,
@@ -848,13 +864,16 @@ export const useMastraDefaultModel = (
848
864
  * Pass `enabled: false` to skip the fetch (e.g. when the caller
849
865
  * supplies its own explicit suggestion list), in which case
850
866
  * `questions` stays empty. Any fetch error degrades silently to no
851
- * suggestions - they're a non-critical enhancement.
867
+ * suggestions - they're a non-critical enhancement. `clientOverride`
868
+ * replaces the plugin's default client (e.g. a direct Genie client).
852
869
  */
853
870
  export const useMastraSuggestions = (
854
871
  agentId?: string,
855
872
  enabled = true,
873
+ clientOverride?: MastraPluginClient,
856
874
  ): { questions: string[]; loading: boolean } => {
857
- const client = useMastraClient();
875
+ const defaultClient = useMastraClient();
876
+ const client = clientOverride ?? defaultClient;
858
877
  const [questions, setQuestions] = useState<string[]>([]);
859
878
  const [loading, setLoading] = useState(enabled);
860
879
 
@@ -898,17 +917,20 @@ export const useMastraSuggestions = (
898
917
  * hidden), in which case `threads` stays empty. A failed fetch degrades
899
918
  * to an empty list - the conversation list is a non-critical
900
919
  * enhancement layered over the always-available default thread.
920
+ * `clientOverride` replaces the plugin's default client.
901
921
  */
902
922
  export const useMastraThreads = (
903
923
  agentId?: string,
904
924
  enabled = true,
925
+ clientOverride?: MastraPluginClient,
905
926
  ): {
906
927
  threads: MastraMemoryThread[];
907
928
  loading: boolean;
908
929
  error: Error | null;
909
930
  refresh: () => void;
910
931
  } => {
911
- const client = useMastraClient();
932
+ const defaultClient = useMastraClient();
933
+ const client = clientOverride ?? defaultClient;
912
934
  const [threads, setThreads] = useState<MastraMemoryThread[]>([]);
913
935
  const [loading, setLoading] = useState(enabled);
914
936
  const [error, setError] = useState<Error | null>(null);
@@ -952,7 +974,7 @@ export const useMastraThreads = (
952
974
  /**
953
975
  * Hook state for {@link useByIdFetch}. Generic over the
954
976
  * resource's payload shape; consumers read `data` / `loading` /
955
- * `error` exactly as the chart and statement slots do. A 404 on
977
+ * `error` exactly as the embed slots do. A 404 on
956
978
  * the server resolves as `data === undefined` with `error ===
957
979
  * null` so the slot renders nothing (matches "expired /
958
980
  * unknown id" semantics across all by-id resources).
@@ -1049,7 +1071,7 @@ function readFetchCache<T>(key: string | undefined): T | undefined {
1049
1071
 
1050
1072
  /**
1051
1073
  * React hook for any resource the Mastra plugin exposes as
1052
- * `/<resource>/:id`. Powers both the chart and statement slots
1074
+ * `/<resource>/:id`. Powers the chart, statement, and visualization slots
1053
1075
  * in the chat UI; the slot-specific hooks below are thin
1054
1076
  * adapters that pick the right cache `key`, `fetcher`, and
1055
1077
  * terminal check.
@@ -1182,3 +1204,16 @@ export const useStatementFetch = (
1182
1204
  const fetcher = useCallback(() => client.statement(statementId as string), [client, statementId]);
1183
1205
  return useByIdFetch<StatementData>(key, fetcher, statementIsTerminal);
1184
1206
  };
1207
+
1208
+ /** Fetch a direct Genie statement through the OBO-only visualization resolver. */
1209
+ export const useVisualizationFetch = (
1210
+ statementId: string | undefined,
1211
+ ): ByIdFetchState<StatementData> => {
1212
+ const client = useMastraClient();
1213
+ const key = statementId ? `visualization:${statementId}` : undefined;
1214
+ const fetcher = useCallback(
1215
+ () => client.visualization(statementId as string),
1216
+ [client, statementId],
1217
+ );
1218
+ return useByIdFetch<StatementData>(key, fetcher, statementIsTerminal);
1219
+ };
@@ -0,0 +1,9 @@
1
+ /** Build the browser storage key for one Mastra agent's selected thread. */
2
+ export function threadStorageKey(
3
+ basePath: string,
4
+ agentId: string,
5
+ resourceScope?: string,
6
+ ): string {
7
+ const prefix = `dbx-mastra-thread:${basePath}:${agentId}`;
8
+ return resourceScope ? `${prefix}:${encodeURIComponent(resourceScope)}` : prefix;
9
+ }
@@ -67,6 +67,25 @@ export function isSessionRunning(session: ThreadSession): boolean {
67
67
  return session.status === "submitted" || session.status === "streaming";
68
68
  }
69
69
 
70
+ /** Return the newest user message in a transcript or turn. */
71
+ export function lastUserMessage(messages: readonly UIMessage[]): UIMessage | undefined {
72
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
73
+ const message = messages[index];
74
+ if (message?.role === "user") return message;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ /** Build a replacement turn that retains the interrupted user request. */
80
+ export function interruptedTurnMessages(
81
+ messages: UIMessage[],
82
+ nextMessage: UIMessage,
83
+ ): UIMessage[] {
84
+ const interrupted = lastUserMessage(messages);
85
+ if (!interrupted || interrupted.id === nextMessage.id) return [nextMessage];
86
+ return [interrupted, nextMessage];
87
+ }
88
+
70
89
  /** Append a steer to the queue (oldest first). Returns a new array. */
71
90
  export function enqueueSteer<T extends QueuedSteer>(queue: T[], steer: T): T[] {
72
91
  return [...queue, steer];