@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,295 @@
1
+ import type { GenieWriterEvent } from "@dbx-tools/shared-mastra";
2
+ import type { UIMessage } from "ai";
3
+ import type { ExportFormat } from "../support/export";
4
+
5
+ export type { ExportFormat } from "../support/export";
6
+
7
+ // Public types for the chat UI: the controlled `ChatView` props plus
8
+ // the supporting tool-event / approval shapes a host transport feeds
9
+ // it. Kept dependency-free of the components so both the presentational
10
+ // layer and the `useMastraChat` driver can share them.
11
+
12
+ export type ChatStatus = "submitted" | "streaming" | "ready" | "error";
13
+
14
+ /**
15
+ * Lifecycle of a single tool invocation surfaced inline in the assistant
16
+ * bubble. `running` while we wait for the backend, `done` on `tool-result`,
17
+ * `error` on `tool-error`. `progress` is an in-order log of mid-flight
18
+ * events the tool itself pushed through Mastra's `ctx.writer`.
19
+ */
20
+ export type ToolEvent = {
21
+ id: string;
22
+ toolName: string;
23
+ status: "running" | "done" | "error";
24
+ progress?: ToolProgress[];
25
+ };
26
+
27
+ /**
28
+ * Normalised progress event shape. Aliases {@link GenieWriterEvent}
29
+ * from `@dbx-tools/appkit-mastra-shared` so the Genie agent
30
+ * (server) and the chat UI (client) stay in lock-step on the
31
+ * unified flat `{type, ...}` events the `tool-output` chunks
32
+ * carry. New variants should be added there.
33
+ */
34
+ export type ToolProgress = GenieWriterEvent;
35
+
36
+ /** Subset of a Model Serving endpoint surfaced in the model picker. */
37
+ export type ChatModelOption = { name: string };
38
+
39
+ /** Thumbs reaction a user can leave on an assistant turn. */
40
+ export type FeedbackValue = "up" | "down";
41
+
42
+ /**
43
+ * One feedback action from the user: a thumbs `value`, a freeform
44
+ * `comment`, or both. Emitted by {@link ChatViewProps.onFeedback}.
45
+ */
46
+ export type FeedbackSubmission = {
47
+ value?: FeedbackValue;
48
+ comment?: string;
49
+ };
50
+
51
+ /**
52
+ * Feedback state for a single assistant message. `traceId` is the
53
+ * MLflow trace the feedback attaches to (captured from the stream
54
+ * response); its presence is what makes a bubble eligible for feedback
55
+ * controls. `value` mirrors the last thumbs the user chose so the
56
+ * active thumb stays highlighted.
57
+ */
58
+ export type MessageFeedback = {
59
+ traceId: string;
60
+ value?: FeedbackValue;
61
+ };
62
+
63
+ /**
64
+ * One conversation thread surfaced in the {@link ChatViewProps}
65
+ * sidebar. A trimmed view of the wire `MastraThread`: only what the
66
+ * list needs to render and reference a conversation.
67
+ */
68
+ export type ThreadSummary = {
69
+ /** Thread id. Passed back out via {@link ChatViewProps.onSelectThread}. */
70
+ id: string;
71
+ /** Human-readable title; falls back to a placeholder when absent (new threads). */
72
+ title?: string;
73
+ /** ISO-8601 last-activity timestamp, used to render a relative "time ago" hint. */
74
+ updatedAt?: string;
75
+ };
76
+
77
+ export type ChatViewProps = {
78
+ messages: UIMessage[];
79
+ status: ChatStatus;
80
+ /**
81
+ * The error from the last failed turn, surfaced as a destructive
82
+ * Alert when `status` is `"error"`. When omitted, the Alert shows a
83
+ * generic message. `useMastraChat` populates it on the drop-in path;
84
+ * a host driving `ChatView` itself supplies its own.
85
+ */
86
+ error?: Error | null;
87
+ sendMessage: (message: { text: string }) => void;
88
+ regenerate?: () => void;
89
+ /**
90
+ * Abort the in-flight response. When provided and the chat is running
91
+ * (`status` is `"submitted"` or `"streaming"`), the composer swaps the
92
+ * Send button for a Stop button that calls this. The handler should
93
+ * cancel the active generation and return the chat to `"ready"`. Omit
94
+ * to hide the Stop affordance (the Send button just disables while a
95
+ * response streams).
96
+ */
97
+ onStop?: () => void;
98
+ /** Extra classes merged onto the root layout container. */
99
+ className?: string;
100
+ /**
101
+ * Starter questions shown as one-tap buttons on the empty state.
102
+ * Defaults to none - nothing renders when omitted or empty, so the
103
+ * component carries no built-in example prompts. The drop-in
104
+ * `MastraChat` fills this from the agent's Genie space sample
105
+ * questions when the caller doesn't pass an explicit list.
106
+ */
107
+ suggestions?: string[];
108
+ toolEventsByMessage?: Record<string, ToolEvent[]>;
109
+ /** Available model endpoints. Pass an empty array (or omit) to hide the picker. */
110
+ models?: ChatModelOption[];
111
+ /** Currently selected model name; empty string means "use server default". */
112
+ model?: string;
113
+ onModelChange?: (model: string) => void;
114
+ /**
115
+ * Optional infinite-scroll-up handler. Fired when the user scrolls
116
+ * within `TOP_LOAD_MORE_THRESHOLD_PX` of the top of the
117
+ * transcript. The parent is expected to fetch the next older page
118
+ * and prepend it to `messages`; the view preserves the visual
119
+ * scroll position across the prepend so the reveal feels like
120
+ * paging up through history rather than a layout jump.
121
+ */
122
+ onLoadMore?: () => void;
123
+ /** True while a {@link ChatViewProps.onLoadMore} fetch is in flight. */
124
+ isLoadingMore?: boolean;
125
+ /** True when more history is still available (drives the trigger). */
126
+ hasMore?: boolean;
127
+ /** True while the *initial* history page is loading. */
128
+ isLoadingHistory?: boolean;
129
+ /**
130
+ * Resolve an approval-gated tool call. Fired when the user clicks
131
+ * Approve or Deny on the inline approval card. The handler must
132
+ * resume the suspended Mastra workflow on its own: with
133
+ * `mastraClient.getAgent(...).stream()`, call
134
+ * `agent.approveToolCall({ runId, toolCallId })` /
135
+ * `agent.declineToolCall({ runId, toolCallId })` to get a fresh
136
+ * stream Response and pipe it through the same chunk handler
137
+ * (this is exactly what `useMastraChat` does).
138
+ *
139
+ * It requires the `runId` Mastra emitted with the approval
140
+ * chunk - the field is always populated when the card was rendered
141
+ * from a live `data-tool-call-approval` part or an out-of-band
142
+ * `pendingApprovalsByMessage` entry. It will be missing only for
143
+ * approvals reconstructed from history (where the original runId
144
+ * is lost), in which case the handler should surface a "this
145
+ * approval is stale, please re-ask the model" message rather than
146
+ * trying to resume a workflow that no longer exists.
147
+ */
148
+ onResolveToolApproval?: (args: ApprovalDecision) => void | Promise<void>;
149
+ /**
150
+ * Out-of-band approval requests keyed by assistant message id, for
151
+ * transports that don't surface approvals as `UIMessage` parts.
152
+ * The `/stream` page populates this from Mastra's
153
+ * `tool-call-approval` chunk so the same `ToolApprovalCard`
154
+ * UI works without injecting synthetic data parts. Each entry is
155
+ * merged with any approvals already discovered in `message.parts`.
156
+ */
157
+ pendingApprovalsByMessage?: Record<string, PendingApproval[]>;
158
+ /**
159
+ * Wipe the current chat thread. When provided, the header renders
160
+ * a "Clear" button that calls this and shows a confirmation
161
+ * prompt first. The handler is responsible for both the
162
+ * server-side delete (typically `mastraClient.clearHistory()`) and
163
+ * resetting client-side transcript / tool-event state so the
164
+ * blank slate sticks across the next render. Omit to hide the
165
+ * button entirely (read-only embeds, history-less agents).
166
+ */
167
+ onClear?: () => void | Promise<void>;
168
+ /**
169
+ * The caller's conversation threads, newest first. When provided
170
+ * together with {@link onSelectThread}, the view renders a
171
+ * collapsible sidebar listing them so the user can switch between
172
+ * conversations. Omit (or pass without `onSelectThread`) to render
173
+ * the classic single-thread chat with no sidebar.
174
+ */
175
+ threads?: ThreadSummary[];
176
+ /** Id of the currently-active thread, highlighted in the sidebar. */
177
+ activeThreadId?: string;
178
+ /**
179
+ * Thread ids with an in-flight generation (`submitted` or `streaming`).
180
+ * Drives a per-row streaming indicator in the sidebar while a turn
181
+ * continues after the user switches away.
182
+ */
183
+ streamingThreadIds?: string[];
184
+ /** True while the initial thread list is loading (drives a sidebar spinner). */
185
+ isLoadingThreads?: boolean;
186
+ /**
187
+ * Switch the conversation to `threadId`. The handler reloads that
188
+ * thread's history and points subsequent turns at it. Providing this
189
+ * (with {@link threads}) is what turns the sidebar on.
190
+ */
191
+ onSelectThread?: (threadId: string) => void;
192
+ /**
193
+ * Start a fresh conversation. The handler mints a new thread id,
194
+ * clears the transcript, and points subsequent turns at it. When
195
+ * provided, the sidebar shows a "New chat" affordance.
196
+ */
197
+ onNewThread?: () => void;
198
+ /**
199
+ * Delete a conversation by id. The handler removes it server-side and
200
+ * refreshes the list; deleting the active thread starts a new one.
201
+ * When provided, each sidebar row shows a delete affordance.
202
+ */
203
+ onDeleteThread?: (threadId: string) => void;
204
+ /**
205
+ * Rename a conversation by id. The handler persists the new title
206
+ * server-side and updates the list (optimistically, so the new name
207
+ * shows immediately). When provided, each sidebar row shows a rename
208
+ * affordance that swaps the title into an inline text field.
209
+ */
210
+ onRenameThread?: (threadId: string, title: string) => void;
211
+ /**
212
+ * Controlled open/closed state for the conversation sidebar. When
213
+ * omitted the view manages its own (session-only) open state; pass
214
+ * this together with {@link onToggleSidebar} to control and persist
215
+ * the show/hide choice from the host (the driver does this so the
216
+ * choice survives reloads). The header toggle is shown whenever the
217
+ * sidebar is enabled, regardless of who owns the state.
218
+ */
219
+ sidebarOpen?: boolean;
220
+ /**
221
+ * Toggle the conversation sidebar's visibility. Provided alongside
222
+ * {@link sidebarOpen} for controlled mode; when omitted the view
223
+ * flips its own internal open state.
224
+ */
225
+ onToggleSidebar?: () => void;
226
+ /**
227
+ * Export the whole conversation in the chosen {@link ExportFormat}
228
+ * (PDF via the browser print dialog, or a Markdown download). When
229
+ * provided, the header shows an Export menu. Charts and data tables are
230
+ * inlined into the export. Omit to hide conversation-level export.
231
+ */
232
+ onExportConversation?: (format: ExportFormat) => void | Promise<void>;
233
+ /**
234
+ * Export a single message in the chosen {@link ExportFormat}. When
235
+ * provided, each assistant bubble shows a per-message export menu.
236
+ */
237
+ onExportMessage?: (message: UIMessage, format: ExportFormat) => void | Promise<void>;
238
+ /**
239
+ * Feedback state keyed by assistant message id. Only messages with an
240
+ * entry (i.e. a captured MLflow trace id) show feedback controls, so
241
+ * this doubles as the "is feedback available for this message" gate.
242
+ * `useMastraChat` populates it from each streamed turn's trace-id
243
+ * response header when MLflow logging is enabled.
244
+ */
245
+ feedbackByMessage?: Record<string, MessageFeedback>;
246
+ /**
247
+ * Submit thumbs / comment feedback for an assistant message. When
248
+ * provided (and the message has a {@link feedbackByMessage} entry),
249
+ * the bubble shows thumbs up/down plus a comment affordance. The
250
+ * handler logs the feedback to MLflow via the plugin's feedback route.
251
+ */
252
+ onFeedback?: (
253
+ message: UIMessage,
254
+ submission: FeedbackSubmission,
255
+ ) => void | Promise<void>;
256
+ };
257
+
258
+ /** Payload {@link ChatViewProps.onResolveToolApproval} receives. */
259
+ export type ApprovalDecision =
260
+ | {
261
+ approved: true;
262
+ toolName: string;
263
+ toolCallId: string;
264
+ /**
265
+ * Mastra run id from the approval chunk. Required to resume
266
+ * the suspended workflow; absent only when the card was
267
+ * reconstructed from history (no live runId available).
268
+ */
269
+ runId?: string;
270
+ input: unknown;
271
+ }
272
+ | {
273
+ approved: false;
274
+ toolName: string;
275
+ toolCallId: string;
276
+ runId?: string;
277
+ input: unknown;
278
+ reason: string;
279
+ };
280
+
281
+ /**
282
+ * One approval-gated tool call paused mid-turn. `runId` is the
283
+ * Mastra workflow id needed to resume; it's always present when the
284
+ * card was constructed from a live source (the stream's
285
+ * `data-tool-call-approval` part or `pendingApprovalsByMessage`),
286
+ * and absent only for approvals reconstructed from a history load
287
+ * where the original runId is lost.
288
+ */
289
+ export type PendingApproval = {
290
+ toolName: string;
291
+ toolCallId: string;
292
+ /** Mastra run id from the approval chunk. */
293
+ runId?: string;
294
+ input: unknown;
295
+ };
package/src/styles.css ADDED
@@ -0,0 +1,22 @@
1
+ /*
2
+ * @dbx-tools/ui-mastra stylesheet.
3
+ *
4
+ * Import once from the host app's Tailwind entry, after Tailwind and
5
+ * your AppKit-UI theme:
6
+ *
7
+ * @import "tailwindcss";
8
+ * @import "@databricks/appkit-ui/styles.css";
9
+ * @import "@dbx-tools/ui-mastra/styles.css";
10
+ *
11
+ * This file deliberately defines NO core design tokens (`--background`,
12
+ * `--primary`, `--success`, ...). The chat UI styles exclusively with
13
+ * AppKit semantic tokens, so it inherits whatever theme the host app
14
+ * provides and restyles to match the surrounding brand for free.
15
+ *
16
+ * Pulls shared Streamdown / shiki styling from `@dbx-tools/ui-appkit` and
17
+ * registers this package's React components with Tailwind via `@source`.
18
+ */
19
+
20
+ @import "@dbx-tools/ui-appkit/styles.css";
21
+
22
+ @source "./react";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Presentation normalizer for planner-produced Echarts specs.
3
+ *
4
+ * The chart planner (`@dbx-tools/appkit-mastra`) emits a JSON-safe
5
+ * `EChartsOption` that travels through the cache and over the wire, so
6
+ * it can't carry function-valued formatters or make width-dependent
7
+ * layout choices. This module patches those presentation concerns back
8
+ * in at render time - identically for the live inline chart
9
+ * (`embed-slots`) and the print/PDF export (`export.ts`) - so both read
10
+ * the same way:
11
+ *
12
+ * - large value-axis ticks render compact (`800M`, not `800,000,000`);
13
+ * - value/category axis names sit in conventional positions (rotated
14
+ * on the left for `y`, centered below for `x`) instead of floating at
15
+ * the axis ends where they collide with the centered title;
16
+ * - category labels stay legible (shown, rotated, de-overlapped) rather
17
+ * than silently decimated when many bars share a narrow canvas;
18
+ * - the title and grid leave room for one another.
19
+ *
20
+ * It only fills gaps: any field the spec already sets (an explicit
21
+ * `axisLabel.formatter`, `nameLocation`, etc.) is preserved.
22
+ */
23
+
24
+ /** Compact SI formatter shared across every value-axis tick. */
25
+ const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
26
+ notation: "compact",
27
+ maximumFractionDigits: 1,
28
+ });
29
+
30
+ /**
31
+ * Format a value-axis tick compactly: `1200 -> "1.2K"`,
32
+ * `800000000 -> "800M"`. Values below 1000 (and non-finite ones) render
33
+ * verbatim so small-scale axes and category-like values are untouched.
34
+ */
35
+ function compactAxisLabel(value: number): string {
36
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value);
37
+ return Math.abs(value) < 1000 ? String(value) : COMPACT_NUMBER.format(value);
38
+ }
39
+
40
+ /** A permissive record view of an option node we patch field-by-field. */
41
+ type Obj = Record<string, unknown>;
42
+
43
+ const isObj = (v: unknown): v is Obj =>
44
+ typeof v === "object" && v !== null && !Array.isArray(v);
45
+
46
+ /** True when `title` (object or array) carries any non-empty `text`. */
47
+ function hasTitleText(title: unknown): boolean {
48
+ const entries = Array.isArray(title) ? title : [title];
49
+ return entries.some(
50
+ (t) => isObj(t) && typeof t.text === "string" && t.text.trim().length > 0,
51
+ );
52
+ }
53
+
54
+ /** Pin a title to the top-center so it clears the plot / axis names. */
55
+ function normalizeTitle(title: unknown): unknown {
56
+ if (Array.isArray(title)) return title.map(normalizeTitle);
57
+ if (!isObj(title)) return title;
58
+ return { left: "center", top: 8, ...title };
59
+ }
60
+
61
+ /**
62
+ * Ensure the grid leaves room for a top title and for rotated category
63
+ * labels + a centered x-axis name below. `containLabel` keeps the tick
64
+ * labels themselves inside the box; the explicit margins reserve space
65
+ * for the title (top) and the axis name (bottom) which `containLabel`
66
+ * does not account for.
67
+ */
68
+ function normalizeGrid(grid: unknown, opts: { hasTitle: boolean }): unknown {
69
+ const base = isObj(grid) ? grid : {};
70
+ return {
71
+ left: 12,
72
+ right: 24,
73
+ bottom: 24,
74
+ ...base,
75
+ top: base.top ?? (opts.hasTitle ? 64 : 32),
76
+ containLabel: base.containLabel ?? true,
77
+ };
78
+ }
79
+
80
+ /** Patch a single axis node in place-safe fashion (`x` or `y`). */
81
+ function normalizeAxis(axis: Obj, pos: "x" | "y"): Obj {
82
+ const next: Obj = { ...axis };
83
+ const existingLabel = isObj(next.axisLabel) ? next.axisLabel : {};
84
+
85
+ if (next.type === "value") {
86
+ // Compact big-number ticks unless the spec pinned its own formatter.
87
+ next.axisLabel = { formatter: compactAxisLabel, ...existingLabel };
88
+ } else if (next.type === "category") {
89
+ // Show every category, rotated and de-overlapped, rather than
90
+ // letting Echarts drop labels on a crowded axis.
91
+ next.axisLabel = { interval: 0, rotate: 30, hideOverlap: true, ...existingLabel };
92
+ }
93
+
94
+ // Move a set axis name to a conventional spot so it never collides
95
+ // with the centered title (y) or floats past the last tick (x).
96
+ if (typeof next.name === "string" && next.name.trim().length > 0) {
97
+ next.nameLocation = next.nameLocation ?? "middle";
98
+ if (pos === "y") {
99
+ next.nameRotate = next.nameRotate ?? 90;
100
+ next.nameGap = next.nameGap ?? 56;
101
+ } else {
102
+ next.nameGap = next.nameGap ?? 56;
103
+ }
104
+ }
105
+ return next;
106
+ }
107
+
108
+ /** Apply {@link normalizeAxis} across an axis field (object or array). */
109
+ function normalizeAxisField(axis: unknown, pos: "x" | "y"): unknown {
110
+ if (Array.isArray(axis))
111
+ return axis.map((a) => (isObj(a) ? normalizeAxis(a, pos) : a));
112
+ return isObj(axis) ? normalizeAxis(axis, pos) : axis;
113
+ }
114
+
115
+ /**
116
+ * Return a render-ready copy of a planner `EChartsOption`: compact
117
+ * value ticks, conventionally-placed axis names, legible category
118
+ * labels, and title/grid spacing. Pure and shallow-cloning - the input
119
+ * (which may be a shared/cached object) is never mutated. Non-object
120
+ * input is returned untouched.
121
+ */
122
+ export function normalizeChartOption<T>(option: T): T {
123
+ if (!isObj(option)) return option;
124
+ const next: Obj = { ...option };
125
+ next.title = normalizeTitle(next.title);
126
+ next.grid = normalizeGrid(next.grid, { hasTitle: hasTitleText(next.title) });
127
+ next.xAxis = normalizeAxisField(next.xAxis, "x");
128
+ next.yAxis = normalizeAxisField(next.yAxis, "y");
129
+ return next as T;
130
+ }