@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,428 @@
1
+ import {
2
+ Avatar,
3
+ AvatarFallback,
4
+ Button,
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ Item,
9
+ ItemContent,
10
+ ItemMedia,
11
+ Tooltip,
12
+ TooltipContent,
13
+ TooltipTrigger,
14
+ } from "@dbx-tools/ui-appkit/react";
15
+ import type { UIMessage } from "ai";
16
+ import {
17
+ CheckIcon,
18
+ ChevronDownIcon,
19
+ CopyIcon,
20
+ MessageSquareIcon,
21
+ RefreshCcwIcon,
22
+ SparklesIcon,
23
+ UserIcon,
24
+ XIcon,
25
+ } from "lucide-react";
26
+ import { useState } from "react";
27
+ import { MarkdownWithEmbeds } from "./embed-slots";
28
+ import { ExportMenu } from "./export-menu";
29
+ import { FeedbackControls } from "./feedback-controls";
30
+ import { SuggestionPills } from "./suggestion-pills";
31
+ import { collectSuggestions } from "./suggestions";
32
+ import { ToolSessionPill, humanizeToolName } from "./tool-pill";
33
+ import type {
34
+ ApprovalDecision,
35
+ ChatStatus,
36
+ ExportFormat,
37
+ FeedbackSubmission,
38
+ FeedbackValue,
39
+ PendingApproval,
40
+ ToolEvent,
41
+ } from "./types";
42
+
43
+ // User / assistant message bubbles plus the inline approval card and
44
+ // the helpers that surface approval-gated tool calls out of a message's
45
+ // parts.
46
+
47
+ const getReasoningText = (parts: UIMessage["parts"]): string =>
48
+ parts
49
+ .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
50
+ .map((p) => p.text)
51
+ .join("\n\n");
52
+
53
+ const RoleAvatar = ({ role }: { role: UIMessage["role"] }) => (
54
+ <Avatar className="size-7">
55
+ <AvatarFallback>
56
+ {role === "assistant" ? (
57
+ <SparklesIcon className="size-4" />
58
+ ) : (
59
+ <UserIcon className="size-4" />
60
+ )}
61
+ </AvatarFallback>
62
+ </Avatar>
63
+ );
64
+
65
+ /**
66
+ * Inline approval prompt rendered when a `requireApproval: true` tool is
67
+ * paused in the agent loop. Approve / deny flows through
68
+ * {@link ApprovalDecision} to the parent resume handler.
69
+ */
70
+ const ToolApprovalCard = ({
71
+ toolName,
72
+ toolCallId,
73
+ runId,
74
+ input,
75
+ onResolve,
76
+ disabled,
77
+ }: {
78
+ toolName: string;
79
+ toolCallId: string;
80
+ runId?: string;
81
+ input: unknown;
82
+ onResolve?: (decision: ApprovalDecision) => void | Promise<void>;
83
+ disabled?: boolean;
84
+ }) => {
85
+ const [pending, setPending] = useState(false);
86
+ const [decision, setDecision] = useState<"approved" | "denied" | null>(null);
87
+ const [expired, setExpired] = useState(false);
88
+ const handle = (approved: boolean) => {
89
+ if (!onResolve || pending || decision) return;
90
+ setPending(true);
91
+ setDecision(approved ? "approved" : "denied");
92
+ Promise.resolve(
93
+ approved
94
+ ? onResolve({ approved: true, toolName, toolCallId, runId, input })
95
+ : onResolve({
96
+ approved: false,
97
+ toolName,
98
+ toolCallId,
99
+ runId,
100
+ input,
101
+ reason: "User denied the request from the chat UI.",
102
+ }),
103
+ )
104
+ .catch(() => setExpired(true))
105
+ .finally(() => setPending(false));
106
+ };
107
+
108
+ return (
109
+ <div className="not-prose my-2 rounded-md border border-warning/40 bg-warning/5 p-3">
110
+ <div className="mb-2 flex items-center gap-2 text-xs font-medium text-warning">
111
+ <MessageSquareIcon className="size-3.5" />
112
+ <span>Approval needed: {humanizeToolName(toolName)}</span>
113
+ </div>
114
+ <pre className="overflow-x-auto rounded bg-background/40 p-2 text-[11px]">
115
+ {JSON.stringify(input, null, 2)}
116
+ </pre>
117
+ {expired ? (
118
+ <div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
119
+ <MessageSquareIcon className="size-3.5" />
120
+ <span>This approval is no longer available.</span>
121
+ </div>
122
+ ) : decision ? (
123
+ <div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
124
+ {decision === "approved" ? (
125
+ <CheckIcon className="size-3.5 text-success" />
126
+ ) : (
127
+ <XIcon className="size-3.5 text-destructive" />
128
+ )}
129
+ <span>
130
+ {decision === "approved"
131
+ ? pending
132
+ ? "Approving…"
133
+ : "Approved"
134
+ : pending
135
+ ? "Denying…"
136
+ : "Denied"}
137
+ </span>
138
+ </div>
139
+ ) : (
140
+ <div className="mt-3 flex items-center gap-2">
141
+ <Button
142
+ type="button"
143
+ size="sm"
144
+ variant="default"
145
+ disabled={disabled || pending || !onResolve}
146
+ onClick={() => handle(true)}
147
+ >
148
+ <CheckIcon className="size-3" />
149
+ Approve
150
+ </Button>
151
+ <Button
152
+ type="button"
153
+ size="sm"
154
+ variant="outline"
155
+ disabled={disabled || pending || !onResolve}
156
+ onClick={() => handle(false)}
157
+ >
158
+ <XIcon className="size-3" />
159
+ Deny
160
+ </Button>
161
+ {!onResolve && (
162
+ <span className="ml-1 text-[11px] text-muted-foreground">
163
+ (approval handler not wired on this page)
164
+ </span>
165
+ )}
166
+ </div>
167
+ )}
168
+ </div>
169
+ );
170
+ };
171
+
172
+ /**
173
+ * Pull every approval-pending tool call out of an assistant message's
174
+ * `data-tool-call-approval` parts.
175
+ */
176
+ const collectPendingApprovals = (parts: UIMessage["parts"]): PendingApproval[] => {
177
+ const resolvedToolCallIds = new Set<string>();
178
+ for (const part of parts ?? []) {
179
+ const p = part as { toolCallId?: unknown; state?: unknown };
180
+ if (typeof p.toolCallId !== "string") continue;
181
+ if (p.state === "output-available" || p.state === "output-error") {
182
+ resolvedToolCallIds.add(p.toolCallId);
183
+ }
184
+ }
185
+
186
+ const out: PendingApproval[] = [];
187
+ for (const part of parts ?? []) {
188
+ const type = (part as { type?: unknown }).type;
189
+ if (type !== "data-tool-call-approval") continue;
190
+ const data = (part as { data?: unknown }).data;
191
+ if (!data || typeof data !== "object") continue;
192
+ const d = data as {
193
+ toolName?: unknown;
194
+ toolCallId?: unknown;
195
+ runId?: unknown;
196
+ args?: unknown;
197
+ };
198
+ if (typeof d.toolName !== "string") continue;
199
+ if (typeof d.toolCallId !== "string") continue;
200
+ if (resolvedToolCallIds.has(d.toolCallId)) continue;
201
+ out.push({
202
+ toolName: d.toolName,
203
+ toolCallId: d.toolCallId,
204
+ runId: typeof d.runId === "string" ? d.runId : undefined,
205
+ input: d.args,
206
+ });
207
+ }
208
+ return out;
209
+ };
210
+
211
+ /**
212
+ * Merge inline approvals (from `message.parts`) with out-of-band
213
+ * approvals supplied by the parent (used by the `/stream` page,
214
+ * which doesn't store approvals as data parts). De-dupes by
215
+ * `toolCallId` so a card never appears twice.
216
+ */
217
+ const mergePendingApprovals = (
218
+ fromParts: PendingApproval[],
219
+ fromExternal: PendingApproval[] | undefined,
220
+ ): PendingApproval[] => {
221
+ if (!fromExternal || fromExternal.length === 0) return fromParts;
222
+ const seen = new Set(fromParts.map((a) => a.toolCallId));
223
+ return [...fromParts, ...fromExternal.filter((a) => !seen.has(a.toolCallId))];
224
+ };
225
+
226
+ type AssistantBubbleProps = {
227
+ message: UIMessage;
228
+ isLast: boolean;
229
+ status: ChatStatus;
230
+ events?: ToolEvent[];
231
+ regenerate?: () => void;
232
+ /** Click handler for tool-suggested follow-up questions. */
233
+ onSuggestionClick?: (question: string) => void;
234
+ /** Approve / deny handler for inline {@link ToolApprovalCard}s. */
235
+ onResolveToolApproval?: (decision: ApprovalDecision) => void | Promise<void>;
236
+ /**
237
+ * Out-of-band approvals for this specific message (Stream page).
238
+ * Merged with inline `data-tool-call-approval` parts.
239
+ */
240
+ externalApprovals?: PendingApproval[];
241
+ /**
242
+ * Export this message in the chosen format. When set, the bubble's
243
+ * action row shows a per-message export menu (charts / tables inlined).
244
+ */
245
+ onExport?: (format: ExportFormat) => void;
246
+ /**
247
+ * Submit thumbs / comment feedback for this message. When set, the
248
+ * action row shows the feedback controls; only wired by the parent
249
+ * when MLflow logging is enabled and the turn has a captured trace id.
250
+ */
251
+ onFeedback?: (submission: FeedbackSubmission) => void | Promise<void>;
252
+ /** Last thumbs the user left on this message, for highlighting. */
253
+ feedbackValue?: FeedbackValue;
254
+ };
255
+
256
+ export const AssistantBubble = ({
257
+ message,
258
+ isLast,
259
+ status,
260
+ events,
261
+ regenerate,
262
+ onSuggestionClick,
263
+ onResolveToolApproval,
264
+ externalApprovals,
265
+ onExport,
266
+ onFeedback,
267
+ feedbackValue,
268
+ }: AssistantBubbleProps) => {
269
+ const reasoning = getReasoningText(message.parts);
270
+ const isReasoningStreaming =
271
+ isLast && status === "streaming" && message.parts.at(-1)?.type === "reasoning";
272
+ const textParts = message.parts.filter(
273
+ (p): p is { type: "text"; text: string } => p.type === "text",
274
+ );
275
+ const fullText = textParts.map((p) => p.text).join("");
276
+ const hasText = fullText.length > 0;
277
+ // Suggestions are deferred until the turn is settled so they don't
278
+ // pop in mid-stream. A bubble is "settled" if it isn't the active
279
+ // streaming target - either the agent has returned to `ready` /
280
+ // `error`, or a newer message has taken over the `isLast` slot.
281
+ const isStreamingThisBubble =
282
+ isLast && (status === "streaming" || status === "submitted");
283
+ const suggestions = isStreamingThisBubble ? [] : collectSuggestions(events);
284
+ // Charts and tables are placed at inline marker positions in
285
+ // the assistant's prose. `prepare_chart` / `render_data` mint
286
+ // a `chartId` and the model embeds `[chart:<chartId>]`; for raw
287
+ // data the model embeds `[data:<statement_id>]` and the host UI fetches the
288
+ // rows on its own. {@link MarkdownWithEmbeds} splits the text
289
+ // on both markers and renders {@link ChartSlot} /
290
+ // {@link DataSlot} inline; unknown / expired ids resolve as
291
+ // nothing so the prose flows unaffected. Suggested questions
292
+ // stay gated on settle to avoid pop-in mid-stream.
293
+ const pendingApprovals = mergePendingApprovals(
294
+ collectPendingApprovals(message.parts),
295
+ externalApprovals,
296
+ );
297
+
298
+ return (
299
+ <Item className="items-start gap-3 border-none bg-transparent p-0">
300
+ <ItemMedia>
301
+ <RoleAvatar role="assistant" />
302
+ </ItemMedia>
303
+ {/*
304
+ * `min-w-0` is required so wide content (markdown tables, long
305
+ * code blocks) can shrink to fit instead of pushing the whole
306
+ * bubble past the chat's `max-w-4xl`. Flex children default to
307
+ * `min-width: auto`, which sizes to content and overflows.
308
+ */}
309
+ <ItemContent className="min-w-0 gap-2">
310
+ {/*
311
+ * Tool session pill leads the message. Charts and tables are
312
+ * embedded inline in the prose (via `[chart:<id>]` / `[data:<id>]`
313
+ * markers rendered by {@link MarkdownWithEmbeds}), so keeping the
314
+ * pill above the text parts guarantees the "what the agent did"
315
+ * summary always sits before its resulting charts and answer.
316
+ */}
317
+ {events && events.length > 0 && <ToolSessionPill events={events} />}
318
+ {reasoning && (
319
+ <Collapsible defaultOpen={isReasoningStreaming}>
320
+ <CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
321
+ <ChevronDownIcon className="size-3" />
322
+ <span>{isReasoningStreaming ? "Thinking..." : "Thoughts"}</span>
323
+ </CollapsibleTrigger>
324
+ <CollapsibleContent>
325
+ <div className="mt-1 border-l-2 border-border/60 pl-3 text-xs text-muted-foreground whitespace-pre-wrap">
326
+ {reasoning}
327
+ </div>
328
+ </CollapsibleContent>
329
+ </Collapsible>
330
+ )}
331
+ {/*
332
+ * Render each text part as its own block. A multi-step turn
333
+ * carries one text part per step (see Stream.tsx segmentation),
334
+ * so this keeps each step's preamble visually separate instead
335
+ * of concatenating them into a single run of prose.
336
+ */}
337
+ {textParts.map((part, i) =>
338
+ part.text.trim().length > 0 ? (
339
+ <MarkdownWithEmbeds
340
+ key={`text-${i}`}
341
+ text={part.text}
342
+ streaming={isStreamingThisBubble}
343
+ />
344
+ ) : null,
345
+ )}
346
+ {pendingApprovals.map((p) => (
347
+ <ToolApprovalCard
348
+ key={p.toolCallId}
349
+ toolName={p.toolName}
350
+ toolCallId={p.toolCallId}
351
+ runId={p.runId}
352
+ input={p.input}
353
+ onResolve={onResolveToolApproval}
354
+ />
355
+ ))}
356
+ {hasText && (isLast || onExport || onFeedback) && (
357
+ <div className="flex items-center gap-1">
358
+ {isLast && regenerate && (
359
+ <Tooltip>
360
+ <TooltipTrigger asChild>
361
+ <Button
362
+ type="button"
363
+ size="icon"
364
+ variant="ghost"
365
+ className="size-7"
366
+ onClick={() => regenerate()}
367
+ >
368
+ <RefreshCcwIcon className="size-3" />
369
+ </Button>
370
+ </TooltipTrigger>
371
+ <TooltipContent>Retry</TooltipContent>
372
+ </Tooltip>
373
+ )}
374
+ {isLast && (
375
+ <Tooltip>
376
+ <TooltipTrigger asChild>
377
+ <Button
378
+ type="button"
379
+ size="icon"
380
+ variant="ghost"
381
+ className="size-7"
382
+ onClick={() => navigator.clipboard.writeText(fullText)}
383
+ >
384
+ <CopyIcon className="size-3" />
385
+ </Button>
386
+ </TooltipTrigger>
387
+ <TooltipContent>Copy</TooltipContent>
388
+ </Tooltip>
389
+ )}
390
+ {onExport && (
391
+ <ExportMenu onExport={onExport} iconOnly tooltip="Export message" />
392
+ )}
393
+ {onFeedback && (
394
+ <FeedbackControls
395
+ onSubmit={onFeedback}
396
+ {...(feedbackValue ? { value: feedbackValue } : {})}
397
+ />
398
+ )}
399
+ </div>
400
+ )}
401
+ <SuggestionPills
402
+ questions={suggestions}
403
+ onSelect={onSuggestionClick}
404
+ className="mt-1"
405
+ />
406
+ </ItemContent>
407
+ </Item>
408
+ );
409
+ };
410
+
411
+ export const UserBubble = ({ message }: { message: UIMessage }) => {
412
+ const text = message.parts
413
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
414
+ .map((p) => p.text)
415
+ .join("");
416
+ return (
417
+ <Item className="items-start gap-3 border-none bg-transparent p-0">
418
+ <ItemMedia>
419
+ <RoleAvatar role="user" />
420
+ </ItemMedia>
421
+ <ItemContent className="min-w-0">
422
+ <div className="rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
423
+ {text}
424
+ </div>
425
+ </ItemContent>
426
+ </Item>
427
+ );
428
+ };