@dbx-tools/ui-mastra 0.1.23 → 0.1.24

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
@@ -26,7 +26,7 @@ export type { UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-c
26
26
  export type { SuggestionPillsProps } from "./src/react/suggestion-pills";
27
27
  export type { ThreadSidebarProps } from "./src/react/thread-sidebar";
28
28
  export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types";
29
- export type { ExportFormat, EmbedResolver, ExportChatOptions } from "./src/support/export";
29
+ export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export";
30
30
  export type { ByIdFetchState } from "./src/support/mastra-client";
31
31
  export type { MastraStreamChunk, MastraStreamResponse } from "./src/support/mastra-stream";
32
32
  export type { ThreadSession } from "./src/support/thread-sessions";
package/package.json CHANGED
@@ -26,18 +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-core": "0.1.23",
30
- "@dbx-tools/shared-mastra": "0.1.23",
31
- "@dbx-tools/ui-appkit": "0.1.23",
32
- "@dbx-tools/shared-genie": "0.1.23",
33
- "@dbx-tools/shared-model": "0.1.23"
29
+ "@dbx-tools/shared-core": "0.1.24",
30
+ "@dbx-tools/shared-genie": "0.1.24",
31
+ "@dbx-tools/shared-mastra": "0.1.24",
32
+ "@dbx-tools/shared-model": "0.1.24",
33
+ "@dbx-tools/ui-appkit": "0.1.24",
34
+ "@dbx-tools/ui-branding": "0.1.24"
34
35
  },
35
36
  "main": "index.ts",
36
37
  "license": "UNLICENSED",
37
38
  "publishConfig": {
38
39
  "access": "public"
39
40
  },
40
- "version": "0.1.23",
41
+ "version": "0.1.24",
41
42
  "types": "index.ts",
42
43
  "type": "module",
43
44
  "exports": {
@@ -8,7 +8,7 @@ import {
8
8
  TooltipContent,
9
9
  TooltipTrigger,
10
10
  } from "@dbx-tools/ui-appkit/react";
11
- import { DownloadIcon } from "lucide-react";
11
+ import { DownloadIcon, FileTextIcon, PrinterIcon } from "lucide-react";
12
12
  import type { ExportFormat } from "../support/export";
13
13
 
14
14
  // Shared export affordance: a download button that opens a small menu of
@@ -16,9 +16,14 @@ import type { ExportFormat } from "../support/export";
16
16
  // labelled) and per-message export (bubble action row, icon-only).
17
17
 
18
18
  /** Menu entries, in display order. */
19
- const FORMATS: ReadonlyArray<{ format: ExportFormat; label: string }> = [
20
- { format: "pdf", label: "PDF" },
21
- { format: "markdown", label: "Markdown" },
19
+ const FORMATS: ReadonlyArray<{
20
+ format: ExportFormat;
21
+ label: string;
22
+ Icon: typeof DownloadIcon;
23
+ }> = [
24
+ { format: "pdf", label: "PDF", Icon: DownloadIcon },
25
+ { format: "print", label: "Print", Icon: PrinterIcon },
26
+ { format: "markdown", label: "Markdown", Icon: FileTextIcon },
22
27
  ];
23
28
 
24
29
  /**
@@ -65,8 +70,9 @@ export const ExportMenu = ({
65
70
  <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
66
71
  )}
67
72
  <DropdownMenuContent align="end">
68
- {FORMATS.map(({ format, label }) => (
73
+ {FORMATS.map(({ format, label, Icon }) => (
69
74
  <DropdownMenuItem key={format} onClick={() => onExport(format)}>
75
+ <Icon className="size-3.5" />
70
76
  {label}
71
77
  </DropdownMenuItem>
72
78
  ))}
@@ -7,6 +7,7 @@ import type { UIMessage } from "ai";
7
7
  import { nanoid } from "nanoid";
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
9
  import { exportChat, type EmbedResolver, type ExportFormat } from "../support/export";
10
+ import { useBrand } from "@dbx-tools/ui-branding/react";
10
11
  import {
11
12
  useMastraClient,
12
13
  useMastraModels,
@@ -1139,6 +1140,21 @@ export const useMastraChat = (
1139
1140
  const resourceId = threads.find((t) => t.resourceId)?.resourceId;
1140
1141
  return resourceId ? `User (${resourceId})` : "User";
1141
1142
  }, [threads]);
1143
+ // Brand styling for the exported document, resolved from the active
1144
+ // BrandProvider (or the built-in dbx-tools default when the host wired
1145
+ // none). The logo asset id is resolved to a portable data URL so the
1146
+ // export is self-contained; colors/font come straight from the context.
1147
+ const { context: brandContext, resolveAsset: resolveBrandAsset } = useBrand();
1148
+ const exportBrand = useMemo(
1149
+ () => ({
1150
+ logoDataUrl: resolveBrandAsset(brandContext.assets.logo.light),
1151
+ primary: brandContext.colors.primary,
1152
+ accent: brandContext.colors.accent,
1153
+ foreground: brandContext.colors.foreground,
1154
+ fontSans: brandContext.typography.sans,
1155
+ }),
1156
+ [brandContext, resolveBrandAsset],
1157
+ );
1142
1158
  const exportConversation = useCallback(
1143
1159
  async (format: ExportFormat) => {
1144
1160
  const title =
@@ -1151,6 +1167,7 @@ export const useMastraChat = (
1151
1167
  resolver: exportResolver,
1152
1168
  title,
1153
1169
  userLabel: exportUserLabel,
1170
+ brand: exportBrand,
1154
1171
  });
1155
1172
  } catch (error) {
1156
1173
  logger.error("conversation export error", {
@@ -1159,7 +1176,15 @@ export const useMastraChat = (
1159
1176
  });
1160
1177
  }
1161
1178
  },
1162
- [exportResolver, activeThreadId, activeKey, getSession, threads, exportUserLabel],
1179
+ [
1180
+ exportResolver,
1181
+ activeThreadId,
1182
+ activeKey,
1183
+ getSession,
1184
+ threads,
1185
+ exportUserLabel,
1186
+ exportBrand,
1187
+ ],
1163
1188
  );
1164
1189
  const exportMessage = useCallback(
1165
1190
  async (message: UIMessage, format: ExportFormat) => {
@@ -1171,6 +1196,7 @@ export const useMastraChat = (
1171
1196
  title: "Message",
1172
1197
  filename: "message",
1173
1198
  userLabel: exportUserLabel,
1199
+ brand: exportBrand,
1174
1200
  });
1175
1201
  } catch (error) {
1176
1202
  logger.error("message export error", {
@@ -1179,7 +1205,7 @@ export const useMastraChat = (
1179
1205
  });
1180
1206
  }
1181
1207
  },
1182
- [exportResolver, exportUserLabel],
1208
+ [exportResolver, exportUserLabel, exportBrand],
1183
1209
  );
1184
1210
 
1185
1211
  // Submit thumbs / comment feedback for an assistant message to MLflow
@@ -5,11 +5,15 @@
5
5
  * the single-message or the whole-conversation level, in one of two
6
6
  * formats:
7
7
  *
8
- * - `"pdf"` - a print-ready HTML document opened in a new tab with
9
- * the browser's print dialog triggered, so the user saves
10
- * a real PDF ("Save as PDF"). Renders reliably offline.
8
+ * - `"pdf"` - a print-ready HTML document rendered in a hidden iframe
9
+ * with the browser's print dialog triggered, so the user
10
+ * saves a real PDF ("Save as PDF") with no popup tab.
11
+ * - `"print"` - the same document routed to a paper printer.
11
12
  * - `"markdown"` - a `.md` file download.
12
13
  *
14
+ * The document can be brand-styled (logo, colors, font) via the optional
15
+ * `brand` option; the driver resolves it from `@dbx-tools/ui-branding`.
16
+ *
13
17
  * Charts are included, not dropped: each `[chart:<id>]` marker is
14
18
  * resolved against the plugin's chart cache and rendered to an inline
15
19
  * SVG via Echarts' server-side renderer (no DOM needed), so a PDF/HTML
@@ -29,8 +33,33 @@ import * as echarts from "echarts";
29
33
  import { marked } from "marked";
30
34
  import { normalizeChartOption } from "./chart-option";
31
35
 
32
- /** Output formats {@link exportChat} can produce. */
33
- export type ExportFormat = "pdf" | "markdown";
36
+ /**
37
+ * Output formats {@link exportChat} can produce.
38
+ * - `"pdf"` - Save-as-PDF via a hidden print iframe (dialog defaults to
39
+ * "Save as PDF"); no new tab.
40
+ * - `"print"` - same rendered document + print dialog, for a paper printer.
41
+ * - `"markdown"` - a `.md` file download.
42
+ */
43
+ export type ExportFormat = "pdf" | "print" | "markdown";
44
+
45
+ /**
46
+ * Optional brand styling for the exported document. Plain data (no React /
47
+ * DOM), so this module stays framework-free; the driver resolves it from
48
+ * `@dbx-tools/ui-branding` (`useBrand()` + `resolveBrandAsset`) and passes it
49
+ * through. Any field omitted falls back to the neutral default styling.
50
+ */
51
+ export interface ExportBrand {
52
+ /** Logo image src (a data URL) rendered in the document header. */
53
+ logoDataUrl?: string;
54
+ /** Primary brand color for the header rule, headings, and role labels. */
55
+ primary?: string;
56
+ /** Accent brand color (links). */
57
+ accent?: string;
58
+ /** Body text color. */
59
+ foreground?: string;
60
+ /** Sans-serif font stack for the document body. */
61
+ fontSans?: string;
62
+ }
34
63
 
35
64
  /**
36
65
  * Resolves the embeds referenced by `[chart:<id>]` / `[data:<id>]`
@@ -61,6 +90,8 @@ export interface ExportChatOptions {
61
90
  * id / email form like `"User (someone@example.com)"`.
62
91
  */
63
92
  userLabel?: string;
93
+ /** Optional brand styling for the exported document. */
94
+ brand?: ExportBrand;
64
95
  }
65
96
 
66
97
  /** Fixed Echarts SSR canvas; the SVG scales to the print column via CSS. */
@@ -71,14 +102,14 @@ const CHART_HEIGHT_PX = 380;
71
102
  const PRINT_SETTLE_MS = 300;
72
103
 
73
104
  /**
74
- * Export `messages` as a PDF (via a print-ready tab) or a Markdown file.
105
+ * Export `messages` as a PDF, a print job, or a Markdown file.
75
106
  *
76
- * For `"pdf"` a new tab is opened **synchronously** (before any async
77
- * embed resolution) so browser popup blockers - which only allow
78
- * `window.open` inside a user gesture - don't swallow it; the resolved
79
- * document is written in once charts / tables are ready. If the popup is
80
- * blocked anyway, the HTML is downloaded as a file instead so the export
81
- * still succeeds.
107
+ * `"pdf"` and `"print"` render the same branded, self-contained HTML
108
+ * document and drive it through a hidden `<iframe>` + `print()` - so the
109
+ * browser's print dialog (defaulting to "Save as PDF" for `pdf`) opens
110
+ * directly, with no popup tab. If the iframe path can't run (e.g. no DOM
111
+ * body), the document is downloaded as a self-contained `.html` file so the
112
+ * export - charts included - still lands.
82
113
  */
83
114
  export async function exportChat(options: ExportChatOptions): Promise<void> {
84
115
  const { messages, format, resolver } = options;
@@ -92,22 +123,59 @@ export async function exportChat(options: ExportChatOptions): Promise<void> {
92
123
  return;
93
124
  }
94
125
 
95
- // Open the tab up-front (user-gesture safe), show a placeholder while
96
- // embeds resolve, then swap in the finished document and print.
97
- const win = window.open("", "_blank");
98
- win?.document.write(PREPARING_HTML);
99
- const html = await buildHtmlDocument(messages, resolver, title, userLabel);
100
- if (!win) {
101
- // Popup blocked: fall back to an HTML file download so the export
102
- // (charts included) still lands.
103
- downloadTextFile(`${stem}.html`, html, "text/html;charset=utf-8");
126
+ // pdf / print: build the branded document, then print it from a hidden
127
+ // iframe so the Save-as-PDF / print dialog opens without a stray tab.
128
+ const html = await buildHtmlDocument(messages, resolver, title, userLabel, options.brand);
129
+ printViaHiddenIframe(html, `${stem}.html`);
130
+ }
131
+
132
+ /**
133
+ * Render `html` in an off-screen iframe and trigger its print dialog. The
134
+ * iframe is same-origin via `srcdoc`, so `contentWindow.print()` is allowed;
135
+ * it's removed after printing. Falls back to a file download when there's no
136
+ * document body to attach to.
137
+ */
138
+ function printViaHiddenIframe(html: string, downloadName: string): void {
139
+ if (typeof document === "undefined" || !document.body) {
140
+ downloadTextFile(downloadName, html, "text/html;charset=utf-8");
104
141
  return;
105
142
  }
106
- win.document.open();
107
- win.document.write(html);
108
- win.document.close();
109
- win.focus();
110
- win.setTimeout(() => win.print(), PRINT_SETTLE_MS);
143
+ const iframe = document.createElement("iframe");
144
+ iframe.setAttribute("aria-hidden", "true");
145
+ iframe.style.position = "fixed";
146
+ iframe.style.right = "0";
147
+ iframe.style.bottom = "0";
148
+ iframe.style.width = "0";
149
+ iframe.style.height = "0";
150
+ iframe.style.border = "0";
151
+ iframe.srcdoc = html;
152
+
153
+ let cleaned = false;
154
+ const cleanup = () => {
155
+ if (cleaned) return;
156
+ cleaned = true;
157
+ window.setTimeout(() => iframe.remove(), 1000);
158
+ };
159
+
160
+ iframe.onload = () => {
161
+ const win = iframe.contentWindow;
162
+ if (!win) {
163
+ iframe.remove();
164
+ downloadTextFile(downloadName, html, "text/html;charset=utf-8");
165
+ return;
166
+ }
167
+ // Settle so charts / fonts lay out, then print. `afterprint` removes the
168
+ // iframe once the dialog closes (a timeout backstops browsers that don't
169
+ // fire it).
170
+ win.addEventListener("afterprint", cleanup);
171
+ win.setTimeout(() => {
172
+ win.focus();
173
+ win.print();
174
+ window.setTimeout(cleanup, 60000);
175
+ }, PRINT_SETTLE_MS);
176
+ };
177
+
178
+ document.body.appendChild(iframe);
111
179
  }
112
180
 
113
181
  /* ------------------------------- segments -------------------------------- */
@@ -168,6 +236,7 @@ async function buildHtmlDocument(
168
236
  resolver: EmbedResolver,
169
237
  title: string,
170
238
  userLabel: string,
239
+ brand?: ExportBrand,
171
240
  ): Promise<string> {
172
241
  const articles: string[] = [];
173
242
  for (const message of messages) {
@@ -179,11 +248,14 @@ async function buildHtmlDocument(
179
248
  `<div class="content">${body}</div></article>`,
180
249
  );
181
250
  }
251
+ const logo = brand?.logoDataUrl
252
+ ? `<img class="doc-logo" src="${escapeHtml(brand.logoDataUrl)}" alt="">`
253
+ : "";
182
254
  return (
183
255
  `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
184
256
  `<meta name="viewport" content="width=device-width, initial-scale=1">` +
185
- `<title>${escapeHtml(title)}</title><style>${PRINT_CSS}</style></head>` +
186
- `<body><header class="doc-header"><h1>${escapeHtml(title)}</h1>` +
257
+ `<title>${escapeHtml(title)}</title><style>${buildDocumentCss(brand)}</style></head>` +
258
+ `<body><header class="doc-header">${logo}<h1>${escapeHtml(title)}</h1>` +
187
259
  `<div class="doc-meta">Exported ${escapeHtml(new Date().toLocaleString())}</div>` +
188
260
  `</header><main>${articles.join("")}</main></body></html>`
189
261
  );
@@ -417,15 +489,10 @@ function downloadTextFile(filename: string, content: string, mime: string): void
417
489
  window.setTimeout(() => URL.revokeObjectURL(url), 1000);
418
490
  }
419
491
 
420
- /** Placeholder shown in the print tab while embeds resolve. */
421
- const PREPARING_HTML =
422
- '<!doctype html><html><head><meta charset="utf-8"><title>Preparing export...</title>' +
423
- "<style>body{font:14px system-ui,sans-serif;color:#475569;display:flex;" +
424
- "height:100vh;margin:0;align-items:center;justify-content:center}</style></head>" +
425
- "<body>Preparing export...</body></html>";
426
-
427
492
  /**
428
- * Inline stylesheet for the exported document. Tuned for print:
493
+ * Build the inline stylesheet for the exported document, folding in optional
494
+ * brand color / font overrides (falling back to the neutral slate/blue theme
495
+ * and a system font stack). Tuned for print:
429
496
  *
430
497
  * - A readable measure with role-labelled message blocks.
431
498
  * - Content flows naturally across pages: whole messages are NOT
@@ -436,23 +503,30 @@ const PREPARING_HTML =
436
503
  * rows - while long tables split across pages and repeat their
437
504
  * header (`thead { display: table-header-group }`).
438
505
  * - `@page { margin: 0 }` collapses the page margin box so the
439
- * browser's own print header/footer (the `about:blank` URL, the
506
+ * browser's own print header/footer (the source URL, the
440
507
  * date, and `n/N` page numbers) have nowhere to render and drop
441
508
  * out; the visible page margin is re-supplied as body padding.
442
509
  */
443
- const PRINT_CSS = `
510
+ function buildDocumentCss(brand?: ExportBrand): string {
511
+ const fg = brand?.foreground || "#0f172a";
512
+ const primary = brand?.primary || "#0f172a";
513
+ const link = brand?.accent || "#2563eb";
514
+ const font =
515
+ brand?.fontSans || 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
516
+ return `
444
517
  :root { color-scheme: light; }
445
518
  * { box-sizing: border-box; }
446
519
  body {
447
- font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
448
- color: #0f172a; margin: 0; padding: 32px; background: #fff;
520
+ font: 15px/1.6 ${font};
521
+ color: ${fg}; margin: 0; padding: 32px; background: #fff;
449
522
  }
450
523
  main { max-width: 820px; margin: 0 auto; }
451
- .doc-header { max-width: 820px; margin: 0 auto 24px; border-bottom: 1px solid #e2e8f0; padding-bottom: 12px; }
452
- .doc-header h1 { font-size: 22px; margin: 0 0 4px; }
524
+ .doc-header { max-width: 820px; margin: 0 auto 24px; border-bottom: 2px solid ${primary}; padding-bottom: 12px; }
525
+ .doc-logo { height: 28px; width: auto; display: block; margin: 0 0 10px; }
526
+ .doc-header h1 { font-size: 22px; margin: 0 0 4px; color: ${primary}; }
453
527
  .doc-meta { font-size: 12px; color: #64748b; }
454
528
  .msg { margin: 0 0 20px; }
455
- .msg .role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #64748b; margin-bottom: 4px; break-after: avoid; }
529
+ .msg .role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: ${primary}; margin-bottom: 4px; break-after: avoid; }
456
530
  .msg-user .content { background: #f1f5f9; border-radius: 8px; padding: 10px 14px; }
457
531
  .msg .content > *:first-child { margin-top: 0; }
458
532
  .msg .content > *:last-child { margin-bottom: 0; }
@@ -462,7 +536,7 @@ const PRINT_CSS = `
462
536
  code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; background: #f1f5f9; padding: .1em .3em; border-radius: 4px; }
463
537
  pre { background: #0f172a; color: #e2e8f0; padding: 12px 14px; border-radius: 8px; overflow-x: auto; break-inside: avoid; }
464
538
  pre code { background: none; padding: 0; color: inherit; }
465
- a { color: #2563eb; }
539
+ a { color: ${link}; }
466
540
  .embed { margin: 12px 0; }
467
541
  .embed-chart { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; break-inside: avoid; }
468
542
  .embed-chart svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
@@ -478,3 +552,4 @@ const PRINT_CSS = `
478
552
  a { color: inherit; text-decoration: none; }
479
553
  }
480
554
  `;
555
+ }