@dbx-tools/ui-mastra 0.9.118 → 0.9.120

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,
@@ -285,6 +288,7 @@ export function ReviewChat(props: ChatViewProps) {
285
288
  sidebar, transcript, tool progress, approval cards, suggestions, export controls,
286
289
  feedback controls, and composer from props. Use it when your app already has a
287
290
  transport or needs to combine Mastra messages with another state model.
291
+
288
292
  `MastraChat` shows a spinner in the model control while its live default model
289
293
  is being resolved. The picker groups endpoints into
290
294
  model-family submenus. Its compact first popup shows only the current
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.118";
6
+ export const PACKAGE_VERSION = "0.9.120";
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,10 +66,12 @@ 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";
71
- export { MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./src/support/mastra-client.ts";
74
+ export { MastraChatRequestError, MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./src/support/mastra-client.ts";
72
75
  export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from "./src/support/mastra-client.ts";
73
76
  export { defaultReasoningEffort, modelStorageKey, readStoredModel, storeSelectedModel } from "./src/support/model-selection.ts";
74
77
  export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./src/support/request-context.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.118",
33
- "@dbx-tools/shared-genie": "^0.9.118",
34
- "@dbx-tools/shared-mastra": "^0.9.118",
35
- "@dbx-tools/shared-model": "^0.9.118",
36
- "@dbx-tools/ui": "^0.9.118",
32
+ "@dbx-tools/shared-core": "^0.9.120",
33
+ "@dbx-tools/shared-genie": "^0.9.120",
34
+ "@dbx-tools/shared-mastra": "^0.9.120",
35
+ "@dbx-tools/shared-model": "^0.9.120",
36
+ "@dbx-tools/ui": "^0.9.120",
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.118",
53
+ "version": "0.9.120",
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[];
@@ -335,6 +340,7 @@ type AssistantBubbleProps = {
335
340
  mlflowExperimentUrl?: string;
336
341
  };
337
342
 
343
+ /** Render one assistant message with tool progress, actions, and optional feedback. */
338
344
  export const AssistantBubble = ({
339
345
  message,
340
346
  isLast,
@@ -360,18 +366,7 @@ export const AssistantBubble = ({
360
366
  ? "done"
361
367
  : undefined;
362
368
  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
- ));
369
+ const activityItems = assistantActivityItems(message.parts, toolEvents);
375
370
  const textParts = message.parts.filter(
376
371
  (p): p is { type: "text"; text: string } => p.type === "text",
377
372
  );
@@ -394,6 +389,14 @@ export const AssistantBubble = ({
394
389
  // `error`, or a newer message has taken over the `isLast` slot.
395
390
  const isStreamingThisBubble = isLast && (status === "streaming" || status === "submitted");
396
391
  const suggestions = isStreamingThisBubble ? [] : collectSuggestions(events);
392
+ const showResponseStatus = shouldShowAssistantResponseStatus(
393
+ isLast,
394
+ status,
395
+ hasText || activityItems.length > 0 || pendingApprovals.length > 0,
396
+ );
397
+ const hasFooterActions =
398
+ hasText && (isLast || Boolean(onExport) || Boolean(onFeedback) || Boolean(mlflowExperimentUrl));
399
+ const showFooter = showResponseStatus || hasFooterActions;
397
400
  // Charts and tables are placed at inline marker positions in
398
401
  // the assistant's prose. `prepare_chart` / `render_data` mint
399
402
  // a `chartId` and the model embeds `[chart:<chartId>]`; for raw
@@ -412,26 +415,13 @@ export const AssistantBubble = ({
412
415
  * `min-width: auto`, which sizes to content and overflows.
413
416
  */}
414
417
  <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>
418
+ {/* Tool calls and their thought events stay in arrival order above the answer text. */}
419
+ {activityItems.map((item) =>
420
+ item.type === "tool" ? (
421
+ <ToolSessionPill key={item.key} events={[item.event]} />
422
+ ) : (
423
+ <ThoughtsDisclosure key={item.key} text={item.text} />
424
+ ),
435
425
  )}
436
426
  {/*
437
427
  * Render each text part as its own block. A multi-step turn
@@ -459,9 +449,9 @@ export const AssistantBubble = ({
459
449
  onResolve={onResolveToolApproval}
460
450
  />
461
451
  ))}
462
- {hasText && (isLast || onExport || onFeedback || mlflowExperimentUrl) && (
463
- <div className="flex items-center gap-1">
464
- {isLast && regenerate && (
452
+ {showFooter && (
453
+ <div className="flex min-h-7 items-center gap-1">
454
+ {hasText && isLast && regenerate && (
465
455
  <Tooltip>
466
456
  <TooltipTrigger asChild>
467
457
  <Button
@@ -478,7 +468,7 @@ export const AssistantBubble = ({
478
468
  <TooltipContent>Retry</TooltipContent>
479
469
  </Tooltip>
480
470
  )}
481
- {isLast && (
471
+ {hasText && isLast && (
482
472
  <Tooltip>
483
473
  <TooltipTrigger asChild>
484
474
  <Button
@@ -495,14 +485,16 @@ export const AssistantBubble = ({
495
485
  <TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
496
486
  </Tooltip>
497
487
  )}
498
- {onExport && <ExportMenu onExport={onExport} iconOnly tooltip="Export message" />}
499
- {onFeedback && (
488
+ {hasText && onExport && (
489
+ <ExportMenu onExport={onExport} iconOnly tooltip="Export message" />
490
+ )}
491
+ {hasText && onFeedback && (
500
492
  <FeedbackControls
501
493
  onSubmit={onFeedback}
502
494
  {...(feedbackValue ? { value: feedbackValue } : {})}
503
495
  />
504
496
  )}
505
- {mlflowExperimentUrl && (
497
+ {hasText && mlflowExperimentUrl && (
506
498
  <Tooltip>
507
499
  <TooltipTrigger asChild>
508
500
  <Button asChild type="button" size="icon" variant="ghost" className="size-7">
@@ -519,6 +511,11 @@ export const AssistantBubble = ({
519
511
  <TooltipContent>Open MLflow experiment</TooltipContent>
520
512
  </Tooltip>
521
513
  )}
514
+ {showResponseStatus && (
515
+ <div className="flex items-center text-muted-foreground">
516
+ <AssistantResponseSpinner />
517
+ </div>
518
+ )}
522
519
  </div>
523
520
  )}
524
521
  <SuggestionPills questions={suggestions} onSelect={onSuggestionClick} className="mt-1" />
@@ -527,6 +524,7 @@ export const AssistantBubble = ({
527
524
  );
528
525
  };
529
526
 
527
+ /** Render one user message, including text and image attachments. */
530
528
  export const UserBubble = ({ message }: { message: UIMessage }) => {
531
529
  const text = message.parts
532
530
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
@@ -423,7 +423,7 @@ export const ChatComposer = ({
423
423
  }}
424
424
  placeholder="Send a message..."
425
425
  rows={1}
426
- className="field-sizing-fixed min-h-10 max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-base md:text-xs"
426
+ className="field-sizing-fixed min-h-10 max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-sm"
427
427
  />
428
428
  {fileError && (
429
429
  <div className="px-4 text-xs text-destructive" role="alert">
@@ -51,7 +51,7 @@ export function useChatFeedback({
51
51
  } catch (error) {
52
52
  logger.error("feedback error", {
53
53
  traceId,
54
- error: errorUtils.errorMessage(error),
54
+ error: errorUtils.toError(error),
55
55
  });
56
56
  }
57
57
  },
@@ -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");
@@ -115,7 +116,7 @@ export function useChatHistory({
115
116
  .catch((error: unknown) => {
116
117
  if ((error as { name?: string }).name === "AbortError") throw error;
117
118
  logger.warn("suspended-run discovery error", {
118
- error: errorUtils.errorMessage(error),
119
+ error: errorUtils.toError(error),
119
120
  });
120
121
  return { runs: [], total: 0 };
121
122
  });
@@ -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,
@@ -137,7 +140,7 @@ export function useChatHistory({
137
140
  .catch((error: unknown) => {
138
141
  if (cancelled || (error as { name?: string }).name === "AbortError") return;
139
142
  logger.error("history load error", {
140
- error: errorUtils.errorMessage(error),
143
+ error: errorUtils.toError(error),
141
144
  });
142
145
  updateSession(threadId, (current) => ({
143
146
  ...current,
@@ -178,7 +181,7 @@ export function useChatHistory({
178
181
  .catch((error: unknown) => {
179
182
  logger.error("history load-more error", {
180
183
  page,
181
- error: errorUtils.errorMessage(error),
184
+ error: errorUtils.toError(error),
182
185
  });
183
186
  updateSession(threadId, (current) => ({
184
187
  ...current,
@@ -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
+ }
@@ -8,13 +8,16 @@ import {
8
8
  type ThreadSession,
9
9
  } from "../support/thread-sessions.ts";
10
10
 
11
+ /** Read or lazily create the mutable session state for one thread. */
11
12
  export type ThreadSessionReader = (threadId: string) => ThreadSession;
12
13
 
14
+ /** Replace one thread session through a state-producing callback. */
13
15
  export type ThreadSessionUpdater = (
14
16
  threadId: string,
15
17
  updater: (session: ThreadSession) => ThreadSession,
16
18
  ) => void;
17
19
 
20
+ /** Replace the transcript stored for one thread. */
18
21
  export type ThreadMessageWriter = (threadId: string, messages: UIMessage[]) => void;
19
22
 
20
23
  /** Own the mutable per-thread session registry behind the public chat hook. */
@@ -127,7 +127,7 @@ export function useChatStream({ getSession, updateSession, writeMessages }: UseC
127
127
  if (error instanceof StreamAborted || signal.aborted) return;
128
128
  if (isStaleMastraResumeError(error)) {
129
129
  logger.warn("ignored stale mastra resume", {
130
- error: errorUtils.errorMessage(error),
130
+ error: errorUtils.toError(error),
131
131
  });
132
132
  return;
133
133
  }
@@ -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,
@@ -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}
@@ -50,6 +50,7 @@ export type DataRow = Record<string, unknown>;
50
50
  */
51
51
  const DELTA_PATTERN = /^([+\u2212-])\s*\d[\d,.\s]*(?:%|\s*pts?)?$/i;
52
52
 
53
+ /** Apply success or destructive text color to a signed numeric delta string. */
53
54
  export function colorizeDelta(content: React.ReactNode): React.ReactNode {
54
55
  if (typeof content !== "string") return content;
55
56
  const text = content.trim();