@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,1376 @@
1
+ import {
2
+ feedback,
3
+ type MastraThread,
4
+ } from "@dbx-tools/shared-mastra";
5
+ import { error as errorUtil, log } from "@dbx-tools/shared-core";
6
+ import type { UIMessage } from "ai";
7
+ import { nanoid } from "nanoid";
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import { exportChat, type EmbedResolver, type ExportFormat } from "../support/export";
10
+ import {
11
+ useMastraClient,
12
+ useMastraModels,
13
+ useMastraSuggestions,
14
+ useMastraThreads,
15
+ } from "../support/mastra-client";
16
+ import type { MastraStreamResponse } from "../support/mastra-stream";
17
+ import {
18
+ createThreadSession,
19
+ DEFAULT_THREAD_SESSION_KEY,
20
+ isSessionRunning,
21
+ sessionKey,
22
+ type ThreadSession,
23
+ } from "../support/thread-sessions";
24
+ import { ChatView } from "./chat-view";
25
+ import { dedupeSuggestions } from "./suggestions";
26
+ import type {
27
+ ApprovalDecision,
28
+ ChatViewProps,
29
+ FeedbackSubmission,
30
+ MessageFeedback,
31
+ ThreadSummary,
32
+ ToolEvent,
33
+ ToolProgress,
34
+ } from "./types";
35
+
36
+ // Self-contained drop-in chat. `useMastraChat` drives the conversation
37
+ // over `@mastra/client-js`: `agent.stream()` returns a Response
38
+ // augmented with `processDataStream()`, which pushes typed Mastra
39
+ // chunks (text-delta, reasoning-delta, tool-*, ...) that we translate
40
+ // into `UIMessage` parts for `ChatView` to render.
41
+ //
42
+ // Approval gates ride the same channel: a paused `requireApproval: true`
43
+ // tool call emits a `tool-call-approval` chunk carrying
44
+ // `{ runId, payload: { toolCallId, toolName, args } }`. We surface that
45
+ // as an out-of-band entry in `pendingApprovalsByMessage` and wire
46
+ // `onResolveToolApproval` to {@link MastraPluginClient.approveToolCallStream}
47
+ // / `declineToolCallStream`, which read SSE directly and avoid a stock
48
+ // `@mastra/client-js` bug on resumed approval streams.
49
+ // both of which return a fresh stream Response we run through the same
50
+ // chunk handler.
51
+ //
52
+ // On mount the transcript hydrates with the most recent page of thread
53
+ // history from the Mastra plugin's `/history` endpoint; scrolling near
54
+ // the top lazy-loads and prepends the next older page, with `ChatView`
55
+ // preserving the visual scroll position across the prepend.
56
+
57
+ const logger = log.logger("ui-mastra/chat");
58
+
59
+ const HISTORY_PAGE_SIZE = 20;
60
+
61
+ const makeUserMessage = (text: string): UIMessage => ({
62
+ id: nanoid(),
63
+ role: "user",
64
+ parts: [{ type: "text", text }],
65
+ });
66
+
67
+ /** Project a wire {@link MastraThread} down to the sidebar's view. */
68
+ const toThreadSummary = (thread: MastraThread): ThreadSummary => ({
69
+ id: thread.id,
70
+ ...(thread.title ? { title: thread.title } : {}),
71
+ updatedAt: thread.updatedAt,
72
+ });
73
+
74
+ /** Max characters of the first user message used as a provisional thread title. */
75
+ const TITLE_PREVIEW_MAX = 60;
76
+
77
+ /**
78
+ * Derive a provisional sidebar title from a user's first message:
79
+ * whitespace-collapsed and truncated with an ellipsis. Shown the instant
80
+ * a brand-new conversation gets its first question so the row stops
81
+ * reading "New conversation", until the server's auto-generated title
82
+ * lands and supersedes it.
83
+ */
84
+ const deriveThreadTitle = (text: string): string => {
85
+ const clean = text.trim().replace(/\s+/g, " ");
86
+ return clean.length > TITLE_PREVIEW_MAX
87
+ ? `${clean.slice(0, TITLE_PREVIEW_MAX - 1)}…`
88
+ : clean;
89
+ };
90
+
91
+ /**
92
+ * `localStorage` key the active thread id is persisted under, namespaced
93
+ * by plugin mount + agent so two agents (or two apps on one origin)
94
+ * don't clobber each other's "current conversation".
95
+ */
96
+ const threadStorageKey = (basePath: string, agentId: string): string =>
97
+ `dbx-mastra-thread:${basePath}:${agentId}`;
98
+
99
+ /** Read the persisted active thread id, tolerating storage being unavailable. */
100
+ const readStoredThreadId = (key: string): string | undefined => {
101
+ try {
102
+ return window.localStorage.getItem(key) ?? undefined;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ };
107
+
108
+ /** Persist the active thread id, silently ignoring storage failures. */
109
+ const storeStoredThreadId = (key: string, id: string): void => {
110
+ try {
111
+ window.localStorage.setItem(key, id);
112
+ } catch {
113
+ // Private-mode / disabled storage: persistence is best-effort, the
114
+ // in-memory selection still works for the session.
115
+ }
116
+ };
117
+
118
+ /**
119
+ * `localStorage` key the conversation sidebar's open/closed state is
120
+ * persisted under, namespaced by plugin mount + agent so the user's
121
+ * show/hide choice survives reloads without clobbering other mounts.
122
+ */
123
+ const sidebarStorageKey = (basePath: string, agentId: string): string =>
124
+ `dbx-mastra-sidebar:${basePath}:${agentId}`;
125
+
126
+ /** Read the persisted sidebar open flag, falling back when unset / unavailable. */
127
+ const readStoredSidebarOpen = (key: string, fallback: boolean): boolean => {
128
+ try {
129
+ const value = window.localStorage.getItem(key);
130
+ return value === null ? fallback : value === "1";
131
+ } catch {
132
+ return fallback;
133
+ }
134
+ };
135
+
136
+ /** Persist the sidebar open flag, silently ignoring storage failures. */
137
+ const storeStoredSidebarOpen = (key: string, open: boolean): void => {
138
+ try {
139
+ window.localStorage.setItem(key, open ? "1" : "0");
140
+ } catch {
141
+ // Best-effort; the in-session toggle still works without persistence.
142
+ }
143
+ };
144
+
145
+ /**
146
+ * Pull the MLflow trace id (`tr-<hex>`) the server stamped on a stream
147
+ * response, if present. `@mastra/client-js`'s `agent.stream()` returns
148
+ * a Response-shaped object, so the header is read defensively (the
149
+ * shape isn't guaranteed across client versions). Returns `undefined`
150
+ * when absent - which is the "no feedback for this turn" signal.
151
+ */
152
+ const readMlflowTraceId = (stream: unknown): string | undefined => {
153
+ const headers = (stream as { headers?: { get?: (name: string) => string | null } })
154
+ ?.headers;
155
+ const raw = headers?.get?.(feedback.MLFLOW_TRACE_ID_HEADER);
156
+ return raw?.trim() || undefined;
157
+ };
158
+
159
+ // `tool-output` chunks carry arbitrary tool-defined payloads; only the
160
+ // `{type: ...}` shape we know how to render in `ToolSessionPill` is
161
+ // surfaced. Anything else (other tools, raw data, etc.) is ignored.
162
+ const isToolProgress = (value: unknown): value is ToolProgress =>
163
+ typeof value === "object" &&
164
+ value !== null &&
165
+ typeof (value as { type?: unknown }).type === "string";
166
+
167
+ /** Options for {@link useMastraChat}. */
168
+ export interface UseMastraChatOptions {
169
+ /**
170
+ * Agent to converse with. Defaults to the Mastra plugin's registered
171
+ * default agent (`clientConfig().defaultAgent`).
172
+ */
173
+ agentId?: string;
174
+ /**
175
+ * Surface the built-in model picker in the header, letting the user
176
+ * override the serving endpoint per turn (via `X-Mastra-Model`).
177
+ * Off by default so the drop-in renders a clean single-model chat;
178
+ * when `false` the model catalogue isn't even fetched.
179
+ */
180
+ showModelPicker?: boolean;
181
+ /**
182
+ * Starter questions shown as one-tap buttons on the empty state.
183
+ * When omitted, the drop-in auto-sources them from the agent's
184
+ * Genie space sample questions (via the plugin's `/suggestions`
185
+ * endpoint); when the agent has no Genie space the empty state
186
+ * stays bare. Pass an explicit list to override that lookup, or
187
+ * `[]` to force no suggestions.
188
+ */
189
+ suggestions?: string[];
190
+ /**
191
+ * Enable built-in conversation (thread) management: the chat tracks a
192
+ * client-selected thread id, persists it across reloads, lists the
193
+ * resource's conversations, and renders a sidebar to switch between
194
+ * them / start new ones / delete them. On by default. Set `false` for
195
+ * the classic single-thread chat anchored to the per-session cookie
196
+ * (no sidebar, no thread tracking).
197
+ */
198
+ enableThreads?: boolean;
199
+ /**
200
+ * Enable chat export. Off by default (opt-in). When on, the header
201
+ * shows an "Export" menu for the whole conversation and each assistant
202
+ * bubble shows a per-message export menu. Both offer PDF (via the
203
+ * browser print dialog) and Markdown; charts and data tables are
204
+ * inlined into the export so it renders reliably offline.
205
+ */
206
+ enableExport?: boolean;
207
+ /**
208
+ * Surface per-message feedback controls (thumbs up/down + a comment
209
+ * popover) that log to MLflow as trace assessments.
210
+ *
211
+ * Defaults to whatever {@link enableExport} is (feedback and export
212
+ * are the two "quality loop" affordances, so turning on export opts
213
+ * into feedback too); pass an explicit `true` / `false` to override.
214
+ * Regardless of this flag, controls only actually render when the
215
+ * server reports MLflow logging is enabled (`clientConfig.feedbackEnabled`)
216
+ * and the turn produced a trace id - so enabling it on a deployment
217
+ * without MLflow tracing is a safe no-op.
218
+ */
219
+ enableFeedback?: boolean;
220
+ }
221
+
222
+ /**
223
+ * Thrown out of the chunk handler to unwind `processDataStream` when
224
+ * the user stops a turn. Callers treat it as a clean stop, not an
225
+ * error, so the composer just returns to idle.
226
+ */
227
+ class StreamAborted extends Error {}
228
+
229
+ /**
230
+ * Headless driver for the Mastra chat experience. Owns the full
231
+ * conversation lifecycle (streaming, tool-event tracking, approvals,
232
+ * model selection, clear, and infinite-scroll-up history) over
233
+ * `@mastra/client-js` and returns the exact prop bag {@link ChatView}
234
+ * consumes. Use this when you want the drop-in behaviour but need to
235
+ * render the view yourself; otherwise reach for {@link MastraChat}.
236
+ */
237
+ export const useMastraChat = (
238
+ options: UseMastraChatOptions = {},
239
+ ): Omit<ChatViewProps, "className"> => {
240
+ const [model, setModel] = useState("");
241
+ // One client drives both the agent stream and the plugin's custom
242
+ // routes (history / threads / models / suggestions / feedback /
243
+ // embeds). Its identity is
244
+ // stable across renders (memoized on `basePath` / `defaultAgent`) so
245
+ // using it as a hook dep doesn't refire the initial-history fetch on
246
+ // every parent render.
247
+ const mastraClient = useMastraClient();
248
+ // Apply the selected model as a per-request override header in place,
249
+ // so the next `agent.stream()` picks it up without rebuilding the
250
+ // client (which would refire history loads on every model change).
251
+ useEffect(() => {
252
+ mastraClient.setModelOverride(model || undefined);
253
+ }, [mastraClient, model]);
254
+ const agentId = options.agentId ?? mastraClient.defaultAgent;
255
+ // Built-in conversation management. When on, the chat always drives an
256
+ // explicit client thread id (rather than leaning on the per-session
257
+ // cookie) so it can reference, persist, and switch between the
258
+ // conversations a user owns. Selecting a thread routes every call
259
+ // (stream + history + clear) at it via `mastraClient.setThreadId`.
260
+ const enableThreads = options.enableThreads !== false;
261
+ // Export is opt-in (default off): the host turns it on explicitly.
262
+ const enableExport = options.enableExport === true;
263
+ // Feedback defaults to export's setting; an explicit option overrides.
264
+ // It only actually surfaces when the server reports MLflow logging is
265
+ // wired (so a trace exists to attach the assessment to).
266
+ const enableFeedback = options.enableFeedback ?? enableExport;
267
+ const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
268
+ const threadKey = threadStorageKey(mastraClient.basePath, agentId);
269
+ const [activeThreadId, setActiveThreadId] = useState<string | undefined>(() =>
270
+ enableThreads ? (readStoredThreadId(threadKey) ?? nanoid()) : undefined,
271
+ );
272
+ const {
273
+ threads,
274
+ loading: isLoadingThreads,
275
+ refresh: refreshThreads,
276
+ } = useMastraThreads(options.agentId, enableThreads);
277
+ // Persist the active thread id so a reload reopens the same
278
+ // conversation. Best-effort; storage may be unavailable.
279
+ useEffect(() => {
280
+ if (enableThreads && activeThreadId) storeStoredThreadId(threadKey, activeThreadId);
281
+ }, [enableThreads, threadKey, activeThreadId]);
282
+ // Conversation sidebar show/hide, persisted so the user's choice
283
+ // sticks across reloads. The view exposes a header toggle wired to
284
+ // `onToggleSidebar`; defaults open the first time.
285
+ const sidebarKey = sidebarStorageKey(mastraClient.basePath, agentId);
286
+ const [sidebarOpen, setSidebarOpen] = useState<boolean>(() =>
287
+ enableThreads ? readStoredSidebarOpen(sidebarKey, true) : true,
288
+ );
289
+ const toggleSidebar = useCallback(() => {
290
+ setSidebarOpen((prev) => {
291
+ const next = !prev;
292
+ storeStoredSidebarOpen(sidebarKey, next);
293
+ return next;
294
+ });
295
+ }, [sidebarKey]);
296
+ // Refresh the conversation list after a turn (a brand-new thread
297
+ // appears, or its auto-generated title lands), a clear, or a delete.
298
+ // Titles are generated server-side after the turn settles, so a short
299
+ // delayed second pass picks them up.
300
+ const refreshThreadsSoon = useCallback(() => {
301
+ if (!enableThreads) return;
302
+ refreshThreads();
303
+ window.setTimeout(refreshThreads, 2000);
304
+ }, [enableThreads, refreshThreads]);
305
+ // Optimistic sidebar rows for conversations the user just started but
306
+ // that the server list hasn't returned yet. A new thread's id is
307
+ // client-minted, and the server only materializes the thread row once
308
+ // the first turn lands (with its auto-title arriving a beat later), so
309
+ // without this the sidebar wouldn't show a brand-new conversation
310
+ // until the delayed post-turn refresh. Keyed by thread id; each entry
311
+ // is pruned once the real server row supersedes it.
312
+ const [optimisticThreads, setOptimisticThreads] = useState<
313
+ Record<string, ThreadSummary>
314
+ >({});
315
+ // Optimistic title overrides for threads the user just renamed, keyed
316
+ // by thread id. Applied over the server rows in `sidebarThreads` so the
317
+ // new name shows instantly; each entry is dropped once the server list
318
+ // reports the matching title (or the rename request fails).
319
+ const [renamedThreads, setRenamedThreads] = useState<Record<string, string>>({});
320
+ // Provisional titles derived from a thread's first user message, keyed
321
+ // by thread id. Shown the instant a brand-new conversation gets its
322
+ // first question so the row stops reading "New conversation"; dropped
323
+ // as soon as the server's auto-generated title lands (see the prune
324
+ // effect below). A manual rename (`renamedThreads`) still wins.
325
+ const [provisionalTitles, setProvisionalTitles] = useState<Record<string, string>>(
326
+ {},
327
+ );
328
+ // Surface the active thread in the sidebar immediately (called when its
329
+ // first message is sent). Upserts an untitled row stamped "now" so it
330
+ // sorts to the top; the server row replaces it on the next refresh.
331
+ const noteThreadActivity = useCallback(
332
+ (threadId: string) => {
333
+ if (!enableThreads) return;
334
+ setOptimisticThreads((prev) => ({
335
+ ...prev,
336
+ [threadId]: {
337
+ ...prev[threadId],
338
+ id: threadId,
339
+ updatedAt: new Date().toISOString(),
340
+ },
341
+ }));
342
+ },
343
+ [enableThreads],
344
+ );
345
+ // Picker is opt-in: an omitted (or falsy) `showModelPicker` keeps it
346
+ // hidden and skips the catalogue fetch entirely.
347
+ const showModelPicker = Boolean(options.showModelPicker);
348
+ const { models } = useMastraModels(showModelPicker);
349
+ // Starter suggestions: an explicit `options.suggestions` always
350
+ // wins (including `[]` to force none) and is rendered verbatim;
351
+ // otherwise auto-source the agent's Genie space sample questions.
352
+ // The fetch is skipped when the caller passed an explicit list so we
353
+ // never round-trip for a value we won't use. Genie-sourced questions
354
+ // run through the same dedupe + cap as in-conversation follow-ups so
355
+ // initial and follow-up suggestions behave identically.
356
+ const explicitSuggestions = options.suggestions;
357
+ const { questions: genieSuggestions } = useMastraSuggestions(
358
+ options.agentId,
359
+ explicitSuggestions === undefined,
360
+ );
361
+ const suggestions = useMemo(
362
+ () => explicitSuggestions ?? dedupeSuggestions(genieSuggestions),
363
+ [explicitSuggestions, genieSuggestions],
364
+ );
365
+ const [sessionsTick, setSessionsTick] = useState(0);
366
+ const sessionsRef = useRef<Map<string, ThreadSession>>(new Map());
367
+ const notifySessions = useCallback(() => {
368
+ setSessionsTick((tick) => tick + 1);
369
+ }, []);
370
+ const getSession = useCallback((threadId: string): ThreadSession => {
371
+ let session = sessionsRef.current.get(threadId);
372
+ if (!session) {
373
+ session = createThreadSession();
374
+ sessionsRef.current.set(threadId, session);
375
+ }
376
+ return session;
377
+ }, []);
378
+ const updateSession = useCallback(
379
+ (threadId: string, updater: (session: ThreadSession) => ThreadSession) => {
380
+ const next = updater(getSession(threadId));
381
+ sessionsRef.current.set(threadId, next);
382
+ notifySessions();
383
+ },
384
+ [getSession, notifySessions],
385
+ );
386
+ const activeKey = sessionKey(activeThreadId);
387
+ const activeSession = useMemo(
388
+ () => getSession(activeKey),
389
+ [activeKey, getSession, sessionsTick],
390
+ );
391
+ const streamingThreadIds = useMemo(() => {
392
+ const ids: string[] = [];
393
+ for (const [id, session] of sessionsRef.current.entries()) {
394
+ if (id === DEFAULT_THREAD_SESSION_KEY) continue;
395
+ if (isSessionRunning(session)) ids.push(id);
396
+ }
397
+ return ids;
398
+ }, [sessionsTick]);
399
+ const [isLoadingHistory, setIsLoadingHistory] = useState(true);
400
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
401
+ const historyInFlightRef = useRef(false);
402
+ const feedbackByMessageRef = useRef<Record<string, MessageFeedback>>({});
403
+ feedbackByMessageRef.current = activeSession.feedbackByMessage;
404
+
405
+ const writeMessages = useCallback(
406
+ (threadId: string, next: UIMessage[]) => {
407
+ updateSession(threadId, (session) => ({ ...session, messages: next }));
408
+ },
409
+ [updateSession],
410
+ );
411
+
412
+ /**
413
+ * Pipe a Mastra stream Response through the same chunk handler used
414
+ * for the initial turn. `assistantId` identifies the in-progress
415
+ * assistant message so resumed streams (from approveToolCall /
416
+ * declineToolCall) keep mutating the same bubble instead of
417
+ * spawning a new one. `runId` is captured in a ref so the approval
418
+ * handler can later resume the suspended workflow.
419
+ */
420
+ const processStream = useCallback(
421
+ async (
422
+ threadId: string,
423
+ stream: MastraStreamResponse,
424
+ assistantId: string,
425
+ runIdRef: { current: string | null },
426
+ signal: AbortSignal,
427
+ ) => {
428
+ const traceId = readMlflowTraceId(stream);
429
+ if (traceId) {
430
+ updateSession(threadId, (session) =>
431
+ session.feedbackByMessage[assistantId]?.traceId === traceId
432
+ ? session
433
+ : {
434
+ ...session,
435
+ feedbackByMessage: {
436
+ ...session.feedbackByMessage,
437
+ [assistantId]: {
438
+ ...session.feedbackByMessage[assistantId],
439
+ traceId,
440
+ },
441
+ },
442
+ },
443
+ );
444
+ }
445
+ const existing = getSession(threadId).messages.find((m) => m.id === assistantId);
446
+ // Text is tracked as ordered segments, one per `text-start` the
447
+ // agent emits. In a multi-step turn the model opens a fresh text
448
+ // block in each step (a short preamble before each tool call),
449
+ // and those blocks read as distinct "updates". Keeping them as
450
+ // separate segments lets the bubble render each as its own block
451
+ // instead of mashing "...summary.This is..." into one paragraph.
452
+ const textSegments: string[] = [];
453
+ let assistantReasoning = "";
454
+ if (existing) {
455
+ for (const part of existing.parts) {
456
+ if (part.type === "text") textSegments.push(part.text);
457
+ else if (part.type === "reasoning") {
458
+ assistantReasoning += (part as { text?: string }).text ?? "";
459
+ }
460
+ }
461
+ }
462
+ // Append a text delta to the current (most recent) segment,
463
+ // opening one if none exists yet (defensive: a provider could
464
+ // stream deltas without a leading `text-start`).
465
+ const appendText = (delta: string) => {
466
+ if (textSegments.length === 0) textSegments.push("");
467
+ textSegments[textSegments.length - 1] += delta;
468
+ };
469
+
470
+ const upsertAssistant = () => {
471
+ const prev = getSession(threadId).messages;
472
+ const next = [...prev];
473
+ const idx = next.findIndex((m) => m.id === assistantId);
474
+ const parts: UIMessage["parts"] = [];
475
+ if (assistantReasoning) {
476
+ parts.push({ type: "reasoning", text: assistantReasoning });
477
+ }
478
+ for (const segment of textSegments) {
479
+ if (segment.length > 0) parts.push({ type: "text", text: segment });
480
+ }
481
+ const message: UIMessage = {
482
+ id: assistantId,
483
+ role: "assistant",
484
+ parts: parts.length > 0 ? parts : [{ type: "text", text: "" }],
485
+ };
486
+ if (idx === -1) next.push(message);
487
+ else next[idx] = message;
488
+ writeMessages(threadId, next);
489
+ };
490
+
491
+ const patchToolEvents = (update: (list: ToolEvent[]) => ToolEvent[]) => {
492
+ updateSession(threadId, (session) => ({
493
+ ...session,
494
+ toolEventsByMessage: {
495
+ ...session.toolEventsByMessage,
496
+ [assistantId]: update(session.toolEventsByMessage[assistantId] ?? []),
497
+ },
498
+ }));
499
+ };
500
+
501
+ let started = false;
502
+ const markStreaming = () => {
503
+ if (started) return;
504
+ started = true;
505
+ updateSession(threadId, (session) =>
506
+ session.status === "streaming"
507
+ ? session
508
+ : { ...session, status: "streaming" },
509
+ );
510
+ };
511
+
512
+ try {
513
+ await stream.processDataStream({
514
+ onChunk: async (chunk: { type: string; payload?: any; runId?: string }) => {
515
+ // The user hit Stop: unwind the read loop. Throwing (rather
516
+ // than returning) is what actually stops `processDataStream`
517
+ // from pulling the next chunk; the wrapper below swallows it.
518
+ if (signal.aborted) throw new StreamAborted();
519
+ // Mastra stamps the stream's runId on most chunks. Capturing
520
+ // it the first time we see it (rather than relying on a
521
+ // separate API) keeps approve/decline calls correct even if
522
+ // the client-supplied runId got overridden server-side.
523
+ if (chunk.runId && !runIdRef.current) {
524
+ runIdRef.current = chunk.runId;
525
+ updateSession(threadId, (session) => ({
526
+ ...session,
527
+ runId: chunk.runId!,
528
+ }));
529
+ }
530
+ switch (chunk.type) {
531
+ case "text-start":
532
+ // Open a new text segment so each step's preamble stays
533
+ // a separate part (and thus a separate rendered block).
534
+ textSegments.push("");
535
+ break;
536
+ case "text-delta":
537
+ appendText(chunk.payload?.text ?? "");
538
+ upsertAssistant();
539
+ markStreaming();
540
+ break;
541
+ case "text-end":
542
+ // Segment boundary is driven by `text-start`; nothing to
543
+ // do on end - the next start opens the next segment.
544
+ break;
545
+ case "reasoning-delta":
546
+ assistantReasoning += chunk.payload?.text ?? "";
547
+ upsertAssistant();
548
+ markStreaming();
549
+ break;
550
+ case "tool-call": {
551
+ const { toolCallId, toolName } = chunk.payload ?? {};
552
+ if (typeof toolCallId !== "string") break;
553
+ patchToolEvents((list) => [
554
+ ...list,
555
+ { id: toolCallId, toolName, status: "running" },
556
+ ]);
557
+ // Make sure the assistant message exists in `messages`
558
+ // even when the model goes straight to a tool call with
559
+ // no preceding text, so the bubble (and its inline
560
+ // tool indicator) renders right away.
561
+ upsertAssistant();
562
+ markStreaming();
563
+ break;
564
+ }
565
+ case "tool-call-approval": {
566
+ // Mastra paused the agent loop on a `requireApproval`
567
+ // tool call. The chunk carries the runId we'll need to
568
+ // resume the suspended workflow later. We surface the
569
+ // approval card via `pendingApprovalsByMessage` so the
570
+ // existing ChatView UI lights up without us having to
571
+ // inject a synthetic data part.
572
+ const { toolCallId, toolName, args } = chunk.payload ?? {};
573
+ const approvalRunId = chunk.runId ?? runIdRef.current;
574
+ if (
575
+ typeof toolCallId !== "string" ||
576
+ typeof toolName !== "string" ||
577
+ !approvalRunId
578
+ ) {
579
+ logger.warn("malformed tool-call-approval chunk", {
580
+ toolCallId,
581
+ toolName,
582
+ hasRunId: Boolean(approvalRunId),
583
+ });
584
+ break;
585
+ }
586
+ updateSession(threadId, (session) => {
587
+ const existingApprovals =
588
+ session.pendingApprovalsByMessage[assistantId] ?? [];
589
+ if (existingApprovals.some((a) => a.toolCallId === toolCallId)) {
590
+ return session;
591
+ }
592
+ return {
593
+ ...session,
594
+ pendingApprovalsByMessage: {
595
+ ...session.pendingApprovalsByMessage,
596
+ [assistantId]: [
597
+ ...existingApprovals,
598
+ {
599
+ toolName,
600
+ toolCallId,
601
+ runId: approvalRunId,
602
+ input: args,
603
+ },
604
+ ],
605
+ },
606
+ };
607
+ });
608
+ upsertAssistant();
609
+ markStreaming();
610
+ break;
611
+ }
612
+ case "tool-result": {
613
+ const toolCallId = chunk.payload?.toolCallId;
614
+ if (typeof toolCallId !== "string") break;
615
+ // Charts resolve from `[chart:<id>]` markers in the
616
+ // assistant's prose (the model embeds the id returned
617
+ // by `prepare_chart`), so the tool-result payload is
618
+ // opaque here - we only need it to flip the pill.
619
+ // Genie tools (`ask_genie`, `get_statement`,
620
+ // `prepare_chart`) stream their entire progress
621
+ // surface through `ctx.writer` and arrive on this
622
+ // page via the `tool-output` path. The settled
623
+ // tool-result return value is opaque to the UI -
624
+ // we only need it to flip the pill to `done`.
625
+ patchToolEvents((list) =>
626
+ list.map((e) => (e.id === toolCallId ? { ...e, status: "done" } : e)),
627
+ );
628
+ break;
629
+ }
630
+ case "tool-error": {
631
+ const toolCallId = chunk.payload?.toolCallId;
632
+ if (typeof toolCallId !== "string") break;
633
+ patchToolEvents((list) =>
634
+ list.map((e) =>
635
+ e.id === toolCallId ? { ...e, status: "error" } : e,
636
+ ),
637
+ );
638
+ break;
639
+ }
640
+ case "tool-output": {
641
+ // Mid-flight progress pushed by a tool via `ctx.writer`
642
+ // (e.g. genie.ts forwarding `status`/`sql`/`data` events
643
+ // from the Genie space). Append to the matching pill so
644
+ // the user sees SQL/row info as soon as Genie publishes
645
+ // it, not only when the LLM call completes.
646
+ const { toolCallId, output } = chunk.payload ?? {};
647
+ if (typeof toolCallId !== "string") break;
648
+ if (!isToolProgress(output)) break;
649
+ patchToolEvents((list) =>
650
+ list.map((e) =>
651
+ e.id === toolCallId
652
+ ? { ...e, progress: [...(e.progress ?? []), output] }
653
+ : e,
654
+ ),
655
+ );
656
+ break;
657
+ }
658
+ case "error": {
659
+ // Surface a stream-reported error through the same path as
660
+ // a thrown one: throwing here propagates out of
661
+ // `processDataStream` to `driveStream`, which records the
662
+ // message and pins `status` to "error" (a plain
663
+ // setStatus would be clobbered by the clean-close "ready").
664
+ const detail = chunk.payload?.error ?? chunk.payload?.message;
665
+ throw new Error(
666
+ typeof detail === "string" && detail
667
+ ? detail
668
+ : "The assistant stream reported an error.",
669
+ );
670
+ }
671
+ default:
672
+ break;
673
+ }
674
+ },
675
+ });
676
+ } catch (error) {
677
+ // A stop (signal aborted) unwinds the loop cleanly - not a
678
+ // failure. Anything else is a real stream error and propagates
679
+ // to the driver's catch.
680
+ if (error instanceof StreamAborted || signal.aborted) return;
681
+ throw error;
682
+ }
683
+ },
684
+ [getSession, updateSession, writeMessages],
685
+ );
686
+
687
+ const driveStream = useCallback(
688
+ async (
689
+ threadId: string,
690
+ assistantId: string,
691
+ open: () => Promise<MastraStreamResponse>,
692
+ ) => {
693
+ const controller = new AbortController();
694
+ let token = 0;
695
+ updateSession(threadId, (session) => {
696
+ session.abortController?.abort();
697
+ token = session.runToken + 1;
698
+ return {
699
+ ...session,
700
+ abortController: controller,
701
+ assistantId,
702
+ runId: session.runId,
703
+ error: null,
704
+ status: "submitted",
705
+ runToken: token,
706
+ };
707
+ });
708
+ const runIdRef = { current: getSession(threadId).runId };
709
+ try {
710
+ const streamThreadId =
711
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
712
+ mastraClient.setThreadId(streamThreadId);
713
+ const stream = await open();
714
+ await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
715
+ updateSession(threadId, (session) => {
716
+ if (session.runToken !== token) return session;
717
+ return {
718
+ ...session,
719
+ status: "ready",
720
+ abortController:
721
+ session.abortController === controller ? null : session.abortController,
722
+ runId: runIdRef.current,
723
+ };
724
+ });
725
+ if (getSession(threadId).runToken === token) {
726
+ refreshThreadsSoon();
727
+ }
728
+ } catch (caught) {
729
+ if (getSession(threadId).runToken !== token) return;
730
+ logger.error("stream error", {
731
+ error: errorUtil.errorMessage(caught),
732
+ });
733
+ updateSession(threadId, (session) => ({
734
+ ...session,
735
+ error: errorUtil.toError(caught),
736
+ status: "error",
737
+ abortController:
738
+ session.abortController === controller ? null : session.abortController,
739
+ runId: runIdRef.current,
740
+ }));
741
+ }
742
+ },
743
+ [getSession, mastraClient, processStream, refreshThreadsSoon, updateSession],
744
+ );
745
+
746
+ const runStream = useCallback(
747
+ (threadId: string, history: UIMessage[]) => {
748
+ const assistantId = nanoid();
749
+ const runId = nanoid();
750
+ updateSession(threadId, (session) => ({
751
+ ...session,
752
+ assistantId,
753
+ runId,
754
+ }));
755
+ return driveStream(threadId, assistantId, () => {
756
+ const agent = mastraClient.getAgent(agentId);
757
+ const messagesForAgent = history.flatMap((m) =>
758
+ m.parts
759
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
760
+ .map((p) => ({ role: m.role, content: p.text })),
761
+ ) as Parameters<typeof agent.stream>[0];
762
+ return agent.stream(messagesForAgent, {
763
+ runId,
764
+ }) as Promise<MastraStreamResponse>;
765
+ });
766
+ },
767
+ [driveStream, mastraClient, agentId, updateSession],
768
+ );
769
+
770
+ const stop = useCallback(() => {
771
+ const threadId = activeKey;
772
+ updateSession(threadId, (session) => {
773
+ if (!isSessionRunning(session)) return session;
774
+ session.abortController?.abort();
775
+ return {
776
+ ...session,
777
+ abortController: null,
778
+ runToken: session.runToken + 1,
779
+ error: null,
780
+ status: "ready",
781
+ };
782
+ });
783
+ }, [activeKey, updateSession]);
784
+
785
+ /**
786
+ * Approve or deny an in-flight `requireApproval` tool call. The
787
+ * suspended workflow lives on the server keyed by `runId`; we resume
788
+ * via {@link MastraPluginClient.approveToolCallStream} /
789
+ * `declineToolCallStream` and pipe the SSE chunk stream back into
790
+ * the same bubble `runStream` was building.
791
+ */
792
+ const handleApproval = useCallback(
793
+ async (decision: ApprovalDecision) => {
794
+ const { runId: decisionRunId, toolCallId, toolName } = decision;
795
+ const session = getSession(activeKey);
796
+ const assistantId = session.assistantId;
797
+ const runId = decisionRunId ?? session.runId;
798
+ if (!runId || !assistantId) {
799
+ logger.warn("approval missing runId or assistantId, cannot resume", {
800
+ tool: toolName,
801
+ toolCallId,
802
+ hasRunId: Boolean(runId),
803
+ hasAssistantId: Boolean(assistantId),
804
+ });
805
+ return;
806
+ }
807
+ updateSession(activeKey, (current) => {
808
+ const existing = current.pendingApprovalsByMessage[assistantId];
809
+ if (!existing) return current;
810
+ const next = existing.filter((a) => a.toolCallId !== toolCallId);
811
+ if (next.length === 0) {
812
+ const { [assistantId]: _drop, ...rest } = current.pendingApprovalsByMessage;
813
+ return { ...current, pendingApprovalsByMessage: rest };
814
+ }
815
+ return {
816
+ ...current,
817
+ pendingApprovalsByMessage: {
818
+ ...current.pendingApprovalsByMessage,
819
+ [assistantId]: next,
820
+ },
821
+ };
822
+ });
823
+ logger.info(decision.approved ? "approved" : "denied", {
824
+ tool: toolName,
825
+ toolCallId,
826
+ runId,
827
+ });
828
+ await driveStream(activeKey, assistantId, () =>
829
+ decision.approved
830
+ ? mastraClient.approveToolCallStream(agentId, { runId, toolCallId })
831
+ : mastraClient.declineToolCallStream(agentId, { runId, toolCallId }),
832
+ );
833
+ },
834
+ [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
835
+ );
836
+
837
+ const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
838
+ (message) => {
839
+ const text = message.text ?? "";
840
+ if (!text) return;
841
+ const threadId = activeKey;
842
+ updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
843
+ if (activeThreadId) {
844
+ noteThreadActivity(activeThreadId);
845
+ if (getSession(threadId).messages.length === 0) {
846
+ const provisional = deriveThreadTitle(text);
847
+ if (provisional) {
848
+ setProvisionalTitles((prev) =>
849
+ prev[activeThreadId] ? prev : { ...prev, [activeThreadId]: provisional },
850
+ );
851
+ }
852
+ }
853
+ }
854
+ const next = [...getSession(threadId).messages, makeUserMessage(text)];
855
+ writeMessages(threadId, next);
856
+ void runStream(threadId, next);
857
+ },
858
+ [
859
+ runStream,
860
+ writeMessages,
861
+ activeThreadId,
862
+ activeKey,
863
+ getSession,
864
+ noteThreadActivity,
865
+ updateSession,
866
+ ],
867
+ );
868
+
869
+ /**
870
+ * Wipe the current thread on the server and reset every piece of
871
+ * client-side state that mirrored it. The session cookie that
872
+ * anchors the thread id is preserved by the server, so the next
873
+ * turn opens against the same (now empty) thread - no reload
874
+ * needed. Suspended approval cards belong to the cleared turns
875
+ * and would be unresolvable anyway, so we drop them too.
876
+ */
877
+ const handleClear = useCallback(async () => {
878
+ const threadId = activeKey;
879
+ try {
880
+ const result = await mastraClient.clearHistory({ agentId });
881
+ logger.info("history cleared", { cleared: result.cleared });
882
+ } catch (error) {
883
+ logger.error("history clear error", {
884
+ error: errorUtil.errorMessage(error),
885
+ });
886
+ }
887
+ const session = getSession(threadId);
888
+ session.abortController?.abort();
889
+ updateSession(threadId, () => ({
890
+ ...createThreadSession(),
891
+ historyLoaded: true,
892
+ }));
893
+ if (activeThreadId) {
894
+ setOptimisticThreads((prev) => {
895
+ if (!prev[activeThreadId]) return prev;
896
+ const { [activeThreadId]: _drop, ...rest } = prev;
897
+ return rest;
898
+ });
899
+ setProvisionalTitles((prev) => {
900
+ if (!(activeThreadId in prev)) return prev;
901
+ const { [activeThreadId]: _drop, ...rest } = prev;
902
+ return rest;
903
+ });
904
+ }
905
+ refreshThreadsSoon();
906
+ }, [
907
+ mastraClient,
908
+ agentId,
909
+ activeKey,
910
+ activeThreadId,
911
+ getSession,
912
+ refreshThreadsSoon,
913
+ updateSession,
914
+ ]);
915
+
916
+ const selectThread = useCallback(
917
+ (threadId: string) => {
918
+ if (threadId === activeThreadId) return;
919
+ setActiveThreadId(threadId);
920
+ },
921
+ [activeThreadId],
922
+ );
923
+
924
+ const newThread = useCallback(() => {
925
+ const id = nanoid();
926
+ sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
927
+ notifySessions();
928
+ setActiveThreadId(id);
929
+ }, [notifySessions]);
930
+
931
+ const deleteThread = useCallback(
932
+ async (threadId: string) => {
933
+ try {
934
+ const result = await mastraClient.removeThread(threadId, { agentId });
935
+ logger.info("thread deleted", { threadId, deleted: result.deleted });
936
+ } catch (error) {
937
+ logger.error("thread delete error", {
938
+ threadId,
939
+ error: errorUtil.errorMessage(error),
940
+ });
941
+ }
942
+ const session = sessionsRef.current.get(threadId);
943
+ session?.abortController?.abort();
944
+ sessionsRef.current.delete(threadId);
945
+ notifySessions();
946
+ setOptimisticThreads((prev) => {
947
+ if (!prev[threadId]) return prev;
948
+ const { [threadId]: _drop, ...rest } = prev;
949
+ return rest;
950
+ });
951
+ setProvisionalTitles((prev) => {
952
+ if (!(threadId in prev)) return prev;
953
+ const { [threadId]: _drop, ...rest } = prev;
954
+ return rest;
955
+ });
956
+ if (threadId === activeThreadId) {
957
+ const id = nanoid();
958
+ sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
959
+ notifySessions();
960
+ setActiveThreadId(id);
961
+ }
962
+ refreshThreads();
963
+ },
964
+ [mastraClient, agentId, activeThreadId, notifySessions, refreshThreads],
965
+ );
966
+
967
+ /**
968
+ * Rename a conversation. Optimistically overlays the new title so the
969
+ * sidebar updates instantly (both the server-row overlay and any
970
+ * still-pending optimistic row for a brand-new thread), persists it,
971
+ * then refreshes the list. On failure the overlay is dropped so the
972
+ * real (unchanged) title reappears.
973
+ */
974
+ const renameThread = useCallback(
975
+ async (threadId: string, rawTitle: string) => {
976
+ const title = rawTitle.trim();
977
+ if (!title) return;
978
+ setRenamedThreads((prev) => ({ ...prev, [threadId]: title }));
979
+ setOptimisticThreads((prev) =>
980
+ prev[threadId]
981
+ ? { ...prev, [threadId]: { ...prev[threadId], id: threadId, title } }
982
+ : prev,
983
+ );
984
+ try {
985
+ await mastraClient.renameThread(threadId, title, { agentId });
986
+ logger.info("thread renamed", { threadId });
987
+ } catch (error) {
988
+ logger.error("thread rename error", {
989
+ threadId,
990
+ error: errorUtil.errorMessage(error),
991
+ });
992
+ setRenamedThreads((prev) => {
993
+ if (!(threadId in prev)) return prev;
994
+ const { [threadId]: _drop, ...rest } = prev;
995
+ return rest;
996
+ });
997
+ }
998
+ refreshThreads();
999
+ },
1000
+ [mastraClient, agentId, refreshThreads],
1001
+ );
1002
+
1003
+ const regenerate = useCallback(() => {
1004
+ const threadId = activeKey;
1005
+ const lastUserText = getSession(threadId).lastUserText;
1006
+ if (!lastUserText) return;
1007
+ const prev = getSession(threadId).messages;
1008
+ const lastAssistant =
1009
+ prev.length > 0 && prev.at(-1)?.role === "assistant" ? prev.at(-1) : null;
1010
+ const trimmed = lastAssistant ? prev.slice(0, -1) : prev;
1011
+ if (lastAssistant) {
1012
+ updateSession(threadId, (session) => {
1013
+ const { [lastAssistant.id]: _events, ...toolEvents } =
1014
+ session.toolEventsByMessage;
1015
+ const { [lastAssistant.id]: _approvals, ...pendingApprovals } =
1016
+ session.pendingApprovalsByMessage;
1017
+ const { [lastAssistant.id]: _feedback, ...feedback } =
1018
+ session.feedbackByMessage;
1019
+ return {
1020
+ ...session,
1021
+ toolEventsByMessage: toolEvents,
1022
+ pendingApprovalsByMessage: pendingApprovals,
1023
+ feedbackByMessage: feedback,
1024
+ };
1025
+ });
1026
+ }
1027
+ writeMessages(threadId, trimmed);
1028
+ void runStream(threadId, trimmed);
1029
+ }, [activeKey, getSession, runStream, updateSession, writeMessages]);
1030
+
1031
+ // Hydrate the active thread from the server when it has no local
1032
+ // session yet. In-flight streams keep updating their session in the
1033
+ // background, so switching back shows live partial text without
1034
+ // refetching or aborting other threads' runs.
1035
+ useEffect(() => {
1036
+ const threadId = activeKey;
1037
+ mastraClient.setThreadId(activeThreadId);
1038
+ const session = getSession(threadId);
1039
+ if (session.historyLoaded) {
1040
+ setIsLoadingHistory(false);
1041
+ return;
1042
+ }
1043
+
1044
+ let cancelled = false;
1045
+ const controller = new AbortController();
1046
+ historyInFlightRef.current = true;
1047
+ setIsLoadingHistory(true);
1048
+ mastraClient
1049
+ .history({
1050
+ agentId,
1051
+ page: 0,
1052
+ perPage: HISTORY_PAGE_SIZE,
1053
+ signal: controller.signal,
1054
+ })
1055
+ .then((response) => {
1056
+ if (cancelled) return;
1057
+ updateSession(threadId, (current) => ({
1058
+ ...current,
1059
+ messages: response.uiMessages as unknown as UIMessage[],
1060
+ historyLoaded: true,
1061
+ hasMoreHistory: response.hasMore,
1062
+ historyPage: 1,
1063
+ toolEventsByMessage: {},
1064
+ pendingApprovalsByMessage: {},
1065
+ feedbackByMessage: {},
1066
+ }));
1067
+ })
1068
+ .catch((error: unknown) => {
1069
+ if (cancelled || (error as { name?: string }).name === "AbortError") return;
1070
+ logger.error("history load error", {
1071
+ error: errorUtil.errorMessage(error),
1072
+ });
1073
+ updateSession(threadId, (current) => ({ ...current, hasMoreHistory: false }));
1074
+ })
1075
+ .finally(() => {
1076
+ historyInFlightRef.current = false;
1077
+ if (!cancelled) setIsLoadingHistory(false);
1078
+ });
1079
+ return () => {
1080
+ cancelled = true;
1081
+ controller.abort();
1082
+ };
1083
+ }, [mastraClient, agentId, activeThreadId, activeKey, getSession, updateSession]);
1084
+
1085
+ const loadOlderHistory = useCallback(() => {
1086
+ const threadId = activeKey;
1087
+ const session = getSession(threadId);
1088
+ if (historyInFlightRef.current || !session.hasMoreHistory) return;
1089
+ historyInFlightRef.current = true;
1090
+ setIsLoadingMore(true);
1091
+ const page = session.historyPage;
1092
+ updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
1093
+ mastraClient
1094
+ .history({ agentId, page, perPage: HISTORY_PAGE_SIZE })
1095
+ .then((response) => {
1096
+ const uiMessages = response.uiMessages as unknown as UIMessage[];
1097
+ if (uiMessages.length > 0) {
1098
+ const currentMessages = getSession(threadId).messages;
1099
+ writeMessages(threadId, [...uiMessages, ...currentMessages]);
1100
+ }
1101
+ updateSession(threadId, (current) => ({
1102
+ ...current,
1103
+ hasMoreHistory: response.hasMore,
1104
+ }));
1105
+ })
1106
+ .catch((error: unknown) => {
1107
+ logger.error("history load-more error", {
1108
+ page,
1109
+ error: errorUtil.errorMessage(error),
1110
+ });
1111
+ updateSession(threadId, (current) => ({
1112
+ ...current,
1113
+ historyPage: page,
1114
+ hasMoreHistory: false,
1115
+ }));
1116
+ })
1117
+ .finally(() => {
1118
+ historyInFlightRef.current = false;
1119
+ setIsLoadingMore(false);
1120
+ });
1121
+ }, [activeKey, getSession, mastraClient, agentId, updateSession, writeMessages]);
1122
+
1123
+ // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
1124
+ // straight off the client so the export inlines the same charts /
1125
+ // tables the UI renders. Handlers are defined unconditionally (rules of
1126
+ // hooks) and only surfaced to ChatView when `enableExport` is on.
1127
+ const exportResolver = useMemo<EmbedResolver>(
1128
+ () => ({
1129
+ chart: (id) => mastraClient.chart(id),
1130
+ statement: (id) => mastraClient.statement(id),
1131
+ }),
1132
+ [mastraClient],
1133
+ );
1134
+ // Speaker label for the human turns in an export. The resource id is
1135
+ // the signed-in user's identity (every thread the client lists is its
1136
+ // own), so any owned thread supplies it; fall back to a bare "User"
1137
+ // before the first thread lands.
1138
+ const exportUserLabel = useMemo(() => {
1139
+ const resourceId = threads.find((t) => t.resourceId)?.resourceId;
1140
+ return resourceId ? `User (${resourceId})` : "User";
1141
+ }, [threads]);
1142
+ const exportConversation = useCallback(
1143
+ async (format: ExportFormat) => {
1144
+ const title =
1145
+ (activeThreadId && threads.find((t) => t.id === activeThreadId)?.title) ||
1146
+ "Conversation";
1147
+ try {
1148
+ await exportChat({
1149
+ messages: getSession(activeKey).messages,
1150
+ format,
1151
+ resolver: exportResolver,
1152
+ title,
1153
+ userLabel: exportUserLabel,
1154
+ });
1155
+ } catch (error) {
1156
+ logger.error("conversation export error", {
1157
+ format,
1158
+ error: errorUtil.errorMessage(error),
1159
+ });
1160
+ }
1161
+ },
1162
+ [exportResolver, activeThreadId, activeKey, getSession, threads, exportUserLabel],
1163
+ );
1164
+ const exportMessage = useCallback(
1165
+ async (message: UIMessage, format: ExportFormat) => {
1166
+ try {
1167
+ await exportChat({
1168
+ messages: [message],
1169
+ format,
1170
+ resolver: exportResolver,
1171
+ title: "Message",
1172
+ filename: "message",
1173
+ userLabel: exportUserLabel,
1174
+ });
1175
+ } catch (error) {
1176
+ logger.error("message export error", {
1177
+ format,
1178
+ error: errorUtil.errorMessage(error),
1179
+ });
1180
+ }
1181
+ },
1182
+ [exportResolver, exportUserLabel],
1183
+ );
1184
+
1185
+ // Submit thumbs / comment feedback for an assistant message to MLflow
1186
+ // via the plugin's feedback route. The message's captured trace id
1187
+ // scopes the assessment; without one there's nothing to attach to, so
1188
+ // the call is skipped. A thumbs value is reflected optimistically so
1189
+ // the active button highlights immediately; a soft "not recorded"
1190
+ // (e.g. the trace is still exporting) is logged, not surfaced as an
1191
+ // error, to keep the chat calm.
1192
+ const submitFeedback = useCallback(
1193
+ async (message: UIMessage, submission: FeedbackSubmission) => {
1194
+ const traceId = feedbackByMessageRef.current[message.id]?.traceId;
1195
+ if (!traceId) return;
1196
+ if (submission.value) {
1197
+ updateSession(activeKey, (session) => ({
1198
+ ...session,
1199
+ feedbackByMessage: {
1200
+ ...session.feedbackByMessage,
1201
+ [message.id]: { traceId, value: submission.value },
1202
+ },
1203
+ }));
1204
+ }
1205
+ try {
1206
+ const result = await mastraClient.feedback({
1207
+ traceId,
1208
+ ...(submission.value !== undefined
1209
+ ? { value: submission.value === "up" }
1210
+ : {}),
1211
+ ...(submission.comment ? { comment: submission.comment } : {}),
1212
+ });
1213
+ if (!result.ok) {
1214
+ logger.warn("feedback not recorded (trace may still be exporting)", {
1215
+ traceId,
1216
+ });
1217
+ }
1218
+ } catch (error) {
1219
+ logger.error("feedback error", {
1220
+ traceId,
1221
+ error: errorUtil.errorMessage(error),
1222
+ });
1223
+ }
1224
+ },
1225
+ [activeKey, mastraClient, updateSession],
1226
+ );
1227
+
1228
+ // Merge optimistic rows
1229
+ // the server list, newest first, dropping any optimistic entry the
1230
+ // server already returns so a thread is never listed twice.
1231
+ const sidebarThreads = useMemo<ThreadSummary[]>(() => {
1232
+ // Title overlay precedence per row: a manual rename always wins;
1233
+ // otherwise a provisional first-message title fills an untitled row
1234
+ // until the server titles the thread.
1235
+ const withOverlay = (t: ThreadSummary): ThreadSummary => {
1236
+ const renamed = renamedThreads[t.id];
1237
+ if (renamed !== undefined) return { ...t, title: renamed };
1238
+ if (!t.title && provisionalTitles[t.id] !== undefined) {
1239
+ return { ...t, title: provisionalTitles[t.id] };
1240
+ }
1241
+ return t;
1242
+ };
1243
+ const server = threads.map(toThreadSummary).map(withOverlay);
1244
+ const serverIds = new Set(server.map((t) => t.id));
1245
+ const pending = Object.values(optimisticThreads)
1246
+ .filter((t) => !serverIds.has(t.id))
1247
+ .map(withOverlay)
1248
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
1249
+ return [...pending, ...server];
1250
+ }, [threads, optimisticThreads, renamedThreads, provisionalTitles]);
1251
+ // Once the server list includes a thread we were tracking optimistically,
1252
+ // drop the optimistic copy so the map doesn't grow without bound.
1253
+ useEffect(() => {
1254
+ const serverIds = new Set(threads.map((t) => t.id));
1255
+ const stale = Object.keys(optimisticThreads).filter((id) => serverIds.has(id));
1256
+ if (stale.length === 0) return;
1257
+ setOptimisticThreads((prev) => {
1258
+ const next = { ...prev };
1259
+ for (const id of stale) delete next[id];
1260
+ return next;
1261
+ });
1262
+ }, [threads, optimisticThreads]);
1263
+ // Drop a rename overlay once the server list reports the new title, so
1264
+ // the map doesn't grow without bound and later server-side title
1265
+ // changes aren't masked by a stale override.
1266
+ useEffect(() => {
1267
+ const settled = threads
1268
+ .filter(
1269
+ (t) => renamedThreads[t.id] !== undefined && t.title === renamedThreads[t.id],
1270
+ )
1271
+ .map((t) => t.id);
1272
+ if (settled.length === 0) return;
1273
+ setRenamedThreads((prev) => {
1274
+ const next = { ...prev };
1275
+ for (const id of settled) delete next[id];
1276
+ return next;
1277
+ });
1278
+ }, [threads, renamedThreads]);
1279
+ // Drop a provisional first-message title once the server reports any
1280
+ // real title for that thread, so the auto-generated title takes over.
1281
+ useEffect(() => {
1282
+ const settled = threads
1283
+ .filter((t) => t.title && provisionalTitles[t.id] !== undefined)
1284
+ .map((t) => t.id);
1285
+ if (settled.length === 0) return;
1286
+ setProvisionalTitles((prev) => {
1287
+ const next = { ...prev };
1288
+ for (const id of settled) delete next[id];
1289
+ return next;
1290
+ });
1291
+ }, [threads, provisionalTitles]);
1292
+
1293
+ return {
1294
+ messages: activeSession.messages,
1295
+ status: activeSession.status,
1296
+ error: activeSession.error,
1297
+ sendMessage,
1298
+ regenerate,
1299
+ onStop: stop,
1300
+ suggestions,
1301
+ toolEventsByMessage: activeSession.toolEventsByMessage,
1302
+ pendingApprovalsByMessage: activeSession.pendingApprovalsByMessage,
1303
+ onResolveToolApproval: handleApproval,
1304
+ // Picker is opt-in: only hand ChatView the catalogue + change
1305
+ // handler when `showModelPicker` is on, otherwise the header hides
1306
+ // it (ChatView shows it only when both are present).
1307
+ models: showModelPicker ? models : undefined,
1308
+ model,
1309
+ onModelChange: showModelPicker ? setModel : undefined,
1310
+ onLoadMore: loadOlderHistory,
1311
+ isLoadingMore,
1312
+ hasMore: activeSession.hasMoreHistory,
1313
+ isLoadingHistory,
1314
+ onClear: handleClear,
1315
+ // Conversation management: hand ChatView the thread list + handlers
1316
+ // only when enabled, so the sidebar stays hidden for the classic
1317
+ // single-thread chat (ChatView keys the sidebar off these props).
1318
+ ...(enableThreads
1319
+ ? {
1320
+ threads: sidebarThreads,
1321
+ ...(activeThreadId ? { activeThreadId } : {}),
1322
+ streamingThreadIds,
1323
+ isLoadingThreads,
1324
+ onSelectThread: selectThread,
1325
+ onNewThread: newThread,
1326
+ onDeleteThread: deleteThread,
1327
+ onRenameThread: renameThread,
1328
+ // Persisted sidebar visibility, controlled from the driver so
1329
+ // the show/hide choice survives reloads.
1330
+ sidebarOpen,
1331
+ onToggleSidebar: toggleSidebar,
1332
+ }
1333
+ : {}),
1334
+ // Export is opt-in: only expose the handlers (which light up the
1335
+ // header + per-message export menus in ChatView) when enabled.
1336
+ ...(enableExport
1337
+ ? {
1338
+ onExportConversation: exportConversation,
1339
+ onExportMessage: exportMessage,
1340
+ }
1341
+ : {}),
1342
+ // Feedback: only expose the state + handler (which light up the
1343
+ // per-bubble thumbs / comment controls in ChatView) when feedback
1344
+ // is enabled AND the server can log to MLflow. `feedbackByMessage`
1345
+ // still gates per-message on a captured trace id.
1346
+ ...(feedbackAvailable
1347
+ ? {
1348
+ feedbackByMessage: activeSession.feedbackByMessage,
1349
+ onFeedback: submitFeedback,
1350
+ }
1351
+ : {}),
1352
+ };
1353
+ };
1354
+
1355
+ /** Props for {@link MastraChat}. */
1356
+ export interface MastraChatProps extends UseMastraChatOptions {
1357
+ /** Extra classes merged onto the chat's root layout container. */
1358
+ className?: string;
1359
+ }
1360
+
1361
+ /**
1362
+ * Self-contained chat component. Mount it anywhere under the Mastra
1363
+ * plugin and it wires itself from the plugin's published client config
1364
+ * (mount paths + default agent) via {@link useMastraChat}, then renders
1365
+ * the conversation through {@link ChatView}. The GenieChat-equivalent
1366
+ * drop-in: full streaming, tool-session pills, approvals, stop control,
1367
+ * history pagination, and built-in conversation management (a sidebar
1368
+ * of the resource's threads with select / new / delete, persisted
1369
+ * across reloads) - all with no host wiring. The model picker is opt-in
1370
+ * via `showModelPicker`; thread management is on by default and can be
1371
+ * turned off with `enableThreads: false`.
1372
+ */
1373
+ export const MastraChat = ({ className, ...options }: MastraChatProps) => {
1374
+ const chat = useMastraChat(options);
1375
+ return <ChatView {...chat} className={className} />;
1376
+ };