@dbx-tools/ui-mastra 0.3.3 → 0.3.5

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
@@ -25,7 +25,7 @@ Key features:
25
25
  the server plugin.
26
26
  - Conversation sidebar with new, select, rename, delete, active-thread, and
27
27
  background-streaming states.
28
- - Export menu for print/PDF and Markdown, resolving charts and tables so
28
+ - Export menu for PDF and Markdown, resolving charts and tables so
29
29
  exported conversations remain useful offline.
30
30
 
31
31
  ## Why Not Just AppKit UI?
@@ -44,7 +44,7 @@ understand Mastra-specific behavior:
44
44
  answer.
45
45
  - `[chart:<id>]` and `[data:<id>]` assistant markers rendered as ECharts charts
46
46
  and sortable tables.
47
- - Conversation export that resolves those embeds into Markdown or print/PDF.
47
+ - Conversation export that resolves those embeds into Markdown or PDF.
48
48
 
49
49
  ## Add The Styles
50
50
 
@@ -133,6 +133,7 @@ transport or needs to combine Mastra messages with another state model.
133
133
  import {
134
134
  MastraPluginClient,
135
135
  useMastraClient,
136
+ useMastraDefaultModel,
136
137
  useMastraModels,
137
138
  useMastraSuggestions,
138
139
  useMastraThreads,
@@ -172,7 +173,7 @@ The UI understands the extra events produced by
172
173
  <MastraChat enableExport />
173
174
  ```
174
175
 
175
- Exports support Markdown downloads and print/PDF. Chart and data markers are
176
+ Exports support Markdown downloads and PDF (rendered through a hidden print iframe, so the browser's Save-as-PDF dialog opens with no popup tab). Chart and data markers are
176
177
  resolved during export: charts are rendered to inline SVG with ECharts' server
177
178
  renderer, and data markers become real tables. Expired or missing embeds are
178
179
  skipped so old transcripts still export cleanly.
@@ -184,8 +185,9 @@ skipped so old transcripts still export cleanly.
184
185
  - `ChatView` - controlled presentational chat shell.
185
186
  - `MastraPluginClient` - `@mastra/client-js` plus AppKit-Mastra custom routes.
186
187
  - `useMastraClient`, `useMastraConfig`, `useMastraModels`,
187
- `useMastraSuggestions`, `useMastraThreads`, `useChartFetch`,
188
- `useStatementFetch` - route/config hooks for controlled clients.
188
+ `useMastraDefaultModel`, `useMastraSuggestions`, `useMastraThreads`,
189
+ `useChartFetch`, `useStatementFetch` - route/config hooks for controlled
190
+ clients.
189
191
  - `ThreadSidebar` - controlled conversation list.
190
192
  - `ExportMenu` - shared export format menu.
191
193
  - Types - `ChatViewProps`, `MastraChatProps`, `UseMastraChatOptions`,
package/package.json CHANGED
@@ -26,19 +26,19 @@
26
26
  "shiki": "^3.0.0",
27
27
  "sql-formatter": "^15.6.9",
28
28
  "streamdown": "^2.5.0",
29
- "@dbx-tools/shared-genie": "0.3.3",
30
- "@dbx-tools/shared-core": "0.3.3",
31
- "@dbx-tools/shared-mastra": "0.3.3",
32
- "@dbx-tools/shared-model": "0.3.3",
33
- "@dbx-tools/ui-appkit": "0.3.3",
34
- "@dbx-tools/ui-branding": "0.3.3"
29
+ "@dbx-tools/shared-core": "0.3.5",
30
+ "@dbx-tools/shared-genie": "0.3.5",
31
+ "@dbx-tools/shared-mastra": "0.3.5",
32
+ "@dbx-tools/ui-appkit": "0.3.5",
33
+ "@dbx-tools/ui-branding": "0.3.5",
34
+ "@dbx-tools/shared-model": "0.3.5"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.3",
41
+ "version": "0.3.5",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -1,6 +1,14 @@
1
1
  import {
2
2
  Alert,
3
3
  AlertDescription,
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
4
12
  AlertTitle,
5
13
  Button,
6
14
  Empty,
@@ -286,18 +294,20 @@ export const ChatView = ({
286
294
  // otherwise as static text.
287
295
  const showModelDisplay = Boolean(onModelChange);
288
296
  const modelChangeable = Boolean(models && models.length > 0);
289
- // Human-readable name for an endpoint id, using the catalogue's
290
- // `displayName` when present, else the raw id.
297
+ // Human-readable name for a pinned endpoint id, using the catalogue's
298
+ // `displayName`. Returns undefined until the catalogue has an entry for it,
299
+ // so we never fall back to the raw id (no raw-name flash on load).
291
300
  const modelLabel = (name?: string): string | undefined => {
292
301
  if (!name) return undefined;
293
- return models?.find((m) => m.name === name)?.displayName || name;
302
+ return models?.find((m) => m.name === name)?.displayName;
294
303
  };
295
- // The default option shows the server's fallback model by name when known
296
- // (from `defaultModelName`), else a neutral "Default" - no "server default"
297
- // phrasing either way.
298
- const defaultOptionLabel = modelLabel(defaultModelName) ?? "Default";
299
- // Label the current model by its human-readable name when a model is
300
- // pinned, else the default-option label.
304
+ // The default option shows the server's fallback model by its already-
305
+ // humanized name (`defaultModelName` is the server's `displayName`), else a
306
+ // neutral "Default". No "server default" phrasing, no raw id.
307
+ const defaultOptionLabel = defaultModelName || "Default";
308
+ // Label the current model by its human-readable name when a model is pinned
309
+ // and the catalogue has resolved it; else the default-option label. Never
310
+ // shows a raw endpoint id.
301
311
  const currentModelLabel = modelLabel(model) || defaultOptionLabel;
302
312
  // Picker entries sorted by their human-readable label (case-insensitive).
303
313
  const sortedModels = [...(models ?? [])].sort((a, b) =>
@@ -339,29 +349,26 @@ export const ChatView = ({
339
349
  if (isMobile) setMobileDrawerOpen((open) => !open);
340
350
  else toggleDesktopSidebar();
341
351
  };
342
- // Top bar carries only the sidebar toggle + Clear now; the model picker and
343
- // export live in a toolbar row just above the composer (Janna-style), closer
344
- // to where the user is typing.
345
- const showHeader = showClear || showSidebar;
346
- const showComposerToolbar = showModelDisplay || showExport;
352
+ // Top bar carries only the sidebar toggle now; the model picker, export, and
353
+ // clear all live in a toolbar row below the composer, closer to where the
354
+ // user is typing.
355
+ const showHeader = showSidebar;
356
+ const showComposerToolbar = showModelDisplay || showExport || showClear;
347
357
 
348
- // Local "in-flight" + confirm latch for the clear button so the
349
- // user can't double-fire the DELETE and so a stray click doesn't
350
- // wipe history without a beat to back out. Resets back to idle
351
- // after the parent's `onClear` settles (success or failure).
352
- const [clearState, setClearState] = useState<"idle" | "confirm" | "clearing">("idle");
358
+ // Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
359
+ // in-flight flag so the DELETE can't be double-fired. `clearing` disables
360
+ // the confirm action while `onClear` runs; the dialog closes on settle.
361
+ const [clearOpen, setClearOpen] = useState(false);
362
+ const [clearing, setClearing] = useState(false);
353
363
 
354
- const handleClearClick = async () => {
355
- if (clearState === "clearing" || !onClear) return;
356
- if (clearState === "idle") {
357
- setClearState("confirm");
358
- return;
359
- }
360
- setClearState("clearing");
364
+ const handleClearConfirm = async () => {
365
+ if (clearing || !onClear) return;
366
+ setClearing(true);
361
367
  try {
362
368
  await onClear();
369
+ setClearOpen(false);
363
370
  } finally {
364
- setClearState("idle");
371
+ setClearing(false);
365
372
  }
366
373
  };
367
374
 
@@ -466,44 +473,6 @@ export const ChatView = ({
466
473
  </Tooltip>
467
474
  )}
468
475
  </div>
469
- <div className="flex min-w-0 items-center justify-end gap-2 md:gap-3">
470
- {showClear && (
471
- <Tooltip>
472
- <TooltipTrigger asChild>
473
- <Button
474
- type="button"
475
- variant={clearState === "confirm" ? "destructive" : "outline"}
476
- size="sm"
477
- onClick={handleClearClick}
478
- onBlur={() =>
479
- // Drop the confirm latch when focus leaves so a
480
- // half-armed button doesn't sit destructive-red
481
- // forever after the user clicks away.
482
- setClearState((s) => (s === "confirm" ? "idle" : s))
483
- }
484
- disabled={clearState === "clearing"}
485
- className="gap-1.5"
486
- >
487
- {clearState === "clearing" ? (
488
- <Spinner className="size-3" />
489
- ) : (
490
- <Trash2Icon className="size-3" />
491
- )}
492
- {clearState === "confirm"
493
- ? "Confirm clear"
494
- : clearState === "clearing"
495
- ? "Clearing..."
496
- : "Clear"}
497
- </Button>
498
- </TooltipTrigger>
499
- <TooltipContent>
500
- {clearState === "confirm"
501
- ? "Click again to confirm; wipes this conversation."
502
- : "Clear chat history for this thread"}
503
- </TooltipContent>
504
- </Tooltip>
505
- )}
506
- </div>
507
476
  </div>
508
477
  )}
509
478
  <div className="relative flex flex-1 flex-col overflow-hidden">
@@ -719,6 +688,51 @@ export const ChatView = ({
719
688
  tooltip="Export conversation"
720
689
  />
721
690
  )}
691
+ {showClear && (
692
+ <>
693
+ <Tooltip>
694
+ <TooltipTrigger asChild>
695
+ <Button
696
+ type="button"
697
+ variant="outline"
698
+ size="sm"
699
+ onClick={() => setClearOpen(true)}
700
+ className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
701
+ >
702
+ <Trash2Icon className="size-3" />
703
+ Clear
704
+ </Button>
705
+ </TooltipTrigger>
706
+ <TooltipContent>Clear chat history for this thread</TooltipContent>
707
+ </Tooltip>
708
+ <AlertDialog open={clearOpen} onOpenChange={setClearOpen}>
709
+ <AlertDialogContent>
710
+ <AlertDialogHeader>
711
+ <AlertDialogTitle>Clear this conversation?</AlertDialogTitle>
712
+ <AlertDialogDescription>
713
+ This permanently deletes the chat history for this thread. This
714
+ can&apos;t be undone.
715
+ </AlertDialogDescription>
716
+ </AlertDialogHeader>
717
+ <AlertDialogFooter>
718
+ <AlertDialogCancel disabled={clearing}>Cancel</AlertDialogCancel>
719
+ <AlertDialogAction
720
+ onClick={(e) => {
721
+ // Keep the dialog open while the DELETE runs; we
722
+ // close it ourselves once `onClear` settles.
723
+ e.preventDefault();
724
+ void handleClearConfirm();
725
+ }}
726
+ disabled={clearing}
727
+ >
728
+ {clearing ? <Spinner className="size-3" /> : null}
729
+ {clearing ? "Clearing..." : "Clear"}
730
+ </AlertDialogAction>
731
+ </AlertDialogFooter>
732
+ </AlertDialogContent>
733
+ </AlertDialog>
734
+ </>
735
+ )}
722
736
  </div>
723
737
  )}
724
738
  </form>
@@ -84,13 +84,7 @@ export function humanizeLabel(
84
84
  value: string,
85
85
  options?: string.TokenizeOptions,
86
86
  ): string {
87
- const tokens = [
88
- ...string.tokenizeWithOptions(
89
- { lowerCase: true, capitalize: true, ...options },
90
- value,
91
- ),
92
- ];
93
- return tokens.length > 0 ? tokens.join(" ") : value;
87
+ return string.toLabel(value, options);
94
88
  }
95
89
 
96
90
  /**
@@ -348,10 +348,10 @@ export const useMastraChat = (
348
348
  // hidden and skips the catalogue fetch entirely.
349
349
  const showModelPicker = Boolean(options.showModelPicker);
350
350
  const { models } = useMastraModels(showModelPicker);
351
- // The endpoint the active agent falls back to when no model is pinned, so
352
- // the picker can name its "Server default" option. Fetched only when the
353
- // picker is shown; `null` when the agent's model is dynamic.
354
- const defaultModelName = useMastraDefaultModel(agentId, showModelPicker);
351
+ // The humanized name of the model the active agent falls back to when no
352
+ // model is pinned, so the picker can label its default option. Fetched only
353
+ // when the picker is shown; `null` when the agent's model is dynamic.
354
+ const { defaultModel: defaultModelName } = useMastraDefaultModel(agentId, showModelPicker);
355
355
  // Starter suggestions: an explicit `options.suggestions` always
356
356
  // wins (including `[]` to force none) and is rendered verbatim;
357
357
  // otherwise auto-source the agent's Genie space sample questions.
@@ -117,10 +117,10 @@ export type ChatViewProps = {
117
117
  model?: string;
118
118
  onModelChange?: (model: string) => void;
119
119
  /**
120
- * The serving-endpoint id the server falls back to when no model is pinned.
121
- * When set, the picker's "Server default" option names it (e.g. "Server
122
- * default (Claude Sonnet 4.6)"), using the matching `models` entry's
123
- * `displayName` when available.
120
+ * The serving-endpoint fallback the server uses when no model is pinned,
121
+ * already humanized (the server's `displayName`, e.g. "Claude Sonnet 4.6").
122
+ * When set, the picker's default option shows this name; otherwise it shows
123
+ * a neutral "Default". Never a raw endpoint id.
124
124
  */
125
125
  defaultModelName?: string;
126
126
  /**
@@ -222,7 +222,7 @@ function messageText(message: UIMessage): string {
222
222
  function roleLabel(role: UIMessage["role"], userLabel: string): string {
223
223
  if (role === "user") return userLabel;
224
224
  if (role === "assistant") return "Assistant";
225
- return role.charAt(0).toUpperCase() + role.slice(1);
225
+ return string.capitalize(role);
226
226
  }
227
227
 
228
228
  /* --------------------------------- HTML ---------------------------------- */
@@ -331,7 +331,7 @@ async function dataTableHtml(
331
331
  const data = await safeStatement(resolver, id);
332
332
  if (!data || data.rows.length === 0) return null;
333
333
  const head = data.columns
334
- .map((c) => `<th>${escapeHtml(humanizeHeader(c))}</th>`)
334
+ .map((c) => `<th>${escapeHtml(string.toLabel(c))}</th>`)
335
335
  .join("");
336
336
  const body = data.rows
337
337
  .map(
@@ -409,7 +409,7 @@ async function dataTableMarkdown(
409
409
  ): Promise<string | null> {
410
410
  const data = await safeStatement(resolver, id);
411
411
  if (!data || data.rows.length === 0) return null;
412
- const header = `| ${data.columns.map((c) => mdCell(humanizeHeader(c))).join(" | ")} |`;
412
+ const header = `| ${data.columns.map((c) => mdCell(string.toLabel(c))).join(" | ")} |`;
413
413
  const sep = `| ${data.columns.map(() => "---").join(" | ")} |`;
414
414
  const rows = data.rows.map(
415
415
  (row) => `| ${data.columns.map((c) => mdCell(cellText(row[c]))).join(" | ")} |`,
@@ -458,14 +458,6 @@ function mdCell(value: string): string {
458
458
  return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
459
459
  }
460
460
 
461
- /** Title-case a snake/kebab/camel column name for a header label. */
462
- function humanizeHeader(name: string): string {
463
- const tokens = [
464
- ...string.tokenizeWithOptions({ lowerCase: true, capitalize: true }, name),
465
- ];
466
- return tokens.length > 0 ? tokens.join(" ") : name;
467
- }
468
-
469
461
  /** Escape HTML-significant characters (from the shared string utils). */
470
462
  const escapeHtml = string.escapeHtml;
471
463
 
@@ -175,21 +175,21 @@ export class MastraPluginClient extends MastraClient {
175
175
  }
176
176
 
177
177
  /**
178
- * Fetch the static default serving-endpoint id `agentId` falls back to
179
- * when no model is pinned, from `GET ${basePath}/default-model`. Returns
180
- * `null` when the agent resolves its model dynamically at call time (or the
181
- * agent id is unknown) - there's nothing static to advertise. The picker
182
- * uses this to name its "Server default" option. Defaults to the server's
183
- * default agent when `agentId` is omitted.
178
+ * Fetch the humanized name of the static default model `agentId` falls back
179
+ * to when no model is pinned, from `GET ${basePath}/default-model`. Returns
180
+ * the server-humanized `displayName` (never a raw endpoint id), or `null`
181
+ * when the agent resolves its model dynamically at call time (or the agent
182
+ * id is unknown) - there's nothing static to advertise. The picker uses this
183
+ * to label its default option on load, before the `/models` catalogue
184
+ * arrives. Defaults to the server's default agent when `agentId` is omitted.
184
185
  */
185
186
  async defaultModel(agentId?: string, signal?: AbortSignal): Promise<string | null> {
186
- const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : "";
187
187
  const payload = await this.#getJson(
188
- `${this.basePath}${routes.MASTRA_ROUTES.defaultModel}${query}`,
188
+ this.#agentScoped(routes.MASTRA_ROUTES.defaultModel, agentId),
189
189
  wire.DefaultModelResponseSchema,
190
190
  signal,
191
191
  );
192
- return payload.model;
192
+ return payload.displayName ?? payload.model;
193
193
  }
194
194
 
195
195
  /**
@@ -552,35 +552,47 @@ export const useMastraModels = (
552
552
  };
553
553
 
554
554
  /**
555
- * Fetch the static default serving-endpoint id the given agent (or the
556
- * server's default agent) falls back to when no model is pinned, via
557
- * `client.defaultModel(agentId)`. `null` while loading, when the agent's
558
- * model is dynamic, or on error - the picker then shows a plain "Server
559
- * default". Pass `enabled: false` to skip the fetch (e.g. picker hidden).
555
+ * Fetch the humanized name of the static default model the given agent (or
556
+ * the server's default agent) falls back to when no model is pinned, via
557
+ * `client.defaultModel(agentId)`. `defaultModel` is `null` while loading,
558
+ * when the agent's model is dynamic, or on error - the picker then shows a
559
+ * plain "Default". Pass `enabled: false` to skip the fetch (e.g. picker
560
+ * hidden), which also clears any prior value. Returns an object to match the
561
+ * shape of the sibling data hooks ({@link useMastraModels},
562
+ * {@link useMastraSuggestions}).
560
563
  */
561
564
  export const useMastraDefaultModel = (
562
565
  agentId?: string,
563
566
  enabled = true,
564
- ): string | null => {
567
+ ): { defaultModel: string | null; loading: boolean } => {
565
568
  const client = useMastraClient();
566
569
  const [model, setModel] = useState<string | null>(null);
570
+ const [loading, setLoading] = useState(enabled);
567
571
 
568
572
  useEffect(() => {
569
- if (!enabled) return;
573
+ if (!enabled) {
574
+ setModel(null);
575
+ setLoading(false);
576
+ return;
577
+ }
570
578
  const controller = new AbortController();
579
+ setLoading(true);
571
580
  client
572
581
  .defaultModel(agentId, controller.signal)
573
582
  .then((m) => {
574
583
  if (!controller.signal.aborted) setModel(m);
575
584
  })
576
585
  .catch(() => {
577
- // Non-critical: a missing default just yields a plain "Server default".
586
+ // Non-critical: a missing default just yields a plain "Default".
578
587
  if (!controller.signal.aborted) setModel(null);
588
+ })
589
+ .finally(() => {
590
+ if (!controller.signal.aborted) setLoading(false);
579
591
  });
580
592
  return () => controller.abort();
581
593
  }, [client, agentId, enabled]);
582
594
 
583
- return model;
595
+ return { defaultModel: model, loading };
584
596
  };
585
597
 
586
598
  /**