@dbx-tools/ui-mastra 0.9.117 → 0.9.119

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
@@ -22,6 +22,9 @@ Key features:
22
22
 
23
23
  - Drop-in `MastraChat` component that discovers the plugin client config and
24
24
  wires itself to the default agent.
25
+ - Reload recovery for background turns: active run metadata is persisted per
26
+ agent and thread, the cached stream reconnects after a page reload, and the
27
+ final persisted history is refreshed when recovery completes.
25
28
  - Headless `useMastraChat()` driver for apps that want the same transport logic
26
29
  with custom layout.
27
30
  - Persistent `MastraAssistant` application shell with a floating launcher,
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/ui-mastra";
6
- export const PACKAGE_VERSION = "0.9.117";
6
+ export const PACKAGE_VERSION = "0.9.119";
7
7
  export * as reactBubbles from "./src/react/bubbles.tsx";
8
8
  export * as reactChatApprovals from "./src/react/chat-approvals.ts";
9
9
  export * as reactChatComposer from "./src/react/chat-composer.tsx";
@@ -27,6 +27,7 @@ export * as reactThreadSidebar from "./src/react/thread-sidebar.tsx";
27
27
  export * as reactThreadTabs from "./src/react/thread-tabs.tsx";
28
28
  export * as reactToolPill from "./src/react/tool-pill.tsx";
29
29
  export * as reactTypes from "./src/react/types.ts";
30
+ export * as supportActiveRuns from "./src/support/active-runs.ts";
30
31
  export * as supportChartOption from "./src/support/chart-option.ts";
31
32
  export * as supportChartTheme from "./src/support/chart-theme.ts";
32
33
  export * as supportClipboard from "./src/support/clipboard.ts";
@@ -65,6 +66,8 @@ export type { ThreadTabsProps, ThreadActionsProps } from "./src/react/thread-tab
65
66
  export { humanizeToolName, ToolSessionPill } from "./src/react/tool-pill.tsx";
66
67
  export type { ToolInputPresentation, WebSearchProgressGroup } from "./src/react/tool-pill.tsx";
67
68
  export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, ChatInputMessage, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
69
+ export { StoredActiveRunSchema, activeRunStorageKey } from "./src/support/active-runs.ts";
70
+ export type { StoredActiveRun } from "./src/support/active-runs.ts";
68
71
  export { LIGHT_CHART_CHROME } from "./src/support/chart-theme.ts";
69
72
  export type { ChartChrome } from "./src/support/chart-theme.ts";
70
73
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export.ts";
package/package.json CHANGED
@@ -29,11 +29,11 @@
29
29
  "react-dom": "~19.2.4"
30
30
  },
31
31
  "dependencies": {
32
- "@dbx-tools/shared-core": "^0.9.117",
33
- "@dbx-tools/shared-genie": "^0.9.117",
34
- "@dbx-tools/shared-mastra": "^0.9.117",
35
- "@dbx-tools/shared-model": "^0.9.117",
36
- "@dbx-tools/ui": "^0.9.117",
32
+ "@dbx-tools/shared-core": "^0.9.119",
33
+ "@dbx-tools/shared-genie": "^0.9.119",
34
+ "@dbx-tools/shared-mastra": "^0.9.119",
35
+ "@dbx-tools/shared-model": "^0.9.119",
36
+ "@dbx-tools/ui": "^0.9.119",
37
37
  "@mastra/client-js": "1.50.0",
38
38
  "@mastra/core": "1.71.0",
39
39
  "@tanstack/react-table": "^8.21.3",
@@ -43,13 +43,14 @@
43
43
  "lucide-react": "^0.554.0",
44
44
  "marked": "^18.0.5",
45
45
  "sql-formatter": "^15.6.9",
46
- "streamdown": "^2.5.0"
46
+ "streamdown": "^2.5.0",
47
+ "zod": "4.3.6"
47
48
  },
48
49
  "license": "Apache-2.0",
49
50
  "publishConfig": {
50
51
  "access": "public"
51
52
  },
52
- "version": "0.9.117",
53
+ "version": "0.9.119",
53
54
  "dbxToolsConfig": {
54
55
  "tags": [
55
56
  "ui"
@@ -0,0 +1,43 @@
1
+ import { isToolUIPart, type UIMessage } from "ai";
2
+
3
+ import type { ToolEvent } from "./types.ts";
4
+ import { compactReasoningText } from "../support/tool-events.ts";
5
+
6
+ /** One ordered activity entry rendered before an assistant's answer text. */
7
+ export type AssistantActivityItem =
8
+ { type: "tool"; key: string; event: ToolEvent } | { type: "thought"; key: string; text: string };
9
+
10
+ /** Preserve model reasoning and tool progress in the order they reached the message. */
11
+ export const assistantActivityItems = (
12
+ parts: UIMessage["parts"],
13
+ events: ToolEvent[],
14
+ ): AssistantActivityItem[] => {
15
+ const eventById = new Map(events.map((event) => [event.id, event]));
16
+ const renderedEventIds = new Set<string>();
17
+ const items: AssistantActivityItem[] = [];
18
+
19
+ const appendTool = (event: ToolEvent) => {
20
+ if (renderedEventIds.has(event.id)) return;
21
+ renderedEventIds.add(event.id);
22
+ items.push({ type: "tool", key: `tool-${event.id}`, event });
23
+ for (const [index, progress] of (event.progress ?? []).entries()) {
24
+ if (progress.type !== "thinking") continue;
25
+ const text = compactReasoningText(progress.text);
26
+ if (text) items.push({ type: "thought", key: `thought-${event.id}-${index}`, text });
27
+ }
28
+ };
29
+
30
+ for (const [index, part] of parts.entries()) {
31
+ if (part.type === "reasoning") {
32
+ const text = compactReasoningText(part.text);
33
+ if (text) items.push({ type: "thought", key: `reasoning-${index}`, text });
34
+ continue;
35
+ }
36
+ if (!isToolUIPart(part)) continue;
37
+ const event = eventById.get(part.toolCallId);
38
+ if (event) appendTool(event);
39
+ }
40
+
41
+ for (const event of events) appendTool(event);
42
+ return items;
43
+ };
@@ -0,0 +1,18 @@
1
+ import { Spinner } from "@dbx-tools/ui/react";
2
+
3
+ import type { ChatStatus } from "./types.ts";
4
+
5
+ /** Whether an assistant bubble should show its active-response status. */
6
+ export const shouldShowAssistantResponseStatus = (
7
+ isLast: boolean,
8
+ status: ChatStatus,
9
+ hasVisibleResponse: boolean,
10
+ ): boolean => isLast && hasVisibleResponse && (status === "submitted" || status === "streaming");
11
+
12
+ /** Compact progress affordance for an active assistant response. */
13
+ export const AssistantResponseSpinner = () => (
14
+ <span role="status" aria-live="polite" className="inline-flex items-center">
15
+ <Spinner className="size-3" />
16
+ <span className="sr-only">Response in progress</span>
17
+ </span>
18
+ );
@@ -0,0 +1,106 @@
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogAction,
4
+ AlertDialogCancel,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogFooter,
8
+ AlertDialogHeader,
9
+ AlertDialogTitle,
10
+ Button,
11
+ } from "@dbx-tools/ui/react";
12
+ import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
13
+ import { useEffect, useMemo, useRef, useState } from "react";
14
+ import type { LinkSafetyModalProps } from "streamdown";
15
+ import { copyText } from "../support/clipboard.ts";
16
+
17
+ /** Display and clipboard values for one confirmed chat link. */
18
+ export interface LinkPresentation {
19
+ displayUrl: string;
20
+ absoluteUrl: string;
21
+ }
22
+
23
+ /**
24
+ * Resolve a chat link for display and copying.
25
+ *
26
+ * Same-origin HTTP links are shown as a compact path while clipboard actions
27
+ * keep the absolute URL. External origins and non-HTTP schemes remain fully
28
+ * visible so the confirmation never obscures where the user is going.
29
+ */
30
+ export function resolveLinkPresentation(url: string, baseUrl?: string): LinkPresentation {
31
+ const fallback = { displayUrl: url, absoluteUrl: url };
32
+ const base = baseUrl ?? (typeof window === "undefined" ? undefined : window.location.href);
33
+
34
+ try {
35
+ const resolved = base ? new URL(url, base) : new URL(url);
36
+ const baseOrigin = base ? new URL(base).origin : undefined;
37
+ const isWebLink = resolved.protocol === "http:" || resolved.protocol === "https:";
38
+ const displayUrl =
39
+ isWebLink && resolved.origin === baseOrigin
40
+ ? `${resolved.pathname}${resolved.search}${resolved.hash}` || "/"
41
+ : resolved.href;
42
+ return { displayUrl, absoluteUrl: resolved.href };
43
+ } catch {
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ /** AppKit-styled confirmation shown before opening a link from assistant markdown. */
49
+ export const ExternalLinkDialog = ({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) => {
50
+ const link = useMemo(() => resolveLinkPresentation(url), [url]);
51
+ const [copied, setCopied] = useState(false);
52
+ const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+
54
+ useEffect(
55
+ () => () => {
56
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
57
+ },
58
+ [],
59
+ );
60
+
61
+ const copyLink = () => {
62
+ void copyText(link.absoluteUrl).then((ok) => {
63
+ if (!ok) return;
64
+ setCopied(true);
65
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
66
+ copiedTimer.current = setTimeout(() => setCopied(false), 1500);
67
+ });
68
+ };
69
+
70
+ return (
71
+ <AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
72
+ <AlertDialogContent className="border-border/60 p-5 shadow-2xl dark:border-white/10 sm:max-w-md">
73
+ <AlertDialogHeader>
74
+ <AlertDialogTitle className="flex items-center gap-2">
75
+ <ExternalLinkIcon className="size-4" aria-hidden="true" />
76
+ Open external link?
77
+ </AlertDialogTitle>
78
+ <AlertDialogDescription>
79
+ You&apos;re about to visit a link outside this chat.
80
+ </AlertDialogDescription>
81
+ </AlertDialogHeader>
82
+ <div
83
+ className="max-h-32 overflow-y-auto break-all rounded-md bg-muted px-3 py-2 font-mono text-sm"
84
+ title={link.absoluteUrl}
85
+ >
86
+ {link.displayUrl}
87
+ </div>
88
+ <AlertDialogFooter>
89
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
90
+ <Button type="button" variant="outline" onClick={copyLink}>
91
+ {copied ? (
92
+ <CheckIcon className="size-4" aria-hidden="true" />
93
+ ) : (
94
+ <CopyIcon className="size-4" aria-hidden="true" />
95
+ )}
96
+ {copied ? "Copied" : "Copy link"}
97
+ </Button>
98
+ <AlertDialogAction onClick={onConfirm}>
99
+ <ExternalLinkIcon className="size-4" aria-hidden="true" />
100
+ Open link
101
+ </AlertDialogAction>
102
+ </AlertDialogFooter>
103
+ </AlertDialogContent>
104
+ </AlertDialog>
105
+ );
106
+ };
@@ -18,7 +18,6 @@ import type { UIMessage } from "ai";
18
18
  import {
19
19
  BugIcon,
20
20
  CheckIcon,
21
- ChevronDownIcon,
22
21
  CopyIcon,
23
22
  MessageSquareIcon,
24
23
  RefreshCcwIcon,
@@ -26,6 +25,11 @@ import {
26
25
  XIcon,
27
26
  } from "lucide-react";
28
27
  import { useState } from "react";
28
+ import { assistantActivityItems } from "./_assistant-activity.ts";
29
+ import {
30
+ AssistantResponseSpinner,
31
+ shouldShowAssistantResponseStatus,
32
+ } from "./_assistant-response-status.tsx";
29
33
  import { MarkdownWithEmbeds } from "./embed-slots.tsx";
30
34
  import { ExportMenu } from "./export-menu.tsx";
31
35
  import { FeedbackControls } from "./feedback-controls.tsx";
@@ -43,24 +47,12 @@ import type {
43
47
  ToolEvent,
44
48
  } from "./types.ts";
45
49
  import { copyText } from "../support/clipboard.ts";
46
- import {
47
- compactReasoningText,
48
- genieReasoningText,
49
- mergeToolEvents,
50
- toolEventsFromParts,
51
- } from "../support/tool-events.ts";
50
+ import { mergeToolEvents, toolEventsFromParts } from "../support/tool-events.ts";
52
51
 
53
52
  // User / assistant message bubbles plus the inline approval card and
54
53
  // the helpers that surface approval-gated tool calls out of a message's
55
54
  // parts.
56
55
 
57
- const getReasoningText = (parts: UIMessage["parts"]): string =>
58
- parts
59
- .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
60
- .map((p) => compactReasoningText(p.text))
61
- .filter(Boolean)
62
- .join("\n");
63
-
64
56
  const UserAvatar = () => (
65
57
  <Avatar className="size-7">
66
58
  <AvatarFallback>
@@ -69,6 +61,19 @@ const UserAvatar = () => (
69
61
  </Avatar>
70
62
  );
71
63
 
64
+ const ThoughtsDisclosure = ({ text }: { text: string }) => (
65
+ <Collapsible>
66
+ <CollapsibleTrigger className="w-fit cursor-pointer rounded-md border border-border/40 bg-background/30 px-2 py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
67
+ Thoughts
68
+ </CollapsibleTrigger>
69
+ <CollapsibleContent>
70
+ <div className="mt-1 border-l-2 border-border/60 pl-2.5 text-xs text-muted-foreground">
71
+ <ToolMarkdown className="prose-p:my-0 [&_p]:!my-0">{text}</ToolMarkdown>
72
+ </div>
73
+ </CollapsibleContent>
74
+ </Collapsible>
75
+ );
76
+
72
77
  /** A tool-call input shaped like an outbound email (the `send_email` tool). */
73
78
  type EmailApprovalInput = {
74
79
  to?: string | string[];
@@ -360,18 +365,7 @@ export const AssistantBubble = ({
360
365
  ? "done"
361
366
  : undefined;
362
367
  const toolEvents = mergeToolEvents(toolEventsFromParts(message.parts), events, terminalToolState);
363
- const reasoning = [getReasoningText(message.parts), genieReasoningText(toolEvents)]
364
- .filter(Boolean)
365
- .join("\n");
366
- const isReasoningStreaming =
367
- isLast &&
368
- status === "streaming" &&
369
- (message.parts.at(-1)?.type === "reasoning" ||
370
- toolEvents.some(
371
- (event) =>
372
- event.status === "running" &&
373
- event.progress?.some((progress) => progress.type === "thinking"),
374
- ));
368
+ const activityItems = assistantActivityItems(message.parts, toolEvents);
375
369
  const textParts = message.parts.filter(
376
370
  (p): p is { type: "text"; text: string } => p.type === "text",
377
371
  );
@@ -394,6 +388,14 @@ export const AssistantBubble = ({
394
388
  // `error`, or a newer message has taken over the `isLast` slot.
395
389
  const isStreamingThisBubble = isLast && (status === "streaming" || status === "submitted");
396
390
  const suggestions = isStreamingThisBubble ? [] : collectSuggestions(events);
391
+ const showResponseStatus = shouldShowAssistantResponseStatus(
392
+ isLast,
393
+ status,
394
+ hasText || activityItems.length > 0 || pendingApprovals.length > 0,
395
+ );
396
+ const hasFooterActions =
397
+ hasText && (isLast || Boolean(onExport) || Boolean(onFeedback) || Boolean(mlflowExperimentUrl));
398
+ const showFooter = showResponseStatus || hasFooterActions;
397
399
  // Charts and tables are placed at inline marker positions in
398
400
  // the assistant's prose. `prepare_chart` / `render_data` mint
399
401
  // a `chartId` and the model embeds `[chart:<chartId>]`; for raw
@@ -412,26 +414,13 @@ export const AssistantBubble = ({
412
414
  * `min-width: auto`, which sizes to content and overflows.
413
415
  */}
414
416
  <ItemContent className="min-w-0 gap-1.5">
415
- {/*
416
- * Tool session pill leads the message. Charts and tables are
417
- * embedded inline in the prose (via `[chart:<id>]` / `[data:<id>]`
418
- * markers rendered by {@link MarkdownWithEmbeds}), so keeping the
419
- * pill above the text parts guarantees the "what the agent did"
420
- * summary always sits before its resulting charts and answer.
421
- */}
422
- {toolEvents.length > 0 && <ToolSessionPill events={toolEvents} />}
423
- {reasoning && (
424
- <Collapsible>
425
- <CollapsibleTrigger className="group flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
426
- <ChevronDownIcon className="size-3 transition-transform group-data-[state=closed]:-rotate-90" />
427
- <span>{isReasoningStreaming ? "Thinking..." : "Thoughts"}</span>
428
- </CollapsibleTrigger>
429
- <CollapsibleContent>
430
- <div className="mt-1 border-l-2 border-border/60 pl-2.5 text-xs text-muted-foreground">
431
- <ToolMarkdown className="prose-p:my-0 [&_p]:!my-0">{reasoning}</ToolMarkdown>
432
- </div>
433
- </CollapsibleContent>
434
- </Collapsible>
417
+ {/* Tool calls and their thought events stay in arrival order above the answer text. */}
418
+ {activityItems.map((item) =>
419
+ item.type === "tool" ? (
420
+ <ToolSessionPill key={item.key} events={[item.event]} />
421
+ ) : (
422
+ <ThoughtsDisclosure key={item.key} text={item.text} />
423
+ ),
435
424
  )}
436
425
  {/*
437
426
  * Render each text part as its own block. A multi-step turn
@@ -459,9 +448,9 @@ export const AssistantBubble = ({
459
448
  onResolve={onResolveToolApproval}
460
449
  />
461
450
  ))}
462
- {hasText && (isLast || onExport || onFeedback || mlflowExperimentUrl) && (
463
- <div className="flex items-center gap-1">
464
- {isLast && regenerate && (
451
+ {showFooter && (
452
+ <div className="flex min-h-7 items-center gap-1">
453
+ {hasText && isLast && regenerate && (
465
454
  <Tooltip>
466
455
  <TooltipTrigger asChild>
467
456
  <Button
@@ -478,7 +467,7 @@ export const AssistantBubble = ({
478
467
  <TooltipContent>Retry</TooltipContent>
479
468
  </Tooltip>
480
469
  )}
481
- {isLast && (
470
+ {hasText && isLast && (
482
471
  <Tooltip>
483
472
  <TooltipTrigger asChild>
484
473
  <Button
@@ -495,14 +484,16 @@ export const AssistantBubble = ({
495
484
  <TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
496
485
  </Tooltip>
497
486
  )}
498
- {onExport && <ExportMenu onExport={onExport} iconOnly tooltip="Export message" />}
499
- {onFeedback && (
487
+ {hasText && onExport && (
488
+ <ExportMenu onExport={onExport} iconOnly tooltip="Export message" />
489
+ )}
490
+ {hasText && onFeedback && (
500
491
  <FeedbackControls
501
492
  onSubmit={onFeedback}
502
493
  {...(feedbackValue ? { value: feedbackValue } : {})}
503
494
  />
504
495
  )}
505
- {mlflowExperimentUrl && (
496
+ {hasText && mlflowExperimentUrl && (
506
497
  <Tooltip>
507
498
  <TooltipTrigger asChild>
508
499
  <Button asChild type="button" size="icon" variant="ghost" className="size-7">
@@ -519,6 +510,11 @@ export const AssistantBubble = ({
519
510
  <TooltipContent>Open MLflow experiment</TooltipContent>
520
511
  </Tooltip>
521
512
  )}
513
+ {showResponseStatus && (
514
+ <div className="flex items-center text-muted-foreground">
515
+ <AssistantResponseSpinner />
516
+ </div>
517
+ )}
522
518
  </div>
523
519
  )}
524
520
  <SuggestionPills questions={suggestions} onSelect={onSuggestionClick} className="mt-1" />
@@ -9,6 +9,7 @@ import type {
9
9
  } from "./chat-sessions.ts";
10
10
  import type { PendingApproval } from "./types.ts";
11
11
  import type { MastraPluginClient } from "../support/mastra-client.ts";
12
+ import { isSessionRunning } from "../support/thread-sessions.ts";
12
13
 
13
14
  const HISTORY_PAGE_SIZE = 20;
14
15
  const logger = log.logger("ui-mastra/chat");
@@ -126,7 +127,9 @@ export function useChatHistory({
126
127
  const messages = toChronologicalUiMessages(response);
127
128
  updateSession(threadId, (current) => ({
128
129
  ...current,
129
- messages,
130
+ messages: isSessionRunning(current)
131
+ ? mergeCurrentMessages(messages, current.messages)
132
+ : messages,
130
133
  historyLoaded: true,
131
134
  hasMoreHistory: response.hasMore ?? false,
132
135
  historyPage: 1,
@@ -203,3 +206,8 @@ export function useChatHistory({
203
206
  loadOlderHistory,
204
207
  };
205
208
  }
209
+
210
+ function mergeCurrentMessages(history: UIMessage[], current: UIMessage[]): UIMessage[] {
211
+ const ids = new Set(history.map((message) => message.id));
212
+ return [...history, ...current.filter((message) => !ids.has(message.id))];
213
+ }
@@ -38,6 +38,8 @@ type ChatThreadLayoutProps = {
38
38
  threads: ChatViewProps["threads"];
39
39
  threadPlacement: NonNullable<ChatViewProps["threadPlacement"]>;
40
40
  threadBarActions: ChatViewProps["threadBarActions"];
41
+ threadHistoryLeadingActions: ChatViewProps["threadHistoryLeadingActions"];
42
+ threadSidebarHeaderActions: ChatViewProps["threadSidebarHeaderActions"];
41
43
  showThreadBarActions: NonNullable<ChatViewProps["showThreadBarActions"]>;
42
44
  threadNewIcon: ChatViewProps["threadNewIcon"];
43
45
  threadHistoryIcon: ChatViewProps["threadHistoryIcon"];
@@ -92,6 +94,8 @@ export const ChatThreadLayout = ({
92
94
  threads,
93
95
  threadPlacement,
94
96
  threadBarActions,
97
+ threadHistoryLeadingActions,
98
+ threadSidebarHeaderActions,
95
99
  showThreadBarActions,
96
100
  threadNewIcon,
97
101
  threadHistoryIcon,
@@ -109,7 +113,7 @@ export const ChatThreadLayout = ({
109
113
  const rootRef = useRef<HTMLDivElement>(null);
110
114
  const isNarrow = useIsNarrow(rootRef);
111
115
  const showThreads = Boolean(threads && onSelectThread) && threadPlacement !== "disabled";
112
- const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "left") : threadPlacement;
116
+ const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "right") : threadPlacement;
113
117
  const tabbedThreads = showThreads && placement === "top";
114
118
  const dockedSide = placement === "right" ? "right" : "left";
115
119
  const dockedThreads = showThreads && (placement === "left" || placement === "right");
@@ -144,6 +148,7 @@ export const ChatThreadLayout = ({
144
148
  ...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
145
149
  ...(onRenameThread ? { onRename: onRenameThread } : {}),
146
150
  ...(onCancelThread ? { onCancel: onCancelThread } : {}),
151
+ ...(threadSidebarHeaderActions ? { headerActions: threadSidebarHeaderActions } : {}),
147
152
  };
148
153
 
149
154
  const [openTabIds, setOpenTabIds] = useState<string[]>([]);
@@ -195,6 +200,7 @@ export const ChatThreadLayout = ({
195
200
  showActions={showThreadBarActions}
196
201
  newIcon={threadNewIcon}
197
202
  historyIcon={threadHistoryIcon}
203
+ historyLeadingActions={threadHistoryLeadingActions}
198
204
  />
199
205
  )}
200
206
  {showSidebarToggle && (
@@ -11,6 +11,7 @@ import {
11
11
  EmptyTitle,
12
12
  Spinner,
13
13
  } from "@dbx-tools/ui/react";
14
+ import { isToolUIPart } from "ai";
14
15
  import { ArrowDownIcon, MessageSquareIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
15
16
  import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
16
17
  import { AssistantBubble, UserBubble } from "./bubbles.tsx";
@@ -185,8 +186,10 @@ export const ChatTranscript = ({
185
186
  const lastAssistantHasContent =
186
187
  lastAssistantParts.some(
187
188
  (part) =>
188
- (part.type === "text" || part.type === "reasoning") &&
189
- Boolean((part as { text?: string }).text),
189
+ ((part.type === "text" || part.type === "reasoning") &&
190
+ Boolean((part as { text?: string }).text)) ||
191
+ isToolUIPart(part) ||
192
+ part.type === "data-tool-call-approval",
190
193
  ) || (lastEvents?.length ?? 0) > 0;
191
194
  const showWaitingIndicator = isRunning && !lastAssistantHasContent;
192
195
 
@@ -255,9 +258,13 @@ export const ChatTranscript = ({
255
258
  return <UserBubble key={message.id} message={message} />;
256
259
  })}
257
260
  {showWaitingIndicator && (
258
- <div className="flex h-7 items-center gap-2 px-3 text-xs text-muted-foreground">
261
+ <div
262
+ role="status"
263
+ aria-live="polite"
264
+ className="flex h-7 items-center px-3 text-muted-foreground"
265
+ >
259
266
  <Spinner className="size-3" />
260
- <span className="animate-pulse">Thinking...</span>
267
+ <span className="sr-only">Response in progress</span>
261
268
  </div>
262
269
  )}
263
270
  {status === "error" && (
@@ -45,6 +45,8 @@ export const ChatView = ({
45
45
  threads,
46
46
  threadPlacement = "auto",
47
47
  threadBarActions,
48
+ threadHistoryLeadingActions,
49
+ threadSidebarHeaderActions,
48
50
  showThreadBarActions = true,
49
51
  threadNewIcon,
50
52
  threadHistoryIcon,
@@ -79,6 +81,8 @@ export const ChatView = ({
79
81
  threads={threads}
80
82
  threadPlacement={threadPlacement}
81
83
  threadBarActions={threadBarActions}
84
+ threadHistoryLeadingActions={threadHistoryLeadingActions}
85
+ threadSidebarHeaderActions={threadSidebarHeaderActions}
82
86
  showThreadBarActions={showThreadBarActions}
83
87
  threadNewIcon={threadNewIcon}
84
88
  threadHistoryIcon={threadHistoryIcon}
@@ -18,6 +18,7 @@ import {
18
18
  import { CheckIcon, CopyIcon } from "lucide-react";
19
19
  import React, { useEffect, useRef, useState } from "react";
20
20
  import { Streamdown } from "streamdown";
21
+ import { ExternalLinkDialog } from "./_external-link-dialog.tsx";
21
22
  import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid.tsx";
22
23
  import { copyText } from "../support/clipboard.ts";
23
24
 
@@ -183,6 +184,14 @@ const TOOL_MARKDOWN_COMPONENTS = {
183
184
  */
184
185
  const SHIKI_PLUGIN = { code: createShikiPlugin() };
185
186
 
187
+ /** Keep Streamdown's link confirmation while matching the chat's AppKit UI. */
188
+ const LINK_SAFETY = {
189
+ enabled: true,
190
+ renderModal: (props: React.ComponentProps<typeof ExternalLinkDialog>) => (
191
+ <ExternalLinkDialog {...props} />
192
+ ),
193
+ };
194
+
186
195
  /**
187
196
  * Per-word fade-in applied while a reply is still streaming. Streamdown
188
197
  * wraps each newly arrived token in a `[data-sd-animate]` span and only
@@ -235,6 +244,7 @@ export const AssistantMarkdown = ({
235
244
  components={MARKDOWN_COMPONENTS}
236
245
  plugins={SHIKI_PLUGIN}
237
246
  controls={false}
247
+ linkSafety={LINK_SAFETY}
238
248
  animated={animate ? ANIMATE_OPTIONS : false}
239
249
  isAnimating={animate}
240
250
  className={cn(
@@ -269,6 +279,7 @@ export const ToolMarkdown = ({ children, className }: { children: string; classN
269
279
  components={TOOL_MARKDOWN_COMPONENTS}
270
280
  plugins={SHIKI_PLUGIN}
271
281
  controls={false}
282
+ linkSafety={LINK_SAFETY}
272
283
  className={cn(
273
284
  "prose prose-sm dark:prose-invert max-w-none break-words",
274
285
  "text-xs leading-snug text-muted-foreground",
@@ -84,6 +84,8 @@ export interface MastraAssistantProps<TValues extends Record<string, unknown>> e
84
84
  closeIcon?: ReactNode;
85
85
  newConversationIcon?: ReactNode;
86
86
  historyIcon?: ReactNode;
87
+ /** Host controls rendered immediately before the History action and in the sidebar header. */
88
+ historyLeadingActions?: ReactNode;
87
89
  contentClassName?: string;
88
90
  panelClassName?: string;
89
91
  headerClassName?: string;
@@ -205,6 +207,7 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
205
207
  closeIcon,
206
208
  newConversationIcon,
207
209
  historyIcon,
210
+ historyLeadingActions,
208
211
  className,
209
212
  contentClassName,
210
213
  panelClassName,
@@ -493,6 +496,7 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
493
496
  onCancel={chatView.onCancelThread}
494
497
  newIcon={newConversationIcon}
495
498
  historyIcon={historyIcon}
499
+ historyLeadingActions={historyLeadingActions}
496
500
  actions={closeAction}
497
501
  />
498
502
  ) : (
@@ -550,6 +554,8 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
550
554
  threadBarActions={!header ? closeAction : undefined}
551
555
  threadNewIcon={newConversationIcon}
552
556
  threadHistoryIcon={historyIcon}
557
+ threadHistoryLeadingActions={historyLeadingActions}
558
+ threadSidebarHeaderActions={historyLeadingActions}
553
559
  className={cn("min-h-0 flex-1", chatClassName)}
554
560
  />
555
561
  {!header && chatView.threadPlacement !== "top" ? (
@@ -4,6 +4,7 @@ import type { ReasoningEffort } from "@dbx-tools/shared-model";
4
4
  import { useBrand } from "@dbx-tools/ui/branding/react";
5
5
  import type { FileUIPart, UIMessage } from "ai";
6
6
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { toChronologicalUiMessages } from "./_history-messages.ts";
7
8
  import { useChatApprovals } from "./chat-approvals.ts";
8
9
  import { useChatFeedback } from "./chat-feedback.ts";
9
10
  import { useChatHistory } from "./chat-history.ts";
@@ -12,6 +13,12 @@ import { useChatStream } from "./chat-stream.ts";
12
13
  import { ChatView } from "./chat-view.tsx";
13
14
  import { dedupeSuggestions } from "./suggestions.ts";
14
15
  import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
16
+ import {
17
+ activeRunStorageKey,
18
+ readStoredActiveRuns,
19
+ removeStoredActiveRun,
20
+ storeActiveRun,
21
+ } from "../support/active-runs.ts";
15
22
  import type { EmbedResolver, ExportFormat } from "../support/export.ts";
16
23
  import {
17
24
  useMastraClient,
@@ -189,7 +196,7 @@ export interface UseMastraChatOptions<
189
196
  * Where conversation management renders: `"left"` / `"right"` dock the list
190
197
  * to that edge, `"top"` shows the open conversations as an editor-style tab
191
198
  * strip with a history menu, `"disabled"` turns thread management off, and
192
- * the default `"auto"` picks `"left"` while the chat is wide enough for a
199
+ * the default `"auto"` picks `"right"` while the chat is wide enough for a
193
200
  * side panel and `"top"` once it gets too narrow for one (measured on the
194
201
  * chat's own width, so an embedded panel decides on its own space).
195
202
  */
@@ -292,6 +299,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
292
299
  const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
293
300
  const { url: mlflowExperimentUrl } = useMastraMlflowExperiment(feedbackAvailable);
294
301
  const threadKey = threadStorageKey(mastraClient.basePath, agentId);
302
+ const activeRunsKey = activeRunStorageKey(mastraClient.basePath, agentId);
295
303
  const [activeThreadId, setActiveThreadId] = useState<string>(() =>
296
304
  enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : hash.id(),
297
305
  );
@@ -510,6 +518,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
510
518
  };
511
519
  });
512
520
  if (getSession(threadId).runToken === token) {
521
+ const completedRunId = runIdRef.current;
522
+ if (completedRunId) removeStoredActiveRun(activeRunsKey, completedRunId);
513
523
  refreshThreadsSoon();
514
524
  // Turn finished on its own: start the oldest queued steer, if any.
515
525
  drainQueueRef.current(threadId);
@@ -531,6 +541,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
531
541
  runId: runIdRef.current,
532
542
  };
533
543
  });
544
+ const staleRunId = runIdRef.current;
545
+ if (staleRunId) removeStoredActiveRun(activeRunsKey, staleRunId);
534
546
  refreshThreadsSoon();
535
547
  drainQueueRef.current(threadId);
536
548
  return;
@@ -549,6 +561,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
549
561
  },
550
562
  [
551
563
  agentId,
564
+ activeRunsKey,
552
565
  getSession,
553
566
  mastraClient,
554
567
  processStream,
@@ -562,6 +575,13 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
562
575
  (threadId: string, message: UIMessage, requestContext?: MastraRequestContextSnapshot) => {
563
576
  const assistantId = hash.id();
564
577
  const runId = hash.id();
578
+ storeActiveRun(activeRunsKey, {
579
+ threadId,
580
+ runId,
581
+ assistantId,
582
+ userMessage: message,
583
+ startedAt: Date.now(),
584
+ });
565
585
  updateSession(threadId, (session) => ({
566
586
  ...session,
567
587
  assistantId,
@@ -595,9 +615,50 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
595
615
  });
596
616
  });
597
617
  },
598
- [driveStream, mastraClient, agentId, updateSession],
618
+ [activeRunsKey, driveStream, mastraClient, agentId, updateSession],
599
619
  );
600
620
 
621
+ const recoveredRunsKeyRef = useRef<string | undefined>(undefined);
622
+ useEffect(() => {
623
+ if (!mastraClient.backgroundTurns || recoveredRunsKeyRef.current === activeRunsKey) return;
624
+ recoveredRunsKeyRef.current = activeRunsKey;
625
+ for (const run of readStoredActiveRuns(activeRunsKey)) {
626
+ const session = getSession(run.threadId);
627
+ if (isSessionRunning(session) || session.runId === run.runId) continue;
628
+ updateSession(run.threadId, (current) => ({
629
+ ...current,
630
+ messages: current.messages.some((message) => message.id === run.userMessage.id)
631
+ ? current.messages
632
+ : [...current.messages, run.userMessage],
633
+ assistantId: run.assistantId,
634
+ runId: run.runId,
635
+ runs: {
636
+ ...current.runs,
637
+ [run.runId]: { assistantId: run.assistantId },
638
+ },
639
+ }));
640
+ void driveStream(run.threadId, run.assistantId, (signal) =>
641
+ mastraClient.observeAgentStream({
642
+ agentId,
643
+ runId: run.runId,
644
+ threadId: run.threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : run.threadId,
645
+ signal,
646
+ }),
647
+ ).then(async () => {
648
+ if (run.threadId === DEFAULT_THREAD_SESSION_KEY) return;
649
+ const history = await mastraClient
650
+ .history({
651
+ agentId,
652
+ threadId: run.threadId,
653
+ page: 0,
654
+ perPage: 20,
655
+ })
656
+ .catch(() => undefined);
657
+ if (history) writeMessages(run.threadId, toChronologicalUiMessages(history));
658
+ });
659
+ }
660
+ }, [activeRunsKey, agentId, driveStream, getSession, mastraClient, updateSession, writeMessages]);
661
+
601
662
  // Start the oldest queued steer as the next turn (FIFO drain). Called from
602
663
  // `driveStream` when a turn ends normally; a no-op when the queue is empty.
603
664
  // Kept behind `drainQueueRef` so `driveStream` can reach it without a
@@ -632,6 +693,9 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
632
693
  runId: current.runId,
633
694
  threadId: streamThreadId,
634
695
  })
696
+ .then((aborted) => {
697
+ if (aborted && current.runId) removeStoredActiveRun(activeRunsKey, current.runId);
698
+ })
635
699
  .catch((error: unknown) => {
636
700
  logger.warn("failed to abort background run", {
637
701
  runId: current.runId,
@@ -651,7 +715,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
651
715
  };
652
716
  });
653
717
  },
654
- [activeKey, agentId, getSession, mastraClient, updateSession],
718
+ [activeKey, activeRunsKey, agentId, getSession, mastraClient, updateSession],
655
719
  );
656
720
 
657
721
  const handleApproval = useChatApprovals({
@@ -1155,6 +1219,10 @@ export interface MastraChatProps<
1155
1219
  modelSelectorActions?: ReactNode;
1156
1220
  /** Host controls placed on the left side of the composer footer. */
1157
1221
  composerLeadingActions?: ReactNode;
1222
+ /** Host controls rendered immediately before the responsive History action. */
1223
+ threadHistoryLeadingActions?: ReactNode;
1224
+ /** Host controls rendered in the full conversation-list header. */
1225
+ threadSidebarHeaderActions?: ReactNode;
1158
1226
  }
1159
1227
 
1160
1228
  /**
@@ -1167,7 +1235,7 @@ export interface MastraChatProps<
1167
1235
  * of the resource's threads with select / new / delete, persisted
1168
1236
  * across reloads) - all with no host wiring. The model picker is opt-in
1169
1237
  * via `showModelPicker`; thread management is on by default, laid out per
1170
- * `threadPlacement` (`auto` docks it left and falls back to a tab strip on a
1238
+ * `threadPlacement` (`auto` docks it right and falls back to a tab strip on a
1171
1239
  * narrow chat) and turned off with `threadPlacement: "disabled"`.
1172
1240
  */
1173
1241
  export const MastraChat = <TValues extends Record<string, unknown> = Record<string, unknown>>({
@@ -1175,6 +1243,8 @@ export const MastraChat = <TValues extends Record<string, unknown> = Record<stri
1175
1243
  composerActions,
1176
1244
  modelSelectorActions,
1177
1245
  composerLeadingActions,
1246
+ threadHistoryLeadingActions,
1247
+ threadSidebarHeaderActions,
1178
1248
  ...options
1179
1249
  }: MastraChatProps<TValues>) => {
1180
1250
  const chat = useMastraChat(options);
@@ -1185,6 +1255,8 @@ export const MastraChat = <TValues extends Record<string, unknown> = Record<stri
1185
1255
  composerActions={composerActions}
1186
1256
  modelSelectorActions={modelSelectorActions}
1187
1257
  composerLeadingActions={composerLeadingActions}
1258
+ threadHistoryLeadingActions={threadHistoryLeadingActions}
1259
+ threadSidebarHeaderActions={threadSidebarHeaderActions}
1188
1260
  />
1189
1261
  );
1190
1262
  };
@@ -16,7 +16,7 @@ import {
16
16
  SquareIcon,
17
17
  Trash2Icon,
18
18
  } from "lucide-react";
19
- import { useState } from "react";
19
+ import { type ReactNode, useState } from "react";
20
20
  import type { ThreadSummary } from "./types.ts";
21
21
  import { relativeTime, threadTitle } from "../support/thread-labels.ts";
22
22
 
@@ -52,6 +52,8 @@ export interface ThreadSidebarProps {
52
52
  onCancel?: (threadId: string) => void;
53
53
  /** Collapse the sidebar. Renders the hide button in the header when provided. */
54
54
  onHide?: () => void;
55
+ /** Host controls rendered in the conversation-list header. */
56
+ headerActions?: ReactNode;
55
57
  /**
56
58
  * Edge the sidebar is docked to. Only affects framing - which side carries
57
59
  * the divider, and which way the collapse icon points. Defaults to `left`.
@@ -82,6 +84,7 @@ export const ThreadSidebar = ({
82
84
  onRename,
83
85
  onCancel,
84
86
  onHide,
87
+ headerActions,
85
88
  side = "left",
86
89
  className,
87
90
  }: ThreadSidebarProps) => {
@@ -134,7 +137,7 @@ export const ThreadSidebar = ({
134
137
  className,
135
138
  )}
136
139
  >
137
- {(onNew || onHide) && (
140
+ {(onNew || headerActions || onHide) && (
138
141
  <div className="flex items-center gap-2 p-2">
139
142
  {onNew && (
140
143
  <Button
@@ -148,6 +151,7 @@ export const ThreadSidebar = ({
148
151
  New chat
149
152
  </Button>
150
153
  )}
154
+ {headerActions}
151
155
  {onHide && (
152
156
  <Tooltip>
153
157
  <TooltipTrigger asChild>
@@ -44,6 +44,8 @@ export interface ThreadTabsProps extends Omit<ThreadSidebarProps, "onHide" | "si
44
44
  newIcon?: ReactNode;
45
45
  /** Replacement for the default history icon. */
46
46
  historyIcon?: ReactNode;
47
+ /** Host controls rendered immediately before History. */
48
+ historyLeadingActions?: ReactNode;
47
49
  }
48
50
 
49
51
  /** New/history controls reused by the tab strip and persistent assistant header. */
@@ -54,10 +56,17 @@ export interface ThreadActionsProps extends Omit<
54
56
  actions?: ReactNode;
55
57
  newIcon?: ReactNode;
56
58
  historyIcon?: ReactNode;
59
+ historyLeadingActions?: ReactNode;
57
60
  }
58
61
 
59
62
  /** Render New and History controls, plus optional host actions, as one group. */
60
- export const ThreadActions = ({ actions, newIcon, historyIcon, ...list }: ThreadActionsProps) => {
63
+ export const ThreadActions = ({
64
+ actions,
65
+ newIcon,
66
+ historyIcon,
67
+ historyLeadingActions,
68
+ ...list
69
+ }: ThreadActionsProps) => {
61
70
  const { onSelect, onNew } = list;
62
71
  const [historyOpen, setHistoryOpen] = useState(false);
63
72
  return (
@@ -79,6 +88,7 @@ export const ThreadActions = ({ actions, newIcon, historyIcon, ...list }: Thread
79
88
  <TooltipContent>New chat</TooltipContent>
80
89
  </Tooltip>
81
90
  )}
91
+ {historyLeadingActions}
82
92
  <Popover open={historyOpen} onOpenChange={setHistoryOpen}>
83
93
  <Tooltip>
84
94
  <TooltipTrigger asChild>
@@ -136,6 +146,7 @@ export const ThreadTabs = ({
136
146
  showActions = true,
137
147
  newIcon,
138
148
  historyIcon,
149
+ historyLeadingActions,
139
150
  className,
140
151
  ...list
141
152
  }: ThreadTabsProps) => {
@@ -207,7 +218,13 @@ export const ThreadTabs = ({
207
218
  })}
208
219
  </div>
209
220
  {showActions ? (
210
- <ThreadActions {...list} actions={actions} newIcon={newIcon} historyIcon={historyIcon} />
221
+ <ThreadActions
222
+ {...list}
223
+ actions={actions}
224
+ newIcon={newIcon}
225
+ historyIcon={historyIcon}
226
+ historyLeadingActions={historyLeadingActions}
227
+ />
211
228
  ) : null}
212
229
  </div>
213
230
  </div>
@@ -124,8 +124,8 @@ type MessageGroup = {
124
124
  * are resolved out-of-band via the chart cache; suggestions live at
125
125
  * message scope.
126
126
  *
127
- * Genie `thinking` and `text` entries render in the assistant bubble's
128
- * consolidated Thoughts panel and are intentionally omitted here.
127
+ * Genie `thinking` entries render as interleaved Thoughts disclosures in the
128
+ * assistant activity timeline and are intentionally omitted here.
129
129
  *
130
130
  * `groups` preserves first-seen order so the rendered detail view
131
131
  * walks Genie sub-calls in the order the LLM dispatched them.
@@ -190,7 +190,7 @@ const summarizeProgress = (progress: ToolProgress[]): ToolDetailSummary => {
190
190
  break;
191
191
  }
192
192
  case "thinking": {
193
- // Reasoning is rendered once in the assistant bubble's Thoughts panel.
193
+ // Reasoning is rendered once in the assistant activity timeline.
194
194
  break;
195
195
  }
196
196
  case "query": {
@@ -265,7 +265,7 @@ export type ChatViewProps = {
265
265
  /**
266
266
  * Where the conversation list renders (or `"disabled"` to hide it even when
267
267
  * {@link threads} and {@link onSelectThread} are wired). Defaults to
268
- * `"auto"`: a left side panel while the chat is wide, a tab strip once it
268
+ * `"auto"`: a right side panel while the chat is wide, a tab strip once it
269
269
  * is too narrow for one. See {@link ThreadPlacement}.
270
270
  */
271
271
  threadPlacement?: ThreadPlacement;
@@ -274,6 +274,10 @@ export type ChatViewProps = {
274
274
  * actions. The persistent assistant uses this for its panel Close button.
275
275
  */
276
276
  threadBarActions?: ReactNode;
277
+ /** Host controls rendered immediately before the top-row History action. */
278
+ threadHistoryLeadingActions?: ReactNode;
279
+ /** Host controls rendered in the docked or drawer conversation-list header. */
280
+ threadSidebarHeaderActions?: ReactNode;
277
281
  /** Whether the top tab row renders its built-in New/History controls. */
278
282
  showThreadBarActions?: boolean;
279
283
  /** Replacement icon for the top tab row's New action. */
@@ -0,0 +1,111 @@
1
+ import type { UIMessage } from "ai";
2
+ import { z } from "zod";
3
+
4
+ const ACTIVE_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
5
+
6
+ export const StoredActiveRunSchema = z
7
+ .object({
8
+ threadId: z.string().min(1).describe("Mastra thread receiving the background turn."),
9
+ runId: z.string().min(1).describe("Mastra background run identifier."),
10
+ assistantId: z.string().min(1).describe("Client assistant message identifier."),
11
+ userMessage: z
12
+ .object({
13
+ id: z.string().min(1),
14
+ role: z.literal("user"),
15
+ parts: z.array(z.unknown()),
16
+ })
17
+ .passthrough()
18
+ .describe("Submitted user message restored before stream replay."),
19
+ startedAt: z.number().int().nonnegative().describe("Client epoch time when the run started."),
20
+ })
21
+ .strict()
22
+ .describe("Reload-recoverable Mastra background run metadata.");
23
+
24
+ /** Reload-recoverable metadata for one active Mastra background run. */
25
+ export type StoredActiveRun = Omit<z.infer<typeof StoredActiveRunSchema>, "userMessage"> & {
26
+ userMessage: UIMessage;
27
+ };
28
+
29
+ const StoredActiveRunFileSchema = z
30
+ .object({
31
+ version: z.literal(1).describe("Active run storage format version."),
32
+ runs: z.array(StoredActiveRunSchema).describe("Background runs awaiting terminal completion."),
33
+ })
34
+ .strict()
35
+ .describe("Browser-persisted Mastra background run registry.");
36
+
37
+ type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
38
+
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}`;
42
+
43
+ function browserStorage(): StorageLike | undefined {
44
+ try {
45
+ return typeof window === "undefined" ? undefined : window.localStorage;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ /** Read current non-stale background runs, deleting malformed storage. */
52
+ export function readStoredActiveRuns(
53
+ key: string,
54
+ storage: StorageLike | undefined = browserStorage(),
55
+ now = Date.now(),
56
+ ): StoredActiveRun[] {
57
+ if (!storage) return [];
58
+ try {
59
+ const serialized = storage.getItem(key);
60
+ if (!serialized) return [];
61
+ const parsed = StoredActiveRunFileSchema.parse(JSON.parse(serialized));
62
+ const parsedRuns: StoredActiveRun[] = parsed.runs.map((run) => ({
63
+ ...run,
64
+ userMessage: run.userMessage as UIMessage,
65
+ }));
66
+ const runs = parsedRuns.filter((run) => now - run.startedAt <= ACTIVE_RUN_MAX_AGE_MS);
67
+ if (runs.length !== parsed.runs.length) writeStoredActiveRuns(key, runs, storage);
68
+ return runs as StoredActiveRun[];
69
+ } catch {
70
+ try {
71
+ storage.removeItem(key);
72
+ } catch {}
73
+ return [];
74
+ }
75
+ }
76
+
77
+ /** Add or replace one reload-recoverable background run. */
78
+ export function storeActiveRun(
79
+ key: string,
80
+ run: StoredActiveRun,
81
+ storage: StorageLike | undefined = browserStorage(),
82
+ ): void {
83
+ if (!storage) return;
84
+ const runs = readStoredActiveRuns(key, storage).filter(
85
+ (candidate) => candidate.runId !== run.runId,
86
+ );
87
+ writeStoredActiveRuns(key, [...runs, run], storage);
88
+ }
89
+
90
+ /** Remove one terminal or explicitly aborted background run. */
91
+ export function removeStoredActiveRun(
92
+ key: string,
93
+ runId: string,
94
+ storage: StorageLike | undefined = browserStorage(),
95
+ ): void {
96
+ if (!storage) return;
97
+ const runs = readStoredActiveRuns(key, storage).filter((run) => run.runId !== runId);
98
+ writeStoredActiveRuns(key, runs, storage);
99
+ }
100
+
101
+ function writeStoredActiveRuns(key: string, runs: StoredActiveRun[], storage: StorageLike): void {
102
+ try {
103
+ if (runs.length === 0) {
104
+ storage.removeItem(key);
105
+ return;
106
+ }
107
+ storage.setItem(key, JSON.stringify({ version: 1, runs }));
108
+ } catch {
109
+ // Browser persistence is best-effort; the live in-memory run remains valid.
110
+ }
111
+ }
@@ -2,6 +2,7 @@
2
2
  // Regenerated from the exporting modules in ./src.
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
+ export * as activeRuns from "./active-runs.ts";
5
6
  export * as chartOption from "./chart-option.ts";
6
7
  export * as chartTheme from "./chart-theme.ts";
7
8
  export * as clipboard from "./clipboard.ts";
@@ -14,6 +15,8 @@ export * as threadLabels from "./thread-labels.ts";
14
15
  export * as threadSessions from "./thread-sessions.ts";
15
16
  export * as threadTabs from "./thread-tabs.ts";
16
17
  export * as toolEvents from "./tool-events.ts";
18
+ export { StoredActiveRunSchema, activeRunStorageKey } from "./active-runs.ts";
19
+ export type { StoredActiveRun } from "./active-runs.ts";
17
20
  export { LIGHT_CHART_CHROME } from "./chart-theme.ts";
18
21
  export type { ChartChrome } from "./chart-theme.ts";
19
22
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./export.ts";
@@ -130,6 +130,7 @@ export class MastraPluginClient extends MastraClient {
130
130
  fetch: async (input, init) => {
131
131
  const response = await fetch(input, init);
132
132
  headers = new Headers(response.headers);
133
+ await assertChatResponse(response);
133
134
  return response;
134
135
  },
135
136
  });
@@ -160,6 +161,7 @@ export class MastraPluginClient extends MastraClient {
160
161
  fetch: async (input, init) => {
161
162
  const response = await fetch(input, init);
162
163
  headers = new Headers(response.headers);
164
+ await assertChatResponse(response);
163
165
  return response;
164
166
  },
165
167
  });
@@ -620,6 +622,35 @@ export class MastraPluginClient extends MastraClient {
620
622
  }
621
623
  }
622
624
 
625
+ async function assertChatResponse(response: Response): Promise<void> {
626
+ if (response.ok) return;
627
+ const body = await response
628
+ .clone()
629
+ .json()
630
+ .catch(() => undefined);
631
+ const message =
632
+ responseErrorMessage(body) ??
633
+ (await response
634
+ .clone()
635
+ .text()
636
+ .catch(() => ""));
637
+ throw new Error(message.trim() || `Chat request failed (${response.status})`);
638
+ }
639
+
640
+ function responseErrorMessage(body: unknown): string | undefined {
641
+ if (!body || typeof body !== "object") return undefined;
642
+ const record = body as Record<string, unknown>;
643
+ for (const field of ["error", "message"] as const) {
644
+ const value = record[field];
645
+ if (typeof value === "string" && value.trim()) return value.trim();
646
+ if (value && typeof value === "object") {
647
+ const nested = (value as Record<string, unknown>).message;
648
+ if (typeof nested === "string" && nested.trim()) return nested.trim();
649
+ }
650
+ }
651
+ return undefined;
652
+ }
653
+
623
654
  /**
624
655
  * Read the Mastra plugin's `clientConfig()` payload (`basePath`,
625
656
  * default agent, registered agent list). One call per render; values