@dbx-tools/ui-mastra 0.3.6 → 0.3.8

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/README.md CHANGED
@@ -140,13 +140,26 @@ import {
140
140
  } from "@dbx-tools/ui-mastra/react";
141
141
 
142
142
  const client = new MastraPluginClient(clientConfig);
143
- client.setModelOverride("claude sonnet");
144
- client.setThreadId(activeThreadId);
143
+
144
+ // Routing (thread + model) is passed per call, so concurrent runs on
145
+ // different threads never share state.
146
+ const stream = await client.streamAgent({
147
+ agentId: client.defaultAgent,
148
+ messages: [{ role: "user", content: "Hello" }],
149
+ runId,
150
+ threadId: activeThreadId,
151
+ model: "claude sonnet",
152
+ signal: controller.signal,
153
+ });
145
154
 
146
155
  const models = await client.models();
147
- const history = await client.history({ page: 0, perPage: 20 });
156
+ const history = await client.history({ threadId: activeThreadId, page: 0, perPage: 20 });
148
157
  ```
149
158
 
159
+ Each conversation thread runs independently: start a turn on one thread, switch
160
+ to another and start a second, and both stream concurrently. Cancel one via its
161
+ `AbortSignal` (or the driver's `onCancelThread`) without touching the others.
162
+
150
163
  `MastraPluginClient` extends `@mastra/client-js` with the AppKit-Mastra custom
151
164
  routes. It uses `credentials: "include"` so session cookies travel with streaming
152
165
  and REST calls. The React hooks wrap common route calls for model catalogues,
package/package.json CHANGED
@@ -26,19 +26,19 @@
26
26
  "shiki": "^3.0.0",
27
27
  "sql-formatter": "^15.6.9",
28
28
  "streamdown": "^2.5.0",
29
- "@dbx-tools/shared-core": "0.3.6",
30
- "@dbx-tools/shared-genie": "0.3.6",
31
- "@dbx-tools/shared-mastra": "0.3.6",
32
- "@dbx-tools/ui-appkit": "0.3.6",
33
- "@dbx-tools/shared-model": "0.3.6",
34
- "@dbx-tools/ui-branding": "0.3.6"
29
+ "@dbx-tools/shared-core": "0.3.8",
30
+ "@dbx-tools/shared-mastra": "0.3.8",
31
+ "@dbx-tools/shared-model": "0.3.8",
32
+ "@dbx-tools/ui-appkit": "0.3.8",
33
+ "@dbx-tools/shared-genie": "0.3.8",
34
+ "@dbx-tools/ui-branding": "0.3.8"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.6",
41
+ "version": "0.3.8",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -124,6 +124,7 @@ export const ChatView = ({
124
124
  onNewThread,
125
125
  onDeleteThread,
126
126
  onRenameThread,
127
+ onCancelThread,
127
128
  sidebarOpen: sidebarOpenProp,
128
129
  onToggleSidebar,
129
130
  onExportConversation,
@@ -349,10 +350,14 @@ export const ChatView = ({
349
350
  if (isMobile) setMobileDrawerOpen((open) => !open);
350
351
  else toggleDesktopSidebar();
351
352
  };
352
- // Top bar carries only the sidebar toggle now; the model picker, export, and
353
- // clear all live in a toolbar row below the composer, closer to where the
354
- // user is typing.
355
- const showHeader = showSidebar;
353
+ // The top bar carries only the sidebar toggle; the model picker, export, and
354
+ // clear controls live in a toolbar row below the composer, closer to where
355
+ // the user is typing. The toggle is a mobile hamburger, or a desktop "show"
356
+ // affordance while the inline sidebar is collapsed - so the bar renders only
357
+ // when that toggle would actually be visible (an open desktop sidebar has its
358
+ // own hide button, leaving nothing for the bar to hold).
359
+ const showSidebarToggle = showSidebar && (isMobile || !desktopSidebarOpen);
360
+ const showHeader = showSidebarToggle;
356
361
  const showComposerToolbar = showModelDisplay || showExport || showClear;
357
362
 
358
363
  // Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
@@ -420,6 +425,7 @@ export const ChatView = ({
420
425
  : {})}
421
426
  {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
422
427
  {...(onRenameThread ? { onRename: onRenameThread } : {})}
428
+ {...(onCancelThread ? { onCancel: onCancelThread } : {})}
423
429
  onHide={toggleSidebar}
424
430
  className="relative z-10 w-[85vw] max-w-xs shadow-xl"
425
431
  />
@@ -440,39 +446,38 @@ export const ChatView = ({
440
446
  {...(onNewThread ? { onNew: onNewThread } : {})}
441
447
  {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
442
448
  {...(onRenameThread ? { onRename: onRenameThread } : {})}
449
+ {...(onCancelThread ? { onCancel: onCancelThread } : {})}
443
450
  onHide={toggleSidebar}
444
451
  />
445
452
  )
446
453
  ))}
447
454
  <div className="flex h-full min-w-0 flex-1 flex-col">
448
455
  {showHeader && (
449
- <div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6">
450
- <div className="flex shrink-0 items-center gap-2">
451
- {/*
452
- * Sidebar toggle. On mobile it's a persistent hamburger (the
453
- * overlay drawer has no always-visible hide button). On desktop
454
- * the panel hides itself, so the header only needs a "show"
455
- * affordance while the inline sidebar is collapsed.
456
- */}
457
- {showSidebar && (isMobile || !desktopSidebarOpen) && (
458
- <Tooltip>
459
- <TooltipTrigger asChild>
460
- <Button
461
- type="button"
462
- variant="ghost"
463
- size="icon-sm"
464
- onClick={toggleSidebar}
465
- aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
466
- >
467
- <PanelLeftIcon className="size-4" />
468
- </Button>
469
- </TooltipTrigger>
470
- <TooltipContent>
471
- {sidebarOpen ? "Hide conversations" : "Show conversations"}
472
- </TooltipContent>
473
- </Tooltip>
474
- )}
475
- </div>
456
+ /*
457
+ * Slim top bar holding the sidebar toggle. On mobile the toggle is
458
+ * a persistent hamburger (the overlay drawer has no always-visible
459
+ * hide button); on desktop it's a "show" affordance rendered only
460
+ * while the inline sidebar is collapsed (an open sidebar has its
461
+ * own hide button). `showHeader` already tracks that visibility, so
462
+ * the bar never renders empty.
463
+ */
464
+ <div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6">
465
+ <Tooltip>
466
+ <TooltipTrigger asChild>
467
+ <Button
468
+ type="button"
469
+ variant="ghost"
470
+ size="icon-sm"
471
+ onClick={toggleSidebar}
472
+ aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
473
+ >
474
+ <PanelLeftIcon className="size-4" />
475
+ </Button>
476
+ </TooltipTrigger>
477
+ <TooltipContent>
478
+ {sidebarOpen ? "Hide conversations" : "Show conversations"}
479
+ </TooltipContent>
480
+ </Tooltip>
476
481
  </div>
477
482
  )}
478
483
  <div className="relative flex flex-1 flex-col overflow-hidden">
@@ -628,7 +633,7 @@ export const ChatView = ({
628
633
  type="button"
629
634
  size="icon-sm"
630
635
  variant="default"
631
- onClick={onStop}
636
+ onClick={() => onStop()}
632
637
  aria-label="Stop response"
633
638
  >
634
639
  <SquareIcon className="size-3 fill-current" />
@@ -46,10 +46,9 @@ import type {
46
46
  // `{ runId, payload: { toolCallId, toolName, args } }`. We surface that
47
47
  // as an out-of-band entry in `pendingApprovalsByMessage` and wire
48
48
  // `onResolveToolApproval` to {@link MastraPluginClient.approveToolCallStream}
49
- // / `declineToolCallStream`, which read SSE directly and avoid a stock
50
- // `@mastra/client-js` bug on resumed approval streams.
51
- // both of which return a fresh stream Response we run through the same
52
- // chunk handler.
49
+ // / `declineToolCallStream` - both of which read SSE directly (avoiding a
50
+ // stock `@mastra/client-js` bug on resumed approval streams) and return a
51
+ // fresh stream Response we run through the same chunk handler.
53
52
  //
54
53
  // On mount the transcript hydrates with the most recent page of thread
55
54
  // history from the Mastra plugin's `/history` endpoint; scrolling near
@@ -247,18 +246,20 @@ export const useMastraChat = (
247
246
  // using it as a hook dep doesn't refire the initial-history fetch on
248
247
  // every parent render.
249
248
  const mastraClient = useMastraClient();
250
- // Apply the selected model as a per-request override header in place,
251
- // so the next `agent.stream()` picks it up without rebuilding the
252
- // client (which would refire history loads on every model change).
253
- useEffect(() => {
254
- mastraClient.setModelOverride(model || undefined);
255
- }, [mastraClient, model]);
249
+ // The selected model rides each turn as a per-call override (see
250
+ // `runStream`), never stored on the shared client - so two threads can
251
+ // stream under different models without clobbering each other. Mirror it
252
+ // into a ref so the send path reads the latest selection without adding
253
+ // `model` to its dependency list.
254
+ const modelRef = useRef(model);
255
+ modelRef.current = model;
256
256
  const agentId = options.agentId ?? mastraClient.defaultAgent;
257
257
  // Built-in conversation management. When on, the chat always drives an
258
258
  // explicit client thread id (rather than leaning on the per-session
259
259
  // cookie) so it can reference, persist, and switch between the
260
- // conversations a user owns. Selecting a thread routes every call
261
- // (stream + history + clear) at it via `mastraClient.setThreadId`.
260
+ // conversations a user owns. Each call (stream / history / clear) carries
261
+ // its thread id per request, so many threads can run concurrently without
262
+ // sharing routing state.
262
263
  const enableThreads = options.enableThreads !== false;
263
264
  // Export is opt-in (default off): the host turns it on explicitly.
264
265
  const enableExport = options.enableExport === true;
@@ -694,7 +695,7 @@ export const useMastraChat = (
694
695
  async (
695
696
  threadId: string,
696
697
  assistantId: string,
697
- open: () => Promise<MastraStreamResponse>,
698
+ open: (signal: AbortSignal) => Promise<MastraStreamResponse>,
698
699
  ) => {
699
700
  const controller = new AbortController();
700
701
  let token = 0;
@@ -713,10 +714,7 @@ export const useMastraChat = (
713
714
  });
714
715
  const runIdRef = { current: getSession(threadId).runId };
715
716
  try {
716
- const streamThreadId =
717
- threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
718
- mastraClient.setThreadId(streamThreadId);
719
- const stream = await open();
717
+ const stream = await open(controller.signal);
720
718
  await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
721
719
  updateSession(threadId, (session) => {
722
720
  if (session.runToken !== token) return session;
@@ -746,7 +744,7 @@ export const useMastraChat = (
746
744
  }));
747
745
  }
748
746
  },
749
- [getSession, mastraClient, processStream, refreshThreadsSoon, updateSession],
747
+ [getSession, processStream, refreshThreadsSoon, updateSession],
750
748
  );
751
749
 
752
750
  const runStream = useCallback(
@@ -758,35 +756,53 @@ export const useMastraChat = (
758
756
  assistantId,
759
757
  runId,
760
758
  }));
761
- return driveStream(threadId, assistantId, () => {
762
- const agent = mastraClient.getAgent(agentId);
763
- const messagesForAgent = history.flatMap((m) =>
759
+ // Capture this run's thread + model at send time and pass them per
760
+ // call, so a run keeps its own routing even if the user switches
761
+ // threads or changes the model picker while it streams.
762
+ const streamThreadId =
763
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
764
+ const model = modelRef.current || undefined;
765
+ return driveStream(threadId, assistantId, (signal) => {
766
+ const messages = history.flatMap((m) =>
764
767
  m.parts
765
768
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
766
769
  .map((p) => ({ role: m.role, content: p.text })),
767
- ) as Parameters<typeof agent.stream>[0];
768
- return agent.stream(messagesForAgent, {
770
+ );
771
+ return mastraClient.streamAgent({
772
+ agentId,
773
+ messages,
769
774
  runId,
770
- }) as Promise<MastraStreamResponse>;
775
+ threadId: streamThreadId,
776
+ model,
777
+ signal,
778
+ });
771
779
  });
772
780
  },
773
781
  [driveStream, mastraClient, agentId, updateSession],
774
782
  );
775
783
 
776
- const stop = useCallback(() => {
777
- const threadId = activeKey;
778
- updateSession(threadId, (session) => {
779
- if (!isSessionRunning(session)) return session;
780
- session.abortController?.abort();
781
- return {
782
- ...session,
783
- abortController: null,
784
- runToken: session.runToken + 1,
785
- error: null,
786
- status: "ready",
787
- };
788
- });
789
- }, [activeKey, updateSession]);
784
+ // Cancel a thread's in-flight run. Defaults to the active thread (the
785
+ // composer stop button), but takes an explicit thread id so the sidebar
786
+ // can cancel a background thread without switching to it. Aborting one
787
+ // thread's controller leaves every other run streaming - each run owns
788
+ // its own controller and routing.
789
+ const stop = useCallback(
790
+ (threadId?: string) => {
791
+ const key = threadId ?? activeKey;
792
+ updateSession(key, (session) => {
793
+ if (!isSessionRunning(session)) return session;
794
+ session.abortController?.abort();
795
+ return {
796
+ ...session,
797
+ abortController: null,
798
+ runToken: session.runToken + 1,
799
+ error: null,
800
+ status: "ready",
801
+ };
802
+ });
803
+ },
804
+ [activeKey, updateSession],
805
+ );
790
806
 
791
807
  /**
792
808
  * Approve or deny an in-flight `requireApproval` tool call. The
@@ -831,10 +847,22 @@ export const useMastraChat = (
831
847
  toolCallId,
832
848
  runId,
833
849
  });
834
- await driveStream(activeKey, assistantId, () =>
850
+ const streamThreadId =
851
+ activeKey === DEFAULT_THREAD_SESSION_KEY ? undefined : activeKey;
852
+ await driveStream(activeKey, assistantId, (signal) =>
835
853
  decision.approved
836
- ? mastraClient.approveToolCallStream(agentId, { runId, toolCallId })
837
- : mastraClient.declineToolCallStream(agentId, { runId, toolCallId }),
854
+ ? mastraClient.approveToolCallStream(agentId, {
855
+ runId,
856
+ toolCallId,
857
+ threadId: streamThreadId,
858
+ signal,
859
+ })
860
+ : mastraClient.declineToolCallStream(agentId, {
861
+ runId,
862
+ toolCallId,
863
+ threadId: streamThreadId,
864
+ signal,
865
+ }),
838
866
  );
839
867
  },
840
868
  [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
@@ -883,7 +911,7 @@ export const useMastraChat = (
883
911
  const handleClear = useCallback(async () => {
884
912
  const threadId = activeKey;
885
913
  try {
886
- const result = await mastraClient.clearHistory({ agentId });
914
+ const result = await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
887
915
  logger.info("history cleared", { cleared: result.cleared });
888
916
  } catch (error) {
889
917
  logger.error("history clear error", {
@@ -1040,7 +1068,6 @@ export const useMastraChat = (
1040
1068
  // refetching or aborting other threads' runs.
1041
1069
  useEffect(() => {
1042
1070
  const threadId = activeKey;
1043
- mastraClient.setThreadId(activeThreadId);
1044
1071
  const session = getSession(threadId);
1045
1072
  if (session.historyLoaded) {
1046
1073
  setIsLoadingHistory(false);
@@ -1054,6 +1081,7 @@ export const useMastraChat = (
1054
1081
  mastraClient
1055
1082
  .history({
1056
1083
  agentId,
1084
+ threadId: activeThreadId,
1057
1085
  page: 0,
1058
1086
  perPage: HISTORY_PAGE_SIZE,
1059
1087
  signal: controller.signal,
@@ -1097,7 +1125,7 @@ export const useMastraChat = (
1097
1125
  const page = session.historyPage;
1098
1126
  updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
1099
1127
  mastraClient
1100
- .history({ agentId, page, perPage: HISTORY_PAGE_SIZE })
1128
+ .history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
1101
1129
  .then((response) => {
1102
1130
  const uiMessages = response.uiMessages as unknown as UIMessage[];
1103
1131
  if (uiMessages.length > 0) {
@@ -1124,7 +1152,15 @@ export const useMastraChat = (
1124
1152
  historyInFlightRef.current = false;
1125
1153
  setIsLoadingMore(false);
1126
1154
  });
1127
- }, [activeKey, getSession, mastraClient, agentId, updateSession, writeMessages]);
1155
+ }, [
1156
+ activeKey,
1157
+ activeThreadId,
1158
+ getSession,
1159
+ mastraClient,
1160
+ agentId,
1161
+ updateSession,
1162
+ writeMessages,
1163
+ ]);
1128
1164
 
1129
1165
  // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
1130
1166
  // straight off the client so the export inlines the same charts /
@@ -1256,9 +1292,9 @@ export const useMastraChat = (
1256
1292
  [activeKey, mastraClient, updateSession],
1257
1293
  );
1258
1294
 
1259
- // Merge optimistic rows
1260
- // the server list, newest first, dropping any optimistic entry the
1261
- // server already returns so a thread is never listed twice.
1295
+ // Merge optimistic rows over the server list, newest first, dropping any
1296
+ // optimistic entry the server already returns so a thread is never listed
1297
+ // twice.
1262
1298
  const sidebarThreads = useMemo<ThreadSummary[]>(() => {
1263
1299
  // Title overlay precedence per row: a manual rename always wins;
1264
1300
  // otherwise a provisional first-message title fills an untitled row
@@ -1357,6 +1393,9 @@ export const useMastraChat = (
1357
1393
  onNewThread: newThread,
1358
1394
  onDeleteThread: deleteThread,
1359
1395
  onRenameThread: renameThread,
1396
+ // Cancel a background thread's run from the sidebar without
1397
+ // switching to it.
1398
+ onCancelThread: stop,
1360
1399
  // Persisted sidebar visibility, controlled from the driver so
1361
1400
  // the show/hide choice survives reloads.
1362
1401
  sidebarOpen,
@@ -13,6 +13,7 @@ import {
13
13
  MessageSquarePlusIcon,
14
14
  PanelLeftIcon,
15
15
  PencilIcon,
16
+ SquareIcon,
16
17
  Trash2Icon,
17
18
  } from "lucide-react";
18
19
  import { useState } from "react";
@@ -42,6 +43,12 @@ export interface ThreadSidebarProps {
42
43
  onDelete?: (threadId: string) => void;
43
44
  /** Rename a thread. Per-row edit affordance (inline text field) hidden when omitted. */
44
45
  onRename?: (threadId: string, title: string) => void;
46
+ /**
47
+ * Cancel a thread's in-flight run. When provided, a streaming row (one in
48
+ * {@link streamingThreadIds}) shows a stop button in place of its spinner
49
+ * on hover, so a background run can be cancelled without switching to it.
50
+ */
51
+ onCancel?: (threadId: string) => void;
45
52
  /** Collapse the sidebar. Renders the hide button in the header when provided. */
46
53
  onHide?: () => void;
47
54
  /** Extra classes merged onto the sidebar root. */
@@ -67,6 +74,7 @@ export const ThreadSidebar = ({
67
74
  onNew,
68
75
  onDelete,
69
76
  onRename,
77
+ onCancel,
70
78
  onHide,
71
79
  className,
72
80
  }: ThreadSidebarProps) => {
@@ -212,12 +220,34 @@ export const ThreadSidebar = ({
212
220
  >
213
221
  <div className="min-w-0 flex-1">
214
222
  <div className="flex items-center gap-1.5 truncate">
215
- {isStreaming && (
216
- <Loader2Icon
217
- aria-label="Streaming"
218
- className="size-3 shrink-0 animate-spin text-primary"
219
- />
220
- )}
223
+ {isStreaming &&
224
+ (onCancel ? (
225
+ <Tooltip>
226
+ <TooltipTrigger asChild>
227
+ <Button
228
+ type="button"
229
+ variant="ghost"
230
+ size="icon"
231
+ onClick={(e) => {
232
+ e.stopPropagation();
233
+ onCancel(thread.id);
234
+ }}
235
+ aria-label="Stop generating"
236
+ className="size-4 shrink-0 text-primary"
237
+ >
238
+ {/* Spinner by default; swap to a stop square on hover/focus. */}
239
+ <Loader2Icon className="size-3 animate-spin group-hover:hidden" />
240
+ <SquareIcon className="hidden size-3 fill-current group-hover:block" />
241
+ </Button>
242
+ </TooltipTrigger>
243
+ <TooltipContent>Stop generating</TooltipContent>
244
+ </Tooltip>
245
+ ) : (
246
+ <Loader2Icon
247
+ aria-label="Streaming"
248
+ className="size-3 shrink-0 animate-spin text-primary"
249
+ />
250
+ ))}
221
251
  <span className="truncate">{threadTitle(thread)}</span>
222
252
  </div>
223
253
  {thread.updatedAt && (
@@ -99,7 +99,7 @@ export type ChatViewProps = {
99
99
  * to hide the Stop affordance (the Send button just disables while a
100
100
  * response streams).
101
101
  */
102
- onStop?: () => void;
102
+ onStop?: (threadId?: string) => void;
103
103
  /** Extra classes merged onto the root layout container. */
104
104
  className?: string;
105
105
  /**
@@ -220,6 +220,13 @@ export type ChatViewProps = {
220
220
  * affordance that swaps the title into an inline text field.
221
221
  */
222
222
  onRenameThread?: (threadId: string, title: string) => void;
223
+ /**
224
+ * Cancel a thread's in-flight run by id, without switching to it. When
225
+ * provided, each running sidebar row shows a stop affordance next to its
226
+ * streaming indicator (see {@link streamingThreadIds}). Isolated to that
227
+ * thread - other threads keep streaming.
228
+ */
229
+ onCancelThread?: (threadId: string) => void;
223
230
  /**
224
231
  * Controlled open/closed state for the conversation sidebar. When
225
232
  * omitted the view manages its own (session-only) open state; pass
@@ -162,13 +162,20 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
162
162
  return;
163
163
  }
164
164
  // Settle so charts / fonts lay out, then print. `afterprint` removes the
165
- // iframe once the dialog closes (a timeout backstops browsers that don't
166
- // fire it).
165
+ // iframe once the dialog closes; a 60s timer backstops browsers that don't
166
+ // fire it. The backstop is armed BEFORE `print()` so a throw (sandboxed /
167
+ // policy-restricted frames can reject `print()`) still cleans up, and the
168
+ // export falls back to a file download rather than silently vanishing.
167
169
  win.addEventListener("afterprint", cleanup);
168
170
  win.setTimeout(() => {
169
- win.focus();
170
- win.print();
171
171
  window.setTimeout(cleanup, 60000);
172
+ try {
173
+ win.focus();
174
+ win.print();
175
+ } catch {
176
+ cleanup();
177
+ downloadTextFile(downloadName, html, "text/html;charset=utf-8");
178
+ }
172
179
  }, PRINT_SETTLE_MS);
173
180
  };
174
181
 
@@ -478,6 +485,20 @@ function downloadTextFile(filename: string, content: string, mime: string): void
478
485
  window.setTimeout(() => URL.revokeObjectURL(url), 1000);
479
486
  }
480
487
 
488
+ /**
489
+ * A brand color / font value safe to interpolate into a `<style>` block.
490
+ * Brand colors are hex-validated upstream, but the font stack is only checked
491
+ * for non-blankness, so strip the characters that could close a declaration or
492
+ * rule (`{ } ; < > \` plus HTML-comment openers) to prevent CSS injection into
493
+ * the export document. Falls back to `fallback` when the value is blank.
494
+ */
495
+ function cssValue(value: string | undefined, fallback: string): string {
496
+ const trimmed = value?.trim();
497
+ if (!trimmed) return fallback;
498
+ const safe = trimmed.replace(/[{}<>;\\]/g, "");
499
+ return safe || fallback;
500
+ }
501
+
481
502
  /**
482
503
  * Build the inline stylesheet for the exported document, folding in optional
483
504
  * brand color / font overrides (falling back to the neutral slate/blue theme
@@ -497,11 +518,13 @@ function downloadTextFile(filename: string, content: string, mime: string): void
497
518
  * out; the visible page margin is re-supplied as body padding.
498
519
  */
499
520
  function buildDocumentCss(brand?: ExportBrand): string {
500
- const fg = brand?.foreground || "#0f172a";
501
- const primary = brand?.primary || "#0f172a";
502
- const link = brand?.accent || "#2563eb";
503
- const font =
504
- brand?.fontSans || 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
521
+ const fg = cssValue(brand?.foreground, "#0f172a");
522
+ const primary = cssValue(brand?.primary, "#0f172a");
523
+ const link = cssValue(brand?.accent, "#2563eb");
524
+ const font = cssValue(
525
+ brand?.fontSans,
526
+ 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
527
+ );
505
528
  return `
506
529
  :root { color-scheme: light; }
507
530
  * { box-sizing: border-box; }
@@ -72,43 +72,55 @@ export class MastraPluginClient extends MastraClient {
72
72
  }
73
73
 
74
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.
75
+ * Build the per-request routing headers for a call: the thread-selection
76
+ * header (`THREAD_ID_HEADER`) so the server pins `RequestContext`'s thread
77
+ * id, and the model-override header (`MODEL_OVERRIDE_HEADER`) so the server
78
+ * swaps the resolved model for that request without an agent redeploy.
82
79
  *
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.
80
+ * Routing is per-CALL - never stored on the shared client - so concurrent
81
+ * runs on different threads / models can't clobber each other's headers.
82
+ * `threadId` is a conversation id (omit to fall back to the per-session
83
+ * cookie thread); `model` is a concrete endpoint name (fuzzy-matched
84
+ * server-side) or a model-class slug (`"chat-fast"` / `"chat-thinking"`).
87
85
  */
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];
86
+ #routingHeaders(routing: { threadId?: string; model?: string }): Record<string, string> {
87
+ const headers: Record<string, string> = {};
88
+ if (routing.threadId) headers[thread.THREAD_ID_HEADER] = routing.threadId;
89
+ if (routing.model) headers[override.MODEL_OVERRIDE_HEADER] = routing.model;
90
+ return headers;
92
91
  }
93
92
 
94
93
  /**
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.
94
+ * Open an agent stream for one conversation turn, routed and cancelled
95
+ * PER CALL. Posts to the agent `/stream` route with this run's own thread
96
+ * id + model as request headers and its own `AbortSignal` on the fetch, so
97
+ * many threads can stream at once and aborting one leaves the others
98
+ * untouched. Reads the SSE directly (like the tool-approval streams) rather
99
+ * than through the inherited `agent.stream()`, whose shared-client headers
100
+ * and single client-level abort signal can't isolate concurrent runs.
107
101
  */
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];
102
+ async streamAgent(params: {
103
+ agentId: string;
104
+ messages: Array<{ role: string; content: string }>;
105
+ runId: string;
106
+ threadId?: string;
107
+ model?: string;
108
+ signal?: AbortSignal;
109
+ }): Promise<MastraStreamResponse> {
110
+ const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/stream`;
111
+ const init: RequestInit = {
112
+ method: "POST",
113
+ credentials: "include",
114
+ headers: {
115
+ "Content-Type": "application/json",
116
+ ...this.#routingHeaders(params),
117
+ },
118
+ body: JSON.stringify({ messages: params.messages, runId: params.runId }),
119
+ };
120
+ if (params.signal) init.signal = params.signal;
121
+ const response = await fetch(url, init);
122
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
123
+ return asMastraStreamResponse(response);
112
124
  }
113
125
 
114
126
  /**
@@ -120,7 +132,7 @@ export class MastraPluginClient extends MastraClient {
120
132
  */
121
133
  async approveToolCallStream(
122
134
  agentId: string,
123
- params: { runId: string; toolCallId: string },
135
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
124
136
  ): Promise<MastraStreamResponse> {
125
137
  return this.#toolApprovalStream(agentId, "approve-tool-call", params);
126
138
  }
@@ -131,7 +143,7 @@ export class MastraPluginClient extends MastraClient {
131
143
  */
132
144
  async declineToolCallStream(
133
145
  agentId: string,
134
- params: { runId: string; toolCallId: string },
146
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
135
147
  ): Promise<MastraStreamResponse> {
136
148
  return this.#toolApprovalStream(agentId, "decline-tool-call", params);
137
149
  }
@@ -139,24 +151,22 @@ export class MastraPluginClient extends MastraClient {
139
151
  async #toolApprovalStream(
140
152
  agentId: string,
141
153
  route: "approve-tool-call" | "decline-tool-call",
142
- params: { runId: string; toolCallId: string },
154
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
143
155
  ): Promise<MastraStreamResponse> {
144
- const response = (await this.request(
145
- `/agents/${encodeURIComponent(agentId)}/${route}`,
146
- {
147
- method: "POST",
148
- body: params,
149
- stream: true,
156
+ const url = `${this.basePath}/agents/${encodeURIComponent(agentId)}/${route}`;
157
+ const init: RequestInit = {
158
+ method: "POST",
159
+ credentials: "include",
160
+ headers: {
161
+ "Content-Type": "application/json",
162
+ ...this.#routingHeaders({ threadId: params.threadId }),
150
163
  },
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
- );
164
+ body: JSON.stringify({ runId: params.runId, toolCallId: params.toolCallId }),
165
+ };
166
+ if (params.signal) init.signal = params.signal;
167
+ const response = await fetch(url, init);
168
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
169
+ return asMastraStreamResponse(response);
160
170
  }
161
171
 
162
172
  /**
@@ -209,11 +219,14 @@ export class MastraPluginClient extends MastraClient {
209
219
  /**
210
220
  * Fetch one page of thread history from `GET ${basePath}/history`.
211
221
  * Messages come back oldest -> newest so the caller can prepend them
212
- * to a live transcript.
222
+ * to a live transcript. `threadId` targets a specific conversation
223
+ * (sent as the `?threadId=` query so it doesn't depend on shared client
224
+ * state); omit it to read the per-session cookie thread.
213
225
  */
214
226
  async history(
215
227
  options: {
216
228
  agentId?: string;
229
+ threadId?: string;
217
230
  page?: number;
218
231
  perPage?: number;
219
232
  signal?: AbortSignal;
@@ -222,6 +235,7 @@ export class MastraPluginClient extends MastraClient {
222
235
  const params = new URLSearchParams();
223
236
  if (options.page !== undefined) params.set("page", String(options.page));
224
237
  if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
238
+ if (options.threadId) params.set(thread.THREAD_ID_QUERY, options.threadId);
225
239
  const qs = params.toString();
226
240
  const base = this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId);
227
241
  return this.#getJson(
@@ -232,19 +246,26 @@ export class MastraPluginClient extends MastraClient {
232
246
  }
233
247
 
234
248
  /**
235
- * Wipe the caller's thread history (`DELETE ${basePath}/history`).
236
- * The session cookie that anchors the thread id is preserved - only
237
- * the messages go away. Idempotent: a fresh thread reports
249
+ * Wipe a thread's history (`DELETE ${basePath}/history`). `threadId`
250
+ * targets a specific conversation via the thread-selection header (so it
251
+ * doesn't depend on shared client state); omit it to clear the per-session
252
+ * cookie thread. The session cookie that anchors the thread id is
253
+ * preserved - only the messages go away. Idempotent: a fresh thread reports
238
254
  * `cleared: 0` without erroring.
239
255
  */
240
256
  async clearHistory(
241
- options: { agentId?: string; signal?: AbortSignal } = {},
257
+ options: { agentId?: string; threadId?: string; signal?: AbortSignal } = {},
242
258
  ): Promise<MastraClearHistoryResponse> {
243
259
  return this.#mutateJson(
244
260
  this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId),
245
261
  "DELETE",
246
262
  wire.MastraClearHistoryResponseSchema,
247
- options.signal ? { signal: options.signal } : {},
263
+ {
264
+ ...(options.threadId
265
+ ? { headers: { [thread.THREAD_ID_HEADER]: options.threadId } }
266
+ : {}),
267
+ ...(options.signal ? { signal: options.signal } : {}),
268
+ },
248
269
  );
249
270
  }
250
271
 
@@ -282,9 +303,8 @@ export class MastraPluginClient extends MastraClient {
282
303
  * clashing with the inherited `MastraClient.deleteThread`, which hits
283
304
  * Mastra's stock thread route rather than our OBO-scoped, custom
284
305
  * mount). The id is sent as the thread-selection header for this one
285
- * call (without disturbing the client's currently-selected thread,
286
- * set via {@link setThreadId}), so the sidebar can remove any thread
287
- * while the user stays on another. Idempotent: deleting an unknown /
306
+ * call, so the sidebar can remove any thread while the user stays on
307
+ * another (routing is per call, never shared). Idempotent: deleting an unknown /
288
308
  * already-removed thread reports `deleted: false` without erroring.
289
309
  */
290
310
  async removeThread(
@@ -306,9 +326,8 @@ export class MastraPluginClient extends MastraClient {
306
326
  * Rename a single conversation thread via the plugin's own
307
327
  * `PATCH ${basePath}/threads` route. The id travels as the
308
328
  * thread-selection header for this one call (mirroring
309
- * {@link removeThread}, so the sidebar can rename any thread without
310
- * disturbing the client's currently-selected thread) and the new
311
- * `title` rides in the JSON body. The server enforces ownership and
329
+ * {@link removeThread}, so the sidebar can rename any thread; routing is
330
+ * per call, never shared) and the new `title` rides in the JSON body. The server enforces ownership and
312
331
  * echoes back the updated thread, so the caller can reflect the new
313
332
  * title immediately. Throws on an unknown / unowned thread (HTTP 404).
314
333
  */
@@ -384,13 +403,13 @@ export class MastraPluginClient extends MastraClient {
384
403
  }
385
404
 
386
405
  /**
387
- * Snapshot of the client's per-request override headers (model +
388
- * thread selection) for the custom-route fetches that don't go
389
- * through `@mastra/client-js`'s own request pipeline. Returns a fresh
390
- * object each call so a caller can safely add one-off headers without
391
- * mutating the shared `options.headers`. The thread-selection header
392
- * here is what routes `history()` / `clearHistory()` / `threads()` to
393
- * the currently-selected thread (see {@link setThreadId}).
406
+ * Snapshot of the client's base headers for the custom-route fetches
407
+ * that don't go through `@mastra/client-js`'s own request pipeline.
408
+ * Returns a fresh object each call so a caller can safely add one-off
409
+ * headers without mutating the shared `options.headers`. Routing
410
+ * (thread selection, model override) is passed per call - via a
411
+ * `?threadId=` query or the thread-selection header on the individual
412
+ * request - not stored here, so concurrent calls never share routing.
394
413
  */
395
414
  #defaultHeaders(): Record<string, string> {
396
415
  return { ...((this.options.headers as Record<string, string>) ?? {}) };
@@ -416,9 +435,8 @@ export class MastraPluginClient extends MastraClient {
416
435
  * `POST` / `DELETE` / `PATCH` + JSON-parse + schema-validate for the
417
436
  * mutating routes (`clearHistory` / `removeThread` / `renameThread` /
418
437
  * `feedback`). A JSON body, when present, sets `Content-Type`;
419
- * `options.headers` add one-off headers (e.g. the thread-selection
420
- * header for a targeted delete / rename) over the client's default
421
- * override headers.
438
+ * `options.headers` add per-call headers (e.g. the thread-selection
439
+ * header for a targeted delete / rename) over the client's base headers.
422
440
  */
423
441
  async #mutateJson<T>(
424
442
  url: string,
@@ -477,12 +495,12 @@ export const useMastraConfig = (): MastraClientConfig =>
477
495
  /**
478
496
  * The single {@link MastraPluginClient} for the plugin, built once from
479
497
  * the published `basePath`. Use it for everything: stream a turn with
480
- * `client.getAgent(agentId).stream(...)`, page history with
481
- * `client.history()`, resolve embeds with `client.chart(id)`, etc.
482
- * Rebuilt only when `basePath` / `defaultAgent` change (e.g. a custom
483
- * mount), so it's safe as a `useMemo` / `useCallback` / `useEffect`
484
- * dependency; the per-request model override is applied in place via
485
- * {@link MastraPluginClient.setModelOverride} without changing identity.
498
+ * `client.streamAgent({ agentId, messages, runId, threadId, model, signal })`,
499
+ * page history with `client.history()`, resolve embeds with
500
+ * `client.chart(id)`, etc. Rebuilt only when `basePath` / `defaultAgent`
501
+ * change (e.g. a custom mount), so it's safe as a `useMemo` / `useCallback`
502
+ * / `useEffect` dependency; per-turn thread + model routing is passed per
503
+ * call rather than stored on the client, so concurrent runs stay isolated.
486
504
  */
487
505
  export const useMastraClient = (): MastraPluginClient => {
488
506
  const config = useMastraConfig();