@dbx-tools/ui-mastra 0.1.9

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.
@@ -0,0 +1,342 @@
1
+ import {
2
+ Button,
3
+ DropdownMenu,
4
+ DropdownMenuCheckboxItem,
5
+ DropdownMenuContent,
6
+ DropdownMenuTrigger,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ cn,
14
+ } from "@dbx-tools/ui-appkit/react";
15
+ import { string } from "@dbx-tools/shared-core";
16
+ import {
17
+ flexRender,
18
+ getCoreRowModel,
19
+ getSortedRowModel,
20
+ useReactTable,
21
+ type ColumnDef,
22
+ type SortingState,
23
+ type VisibilityState,
24
+ } from "@tanstack/react-table";
25
+ import {
26
+ ArrowDownIcon,
27
+ ArrowUpIcon,
28
+ ChevronsUpDownIcon,
29
+ Columns3Icon,
30
+ DownloadIcon,
31
+ } from "lucide-react";
32
+ import React, { useMemo, useState } from "react";
33
+
34
+ // Interactive result table plus the cell/label/CSV helpers it and the
35
+ // markdown table renderer share. Built on `@tanstack/react-table` over
36
+ // AppKit's `Table` primitives so every table in the chat - statement
37
+ // results and lifted markdown tables alike - reads as the same block.
38
+
39
+ /** A statement row: column name -> cell value, as `StatementData.rows` arrives. */
40
+ export type DataRow = Record<string, unknown>;
41
+
42
+ /**
43
+ * Color-code numeric deltas like `+1.8%`, `-3.1%`, or `+0.6 pts` inside
44
+ * a single table cell. Matches the *first* signed numeric token in the
45
+ * cell; if no match, returns the children unchanged.
46
+ *
47
+ * Patterns recognized (case insensitive, allow comma/decimal):
48
+ * +1.8% -3.1% +0.6 pts -0.9 pts
49
+ */
50
+ const DELTA_PATTERN = /^([+\u2212-])\s*\d[\d,.\s]*(?:%|\s*pts?)?$/i;
51
+
52
+ export function colorizeDelta(content: React.ReactNode): React.ReactNode {
53
+ if (typeof content !== "string") return content;
54
+ const text = content.trim();
55
+ const match = DELTA_PATTERN.exec(text);
56
+ if (!match) return content;
57
+ const sign = match[1];
58
+ if (sign === "+") return <span className="font-medium text-success">{content}</span>;
59
+ if (sign === "-" || sign === "\u2212")
60
+ return <span className="font-medium text-destructive">{content}</span>;
61
+ return content;
62
+ }
63
+
64
+ /**
65
+ * Render a cell value: blank for nullish, otherwise the string form
66
+ * run through {@link colorizeDelta} so signed deltas keep their
67
+ * green/red treatment.
68
+ */
69
+ export function renderDataCell(value: unknown): React.ReactNode {
70
+ return colorizeDelta(value == null ? "" : String(value));
71
+ }
72
+
73
+ /**
74
+ * Turn a raw statement column name into a human-readable header by
75
+ * tokenizing it (camelCase / snake_case / kebab / etc. all split) and
76
+ * Title-Casing each token: `total_revenue` -> "Total Revenue",
77
+ * `aiScore` -> "AI Score" (the tokenizer special-cases `ai`). Falls
78
+ * back to the original string when tokenization yields nothing (e.g. a
79
+ * punctuation-only column name). The raw name is still used as the
80
+ * column id, accessor key, and CSV header, so only the on-screen label
81
+ * is prettified.
82
+ */
83
+ export function humanizeLabel(
84
+ value: string,
85
+ options?: string.TokenizeOptions,
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;
94
+ }
95
+
96
+ /**
97
+ * Serialize already-ordered `rows` to a CSV over `columns` and trigger
98
+ * a browser download. Fields are quoted only when they contain a
99
+ * comma, double-quote, or newline (RFC-4180 minimal quoting); embedded
100
+ * quotes are doubled. The blob URL is revoked right after the click so
101
+ * we don't leak object URLs across repeated exports.
102
+ */
103
+ function downloadCsv(columns: string[], rows: DataRow[], filename: string): void {
104
+ const escape = (value: unknown): string => {
105
+ const s = value == null ? "" : String(value);
106
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
107
+ };
108
+ const csv = [
109
+ columns.map(escape).join(","),
110
+ ...rows.map((row) => columns.map((c) => escape(row[c])).join(",")),
111
+ ].join("\n");
112
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" }));
113
+ const anchor = document.createElement("a");
114
+ anchor.href = url;
115
+ anchor.download = filename;
116
+ anchor.click();
117
+ URL.revokeObjectURL(url);
118
+ }
119
+
120
+ /**
121
+ * Wrapper class layered on every chat table (markdown + statement
122
+ * results). It frames the table as a distinct card so it reads as a
123
+ * separate block in the conversation. AppKit's `Table` family already
124
+ * owns row borders, hover, and color tokens - on top of that we add:
125
+ * - `not-prose` to escape `@tailwindcss/typography`'s table styles
126
+ * (margins, font-weight, etc.) which fight the AppKit defaults
127
+ * - a `rounded-lg` border + `bg-card` + `shadow-sm` card frame with
128
+ * `overflow-hidden` so the rounded corners clip the scroll area
129
+ * - compact `text-xs` + `tabular-nums` so columns of numbers align
130
+ * - right-alignment for every column except the first label column
131
+ * - a `max-h-[60vh]` vertical cap so tall tables scroll their body
132
+ * instead of running off the viewport, plus `overflow-auto` so wide
133
+ * tables scroll horizontally *inside* the card rather than pushing
134
+ * the chat past its max width. AppKit's `Table` nests the `<table>`
135
+ * in its own scroll container (`<div>` - the wrapper's only child),
136
+ * so the cap + overflow land there, making it the scroll ancestor
137
+ * the sticky header pins to.
138
+ * - a prominent, sticky header: tinted (`bg-muted`), bold, opaque
139
+ * (so rows don't bleed through while scrolling), and divided from
140
+ * the body with a bottom border.
141
+ */
142
+ export const TABLE_WRAPPER_CLASSES = cn(
143
+ // Card-like frame so each table reads as a distinct block in the
144
+ // chat rather than bleeding into the surrounding prose.
145
+ "not-prose my-4 max-w-full overflow-hidden rounded-lg border border-border bg-card shadow-sm",
146
+ "text-xs tabular-nums",
147
+ "[&_th:not(:first-child)]:text-right [&_td:not(:first-child)]:text-right",
148
+ // The inner AppKit scroll container is the scroll surface for both
149
+ // axes; cap its height so tall tables scroll their body in place.
150
+ "[&>div]:max-h-[60vh] [&>div]:overflow-auto",
151
+ // Make the header read as a header: opaque, tinted, bold, and pinned
152
+ // to the top of the scroll container with a divider beneath it.
153
+ "[&_thead_th]:sticky [&_thead_th]:top-0 [&_thead_th]:z-10",
154
+ "[&_thead_th]:bg-muted [&_thead_th]:font-semibold [&_thead_th]:text-foreground",
155
+ "[&_thead_th]:border-b [&_thead_th]:border-border",
156
+ );
157
+
158
+ /**
159
+ * Card frame for {@link DataGrid} - the same rounded/bordered/elevated
160
+ * treatment as {@link TABLE_WRAPPER_CLASSES} so interactive statement
161
+ * tables and static markdown tables read as the same kind of block.
162
+ */
163
+ const DATA_GRID_CARD_CLASSES = cn(
164
+ "not-prose my-4 max-w-full overflow-hidden rounded-lg border border-border bg-card shadow-sm",
165
+ "text-xs tabular-nums",
166
+ );
167
+
168
+ /** Toolbar strip across the top of a {@link DataGrid} card. */
169
+ const DATA_GRID_TOOLBAR_CLASSES = cn(
170
+ "flex items-center justify-between gap-2",
171
+ "border-b border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground",
172
+ );
173
+
174
+ /**
175
+ * Scroll surface wrapping the {@link DataGrid} table. AppKit's `Table`
176
+ * nests its `<table>` inside its own scroll container `<div>` (this
177
+ * wrapper's only child), so the height cap + `overflow` must land on
178
+ * THAT div (`[&>div]`) - not this wrapper - to make it the single
179
+ * scroll box. Otherwise the inner container becomes a nested scroll
180
+ * box the sticky header pins to, and the header rides up with the body
181
+ * as the outer wrapper scrolls. With the cap on the inner div the
182
+ * sticky header pins to it and stays put. Header cells are tinted,
183
+ * bold, and opaque so rows don't bleed through while scrolling.
184
+ */
185
+ const DATA_GRID_SCROLL_CLASSES = cn(
186
+ "[&>div]:max-h-[60vh] [&>div]:overflow-auto",
187
+ "[&_th:not(:first-child)]:text-right [&_td:not(:first-child)]:text-right",
188
+ "[&_thead_th]:sticky [&_thead_th]:top-0 [&_thead_th]:z-10",
189
+ "[&_thead_th]:bg-muted [&_thead_th]:font-semibold [&_thead_th]:text-foreground",
190
+ "[&_thead_th]:border-b [&_thead_th]:border-border",
191
+ );
192
+
193
+ /**
194
+ * Interactive table for a settled statement result, built on
195
+ * `@tanstack/react-table` over AppKit's `Table` primitives so it
196
+ * matches the rest of the chat. A toolbar in the card header carries
197
+ * the row count, a column show/hide menu, and a CSV export of the
198
+ * visible columns in the current sort order. Header cells are sort
199
+ * toggles (click to cycle asc -> desc -> none): the active column
200
+ * shows a direction arrow, idle columns a faded up/down glyph. All
201
+ * state is client-side - the rows arrive once from {@link DataSlot}.
202
+ */
203
+ export const DataGrid = ({
204
+ columns,
205
+ rows,
206
+ truncated,
207
+ rowCount,
208
+ humanizeHeaders = false,
209
+ }: {
210
+ columns: string[];
211
+ rows: DataRow[];
212
+ truncated: boolean;
213
+ rowCount: number;
214
+ /**
215
+ * Title-Case raw identifier column names for display (statement
216
+ * results). Off for markdown tables, whose headers are already
217
+ * human-authored and would be mangled by the tokenizer.
218
+ */
219
+ humanizeHeaders?: boolean;
220
+ }) => {
221
+ const [sorting, setSorting] = useState<SortingState>([]);
222
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
223
+
224
+ const columnDefs = useMemo<ColumnDef<DataRow>[]>(
225
+ () =>
226
+ columns.map((col): ColumnDef<DataRow> => ({
227
+ id: col,
228
+ accessorFn: (row) => row[col],
229
+ header: humanizeHeaders ? humanizeLabel(col) : col,
230
+ cell: (info) => renderDataCell(info.getValue()),
231
+ sortUndefined: "last",
232
+ })),
233
+ [columns, humanizeHeaders],
234
+ );
235
+
236
+ const table = useReactTable({
237
+ data: rows,
238
+ columns: columnDefs,
239
+ state: { sorting, columnVisibility },
240
+ onSortingChange: setSorting,
241
+ onColumnVisibilityChange: setColumnVisibility,
242
+ getCoreRowModel: getCoreRowModel(),
243
+ getSortedRowModel: getSortedRowModel(),
244
+ });
245
+
246
+ const exportCsv = () =>
247
+ downloadCsv(
248
+ table.getVisibleLeafColumns().map((c) => c.id),
249
+ table.getSortedRowModel().rows.map((r) => r.original),
250
+ "statement.csv",
251
+ );
252
+
253
+ return (
254
+ <div className={DATA_GRID_CARD_CLASSES}>
255
+ <div className={DATA_GRID_TOOLBAR_CLASSES}>
256
+ <span>
257
+ {truncated
258
+ ? `Showing ${rows.length} of ${rowCount} rows`
259
+ : `${rows.length} ${rows.length === 1 ? "row" : "rows"}`}
260
+ </span>
261
+ <div className="flex items-center gap-1">
262
+ <DropdownMenu>
263
+ <DropdownMenuTrigger asChild>
264
+ <Button variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs">
265
+ <Columns3Icon className="size-3.5" />
266
+ Columns
267
+ </Button>
268
+ </DropdownMenuTrigger>
269
+ <DropdownMenuContent align="end" className="max-h-72 overflow-y-auto">
270
+ {table.getAllLeafColumns().map((column) => (
271
+ <DropdownMenuCheckboxItem
272
+ key={column.id}
273
+ className="text-xs"
274
+ checked={column.getIsVisible()}
275
+ // Keep the menu open while toggling several columns.
276
+ onSelect={(e) => e.preventDefault()}
277
+ onCheckedChange={(value) => column.toggleVisibility(!!value)}
278
+ >
279
+ {String(column.columnDef.header)}
280
+ </DropdownMenuCheckboxItem>
281
+ ))}
282
+ </DropdownMenuContent>
283
+ </DropdownMenu>
284
+ <Button
285
+ variant="ghost"
286
+ size="sm"
287
+ className="h-7 gap-1 px-2 text-xs"
288
+ onClick={exportCsv}
289
+ >
290
+ <DownloadIcon className="size-3.5" />
291
+ Export
292
+ </Button>
293
+ </div>
294
+ </div>
295
+ <div className={DATA_GRID_SCROLL_CLASSES}>
296
+ <Table>
297
+ <TableHeader>
298
+ {table.getHeaderGroups().map((group) => (
299
+ <TableRow key={group.id}>
300
+ {group.headers.map((header) => {
301
+ const sorted = header.column.getIsSorted();
302
+ return (
303
+ <TableHead key={header.id}>
304
+ <button
305
+ type="button"
306
+ className="inline-flex items-center gap-1 hover:text-foreground"
307
+ onClick={header.column.getToggleSortingHandler()}
308
+ >
309
+ {flexRender(
310
+ header.column.columnDef.header,
311
+ header.getContext(),
312
+ )}
313
+ {sorted === "asc" ? (
314
+ <ArrowUpIcon className="size-3" />
315
+ ) : sorted === "desc" ? (
316
+ <ArrowDownIcon className="size-3" />
317
+ ) : (
318
+ <ChevronsUpDownIcon className="size-3 opacity-40" />
319
+ )}
320
+ </button>
321
+ </TableHead>
322
+ );
323
+ })}
324
+ </TableRow>
325
+ ))}
326
+ </TableHeader>
327
+ <TableBody>
328
+ {table.getRowModel().rows.map((row) => (
329
+ <TableRow key={row.id}>
330
+ {row.getVisibleCells().map((cell) => (
331
+ <TableCell key={cell.id}>
332
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
333
+ </TableCell>
334
+ ))}
335
+ </TableRow>
336
+ ))}
337
+ </TableBody>
338
+ </Table>
339
+ </div>
340
+ </div>
341
+ );
342
+ };
@@ -0,0 +1,258 @@
1
+ import { Spinner } from "@dbx-tools/ui-appkit/react";
2
+ import {
3
+ marker as markers,
4
+ type ParsedMarker,
5
+ } from "@dbx-tools/shared-mastra";
6
+ import ReactECharts from "echarts-for-react";
7
+ import { ClockIcon } from "lucide-react";
8
+ import { useMemo } from "react";
9
+ import { normalizeChartOption } from "../support/chart-option";
10
+ import { useChartFetch, useStatementFetch } from "../support/mastra-client";
11
+ import { DataGrid, humanizeLabel } from "./data-grid";
12
+ import { AssistantMarkdown } from "./markdown";
13
+
14
+ // Inline embed slots: chart / data tables resolved from `[chart:<id>]`
15
+ // and `[data:<id>]` markers in the assistant's prose, plus the splitter
16
+ // that interleaves those slots with the surrounding markdown.
17
+
18
+ /**
19
+ * Frame shared by every chart-slot state so the layout stays
20
+ * stable as a slot transitions from queueing → rendering →
21
+ * rendered (or → render-failed). Width tracks the bubble; height
22
+ * is fixed so Echarts has a deterministic canvas regardless of
23
+ * the parent's flex layout. `not-prose` opts out of Tailwind
24
+ * Typography.
25
+ */
26
+ const CHART_FRAME_CLASSES =
27
+ "not-prose my-3 max-w-full rounded border border-border/60 bg-background/40 p-2";
28
+ const CHART_HEIGHT_PX = 320;
29
+
30
+ /**
31
+ * Compact inline notice rendered in place of an embed - a chart, data
32
+ * table, or any future `[<type>:<id>]` marker - whose backing cache
33
+ * entry 404'd, i.e. the embed id expired (TTL elapsed) or was never
34
+ * minted. The displayed noun is the marker `type` run through
35
+ * {@link humanizeLabel}, keeping this slot agnostic to which kinds
36
+ * exist. Deliberately small so an expired artifact in a long
37
+ * transcript reads as a quiet footnote rather than a broken,
38
+ * full-height frame. Only the genuine "settled 404" case reaches here;
39
+ * in-flight fetches and hard errors still render nothing (see
40
+ * {@link ChartSlot} / {@link DataSlot}).
41
+ */
42
+ const ExpiredSlot = ({ type }: { type: string }) => (
43
+ <div className="not-prose my-3 inline-flex items-center gap-2 rounded-md border border-dashed border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground">
44
+ <ClockIcon className="size-3.5 shrink-0" />
45
+ <span>
46
+ This {humanizeLabel(type, { capitalize: false })} has expired and is no longer
47
+ available.
48
+ </span>
49
+ </div>
50
+ );
51
+
52
+ /**
53
+ * Inline chart slot. Each `[chart:<chartId>]` marker in the
54
+ * assistant's reply resolves to one of these. The Mastra plugin
55
+ * caches the resolved Echarts
56
+ * spec under the chartId; this slot fetches it via the generic
57
+ * `embedPathTemplate` (`/embed/chart/:id`, long-poll until ready /
58
+ * error / 404).
59
+ *
60
+ * Render contract:
61
+ *
62
+ * - Cache entry settled with `result` -> render the full
63
+ * Echarts spec.
64
+ * - Fetch / long-poll in flight -> render the chart frame at its
65
+ * known fixed footprint (`CHART_HEIGHT_PX`) with a centered
66
+ * spinner, so the slot reserves the chart's eventual space and
67
+ * the prose below doesn't reflow when it lands.
68
+ * - 404 (unknown / TTL-expired id) -> render a small
69
+ * {@link ExpiredSlot} notice so the reader knows the chart
70
+ * used to be here but has aged out, instead of a silent gap.
71
+ * - Settled with `error`, or a non-terminal payload that has
72
+ * neither `result` nor `error` -> render NOTHING. Genuine
73
+ * planner failures are silently dropped rather than left as
74
+ * placeholder frames. The Echarts `option` already carries its
75
+ * own `title.text`, so no separate header is needed above the
76
+ * chart frame.
77
+ */
78
+ const ChartSlot = ({ chartId }: { chartId: string }) => {
79
+ const { data: chart, loading, error } = useChartFetch(chartId);
80
+ // Patch presentation (compact ticks, axis-name placement, legible
81
+ // category labels) into the JSON-safe planner spec before rendering,
82
+ // matching the export path. Memoized on the resolved option identity.
83
+ const option = useMemo(
84
+ () => (chart?.result ? normalizeChartOption(chart.result.option) : undefined),
85
+ [chart?.result],
86
+ );
87
+ if (option) {
88
+ return (
89
+ <div className={CHART_FRAME_CLASSES}>
90
+ <ReactECharts
91
+ option={option}
92
+ style={{ height: CHART_HEIGHT_PX, width: "100%" }}
93
+ notMerge
94
+ lazyUpdate
95
+ />
96
+ </div>
97
+ );
98
+ }
99
+ // In-flight fetch: the chart's footprint is known ahead of time, so
100
+ // reserve the same frame + height with a spinner instead of
101
+ // collapsing - the chart fades in without shifting the prose below.
102
+ if (loading) {
103
+ return (
104
+ <div className={CHART_FRAME_CLASSES}>
105
+ <div
106
+ className="flex items-center justify-center"
107
+ style={{ height: CHART_HEIGHT_PX, width: "100%" }}
108
+ >
109
+ <Spinner className="size-5 text-muted-foreground" />
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+ // Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
115
+ // Hard-error / non-terminal payloads render nothing.
116
+ if (!error && chart === undefined) return <ExpiredSlot type="chart" />;
117
+ return null;
118
+ };
119
+
120
+ /**
121
+ * Inline data-table slot. Each `[data:<statement_id>]` marker in
122
+ * the assistant's reply resolves to one of these. A single
123
+ * OBO-scoped fetch against `embedPathTemplate` (`/embed/data/:id`)
124
+ * returns the columns + rows; the slot hands them to {@link DataGrid}
125
+ * for an interactive (sortable, column-toggle, CSV-export) table.
126
+ *
127
+ * Render contract (matches {@link ChartSlot}):
128
+ *
129
+ * - 404 (unknown / TTL-expired id) -> render a small
130
+ * {@link ExpiredSlot} notice so the reader knows a table aged
131
+ * out rather than seeing a silent gap.
132
+ * - Fetch in flight, hard error, or empty rows -> render
133
+ * NOTHING. Stale markers in persisted transcripts stay quiet
134
+ * so a reload doesn't leave dead frames.
135
+ * - Data settled with rows -> render the {@link DataGrid}, whose
136
+ * toolbar surfaces the "showing N of M rows" affordance when the
137
+ * server-side cap truncated the result set.
138
+ */
139
+ const DataSlot = ({ statementId }: { statementId: string }) => {
140
+ const { data, loading, error } = useStatementFetch(statementId);
141
+ // Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
142
+ // In-flight fetches, hard errors, and empty result sets render
143
+ // nothing so stale markers in reloaded transcripts stay quiet.
144
+ if (!loading && !error && data === undefined) return <ExpiredSlot type="data" />;
145
+ if (!data || data.rows.length === 0) return null;
146
+ return (
147
+ <DataGrid
148
+ columns={data.columns}
149
+ rows={data.rows}
150
+ truncated={data.truncated}
151
+ rowCount={data.rowCount}
152
+ humanizeHeaders
153
+ />
154
+ );
155
+ };
156
+
157
+ /** One slice of an assistant message: prose, a chart slot, or a data slot. */
158
+ type RenderSegment =
159
+ | { kind: "text"; text: string }
160
+ | { kind: "chart"; chartId: string }
161
+ | { kind: "data"; statementId: string };
162
+
163
+ /**
164
+ * Map one parsed marker onto its render segment. The marker grammar
165
+ * matches ANY `[<type>:<id>]`, including fabricated ids the model
166
+ * glued together from a label (e.g. `[chart:placeholder]`), so the id
167
+ * is validated with {@link isUuid} before it's treated as a real
168
+ * embed. A non-UUID id - or a UUID with a type this UI can't render -
169
+ * collapses to an empty text segment: the marker is consumed so no
170
+ * literal `[<type>:...]` leaks into the prose, but no slot renders and
171
+ * no `/embed/<type>/:id` request fires. Only a UUID-shaped id with a
172
+ * known, renderable type (charts need ECharts, data needs a Table)
173
+ * resolves to a slot.
174
+ */
175
+ const markerSegment = (marker: ParsedMarker): RenderSegment => {
176
+ if (!markers.isUuid(marker.id)) return { kind: "text", text: "" };
177
+ switch (marker.type) {
178
+ case "chart":
179
+ return { kind: "chart", chartId: marker.id };
180
+ case "data":
181
+ return { kind: "data", statementId: marker.id };
182
+ default:
183
+ return { kind: "text", text: "" };
184
+ }
185
+ };
186
+
187
+ const splitTextWithEmbeds = (text: string): RenderSegment[] => {
188
+ const segments: RenderSegment[] = [];
189
+ let lastIdx = 0;
190
+ // `parseMarkers` yields hits in source order with no overlaps (one
191
+ // regex pass), so the spans splice in directly - no sort or
192
+ // overlap guard needed.
193
+ for (const marker of markers.parseMarkers(text)) {
194
+ if (marker.start > lastIdx) {
195
+ segments.push({ kind: "text", text: text.slice(lastIdx, marker.start) });
196
+ }
197
+ segments.push(markerSegment(marker));
198
+ lastIdx = marker.end;
199
+ }
200
+ if (lastIdx < text.length) {
201
+ segments.push({ kind: "text", text: text.slice(lastIdx) });
202
+ }
203
+ return segments;
204
+ };
205
+
206
+ /**
207
+ * Render the assistant's markdown with chart and data tables
208
+ * placed at their inline marker positions. Each prose segment
209
+ * is its own {@link AssistantMarkdown} so streaming chunks still
210
+ * incrementally parse correctly; embed slots break the markdown
211
+ * flow with full-width block elements.
212
+ *
213
+ * Each marker resolves through its own slot:
214
+ *
215
+ * - `[chart:<id>]` -> {@link ChartSlot} long-polls the
216
+ * server-side chart cache and renders the Echarts spec
217
+ * inline once ready (or nothing on miss / TTL-expired).
218
+ * - `[data:<statement_id>]` -> {@link DataSlot} fetches the
219
+ * statement rows OBO-scoped and renders an inline Table
220
+ * (or nothing on 404 / empty result).
221
+ *
222
+ * Stale markers in persisted transcript text are silently
223
+ * dropped on reload so the prose around them stays clean.
224
+ *
225
+ * `streaming` flags the bubble as still receiving tokens; it opts the
226
+ * prose segments into Streamdown's word-by-word fade-in so the reply
227
+ * eases in rather than snapping in whole chunks. Settled bubbles pass
228
+ * `false` and render plain markdown.
229
+ */
230
+ export const MarkdownWithEmbeds = ({
231
+ text,
232
+ streaming = false,
233
+ }: {
234
+ text: string;
235
+ streaming?: boolean;
236
+ }) => {
237
+ const segments = splitTextWithEmbeds(markers.stripIncompleteMarkerTail(text));
238
+ return (
239
+ <>
240
+ {segments.map((seg, i) => {
241
+ if (seg.kind === "text") {
242
+ if (seg.text.trim().length === 0) return null;
243
+ return (
244
+ <AssistantMarkdown key={`t-${i}`} animate={streaming}>
245
+ {seg.text}
246
+ </AssistantMarkdown>
247
+ );
248
+ }
249
+ if (seg.kind === "chart") {
250
+ return <ChartSlot key={`c-${i}-${seg.chartId}`} chartId={seg.chartId} />;
251
+ }
252
+ return (
253
+ <DataSlot key={`d-${i}-${seg.statementId}`} statementId={seg.statementId} />
254
+ );
255
+ })}
256
+ </>
257
+ );
258
+ };
@@ -0,0 +1,71 @@
1
+ import {
2
+ Button,
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuTrigger,
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipTrigger,
10
+ } from "@dbx-tools/ui-appkit/react";
11
+ import { DownloadIcon } from "lucide-react";
12
+ import type { ExportFormat } from "../support/export";
13
+
14
+ // Shared export affordance: a download button that opens a small menu of
15
+ // output formats. Reused for both the whole-conversation export (header,
16
+ // labelled) and per-message export (bubble action row, icon-only).
17
+
18
+ /** Menu entries, in display order. */
19
+ const FORMATS: ReadonlyArray<{ format: ExportFormat; label: string }> = [
20
+ { format: "pdf", label: "PDF" },
21
+ { format: "markdown", label: "Markdown" },
22
+ ];
23
+
24
+ /**
25
+ * Export dropdown. Fires {@link onExport} with the chosen
26
+ * {@link ExportFormat}. `iconOnly` renders a compact icon trigger (used
27
+ * inside message bubbles) with the label surfaced as a tooltip; the
28
+ * default renders an icon + "Export" text button (used in the header).
29
+ */
30
+ export const ExportMenu = ({
31
+ onExport,
32
+ iconOnly = false,
33
+ tooltip = "Export",
34
+ }: {
35
+ onExport: (format: ExportFormat) => void;
36
+ iconOnly?: boolean;
37
+ tooltip?: string;
38
+ }) => {
39
+ const trigger = iconOnly ? (
40
+ <Button type="button" size="icon" variant="ghost" className="size-7">
41
+ <DownloadIcon className="size-3" />
42
+ </Button>
43
+ ) : (
44
+ <Button type="button" size="sm" variant="outline" className="gap-1.5">
45
+ <DownloadIcon className="size-3" />
46
+ Export
47
+ </Button>
48
+ );
49
+
50
+ return (
51
+ <DropdownMenu>
52
+ {iconOnly ? (
53
+ <Tooltip>
54
+ <TooltipTrigger asChild>
55
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
56
+ </TooltipTrigger>
57
+ <TooltipContent>{tooltip}</TooltipContent>
58
+ </Tooltip>
59
+ ) : (
60
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
61
+ )}
62
+ <DropdownMenuContent align="end">
63
+ {FORMATS.map(({ format, label }) => (
64
+ <DropdownMenuItem key={format} onClick={() => onExport(format)}>
65
+ {label}
66
+ </DropdownMenuItem>
67
+ ))}
68
+ </DropdownMenuContent>
69
+ </DropdownMenu>
70
+ );
71
+ };