@dbx-tools/ui-mastra 0.6.218 → 0.6.219

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.
@@ -1,14 +1,13 @@
1
1
  import { error as sharedError, hash, log } from "@dbx-tools/shared-core";
2
- import type { MastraThread } from "@dbx-tools/shared-mastra";
3
2
  import { useBrand } from "@dbx-tools/ui-branding/react";
4
3
  import type { UIMessage } from "ai";
5
4
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
6
5
  import { useChatApprovals } from "./chat-approvals.ts";
7
- import { ChatView } from "./chat-view.tsx";
8
6
  import { useChatFeedback } from "./chat-feedback.ts";
9
7
  import { useChatHistory } from "./chat-history.ts";
10
8
  import { useChatSessions } from "./chat-sessions.ts";
11
9
  import { useChatStream } from "./chat-stream.ts";
10
+ import { ChatView } from "./chat-view.tsx";
12
11
  import { dedupeSuggestions } from "./suggestions.ts";
13
12
  import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
14
13
  import type { EmbedResolver, ExportFormat } from "../support/export.ts";
@@ -18,21 +17,25 @@ import {
18
17
  useMastraModels,
19
18
  useMastraSuggestions,
20
19
  useMastraThreads,
20
+ type MastraMemoryThread,
21
+ type MastraStreamResponse,
21
22
  } from "../support/mastra-client.ts";
22
- import type { MastraStreamResponse } from "../support/mastra-stream.ts";
23
23
  import {
24
24
  modelStorageKey,
25
25
  readStoredModel,
26
26
  storeSelectedModel,
27
27
  } from "../support/model-selection.ts";
28
+ import {
29
+ snapshotRequestContext,
30
+ type MastraRequestContextSnapshot,
31
+ type MastraRequestContextSource,
32
+ } from "../support/request-context.ts";
28
33
  import {
29
34
  DEFAULT_THREAD_SESSION_KEY,
30
35
  enqueueSteer,
31
36
  isSessionRunning,
32
37
  removeSteer as removeSteerFromQueue,
33
38
  reorderSteers as reorderSteerQueue,
34
- terminateRunningToolEvents,
35
- type ThreadSession,
36
39
  } from "../support/thread-sessions.ts";
37
40
 
38
41
  const _loadChatExport = () => import("../support/export.ts");
@@ -40,21 +43,20 @@ const _loadChatExport = () => import("../support/export.ts");
40
43
  // Self-contained drop-in chat. `useMastraChat` drives the conversation
41
44
  // over `@mastra/client-js`: `agent.stream()` returns a Response
42
45
  // augmented with `processDataStream()`, which pushes typed Mastra
43
- // chunks (text-delta, reasoning-delta, tool-*, ...) that we translate
44
- // into `UIMessage` parts for `ChatView` to render.
46
+ // chunks that `@mastra/ai-sdk` projects into native AI SDK `UIMessage`
47
+ // parts for `ChatView` to render.
45
48
  //
46
49
  // Approval gates ride the same channel: a paused `requireApproval: true`
47
50
  // tool call emits a `tool-call-approval` chunk carrying
48
- // `{ runId, payload: { toolCallId, toolName, args } }`. We surface that
49
- // as an out-of-band entry in `pendingApprovalsByMessage` and wire
51
+ // `{ runId, payload: { toolCallId, toolName, args } }`. The native stream
52
+ // converter preserves that data part and wires
50
53
  // `onResolveToolApproval` to {@link MastraPluginClient.approveToolCallStream}
51
- // / `declineToolCallStream` - both of which read SSE directly (avoiding a
52
- // stock `@mastra/client-js` bug on resumed approval streams) and return a
53
- // fresh stream Response we run through the same chunk handler.
54
+ // / `declineToolCallStream`. Both use the native per-request Mastra client and
55
+ // return a fresh native stream Response we run through the same chunk handler.
54
56
  //
55
57
  // On mount the transcript hydrates with the most recent page of thread
56
- // history from the Mastra plugin's `/route/history` endpoint; scrolling near
57
- // the top lazy-loads and prepends the next older page, with `ChatView`
58
+ // history from Mastra's resource-scoped memory endpoint; scrolling near the
59
+ // top lazy-loads and prepends the next older page, with `ChatView`
58
60
  // preserving the visual scroll position across the prepend.
59
61
 
60
62
  const logger = log.logger("ui-mastra/chat");
@@ -65,11 +67,11 @@ const makeUserMessage = (text: string): UIMessage => ({
65
67
  parts: [{ type: "text", text }],
66
68
  });
67
69
 
68
- /** Project a wire {@link MastraThread} down to the sidebar's view. */
69
- const toThreadSummary = (thread: MastraThread): ThreadSummary => ({
70
+ /** Project a native Mastra memory thread down to the sidebar's view. */
71
+ const toThreadSummary = (thread: MastraMemoryThread): ThreadSummary => ({
70
72
  id: thread.id,
71
73
  ...(thread.title ? { title: thread.title } : {}),
72
- updatedAt: thread.updatedAt,
74
+ updatedAt: new Date(thread.updatedAt).toISOString(),
73
75
  });
74
76
 
75
77
  /** Max characters of the first user message used as a provisional thread title. */
@@ -142,7 +144,9 @@ const storeStoredSidebarOpen = (key: string, open: boolean): void => {
142
144
  };
143
145
 
144
146
  /** Options for {@link useMastraChat}. */
145
- export interface UseMastraChatOptions {
147
+ export interface UseMastraChatOptions<
148
+ TValues extends Record<string, unknown> = Record<string, unknown>,
149
+ > {
146
150
  /**
147
151
  * Agent to converse with. Defaults to the Mastra plugin's registered
148
152
  * default agent (`clientConfig().defaultAgent`).
@@ -206,6 +210,11 @@ export interface UseMastraChatOptions {
206
210
  * without MLflow tracing is a safe no-op.
207
211
  */
208
212
  enableFeedback?: boolean;
213
+ /**
214
+ * Typed application metadata forwarded through Mastra's native
215
+ * `requestContext` field. A resolver is read when each turn is submitted.
216
+ */
217
+ requestContext?: MastraRequestContextSource<TValues>;
209
218
  }
210
219
 
211
220
  /**
@@ -216,8 +225,8 @@ export interface UseMastraChatOptions {
216
225
  * consumes. Use this when you want the drop-in behaviour but need to
217
226
  * render the view yourself; otherwise reach for {@link MastraChat}.
218
227
  */
219
- export const useMastraChat = (
220
- options: UseMastraChatOptions = {},
228
+ export const useMastraChat = <TValues extends Record<string, unknown> = Record<string, unknown>>(
229
+ options: UseMastraChatOptions<TValues> = {},
221
230
  ): Omit<ChatViewProps, "className"> => {
222
231
  // One client drives both the agent stream and the plugin's custom
223
232
  // routes (history / threads / models / suggestions / feedback /
@@ -247,6 +256,8 @@ export const useMastraChat = (
247
256
  // `model` to its dependency list.
248
257
  const modelRef = useRef(model);
249
258
  modelRef.current = model;
259
+ const requestContextRef = useRef(options.requestContext);
260
+ requestContextRef.current = options.requestContext;
250
261
  // Built-in conversation management. When on, the chat always drives an
251
262
  // explicit client thread id (rather than leaning on the per-session
252
263
  // cookie) so it can reference, persist, and switch between the
@@ -266,8 +277,8 @@ export const useMastraChat = (
266
277
  const enableFeedback = options.enableFeedback ?? enableExport;
267
278
  const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
268
279
  const threadKey = threadStorageKey(mastraClient.basePath, agentId);
269
- const [activeThreadId, setActiveThreadId] = useState<string | undefined>(() =>
270
- enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : undefined,
280
+ const [activeThreadId, setActiveThreadId] = useState<string>(() =>
281
+ enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : hash.id(),
271
282
  );
272
283
  const {
273
284
  threads,
@@ -371,10 +382,16 @@ export const useMastraChat = (
371
382
  updateSession,
372
383
  writeMessages,
373
384
  } = useChatSessions(activeThreadId);
385
+ const historyAvailability = isLoadingThreads
386
+ ? "loading"
387
+ : threads.some((thread) => thread.id === activeThreadId) || optimisticThreads[activeThreadId]
388
+ ? "present"
389
+ : "missing";
374
390
  const { isLoadingHistory, isLoadingMore, loadOlderHistory } = useChatHistory({
375
391
  activeKey,
376
392
  activeThreadId,
377
393
  agentId,
394
+ availability: historyAvailability,
378
395
  getSession,
379
396
  mastraClient,
380
397
  updateSession,
@@ -412,9 +429,6 @@ export const useMastraChat = (
412
429
  error: null,
413
430
  status: "submitted",
414
431
  runToken: token,
415
- // Superseding an in-flight run (a steer that interrupts, or a rapid
416
- // re-send) stops its stream, so close any pills it left running.
417
- toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
418
432
  };
419
433
  });
420
434
  const runIdRef = { current: getSession(threadId).runId };
@@ -454,13 +468,15 @@ export const useMastraChat = (
454
468
  );
455
469
 
456
470
  const runStream = useCallback(
457
- (threadId: string, history: UIMessage[]) => {
471
+ (threadId: string, message: UIMessage, requestContext?: MastraRequestContextSnapshot) => {
458
472
  const assistantId = hash.id();
459
473
  const runId = hash.id();
460
474
  updateSession(threadId, (session) => ({
461
475
  ...session,
462
476
  assistantId,
463
477
  runId,
478
+ runRequestContext: requestContext,
479
+ lastRequestContext: requestContext,
464
480
  }));
465
481
  // Capture this run's thread + model at send time and pass them per
466
482
  // call, so a run keeps its own routing even if the user switches
@@ -468,17 +484,13 @@ export const useMastraChat = (
468
484
  const streamThreadId = threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
469
485
  const model = modelRef.current || undefined;
470
486
  return driveStream(threadId, assistantId, (signal) => {
471
- const messages = history.flatMap((m) =>
472
- m.parts
473
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
474
- .map((p) => ({ role: m.role, content: p.text })),
475
- );
476
487
  return mastraClient.streamAgent({
477
488
  agentId,
478
- messages,
489
+ messages: [message],
479
490
  runId,
480
491
  threadId: streamThreadId,
481
492
  model,
493
+ requestContext,
482
494
  signal,
483
495
  });
484
496
  });
@@ -498,8 +510,8 @@ export const useMastraChat = (
498
510
  ...session,
499
511
  queuedSteers: removeSteerFromQueue(session.queuedSteers, head.id),
500
512
  }));
501
- const { next } = appendUserMessage(threadId, head.text);
502
- void runStream(threadId, next);
513
+ const { message } = appendUserMessage(threadId, head.text);
514
+ void runStream(threadId, message, head.requestContext);
503
515
  };
504
516
 
505
517
  // Cancel a thread's in-flight run. Defaults to the active thread (the
@@ -519,9 +531,6 @@ export const useMastraChat = (
519
531
  runToken: session.runToken + 1,
520
532
  error: null,
521
533
  status: "ready",
522
- // Cancelling stops the stream, so close any tool pills still marked
523
- // running - the closing chunks will never arrive.
524
- toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
525
534
  };
526
535
  });
527
536
  },
@@ -537,13 +546,10 @@ export const useMastraChat = (
537
546
  updateSession,
538
547
  });
539
548
 
540
- // Append a user message to a thread's transcript, stamping `lastUserText`,
541
- // thread-activity, and a provisional title for a brand-new thread. Returns
542
- // the pre-append session (so callers can see whether a run was in flight)
543
- // and the new message list. Shared by send + interrupt.
549
+ // Append a user message to a thread's transcript, update thread activity,
550
+ // and derive a provisional title for a brand-new thread.
544
551
  const appendUserMessage = useCallback(
545
- (threadId: string, text: string): { before: ThreadSession; next: UIMessage[] } => {
546
- updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
552
+ (threadId: string, text: string): { message: UIMessage; next: UIMessage[] } => {
547
553
  if (activeThreadId) {
548
554
  noteThreadActivity(activeThreadId);
549
555
  if (getSession(threadId).messages.length === 0) {
@@ -555,10 +561,11 @@ export const useMastraChat = (
555
561
  }
556
562
  }
557
563
  }
558
- const before = getSession(threadId);
559
- const next = [...before.messages, makeUserMessage(text)];
564
+ const session = getSession(threadId);
565
+ const message = makeUserMessage(text);
566
+ const next = [...session.messages, message];
560
567
  writeMessages(threadId, next);
561
- return { before, next };
568
+ return { message, next };
562
569
  },
563
570
  [activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
564
571
  );
@@ -572,15 +579,20 @@ export const useMastraChat = (
572
579
  const text = message.text ?? "";
573
580
  if (!text) return;
574
581
  const threadId = activeKey;
582
+ const requestContext = snapshotRequestContext(requestContextRef.current);
575
583
  if (isSessionRunning(getSession(threadId))) {
576
584
  updateSession(threadId, (session) => ({
577
585
  ...session,
578
- queuedSteers: enqueueSteer(session.queuedSteers, { id: hash.id(), text }),
586
+ queuedSteers: enqueueSteer(session.queuedSteers, {
587
+ id: hash.id(),
588
+ text,
589
+ ...(requestContext ? { requestContext } : {}),
590
+ }),
579
591
  }));
580
592
  return;
581
593
  }
582
- const { next } = appendUserMessage(threadId, text);
583
- void runStream(threadId, next);
594
+ const { message: userMessage } = appendUserMessage(threadId, text);
595
+ void runStream(threadId, userMessage, requestContext);
584
596
  },
585
597
  [appendUserMessage, runStream, activeKey, getSession, updateSession],
586
598
  );
@@ -599,8 +611,8 @@ export const useMastraChat = (
599
611
  queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
600
612
  }));
601
613
  logger.info("steer:send-now", { threadId });
602
- const { next } = appendUserMessage(threadId, steer.text);
603
- void runStream(threadId, next);
614
+ const { message } = appendUserMessage(threadId, steer.text);
615
+ void runStream(threadId, message, steer.requestContext);
604
616
  },
605
617
  [activeKey, appendUserMessage, getSession, runStream, updateSession],
606
618
  );
@@ -630,17 +642,14 @@ export const useMastraChat = (
630
642
 
631
643
  /**
632
644
  * Wipe the current thread on the server and reset every piece of
633
- * client-side state that mirrored it. The session cookie that
634
- * anchors the thread id is preserved by the server, so the next
635
- * turn opens against the same (now empty) thread - no reload
636
- * needed. Suspended approval cards belong to the cleared turns
637
- * and would be unresolvable anyway, so we drop them too.
645
+ * client-side state that mirrored it. The next turn recreates the
646
+ * same client-owned thread id without a reload.
638
647
  */
639
648
  const handleClear = useCallback(async () => {
640
649
  const threadId = activeKey;
641
650
  try {
642
- const result = await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
643
- logger.info("history cleared", { cleared: result.cleared });
651
+ await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
652
+ logger.info("history cleared", { threadId });
644
653
  } catch (error) {
645
654
  logger.error("history clear error", {
646
655
  error: sharedError.errorMessage(error),
@@ -679,8 +688,8 @@ export const useMastraChat = (
679
688
  const deleteThread = useCallback(
680
689
  async (threadId: string) => {
681
690
  try {
682
- const result = await mastraClient.removeThread(threadId, { agentId });
683
- logger.info("thread deleted", { threadId, deleted: result.deleted });
691
+ await mastraClient.removeThread(threadId, { agentId });
692
+ logger.info("thread deleted", { threadId });
684
693
  } catch (error) {
685
694
  logger.error("thread delete error", {
686
695
  threadId,
@@ -744,28 +753,52 @@ export const useMastraChat = (
744
753
 
745
754
  const regenerate = useCallback(() => {
746
755
  const threadId = activeKey;
747
- const lastUserText = getSession(threadId).lastUserText;
748
- if (!lastUserText) return;
749
756
  const prev = getSession(threadId).messages;
750
757
  const lastAssistant = prev.length > 0 && prev.at(-1)?.role === "assistant" ? prev.at(-1) : null;
751
758
  const trimmed = lastAssistant ? prev.slice(0, -1) : prev;
759
+ const lastUser = [...trimmed].reverse().find((message) => message.role === "user");
760
+ if (!lastUser) return;
752
761
  if (lastAssistant) {
753
762
  updateSession(threadId, (session) => {
754
- const { [lastAssistant.id]: _events, ...toolEvents } = session.toolEventsByMessage;
755
763
  const { [lastAssistant.id]: _approvals, ...pendingApprovals } =
756
764
  session.pendingApprovalsByMessage;
757
765
  const { [lastAssistant.id]: _feedback, ...feedback } = session.feedbackByMessage;
758
766
  return {
759
767
  ...session,
760
- toolEventsByMessage: toolEvents,
761
768
  pendingApprovalsByMessage: pendingApprovals,
762
769
  feedbackByMessage: feedback,
763
770
  };
764
771
  });
765
772
  }
766
- writeMessages(threadId, trimmed);
767
- void runStream(threadId, trimmed);
768
- }, [activeKey, getSession, runStream, updateSession, writeMessages]);
773
+ void mastraClient
774
+ .deleteMessages(activeThreadId, [lastUser.id, ...(lastAssistant ? [lastAssistant.id] : [])], {
775
+ agentId,
776
+ })
777
+ .then(() => {
778
+ writeMessages(threadId, trimmed);
779
+ void runStream(threadId, lastUser, getSession(threadId).lastRequestContext);
780
+ })
781
+ .catch((error: unknown) => {
782
+ logger.error("regenerate cleanup error", {
783
+ threadId,
784
+ error: sharedError.errorMessage(error),
785
+ });
786
+ updateSession(threadId, (session) => ({
787
+ ...session,
788
+ error: sharedError.toError(error),
789
+ status: "error",
790
+ }));
791
+ });
792
+ }, [
793
+ activeKey,
794
+ activeThreadId,
795
+ agentId,
796
+ getSession,
797
+ mastraClient,
798
+ runStream,
799
+ updateSession,
800
+ writeMessages,
801
+ ]);
769
802
 
770
803
  // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
771
804
  // straight off the client so the export inlines the same charts /
@@ -922,7 +955,6 @@ export const useMastraChat = (
922
955
  regenerate,
923
956
  onStop: stop,
924
957
  suggestions,
925
- toolEventsByMessage: activeSession.toolEventsByMessage,
926
958
  pendingApprovalsByMessage: activeSession.pendingApprovalsByMessage,
927
959
  onResolveToolApproval: handleApproval,
928
960
  // Picker is opt-in: only hand ChatView the catalogue + change
@@ -982,11 +1014,15 @@ export const useMastraChat = (
982
1014
  };
983
1015
 
984
1016
  /** Props for {@link MastraChat}. */
985
- export interface MastraChatProps extends UseMastraChatOptions {
1017
+ export interface MastraChatProps<
1018
+ TValues extends Record<string, unknown> = Record<string, unknown>,
1019
+ > extends UseMastraChatOptions<TValues> {
986
1020
  /** Extra classes merged onto the chat's root layout container. */
987
1021
  className?: string;
988
- /** Host controls placed beside the model picker in the composer toolbar. */
1022
+ /** Host controls placed beside the model picker in the composer footer. */
989
1023
  composerActions?: ReactNode;
1024
+ /** Host controls placed on the left side of the composer footer. */
1025
+ composerLeadingActions?: ReactNode;
990
1026
  }
991
1027
 
992
1028
  /**
@@ -1002,7 +1038,19 @@ export interface MastraChatProps extends UseMastraChatOptions {
1002
1038
  * `threadPlacement` (`auto` docks it left and falls back to a tab strip on a
1003
1039
  * narrow chat) and turned off with `threadPlacement: "disabled"`.
1004
1040
  */
1005
- export const MastraChat = ({ className, composerActions, ...options }: MastraChatProps) => {
1041
+ export const MastraChat = <TValues extends Record<string, unknown> = Record<string, unknown>>({
1042
+ className,
1043
+ composerActions,
1044
+ composerLeadingActions,
1045
+ ...options
1046
+ }: MastraChatProps<TValues>) => {
1006
1047
  const chat = useMastraChat(options);
1007
- return <ChatView {...chat} className={className} composerActions={composerActions} />;
1048
+ return (
1049
+ <ChatView
1050
+ {...chat}
1051
+ className={className}
1052
+ composerActions={composerActions}
1053
+ composerLeadingActions={composerLeadingActions}
1054
+ />
1055
+ );
1008
1056
  };
@@ -1,4 +1,3 @@
1
- import { wire } from "@dbx-tools/shared-mastra";
2
1
  import {
3
2
  Button,
4
3
  Input,
@@ -192,7 +191,6 @@ export const ThreadSidebar = ({
192
191
  <Input
193
192
  autoFocus
194
193
  value={draftTitle}
195
- maxLength={wire.MASTRA_THREAD_TITLE_MAX}
196
194
  aria-label="Conversation name"
197
195
  onChange={(e) => setDraftTitle(e.target.value)}
198
196
  onKeyDown={(e) => {
@@ -214,59 +212,51 @@ export const ThreadSidebar = ({
214
212
  return (
215
213
  <li key={thread.id}>
216
214
  <div
217
- role="button"
218
- tabIndex={0}
219
- onClick={() => onSelect(thread.id)}
220
- onKeyDown={(e) => {
221
- if (e.key === "Enter" || e.key === " ") {
222
- e.preventDefault();
223
- onSelect(thread.id);
224
- }
225
- }}
226
215
  className={cn(
227
- "group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm",
216
+ "group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
228
217
  "hover:bg-accent hover:text-accent-foreground",
229
218
  isActive && "bg-accent text-accent-foreground",
230
219
  )}
231
220
  >
232
- <div className="min-w-0 flex-1">
221
+ <button
222
+ type="button"
223
+ onClick={() => onSelect(thread.id)}
224
+ aria-current={isActive ? "page" : undefined}
225
+ className="min-w-0 flex-1 cursor-pointer text-left"
226
+ >
233
227
  <div className="flex items-center gap-1.5 truncate">
234
- {isStreaming &&
235
- (onCancel ? (
236
- <Tooltip>
237
- <TooltipTrigger asChild>
238
- <Button
239
- type="button"
240
- variant="ghost"
241
- size="icon"
242
- onClick={(e) => {
243
- e.stopPropagation();
244
- onCancel(thread.id);
245
- }}
246
- aria-label="Stop generating"
247
- className="size-4 shrink-0 text-primary"
248
- >
249
- {/* Spinner by default; swap to a stop square on hover/focus. */}
250
- <Loader2Icon className="size-3 animate-spin group-hover:hidden" />
251
- <SquareIcon className="hidden size-3 fill-current group-hover:block" />
252
- </Button>
253
- </TooltipTrigger>
254
- <TooltipContent>Stop generating</TooltipContent>
255
- </Tooltip>
256
- ) : (
257
- <Loader2Icon
258
- aria-label="Streaming"
259
- className="size-3 shrink-0 animate-spin text-primary"
260
- />
261
- ))}
228
+ {isStreaming && !onCancel ? (
229
+ <Loader2Icon
230
+ aria-label="Streaming"
231
+ className="size-3 shrink-0 animate-spin text-primary"
232
+ />
233
+ ) : null}
262
234
  <span className="truncate">{threadTitle(thread)}</span>
263
235
  </div>
264
236
  {thread.updatedAt && (
265
- <div className="truncate text-xs text-muted-foreground">
237
+ <div className="truncate text-xs text-foreground/70">
266
238
  {relativeTime(thread.updatedAt)}
267
239
  </div>
268
240
  )}
269
- </div>
241
+ </button>
242
+ {isStreaming && onCancel ? (
243
+ <Tooltip>
244
+ <TooltipTrigger asChild>
245
+ <Button
246
+ type="button"
247
+ variant="ghost"
248
+ size="icon"
249
+ onClick={() => onCancel(thread.id)}
250
+ aria-label="Stop generating"
251
+ className="size-6 shrink-0 text-primary"
252
+ >
253
+ <Loader2Icon className="size-3 animate-spin group-hover:hidden" />
254
+ <SquareIcon className="hidden size-3 fill-current group-hover:block" />
255
+ </Button>
256
+ </TooltipTrigger>
257
+ <TooltipContent>Stop generating</TooltipContent>
258
+ </Tooltip>
259
+ ) : null}
270
260
  {onRename && (
271
261
  <Tooltip>
272
262
  <TooltipTrigger asChild>