@dbx-tools/ui-mastra 0.9.112 → 0.9.117

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.112";
6
+ export const PACKAGE_VERSION = "0.9.117";
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";
@@ -64,7 +64,7 @@ export { ThreadActions, ThreadTabs } from "./src/react/thread-tabs.tsx";
64
64
  export type { ThreadTabsProps, ThreadActionsProps } from "./src/react/thread-tabs.tsx";
65
65
  export { humanizeToolName, ToolSessionPill } from "./src/react/tool-pill.tsx";
66
66
  export type { ToolInputPresentation, WebSearchProgressGroup } from "./src/react/tool-pill.tsx";
67
- export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
67
+ export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, ChatInputMessage, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
68
68
  export { LIGHT_CHART_CHROME } from "./src/support/chart-theme.ts";
69
69
  export type { ChartChrome } from "./src/support/chart-theme.ts";
70
70
  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.112",
33
- "@dbx-tools/shared-genie": "^0.9.112",
34
- "@dbx-tools/shared-mastra": "^0.9.112",
35
- "@dbx-tools/shared-model": "^0.9.112",
36
- "@dbx-tools/ui": "^0.9.112",
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",
37
37
  "@mastra/client-js": "1.50.0",
38
38
  "@mastra/core": "1.71.0",
39
39
  "@tanstack/react-table": "^8.21.3",
@@ -49,7 +49,7 @@
49
49
  "publishConfig": {
50
50
  "access": "public"
51
51
  },
52
- "version": "0.9.112",
52
+ "version": "0.9.117",
53
53
  "dbxToolsConfig": {
54
54
  "tags": [
55
55
  "ui"
@@ -59,6 +59,7 @@
59
59
  "exports": {
60
60
  "./react": "./src/react/index.ts",
61
61
  "./styles.css": "./src/styles.css",
62
+ "./*": "./src/*/index.ts",
62
63
  "./package.json": "./package.json"
63
64
  },
64
65
  "files": [
@@ -0,0 +1,112 @@
1
+ import type { FileUIPart } from "ai";
2
+
3
+ export const MAX_COMPOSER_IMAGES = 4;
4
+ export const MAX_COMPOSER_IMAGE_BYTES = 4 * 1024 * 1024;
5
+ export const MAX_COMPOSER_IMAGE_EDGE = 2048;
6
+
7
+ type ImageDimensions = {
8
+ width: number;
9
+ height: number;
10
+ };
11
+
12
+ /** Fit image dimensions within a maximum edge while preserving aspect ratio. */
13
+ export function fitImageDimensions(
14
+ width: number,
15
+ height: number,
16
+ maxEdge = MAX_COMPOSER_IMAGE_EDGE,
17
+ ): ImageDimensions {
18
+ if (width <= maxEdge && height <= maxEdge) return { width, height };
19
+ const scale = maxEdge / Math.max(width, height);
20
+ return {
21
+ width: Math.max(1, Math.round(width * scale)),
22
+ height: Math.max(1, Math.round(height * scale)),
23
+ };
24
+ }
25
+
26
+ function dataUrlForBlob(blob: Blob): Promise<string> {
27
+ return new Promise((resolve, reject) => {
28
+ const reader = new FileReader();
29
+ reader.onerror = () => reject(reader.error ?? new Error("Unable to read image"));
30
+ reader.onload = () => resolve(String(reader.result));
31
+ reader.readAsDataURL(blob);
32
+ });
33
+ }
34
+
35
+ function loadImage(file: File): Promise<HTMLImageElement> {
36
+ return new Promise((resolve, reject) => {
37
+ const url = URL.createObjectURL(file);
38
+ const image = new Image();
39
+ image.onload = () => {
40
+ URL.revokeObjectURL(url);
41
+ resolve(image);
42
+ };
43
+ image.onerror = () => {
44
+ URL.revokeObjectURL(url);
45
+ reject(new Error(`Unable to decode ${file.name || "image"}`));
46
+ };
47
+ image.src = url;
48
+ });
49
+ }
50
+
51
+ function canvasBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
52
+ return new Promise((resolve, reject) => {
53
+ canvas.toBlob(
54
+ (blob) => (blob ? resolve(blob) : reject(new Error("Unable to resize image"))),
55
+ type,
56
+ quality,
57
+ );
58
+ });
59
+ }
60
+
61
+ function resizedFilename(filename: string, mediaType: string): string {
62
+ const extension = mediaType === "image/webp" ? "webp" : mediaType === "image/png" ? "png" : "jpg";
63
+ const stem = filename.replace(/\.[^.]+$/, "") || "image";
64
+ return `${stem}.${extension}`;
65
+ }
66
+
67
+ async function shrinkImage(
68
+ file: File,
69
+ maxBytes: number,
70
+ ): Promise<{ blob: Blob; filename: string }> {
71
+ const image = await loadImage(file);
72
+ const fitted = fitImageDimensions(image.naturalWidth, image.naturalHeight);
73
+ const canvas = document.createElement("canvas");
74
+ const context = canvas.getContext("2d");
75
+ if (!context) throw new Error("Image resizing is unavailable in this browser");
76
+
77
+ let smallest: Blob | undefined;
78
+ for (let attempt = 0; attempt < 8; attempt += 1) {
79
+ const scale = 0.82 ** Math.floor(attempt / 2);
80
+ canvas.width = Math.max(1, Math.round(fitted.width * scale));
81
+ canvas.height = Math.max(1, Math.round(fitted.height * scale));
82
+ context.clearRect(0, 0, canvas.width, canvas.height);
83
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
84
+ const quality = attempt % 2 === 0 ? 0.84 : 0.66;
85
+ const blob = await canvasBlob(canvas, "image/webp", quality);
86
+ if (!smallest || blob.size < smallest.size) smallest = blob;
87
+ if (blob.size <= maxBytes) {
88
+ return { blob, filename: resizedFilename(file.name, blob.type) };
89
+ }
90
+ }
91
+
92
+ if (!smallest || smallest.size > maxBytes) {
93
+ throw new Error(`${file.name || "Image"} could not be reduced below 4 MB`);
94
+ }
95
+ return { blob: smallest, filename: resizedFilename(file.name, smallest.type) };
96
+ }
97
+
98
+ /** Convert an image file to the native AI SDK file part, shrinking oversized images first. */
99
+ export async function imageFileToUIPart(
100
+ file: File,
101
+ maxBytes = MAX_COMPOSER_IMAGE_BYTES,
102
+ ): Promise<FileUIPart> {
103
+ if (!file.type.startsWith("image/")) throw new Error(`${file.name || "File"} is not an image`);
104
+ const prepared =
105
+ file.size > maxBytes ? await shrinkImage(file, maxBytes) : { blob: file, filename: file.name };
106
+ return {
107
+ type: "file",
108
+ mediaType: prepared.blob.type || file.type,
109
+ filename: prepared.filename || "image",
110
+ url: await dataUrlForBlob(prepared.blob),
111
+ };
112
+ }
@@ -16,6 +16,7 @@ import {
16
16
  DropdownMenuTrigger,
17
17
  Slider,
18
18
  Spinner,
19
+ useIsMobile,
19
20
  } from "@dbx-tools/ui/react";
20
21
  import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
21
22
  import { useMemo, useState, type ReactNode } from "react";
@@ -185,6 +186,7 @@ export const ModelSelector = ({
185
186
  onModelChange,
186
187
  actions,
187
188
  }: ModelSelectorProps) => {
189
+ const isMobile = useIsMobile();
188
190
  const [open, setOpen] = useState(false);
189
191
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
190
192
  const [activeFamily, setActiveFamily] = useState<string>();
@@ -242,50 +244,102 @@ export const ModelSelector = ({
242
244
  )}
243
245
  </Button>
244
246
  </DropdownMenuTrigger>
245
- <DropdownMenuContent align="end" className="w-48">
246
- <DropdownMenuSub
247
- open={modelPickerOpen}
248
- onOpenChange={(next) => {
249
- if (!next && open) return;
250
- setModelPickerOpen(next);
251
- if (!next) setActiveFamily(undefined);
252
- }}
253
- >
254
- <DropdownMenuSubTrigger className="mx-1 justify-center rounded-lg bg-accent/60 px-3 py-2 text-xs font-medium">
255
- {showLoading ? (
247
+ <DropdownMenuContent
248
+ align="end"
249
+ collisionPadding={8}
250
+ className="w-48 max-w-[calc(100vw_-_1rem)]"
251
+ >
252
+ {isMobile && modelPickerOpen ? (
253
+ <>
254
+ {!activeFamily && (
256
255
  <>
257
- <Spinner className="size-3 shrink-0" />
258
- <span className="sr-only">Loading default model</span>
256
+ <button
257
+ type="button"
258
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs font-medium outline-none hover:bg-accent focus-visible:bg-accent"
259
+ onClick={() => setModelPickerOpen(false)}
260
+ >
261
+ <ChevronLeftIcon className="size-3" />
262
+ Options
263
+ </button>
264
+ <DropdownMenuSeparator />
259
265
  </>
266
+ )}
267
+ <FamilyPicker
268
+ families={families}
269
+ selectedModelId={selectedModelId}
270
+ model={model}
271
+ defaultModelName={defaultModelName}
272
+ showLoading={showLoading}
273
+ activeFamily={activeFamily}
274
+ onActiveFamilyChange={setActiveFamily}
275
+ onModelChange={onModelChange}
276
+ />
277
+ </>
278
+ ) : (
279
+ <>
280
+ {isMobile ? (
281
+ <button
282
+ type="button"
283
+ className="mx-1 flex w-[calc(100%_-_0.5rem)] items-center rounded-lg bg-accent/60 px-3 py-2 text-xs font-medium outline-none hover:bg-accent focus-visible:bg-accent"
284
+ onClick={() => setModelPickerOpen(true)}
285
+ >
286
+ {showLoading ? (
287
+ <>
288
+ <Spinner className="size-3 shrink-0" />
289
+ <span className="sr-only">Loading default model</span>
290
+ </>
291
+ ) : (
292
+ <span className="truncate">{triggerLabel}</span>
293
+ )}
294
+ <ChevronRightIcon className="ml-auto size-3 shrink-0 text-muted-foreground" />
295
+ </button>
260
296
  ) : (
261
- triggerLabel
297
+ <DropdownMenuSub
298
+ open={modelPickerOpen}
299
+ onOpenChange={(next) => {
300
+ if (!next && open) return;
301
+ setModelPickerOpen(next);
302
+ if (!next) setActiveFamily(undefined);
303
+ }}
304
+ >
305
+ <DropdownMenuSubTrigger className="mx-1 justify-center rounded-lg bg-accent/60 px-3 py-2 text-xs font-medium">
306
+ {showLoading ? (
307
+ <>
308
+ <Spinner className="size-3 shrink-0" />
309
+ <span className="sr-only">Loading default model</span>
310
+ </>
311
+ ) : (
312
+ triggerLabel
313
+ )}
314
+ </DropdownMenuSubTrigger>
315
+ <DropdownMenuPortal>
316
+ <DropdownMenuSubContent collisionPadding={8} className="w-48">
317
+ <FamilyPicker
318
+ families={families}
319
+ selectedModelId={selectedModelId}
320
+ model={model}
321
+ defaultModelName={defaultModelName}
322
+ showLoading={showLoading}
323
+ activeFamily={activeFamily}
324
+ onActiveFamilyChange={setActiveFamily}
325
+ onModelChange={onModelChange}
326
+ />
327
+ </DropdownMenuSubContent>
328
+ </DropdownMenuPortal>
329
+ </DropdownMenuSub>
330
+ )}
331
+ {(actions || (reasoningEfforts.length > 0 && onReasoningEffortChange)) && (
332
+ <DropdownMenuSeparator />
262
333
  )}
263
- </DropdownMenuSubTrigger>
264
- <DropdownMenuPortal>
265
- <DropdownMenuSubContent className="w-48">
266
- <FamilyPicker
267
- families={families}
268
- selectedModelId={selectedModelId}
269
- model={model}
270
- defaultModelName={defaultModelName}
271
- showLoading={showLoading}
272
- activeFamily={activeFamily}
273
- onActiveFamilyChange={setActiveFamily}
274
- onModelChange={onModelChange}
334
+ {actions && <div className="px-1">{actions}</div>}
335
+ {reasoningEfforts.length > 0 && onReasoningEffortChange && (
336
+ <ReasoningControl
337
+ efforts={reasoningEfforts}
338
+ value={reasoningEffort}
339
+ onChange={onReasoningEffortChange}
275
340
  />
276
- </DropdownMenuSubContent>
277
- </DropdownMenuPortal>
278
- </DropdownMenuSub>
279
- {(actions || (reasoningEfforts.length > 0 && onReasoningEffortChange)) && (
280
- <DropdownMenuSeparator />
281
- )}
282
- {actions && <div className="px-1">{actions}</div>}
283
- {reasoningEfforts.length > 0 && onReasoningEffortChange && (
284
- <ReasoningControl
285
- efforts={reasoningEfforts}
286
- value={reasoningEffort}
287
- onChange={onReasoningEffortChange}
288
- />
341
+ )}
342
+ </>
289
343
  )}
290
344
  </DropdownMenuContent>
291
345
  </DropdownMenu>
@@ -12,6 +12,7 @@ import {
12
12
  Tooltip,
13
13
  TooltipContent,
14
14
  TooltipTrigger,
15
+ cn,
15
16
  } from "@dbx-tools/ui/react";
16
17
  import type { UIMessage } from "ai";
17
18
  import {
@@ -21,7 +22,6 @@ import {
21
22
  CopyIcon,
22
23
  MessageSquareIcon,
23
24
  RefreshCcwIcon,
24
- SparklesIcon,
25
25
  UserIcon,
26
26
  XIcon,
27
27
  } from "lucide-react";
@@ -59,12 +59,12 @@ const getReasoningText = (parts: UIMessage["parts"]): string =>
59
59
  .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
60
60
  .map((p) => compactReasoningText(p.text))
61
61
  .filter(Boolean)
62
- .join("\n\n");
62
+ .join("\n");
63
63
 
64
- const RoleAvatar = ({ role }: { role: UIMessage["role"] }) => (
64
+ const UserAvatar = () => (
65
65
  <Avatar className="size-7">
66
66
  <AvatarFallback>
67
- {role === "assistant" ? <SparklesIcon className="size-4" /> : <UserIcon className="size-4" />}
67
+ <UserIcon className="size-4" />
68
68
  </AvatarFallback>
69
69
  </Avatar>
70
70
  );
@@ -192,7 +192,7 @@ const ToolApprovalCard = ({
192
192
  {asEmailInput(input) ? (
193
193
  <EmailApprovalPreview email={asEmailInput(input)!} />
194
194
  ) : (
195
- <RecordPreview value={input} className="max-h-80 rounded bg-background/40" />
195
+ <RecordPreview value={input} className="rounded bg-background/40" />
196
196
  )}
197
197
  {expired ? (
198
198
  <div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
@@ -362,7 +362,7 @@ export const AssistantBubble = ({
362
362
  const toolEvents = mergeToolEvents(toolEventsFromParts(message.parts), events, terminalToolState);
363
363
  const reasoning = [getReasoningText(message.parts), genieReasoningText(toolEvents)]
364
364
  .filter(Boolean)
365
- .join("\n\n");
365
+ .join("\n");
366
366
  const isReasoningStreaming =
367
367
  isLast &&
368
368
  status === "streaming" &&
@@ -404,10 +404,7 @@ export const AssistantBubble = ({
404
404
  // nothing so the prose flows unaffected. Suggested questions
405
405
  // stay gated on settle to avoid pop-in mid-stream.
406
406
  return (
407
- <Item className="items-start gap-2.5 border-none bg-transparent p-0">
408
- <ItemMedia>
409
- <RoleAvatar role="assistant" />
410
- </ItemMedia>
407
+ <Item className="items-start gap-0 border-none bg-transparent p-0">
411
408
  {/*
412
409
  * `min-w-0` is required so wide content (markdown tables, long
413
410
  * code blocks) can shrink to fit instead of pushing the whole
@@ -431,7 +428,7 @@ export const AssistantBubble = ({
431
428
  </CollapsibleTrigger>
432
429
  <CollapsibleContent>
433
430
  <div className="mt-1 border-l-2 border-border/60 pl-2.5 text-xs text-muted-foreground">
434
- <ToolMarkdown>{reasoning}</ToolMarkdown>
431
+ <ToolMarkdown className="prose-p:my-0 [&_p]:!my-0">{reasoning}</ToolMarkdown>
435
432
  </div>
436
433
  </CollapsibleContent>
437
434
  </Collapsible>
@@ -535,14 +532,31 @@ export const UserBubble = ({ message }: { message: UIMessage }) => {
535
532
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
536
533
  .map((p) => p.text)
537
534
  .join("");
535
+ const files = message.parts.filter((part) => part.type === "file");
538
536
  return (
539
537
  <Item className="items-start gap-2.5 border-none bg-transparent p-0">
540
538
  <ItemMedia>
541
- <RoleAvatar role="user" />
539
+ <UserAvatar />
542
540
  </ItemMedia>
543
541
  <ItemContent className="min-w-0">
544
- <div className="rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
545
- {text}
542
+ <div className="max-w-full rounded-lg bg-muted p-2 text-sm">
543
+ {files.length > 0 && (
544
+ <div className={cn("grid gap-1.5", files.length > 1 && "grid-cols-2")}>
545
+ {files.map((file, index) => (
546
+ <img
547
+ key={`${file.filename ?? "image"}-${index}`}
548
+ src={file.url}
549
+ alt={file.filename || "Attached image"}
550
+ className="max-h-64 max-w-full rounded-md object-contain"
551
+ />
552
+ ))}
553
+ </div>
554
+ )}
555
+ {text && (
556
+ <div className={cn("whitespace-pre-wrap break-words px-1", files.length > 0 && "pt-2")}>
557
+ {text}
558
+ </div>
559
+ )}
546
560
  </div>
547
561
  </ItemContent>
548
562
  </Item>
@@ -18,8 +18,10 @@ import {
18
18
  TooltipTrigger,
19
19
  cn,
20
20
  } from "@dbx-tools/ui/react";
21
+ import type { FileUIPart } from "ai";
21
22
  import {
22
23
  GripVerticalIcon,
24
+ ImagePlusIcon,
23
25
  SendHorizontalIcon,
24
26
  SendIcon,
25
27
  SquareIcon,
@@ -28,6 +30,7 @@ import {
28
30
  } from "lucide-react";
29
31
  import { useCallback, useId, useLayoutEffect, useRef, useState } from "react";
30
32
  import { autosizeComposerTextarea, observeComposerWidth } from "./_composer-autosize.ts";
33
+ import { imageFileToUIPart, MAX_COMPOSER_IMAGES } from "./_image-attachments.ts";
31
34
  import { ModelSelector } from "./_model-selector.tsx";
32
35
  import { ExportMenu } from "./export-menu.tsx";
33
36
  import { SuggestionPills } from "./suggestion-pills.tsx";
@@ -123,7 +126,9 @@ const QueuedSteerList = ({
123
126
  </span>
124
127
  )}
125
128
  <span className="text-muted-foreground">Queued</span>
126
- <span className="min-w-0 flex-1 truncate">{steer.text}</span>
129
+ <span className="min-w-0 flex-1 truncate">
130
+ {steer.text || steer.files?.map((file) => file.filename || "Image").join(", ")}
131
+ </span>
127
132
  {onSendSteerNow && (
128
133
  <Tooltip>
129
134
  <TooltipTrigger asChild>
@@ -192,13 +197,13 @@ const ClearConversationAction = ({ onClear, isLoadingHistory }: ClearConversatio
192
197
  <Button
193
198
  type="button"
194
199
  variant="outline"
195
- size="sm"
200
+ size="icon"
196
201
  onClick={() => setOpen(true)}
197
202
  disabled={isLoadingHistory}
198
- className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
203
+ className="size-7 rounded-full [&_svg]:size-3"
204
+ aria-label="Clear conversation"
199
205
  >
200
206
  <Trash2Icon className="size-3" />
201
- Clear
202
207
  </Button>
203
208
  </TooltipTrigger>
204
209
  <TooltipContent>Clear chat history for this thread</TooltipContent>
@@ -285,7 +290,11 @@ export const ChatComposer = ({
285
290
  onResumeFollow,
286
291
  }: ChatComposerProps) => {
287
292
  const [input, setInput] = useState("");
293
+ const [files, setFiles] = useState<FileUIPart[]>([]);
294
+ const [fileError, setFileError] = useState<string | null>(null);
295
+ const [isPreparingFiles, setIsPreparingFiles] = useState(false);
288
296
  const inputId = useId();
297
+ const fileInputRef = useRef<HTMLInputElement>(null);
289
298
  const inputGroupRef = useRef<HTMLDivElement>(null);
290
299
  const textareaRef = useRef<HTMLTextAreaElement>(null);
291
300
 
@@ -304,14 +313,44 @@ export const ChatComposer = ({
304
313
  }, [resizeTextarea]);
305
314
 
306
315
  const isRunning = status === "submitted" || status === "streaming";
316
+ const hasDraft = Boolean(input.trim() || files.length > 0);
307
317
  const submit = () => {
308
318
  const text = input.trim();
309
- if (!text || isLoadingHistory) return;
310
- sendMessage({ text });
319
+ if ((!text && files.length === 0) || isLoadingHistory || isPreparingFiles) return;
320
+ sendMessage({ ...(text ? { text } : {}), ...(files.length > 0 ? { files } : {}) });
311
321
  setInput("");
322
+ setFiles([]);
323
+ setFileError(null);
312
324
  onResumeFollow();
313
325
  };
314
326
 
327
+ const addFiles = async (selected: FileList | null) => {
328
+ if (!selected?.length) return;
329
+ const remaining = MAX_COMPOSER_IMAGES - files.length;
330
+ if (remaining <= 0) {
331
+ setFileError(`You can attach up to ${MAX_COMPOSER_IMAGES} images`);
332
+ return;
333
+ }
334
+ setIsPreparingFiles(true);
335
+ setFileError(null);
336
+ try {
337
+ const next = await Promise.all(
338
+ Array.from(selected)
339
+ .slice(0, remaining)
340
+ .map((file) => imageFileToUIPart(file)),
341
+ );
342
+ setFiles((current) => [...current, ...next]);
343
+ if (selected.length > remaining) {
344
+ setFileError(`Only the first ${remaining} images were added`);
345
+ }
346
+ } catch (error) {
347
+ setFileError(error instanceof Error ? error.message : "Unable to attach image");
348
+ } finally {
349
+ setIsPreparingFiles(false);
350
+ if (fileInputRef.current) fileInputRef.current.value = "";
351
+ }
352
+ };
353
+
315
354
  const modelChangeable = Boolean(models && models.length > 0);
316
355
  return (
317
356
  <>
@@ -339,6 +378,37 @@ export const ChatComposer = ({
339
378
  ref={inputGroupRef}
340
379
  className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md"
341
380
  >
381
+ {files.length > 0 && (
382
+ <InputGroupAddon
383
+ align="block-start"
384
+ className="max-w-full justify-start gap-2 overflow-x-auto pb-0"
385
+ >
386
+ {files.map((file, index) => (
387
+ <div
388
+ key={`${file.filename ?? "image"}-${index}`}
389
+ className="group relative size-20 shrink-0 overflow-hidden rounded-xl border border-border bg-muted"
390
+ >
391
+ <img
392
+ src={file.url}
393
+ alt={file.filename || "Attached image"}
394
+ className="size-full object-cover"
395
+ />
396
+ <Button
397
+ type="button"
398
+ size="icon"
399
+ variant="secondary"
400
+ className="absolute right-0.5 top-0.5 size-5 rounded-full opacity-90"
401
+ onClick={() =>
402
+ setFiles((current) => current.filter((_, item) => item !== index))
403
+ }
404
+ aria-label={`Remove ${file.filename || "image"}`}
405
+ >
406
+ <XIcon className="size-3" />
407
+ </Button>
408
+ </div>
409
+ ))}
410
+ </InputGroupAddon>
411
+ )}
342
412
  <InputGroupTextarea
343
413
  id={`${inputId}-message`}
344
414
  name="message"
@@ -355,15 +425,51 @@ export const ChatComposer = ({
355
425
  rows={1}
356
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"
357
427
  />
428
+ {fileError && (
429
+ <div className="px-4 text-xs text-destructive" role="alert">
430
+ {fileError}
431
+ </div>
432
+ )}
358
433
  <InputGroupAddon
359
434
  align="block-end"
360
435
  className="flex w-full flex-row flex-wrap items-center justify-between gap-2 px-3 pb-3 pt-1"
361
436
  >
362
437
  <div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
438
+ <input
439
+ ref={fileInputRef}
440
+ type="file"
441
+ accept="image/*"
442
+ multiple
443
+ className="sr-only"
444
+ onChange={(event) => void addFiles(event.currentTarget.files)}
445
+ />
446
+ <Tooltip>
447
+ <TooltipTrigger asChild>
448
+ <Button
449
+ type="button"
450
+ variant="outline"
451
+ size="icon"
452
+ className="size-7 rounded-full [&_svg]:size-3"
453
+ onClick={() => fileInputRef.current?.click()}
454
+ disabled={
455
+ isLoadingHistory || isPreparingFiles || files.length >= MAX_COMPOSER_IMAGES
456
+ }
457
+ aria-label="Upload images"
458
+ >
459
+ {isPreparingFiles ? (
460
+ <Spinner className="size-3" />
461
+ ) : (
462
+ <ImagePlusIcon className="size-3" />
463
+ )}
464
+ </Button>
465
+ </TooltipTrigger>
466
+ <TooltipContent>Upload images</TooltipContent>
467
+ </Tooltip>
363
468
  {composerLeadingActions}
364
469
  {onExportConversation && (
365
470
  <ExportMenu
366
471
  onExport={(format) => void onExportConversation(format)}
472
+ iconOnly
367
473
  tooltip="Export conversation"
368
474
  disabled={isLoadingHistory}
369
475
  />
@@ -399,7 +505,7 @@ export const ChatComposer = ({
399
505
  )}
400
506
  </span>
401
507
  ))}
402
- {isRunning && onStop && !input.trim() ? (
508
+ {isRunning && onStop && !hasDraft ? (
403
509
  <InputGroupButton
404
510
  type="button"
405
511
  size="icon-sm"
@@ -415,7 +521,7 @@ export const ChatComposer = ({
415
521
  type="submit"
416
522
  size="icon-sm"
417
523
  variant="default"
418
- disabled={!input.trim() || isLoadingHistory}
524
+ disabled={!hasDraft || isLoadingHistory || isPreparingFiles}
419
525
  aria-label={isRunning ? "Queue message" : "Send message"}
420
526
  className="shrink-0 rounded-full"
421
527
  >
@@ -188,12 +188,7 @@ export const ChatTranscript = ({
188
188
  (part.type === "text" || part.type === "reasoning") &&
189
189
  Boolean((part as { text?: string }).text),
190
190
  ) || (lastEvents?.length ?? 0) > 0;
191
- const hasRunningTool = (lastEvents ?? []).some((event) => event.status === "running");
192
- const waitingLabel = !lastAssistantHasContent
193
- ? "Thinking..."
194
- : hasRunningTool
195
- ? "Working..."
196
- : "Composing response...";
191
+ const showWaitingIndicator = isRunning && !lastAssistantHasContent;
197
192
 
198
193
  return (
199
194
  <div className="relative flex flex-1 flex-col overflow-hidden">
@@ -259,10 +254,10 @@ export const ChatTranscript = ({
259
254
  }
260
255
  return <UserBubble key={message.id} message={message} />;
261
256
  })}
262
- {isRunning && (
257
+ {showWaitingIndicator && (
263
258
  <div className="flex h-7 items-center gap-2 px-3 text-xs text-muted-foreground">
264
259
  <Spinner className="size-3" />
265
- <span className="animate-pulse">{waitingLabel}</span>
260
+ <span className="animate-pulse">Thinking...</span>
266
261
  </div>
267
262
  )}
268
263
  {status === "error" && (
@@ -52,6 +52,7 @@ export { ThreadActions, ThreadTabs } from "./thread-tabs.tsx";
52
52
  export type { ThreadActionsProps, ThreadTabsProps } from "./thread-tabs.tsx";
53
53
  export type {
54
54
  ApprovalDecision,
55
+ ChatInputMessage,
55
56
  ChatModelOption,
56
57
  ChatStatus,
57
58
  ChatViewProps,
@@ -264,7 +264,7 @@ export const AssistantMarkdown = ({
264
264
  * (bold) gets the foreground color so KPI names still pop against
265
265
  * the muted body.
266
266
  */
267
- export const ToolMarkdown = ({ children }: { children: string }) => (
267
+ export const ToolMarkdown = ({ children, className }: { children: string; className?: string }) => (
268
268
  <Streamdown
269
269
  components={TOOL_MARKDOWN_COMPONENTS}
270
270
  plugins={SHIKI_PLUGIN}
@@ -272,12 +272,13 @@ export const ToolMarkdown = ({ children }: { children: string }) => (
272
272
  className={cn(
273
273
  "prose prose-sm dark:prose-invert max-w-none break-words",
274
274
  "text-xs leading-snug text-muted-foreground",
275
- "prose-p:my-0.5 prose-p:leading-snug",
275
+ "prose-p:my-0.5 prose-p:leading-snug [&_p]:!leading-snug",
276
276
  "prose-ul:my-0.5 prose-ul:pl-4 prose-ol:my-0.5 prose-ol:pl-4",
277
277
  "prose-li:my-0 prose-li:leading-snug prose-li:marker:text-muted-foreground/60",
278
278
  "prose-headings:my-1 prose-headings:text-xs prose-headings:font-semibold",
279
279
  "prose-strong:text-foreground/90 prose-strong:font-medium",
280
280
  "prose-code:text-xs prose-code:font-medium",
281
+ className,
281
282
  )}
282
283
  >
283
284
  {children}
@@ -2,7 +2,7 @@ import { errorUtils, hash, log } from "@dbx-tools/shared-core";
2
2
  import { isStaleMastraResumeError } from "@dbx-tools/shared-mastra/resume";
3
3
  import type { ReasoningEffort } from "@dbx-tools/shared-model";
4
4
  import { useBrand } from "@dbx-tools/ui/branding/react";
5
- import type { UIMessage } from "ai";
5
+ import type { FileUIPart, UIMessage } from "ai";
6
6
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { useChatApprovals } from "./chat-approvals.ts";
8
8
  import { useChatFeedback } from "./chat-feedback.ts";
@@ -66,10 +66,10 @@ const EMPTY_REASONING_EFFORTS: readonly ReasoningEffort[] = [];
66
66
 
67
67
  const logger = log.logger("ui-mastra/chat");
68
68
 
69
- const makeUserMessage = (text: string): UIMessage => ({
69
+ const makeUserMessage = (text: string, files: FileUIPart[] = []): UIMessage => ({
70
70
  id: hash.id(),
71
71
  role: "user",
72
- parts: [{ type: "text", text }],
72
+ parts: [...files, ...(text ? [{ type: "text" as const, text }] : [])],
73
73
  });
74
74
 
75
75
  /** Project a native Mastra memory thread down to the sidebar's view. */
@@ -610,7 +610,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
610
610
  ...session,
611
611
  queuedSteers: removeSteerFromQueue(session.queuedSteers, head.id),
612
612
  }));
613
- const { message } = appendUserMessage(threadId, head.text);
613
+ const { message } = appendUserMessage(threadId, head.text ?? "", head.files);
614
614
  void runStream(threadId, message, head.requestContext);
615
615
  };
616
616
 
@@ -666,11 +666,17 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
666
666
  // Append a user message to a thread's transcript, update thread activity,
667
667
  // and derive a provisional title for a brand-new thread.
668
668
  const appendUserMessage = useCallback(
669
- (threadId: string, text: string): { message: UIMessage; next: UIMessage[] } => {
669
+ (
670
+ threadId: string,
671
+ text: string,
672
+ files: FileUIPart[] = [],
673
+ ): { message: UIMessage; next: UIMessage[] } => {
670
674
  if (activeThreadId) {
671
675
  noteThreadActivity(activeThreadId);
672
676
  if (getSession(threadId).messages.length === 0) {
673
- const provisional = deriveThreadTitle(text);
677
+ const provisional = deriveThreadTitle(
678
+ text || files.map((file) => file.filename || "Image").join(", "),
679
+ );
674
680
  if (provisional) {
675
681
  setProvisionalTitles((prev) =>
676
682
  prev[activeThreadId] ? prev : { ...prev, [activeThreadId]: provisional },
@@ -679,7 +685,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
679
685
  }
680
686
  }
681
687
  const session = getSession(threadId);
682
- const message = makeUserMessage(text);
688
+ const message = makeUserMessage(text, files);
683
689
  const next = [...session.messages, message];
684
690
  writeMessages(threadId, next);
685
691
  return { message, next };
@@ -694,7 +700,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
694
700
  const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
695
701
  (message) => {
696
702
  const text = message.text ?? "";
697
- if (!text) return;
703
+ const files = message.files ?? [];
704
+ if (!text && files.length === 0) return;
698
705
  const threadId = activeKey;
699
706
  const requestContext = snapshotRequestContext(requestContextRef.current);
700
707
  if (isSessionRunning(getSession(threadId))) {
@@ -703,12 +710,13 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
703
710
  queuedSteers: enqueueSteer(session.queuedSteers, {
704
711
  id: hash.id(),
705
712
  text,
713
+ ...(files.length > 0 ? { files } : {}),
706
714
  ...(requestContext ? { requestContext } : {}),
707
715
  }),
708
716
  }));
709
717
  return;
710
718
  }
711
- const { message: userMessage } = appendUserMessage(threadId, text);
719
+ const { message: userMessage } = appendUserMessage(threadId, text, files);
712
720
  void runStream(threadId, userMessage, requestContext);
713
721
  },
714
722
  [appendUserMessage, runStream, activeKey, getSession, updateSession],
@@ -728,7 +736,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
728
736
  queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
729
737
  }));
730
738
  logger.info("steer:send-now", { threadId });
731
- const { message } = appendUserMessage(threadId, steer.text);
739
+ const { message } = appendUserMessage(threadId, steer.text ?? "", steer.files);
732
740
  void runStream(threadId, message, steer.requestContext);
733
741
  },
734
742
  [activeKey, appendUserMessage, getSession, runStream, updateSession],
@@ -1,16 +1,17 @@
1
- import { stringUtils } from "@dbx-tools/shared-core";
1
+ import { json, stringUtils } from "@dbx-tools/shared-core";
2
2
  import { genieModel } from "@dbx-tools/shared-genie";
3
3
  import {
4
4
  Collapsible,
5
5
  CollapsibleContent,
6
6
  CollapsibleTrigger,
7
+ detectCodeLanguage,
7
8
  JsonBlock,
8
9
  RecordPreview,
9
10
  Spinner,
10
11
  cn,
11
12
  } from "@dbx-tools/ui/react";
12
13
  import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
13
- import { SourceBlock, SqlBlock, ToolMarkdown } from "./markdown.tsx";
14
+ import { SourceBlock, ToolMarkdown } from "./markdown.tsx";
14
15
  import type { ToolEvent, ToolProgress } from "./types.ts";
15
16
 
16
17
  // Consolidated tool-session pill and its Genie progress detail view:
@@ -262,7 +263,7 @@ const isGroupRenderable = (g: MessageGroup): boolean =>
262
263
  * 1. The LLM's sub-question for this Genie call, always
263
264
  * visible, styled as a blockquote so it reads as provenance.
264
265
  * 2. One numbered "Query N" Collapsible per query attachment
265
- * (or just "Query" when there's one). Opens to reveal SQL.
266
+ * (or just "Query" when there's one). Opens directly to the SQL.
266
267
  * 3. Errors that landed under this `message_id`, always
267
268
  * visible (red text - users need to see failures, not click
268
269
  * to find them).
@@ -291,11 +292,9 @@ const MessageGroupBody = ({
291
292
  {queryBuckets.map((bucket, i) => (
292
293
  // Each query bucket is a single Collapsible (default
293
294
  // closed) so the expanded group reads as just the
294
- // question + a short stack of "Query N" rows.
295
- // Click any row to drill into its SQL.
296
- // SQL itself stays a nested Collapsible (default closed)
297
- // for the same reason: code is the heaviest content here,
298
- // and most readers only want a glance.
295
+ // question + a short stack of "Query N" rows. The query
296
+ // opens directly to its SQL instead of adding a redundant
297
+ // second "SQL" pill around the only child.
299
298
  <Collapsible key={bucket.key} className="rounded border border-border/60 bg-background/40">
300
299
  <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">
301
300
  <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
@@ -305,26 +304,20 @@ const MessageGroupBody = ({
305
304
  ) : null}
306
305
  </CollapsibleTrigger>
307
306
  <CollapsibleContent>
308
- <div className="flex flex-col gap-2 px-2 pb-2 text-xs">
309
- {bucket.query && (
310
- <Collapsible className="rounded border border-border/60 bg-background/30">
311
- <CollapsibleTrigger className="group flex w-full items-center gap-1 px-2 py-1 text-left text-muted-foreground hover:text-foreground">
312
- <ChevronDownIcon className="size-3 transition-transform group-data-[state=closed]:-rotate-90" />
313
- <span>SQL</span>
314
- </CollapsibleTrigger>
315
- <CollapsibleContent>
316
- <div className="px-2 pb-2">
317
- <SqlBlock sql={bucket.query.sql} />
318
- </div>
319
- {bucket.query.description && (
320
- <div className="px-2 pb-2">
321
- <ToolMarkdown>{bucket.query.description}</ToolMarkdown>
322
- </div>
323
- )}
324
- </CollapsibleContent>
325
- </Collapsible>
326
- )}
327
- </div>
307
+ {bucket.query && (
308
+ <>
309
+ <SourceBlock
310
+ source={bucket.query.sql}
311
+ language="sql"
312
+ className="max-h-64 border-t border-border/50 px-2 py-1.5 text-foreground"
313
+ />
314
+ {bucket.query.description && (
315
+ <div className="border-t border-border/50 px-2 py-1.5">
316
+ <ToolMarkdown>{bucket.query.description}</ToolMarkdown>
317
+ </div>
318
+ )}
319
+ </>
320
+ )}
328
321
  </CollapsibleContent>
329
322
  </Collapsible>
330
323
  ))}
@@ -396,12 +389,8 @@ export function structuredToolPayload(value: unknown): unknown | undefined {
396
389
  if (typeof value !== "string") return undefined;
397
390
  const trimmed = value.trim();
398
391
  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
399
- try {
400
- const parsed: unknown = JSON.parse(trimmed);
401
- return typeof parsed === "object" && parsed !== null ? parsed : undefined;
402
- } catch {
403
- return undefined;
404
- }
392
+ const parsed = json.parse(trimmed);
393
+ return typeof parsed === "object" && parsed !== null ? parsed : undefined;
405
394
  }
406
395
 
407
396
  const RawToolPayload = ({
@@ -427,7 +416,7 @@ const RawToolPayload = ({
427
416
  ) : (
428
417
  <RecordPreview
429
418
  value={structured}
430
- className="max-h-80 border-t border-border/50 bg-transparent text-foreground"
419
+ className="border-t border-border/50 bg-transparent text-foreground"
431
420
  />
432
421
  )}
433
422
  </CollapsibleContent>
@@ -451,24 +440,7 @@ export function detectToolSourceLanguage(
451
440
  source: string,
452
441
  fallback: ToolInputPresentation["language"],
453
442
  ): ToolInputPresentation["language"] {
454
- const trimmed = source.trim();
455
- if (/^(?:select|with|insert|update|delete|merge|create|alter)\b/i.test(trimmed)) return "sql";
456
- if (/^(?:from\s+\S+\s+import|import\s+\S+|def\s+\w+|class\s+\w+):?/m.test(trimmed)) {
457
- return "python";
458
- }
459
- if (/^(?:const|let|var|interface|type|export|import)\b/m.test(trimmed)) return "typescript";
460
- if (/^(?:#!.*\b(?:ba|z|k)?sh\b|(?:cd|ls|cat|grep|find|bun|npm|pnpm|yarn)\s)/m.test(trimmed)) {
461
- return "bash";
462
- }
463
- if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && trimmed.length > 1) {
464
- try {
465
- JSON.parse(trimmed);
466
- return "json";
467
- } catch {
468
- // Keep the tool-specific fallback for incomplete source.
469
- }
470
- }
471
- return fallback;
443
+ return detectCodeLanguage(source) ?? fallback;
472
444
  }
473
445
 
474
446
  /** Extract a compact code or command preview from a tool request payload. */
@@ -640,15 +612,18 @@ const ToolCallDetails = ({
640
612
  "input" in event ? toolInputPresentation(event.toolName, event.input) : undefined;
641
613
  const outputPresentation =
642
614
  "output" in event ? toolOutputPresentation(event.toolName, event.output) : undefined;
615
+ const isAskGenie = event.toolName.startsWith("ask_genie");
643
616
 
644
617
  return (
645
618
  <div className="flex flex-col gap-2">
646
619
  {showQuestion && question ? (
647
- <p className="break-words px-0.5 text-xs italic text-foreground/80">{question}</p>
620
+ <p className="break-words px-0.5 text-[11px] leading-snug text-muted-foreground">
621
+ {question}
622
+ </p>
648
623
  ) : null}
649
624
  {inputPresentation ? (
650
625
  <ToolInputSource presentation={inputPresentation} />
651
- ) : "input" in event ? (
626
+ ) : "input" in event && !isAskGenie ? (
652
627
  <RawToolPayload label="Request" value={event.input} />
653
628
  ) : null}
654
629
  <WebSearchProgressDetails event={event} />
@@ -699,7 +674,9 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
699
674
  {verb}
700
675
  </span>
701
676
  {question && (
702
- <span className="mt-0.5 block break-words italic text-foreground/80">{question}</span>
677
+ <span className="mt-0.5 block break-words text-[11px] leading-snug text-foreground/75">
678
+ {question}
679
+ </span>
703
680
  )}
704
681
  </span>
705
682
  );
@@ -1,6 +1,6 @@
1
1
  import type { ToolProgressEvent } from "@dbx-tools/shared-mastra/wire";
2
2
  import type { ReasoningEffort, ServingEndpointSummary } from "@dbx-tools/shared-model";
3
- import type { UIMessage } from "ai";
3
+ import type { FileUIPart, UIMessage } from "ai";
4
4
  import type { ReactNode } from "react";
5
5
  import type { ExportFormat } from "../support/export.ts";
6
6
 
@@ -52,12 +52,14 @@ export type ChatModelOption = Pick<
52
52
  >;
53
53
 
54
54
  /**
55
- * A steer message submitted while a turn was already streaming. It waits in a
56
- * per-thread queue until the running turn ends (auto-sent oldest-first) or the
57
- * user fires it early with "send now". `id` is a stable key for rendering /
58
- * removal.
55
+ * A text and/or image message submitted while a turn was already streaming.
56
+ * It waits in a per-thread queue until the running turn ends (auto-sent
57
+ * oldest-first) or the user fires it early with "send now". `id` is a stable
58
+ * key for rendering / removal.
59
59
  */
60
- export type QueuedSteer = { id: string; text: string };
60
+ export type ChatInputMessage = { text?: string; files?: FileUIPart[] };
61
+
62
+ export type QueuedSteer = ChatInputMessage & { id: string };
61
63
 
62
64
  /** Thumbs reaction a user can leave on an assistant turn. */
63
65
  export type FeedbackValue = "up" | "down";
@@ -128,7 +130,7 @@ export type ChatViewProps = {
128
130
  * ENQUEUES the message as a steer (it waits, no interrupt); the queue drains
129
131
  * oldest-first when the turn ends. An idle submit starts a turn immediately.
130
132
  */
131
- sendMessage: (message: { text: string }) => void;
133
+ sendMessage: (message: ChatInputMessage) => void;
132
134
  /**
133
135
  * Steers submitted mid-turn that are waiting to run (oldest first). When
134
136
  * non-empty the composer shows them as chips above the input, each with a
@@ -202,7 +204,7 @@ export type ChatViewProps = {
202
204
  modelSelectorActions?: ReactNode;
203
205
  /**
204
206
  * Host-owned actions rendered on the left side of the composer's fixed
205
- * footer, before Export and Clear.
207
+ * footer, after Upload and before Export and Clear.
206
208
  */
207
209
  composerLeadingActions?: ReactNode;
208
210
  /**
@@ -0,0 +1,26 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as chartOption from "./chart-option.ts";
6
+ export * as chartTheme from "./chart-theme.ts";
7
+ export * as clipboard from "./clipboard.ts";
8
+ export * as download from "./download.ts";
9
+ export * as exportModule from "./export.ts";
10
+ export * as mastraClient from "./mastra-client.ts";
11
+ export * as modelSelection from "./model-selection.ts";
12
+ export * as requestContext from "./request-context.ts";
13
+ export * as threadLabels from "./thread-labels.ts";
14
+ export * as threadSessions from "./thread-sessions.ts";
15
+ export * as threadTabs from "./thread-tabs.ts";
16
+ export * as toolEvents from "./tool-events.ts";
17
+ export { LIGHT_CHART_CHROME } from "./chart-theme.ts";
18
+ export type { ChartChrome } from "./chart-theme.ts";
19
+ export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./export.ts";
20
+ export { MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./mastra-client.ts";
21
+ export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from "./mastra-client.ts";
22
+ export { defaultReasoningEffort, modelStorageKey, readStoredModel, storeSelectedModel } from "./model-selection.ts";
23
+ export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./request-context.ts";
24
+ export { DEFAULT_THREAD_SESSION_KEY } from "./thread-sessions.ts";
25
+ export type { SessionQueuedSteer, SessionRun, ThreadSession } from "./thread-sessions.ts";
26
+ export { THREAD_TAB_SEED_MAX } from "./thread-tabs.ts";
@@ -26,10 +26,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
26
26
  import type { MastraRequestContextSnapshot } from "./request-context.ts";
27
27
 
28
28
  type NativeAgentClient = ReturnType<MastraClient["getAgent"]>;
29
+
30
+ /** Browser stream and response headers returned by a Mastra chat request. */
29
31
  export interface MastraStreamResponse {
30
32
  headers: Headers;
31
33
  stream: ReadableStream<UIMessageChunk>;
32
34
  }
35
+
36
+ /** One memory thread returned by the native Mastra client. */
33
37
  export type MastraMemoryThread = ListMemoryThreadsResponse["threads"][number];
34
38
 
35
39
  /**