@dbx-tools/ui-mastra 0.3.28 → 0.3.29

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
@@ -204,6 +204,25 @@ resolved during export: charts are rendered to inline SVG with ECharts' server
204
204
  renderer, and data markers become real tables. Expired or missing embeds are
205
205
  skipped so old transcripts still export cleanly.
206
206
 
207
+ ## Copying And Downloading
208
+
209
+ Copy and download affordances appear in several places - message bubbles, code
210
+ blocks, conversation export, and the data-grid CSV button - and they behave the
211
+ same everywhere because each is backed by one internal module rather than
212
+ per-component code.
213
+
214
+ Copying works on hosts served over plain HTTP. An insecure context has no
215
+ Clipboard API, so a bare `navigator.clipboard.writeText` silently does nothing on
216
+ a local or intranet deployment; the shared helper falls back to a hidden
217
+ `<textarea>` plus `document.execCommand("copy")` and reports whether the copy
218
+ actually succeeded. Downloads revoke their object URL after the temporary anchor
219
+ fires, so a long session does not accumulate blobs.
220
+
221
+ If you build a controlled surface with `ChatView`, you get both without wiring
222
+ anything. Adding a new copy or download button anywhere in this package should
223
+ reuse `src/support/clipboard.ts` and `src/support/download.ts` instead of
224
+ touching `navigator.clipboard` or `URL.createObjectURL` again.
225
+
207
226
  ## Modules
208
227
 
209
228
  - `MastraChat` - self-contained drop-in chat component.
package/index.ts CHANGED
@@ -16,6 +16,8 @@ export * as reactThreadSidebar from "./src/react/thread-sidebar";
16
16
  export * as reactToolPill from "./src/react/tool-pill";
17
17
  export * as reactTypes from "./src/react/types";
18
18
  export * as supportChartOption from "./src/support/chart-option";
19
+ export * as supportClipboard from "./src/support/clipboard";
20
+ export * as supportDownload from "./src/support/download";
19
21
  export * as supportExport from "./src/support/export";
20
22
  export * as supportMastraClient from "./src/support/mastra-client";
21
23
  export * as supportMastraStream from "./src/support/mastra-stream";
package/package.json CHANGED
@@ -20,25 +20,24 @@
20
20
  "echarts-for-react": "^3.0.2",
21
21
  "lucide-react": "^0.554.0",
22
22
  "marked": "^18.0.5",
23
- "nanoid": "^5.1.6",
24
23
  "react": "^19.2.4",
25
24
  "react-dom": "^19.2.4",
26
25
  "shiki": "^3.0.0",
27
26
  "sql-formatter": "^15.6.9",
28
27
  "streamdown": "^2.5.0",
29
- "@dbx-tools/shared-core": "0.3.28",
30
- "@dbx-tools/shared-genie": "0.3.28",
31
- "@dbx-tools/shared-mastra": "0.3.28",
32
- "@dbx-tools/shared-model": "0.3.28",
33
- "@dbx-tools/ui-appkit": "0.3.28",
34
- "@dbx-tools/ui-branding": "0.3.28"
28
+ "@dbx-tools/shared-core": "0.3.29",
29
+ "@dbx-tools/shared-genie": "0.3.29",
30
+ "@dbx-tools/shared-mastra": "0.3.29",
31
+ "@dbx-tools/shared-model": "0.3.29",
32
+ "@dbx-tools/ui-appkit": "0.3.29",
33
+ "@dbx-tools/ui-branding": "0.3.29"
35
34
  },
36
35
  "main": "index.ts",
37
36
  "license": "UNLICENSED",
38
37
  "publishConfig": {
39
38
  "access": "public"
40
39
  },
41
- "version": "0.3.28",
40
+ "version": "0.3.29",
42
41
  "types": "index.ts",
43
42
  "type": "module",
44
43
  "exports": {
@@ -27,6 +27,7 @@ import { useState } from "react";
27
27
  import { MarkdownWithEmbeds } from "./embed-slots";
28
28
  import { ExportMenu } from "./export-menu";
29
29
  import { FeedbackControls } from "./feedback-controls";
30
+ import { copyText } from "../support/clipboard";
30
31
  import { ToolMarkdown } from "./markdown";
31
32
  import { SuggestionPills } from "./suggestion-pills";
32
33
  import { collectSuggestions } from "./suggestions";
@@ -45,39 +46,6 @@ import type {
45
46
  // the helpers that surface approval-gated tool calls out of a message's
46
47
  // parts.
47
48
 
48
- /**
49
- * Copy `text` to the clipboard, resolving `true` on success. Prefers the
50
- * async Clipboard API, but that is only available in a secure context
51
- * (HTTPS / localhost) - over plain HTTP (e.g. a LAN / ZeroTier IP) it is
52
- * `undefined`, which is why a tap on mobile could silently do nothing. Falls
53
- * back to a hidden `<textarea>` + `document.execCommand("copy")` so copy works
54
- * off a non-secure origin too.
55
- */
56
- async function copyText(text: string): Promise<boolean> {
57
- try {
58
- if (navigator.clipboard?.writeText) {
59
- await navigator.clipboard.writeText(text);
60
- return true;
61
- }
62
- } catch {
63
- // Fall through to the execCommand path.
64
- }
65
- try {
66
- const area = document.createElement("textarea");
67
- area.value = text;
68
- area.setAttribute("readonly", "");
69
- area.style.position = "fixed";
70
- area.style.opacity = "0";
71
- document.body.appendChild(area);
72
- area.select();
73
- const ok = document.execCommand("copy");
74
- area.remove();
75
- return ok;
76
- } catch {
77
- return false;
78
- }
79
- }
80
-
81
49
  const getReasoningText = (parts: UIMessage["parts"]): string =>
82
50
  parts
83
51
  .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
@@ -30,6 +30,7 @@ import {
30
30
  DownloadIcon,
31
31
  } from "lucide-react";
32
32
  import React, { useMemo, useState } from "react";
33
+ import { downloadFile } from "../support/download";
33
34
 
34
35
  // Interactive result table plus the cell/label/CSV helpers it and the
35
36
  // markdown table renderer share. Built on `@tanstack/react-table` over
@@ -101,12 +102,7 @@ function downloadCsv(columns: string[], rows: DataRow[], filename: string): void
101
102
  columns.map(escape).join(","),
102
103
  ...rows.map((row) => columns.map((c) => escape(row[c])).join(",")),
103
104
  ].join("\n");
104
- const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" }));
105
- const anchor = document.createElement("a");
106
- anchor.href = url;
107
- anchor.download = filename;
108
- anchor.click();
109
- URL.revokeObjectURL(url);
105
+ downloadFile(filename, csv, "text/csv;charset=utf-8");
110
106
  }
111
107
 
112
108
  /**
@@ -17,6 +17,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
17
17
  import { format as formatSql } from "sql-formatter";
18
18
  import { Streamdown } from "streamdown";
19
19
  import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid";
20
+ import { copyText } from "../support/clipboard";
20
21
  import { createShikiPlugin, highlightToHtml } from "../support/shiki-plugin";
21
22
 
22
23
  // Markdown rendering for the chat: the streaming `Streamdown` engine
@@ -307,7 +308,8 @@ const CopyButton = ({ value, className }: { value: string; className?: string })
307
308
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
308
309
  useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
309
310
  const onCopy = () => {
310
- void navigator.clipboard.writeText(value).then(() => {
311
+ void copyText(value).then((ok) => {
312
+ if (!ok) return;
311
313
  setCopied(true);
312
314
  clearTimeout(timer.current ?? undefined);
313
315
  timer.current = setTimeout(() => setCopied(false), 1500);
@@ -1,8 +1,7 @@
1
- import { error as errorUtil, log } from "@dbx-tools/shared-core";
1
+ import { error as errorUtil, hash, log } from "@dbx-tools/shared-core";
2
2
  import { feedback, type MastraThread } from "@dbx-tools/shared-mastra";
3
3
  import { useBrand } from "@dbx-tools/ui-branding/react";
4
4
  import type { UIMessage } from "ai";
5
- import { nanoid } from "nanoid";
6
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
6
  import { ChatView } from "./chat-view";
8
7
  import { dedupeSuggestions } from "./suggestions";
@@ -61,7 +60,7 @@ const logger = log.logger("ui-mastra/chat");
61
60
  const HISTORY_PAGE_SIZE = 20;
62
61
 
63
62
  const makeUserMessage = (text: string): UIMessage => ({
64
- id: nanoid(),
63
+ id: hash.id(),
65
64
  role: "user",
66
65
  parts: [{ type: "text", text }],
67
66
  });
@@ -268,7 +267,7 @@ export const useMastraChat = (
268
267
  const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
269
268
  const threadKey = threadStorageKey(mastraClient.basePath, agentId);
270
269
  const [activeThreadId, setActiveThreadId] = useState<string | undefined>(() =>
271
- enableThreads ? (readStoredThreadId(threadKey) ?? nanoid()) : undefined,
270
+ enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : undefined,
272
271
  );
273
272
  const {
274
273
  threads,
@@ -743,8 +742,8 @@ export const useMastraChat = (
743
742
 
744
743
  const runStream = useCallback(
745
744
  (threadId: string, history: UIMessage[]) => {
746
- const assistantId = nanoid();
747
- const runId = nanoid();
745
+ const assistantId = hash.id();
746
+ const runId = hash.id();
748
747
  updateSession(threadId, (session) => ({
749
748
  ...session,
750
749
  assistantId,
@@ -917,7 +916,7 @@ export const useMastraChat = (
917
916
  if (isSessionRunning(getSession(threadId))) {
918
917
  updateSession(threadId, (session) => ({
919
918
  ...session,
920
- queuedSteers: enqueueSteer(session.queuedSteers, { id: nanoid(), text }),
919
+ queuedSteers: enqueueSteer(session.queuedSteers, { id: hash.id(), text }),
921
920
  }));
922
921
  return;
923
922
  }
@@ -1026,7 +1025,7 @@ export const useMastraChat = (
1026
1025
  );
1027
1026
 
1028
1027
  const newThread = useCallback(() => {
1029
- const id = nanoid();
1028
+ const id = hash.id();
1030
1029
  sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
1031
1030
  notifySessions();
1032
1031
  setActiveThreadId(id);
@@ -1058,7 +1057,7 @@ export const useMastraChat = (
1058
1057
  return rest;
1059
1058
  });
1060
1059
  if (threadId === activeThreadId) {
1061
- const id = nanoid();
1060
+ const id = hash.id();
1062
1061
  sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
1063
1062
  notifySessions();
1064
1063
  setActiveThreadId(id);
@@ -1,3 +1,4 @@
1
+ import { string } from "@dbx-tools/shared-core";
1
2
  import { genieModel } from "@dbx-tools/shared-genie";
2
3
  import {
3
4
  Collapsible,
@@ -25,21 +26,7 @@ import type { ToolEvent, ToolProgress } from "./types";
25
26
  * `ask_genie_sales` -> `Ask Genie Sales`
26
27
  * `myCoolTool` -> `My Cool Tool`
27
28
  */
28
- export const humanizeToolName = (toolName: string): string =>
29
- toolName
30
- .replace(/[._]/g, " ")
31
- .replace(/([a-z])([A-Z])/g, "$1 $2")
32
- .split(/\s+/)
33
- .filter(Boolean)
34
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
35
- .join(" ");
36
-
37
- /**
38
- * Capitalize the first character of a label without touching the rest
39
- * (preserves `EXECUTING_QUERY`-style backend status labels).
40
- */
41
- const capitalizeFirst = (s: string): string =>
42
- s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
29
+ export const humanizeToolName = (toolName: string): string => string.toLabel(toolName);
43
30
 
44
31
  /**
45
32
  * Track the freshest status label a running tool has published so the
@@ -54,7 +41,7 @@ const runningLabelFor = (event: ToolEvent): string => {
54
41
  .reverse()
55
42
  .find((p): p is Extract<ToolProgress, { type: "status" }> => p.type === "status");
56
43
  return latest
57
- ? capitalizeFirst(genieModel.humanizeStatus(latest.status))
44
+ ? string.capitalize(genieModel.humanizeStatus(latest.status))
58
45
  : `Calling ${humanizeToolName(event.toolName)}`;
59
46
  };
60
47
 
@@ -256,13 +243,7 @@ const askGenieQuestion = (event: ToolEvent): string | undefined => {
256
243
  * `THOUGHT_TYPE_UNDERSTANDING` -> `Understanding`
257
244
  */
258
245
  const humanizeThoughtType = (kind: string): string =>
259
- kind
260
- .replace(/^THOUGHT_TYPE_/i, "")
261
- .toLowerCase()
262
- .split("_")
263
- .filter(Boolean)
264
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
265
- .join(" ");
246
+ string.toLabel(kind.replace(/^THOUGHT_TYPE_/i, ""));
266
247
 
267
248
  /**
268
249
  * Genie attaches one of three payload kinds per attachment slot:
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Clipboard write that also works off a non-secure origin.
3
+ *
4
+ * `navigator.clipboard` only exists in a secure context (HTTPS / localhost).
5
+ * Over plain HTTP - a LAN IP, a ZeroTier address, a port-forwarded dev host -
6
+ * it is `undefined`, so a copy button wired straight to it silently does
7
+ * nothing. {@link copyText} falls back to a hidden `<textarea>` +
8
+ * `document.execCommand("copy")`, which is deprecated but still the only thing
9
+ * that works there.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ /**
15
+ * Copy `text` to the clipboard, returning whether it succeeded so the caller
16
+ * can decide whether to show a confirmation.
17
+ */
18
+ export async function copyText(text: string): Promise<boolean> {
19
+ try {
20
+ if (navigator.clipboard?.writeText) {
21
+ await navigator.clipboard.writeText(text);
22
+ return true;
23
+ }
24
+ } catch {
25
+ // Permission denied or non-secure context; fall through to execCommand.
26
+ }
27
+ try {
28
+ const area = document.createElement("textarea");
29
+ area.value = text;
30
+ area.setAttribute("readonly", "");
31
+ area.style.position = "fixed";
32
+ area.style.opacity = "0";
33
+ document.body.appendChild(area);
34
+ area.select();
35
+ const ok = document.execCommand("copy");
36
+ area.remove();
37
+ return ok;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Triggering a browser download of an in-memory file.
3
+ *
4
+ * The chat surface exports from two places - the transcript exporter
5
+ * (markdown / HTML) and the data-grid CSV button - which both need the same
6
+ * blob-URL + synthetic-anchor dance, including revoking the URL so repeated
7
+ * exports do not leak object URLs.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /**
13
+ * Download `content` as `filename`. `mime` should include the charset for text
14
+ * formats (e.g. `"text/csv;charset=utf-8"`) so the file opens correctly.
15
+ *
16
+ * The anchor is attached to the document before clicking because Firefox
17
+ * ignores a click on a detached element, and the object URL is revoked on a
18
+ * delay so the download has started before the blob is released.
19
+ */
20
+ export function downloadFile(filename: string, content: BlobPart, mime: string): void {
21
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
22
+ const anchor = document.createElement("a");
23
+ anchor.href = url;
24
+ anchor.download = filename;
25
+ document.body.appendChild(anchor);
26
+ anchor.click();
27
+ anchor.remove();
28
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
29
+ }
@@ -29,6 +29,7 @@ import type { UIMessage } from "ai";
29
29
  import * as echarts from "echarts";
30
30
  import { marked } from "marked";
31
31
  import { normalizeChartOption } from "./chart-option";
32
+ import { downloadFile } from "./download";
32
33
 
33
34
  /**
34
35
  * Output formats {@link exportChat} can produce.
@@ -114,7 +115,7 @@ export async function exportChat(options: ExportChatOptions): Promise<void> {
114
115
 
115
116
  if (format === "markdown") {
116
117
  const md = await buildMarkdown(messages, resolver, title, userLabel);
117
- downloadTextFile(`${stem}.md`, md, "text/markdown;charset=utf-8");
118
+ downloadFile(`${stem}.md`, md, "text/markdown;charset=utf-8");
118
119
  return;
119
120
  }
120
121
 
@@ -132,7 +133,7 @@ export async function exportChat(options: ExportChatOptions): Promise<void> {
132
133
  */
133
134
  function printViaHiddenIframe(html: string, downloadName: string): void {
134
135
  if (typeof document === "undefined" || !document.body) {
135
- downloadTextFile(downloadName, html, "text/html;charset=utf-8");
136
+ downloadFile(downloadName, html, "text/html;charset=utf-8");
136
137
  return;
137
138
  }
138
139
  const iframe = document.createElement("iframe");
@@ -156,7 +157,7 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
156
157
  const win = iframe.contentWindow;
157
158
  if (!win) {
158
159
  iframe.remove();
159
- downloadTextFile(downloadName, html, "text/html;charset=utf-8");
160
+ downloadFile(downloadName, html, "text/html;charset=utf-8");
160
161
  return;
161
162
  }
162
163
  // Settle so charts / fonts lay out, then print. `afterprint` removes the
@@ -172,7 +173,7 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
172
173
  win.print();
173
174
  } catch {
174
175
  cleanup();
175
- downloadTextFile(downloadName, html, "text/html;charset=utf-8");
176
+ downloadFile(downloadName, html, "text/html;charset=utf-8");
176
177
  }
177
178
  }, PRINT_SETTLE_MS);
178
179
  };
@@ -443,18 +444,6 @@ function slugify(value: string): string {
443
444
  return string.toSlugWithOptions({ maxLength: 60 }, value) || "conversation";
444
445
  }
445
446
 
446
- /** Trigger a browser download of an in-memory text file. */
447
- function downloadTextFile(filename: string, content: string, mime: string): void {
448
- const url = URL.createObjectURL(new Blob([content], { type: mime }));
449
- const anchor = document.createElement("a");
450
- anchor.href = url;
451
- anchor.download = filename;
452
- document.body.appendChild(anchor);
453
- anchor.click();
454
- anchor.remove();
455
- window.setTimeout(() => URL.revokeObjectURL(url), 1000);
456
- }
457
-
458
447
  /**
459
448
  * A brand color / font value safe to interpolate into a `<style>` block.
460
449
  * Brand colors are hex-validated upstream, but the font stack is only checked
@@ -1,3 +1,5 @@
1
+ import { json } from "@dbx-tools/shared-core";
2
+
1
3
  /** One chunk from a Mastra agent SSE stream (`data: { type, payload, ... }`). */
2
4
  export interface MastraStreamChunk {
3
5
  type: string;
@@ -40,13 +42,8 @@ export async function processMastraStream(options: {
40
42
  if (!line.startsWith("data: ")) continue;
41
43
  const data = line.slice(6);
42
44
  if (data === "[DONE]") return;
43
- let json: MastraStreamChunk;
44
- try {
45
- json = JSON.parse(data) as MastraStreamChunk;
46
- } catch {
47
- continue;
48
- }
49
- if (json) await options.onChunk(json);
45
+ const chunk = json.parse<MastraStreamChunk>(data);
46
+ if (chunk) await options.onChunk(chunk);
50
47
  }
51
48
  }
52
49
  } finally {