@dbx-tools/ui-mastra 0.9.129 → 0.9.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -256,6 +256,7 @@ type ChatComposerProps = {
256
256
  composerActions: ChatViewProps["composerActions"];
257
257
  modelSelectorActions: ChatViewProps["modelSelectorActions"];
258
258
  composerLeadingActions: ChatViewProps["composerLeadingActions"];
259
+ allowAttachments: NonNullable<ChatViewProps["allowAttachments"]>;
259
260
  isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
260
261
  onClear: ChatViewProps["onClear"];
261
262
  onExportConversation: ChatViewProps["onExportConversation"];
@@ -284,6 +285,7 @@ export const ChatComposer = ({
284
285
  composerActions,
285
286
  modelSelectorActions,
286
287
  composerLeadingActions,
288
+ allowAttachments,
287
289
  isLoadingHistory,
288
290
  onClear,
289
291
  onExportConversation,
@@ -293,7 +295,9 @@ export const ChatComposer = ({
293
295
  const [files, setFiles] = useState<FileUIPart[]>([]);
294
296
  const [fileError, setFileError] = useState<string | null>(null);
295
297
  const [isPreparingFiles, setIsPreparingFiles] = useState(false);
298
+ const [isDraggingFiles, setIsDraggingFiles] = useState(false);
296
299
  const inputId = useId();
300
+ const dragDepthRef = useRef(0);
297
301
  const fileInputRef = useRef<HTMLInputElement>(null);
298
302
  const inputGroupRef = useRef<HTMLDivElement>(null);
299
303
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -324,8 +328,14 @@ export const ChatComposer = ({
324
328
  onResumeFollow();
325
329
  };
326
330
 
327
- const addFiles = async (selected: FileList | null) => {
331
+ const addFiles = async (selected: FileList | readonly File[] | null) => {
328
332
  if (!selected?.length) return;
333
+ const selectedFiles = Array.from(selected);
334
+ const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/"));
335
+ if (imageFiles.length === 0) {
336
+ setFileError("Only image files can be attached");
337
+ return;
338
+ }
329
339
  const remaining = MAX_COMPOSER_IMAGES - files.length;
330
340
  if (remaining <= 0) {
331
341
  setFileError(`You can attach up to ${MAX_COMPOSER_IMAGES} images`);
@@ -335,12 +345,12 @@ export const ChatComposer = ({
335
345
  setFileError(null);
336
346
  try {
337
347
  const next = await Promise.all(
338
- Array.from(selected)
339
- .slice(0, remaining)
340
- .map((file) => imageFileToUIPart(file)),
348
+ imageFiles.slice(0, remaining).map((file) => imageFileToUIPart(file)),
341
349
  );
342
350
  setFiles((current) => [...current, ...next]);
343
- if (selected.length > remaining) {
351
+ if (selectedFiles.length !== imageFiles.length) {
352
+ setFileError("Only image files were added");
353
+ } else if (imageFiles.length > remaining) {
344
354
  setFileError(`Only the first ${remaining} images were added`);
345
355
  }
346
356
  } catch (error) {
@@ -376,9 +386,39 @@ export const ChatComposer = ({
376
386
  />
377
387
  <InputGroup
378
388
  ref={inputGroupRef}
379
- className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md"
389
+ className={cn(
390
+ "rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md",
391
+ isDraggingFiles && "border-primary ring-2 ring-primary/20",
392
+ )}
393
+ onDragEnter={(event) => {
394
+ if (!event.dataTransfer.types.includes("Files")) return;
395
+ event.preventDefault();
396
+ if (!allowAttachments) return;
397
+ dragDepthRef.current += 1;
398
+ setIsDraggingFiles(true);
399
+ }}
400
+ onDragOver={(event) => {
401
+ if (!event.dataTransfer.types.includes("Files")) return;
402
+ event.preventDefault();
403
+ if (!allowAttachments) {
404
+ event.dataTransfer.dropEffect = "none";
405
+ return;
406
+ }
407
+ event.dataTransfer.dropEffect = "copy";
408
+ }}
409
+ onDragLeave={(event) => {
410
+ if (!event.dataTransfer.types.includes("Files")) return;
411
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
412
+ if (dragDepthRef.current === 0) setIsDraggingFiles(false);
413
+ }}
414
+ onDrop={(event) => {
415
+ event.preventDefault();
416
+ dragDepthRef.current = 0;
417
+ setIsDraggingFiles(false);
418
+ if (allowAttachments) void addFiles(event.dataTransfer.files);
419
+ }}
380
420
  >
381
- {files.length > 0 && (
421
+ {allowAttachments && files.length > 0 && (
382
422
  <InputGroupAddon
383
423
  align="block-start"
384
424
  className="max-w-full justify-start gap-2 overflow-x-auto pb-0"
@@ -415,6 +455,15 @@ export const ChatComposer = ({
415
455
  ref={textareaRef}
416
456
  value={input}
417
457
  onChange={(event) => setInput(event.target.value)}
458
+ onPaste={(event) => {
459
+ if (!allowAttachments) return;
460
+ const pastedImages = Array.from(event.clipboardData.files).filter((file) =>
461
+ file.type.startsWith("image/"),
462
+ );
463
+ if (pastedImages.length === 0) return;
464
+ event.preventDefault();
465
+ void addFiles(pastedImages);
466
+ }}
418
467
  onKeyDown={(event) => {
419
468
  if (event.key === "Enter" && !event.shiftKey) {
420
469
  event.preventDefault();
@@ -425,7 +474,7 @@ export const ChatComposer = ({
425
474
  rows={1}
426
475
  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
476
  />
428
- {fileError && (
477
+ {allowAttachments && fileError && (
429
478
  <div className="px-4 text-xs text-destructive" role="alert">
430
479
  {fileError}
431
480
  </div>
@@ -435,36 +484,42 @@ export const ChatComposer = ({
435
484
  className="flex w-full flex-row flex-wrap items-center justify-between gap-2 px-3 pb-3 pt-1"
436
485
  >
437
486
  <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>
487
+ {allowAttachments && (
488
+ <>
489
+ <input
490
+ ref={fileInputRef}
491
+ type="file"
492
+ accept="image/*"
493
+ multiple
494
+ className="sr-only"
495
+ onChange={(event) => void addFiles(event.currentTarget.files)}
496
+ />
497
+ <Tooltip>
498
+ <TooltipTrigger asChild>
499
+ <Button
500
+ type="button"
501
+ variant="outline"
502
+ size="icon"
503
+ className="size-7 rounded-full [&_svg]:size-3"
504
+ onClick={() => fileInputRef.current?.click()}
505
+ disabled={
506
+ isLoadingHistory ||
507
+ isPreparingFiles ||
508
+ files.length >= MAX_COMPOSER_IMAGES
509
+ }
510
+ aria-label="Upload images"
511
+ >
512
+ {isPreparingFiles ? (
513
+ <Spinner className="size-3" />
514
+ ) : (
515
+ <ImagePlusIcon className="size-3" />
516
+ )}
517
+ </Button>
518
+ </TooltipTrigger>
519
+ <TooltipContent>Upload images</TooltipContent>
520
+ </Tooltip>
521
+ </>
522
+ )}
468
523
  {composerLeadingActions}
469
524
  {onExportConversation && (
470
525
  <ExportMenu
@@ -55,6 +55,14 @@ export function useChatSessions(activeThreadId: string | undefined) {
55
55
  sessionsRef.current.delete(threadId);
56
56
  setRevision((current) => current + 1);
57
57
  }, []);
58
+ /** Re-key one session, keeping its transcript and run state intact. */
59
+ const moveSession = useCallback((fromThreadId: string, toThreadId: string) => {
60
+ const session = sessionsRef.current.get(fromThreadId);
61
+ if (!session || fromThreadId === toThreadId) return;
62
+ sessionsRef.current.delete(fromThreadId);
63
+ sessionsRef.current.set(toThreadId, session);
64
+ setRevision((current) => current + 1);
65
+ }, []);
58
66
  const activeKey = sessionKey(activeThreadId);
59
67
  const activeSession = useMemo(() => getSession(activeKey), [activeKey, getSession, revision]);
60
68
  const streamingThreadIds = useMemo(() => {
@@ -70,6 +78,7 @@ export function useChatSessions(activeThreadId: string | undefined) {
70
78
  activeKey,
71
79
  activeSession,
72
80
  getSession,
81
+ moveSession,
73
82
  removeSession,
74
83
  resetSession,
75
84
  streamingThreadIds,
@@ -59,6 +59,18 @@ export function closeOnTerminalChunk(
59
59
  });
60
60
  }
61
61
 
62
+ /** Drain a run observation until its terminal finish or abort chunk arrives. */
63
+ export async function waitForTerminalChunk(stream: ReadableStream<UIMessageChunk>): Promise<void> {
64
+ const reader = closeOnTerminalChunk(stream).getReader();
65
+ try {
66
+ while (!(await reader.read()).done) {
67
+ // Draining is the synchronization point; the replacement turn does not replay this output.
68
+ }
69
+ } finally {
70
+ reader.releaseLock();
71
+ }
72
+ }
73
+
62
74
  /** Replace or append one assistant message without disturbing the transcript. */
63
75
  function upsertAssistant(messages: UIMessage[], assistant: UIMessage): UIMessage[] {
64
76
  const next = [...messages];
@@ -35,6 +35,7 @@ export const ChatView = ({
35
35
  composerActions,
36
36
  modelSelectorActions,
37
37
  composerLeadingActions,
38
+ allowAttachments = true,
38
39
  onLoadMore,
39
40
  isLoadingMore = false,
40
41
  hasMore = false,
@@ -136,6 +137,7 @@ export const ChatView = ({
136
137
  composerActions={composerActions}
137
138
  modelSelectorActions={modelSelectorActions}
138
139
  composerLeadingActions={composerLeadingActions}
140
+ allowAttachments={allowAttachments}
139
141
  isLoadingHistory={isLoadingHistory}
140
142
  onClear={onClear}
141
143
  onExportConversation={onExportConversation}
@@ -1,3 +1,4 @@
1
+ import { GenieQueryVisualization } from "@databricks/appkit-ui/react";
1
2
  import { markers, type ParsedMarker } from "@dbx-tools/shared-mastra";
2
3
  import { Spinner } from "@dbx-tools/ui/react";
3
4
  import { ClockIcon } from "lucide-react";
@@ -6,13 +7,16 @@ import { DataGrid, humanizeLabel } from "./data-grid.tsx";
6
7
  import { AssistantMarkdown } from "./markdown.tsx";
7
8
  import { normalizeChartOption } from "../support/chart-option.ts";
8
9
  import { useChartChrome } from "../support/chart-theme.ts";
9
- import { useChartFetch, useStatementFetch } from "../support/mastra-client.ts";
10
+ import {
11
+ useChartFetch,
12
+ useStatementFetch,
13
+ useVisualizationFetch,
14
+ } from "../support/mastra-client.ts";
10
15
 
11
16
  const ReactECharts = lazy(() => import("echarts-for-react"));
12
17
 
13
- // Inline embed slots: chart / data tables resolved from `[chart:<id>]`
14
- // and `[data:<id>]` markers in the assistant's prose, plus the splitter
15
- // that interleaves those slots with the surrounding markdown.
18
+ // Inline embed slots resolved from markers in assistant prose, plus the
19
+ // splitter that interleaves those slots with surrounding markdown.
16
20
 
17
21
  /**
18
22
  * Frame shared by every chart-slot state so the layout stays
@@ -170,11 +174,27 @@ const DataSlot = ({ statementId }: { statementId: string }) => {
170
174
  );
171
175
  };
172
176
 
173
- /** One slice of an assistant message: prose, a chart slot, or a data slot. */
177
+ /**
178
+ * Native AppKit Genie visualization for a query Genie chose to visualize.
179
+ * The same statement payload backs its Chart and Table tabs.
180
+ */
181
+ const GenieVisualizationSlot = ({ statementId }: { statementId: string }) => {
182
+ const { data, loading, error } = useVisualizationFetch(statementId);
183
+ if (!loading && !error && data === undefined) return <ExpiredSlot type="visualization" />;
184
+ if (!data) return null;
185
+ return (
186
+ <div className="not-prose my-3 max-w-full rounded border border-border bg-card p-3">
187
+ <GenieQueryVisualization data={data.visualization} />
188
+ </div>
189
+ );
190
+ };
191
+
192
+ /** One slice of an assistant message. */
174
193
  type RenderSegment =
175
194
  | { kind: "text"; text: string }
176
195
  | { kind: "chart"; chartId: string }
177
- | { kind: "data"; statementId: string };
196
+ | { kind: "data"; statementId: string }
197
+ | { kind: "visualization"; statementId: string };
178
198
 
179
199
  /**
180
200
  * Map one parsed marker onto its render segment. The marker grammar
@@ -185,8 +205,7 @@ type RenderSegment =
185
205
  * collapses to an empty text segment: the marker is consumed so no
186
206
  * literal `[<type>:...]` leaks into the prose, but no slot renders and
187
207
  * no `/embed/<type>/:id` request fires. Only a UUID-shaped id with a
188
- * known, renderable type (charts need ECharts, data needs a Table)
189
- * resolves to a slot.
208
+ * known, renderable type resolves to a slot.
190
209
  */
191
210
  const markerSegment = (marker: ParsedMarker): RenderSegment => {
192
211
  if (!markers.isUuid(marker.id)) return { kind: "text", text: "" };
@@ -195,6 +214,8 @@ const markerSegment = (marker: ParsedMarker): RenderSegment => {
195
214
  return { kind: "chart", chartId: marker.id };
196
215
  case "data":
197
216
  return { kind: "data", statementId: marker.id };
217
+ case "visualization":
218
+ return { kind: "visualization", statementId: marker.id };
198
219
  default:
199
220
  return { kind: "text", text: "" };
200
221
  }
@@ -220,8 +241,7 @@ const splitTextWithEmbeds = (text: string): RenderSegment[] => {
220
241
  };
221
242
 
222
243
  /**
223
- * Render the assistant's markdown with chart and data tables
224
- * placed at their inline marker positions. Each prose segment
244
+ * Render the assistant's markdown with embeds at their marker positions. Each prose segment
225
245
  * is its own {@link AssistantMarkdown} so streaming chunks still
226
246
  * incrementally parse correctly; embed slots break the markdown
227
247
  * flow with full-width block elements.
@@ -234,6 +254,8 @@ const splitTextWithEmbeds = (text: string): RenderSegment[] => {
234
254
  * - `[data:<statement_id>]` -> {@link DataSlot} fetches the
235
255
  * statement rows OBO-scoped and renders an inline Table
236
256
  * (or nothing on 404 / empty result).
257
+ * - `[visualization:<statement_id>]` -> {@link GenieVisualizationSlot}
258
+ * renders AppKit UI's native Genie Chart/Table view.
237
259
  *
238
260
  * Stale markers in persisted transcript text are silently
239
261
  * dropped on reload so the prose around them stays clean.
@@ -265,6 +287,14 @@ export const MarkdownWithEmbeds = ({
265
287
  if (seg.kind === "chart") {
266
288
  return <ChartSlot key={`c-${i}-${seg.chartId}`} chartId={seg.chartId} />;
267
289
  }
290
+ if (seg.kind === "visualization") {
291
+ return (
292
+ <GenieVisualizationSlot
293
+ key={`v-${i}-${seg.statementId}`}
294
+ statementId={seg.statementId}
295
+ />
296
+ );
297
+ }
268
298
  return <DataSlot key={`d-${i}-${seg.statementId}`} statementId={seg.statementId} />;
269
299
  })}
270
300
  </>
@@ -45,7 +45,11 @@ export type {
45
45
  } from "../support/request-context.ts";
46
46
  export { dedupeSuggestions } from "./suggestions.ts";
47
47
  export { MastraChat, useMastraChat } from "./mastra-chat.tsx";
48
- export type { MastraChatProps, UseMastraChatOptions } from "./mastra-chat.tsx";
48
+ export type {
49
+ MastraChatProps,
50
+ ThreadIdChangeReason,
51
+ UseMastraChatOptions,
52
+ } from "./mastra-chat.tsx";
49
53
  export { ThreadSidebar } from "./thread-sidebar.tsx";
50
54
  export type { ThreadSidebarProps } from "./thread-sidebar.tsx";
51
55
  export { ThreadActions, ThreadTabs } from "./thread-tabs.tsx";