@dbx-tools/ui-mastra 0.9.129 → 0.9.132

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.
@@ -1,6 +1,7 @@
1
1
  import { errorUtils, hash, log } from "@dbx-tools/shared-core";
2
2
  import { isStaleMastraResumeError } from "@dbx-tools/shared-mastra/resume";
3
3
  import type { ReasoningEffort } from "@dbx-tools/shared-model";
4
+ import { getAuthStatus } from "@dbx-tools/ui/auth/react";
4
5
  import { useBrand } from "@dbx-tools/ui/branding/react";
5
6
  import type { FileUIPart, UIMessage } from "ai";
6
7
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -9,7 +10,7 @@ import { useChatApprovals } from "./chat-approvals.ts";
9
10
  import { useChatFeedback } from "./chat-feedback.ts";
10
11
  import { useChatHistory } from "./chat-history.ts";
11
12
  import { useChatSessions } from "./chat-sessions.ts";
12
- import { useChatStream } from "./chat-stream.ts";
13
+ import { useChatStream, waitForTerminalChunk } from "./chat-stream.ts";
13
14
  import { ChatView } from "./chat-view.tsx";
14
15
  import { dedupeSuggestions } from "./suggestions.ts";
15
16
  import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
@@ -20,8 +21,10 @@ import {
20
21
  storeActiveRun,
21
22
  } from "../support/active-runs.ts";
22
23
  import type { EmbedResolver, ExportFormat } from "../support/export.ts";
24
+ import { GenieChatClient } from "../support/genie-client.ts";
23
25
  import {
24
26
  useMastraClient,
27
+ useMastraConfig,
25
28
  useMastraDefaultModel,
26
29
  useMastraMlflowExperiment,
27
30
  useMastraModels,
@@ -42,10 +45,13 @@ import {
42
45
  type MastraRequestContextSnapshot,
43
46
  type MastraRequestContextSource,
44
47
  } from "../support/request-context.ts";
48
+ import { threadStorageKey } from "../support/thread-persistence.ts";
45
49
  import {
46
50
  DEFAULT_THREAD_SESSION_KEY,
47
51
  enqueueSteer,
52
+ interruptedTurnMessages,
48
53
  isSessionRunning,
54
+ lastUserMessage,
49
55
  removeSteer as removeSteerFromQueue,
50
56
  reorderSteers as reorderSteerQueue,
51
57
  } from "../support/thread-sessions.ts";
@@ -53,11 +59,13 @@ import {
53
59
  const _loadChatExport = () => import("../support/export.ts");
54
60
  const EMPTY_REASONING_EFFORTS: readonly ReasoningEffort[] = [];
55
61
 
56
- // Self-contained drop-in chat. `useMastraChat` drives the conversation
57
- // over `@mastra/client-js`: `agent.stream()` returns a Response
58
- // augmented with `processDataStream()`, which pushes typed Mastra
59
- // chunks that `@mastra/ai-sdk` projects into native AI SDK `UIMessage`
60
- // parts for `ChatView` to render.
62
+ /** Why the active managed thread changed. */
63
+ export type ThreadIdChangeReason = "hydrate" | "select" | "new" | "delete" | "missing" | "resolve";
64
+
65
+ // Self-contained drop-in chat. `useMastraChat` consumes native AI SDK
66
+ // `UIMessage` streams through one client contract: Mastra agents use the
67
+ // official chat route and direct Genie projects its events onto that wire.
68
+ // `ChatView` and the thread session state are transport-agnostic.
61
69
  //
62
70
  // Approval gates ride the same channel: a paused `requireApproval: true`
63
71
  // tool call emits a `tool-call-approval` chunk carrying
@@ -102,14 +110,6 @@ const deriveThreadTitle = (text: string): string => {
102
110
  return clean.length > TITLE_PREVIEW_MAX ? `${clean.slice(0, TITLE_PREVIEW_MAX - 1)}…` : clean;
103
111
  };
104
112
 
105
- /**
106
- * `localStorage` key the active thread id is persisted under, namespaced
107
- * by plugin mount + agent so two agents (or two apps on one origin)
108
- * don't clobber each other's "current conversation".
109
- */
110
- const threadStorageKey = (basePath: string, agentId: string): string =>
111
- `dbx-mastra-thread:${basePath}:${agentId}`;
112
-
113
113
  /** Read the persisted active thread id, tolerating storage being unavailable. */
114
114
  const readStoredThreadId = (key: string): string | undefined => {
115
115
  try {
@@ -162,9 +162,19 @@ export interface UseMastraChatOptions<
162
162
  > {
163
163
  /**
164
164
  * Agent to converse with. Defaults to the Mastra plugin's registered
165
- * default agent (`clientConfig().defaultAgent`).
165
+ * default agent (`clientConfig().defaultAgent`). Ignored when
166
+ * {@link genieSpace} is set.
166
167
  */
167
168
  agentId?: string;
169
+ /**
170
+ * Converse with this Genie space alias directly instead of an agent. Every
171
+ * turn is one Genie message streamed as an `ask_genie` tool call, so pills,
172
+ * thoughts, suggestions, and embeds render exactly as in an agent chat.
173
+ * Genie owns the state: its conversations are the threads and its messages
174
+ * are the history; nothing is stored in Mastra Memory. Conversations cannot
175
+ * be renamed, turns take no attachments, and feedback is unavailable.
176
+ */
177
+ genieSpace?: string;
168
178
  /**
169
179
  * Surface the built-in model picker in the header, letting the user
170
180
  * override the serving endpoint per turn (via `X-Mastra-Model`).
@@ -202,6 +212,17 @@ export interface UseMastraChatOptions<
202
212
  * chat's own width, so an embedded panel decides on its own space).
203
213
  */
204
214
  threadPlacement?: ThreadPlacement;
215
+ /**
216
+ * Host-selected managed thread id. Use this to restore a thread from a URL
217
+ * or another external state owner. Changes after mount switch the active
218
+ * conversation without remounting the chat.
219
+ */
220
+ threadId?: string;
221
+ /**
222
+ * Receives every chat-owned thread change. Missing externally selected
223
+ * threads are replaced with a new local thread and reported as `"missing"`.
224
+ */
225
+ onThreadIdChange?: (threadId: string, reason: ThreadIdChangeReason) => void;
205
226
  /**
206
227
  * Enable chat export. Off by default (opt-in). When on, the header
207
228
  * shows an "Export" menu for the whole conversation and each assistant
@@ -251,9 +272,20 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
251
272
  // stable across renders (memoized on `basePath` / `defaultAgent`) so
252
273
  // using it as a hook dep doesn't refire the initial-history fetch on
253
274
  // every parent render.
254
- const mastraClient = useMastraClient();
255
- const agentId = options.agentId ?? mastraClient.defaultAgent;
256
- const showModelPicker = Boolean(options.showModelPicker);
275
+ const pluginClient = useMastraClient();
276
+ const mastraConfig = useMastraConfig();
277
+ const genieSpace = options.genieSpace?.trim() || undefined;
278
+ const genieClient = useMemo(
279
+ () => (genieSpace ? new GenieChatClient(mastraConfig, genieSpace) : undefined),
280
+ // Same scalars `useMastraClient` keys on; `mastraConfig` identity churns.
281
+ [genieSpace, mastraConfig.basePath, mastraConfig.defaultAgent],
282
+ );
283
+ const mastraClient = genieClient ?? pluginClient;
284
+ // Genie chats key persisted state (active thread, sidebar) by space alias.
285
+ const agentId = genieSpace
286
+ ? `genie:${genieSpace}`
287
+ : (options.agentId ?? mastraClient.defaultAgent);
288
+ const showModelPicker = Boolean(options.showModelPicker) && !genieSpace;
257
289
  const modelKey = modelStorageKey(mastraClient.basePath, agentId);
258
290
  const [model, setModel] = useState(() => (showModelPicker ? readStoredModel(modelKey) : ""));
259
291
  const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort | undefined>();
@@ -299,21 +331,89 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
299
331
  const enableFeedback = options.enableFeedback !== false;
300
332
  const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
301
333
  const { url: mlflowExperimentUrl } = useMastraMlflowExperiment(feedbackAvailable);
302
- const threadKey = threadStorageKey(mastraClient.basePath, agentId);
303
- const activeRunsKey = activeRunStorageKey(mastraClient.basePath, agentId);
304
- const [activeThreadId, setActiveThreadId] = useState<string>(() =>
305
- enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : hash.id(),
334
+ const [resourceScope, setResourceScope] = useState<string | null>(null);
335
+ useEffect(() => {
336
+ let cancelled = false;
337
+ void getAuthStatus()
338
+ .then((status) => {
339
+ if (cancelled) return;
340
+ setResourceScope(
341
+ status.enabled && status.authenticated ? (status.email?.trim().toLowerCase() ?? "") : "",
342
+ );
343
+ })
344
+ .catch(() => {
345
+ if (!cancelled) setResourceScope("");
346
+ });
347
+ return () => {
348
+ cancelled = true;
349
+ };
350
+ }, []);
351
+ const persistenceReady = resourceScope !== null;
352
+ const threadKey = persistenceReady
353
+ ? threadStorageKey(mastraClient.basePath, agentId, resourceScope || undefined)
354
+ : undefined;
355
+ const activeRunsKey = activeRunStorageKey(
356
+ mastraClient.basePath,
357
+ agentId,
358
+ persistenceReady ? resourceScope || undefined : "pending",
306
359
  );
360
+ const requestedThreadId = options.threadId?.trim() || undefined;
361
+ const localThreadIdsRef = useRef(new Set<string>());
362
+ const emittedThreadIdRef = useRef<string | undefined>(undefined);
363
+ const threadIdChangeRef = useRef(options.onThreadIdChange);
364
+ threadIdChangeRef.current = options.onThreadIdChange;
365
+ const [activeThreadId, setActiveThreadId] = useState<string>(() => {
366
+ const id = requestedThreadId ?? hash.id();
367
+ if (!requestedThreadId) localThreadIdsRef.current.add(id);
368
+ return id;
369
+ });
370
+ const mintThreadId = useCallback(() => {
371
+ const id = hash.id();
372
+ localThreadIdsRef.current.add(id);
373
+ return id;
374
+ }, []);
375
+ const activateThread = useCallback((threadId: string, reason: ThreadIdChangeReason) => {
376
+ emittedThreadIdRef.current = threadId;
377
+ setActiveThreadId(threadId);
378
+ threadIdChangeRef.current?.(threadId, reason);
379
+ }, []);
380
+ const initializedThreadKeyRef = useRef<string | undefined>(undefined);
381
+ const [hydratedThreadKey, setHydratedThreadKey] = useState<string | undefined>(undefined);
382
+ useEffect(() => {
383
+ if (!enableThreads || !threadKey || initializedThreadKeyRef.current === threadKey) return;
384
+ initializedThreadKeyRef.current = threadKey;
385
+ const storedThreadId = readStoredThreadId(threadKey);
386
+ const threadId = requestedThreadId ?? storedThreadId ?? mintThreadId();
387
+ setActiveThreadId(threadId);
388
+ threadIdChangeRef.current?.(threadId, "hydrate");
389
+ setHydratedThreadKey(threadKey);
390
+ }, [enableThreads, mintThreadId, requestedThreadId, threadKey]);
391
+ useEffect(() => {
392
+ if (
393
+ enableThreads &&
394
+ hydratedThreadKey === threadKey &&
395
+ requestedThreadId &&
396
+ requestedThreadId !== activeThreadId
397
+ ) {
398
+ if (emittedThreadIdRef.current === activeThreadId) return;
399
+ setActiveThreadId(requestedThreadId);
400
+ } else if (requestedThreadId === activeThreadId) {
401
+ emittedThreadIdRef.current = undefined;
402
+ }
403
+ }, [activeThreadId, enableThreads, hydratedThreadKey, requestedThreadId, threadKey]);
307
404
  const {
308
405
  threads,
309
406
  loading: isLoadingThreads,
407
+ error: threadsError,
310
408
  refresh: refreshThreads,
311
- } = useMastraThreads(options.agentId, enableThreads);
409
+ } = useMastraThreads(options.agentId, enableThreads, genieClient);
312
410
  // Persist the active thread id so a reload reopens the same
313
411
  // conversation. Best-effort; storage may be unavailable.
314
412
  useEffect(() => {
315
- if (enableThreads && activeThreadId) storeStoredThreadId(threadKey, activeThreadId);
316
- }, [enableThreads, threadKey, activeThreadId]);
413
+ if (enableThreads && threadKey && hydratedThreadKey === threadKey && activeThreadId) {
414
+ storeStoredThreadId(threadKey, activeThreadId);
415
+ }
416
+ }, [enableThreads, threadKey, hydratedThreadKey, activeThreadId]);
317
417
  // Conversation sidebar show/hide, persisted so the user's choice
318
418
  // sticks across reloads. The view exposes a header toggle wired to
319
419
  // `onToggleSidebar`; defaults open the first time.
@@ -409,6 +509,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
409
509
  const { questions: genieSuggestions } = useMastraSuggestions(
410
510
  options.agentId,
411
511
  explicitSuggestions === undefined,
512
+ genieClient,
412
513
  );
413
514
  const suggestions = useMemo(
414
515
  () => explicitSuggestions ?? dedupeSuggestions(genieSuggestions),
@@ -418,12 +519,46 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
418
519
  activeKey,
419
520
  activeSession,
420
521
  getSession,
522
+ moveSession,
421
523
  removeSession,
422
524
  resetSession,
423
525
  streamingThreadIds,
424
526
  updateSession,
425
527
  writeMessages,
426
528
  } = useChatSessions(activeThreadId);
529
+ useEffect(() => {
530
+ if (
531
+ !enableThreads ||
532
+ hydratedThreadKey !== threadKey ||
533
+ !requestedThreadId ||
534
+ activeThreadId !== requestedThreadId ||
535
+ isLoadingThreads ||
536
+ threadsError ||
537
+ localThreadIdsRef.current.has(requestedThreadId) ||
538
+ optimisticThreads[requestedThreadId] ||
539
+ threads.some((thread) => thread.id === requestedThreadId) ||
540
+ readStoredActiveRuns(activeRunsKey).some((run) => run.threadId === requestedThreadId)
541
+ ) {
542
+ return;
543
+ }
544
+ const replacement = mintThreadId();
545
+ resetSession(replacement, true);
546
+ activateThread(replacement, "missing");
547
+ }, [
548
+ activateThread,
549
+ activeRunsKey,
550
+ activeThreadId,
551
+ enableThreads,
552
+ hydratedThreadKey,
553
+ isLoadingThreads,
554
+ mintThreadId,
555
+ optimisticThreads,
556
+ requestedThreadId,
557
+ resetSession,
558
+ threadKey,
559
+ threads,
560
+ threadsError,
561
+ ]);
427
562
  const historyAvailability = isLoadingThreads
428
563
  ? "loading"
429
564
  : threads.some((thread) => thread.id === activeThreadId) || optimisticThreads[activeThreadId]
@@ -451,6 +586,33 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
451
586
  // stale-closure hazard (assigned each render, same pattern as `loadMoreRef`).
452
587
  const drainQueueRef = useRef<(threadId: string) => void>(() => {});
453
588
 
589
+ const activeThreadIdRef = useRef(activeThreadId);
590
+ activeThreadIdRef.current = activeThreadId;
591
+ // Adopt the server-assigned id of a thread that streamed under a local id
592
+ // (a new Genie conversation): move its session and sidebar overlays, then
593
+ // switch to it when it is still the active thread.
594
+ const adoptThreadId = useCallback(
595
+ (localId: string, assignedId: string) => {
596
+ moveSession(localId, assignedId);
597
+ localThreadIdsRef.current.delete(localId);
598
+ localThreadIdsRef.current.add(assignedId);
599
+ setOptimisticThreads((prev) => {
600
+ const row = prev[localId];
601
+ if (!row) return prev;
602
+ const { [localId]: _drop, ...rest } = prev;
603
+ return { ...rest, [assignedId]: { ...row, id: assignedId } };
604
+ });
605
+ setProvisionalTitles((prev) => {
606
+ const title = prev[localId];
607
+ if (title === undefined) return prev;
608
+ const { [localId]: _drop, ...rest } = prev;
609
+ return { ...rest, [assignedId]: title };
610
+ });
611
+ if (activeThreadIdRef.current === localId) activateThread(assignedId, "resolve");
612
+ },
613
+ [activateThread, moveSession],
614
+ );
615
+
454
616
  const processStream = useChatStream({ getSession, updateSession, writeMessages });
455
617
  const driveStream = useCallback(
456
618
  async (
@@ -474,10 +636,20 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
474
636
  };
475
637
  });
476
638
  const runIdRef = { current: getSession(threadId).runId };
639
+ // The session's key; changes when the server assigns the thread its id.
640
+ let sessionThreadId = threadId;
641
+ let openedStream: MastraStreamResponse | undefined;
642
+ const adoptAssignedThreadId = () => {
643
+ const assigned = openedStream?.threadId?.();
644
+ if (!assigned || assigned === sessionThreadId) return;
645
+ adoptThreadId(sessionThreadId, assigned);
646
+ sessionThreadId = assigned;
647
+ };
477
648
  try {
478
649
  try {
479
- const stream = await open(controller.signal);
480
- await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
650
+ openedStream = await open(controller.signal);
651
+ await processStream(threadId, openedStream, assistantId, runIdRef, controller.signal);
652
+ adoptAssignedThreadId();
481
653
  } catch (initialError) {
482
654
  const runId = runIdRef.current;
483
655
  if (
@@ -512,7 +684,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
512
684
  throw reconnectError;
513
685
  }
514
686
  }
515
- updateSession(threadId, (session) => {
687
+ updateSession(sessionThreadId, (session) => {
516
688
  if (session.runToken !== token) return session;
517
689
  return {
518
690
  ...session,
@@ -522,14 +694,17 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
522
694
  runId: runIdRef.current,
523
695
  };
524
696
  });
525
- if (getSession(threadId).runToken === token) {
697
+ if (getSession(sessionThreadId).runToken === token) {
526
698
  const completedRunId = runIdRef.current;
527
699
  if (completedRunId) removeStoredActiveRun(activeRunsKey, completedRunId);
528
700
  refreshThreadsSoon();
529
701
  // Turn finished on its own: start the oldest queued steer, if any.
530
- drainQueueRef.current(threadId);
702
+ drainQueueRef.current(sessionThreadId);
531
703
  }
532
704
  } catch (caught) {
705
+ // A failed turn may still have created its server-side thread.
706
+ adoptAssignedThreadId();
707
+ const threadId = sessionThreadId;
533
708
  if (getSession(threadId).runToken !== token) return;
534
709
  if (isStaleMastraResumeError(caught)) {
535
710
  logger.warn("ignored stale mastra resume", {
@@ -567,6 +742,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
567
742
  }
568
743
  },
569
744
  [
745
+ adoptThreadId,
570
746
  agentId,
571
747
  activeRunsKey,
572
748
  getSession,
@@ -579,16 +755,25 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
579
755
  );
580
756
 
581
757
  const runStream = useCallback(
582
- (threadId: string, message: UIMessage, requestContext?: MastraRequestContextSnapshot) => {
758
+ (
759
+ threadId: string,
760
+ messages: UIMessage | UIMessage[],
761
+ requestContext?: MastraRequestContextSnapshot,
762
+ ) => {
763
+ const turnMessages = Array.isArray(messages) ? messages : [messages];
764
+ const userMessage = lastUserMessage(turnMessages);
765
+ if (!userMessage) throw new Error("A Mastra chat turn requires a user message");
583
766
  const assistantId = hash.id();
584
767
  const runId = hash.id();
585
- storeActiveRun(activeRunsKey, {
586
- threadId,
587
- runId,
588
- assistantId,
589
- userMessage: message,
590
- startedAt: Date.now(),
591
- });
768
+ if (mastraClient.backgroundTurns) {
769
+ storeActiveRun(activeRunsKey, {
770
+ threadId,
771
+ runId,
772
+ assistantId,
773
+ userMessage,
774
+ startedAt: Date.now(),
775
+ });
776
+ }
592
777
  updateSession(threadId, (session) => ({
593
778
  ...session,
594
779
  assistantId,
@@ -612,7 +797,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
612
797
  return driveStream(threadId, assistantId, (signal) => {
613
798
  return mastraClient.streamAgent({
614
799
  agentId,
615
- messages: [message],
800
+ messages: turnMessages,
616
801
  runId,
617
802
  threadId: streamThreadId,
618
803
  model,
@@ -627,7 +812,19 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
627
812
 
628
813
  const recoveredRunsKeyRef = useRef<string | undefined>(undefined);
629
814
  useEffect(() => {
630
- if (!mastraClient.backgroundTurns || recoveredRunsKeyRef.current === activeRunsKey) return;
815
+ if (!persistenceReady || mastraClient.backgroundTurns) return;
816
+ for (const run of readStoredActiveRuns(activeRunsKey)) {
817
+ removeStoredActiveRun(activeRunsKey, run.runId);
818
+ }
819
+ }, [activeRunsKey, mastraClient.backgroundTurns, persistenceReady]);
820
+ useEffect(() => {
821
+ if (
822
+ !persistenceReady ||
823
+ !mastraClient.backgroundTurns ||
824
+ recoveredRunsKeyRef.current === activeRunsKey
825
+ ) {
826
+ return;
827
+ }
631
828
  recoveredRunsKeyRef.current = activeRunsKey;
632
829
  for (const run of readStoredActiveRuns(activeRunsKey)) {
633
830
  const session = getSession(run.threadId);
@@ -664,7 +861,16 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
664
861
  if (history) writeMessages(run.threadId, toChronologicalUiMessages(history));
665
862
  });
666
863
  }
667
- }, [activeRunsKey, agentId, driveStream, getSession, mastraClient, updateSession, writeMessages]);
864
+ }, [
865
+ activeRunsKey,
866
+ agentId,
867
+ driveStream,
868
+ getSession,
869
+ mastraClient,
870
+ persistenceReady,
871
+ updateSession,
872
+ writeMessages,
873
+ ]);
668
874
 
669
875
  // Start the oldest queued steer as the next turn (FIFO drain). Called from
670
876
  // `driveStream` when a turn ends normally; a no-op when the queue is empty.
@@ -734,6 +940,56 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
734
940
  updateSession,
735
941
  });
736
942
 
943
+ /** Abort the active run and wait until its durable stream reaches a terminal event. */
944
+ const interruptActiveRun = useCallback(
945
+ async (threadId: string): Promise<void> => {
946
+ const current = getSession(threadId);
947
+ if (!isSessionRunning(current)) return;
948
+ const streamThreadId = threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
949
+ const runId = current.runId;
950
+ const settlement =
951
+ mastraClient.backgroundTurns && runId
952
+ ? mastraClient
953
+ .observeAgentStream({ agentId, runId, threadId: streamThreadId })
954
+ .then((stream) => waitForTerminalChunk(stream.stream))
955
+ .catch((error: unknown) => {
956
+ logger.warn("failed to observe interrupted background run", {
957
+ runId,
958
+ error: errorUtils.toError(error),
959
+ });
960
+ })
961
+ : undefined;
962
+ const serverAbort =
963
+ mastraClient.backgroundTurns && runId
964
+ ? mastraClient
965
+ .abortAgentRun({ agentId, runId, threadId: streamThreadId })
966
+ .then((aborted) => {
967
+ if (aborted) removeStoredActiveRun(activeRunsKey, runId);
968
+ })
969
+ .catch((error: unknown) => {
970
+ logger.warn("failed to abort interrupted background run", {
971
+ runId,
972
+ error: errorUtils.toError(error),
973
+ });
974
+ })
975
+ : undefined;
976
+ current.abortController?.abort();
977
+ updateSession(threadId, (session) => {
978
+ if (!isSessionRunning(session)) return session;
979
+ return {
980
+ ...session,
981
+ abortController: null,
982
+ runToken: session.runToken + 1,
983
+ error: null,
984
+ status: "submitted",
985
+ };
986
+ });
987
+ await serverAbort;
988
+ await settlement;
989
+ },
990
+ [activeRunsKey, agentId, getSession, mastraClient, updateSession],
991
+ );
992
+
737
993
  // Append a user message to a thread's transcript, update thread activity,
738
994
  // and derive a provisional title for a brand-new thread.
739
995
  const appendUserMessage = useCallback(
@@ -800,17 +1056,25 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
800
1056
  const sendSteerNow = useCallback(
801
1057
  (steerId: string) => {
802
1058
  const threadId = activeKey;
803
- const steer = getSession(threadId).queuedSteers.find((s) => s.id === steerId);
1059
+ const current = getSession(threadId);
1060
+ const steer = current.queuedSteers.find((s) => s.id === steerId);
804
1061
  if (!steer) return;
805
1062
  updateSession(threadId, (session) => ({
806
1063
  ...session,
807
1064
  queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
808
1065
  }));
809
1066
  logger.info("steer:send-now", { threadId });
810
- const { message } = appendUserMessage(threadId, steer.text ?? "", steer.files);
811
- void runStream(threadId, message, steer.requestContext);
1067
+ void (async () => {
1068
+ await interruptActiveRun(threadId);
1069
+ const { message } = appendUserMessage(threadId, steer.text ?? "", steer.files);
1070
+ void runStream(
1071
+ threadId,
1072
+ interruptedTurnMessages(current.messages, message),
1073
+ steer.requestContext,
1074
+ );
1075
+ })();
812
1076
  },
813
- [activeKey, appendUserMessage, getSession, runStream, updateSession],
1077
+ [activeKey, appendUserMessage, getSession, interruptActiveRun, runStream, updateSession],
814
1078
  );
815
1079
 
816
1080
  // Drop a queued steer without sending it.
@@ -870,16 +1134,16 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
870
1134
  const selectThread = useCallback(
871
1135
  (threadId: string) => {
872
1136
  if (threadId === activeThreadId) return;
873
- setActiveThreadId(threadId);
1137
+ activateThread(threadId, "select");
874
1138
  },
875
- [activeThreadId],
1139
+ [activateThread, activeThreadId],
876
1140
  );
877
1141
 
878
1142
  const newThread = useCallback(() => {
879
- const id = hash.id();
1143
+ const id = mintThreadId();
880
1144
  resetSession(id, true);
881
- setActiveThreadId(id);
882
- }, [resetSession]);
1145
+ activateThread(id, "new");
1146
+ }, [activateThread, mintThreadId, resetSession]);
883
1147
 
884
1148
  const deleteThread = useCallback(
885
1149
  async (threadId: string) => {
@@ -896,9 +1160,9 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
896
1160
  return rest;
897
1161
  });
898
1162
  if (threadId === activeThreadId) {
899
- const id = hash.id();
1163
+ const id = mintThreadId();
900
1164
  resetSession(id, true);
901
- setActiveThreadId(id);
1165
+ activateThread(id, "delete");
902
1166
  }
903
1167
  try {
904
1168
  await mastraClient.removeThread(threadId, { agentId });
@@ -917,7 +1181,16 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
917
1181
  });
918
1182
  }
919
1183
  },
920
- [activeThreadId, agentId, mastraClient, refreshThreads, removeSession, resetSession],
1184
+ [
1185
+ activateThread,
1186
+ activeThreadId,
1187
+ agentId,
1188
+ mastraClient,
1189
+ mintThreadId,
1190
+ refreshThreads,
1191
+ removeSession,
1192
+ resetSession,
1193
+ ],
921
1194
  );
922
1195
 
923
1196
  /**
@@ -1011,6 +1284,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
1011
1284
  () => ({
1012
1285
  chart: (id) => mastraClient.chart(id),
1013
1286
  statement: (id) => mastraClient.statement(id),
1287
+ visualization: (id) => mastraClient.visualization(id),
1014
1288
  }),
1015
1289
  [mastraClient],
1016
1290
  );
@@ -1185,7 +1459,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
1185
1459
  onLoadMore: loadOlderHistory,
1186
1460
  isLoadingMore,
1187
1461
  hasMore: activeSession.hasMoreHistory,
1188
- isLoadingHistory,
1462
+ isLoadingHistory: !persistenceReady || isLoadingHistory,
1463
+ ...(mastraClient.attachments ? {} : { allowAttachments: false }),
1189
1464
  ...(enableClear ? { onClear: handleClear } : {}),
1190
1465
  threadPlacement,
1191
1466
  // Conversation management: hand ChatView the thread list + handlers
@@ -1200,7 +1475,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
1200
1475
  onSelectThread: selectThread,
1201
1476
  onNewThread: newThread,
1202
1477
  onDeleteThread: deleteThread,
1203
- onRenameThread: renameThread,
1478
+ ...(mastraClient.threadRename ? { onRenameThread: renameThread } : {}),
1204
1479
  // Cancel a background thread's run from the sidebar without
1205
1480
  // switching to it.
1206
1481
  onCancelThread: stop,
@@ -207,6 +207,8 @@ export type ChatViewProps = {
207
207
  * footer, after Upload and before Export and Clear.
208
208
  */
209
209
  composerLeadingActions?: ReactNode;
210
+ /** Whether the composer accepts image attachments. Defaults to true. */
211
+ allowAttachments?: boolean;
210
212
  /**
211
213
  * Optional infinite-scroll-up handler. Fired when the user scrolls
212
214
  * within `TOP_LOAD_MORE_THRESHOLD_PX` of the top of the
@@ -37,8 +37,14 @@ const StoredActiveRunFileSchema = z
37
37
  type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
38
38
 
39
39
  /** Build the browser storage key for one Mastra agent's active runs. */
40
- export const activeRunStorageKey = (basePath: string, agentId: string): string =>
41
- `dbx-mastra-active-runs:${basePath}:${agentId}`;
40
+ export const activeRunStorageKey = (
41
+ basePath: string,
42
+ agentId: string,
43
+ resourceScope?: string,
44
+ ): string => {
45
+ const prefix = `dbx-mastra-active-runs:${basePath}:${agentId}`;
46
+ return resourceScope ? `${prefix}:${encodeURIComponent(resourceScope)}` : prefix;
47
+ };
42
48
 
43
49
  function browserStorage(): StorageLike | undefined {
44
50
  try {