@dbx-tools/ui-mastra 0.9.129 → 0.9.131

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,9 +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
+ - Reload recovery for agent-backed background turns: active run metadata is
26
+ persisted per agent and thread, the cached stream reconnects after a page
27
+ reload, and the final persisted history is refreshed when recovery completes.
28
28
  - Headless `useMastraChat()` driver for apps that want the same transport logic
29
29
  with custom layout.
30
30
  - Persistent `MastraAssistant` application shell with a floating launcher,
@@ -172,6 +172,7 @@ precomputed `uiMessages` projection, history hydration uses Mastra's native
172
172
  Useful options:
173
173
 
174
174
  - `agentId` selects a registered agent; defaults to the plugin default agent.
175
+ - `genieSpace` selects a configured Genie alias and bypasses the agent.
175
176
  - `showModelPicker` fetches `/models` and sends `X-Mastra-Model` overrides.
176
177
  - `composerActions` places host-owned per-turn controls beside the model
177
178
  selector without replacing the built-in composer.
@@ -187,12 +188,43 @@ Useful options:
187
188
  off. See [Place The Conversation List](#place-the-conversation-list).
188
189
  - `enableThreads: false` is the older shorthand for
189
190
  `threadPlacement: "disabled"`.
191
+ - `threadId` and `onThreadIdChange` let a host bind the active conversation to
192
+ URL state or another external selector. Unknown external ids are replaced by
193
+ a new thread and reported with the `missing` reason.
190
194
  - `enableExport` adds whole-conversation and per-message export affordances.
191
195
  - Feedback defaults to auto: thumbs, dislike, and comment controls appear when
192
196
  the server reports MLflow feedback is available and a turn produced a trace
193
197
  id. Set `enableFeedback={false}` to hide them. Viewers with `CAN_MANAGE` on
194
198
  the experiment also get an experiment debug link at the end of the action row.
195
199
 
200
+ ## Use Direct Genie Chat
201
+
202
+ Use `genieSpace` when the application needs the same chat shell without a
203
+ supervisor agent:
204
+
205
+ ```tsx
206
+ <MastraChat genieSpace="sales" threadPlacement="auto" enableExport />
207
+ ```
208
+
209
+ The alias comes from the server plugin's `genieSpaces`. The client still uses
210
+ AI SDK `UIMessage` streams and the same `ChatView`; Genie conversations replace
211
+ Mastra threads and Genie messages replace Mastra Memory history.
212
+
213
+ Turns and history require the Genie Agent API and never fall back to classic
214
+ Genie message polling. The sidebar filters the GA Genie conversation listing to
215
+ Agent Mode conversations. Genie decides whether a query should be visualized;
216
+ visualization attachments render through AppKit UI's native
217
+ `GenieQueryVisualization`, with Chart and Table tabs. This does not invoke a
218
+ supervisor or the Mastra Model Serving chart planner. Queries without a
219
+ visualization attachment render through the existing sortable data table.
220
+
221
+ Direct mode requires the signed-in user's Databricks OBO context. It supports
222
+ new/select/delete conversations, history, suggestions, thoughts, exports, and
223
+ stream cancellation. It does not support conversation rename, file
224
+ attachments, MLflow feedback, approvals, background turns, or stream
225
+ reconnection. The newest 100 conversations are listed and each selected
226
+ conversation loads as one history page.
227
+
196
228
  ## Place The Conversation List
197
229
 
198
230
  Conversation management is on by default. Where it lives is one option:
@@ -213,6 +245,9 @@ show/hide toggle. Below 768px of chat width the panel becomes an overlay drawer
213
245
  on the same edge, so the transcript never loses room; the drawer is
214
246
  session-scoped and starts closed, and closes itself after a selection.
215
247
 
248
+ The composer accepts images from the upload action, drag and drop, or clipboard
249
+ paste. Oversized images use the same client-side resize path before submission.
250
+
216
251
  The `top` placement is the editor-tab model: each open conversation is a tab with
217
252
  its title, a spinner while it streams in the background, and a close affordance
218
253
  that only takes the tab off the strip (the conversation stays in history). A `+`
@@ -223,10 +258,11 @@ starts a fresh conversation when it was the last tab open. The open set is
223
258
  session state; the strip reseeds from the most recent conversations on the next
224
259
  load.
225
260
 
226
- Selecting a conversation fetches only the newest 20 native memory messages.
227
- Scrolling upward requests the next page and prepends it without moving the
228
- reader's viewport. Agent turns still send only the new user message; Mastra
229
- loads the remembered transcript server-side.
261
+ For agent-backed chat, selecting a conversation fetches only the newest 20
262
+ native memory messages. Scrolling upward requests the next page and prepends it
263
+ without moving the reader's viewport. Agent turns still send only the new user
264
+ message; Mastra loads the remembered transcript server-side. Direct Genie mode
265
+ loads the selected Genie conversation in one page.
230
266
 
231
267
  `auto` measures the chat's own element rather than the viewport, so a chat
232
268
  embedded in a split view or side panel switches to tabs on the space it actually
@@ -363,6 +399,8 @@ The UI understands the extra events produced by
363
399
  - `[chart:<id>]` markers long-poll the chart cache and render ECharts inline.
364
400
  - `[data:<id>]` markers fetch statement rows and render a sortable table with
365
401
  column toggles and CSV export.
402
+ - `[visualization:<id>]` markers fetch the same statement under OBO identity and
403
+ render AppKit UI's native Genie Chart/Table component.
366
404
  - MLflow trace headers enable per-message feedback controls when the server
367
405
  reports feedback is available.
368
406
 
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.129";
6
+ export const PACKAGE_VERSION = "0.9.131";
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";
@@ -33,10 +33,12 @@ export * as supportChartTheme from "./src/support/chart-theme.ts";
33
33
  export * as supportClipboard from "./src/support/clipboard.ts";
34
34
  export * as supportDownload from "./src/support/download.ts";
35
35
  export * as supportExport from "./src/support/export.ts";
36
+ export * as supportGenieClient from "./src/support/genie-client.ts";
36
37
  export * as supportMastraClient from "./src/support/mastra-client.ts";
37
38
  export * as supportModelSelection from "./src/support/model-selection.ts";
38
39
  export * as supportRequestContext from "./src/support/request-context.ts";
39
40
  export * as supportThreadLabels from "./src/support/thread-labels.ts";
41
+ export * as supportThreadPersistence from "./src/support/thread-persistence.ts";
40
42
  export * as supportThreadSessions from "./src/support/thread-sessions.ts";
41
43
  export * as supportThreadTabs from "./src/support/thread-tabs.ts";
42
44
  export * as supportToolEvents from "./src/support/tool-events.ts";
@@ -55,7 +57,7 @@ export { FeedbackControls } from "./src/react/feedback-controls.tsx";
55
57
  export { AssistantMarkdown, ToolMarkdown, SqlBlock, SourceBlock } from "./src/react/markdown.tsx";
56
58
  export type { MastraAssistantMode, MastraAssistantSide, MastraAssistantLauncherPosition, MastraAssistantResizeOptions, MastraAssistantLauncherOptions, MastraAssistantController, MastraAssistantProps } from "./src/react/mastra-assistant.tsx";
57
59
  export { useMastraChat, MastraChat } from "./src/react/mastra-chat.tsx";
58
- export type { UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-chat.tsx";
60
+ export type { ThreadIdChangeReason, UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-chat.tsx";
59
61
  export { SuggestionPills } from "./src/react/suggestion-pills.tsx";
60
62
  export type { SuggestionPillsProps } from "./src/react/suggestion-pills.tsx";
61
63
  export { dedupeSuggestions, collectSuggestions } from "./src/react/suggestions.ts";
@@ -71,7 +73,8 @@ export type { StoredActiveRun } from "./src/support/active-runs.ts";
71
73
  export { LIGHT_CHART_CHROME } from "./src/support/chart-theme.ts";
72
74
  export type { ChartChrome } from "./src/support/chart-theme.ts";
73
75
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export.ts";
74
- export { MastraChatRequestError, MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./src/support/mastra-client.ts";
76
+ export { GenieChatClient } from "./src/support/genie-client.ts";
77
+ export { MastraChatRequestError, MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch, useVisualizationFetch } from "./src/support/mastra-client.ts";
75
78
  export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from "./src/support/mastra-client.ts";
76
79
  export { defaultReasoningEffort, modelStorageKey, readStoredModel, storeSelectedModel } from "./src/support/model-selection.ts";
77
80
  export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./src/support/request-context.ts";
package/package.json CHANGED
@@ -29,11 +29,12 @@
29
29
  "react-dom": "~19.2.4"
30
30
  },
31
31
  "dependencies": {
32
- "@dbx-tools/shared-core": "^0.9.129",
33
- "@dbx-tools/shared-genie": "^0.9.129",
34
- "@dbx-tools/shared-mastra": "^0.9.129",
35
- "@dbx-tools/shared-model": "^0.9.129",
36
- "@dbx-tools/ui": "^0.9.129",
32
+ "@databricks/appkit-ui": "0.81.0",
33
+ "@dbx-tools/shared-core": "^0.9.131",
34
+ "@dbx-tools/shared-genie": "^0.9.131",
35
+ "@dbx-tools/shared-mastra": "^0.9.131",
36
+ "@dbx-tools/shared-model": "^0.9.131",
37
+ "@dbx-tools/ui": "^0.9.131",
37
38
  "@mastra/client-js": "1.50.0",
38
39
  "@mastra/core": "1.71.0",
39
40
  "@tanstack/react-table": "^8.21.3",
@@ -50,7 +51,7 @@
50
51
  "publishConfig": {
51
52
  "access": "public"
52
53
  },
53
- "version": "0.9.129",
54
+ "version": "0.9.131",
54
55
  "dbxToolsConfig": {
55
56
  "tags": [
56
57
  "ui"
@@ -18,6 +18,7 @@ import type { UIMessage } from "ai";
18
18
  import {
19
19
  BugIcon,
20
20
  CheckIcon,
21
+ ChevronDownIcon,
21
22
  CopyIcon,
22
23
  MessageSquareIcon,
23
24
  RefreshCcwIcon,
@@ -63,7 +64,8 @@ const UserAvatar = () => (
63
64
 
64
65
  const ThoughtsDisclosure = ({ text }: { text: string }) => (
65
66
  <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
+ <CollapsibleTrigger className="group flex w-fit cursor-pointer items-center gap-1.5 px-0 py-0.5 text-left text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
68
+ <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
67
69
  Thoughts
68
70
  </CollapsibleTrigger>
69
71
  <CollapsibleContent>
@@ -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";