@dbx-tools/ui-mastra 0.6.218 → 0.6.220

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,22 @@ 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,
480
+ runs: {
481
+ ...session.runs,
482
+ [runId]: {
483
+ assistantId,
484
+ ...(requestContext ? { requestContext } : {}),
485
+ },
486
+ },
464
487
  }));
465
488
  // Capture this run's thread + model at send time and pass them per
466
489
  // call, so a run keeps its own routing even if the user switches
@@ -468,17 +491,13 @@ export const useMastraChat = (
468
491
  const streamThreadId = threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
469
492
  const model = modelRef.current || undefined;
470
493
  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
494
  return mastraClient.streamAgent({
477
495
  agentId,
478
- messages,
496
+ messages: [message],
479
497
  runId,
480
498
  threadId: streamThreadId,
481
499
  model,
500
+ requestContext,
482
501
  signal,
483
502
  });
484
503
  });
@@ -498,8 +517,8 @@ export const useMastraChat = (
498
517
  ...session,
499
518
  queuedSteers: removeSteerFromQueue(session.queuedSteers, head.id),
500
519
  }));
501
- const { next } = appendUserMessage(threadId, head.text);
502
- void runStream(threadId, next);
520
+ const { message } = appendUserMessage(threadId, head.text);
521
+ void runStream(threadId, message, head.requestContext);
503
522
  };
504
523
 
505
524
  // Cancel a thread's in-flight run. Defaults to the active thread (the
@@ -519,9 +538,6 @@ export const useMastraChat = (
519
538
  runToken: session.runToken + 1,
520
539
  error: null,
521
540
  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
541
  };
526
542
  });
527
543
  },
@@ -537,13 +553,10 @@ export const useMastraChat = (
537
553
  updateSession,
538
554
  });
539
555
 
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.
556
+ // Append a user message to a thread's transcript, update thread activity,
557
+ // and derive a provisional title for a brand-new thread.
544
558
  const appendUserMessage = useCallback(
545
- (threadId: string, text: string): { before: ThreadSession; next: UIMessage[] } => {
546
- updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
559
+ (threadId: string, text: string): { message: UIMessage; next: UIMessage[] } => {
547
560
  if (activeThreadId) {
548
561
  noteThreadActivity(activeThreadId);
549
562
  if (getSession(threadId).messages.length === 0) {
@@ -555,10 +568,11 @@ export const useMastraChat = (
555
568
  }
556
569
  }
557
570
  }
558
- const before = getSession(threadId);
559
- const next = [...before.messages, makeUserMessage(text)];
571
+ const session = getSession(threadId);
572
+ const message = makeUserMessage(text);
573
+ const next = [...session.messages, message];
560
574
  writeMessages(threadId, next);
561
- return { before, next };
575
+ return { message, next };
562
576
  },
563
577
  [activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
564
578
  );
@@ -572,15 +586,20 @@ export const useMastraChat = (
572
586
  const text = message.text ?? "";
573
587
  if (!text) return;
574
588
  const threadId = activeKey;
589
+ const requestContext = snapshotRequestContext(requestContextRef.current);
575
590
  if (isSessionRunning(getSession(threadId))) {
576
591
  updateSession(threadId, (session) => ({
577
592
  ...session,
578
- queuedSteers: enqueueSteer(session.queuedSteers, { id: hash.id(), text }),
593
+ queuedSteers: enqueueSteer(session.queuedSteers, {
594
+ id: hash.id(),
595
+ text,
596
+ ...(requestContext ? { requestContext } : {}),
597
+ }),
579
598
  }));
580
599
  return;
581
600
  }
582
- const { next } = appendUserMessage(threadId, text);
583
- void runStream(threadId, next);
601
+ const { message: userMessage } = appendUserMessage(threadId, text);
602
+ void runStream(threadId, userMessage, requestContext);
584
603
  },
585
604
  [appendUserMessage, runStream, activeKey, getSession, updateSession],
586
605
  );
@@ -599,8 +618,8 @@ export const useMastraChat = (
599
618
  queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
600
619
  }));
601
620
  logger.info("steer:send-now", { threadId });
602
- const { next } = appendUserMessage(threadId, steer.text);
603
- void runStream(threadId, next);
621
+ const { message } = appendUserMessage(threadId, steer.text);
622
+ void runStream(threadId, message, steer.requestContext);
604
623
  },
605
624
  [activeKey, appendUserMessage, getSession, runStream, updateSession],
606
625
  );
@@ -630,17 +649,14 @@ export const useMastraChat = (
630
649
 
631
650
  /**
632
651
  * 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.
652
+ * client-side state that mirrored it. The next turn recreates the
653
+ * same client-owned thread id without a reload.
638
654
  */
639
655
  const handleClear = useCallback(async () => {
640
656
  const threadId = activeKey;
641
657
  try {
642
- const result = await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
643
- logger.info("history cleared", { cleared: result.cleared });
658
+ await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
659
+ logger.info("history cleared", { threadId });
644
660
  } catch (error) {
645
661
  logger.error("history clear error", {
646
662
  error: sharedError.errorMessage(error),
@@ -679,8 +695,8 @@ export const useMastraChat = (
679
695
  const deleteThread = useCallback(
680
696
  async (threadId: string) => {
681
697
  try {
682
- const result = await mastraClient.removeThread(threadId, { agentId });
683
- logger.info("thread deleted", { threadId, deleted: result.deleted });
698
+ await mastraClient.removeThread(threadId, { agentId });
699
+ logger.info("thread deleted", { threadId });
684
700
  } catch (error) {
685
701
  logger.error("thread delete error", {
686
702
  threadId,
@@ -744,28 +760,52 @@ export const useMastraChat = (
744
760
 
745
761
  const regenerate = useCallback(() => {
746
762
  const threadId = activeKey;
747
- const lastUserText = getSession(threadId).lastUserText;
748
- if (!lastUserText) return;
749
763
  const prev = getSession(threadId).messages;
750
764
  const lastAssistant = prev.length > 0 && prev.at(-1)?.role === "assistant" ? prev.at(-1) : null;
751
765
  const trimmed = lastAssistant ? prev.slice(0, -1) : prev;
766
+ const lastUser = [...trimmed].reverse().find((message) => message.role === "user");
767
+ if (!lastUser) return;
752
768
  if (lastAssistant) {
753
769
  updateSession(threadId, (session) => {
754
- const { [lastAssistant.id]: _events, ...toolEvents } = session.toolEventsByMessage;
755
770
  const { [lastAssistant.id]: _approvals, ...pendingApprovals } =
756
771
  session.pendingApprovalsByMessage;
757
772
  const { [lastAssistant.id]: _feedback, ...feedback } = session.feedbackByMessage;
758
773
  return {
759
774
  ...session,
760
- toolEventsByMessage: toolEvents,
761
775
  pendingApprovalsByMessage: pendingApprovals,
762
776
  feedbackByMessage: feedback,
763
777
  };
764
778
  });
765
779
  }
766
- writeMessages(threadId, trimmed);
767
- void runStream(threadId, trimmed);
768
- }, [activeKey, getSession, runStream, updateSession, writeMessages]);
780
+ void mastraClient
781
+ .deleteMessages(activeThreadId, [lastUser.id, ...(lastAssistant ? [lastAssistant.id] : [])], {
782
+ agentId,
783
+ })
784
+ .then(() => {
785
+ writeMessages(threadId, trimmed);
786
+ void runStream(threadId, lastUser, getSession(threadId).lastRequestContext);
787
+ })
788
+ .catch((error: unknown) => {
789
+ logger.error("regenerate cleanup error", {
790
+ threadId,
791
+ error: sharedError.errorMessage(error),
792
+ });
793
+ updateSession(threadId, (session) => ({
794
+ ...session,
795
+ error: sharedError.toError(error),
796
+ status: "error",
797
+ }));
798
+ });
799
+ }, [
800
+ activeKey,
801
+ activeThreadId,
802
+ agentId,
803
+ getSession,
804
+ mastraClient,
805
+ runStream,
806
+ updateSession,
807
+ writeMessages,
808
+ ]);
769
809
 
770
810
  // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
771
811
  // straight off the client so the export inlines the same charts /
@@ -922,7 +962,6 @@ export const useMastraChat = (
922
962
  regenerate,
923
963
  onStop: stop,
924
964
  suggestions,
925
- toolEventsByMessage: activeSession.toolEventsByMessage,
926
965
  pendingApprovalsByMessage: activeSession.pendingApprovalsByMessage,
927
966
  onResolveToolApproval: handleApproval,
928
967
  // Picker is opt-in: only hand ChatView the catalogue + change
@@ -982,11 +1021,15 @@ export const useMastraChat = (
982
1021
  };
983
1022
 
984
1023
  /** Props for {@link MastraChat}. */
985
- export interface MastraChatProps extends UseMastraChatOptions {
1024
+ export interface MastraChatProps<
1025
+ TValues extends Record<string, unknown> = Record<string, unknown>,
1026
+ > extends UseMastraChatOptions<TValues> {
986
1027
  /** Extra classes merged onto the chat's root layout container. */
987
1028
  className?: string;
988
- /** Host controls placed beside the model picker in the composer toolbar. */
1029
+ /** Host controls placed beside the model picker in the composer footer. */
989
1030
  composerActions?: ReactNode;
1031
+ /** Host controls placed on the left side of the composer footer. */
1032
+ composerLeadingActions?: ReactNode;
990
1033
  }
991
1034
 
992
1035
  /**
@@ -1002,7 +1045,19 @@ export interface MastraChatProps extends UseMastraChatOptions {
1002
1045
  * `threadPlacement` (`auto` docks it left and falls back to a tab strip on a
1003
1046
  * narrow chat) and turned off with `threadPlacement: "disabled"`.
1004
1047
  */
1005
- export const MastraChat = ({ className, composerActions, ...options }: MastraChatProps) => {
1048
+ export const MastraChat = <TValues extends Record<string, unknown> = Record<string, unknown>>({
1049
+ className,
1050
+ composerActions,
1051
+ composerLeadingActions,
1052
+ ...options
1053
+ }: MastraChatProps<TValues>) => {
1006
1054
  const chat = useMastraChat(options);
1007
- return <ChatView {...chat} className={className} composerActions={composerActions} />;
1055
+ return (
1056
+ <ChatView
1057
+ {...chat}
1058
+ className={className}
1059
+ composerActions={composerActions}
1060
+ composerLeadingActions={composerLeadingActions}
1061
+ />
1062
+ );
1008
1063
  };
@@ -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>