@dbx-tools/ui-mastra 0.6.210 → 0.6.212

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,309 @@
1
+ import { error as sharedError } from "@dbx-tools/shared-core";
2
+ import {
3
+ Alert,
4
+ AlertDescription,
5
+ AlertTitle,
6
+ Button,
7
+ Empty,
8
+ EmptyDescription,
9
+ EmptyHeader,
10
+ EmptyMedia,
11
+ EmptyTitle,
12
+ Spinner,
13
+ } from "@dbx-tools/ui-appkit/react";
14
+ import { ArrowDownIcon, MessageSquareIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
15
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
16
+ import { AssistantBubble, UserBubble } from "./bubbles.tsx";
17
+ import type { ChatViewProps } from "./types.ts";
18
+
19
+ const BOTTOM_THRESHOLD_PX = 24;
20
+ const TOP_LOAD_MORE_THRESHOLD_PX = 120;
21
+
22
+ type ChatTranscriptControllerOptions = {
23
+ messages: ChatViewProps["messages"];
24
+ toolEventsByMessage: NonNullable<ChatViewProps["toolEventsByMessage"]>;
25
+ onLoadMore: ChatViewProps["onLoadMore"];
26
+ isLoadingMore: NonNullable<ChatViewProps["isLoadingMore"]>;
27
+ hasMore: NonNullable<ChatViewProps["hasMore"]>;
28
+ isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
29
+ };
30
+
31
+ /** Shared transcript scrolling actions used by the transcript and composer. */
32
+ export type ChatTranscriptController = {
33
+ scrollRef: React.RefObject<HTMLDivElement | null>;
34
+ contentRef: React.RefObject<HTMLDivElement | null>;
35
+ isAtBottom: boolean;
36
+ handleScroll: React.UIEventHandler<HTMLDivElement>;
37
+ scrollToBottom: () => void;
38
+ resumeFollow: () => void;
39
+ };
40
+
41
+ /** Owns bottom-following and prepend anchoring for the transcript viewport. */
42
+ export const useChatTranscriptController = ({
43
+ messages,
44
+ toolEventsByMessage,
45
+ onLoadMore,
46
+ isLoadingMore,
47
+ hasMore,
48
+ isLoadingHistory,
49
+ }: ChatTranscriptControllerOptions): ChatTranscriptController => {
50
+ const scrollRef = useRef<HTMLDivElement>(null);
51
+ const contentRef = useRef<HTMLDivElement>(null);
52
+ const [isAtBottom, setIsAtBottom] = useState(true);
53
+ const pinnedRef = useRef(true);
54
+ const programmaticScrollRef = useRef(false);
55
+ const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
56
+ const loadMoreRef = useRef(onLoadMore);
57
+ loadMoreRef.current = onLoadMore;
58
+
59
+ const pinToBottomNow = useCallback(() => {
60
+ const element = scrollRef.current;
61
+ if (!element) return;
62
+ programmaticScrollRef.current = true;
63
+ element.scrollTop = element.scrollHeight;
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ const element = scrollRef.current;
68
+ const content = contentRef.current;
69
+ if (!element || !content || typeof ResizeObserver === "undefined") return;
70
+ const observer = new ResizeObserver(() => {
71
+ if (prependAnchorRef.current || !pinnedRef.current) return;
72
+ programmaticScrollRef.current = true;
73
+ element.scrollTop = element.scrollHeight;
74
+ });
75
+ observer.observe(content);
76
+ return () => observer.disconnect();
77
+ }, [messages.length, isLoadingHistory]);
78
+
79
+ useEffect(() => {
80
+ if (prependAnchorRef.current || !pinnedRef.current) return;
81
+ pinToBottomNow();
82
+ }, [messages, toolEventsByMessage, pinToBottomNow]);
83
+
84
+ useLayoutEffect(() => {
85
+ const element = scrollRef.current;
86
+ const anchor = prependAnchorRef.current;
87
+ prependAnchorRef.current = null;
88
+ if (!element || !anchor) return;
89
+ const delta = element.scrollHeight - anchor.scrollHeight;
90
+ element.scrollTop = anchor.scrollTop + delta;
91
+ }, [messages]);
92
+
93
+ const handleScroll: React.UIEventHandler<HTMLDivElement> = (event) => {
94
+ const element = event.currentTarget;
95
+ const atBottom =
96
+ element.scrollHeight - element.scrollTop - element.clientHeight < BOTTOM_THRESHOLD_PX;
97
+ if (programmaticScrollRef.current) programmaticScrollRef.current = false;
98
+ else pinnedRef.current = atBottom;
99
+ setIsAtBottom(atBottom);
100
+
101
+ if (
102
+ element.scrollTop <= TOP_LOAD_MORE_THRESHOLD_PX &&
103
+ hasMore &&
104
+ !isLoadingMore &&
105
+ loadMoreRef.current
106
+ ) {
107
+ prependAnchorRef.current = {
108
+ scrollHeight: element.scrollHeight,
109
+ scrollTop: element.scrollTop,
110
+ };
111
+ loadMoreRef.current();
112
+ }
113
+ };
114
+
115
+ const scrollToBottom = useCallback(() => {
116
+ const element = scrollRef.current;
117
+ if (!element) return;
118
+ pinnedRef.current = true;
119
+ setIsAtBottom(true);
120
+ programmaticScrollRef.current = true;
121
+ element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
122
+ }, []);
123
+
124
+ const resumeFollow = useCallback(() => {
125
+ pinnedRef.current = true;
126
+ setIsAtBottom(true);
127
+ requestAnimationFrame(() => {
128
+ pinToBottomNow();
129
+ requestAnimationFrame(pinToBottomNow);
130
+ });
131
+ }, [pinToBottomNow]);
132
+
133
+ return {
134
+ scrollRef,
135
+ contentRef,
136
+ isAtBottom,
137
+ handleScroll,
138
+ scrollToBottom,
139
+ resumeFollow,
140
+ };
141
+ };
142
+
143
+ type ChatTranscriptProps = {
144
+ controller: ChatTranscriptController;
145
+ messages: ChatViewProps["messages"];
146
+ status: ChatViewProps["status"];
147
+ error: ChatViewProps["error"];
148
+ sendMessage: ChatViewProps["sendMessage"];
149
+ suggestions: NonNullable<ChatViewProps["suggestions"]>;
150
+ toolEventsByMessage: NonNullable<ChatViewProps["toolEventsByMessage"]>;
151
+ regenerate: ChatViewProps["regenerate"];
152
+ isLoadingMore: NonNullable<ChatViewProps["isLoadingMore"]>;
153
+ isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
154
+ onResolveToolApproval: ChatViewProps["onResolveToolApproval"];
155
+ pendingApprovalsByMessage: NonNullable<ChatViewProps["pendingApprovalsByMessage"]>;
156
+ onExportMessage: ChatViewProps["onExportMessage"];
157
+ feedbackByMessage: NonNullable<ChatViewProps["feedbackByMessage"]>;
158
+ onFeedback: ChatViewProps["onFeedback"];
159
+ };
160
+
161
+ /** Internal transcript renderer with loading, error, approval, and feedback states. */
162
+ export const ChatTranscript = ({
163
+ controller,
164
+ messages,
165
+ status,
166
+ error,
167
+ sendMessage,
168
+ suggestions,
169
+ toolEventsByMessage,
170
+ regenerate,
171
+ isLoadingMore,
172
+ isLoadingHistory,
173
+ onResolveToolApproval,
174
+ pendingApprovalsByMessage,
175
+ onExportMessage,
176
+ feedbackByMessage,
177
+ onFeedback,
178
+ }: ChatTranscriptProps) => {
179
+ const isRunning = status === "submitted" || status === "streaming";
180
+ const lastMessage = messages.at(-1);
181
+ const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
182
+ const lastAssistantParts = lastMessage?.role === "assistant" ? lastMessage.parts : [];
183
+ const lastAssistantHasContent =
184
+ lastAssistantParts.some(
185
+ (part) =>
186
+ (part.type === "text" || part.type === "reasoning") &&
187
+ Boolean((part as { text?: string }).text),
188
+ ) || (lastEvents?.length ?? 0) > 0;
189
+ const hasRunningTool = (lastEvents ?? []).some((event) => event.status === "running");
190
+ const waitingLabel = !lastAssistantHasContent
191
+ ? "Thinking..."
192
+ : hasRunningTool
193
+ ? "Working..."
194
+ : "Composing response...";
195
+
196
+ return (
197
+ <div className="relative flex flex-1 flex-col overflow-hidden">
198
+ <div
199
+ ref={controller.scrollRef}
200
+ onScroll={controller.handleScroll}
201
+ className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [overflow-anchor:none] [scrollbar-gutter:stable]"
202
+ >
203
+ {messages.length === 0 && !isLoadingHistory ? (
204
+ <Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
205
+ <EmptyHeader>
206
+ <EmptyMedia variant="icon">
207
+ <MessageSquareIcon className="size-5" />
208
+ </EmptyMedia>
209
+ <EmptyTitle>Start a conversation</EmptyTitle>
210
+ <EmptyDescription>
211
+ {suggestions.length > 0
212
+ ? "Ask anything, or pick a suggestion below."
213
+ : "Ask anything to get started."}
214
+ </EmptyDescription>
215
+ </EmptyHeader>
216
+ </Empty>
217
+ ) : (
218
+ <div
219
+ ref={controller.contentRef}
220
+ className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-4 py-4 md:px-6"
221
+ >
222
+ {(isLoadingMore || isLoadingHistory) && (
223
+ <div className="flex items-center justify-center gap-2 py-1 text-xs text-muted-foreground">
224
+ <Spinner className="size-3" />
225
+ <span>{isLoadingHistory ? "Loading history..." : "Loading older messages..."}</span>
226
+ </div>
227
+ )}
228
+ {messages.map((message, index) => {
229
+ const isLast = index === messages.length - 1;
230
+ if (message.role === "assistant") {
231
+ const messageFeedback = feedbackByMessage[message.id];
232
+ return (
233
+ <AssistantBubble
234
+ key={message.id}
235
+ message={message}
236
+ isLast={isLast}
237
+ status={status}
238
+ events={toolEventsByMessage[message.id]}
239
+ regenerate={regenerate}
240
+ onSuggestionClick={(text) => sendMessage({ text })}
241
+ onResolveToolApproval={onResolveToolApproval}
242
+ externalApprovals={pendingApprovalsByMessage[message.id]}
243
+ {...(onExportMessage
244
+ ? { onExport: (format) => onExportMessage(message, format) }
245
+ : {})}
246
+ {...(onFeedback && messageFeedback
247
+ ? {
248
+ onFeedback: (submission) => onFeedback(message, submission),
249
+ ...(messageFeedback.value
250
+ ? { feedbackValue: messageFeedback.value }
251
+ : {}),
252
+ }
253
+ : {})}
254
+ />
255
+ );
256
+ }
257
+ return <UserBubble key={message.id} message={message} />;
258
+ })}
259
+ {isRunning && (
260
+ <div className="flex h-7 items-center gap-2 px-3 text-xs text-muted-foreground">
261
+ <Spinner className="size-3" />
262
+ <span className="animate-pulse">{waitingLabel}</span>
263
+ </div>
264
+ )}
265
+ {status === "error" && (
266
+ <div className="flex flex-col items-start gap-2">
267
+ <Alert variant="destructive">
268
+ <TriangleAlertIcon className="size-4" />
269
+ <AlertTitle>Something went wrong</AlertTitle>
270
+ <AlertDescription>
271
+ {error
272
+ ? sharedError.errorMessage(error)
273
+ : "The assistant ran into an error. Please try again."}
274
+ </AlertDescription>
275
+ </Alert>
276
+ {regenerate && (
277
+ <Button
278
+ type="button"
279
+ variant="outline"
280
+ size="sm"
281
+ onClick={regenerate}
282
+ className="gap-1.5"
283
+ >
284
+ <RefreshCwIcon className="size-3" />
285
+ Retry
286
+ </Button>
287
+ )}
288
+ </div>
289
+ )}
290
+ </div>
291
+ )}
292
+ </div>
293
+ {!controller.isAtBottom && (
294
+ <div className="pointer-events-none absolute inset-x-0 bottom-4 z-20 mx-auto flex w-full max-w-4xl justify-end px-4 md:px-6">
295
+ <Button
296
+ type="button"
297
+ variant="outline"
298
+ size="icon"
299
+ onClick={controller.scrollToBottom}
300
+ aria-label="Jump to latest message"
301
+ className="pointer-events-auto rounded-full shadow"
302
+ >
303
+ <ArrowDownIcon className="size-4" />
304
+ </Button>
305
+ </div>
306
+ )}
307
+ </div>
308
+ );
309
+ };