@dbx-tools/ui-mastra 0.9.121 → 0.9.123

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/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.121";
6
+ export const PACKAGE_VERSION = "0.9.123";
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";
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.121",
33
- "@dbx-tools/shared-genie": "^0.9.121",
34
- "@dbx-tools/shared-mastra": "^0.9.121",
35
- "@dbx-tools/shared-model": "^0.9.121",
36
- "@dbx-tools/ui": "^0.9.121",
32
+ "@dbx-tools/shared-core": "^0.9.123",
33
+ "@dbx-tools/shared-genie": "^0.9.123",
34
+ "@dbx-tools/shared-mastra": "^0.9.123",
35
+ "@dbx-tools/shared-model": "^0.9.123",
36
+ "@dbx-tools/ui": "^0.9.123",
37
37
  "@mastra/client-js": "1.50.0",
38
38
  "@mastra/core": "1.71.0",
39
39
  "@tanstack/react-table": "^8.21.3",
@@ -50,7 +50,7 @@
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
- "version": "0.9.121",
53
+ "version": "0.9.123",
54
54
  "dbxToolsConfig": {
55
55
  "tags": [
56
56
  "ui"
@@ -20,6 +20,22 @@ export interface LinkPresentation {
20
20
  absoluteUrl: string;
21
21
  }
22
22
 
23
+ /** Return whether a chat URL belongs to the current app's Sandbox route space. */
24
+ export function isSandboxLink(url: string, baseUrl?: string): boolean {
25
+ const base = baseUrl ?? (typeof window === "undefined" ? undefined : window.location.href);
26
+ if (!base) return false;
27
+ try {
28
+ const resolved = new URL(url, base);
29
+ const current = new URL(base);
30
+ return (
31
+ resolved.origin === current.origin &&
32
+ (resolved.pathname === "/sandbox" || resolved.pathname.startsWith("/sandbox/"))
33
+ );
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
23
39
  /**
24
40
  * Resolve a chat link for display and copying.
25
41
  *
@@ -407,14 +407,14 @@ export const AssistantBubble = ({
407
407
  // nothing so the prose flows unaffected. Suggested questions
408
408
  // stay gated on settle to avoid pop-in mid-stream.
409
409
  return (
410
- <Item className="items-start gap-0 border-none bg-transparent p-0">
410
+ <Item className="min-w-0 max-w-full items-start gap-0 overflow-hidden border-none bg-transparent p-0">
411
411
  {/*
412
412
  * `min-w-0` is required so wide content (markdown tables, long
413
413
  * code blocks) can shrink to fit instead of pushing the whole
414
414
  * bubble past the chat's `max-w-4xl`. Flex children default to
415
415
  * `min-width: auto`, which sizes to content and overflows.
416
416
  */}
417
- <ItemContent className="min-w-0 gap-1.5">
417
+ <ItemContent className="min-w-0 max-w-full overflow-hidden gap-1.5">
418
418
  {/* Tool calls and their thought events stay in arrival order above the answer text. */}
419
419
  {activityItems.map((item) =>
420
420
  item.type === "tool" ? (
@@ -551,7 +551,12 @@ export const UserBubble = ({ message }: { message: UIMessage }) => {
551
551
  </div>
552
552
  )}
553
553
  {text && (
554
- <div className={cn("whitespace-pre-wrap break-words px-1", files.length > 0 && "pt-2")}>
554
+ <div
555
+ className={cn(
556
+ "whitespace-pre-wrap break-words px-1 leading-snug sm:leading-normal",
557
+ files.length > 0 && "pt-2",
558
+ )}
559
+ >
555
560
  {text}
556
561
  </div>
557
562
  )}
@@ -194,11 +194,11 @@ export const ChatTranscript = ({
194
194
  const showWaitingIndicator = isRunning && !lastAssistantHasContent;
195
195
 
196
196
  return (
197
- <div className="relative flex flex-1 flex-col overflow-hidden">
197
+ <div className="relative flex min-w-0 max-w-full flex-1 flex-col overflow-hidden">
198
198
  <div
199
199
  ref={controller.scrollRef}
200
200
  onScroll={controller.handleScroll}
201
- className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [overflow-anchor:none] [scrollbar-gutter:stable]"
201
+ className="min-w-0 max-w-full flex-1 overflow-y-auto overflow-x-clip overscroll-contain [overflow-anchor:none] [scrollbar-gutter:stable]"
202
202
  >
203
203
  {messages.length === 0 && !isLoadingHistory ? (
204
204
  <Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
@@ -217,7 +217,7 @@ export const ChatTranscript = ({
217
217
  ) : (
218
218
  <div
219
219
  ref={controller.contentRef}
220
- className="mx-auto flex w-full max-w-4xl flex-col gap-3 px-4 py-3 md:px-6"
220
+ className="mx-auto flex min-w-0 w-full max-w-4xl flex-col gap-3 overflow-x-clip px-4 py-3 md:px-6"
221
221
  >
222
222
  {(isLoadingMore || isLoadingHistory) && (
223
223
  <div className="flex items-center justify-center gap-2 py-1 text-xs text-muted-foreground">
@@ -18,7 +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
+ import { ExternalLinkDialog, isSandboxLink } from "./_external-link-dialog.tsx";
22
22
  import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid.tsx";
23
23
  import { copyText } from "../support/clipboard.ts";
24
24
 
@@ -136,6 +136,20 @@ const MARKDOWN_TABLE_PARTS = {
136
136
  },
137
137
  };
138
138
 
139
+ /** Chat links use a dedicated high-contrast blue instead of the darker brand primary. */
140
+ const MarkdownLink = ({ className, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
141
+ <a
142
+ {...props}
143
+ className={cn(
144
+ "font-normal text-blue-700 underline decoration-blue-700/40 decoration-1 underline-offset-2",
145
+ "transition-colors hover:text-blue-800 hover:decoration-current",
146
+ "focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
147
+ "visited:text-blue-800 dark:text-blue-300 dark:decoration-blue-300/40 dark:visited:text-blue-300 dark:hover:text-blue-200",
148
+ className,
149
+ )}
150
+ />
151
+ );
152
+
139
153
  /**
140
154
  * Markdown component map for the main assistant reply. Tables are
141
155
  * lifted out of the parsed `node` and rendered through the interactive
@@ -145,6 +159,7 @@ const MARKDOWN_TABLE_PARTS = {
145
159
  */
146
160
  const MARKDOWN_COMPONENTS = {
147
161
  ...MARKDOWN_TABLE_PARTS,
162
+ a: MarkdownLink,
148
163
  table: ({
149
164
  node,
150
165
  children,
@@ -173,6 +188,7 @@ const MARKDOWN_COMPONENTS = {
173
188
  */
174
189
  const TOOL_MARKDOWN_COMPONENTS = {
175
190
  ...MARKDOWN_TABLE_PARTS,
191
+ a: MarkdownLink,
176
192
  table: plainMarkdownTable,
177
193
  };
178
194
 
@@ -187,6 +203,7 @@ const SHIKI_PLUGIN = { code: createShikiPlugin() };
187
203
  /** Keep Streamdown's link confirmation while matching the chat's AppKit UI. */
188
204
  const LINK_SAFETY = {
189
205
  enabled: true,
206
+ onLinkCheck: isSandboxLink,
190
207
  renderModal: (props: React.ComponentProps<typeof ExternalLinkDialog>) => (
191
208
  <ExternalLinkDialog {...props} />
192
209
  ),
@@ -248,8 +265,8 @@ export const AssistantMarkdown = ({
248
265
  animated={animate ? ANIMATE_OPTIONS : false}
249
266
  isAnimating={animate}
250
267
  className={cn(
251
- "prose prose-sm dark:prose-invert max-w-none break-words",
252
- "leading-relaxed prose-p:my-1.5 prose-p:leading-relaxed",
268
+ "prose prose-sm dark:prose-invert min-w-0 max-w-full overflow-x-hidden break-words",
269
+ "leading-snug prose-p:my-1.5 prose-p:leading-snug sm:leading-relaxed sm:prose-p:leading-relaxed",
253
270
  "prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5",
254
271
  "prose-headings:font-semibold prose-headings:tracking-tight",
255
272
  "prose-h1:text-lg prose-h1:mt-4 prose-h1:mb-2",
@@ -281,11 +298,11 @@ export const ToolMarkdown = ({ children, className }: { children: string; classN
281
298
  controls={false}
282
299
  linkSafety={LINK_SAFETY}
283
300
  className={cn(
284
- "prose prose-sm dark:prose-invert max-w-none break-words",
285
- "text-xs leading-snug text-muted-foreground",
286
- "prose-p:my-0.5 prose-p:leading-snug [&_p]:!leading-snug",
301
+ "prose prose-sm dark:prose-invert min-w-0 max-w-full overflow-x-hidden break-words",
302
+ "text-xs leading-tight text-muted-foreground sm:leading-snug",
303
+ "prose-p:my-0.5 prose-p:leading-tight [&_p]:!leading-tight sm:prose-p:leading-snug sm:[&_p]:!leading-snug",
287
304
  "prose-ul:my-0.5 prose-ul:pl-4 prose-ol:my-0.5 prose-ol:pl-4",
288
- "prose-li:my-0 prose-li:leading-snug prose-li:marker:text-muted-foreground/60",
305
+ "prose-li:my-0 prose-li:leading-tight prose-li:marker:text-muted-foreground/60 sm:prose-li:leading-snug",
289
306
  "prose-headings:my-1 prose-headings:text-xs prose-headings:font-semibold",
290
307
  "prose-strong:text-foreground/90 prose-strong:font-medium",
291
308
  "prose-code:text-xs prose-code:font-medium",
@@ -379,7 +396,7 @@ export const SqlBlock = ({ sql }: { sql: string }) => {
379
396
  value={formatted}
380
397
  className="absolute right-1.5 top-1.5 z-10 bg-background/70 opacity-0 backdrop-blur transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
381
398
  />
382
- <pre className="max-w-full overflow-x-auto rounded-md border border-border bg-background p-3 font-mono text-xs leading-relaxed">
399
+ <pre className="max-w-full overflow-x-auto rounded-md border border-border bg-background p-3 font-mono text-xs leading-tight sm:leading-relaxed">
383
400
  {html === null ? (
384
401
  <code>{formatted}</code>
385
402
  ) : (
@@ -404,7 +421,7 @@ export const SourceBlock = ({
404
421
  return (
405
422
  <pre
406
423
  className={cn(
407
- "max-w-full overflow-auto whitespace-pre font-mono text-xs leading-snug",
424
+ "max-w-full overflow-auto whitespace-pre font-mono text-xs leading-tight sm:leading-snug",
408
425
  className,
409
426
  )}
410
427
  >
@@ -356,6 +356,9 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
356
356
  // as soon as the server's auto-generated title lands (see the prune
357
357
  // effect below). A manual rename (`renamedThreads`) still wins.
358
358
  const [provisionalTitles, setProvisionalTitles] = useState<Record<string, string>>({});
359
+ // Threads the user just deleted. The server list can lag, so hide them
360
+ // immediately and drop the overlay once the listing no longer returns them.
361
+ const [removedThreadIds, setRemovedThreadIds] = useState<ReadonlySet<string>>(() => new Set());
359
362
  // Surface the active thread in the sidebar immediately (called when its
360
363
  // first message is sent). Upserts an untitled row stamped "now" so it
361
364
  // sorts to the top; the server row replaces it on the next refresh.
@@ -880,15 +883,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
880
883
 
881
884
  const deleteThread = useCallback(
882
885
  async (threadId: string) => {
883
- try {
884
- await mastraClient.removeThread(threadId, { agentId });
885
- logger.info("thread deleted", { threadId });
886
- } catch (error) {
887
- logger.error("thread delete error", {
888
- threadId,
889
- error: errorUtils.toError(error),
890
- });
891
- }
886
+ setRemovedThreadIds((prev) => new Set(prev).add(threadId));
892
887
  removeSession(threadId);
893
888
  setOptimisticThreads((prev) => {
894
889
  if (!prev[threadId]) return prev;
@@ -905,7 +900,22 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
905
900
  resetSession(id, true);
906
901
  setActiveThreadId(id);
907
902
  }
908
- refreshThreads();
903
+ try {
904
+ await mastraClient.removeThread(threadId, { agentId });
905
+ logger.info("thread deleted", { threadId });
906
+ refreshThreads();
907
+ } catch (error) {
908
+ logger.error("thread delete error", {
909
+ threadId,
910
+ error: errorUtils.toError(error),
911
+ });
912
+ setRemovedThreadIds((prev) => {
913
+ if (!prev.has(threadId)) return prev;
914
+ const next = new Set(prev);
915
+ next.delete(threadId);
916
+ return next;
917
+ });
918
+ }
909
919
  },
910
920
  [activeThreadId, agentId, mastraClient, refreshThreads, removeSession, resetSession],
911
921
  );
@@ -1094,10 +1104,21 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
1094
1104
  .filter((t) => !serverIds.has(t.id))
1095
1105
  .map(withOverlay)
1096
1106
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
1097
- return [...pending, ...server];
1098
- }, [threads, optimisticThreads, renamedThreads, provisionalTitles]);
1107
+ return [...pending, ...server].filter((thread) => !removedThreadIds.has(thread.id));
1108
+ }, [threads, optimisticThreads, renamedThreads, provisionalTitles, removedThreadIds]);
1099
1109
  // Once the server list includes a thread we were tracking optimistically,
1100
1110
  // drop the optimistic copy so the map doesn't grow without bound.
1111
+ useEffect(() => {
1112
+ if (removedThreadIds.size === 0) return;
1113
+ const serverIds = new Set(threads.map((t) => t.id));
1114
+ const settled = [...removedThreadIds].filter((id) => !serverIds.has(id));
1115
+ if (settled.length === 0) return;
1116
+ setRemovedThreadIds((prev) => {
1117
+ const next = new Set(prev);
1118
+ for (const id of settled) next.delete(id);
1119
+ return next;
1120
+ });
1121
+ }, [threads, removedThreadIds]);
1101
1122
  useEffect(() => {
1102
1123
  const serverIds = new Set(threads.map((t) => t.id));
1103
1124
  const stale = Object.keys(optimisticThreads).filter((id) => serverIds.has(id));
@@ -100,6 +100,7 @@ export const ThreadSidebar = ({
100
100
  const [draftTitle, setDraftTitle] = useState("");
101
101
 
102
102
  const handleDeleteClick = (e: React.MouseEvent, threadId: string) => {
103
+ e.preventDefault();
103
104
  e.stopPropagation();
104
105
  if (!onDelete) return;
105
106
  if (confirmDeleteId !== threadId) {
@@ -229,7 +230,10 @@ export const ThreadSidebar = ({
229
230
  >
230
231
  <button
231
232
  type="button"
232
- onClick={() => onSelect(thread.id)}
233
+ onClick={() => {
234
+ setConfirmDeleteId(null);
235
+ onSelect(thread.id);
236
+ }}
233
237
  aria-current={isActive ? "page" : undefined}
234
238
  className={cn(
235
239
  "min-w-0 flex-1 cursor-pointer text-left transition-[padding] duration-150",
@@ -309,10 +313,8 @@ export const ThreadSidebar = ({
309
313
  type="button"
310
314
  variant={isConfirming ? "destructive" : "ghost"}
311
315
  size="icon"
316
+ onMouseDown={(e) => e.preventDefault()}
312
317
  onClick={(e) => handleDeleteClick(e, thread.id)}
313
- onBlur={() =>
314
- setConfirmDeleteId((id) => (id === thread.id ? null : id))
315
- }
316
318
  aria-label={
317
319
  isConfirming
318
320
  ? "Confirm delete conversation"
@@ -89,7 +89,7 @@ export const ThreadActions = ({
89
89
  </Tooltip>
90
90
  )}
91
91
  {historyLeadingActions}
92
- <Popover open={historyOpen} onOpenChange={setHistoryOpen}>
92
+ <Popover open={historyOpen} onOpenChange={setHistoryOpen} modal>
93
93
  <Tooltip>
94
94
  <TooltipTrigger asChild>
95
95
  <PopoverTrigger asChild>
@@ -106,7 +106,23 @@ export const ThreadActions = ({
106
106
  </TooltipTrigger>
107
107
  <TooltipContent>Conversation history</TooltipContent>
108
108
  </Tooltip>
109
- <PopoverContent align="end" aria-label="Conversation history" className="w-72 p-0">
109
+ <PopoverContent
110
+ align="end"
111
+ aria-label="Conversation history"
112
+ className="w-72 p-0"
113
+ onOpenAutoFocus={(event) => event.preventDefault()}
114
+ onCloseAutoFocus={(event) => event.preventDefault()}
115
+ onFocusOutside={(event) => event.preventDefault()}
116
+ onPointerDownOutside={(event) => {
117
+ const target = event.target;
118
+ if (
119
+ target instanceof Element &&
120
+ target.closest("[data-radix-popper-content-wrapper]")
121
+ ) {
122
+ event.preventDefault();
123
+ }
124
+ }}
125
+ >
110
126
  <ThreadSidebar
111
127
  {...list}
112
128
  onSelect={(id) => {
@@ -151,9 +167,19 @@ export const ThreadTabs = ({
151
167
  ...list
152
168
  }: ThreadTabsProps) => {
153
169
  const { threads, activeThreadId, streamingThreadIds = [], onSelect } = list;
170
+ const tabStripRef = useRef<HTMLDivElement | null>(null);
154
171
  const activeTabRef = useRef<HTMLDivElement | null>(null);
155
172
  useEffect(() => {
156
- activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
173
+ const container = tabStripRef.current;
174
+ const activeTab = activeTabRef.current;
175
+ if (!container || !activeTab) return;
176
+ const containerRect = container.getBoundingClientRect();
177
+ const tabRect = activeTab.getBoundingClientRect();
178
+ if (tabRect.left < containerRect.left) {
179
+ container.scrollBy({ left: tabRect.left - containerRect.left, behavior: "smooth" });
180
+ } else if (tabRect.right > containerRect.right) {
181
+ container.scrollBy({ left: tabRect.right - containerRect.right, behavior: "smooth" });
182
+ }
157
183
  }, [activeThreadId]);
158
184
 
159
185
  return (
@@ -161,7 +187,7 @@ export const ThreadTabs = ({
161
187
  // in the same centered column as the transcript and composer.
162
188
  <div className={cn("w-full border-b border-border text-xs", className)}>
163
189
  <div className="mx-auto flex w-full max-w-4xl items-center gap-1 px-2 py-1 md:px-4">
164
- <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
190
+ <div ref={tabStripRef} className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
165
191
  {openThreadIds.map((id) => {
166
192
  const thread = threads.find((t) => t.id === id) ?? { id };
167
193
  const isActive = id === activeThreadId;
@@ -61,6 +61,10 @@ export const humanizeToolName = (toolName: string): string => {
61
61
  * server-side label generator.
62
62
  */
63
63
  const runningLabelFor = (event: ToolEvent): string => {
64
+ const toolStatus = [...(event.progress ?? [])]
65
+ .reverse()
66
+ .find((p): p is Extract<ToolProgress, { type: "tool_status" }> => p.type === "tool_status");
67
+ if (toolStatus?.message.trim()) return toolStatus.message.trim();
64
68
  const latest = [...(event.progress ?? [])]
65
69
  .reverse()
66
70
  .find((p): p is Extract<ToolProgress, { type: "status" }> => p.type === "status");
@@ -285,7 +289,7 @@ const MessageGroupBody = ({
285
289
  return (
286
290
  <div className="flex flex-col gap-1.5">
287
291
  {!omitQuestion && group.question && (
288
- <div className="rounded border-l-2 border-primary/40 bg-background/40 px-2 py-1 text-xs italic leading-snug text-muted-foreground">
292
+ <div className="rounded border-l-2 border-primary/40 bg-background/40 px-2 py-1 text-xs italic leading-tight text-muted-foreground sm:leading-snug">
289
293
  {group.question}
290
294
  </div>
291
295
  )}
@@ -309,7 +313,7 @@ const MessageGroupBody = ({
309
313
  <SourceBlock
310
314
  source={bucket.query.sql}
311
315
  language="sql"
312
- className="max-h-64 border-t border-border/50 px-2 py-1.5 text-foreground"
316
+ className="max-h-48 overflow-auto overscroll-contain border-t border-border/50 px-2 py-1.5 text-foreground sm:max-h-64"
313
317
  />
314
318
  {bucket.query.description && (
315
319
  <div className="border-t border-border/50 px-2 py-1.5">
@@ -402,23 +406,25 @@ const RawToolPayload = ({
402
406
  }) => {
403
407
  const structured = structuredToolPayload(value);
404
408
  return (
405
- <Collapsible className="rounded border border-border/60 bg-background/40">
406
- <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
409
+ <Collapsible className="min-w-0 max-w-full overflow-hidden rounded border border-border/60 bg-background/40">
410
+ <CollapsibleTrigger className="group flex w-full min-w-0 items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
407
411
  <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
408
412
  <span>{label}</span>
409
413
  </CollapsibleTrigger>
410
- <CollapsibleContent>
411
- {structured === undefined ? (
412
- <JsonBlock
413
- json={formatRawToolPayload(value)}
414
- className="max-h-80 border-t border-border/50 px-2 py-2 text-foreground"
415
- />
416
- ) : (
417
- <RecordPreview
418
- value={structured}
419
- className="border-t border-border/50 bg-transparent text-foreground"
420
- />
421
- )}
414
+ <CollapsibleContent className="min-w-0 max-w-full overflow-hidden">
415
+ <div className="max-h-48 max-w-full overflow-auto overscroll-contain sm:max-h-64">
416
+ {structured === undefined ? (
417
+ <JsonBlock
418
+ json={formatRawToolPayload(value)}
419
+ className="border-t border-border/50 px-2 py-2 leading-tight text-foreground sm:leading-relaxed"
420
+ />
421
+ ) : (
422
+ <RecordPreview
423
+ value={structured}
424
+ className="max-w-full border-t border-border/50 bg-transparent text-foreground"
425
+ />
426
+ )}
427
+ </div>
422
428
  </CollapsibleContent>
423
429
  </Collapsible>
424
430
  );
@@ -488,8 +494,8 @@ export function toolOutputPresentation(
488
494
  const ToolInputSource = ({ presentation }: { presentation: ToolInputPresentation }) => {
489
495
  const lineCount = presentation.source.split("\n").length;
490
496
  return (
491
- <Collapsible className="rounded border border-border/60 bg-background/40">
492
- <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground">
497
+ <Collapsible className="min-w-0 max-w-full overflow-hidden rounded border border-border/60 bg-background/40">
498
+ <CollapsibleTrigger className="group flex w-full min-w-0 items-center gap-1.5 px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground">
493
499
  <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
494
500
  <span className="font-medium text-foreground/80">{presentation.label}</span>
495
501
  <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase leading-none tracking-wide">
@@ -499,11 +505,11 @@ const ToolInputSource = ({ presentation }: { presentation: ToolInputPresentation
499
505
  {lineCount} {lineCount === 1 ? "line" : "lines"}
500
506
  </span>
501
507
  </CollapsibleTrigger>
502
- <CollapsibleContent>
508
+ <CollapsibleContent className="min-w-0 max-w-full overflow-hidden">
503
509
  <SourceBlock
504
510
  source={presentation.source}
505
511
  language={presentation.language}
506
- className="max-h-64 border-t border-border/50 px-2 py-1.5 text-foreground"
512
+ className="max-h-48 overflow-auto overscroll-contain border-t border-border/50 px-2 py-1.5 text-foreground sm:max-h-64"
507
513
  />
508
514
  </CollapsibleContent>
509
515
  </Collapsible>
@@ -586,6 +592,25 @@ const WebSearchProgressDetails = ({ event }: { event: ToolEvent }) => {
586
592
  );
587
593
  };
588
594
 
595
+ /** Latest cumulative detail emitted by a non-search tool. */
596
+ const ToolStatusProgressDetails = ({ event }: { event: ToolEvent }) => {
597
+ if (event.toolName === "web_search") return null;
598
+ const detail = [...(event.progress ?? [])]
599
+ .reverse()
600
+ .find(
601
+ (progress): progress is ToolStatusProgress =>
602
+ progress.type === "tool_status" && Boolean(progress.detail?.trim()),
603
+ )?.detail;
604
+ if (!detail) return null;
605
+ return (
606
+ <SourceBlock
607
+ source={detail}
608
+ language="text"
609
+ className="max-h-48 overflow-auto overscroll-contain whitespace-pre-wrap break-words rounded border border-border/50 bg-background/40 p-2 text-foreground sm:max-h-64"
610
+ />
611
+ );
612
+ };
613
+
589
614
  /**
590
615
  * True when a tool event has anything worth expanding for. The
591
616
  * question text already rides on the row header (see
@@ -619,7 +644,7 @@ const ToolCallDetails = ({
619
644
  return (
620
645
  <div className="flex flex-col gap-2">
621
646
  {showQuestion && question ? (
622
- <p className="break-words px-0.5 text-[11px] leading-snug text-muted-foreground">
647
+ <p className="break-words px-0.5 text-[11px] leading-tight text-muted-foreground sm:leading-snug">
623
648
  {question}
624
649
  </p>
625
650
  ) : null}
@@ -629,6 +654,7 @@ const ToolCallDetails = ({
629
654
  <RawToolPayload label="Request" value={event.input} />
630
655
  ) : null}
631
656
  <WebSearchProgressDetails event={event} />
657
+ <ToolStatusProgressDetails event={event} />
632
658
  <ToolProgressDetails summary={summary} omitQuestion />
633
659
  {outputPresentation?.language === "json" ? (
634
660
  <RawToolPayload label="Output" value={outputPresentation.source} />
@@ -676,7 +702,7 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
676
702
  {verb}
677
703
  </span>
678
704
  {question && (
679
- <span className="mt-0.5 block break-words text-[11px] leading-snug text-foreground/75">
705
+ <span className="mt-0.5 block break-words text-[11px] leading-tight text-foreground/75 sm:leading-snug">
680
706
  {question}
681
707
  </span>
682
708
  )}
@@ -698,7 +724,7 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
698
724
  }
699
725
 
700
726
  return (
701
- <Collapsible className="rounded border border-border/40 bg-background/30">
727
+ <Collapsible className="min-w-0 max-w-full overflow-hidden rounded border border-border/40 bg-background/30">
702
728
  <CollapsibleTrigger className="group flex w-full items-start gap-2 px-2 py-1 text-left text-xs text-muted-foreground cursor-pointer hover:text-foreground">
703
729
  <ChevronDownIcon className="mt-0.5 size-3 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
704
730
  <span className="mt-0.5 shrink-0">
@@ -707,7 +733,7 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
707
733
  {header}
708
734
  </CollapsibleTrigger>
709
735
  <CollapsibleContent>
710
- <div className="px-2 pb-2">
736
+ <div className="min-w-0 max-w-full overflow-hidden px-2 pb-2">
711
737
  <ToolCallDetails event={event} />
712
738
  </div>
713
739
  </CollapsibleContent>
@@ -754,7 +780,7 @@ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
754
780
  return (
755
781
  <div
756
782
  className={cn(
757
- "rounded-md border bg-background/30 px-2 py-1 transition-colors",
783
+ "min-w-0 max-w-full overflow-hidden rounded-md border bg-background/30 px-2 py-1 transition-colors",
758
784
  // Loud-but-not-jarring "in flight" treatment when any row
759
785
  // is running: primary border + soft ring. Failed sessions
760
786
  // (no longer running) pick up the destructive border;
@@ -798,8 +824,13 @@ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
798
824
  </span>
799
825
  )}
800
826
  </CollapsibleTrigger>
801
- <CollapsibleContent>
802
- <div className={cn("mt-1.5", events.length > 1 && "flex flex-col gap-1")}>
827
+ <CollapsibleContent className="min-w-0 max-w-full overflow-hidden">
828
+ <div
829
+ className={cn(
830
+ "mt-1.5 min-w-0 max-w-full overflow-hidden",
831
+ events.length > 1 && "flex flex-col gap-1",
832
+ )}
833
+ >
803
834
  {events.length === 1 ? (
804
835
  <ToolCallDetails event={latest} showQuestion />
805
836
  ) : (
@@ -22,7 +22,7 @@ import {
22
22
  type ListMemoryThreadsResponse,
23
23
  } from "@mastra/client-js";
24
24
  import { DefaultChatTransport, type UIMessage, type UIMessageChunk } from "ai";
25
- import { useCallback, useEffect, useMemo, useState } from "react";
25
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26
26
  import type { MastraRequestContextSnapshot } from "./request-context.ts";
27
27
 
28
28
  type NativeAgentClient = ReturnType<MastraClient["getAgent"]>;
@@ -916,19 +916,22 @@ export const useMastraThreads = (
916
916
  // the natural deps (client / agentId / enabled).
917
917
  const [nonce, setNonce] = useState(0);
918
918
  const refresh = useCallback(() => setNonce((n) => n + 1), []);
919
+ const hasLoadedRef = useRef(false);
919
920
 
920
921
  useEffect(() => {
921
922
  if (!enabled) {
923
+ hasLoadedRef.current = false;
922
924
  setLoading(false);
923
925
  setThreads([]);
924
926
  return;
925
927
  }
926
928
  const controller = new AbortController();
927
- setLoading(true);
929
+ if (!hasLoadedRef.current) setLoading(true);
928
930
  client
929
931
  .threads({ agentId, signal: controller.signal })
930
932
  .then((res) => {
931
933
  if (controller.signal.aborted) return;
934
+ hasLoadedRef.current = true;
932
935
  setThreads(res.threads);
933
936
  setError(null);
934
937
  })