@dbx-tools/ui-mastra 0.3.7 → 0.3.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.
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.7",
30
- "@dbx-tools/shared-genie": "0.3.7",
31
- "@dbx-tools/shared-mastra": "0.3.7",
32
- "@dbx-tools/ui-appkit": "0.3.7",
33
- "@dbx-tools/shared-model": "0.3.7",
34
- "@dbx-tools/ui-branding": "0.3.7"
29
+ "@dbx-tools/shared-genie": "0.3.9",
30
+ "@dbx-tools/shared-mastra": "0.3.9",
31
+ "@dbx-tools/shared-model": "0.3.9",
32
+ "@dbx-tools/ui-appkit": "0.3.9",
33
+ "@dbx-tools/shared-core": "0.3.9",
34
+ "@dbx-tools/ui-branding": "0.3.9"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.7",
41
+ "version": "0.3.9",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -47,7 +47,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
47
47
  import { AssistantBubble, UserBubble } from "./bubbles";
48
48
  import { ExportMenu } from "./export-menu";
49
49
  import { SuggestionPills } from "./suggestion-pills";
50
- import { ThreadSidebar } from "./thread-sidebar";
50
+ import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar";
51
51
  import type { ChatViewProps } from "./types";
52
52
 
53
53
  // Controlled, presentational chat shell: the scroll container, header
@@ -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,
@@ -246,11 +247,10 @@ export const ChatView = ({
246
247
 
247
248
  const handleSubmit = (e: React.FormEvent) => {
248
249
  e.preventDefault();
249
- // Don't queue a new turn while one is streaming - the Enter-key
250
- // path would otherwise bypass the disabled Send button.
251
- if (isRunning) return;
252
250
  const text = input.trim();
253
251
  if (!text) return;
252
+ // Submitting while a turn streams is a steer: the driver hands the text
253
+ // to the live run (or interrupts + resends). Idle submits start a turn.
254
254
  sendMessage({ text });
255
255
  setInput("");
256
256
  // Sending is an explicit "I want to see the response" action, so
@@ -359,6 +359,23 @@ export const ChatView = ({
359
359
  const showHeader = showSidebarToggle;
360
360
  const showComposerToolbar = showModelDisplay || showExport || showClear;
361
361
 
362
+ // Props shared by the mobile drawer and the desktop inline sidebar - the two
363
+ // render the SAME `ThreadSidebar`, differing only in framing (overlay vs.
364
+ // inline) and, on mobile, closing the drawer after select / new. Building
365
+ // the prop bag once keeps the two call sites from drifting.
366
+ const sidebarProps: ThreadSidebarProps = {
367
+ threads: threads ?? [],
368
+ ...(activeThreadId ? { activeThreadId } : {}),
369
+ streamingThreadIds,
370
+ isLoading: isLoadingThreads,
371
+ onSelect: (id) => onSelectThread?.(id),
372
+ onHide: toggleSidebar,
373
+ ...(onNewThread ? { onNew: onNewThread } : {}),
374
+ ...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
375
+ ...(onRenameThread ? { onRename: onRenameThread } : {}),
376
+ ...(onCancelThread ? { onCancel: onCancelThread } : {}),
377
+ };
378
+
362
379
  // Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
363
380
  // in-flight flag so the DELETE can't be double-fired. `clearing` disables
364
381
  // the confirm action while `onClear` runs; the dialog closes on settle.
@@ -394,9 +411,9 @@ export const ChatView = ({
394
411
  /*
395
412
  * Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
396
413
  * the conversation list never eats horizontal space from the chat
397
- * on a phone. Selecting a thread / starting a new one closes it so
398
- * the transcript comes back into view. Session-only + default
399
- * closed (see `mobileDrawerOpen`).
414
+ * on a phone. Selecting a thread / starting a new one also closes
415
+ * the drawer so the transcript comes back into view. Session-only
416
+ * + default closed (see `mobileDrawerOpen`).
400
417
  */
401
418
  mobileDrawerOpen && (
402
419
  <div className="fixed inset-0 z-40 flex">
@@ -406,10 +423,7 @@ export const ChatView = ({
406
423
  aria-hidden="true"
407
424
  />
408
425
  <ThreadSidebar
409
- threads={threads ?? []}
410
- {...(activeThreadId ? { activeThreadId } : {})}
411
- streamingThreadIds={streamingThreadIds}
412
- isLoading={isLoadingThreads}
426
+ {...sidebarProps}
413
427
  onSelect={(id) => {
414
428
  onSelectThread?.(id);
415
429
  toggleSidebar();
@@ -422,9 +436,6 @@ export const ChatView = ({
422
436
  },
423
437
  }
424
438
  : {})}
425
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
426
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
427
- onHide={toggleSidebar}
428
439
  className="relative z-10 w-[85vw] max-w-xs shadow-xl"
429
440
  />
430
441
  </div>
@@ -432,21 +443,11 @@ export const ChatView = ({
432
443
  ) : (
433
444
  /*
434
445
  * Desktop: an inline flex child sharing the row with the chat
435
- * column, using the persisted open/hide preference.
446
+ * column, using the persisted open/hide preference. Same
447
+ * `sidebarProps` as mobile - only the framing + close-on-select
448
+ * differ.
436
449
  */
437
- desktopSidebarOpen && (
438
- <ThreadSidebar
439
- threads={threads ?? []}
440
- {...(activeThreadId ? { activeThreadId } : {})}
441
- streamingThreadIds={streamingThreadIds}
442
- isLoading={isLoadingThreads}
443
- onSelect={(id) => onSelectThread?.(id)}
444
- {...(onNewThread ? { onNew: onNewThread } : {})}
445
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
446
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
447
- onHide={toggleSidebar}
448
- />
449
- )
450
+ desktopSidebarOpen && <ThreadSidebar {...sidebarProps} />
450
451
  ))}
451
452
  <div className="flex h-full min-w-0 flex-1 flex-col">
452
453
  {showHeader && (
@@ -625,29 +626,28 @@ export const ChatView = ({
625
626
  className="max-h-48 text-base md:text-sm"
626
627
  />
627
628
  <InputGroupAddon align="inline-end">
628
- {isRunning && onStop ? (
629
+ {isRunning && onStop && !input.trim() ? (
630
+ // Running with an empty composer: the button stops the turn.
629
631
  <InputGroupButton
630
632
  type="button"
631
633
  size="icon-sm"
632
634
  variant="default"
633
- onClick={onStop}
635
+ onClick={() => onStop()}
634
636
  aria-label="Stop response"
635
637
  >
636
638
  <SquareIcon className="size-3 fill-current" />
637
639
  </InputGroupButton>
638
640
  ) : (
641
+ // Idle, or running with typed text: the button sends. A send
642
+ // mid-run steers the live turn (see the driver's sendMessage).
639
643
  <InputGroupButton
640
644
  type="submit"
641
645
  size="icon-sm"
642
646
  variant="default"
643
- disabled={!input.trim() || isRunning}
644
- aria-label="Send message"
647
+ disabled={!input.trim()}
648
+ aria-label={isRunning ? "Steer response" : "Send message"}
645
649
  >
646
- {isRunning ? (
647
- <Spinner className="size-3" />
648
- ) : (
649
- <SendIcon className="size-3" />
650
- )}
650
+ <SendIcon className="size-3" />
651
651
  </InputGroupButton>
652
652
  )}
653
653
  </InputGroupAddon>
@@ -21,6 +21,7 @@ import {
21
21
  DEFAULT_THREAD_SESSION_KEY,
22
22
  isSessionRunning,
23
23
  sessionKey,
24
+ terminateRunningToolEvents,
24
25
  type ThreadSession,
25
26
  } from "../support/thread-sessions";
26
27
  import { ChatView } from "./chat-view";
@@ -246,18 +247,20 @@ export const useMastraChat = (
246
247
  // using it as a hook dep doesn't refire the initial-history fetch on
247
248
  // every parent render.
248
249
  const mastraClient = useMastraClient();
249
- // Apply the selected model as a per-request override header in place,
250
- // so the next `agent.stream()` picks it up without rebuilding the
251
- // client (which would refire history loads on every model change).
252
- useEffect(() => {
253
- mastraClient.setModelOverride(model || undefined);
254
- }, [mastraClient, model]);
250
+ // The selected model rides each turn as a per-call override (see
251
+ // `runStream`), never stored on the shared client - so two threads can
252
+ // stream under different models without clobbering each other. Mirror it
253
+ // into a ref so the send path reads the latest selection without adding
254
+ // `model` to its dependency list.
255
+ const modelRef = useRef(model);
256
+ modelRef.current = model;
255
257
  const agentId = options.agentId ?? mastraClient.defaultAgent;
256
258
  // Built-in conversation management. When on, the chat always drives an
257
259
  // explicit client thread id (rather than leaning on the per-session
258
260
  // cookie) so it can reference, persist, and switch between the
259
- // conversations a user owns. Selecting a thread routes every call
260
- // (stream + history + clear) at it via `mastraClient.setThreadId`.
261
+ // conversations a user owns. Each call (stream / history / clear) carries
262
+ // its thread id per request, so many threads can run concurrently without
263
+ // sharing routing state.
261
264
  const enableThreads = options.enableThreads !== false;
262
265
  // Export is opt-in (default off): the host turns it on explicitly.
263
266
  const enableExport = options.enableExport === true;
@@ -693,7 +696,7 @@ export const useMastraChat = (
693
696
  async (
694
697
  threadId: string,
695
698
  assistantId: string,
696
- open: () => Promise<MastraStreamResponse>,
699
+ open: (signal: AbortSignal) => Promise<MastraStreamResponse>,
697
700
  ) => {
698
701
  const controller = new AbortController();
699
702
  let token = 0;
@@ -708,14 +711,14 @@ export const useMastraChat = (
708
711
  error: null,
709
712
  status: "submitted",
710
713
  runToken: token,
714
+ // Superseding an in-flight run (a steer that interrupts, or a rapid
715
+ // re-send) stops its stream, so close any pills it left running.
716
+ toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
711
717
  };
712
718
  });
713
719
  const runIdRef = { current: getSession(threadId).runId };
714
720
  try {
715
- const streamThreadId =
716
- threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
717
- mastraClient.setThreadId(streamThreadId);
718
- const stream = await open();
721
+ const stream = await open(controller.signal);
719
722
  await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
720
723
  updateSession(threadId, (session) => {
721
724
  if (session.runToken !== token) return session;
@@ -745,7 +748,7 @@ export const useMastraChat = (
745
748
  }));
746
749
  }
747
750
  },
748
- [getSession, mastraClient, processStream, refreshThreadsSoon, updateSession],
751
+ [getSession, processStream, refreshThreadsSoon, updateSession],
749
752
  );
750
753
 
751
754
  const runStream = useCallback(
@@ -757,35 +760,56 @@ export const useMastraChat = (
757
760
  assistantId,
758
761
  runId,
759
762
  }));
760
- return driveStream(threadId, assistantId, () => {
761
- const agent = mastraClient.getAgent(agentId);
762
- const messagesForAgent = history.flatMap((m) =>
763
+ // Capture this run's thread + model at send time and pass them per
764
+ // call, so a run keeps its own routing even if the user switches
765
+ // threads or changes the model picker while it streams.
766
+ const streamThreadId =
767
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
768
+ const model = modelRef.current || undefined;
769
+ return driveStream(threadId, assistantId, (signal) => {
770
+ const messages = history.flatMap((m) =>
763
771
  m.parts
764
772
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
765
773
  .map((p) => ({ role: m.role, content: p.text })),
766
- ) as Parameters<typeof agent.stream>[0];
767
- return agent.stream(messagesForAgent, {
774
+ );
775
+ return mastraClient.streamAgent({
776
+ agentId,
777
+ messages,
768
778
  runId,
769
- }) as Promise<MastraStreamResponse>;
779
+ threadId: streamThreadId,
780
+ model,
781
+ signal,
782
+ });
770
783
  });
771
784
  },
772
785
  [driveStream, mastraClient, agentId, updateSession],
773
786
  );
774
787
 
775
- const stop = useCallback(() => {
776
- const threadId = activeKey;
777
- updateSession(threadId, (session) => {
778
- if (!isSessionRunning(session)) return session;
779
- session.abortController?.abort();
780
- return {
781
- ...session,
782
- abortController: null,
783
- runToken: session.runToken + 1,
784
- error: null,
785
- status: "ready",
786
- };
787
- });
788
- }, [activeKey, updateSession]);
788
+ // Cancel a thread's in-flight run. Defaults to the active thread (the
789
+ // composer stop button), but takes an explicit thread id so the sidebar
790
+ // can cancel a background thread without switching to it. Aborting one
791
+ // thread's controller leaves every other run streaming - each run owns
792
+ // its own controller and routing.
793
+ const stop = useCallback(
794
+ (threadId?: string) => {
795
+ const key = threadId ?? activeKey;
796
+ updateSession(key, (session) => {
797
+ if (!isSessionRunning(session)) return session;
798
+ session.abortController?.abort();
799
+ return {
800
+ ...session,
801
+ abortController: null,
802
+ runToken: session.runToken + 1,
803
+ error: null,
804
+ status: "ready",
805
+ // Cancelling stops the stream, so close any tool pills still marked
806
+ // running - the closing chunks will never arrive.
807
+ toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
808
+ };
809
+ });
810
+ },
811
+ [activeKey, updateSession],
812
+ );
789
813
 
790
814
  /**
791
815
  * Approve or deny an in-flight `requireApproval` tool call. The
@@ -830,10 +854,22 @@ export const useMastraChat = (
830
854
  toolCallId,
831
855
  runId,
832
856
  });
833
- await driveStream(activeKey, assistantId, () =>
857
+ const streamThreadId =
858
+ activeKey === DEFAULT_THREAD_SESSION_KEY ? undefined : activeKey;
859
+ await driveStream(activeKey, assistantId, (signal) =>
834
860
  decision.approved
835
- ? mastraClient.approveToolCallStream(agentId, { runId, toolCallId })
836
- : mastraClient.declineToolCallStream(agentId, { runId, toolCallId }),
861
+ ? mastraClient.approveToolCallStream(agentId, {
862
+ runId,
863
+ toolCallId,
864
+ threadId: streamThreadId,
865
+ signal,
866
+ })
867
+ : mastraClient.declineToolCallStream(agentId, {
868
+ runId,
869
+ toolCallId,
870
+ threadId: streamThreadId,
871
+ signal,
872
+ }),
837
873
  );
838
874
  },
839
875
  [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
@@ -856,8 +892,30 @@ export const useMastraChat = (
856
892
  }
857
893
  }
858
894
  }
859
- const next = [...getSession(threadId).messages, makeUserMessage(text)];
895
+ const before = getSession(threadId);
896
+ const next = [...before.messages, makeUserMessage(text)];
860
897
  writeMessages(threadId, next);
898
+ // Steering: if a turn is already streaming on this thread, try to hand
899
+ // the message to the live run so the agent folds it into the current
900
+ // turn (no restart). If the server won't deliver it, fall back to
901
+ // interrupting the run and starting a fresh turn with the message
902
+ // included, so a steer never silently drops.
903
+ if (isSessionRunning(before) && before.runId) {
904
+ const runId = before.runId;
905
+ const steerThreadId =
906
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
907
+ void mastraClient
908
+ .queueMessage({ agentId, runId, threadId: steerThreadId, message: text })
909
+ .then((accepted) => {
910
+ if (accepted) {
911
+ logger.info("steer:queued", { runId });
912
+ return;
913
+ }
914
+ logger.info("steer:fallback-interrupt", { runId });
915
+ void runStream(threadId, getSession(threadId).messages);
916
+ });
917
+ return;
918
+ }
861
919
  void runStream(threadId, next);
862
920
  },
863
921
  [
@@ -866,6 +924,8 @@ export const useMastraChat = (
866
924
  activeThreadId,
867
925
  activeKey,
868
926
  getSession,
927
+ mastraClient,
928
+ agentId,
869
929
  noteThreadActivity,
870
930
  updateSession,
871
931
  ],
@@ -882,7 +942,7 @@ export const useMastraChat = (
882
942
  const handleClear = useCallback(async () => {
883
943
  const threadId = activeKey;
884
944
  try {
885
- const result = await mastraClient.clearHistory({ agentId });
945
+ const result = await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
886
946
  logger.info("history cleared", { cleared: result.cleared });
887
947
  } catch (error) {
888
948
  logger.error("history clear error", {
@@ -1039,7 +1099,6 @@ export const useMastraChat = (
1039
1099
  // refetching or aborting other threads' runs.
1040
1100
  useEffect(() => {
1041
1101
  const threadId = activeKey;
1042
- mastraClient.setThreadId(activeThreadId);
1043
1102
  const session = getSession(threadId);
1044
1103
  if (session.historyLoaded) {
1045
1104
  setIsLoadingHistory(false);
@@ -1053,6 +1112,7 @@ export const useMastraChat = (
1053
1112
  mastraClient
1054
1113
  .history({
1055
1114
  agentId,
1115
+ threadId: activeThreadId,
1056
1116
  page: 0,
1057
1117
  perPage: HISTORY_PAGE_SIZE,
1058
1118
  signal: controller.signal,
@@ -1096,7 +1156,7 @@ export const useMastraChat = (
1096
1156
  const page = session.historyPage;
1097
1157
  updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
1098
1158
  mastraClient
1099
- .history({ agentId, page, perPage: HISTORY_PAGE_SIZE })
1159
+ .history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
1100
1160
  .then((response) => {
1101
1161
  const uiMessages = response.uiMessages as unknown as UIMessage[];
1102
1162
  if (uiMessages.length > 0) {
@@ -1123,7 +1183,15 @@ export const useMastraChat = (
1123
1183
  historyInFlightRef.current = false;
1124
1184
  setIsLoadingMore(false);
1125
1185
  });
1126
- }, [activeKey, getSession, mastraClient, agentId, updateSession, writeMessages]);
1186
+ }, [
1187
+ activeKey,
1188
+ activeThreadId,
1189
+ getSession,
1190
+ mastraClient,
1191
+ agentId,
1192
+ updateSession,
1193
+ writeMessages,
1194
+ ]);
1127
1195
 
1128
1196
  // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
1129
1197
  // straight off the client so the export inlines the same charts /
@@ -1356,6 +1424,9 @@ export const useMastraChat = (
1356
1424
  onNewThread: newThread,
1357
1425
  onDeleteThread: deleteThread,
1358
1426
  onRenameThread: renameThread,
1427
+ // Cancel a background thread's run from the sidebar without
1428
+ // switching to it.
1429
+ onCancelThread: stop,
1359
1430
  // Persisted sidebar visibility, controlled from the driver so
1360
1431
  // the show/hide choice survives reloads.
1361
1432
  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
@@ -72,43 +72,99 @@ 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.
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.
101
+ */
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);
124
+ }
125
+
126
+ /**
127
+ * Steer a live run by handing it another message mid-turn ("queue"), via
128
+ * the agent `/queue-message` route. `behavior` controls what happens when
129
+ * the run is active: `deliver` (default) folds the message into the current
130
+ * turn so the agent picks it up without restarting; `persist` holds it for
131
+ * the next turn; `discard` drops it. Routed per call (own thread header, no
132
+ * shared client state) like {@link streamAgent}.
101
133
  *
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.
134
+ * Resolves `true` when the server accepted the message for delivery, `false`
135
+ * otherwise (unknown run / route unsupported / non-2xx) - never throws for a
136
+ * rejected steer, so the caller can fall back to interrupting + resending.
137
+ * Queue / signal routes are `@experimental` in Mastra.
107
138
  */
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];
139
+ async queueMessage(params: {
140
+ agentId: string;
141
+ runId: string;
142
+ threadId?: string;
143
+ message: string;
144
+ behavior?: "deliver" | "persist" | "discard";
145
+ }): Promise<boolean> {
146
+ const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/queue-message`;
147
+ try {
148
+ const response = await fetch(url, {
149
+ method: "POST",
150
+ credentials: "include",
151
+ headers: {
152
+ "Content-Type": "application/json",
153
+ ...this.#routingHeaders({ threadId: params.threadId }),
154
+ },
155
+ body: JSON.stringify({
156
+ runId: params.runId,
157
+ ...(params.threadId ? { threadId: params.threadId } : {}),
158
+ message: params.message,
159
+ ifActive: { behavior: params.behavior ?? "deliver" },
160
+ }),
161
+ });
162
+ if (!response.ok) return false;
163
+ const body = (await response.json().catch(() => null)) as { accepted?: boolean } | null;
164
+ return body?.accepted === true;
165
+ } catch {
166
+ return false;
167
+ }
112
168
  }
113
169
 
114
170
  /**
@@ -120,7 +176,7 @@ export class MastraPluginClient extends MastraClient {
120
176
  */
121
177
  async approveToolCallStream(
122
178
  agentId: string,
123
- params: { runId: string; toolCallId: string },
179
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
124
180
  ): Promise<MastraStreamResponse> {
125
181
  return this.#toolApprovalStream(agentId, "approve-tool-call", params);
126
182
  }
@@ -131,7 +187,7 @@ export class MastraPluginClient extends MastraClient {
131
187
  */
132
188
  async declineToolCallStream(
133
189
  agentId: string,
134
- params: { runId: string; toolCallId: string },
190
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
135
191
  ): Promise<MastraStreamResponse> {
136
192
  return this.#toolApprovalStream(agentId, "decline-tool-call", params);
137
193
  }
@@ -139,24 +195,22 @@ export class MastraPluginClient extends MastraClient {
139
195
  async #toolApprovalStream(
140
196
  agentId: string,
141
197
  route: "approve-tool-call" | "decline-tool-call",
142
- params: { runId: string; toolCallId: string },
198
+ params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
143
199
  ): Promise<MastraStreamResponse> {
144
- const response = (await this.request(
145
- `/agents/${encodeURIComponent(agentId)}/${route}`,
146
- {
147
- method: "POST",
148
- body: params,
149
- stream: true,
200
+ const url = `${this.basePath}/agents/${encodeURIComponent(agentId)}/${route}`;
201
+ const init: RequestInit = {
202
+ method: "POST",
203
+ credentials: "include",
204
+ headers: {
205
+ "Content-Type": "application/json",
206
+ ...this.#routingHeaders({ threadId: params.threadId }),
150
207
  },
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
- );
208
+ body: JSON.stringify({ runId: params.runId, toolCallId: params.toolCallId }),
209
+ };
210
+ if (params.signal) init.signal = params.signal;
211
+ const response = await fetch(url, init);
212
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
213
+ return asMastraStreamResponse(response);
160
214
  }
161
215
 
162
216
  /**
@@ -209,11 +263,14 @@ export class MastraPluginClient extends MastraClient {
209
263
  /**
210
264
  * Fetch one page of thread history from `GET ${basePath}/history`.
211
265
  * Messages come back oldest -> newest so the caller can prepend them
212
- * to a live transcript.
266
+ * to a live transcript. `threadId` targets a specific conversation
267
+ * (sent as the `?threadId=` query so it doesn't depend on shared client
268
+ * state); omit it to read the per-session cookie thread.
213
269
  */
214
270
  async history(
215
271
  options: {
216
272
  agentId?: string;
273
+ threadId?: string;
217
274
  page?: number;
218
275
  perPage?: number;
219
276
  signal?: AbortSignal;
@@ -222,6 +279,7 @@ export class MastraPluginClient extends MastraClient {
222
279
  const params = new URLSearchParams();
223
280
  if (options.page !== undefined) params.set("page", String(options.page));
224
281
  if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
282
+ if (options.threadId) params.set(thread.THREAD_ID_QUERY, options.threadId);
225
283
  const qs = params.toString();
226
284
  const base = this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId);
227
285
  return this.#getJson(
@@ -232,19 +290,26 @@ export class MastraPluginClient extends MastraClient {
232
290
  }
233
291
 
234
292
  /**
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
293
+ * Wipe a thread's history (`DELETE ${basePath}/history`). `threadId`
294
+ * targets a specific conversation via the thread-selection header (so it
295
+ * doesn't depend on shared client state); omit it to clear the per-session
296
+ * cookie thread. The session cookie that anchors the thread id is
297
+ * preserved - only the messages go away. Idempotent: a fresh thread reports
238
298
  * `cleared: 0` without erroring.
239
299
  */
240
300
  async clearHistory(
241
- options: { agentId?: string; signal?: AbortSignal } = {},
301
+ options: { agentId?: string; threadId?: string; signal?: AbortSignal } = {},
242
302
  ): Promise<MastraClearHistoryResponse> {
243
303
  return this.#mutateJson(
244
304
  this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId),
245
305
  "DELETE",
246
306
  wire.MastraClearHistoryResponseSchema,
247
- options.signal ? { signal: options.signal } : {},
307
+ {
308
+ ...(options.threadId
309
+ ? { headers: { [thread.THREAD_ID_HEADER]: options.threadId } }
310
+ : {}),
311
+ ...(options.signal ? { signal: options.signal } : {}),
312
+ },
248
313
  );
249
314
  }
250
315
 
@@ -282,9 +347,8 @@ export class MastraPluginClient extends MastraClient {
282
347
  * clashing with the inherited `MastraClient.deleteThread`, which hits
283
348
  * Mastra's stock thread route rather than our OBO-scoped, custom
284
349
  * 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 /
350
+ * call, so the sidebar can remove any thread while the user stays on
351
+ * another (routing is per call, never shared). Idempotent: deleting an unknown /
288
352
  * already-removed thread reports `deleted: false` without erroring.
289
353
  */
290
354
  async removeThread(
@@ -306,9 +370,8 @@ export class MastraPluginClient extends MastraClient {
306
370
  * Rename a single conversation thread via the plugin's own
307
371
  * `PATCH ${basePath}/threads` route. The id travels as the
308
372
  * 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
373
+ * {@link removeThread}, so the sidebar can rename any thread; routing is
374
+ * per call, never shared) and the new `title` rides in the JSON body. The server enforces ownership and
312
375
  * echoes back the updated thread, so the caller can reflect the new
313
376
  * title immediately. Throws on an unknown / unowned thread (HTTP 404).
314
377
  */
@@ -384,13 +447,13 @@ export class MastraPluginClient extends MastraClient {
384
447
  }
385
448
 
386
449
  /**
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}).
450
+ * Snapshot of the client's base headers for the custom-route fetches
451
+ * that don't go through `@mastra/client-js`'s own request pipeline.
452
+ * Returns a fresh object each call so a caller can safely add one-off
453
+ * headers without mutating the shared `options.headers`. Routing
454
+ * (thread selection, model override) is passed per call - via a
455
+ * `?threadId=` query or the thread-selection header on the individual
456
+ * request - not stored here, so concurrent calls never share routing.
394
457
  */
395
458
  #defaultHeaders(): Record<string, string> {
396
459
  return { ...((this.options.headers as Record<string, string>) ?? {}) };
@@ -416,9 +479,8 @@ export class MastraPluginClient extends MastraClient {
416
479
  * `POST` / `DELETE` / `PATCH` + JSON-parse + schema-validate for the
417
480
  * mutating routes (`clearHistory` / `removeThread` / `renameThread` /
418
481
  * `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.
482
+ * `options.headers` add per-call headers (e.g. the thread-selection
483
+ * header for a targeted delete / rename) over the client's base headers.
422
484
  */
423
485
  async #mutateJson<T>(
424
486
  url: string,
@@ -477,12 +539,12 @@ export const useMastraConfig = (): MastraClientConfig =>
477
539
  /**
478
540
  * The single {@link MastraPluginClient} for the plugin, built once from
479
541
  * 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.
542
+ * `client.streamAgent({ agentId, messages, runId, threadId, model, signal })`,
543
+ * page history with `client.history()`, resolve embeds with
544
+ * `client.chart(id)`, etc. Rebuilt only when `basePath` / `defaultAgent`
545
+ * change (e.g. a custom mount), so it's safe as a `useMemo` / `useCallback`
546
+ * / `useEffect` dependency; per-turn thread + model routing is passed per
547
+ * call rather than stored on the client, so concurrent runs stay isolated.
486
548
  */
487
549
  export const useMastraClient = (): MastraPluginClient => {
488
550
  const config = useMastraConfig();
@@ -50,6 +50,28 @@ export function isSessionRunning(session: ThreadSession): boolean {
50
50
  return session.status === "submitted" || session.status === "streaming";
51
51
  }
52
52
 
53
+ /**
54
+ * Settle any tool-progress pills still marked `running` to `done`. A cancelled
55
+ * or interrupted turn stops delivering the `tool-result` / `tool-error` chunks
56
+ * that would otherwise close them, so without this a Genie / chart pill would
57
+ * spin forever after the user hits stop. Returns the same map when nothing was
58
+ * running so callers can skip a needless state update.
59
+ */
60
+ export function terminateRunningToolEvents(
61
+ toolEventsByMessage: Record<string, ToolEvent[]>,
62
+ ): Record<string, ToolEvent[]> {
63
+ let changed = false;
64
+ const next: Record<string, ToolEvent[]> = {};
65
+ for (const [messageId, events] of Object.entries(toolEventsByMessage)) {
66
+ next[messageId] = events.map((event) => {
67
+ if (event.status !== "running") return event;
68
+ changed = true;
69
+ return { ...event, status: "done" as const };
70
+ });
71
+ }
72
+ return changed ? next : toolEventsByMessage;
73
+ }
74
+
53
75
  export function sessionKey(activeThreadId: string | undefined): string {
54
76
  return activeThreadId ?? DEFAULT_THREAD_SESSION_KEY;
55
77
  }