@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.
- package/README.md +198 -0
- package/index.ts +32 -0
- package/package.json +62 -0
- package/src/react/bubbles.tsx +428 -0
- package/src/react/chat-view.tsx +574 -0
- package/src/react/data-grid.tsx +342 -0
- package/src/react/embed-slots.tsx +258 -0
- package/src/react/export-menu.tsx +71 -0
- package/src/react/feedback-controls.tsx +139 -0
- package/src/react/index.ts +42 -0
- package/src/react/markdown.tsx +387 -0
- package/src/react/mastra-chat.tsx +1376 -0
- package/src/react/suggestion-pills.tsx +46 -0
- package/src/react/suggestions.ts +109 -0
- package/src/react/thread-sidebar.tsx +315 -0
- package/src/react/tool-pill.tsx +684 -0
- package/src/react/types.ts +295 -0
- package/src/styles.css +22 -0
- package/src/support/chart-option.ts +130 -0
- package/src/support/export.ts +485 -0
- package/src/support/mastra-client.ts +883 -0
- package/src/support/mastra-stream.ts +66 -0
- package/src/support/shiki-plugin.ts +134 -0
- package/src/support/thread-sessions.ts +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Alert,
|
|
3
|
+
AlertDescription,
|
|
4
|
+
AlertTitle,
|
|
5
|
+
Button,
|
|
6
|
+
Empty,
|
|
7
|
+
EmptyDescription,
|
|
8
|
+
EmptyHeader,
|
|
9
|
+
EmptyMedia,
|
|
10
|
+
EmptyTitle,
|
|
11
|
+
InputGroup,
|
|
12
|
+
InputGroupAddon,
|
|
13
|
+
InputGroupButton,
|
|
14
|
+
InputGroupTextarea,
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
Spinner,
|
|
21
|
+
Tooltip,
|
|
22
|
+
TooltipContent,
|
|
23
|
+
TooltipProvider,
|
|
24
|
+
TooltipTrigger,
|
|
25
|
+
cn,
|
|
26
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
27
|
+
import { error as errorUtil } from "@dbx-tools/shared-core";
|
|
28
|
+
import {
|
|
29
|
+
ArrowDownIcon,
|
|
30
|
+
MessageSquareIcon,
|
|
31
|
+
PanelLeftIcon,
|
|
32
|
+
RefreshCwIcon,
|
|
33
|
+
SendIcon,
|
|
34
|
+
SquareIcon,
|
|
35
|
+
Trash2Icon,
|
|
36
|
+
TriangleAlertIcon,
|
|
37
|
+
} from "lucide-react";
|
|
38
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
39
|
+
import { AssistantBubble, UserBubble } from "./bubbles";
|
|
40
|
+
import { ExportMenu } from "./export-menu";
|
|
41
|
+
import { SuggestionPills } from "./suggestion-pills";
|
|
42
|
+
import { ThreadSidebar } from "./thread-sidebar";
|
|
43
|
+
import type { ChatViewProps } from "./types";
|
|
44
|
+
|
|
45
|
+
// Controlled, presentational chat shell: the scroll container, header
|
|
46
|
+
// (model picker + clear), empty state, transcript of message bubbles,
|
|
47
|
+
// and the composer. All conversation state is owned by the caller and
|
|
48
|
+
// fed in through props - this component renders it and reports user
|
|
49
|
+
// intent back out (send, regenerate, load-more, clear, approve).
|
|
50
|
+
|
|
51
|
+
const BOTTOM_THRESHOLD_PX = 24;
|
|
52
|
+
/**
|
|
53
|
+
* Distance from the top of the scroll container at which we trigger
|
|
54
|
+
* `onLoadMore`. Sized to give the lazy fetch a head-start before the
|
|
55
|
+
* user actually hits the top so the reveal feels seamless.
|
|
56
|
+
*/
|
|
57
|
+
const TOP_LOAD_MORE_THRESHOLD_PX = 120;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sentinel for "no explicit model" in the Select. Radix's `SelectItem`
|
|
61
|
+
* forbids an empty string `value`, so we map `""` <-> `__default__`
|
|
62
|
+
* across the dropdown boundary.
|
|
63
|
+
*/
|
|
64
|
+
const DEFAULT_MODEL_VALUE = "__default__";
|
|
65
|
+
|
|
66
|
+
export const ChatView = ({
|
|
67
|
+
messages,
|
|
68
|
+
status,
|
|
69
|
+
error,
|
|
70
|
+
sendMessage,
|
|
71
|
+
regenerate,
|
|
72
|
+
onStop,
|
|
73
|
+
className,
|
|
74
|
+
suggestions = [],
|
|
75
|
+
toolEventsByMessage = {},
|
|
76
|
+
models,
|
|
77
|
+
model,
|
|
78
|
+
onModelChange,
|
|
79
|
+
onLoadMore,
|
|
80
|
+
isLoadingMore = false,
|
|
81
|
+
hasMore = false,
|
|
82
|
+
isLoadingHistory = false,
|
|
83
|
+
onResolveToolApproval,
|
|
84
|
+
pendingApprovalsByMessage = {},
|
|
85
|
+
onClear,
|
|
86
|
+
threads,
|
|
87
|
+
activeThreadId,
|
|
88
|
+
streamingThreadIds = [],
|
|
89
|
+
isLoadingThreads = false,
|
|
90
|
+
onSelectThread,
|
|
91
|
+
onNewThread,
|
|
92
|
+
onDeleteThread,
|
|
93
|
+
onRenameThread,
|
|
94
|
+
sidebarOpen: sidebarOpenProp,
|
|
95
|
+
onToggleSidebar,
|
|
96
|
+
onExportConversation,
|
|
97
|
+
onExportMessage,
|
|
98
|
+
feedbackByMessage = {},
|
|
99
|
+
onFeedback,
|
|
100
|
+
}: ChatViewProps) => {
|
|
101
|
+
const [input, setInput] = useState("");
|
|
102
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
103
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
104
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
105
|
+
// Mirror `isAtBottom` into a ref so the ResizeObserver below (created
|
|
106
|
+
// per transcript mount, not per render) reads the latest value
|
|
107
|
+
// without re-subscribing every time the flag toggles.
|
|
108
|
+
const isAtBottomRef = useRef(isAtBottom);
|
|
109
|
+
isAtBottomRef.current = isAtBottom;
|
|
110
|
+
// Scroll-anchor state for prepending older messages. When the
|
|
111
|
+
// parent answers an `onLoadMore` call we capture the pre-prepend
|
|
112
|
+
// `scrollHeight`/`scrollTop`; once the new DOM nodes mount we shift
|
|
113
|
+
// `scrollTop` so the previously-visible content stays in place
|
|
114
|
+
// (instead of jumping to the bottom of the new transcript).
|
|
115
|
+
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(
|
|
116
|
+
null,
|
|
117
|
+
);
|
|
118
|
+
const loadMoreRef = useRef(onLoadMore);
|
|
119
|
+
loadMoreRef.current = onLoadMore;
|
|
120
|
+
|
|
121
|
+
// Auto-scroll to bottom whenever a new chunk lands, but only while the
|
|
122
|
+
// user is already pinned to the bottom. Lets them scroll up to read
|
|
123
|
+
// history mid-stream without the view yanking them back. Skip the
|
|
124
|
+
// adjust when an older page was just prepended (the anchor restore
|
|
125
|
+
// below owns that case).
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const el = scrollRef.current;
|
|
128
|
+
if (!el) return;
|
|
129
|
+
if (prependAnchorRef.current) return;
|
|
130
|
+
if (!isAtBottom) return;
|
|
131
|
+
el.scrollTop = el.scrollHeight;
|
|
132
|
+
}, [messages, toolEventsByMessage, isAtBottom]);
|
|
133
|
+
|
|
134
|
+
// Keep the view pinned to the bottom as streamed content grows. The
|
|
135
|
+
// `messages`-dep effect above only fires when the array reference
|
|
136
|
+
// changes; a ResizeObserver on the transcript also catches height
|
|
137
|
+
// growth that lands without a new `messages` ref - token-by-token
|
|
138
|
+
// text, async markdown/syntax layout, the in-flight waiting row - so
|
|
139
|
+
// the scroll keeps up while the user is pinned to the bottom. Only
|
|
140
|
+
// pins when already at the bottom, so scrolling up to read history
|
|
141
|
+
// mid-stream still works. Re-subscribes when the transcript mounts
|
|
142
|
+
// (empty -> first message, or history finishes loading).
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const el = scrollRef.current;
|
|
145
|
+
const content = contentRef.current;
|
|
146
|
+
if (!el || !content) return;
|
|
147
|
+
const observer = new ResizeObserver(() => {
|
|
148
|
+
if (prependAnchorRef.current || !isAtBottomRef.current) return;
|
|
149
|
+
el.scrollTop = el.scrollHeight;
|
|
150
|
+
});
|
|
151
|
+
observer.observe(content);
|
|
152
|
+
return () => observer.disconnect();
|
|
153
|
+
}, [messages.length, isLoadingHistory]);
|
|
154
|
+
|
|
155
|
+
// Restore the visual scroll position after a prepend. Runs in
|
|
156
|
+
// `useLayoutEffect` so the adjustment happens before the browser
|
|
157
|
+
// paints; an effect would let the new content flash at the top.
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
const el = scrollRef.current;
|
|
160
|
+
const anchor = prependAnchorRef.current;
|
|
161
|
+
prependAnchorRef.current = null;
|
|
162
|
+
if (!el || !anchor) return;
|
|
163
|
+
const delta = el.scrollHeight - anchor.scrollHeight;
|
|
164
|
+
el.scrollTop = anchor.scrollTop + delta;
|
|
165
|
+
}, [messages]);
|
|
166
|
+
|
|
167
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
168
|
+
const el = e.currentTarget;
|
|
169
|
+
setIsAtBottom(
|
|
170
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX,
|
|
171
|
+
);
|
|
172
|
+
// Lazy-load older messages once the user gets close to the top.
|
|
173
|
+
// Capture the anchor *before* firing the callback so the parent's
|
|
174
|
+
// synchronous state updates don't beat us to the layout effect.
|
|
175
|
+
if (
|
|
176
|
+
el.scrollTop <= TOP_LOAD_MORE_THRESHOLD_PX &&
|
|
177
|
+
hasMore &&
|
|
178
|
+
!isLoadingMore &&
|
|
179
|
+
loadMoreRef.current
|
|
180
|
+
) {
|
|
181
|
+
prependAnchorRef.current = {
|
|
182
|
+
scrollHeight: el.scrollHeight,
|
|
183
|
+
scrollTop: el.scrollTop,
|
|
184
|
+
};
|
|
185
|
+
loadMoreRef.current();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const scrollToBottom = () => {
|
|
190
|
+
if (!scrollRef.current) return;
|
|
191
|
+
scrollRef.current.scrollTo({
|
|
192
|
+
top: scrollRef.current.scrollHeight,
|
|
193
|
+
behavior: "smooth",
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// A turn is in flight from the moment the run opens (`submitted`)
|
|
198
|
+
// until the server signals done (`ready`/`error`). Used to gate new
|
|
199
|
+
// submissions and to swap the composer's Send button for Stop.
|
|
200
|
+
const isRunning = status === "submitted" || status === "streaming";
|
|
201
|
+
|
|
202
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
// Don't queue a new turn while one is streaming - the Enter-key
|
|
205
|
+
// path would otherwise bypass the disabled Send button.
|
|
206
|
+
if (isRunning) return;
|
|
207
|
+
const text = input.trim();
|
|
208
|
+
if (!text) return;
|
|
209
|
+
sendMessage({ text });
|
|
210
|
+
setInput("");
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const lastMessage = messages.at(-1);
|
|
214
|
+
const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
|
|
215
|
+
// Single in-flight indicator for the whole turn: visible from the
|
|
216
|
+
// moment the agent run opens (`status === "submitted"`) until the
|
|
217
|
+
// server signals done (`status === "ready"` / `"error"`). The label
|
|
218
|
+
// refines based on what the turn is currently doing so the user
|
|
219
|
+
// gets a finer-grained hint without the spinner blinking on/off
|
|
220
|
+
// between text, tool, and "between-step" phases.
|
|
221
|
+
const lastAssistantParts = lastMessage?.role === "assistant" ? lastMessage.parts : [];
|
|
222
|
+
const lastAssistantHasContent =
|
|
223
|
+
lastAssistantParts.some(
|
|
224
|
+
(p) =>
|
|
225
|
+
(p.type === "text" || p.type === "reasoning") &&
|
|
226
|
+
Boolean((p as { text?: string }).text),
|
|
227
|
+
) || (lastEvents?.length ?? 0) > 0;
|
|
228
|
+
const hasRunningTool = (lastEvents ?? []).some((e) => e.status === "running");
|
|
229
|
+
const showWaiting = isRunning;
|
|
230
|
+
const waitingLabel = !lastAssistantHasContent
|
|
231
|
+
? "Thinking..."
|
|
232
|
+
: hasRunningTool
|
|
233
|
+
? "Working..."
|
|
234
|
+
: "Composing response...";
|
|
235
|
+
|
|
236
|
+
const showModelPicker = Boolean(models && models.length > 0 && onModelChange);
|
|
237
|
+
const showClear = Boolean(onClear);
|
|
238
|
+
const showExport = Boolean(onExportConversation);
|
|
239
|
+
// The conversation sidebar turns on once the host wires both the
|
|
240
|
+
// thread list and a selection handler. A header toggle lets the user
|
|
241
|
+
// show/hide it on demand. Open state is controlled when the caller
|
|
242
|
+
// supplies `sidebarOpen` + `onToggleSidebar` (the driver does this and
|
|
243
|
+
// persists the choice); otherwise the view manages a session-only
|
|
244
|
+
// open flag. Defaults to open.
|
|
245
|
+
const showSidebar = Boolean(threads && onSelectThread);
|
|
246
|
+
const [internalSidebarOpen, setInternalSidebarOpen] = useState(true);
|
|
247
|
+
const sidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
|
|
248
|
+
const toggleSidebar = () => {
|
|
249
|
+
if (onToggleSidebar) onToggleSidebar();
|
|
250
|
+
else setInternalSidebarOpen((open) => !open);
|
|
251
|
+
};
|
|
252
|
+
const showHeader = showModelPicker || showClear || showSidebar || showExport;
|
|
253
|
+
|
|
254
|
+
// Local "in-flight" + confirm latch for the clear button so the
|
|
255
|
+
// user can't double-fire the DELETE and so a stray click doesn't
|
|
256
|
+
// wipe history without a beat to back out. Resets back to idle
|
|
257
|
+
// after the parent's `onClear` settles (success or failure).
|
|
258
|
+
const [clearState, setClearState] = useState<"idle" | "confirm" | "clearing">("idle");
|
|
259
|
+
|
|
260
|
+
const handleClearClick = async () => {
|
|
261
|
+
if (clearState === "clearing" || !onClear) return;
|
|
262
|
+
if (clearState === "idle") {
|
|
263
|
+
setClearState("confirm");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
setClearState("clearing");
|
|
267
|
+
try {
|
|
268
|
+
await onClear();
|
|
269
|
+
} finally {
|
|
270
|
+
setClearState("idle");
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<TooltipProvider delayDuration={200}>
|
|
276
|
+
{/*
|
|
277
|
+
* Outer row hosts the optional conversation sidebar beside the
|
|
278
|
+
* chat column. The chat column owns the vertical layout and the
|
|
279
|
+
* scroll; the centered `max-w-4xl` framing lives on each section
|
|
280
|
+
* (header, transcript, suggestions, composer) instead of the
|
|
281
|
+
* outer shell, so the scroll area's scrollbar sits at the far
|
|
282
|
+
* right - outside the centered column - and the composer lines up
|
|
283
|
+
* with the message column regardless of whether a scrollbar is
|
|
284
|
+
* showing.
|
|
285
|
+
*/}
|
|
286
|
+
<div className={cn("flex h-full", className)}>
|
|
287
|
+
{showSidebar && sidebarOpen && (
|
|
288
|
+
<ThreadSidebar
|
|
289
|
+
threads={threads ?? []}
|
|
290
|
+
{...(activeThreadId ? { activeThreadId } : {})}
|
|
291
|
+
streamingThreadIds={streamingThreadIds}
|
|
292
|
+
isLoading={isLoadingThreads}
|
|
293
|
+
onSelect={(id) => onSelectThread?.(id)}
|
|
294
|
+
{...(onNewThread ? { onNew: onNewThread } : {})}
|
|
295
|
+
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
296
|
+
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
297
|
+
onHide={toggleSidebar}
|
|
298
|
+
/>
|
|
299
|
+
)}
|
|
300
|
+
<div className="flex h-full min-w-0 flex-1 flex-col py-0 md:py-6">
|
|
301
|
+
{showHeader && (
|
|
302
|
+
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-3 px-4 pb-2 pt-1 text-xs text-muted-foreground md:px-6">
|
|
303
|
+
<div className="flex items-center gap-2">
|
|
304
|
+
{/*
|
|
305
|
+
* The hide control lives on the sidebar itself; the header
|
|
306
|
+
* only offers a "show" toggle while the sidebar is collapsed.
|
|
307
|
+
*/}
|
|
308
|
+
{showSidebar && !sidebarOpen && (
|
|
309
|
+
<Tooltip>
|
|
310
|
+
<TooltipTrigger asChild>
|
|
311
|
+
<Button
|
|
312
|
+
type="button"
|
|
313
|
+
variant="ghost"
|
|
314
|
+
size="icon-sm"
|
|
315
|
+
onClick={toggleSidebar}
|
|
316
|
+
aria-label="Show conversations"
|
|
317
|
+
>
|
|
318
|
+
<PanelLeftIcon className="size-4" />
|
|
319
|
+
</Button>
|
|
320
|
+
</TooltipTrigger>
|
|
321
|
+
<TooltipContent>Show conversations</TooltipContent>
|
|
322
|
+
</Tooltip>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
<div className="flex items-center gap-3">
|
|
326
|
+
{showModelPicker && (
|
|
327
|
+
<div className="flex items-center gap-2">
|
|
328
|
+
<span>Model</span>
|
|
329
|
+
<Select
|
|
330
|
+
value={model ? model : DEFAULT_MODEL_VALUE}
|
|
331
|
+
onValueChange={(v) =>
|
|
332
|
+
onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)
|
|
333
|
+
}
|
|
334
|
+
>
|
|
335
|
+
<SelectTrigger size="sm" className="w-[260px]">
|
|
336
|
+
<SelectValue placeholder="Server default" />
|
|
337
|
+
</SelectTrigger>
|
|
338
|
+
<SelectContent>
|
|
339
|
+
<SelectItem value={DEFAULT_MODEL_VALUE}>
|
|
340
|
+
Server default
|
|
341
|
+
</SelectItem>
|
|
342
|
+
{models!.map((m) => (
|
|
343
|
+
<SelectItem key={m.name} value={m.name}>
|
|
344
|
+
{m.name}
|
|
345
|
+
</SelectItem>
|
|
346
|
+
))}
|
|
347
|
+
</SelectContent>
|
|
348
|
+
</Select>
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
{showExport && (
|
|
352
|
+
<ExportMenu
|
|
353
|
+
onExport={(format) => void onExportConversation?.(format)}
|
|
354
|
+
tooltip="Export conversation"
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
{showClear && (
|
|
358
|
+
<Tooltip>
|
|
359
|
+
<TooltipTrigger asChild>
|
|
360
|
+
<Button
|
|
361
|
+
type="button"
|
|
362
|
+
variant={clearState === "confirm" ? "destructive" : "outline"}
|
|
363
|
+
size="sm"
|
|
364
|
+
onClick={handleClearClick}
|
|
365
|
+
onBlur={() =>
|
|
366
|
+
// Drop the confirm latch when focus leaves so a
|
|
367
|
+
// half-armed button doesn't sit destructive-red
|
|
368
|
+
// forever after the user clicks away.
|
|
369
|
+
setClearState((s) => (s === "confirm" ? "idle" : s))
|
|
370
|
+
}
|
|
371
|
+
disabled={clearState === "clearing"}
|
|
372
|
+
className="gap-1.5"
|
|
373
|
+
>
|
|
374
|
+
{clearState === "clearing" ? (
|
|
375
|
+
<Spinner className="size-3" />
|
|
376
|
+
) : (
|
|
377
|
+
<Trash2Icon className="size-3" />
|
|
378
|
+
)}
|
|
379
|
+
{clearState === "confirm"
|
|
380
|
+
? "Confirm clear"
|
|
381
|
+
: clearState === "clearing"
|
|
382
|
+
? "Clearing..."
|
|
383
|
+
: "Clear"}
|
|
384
|
+
</Button>
|
|
385
|
+
</TooltipTrigger>
|
|
386
|
+
<TooltipContent>
|
|
387
|
+
{clearState === "confirm"
|
|
388
|
+
? "Click again to confirm; wipes this conversation."
|
|
389
|
+
: "Clear chat history for this thread"}
|
|
390
|
+
</TooltipContent>
|
|
391
|
+
</Tooltip>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
<div className="relative flex flex-1 flex-col overflow-hidden">
|
|
397
|
+
<div
|
|
398
|
+
ref={scrollRef}
|
|
399
|
+
onScroll={handleScroll}
|
|
400
|
+
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
|
401
|
+
>
|
|
402
|
+
{messages.length === 0 && !isLoadingHistory ? (
|
|
403
|
+
<Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
|
|
404
|
+
<EmptyHeader>
|
|
405
|
+
<EmptyMedia variant="icon">
|
|
406
|
+
<MessageSquareIcon className="size-5" />
|
|
407
|
+
</EmptyMedia>
|
|
408
|
+
<EmptyTitle>Start a conversation</EmptyTitle>
|
|
409
|
+
<EmptyDescription>
|
|
410
|
+
{suggestions.length > 0
|
|
411
|
+
? "Ask anything, or pick a suggestion below."
|
|
412
|
+
: "Ask anything to get started."}
|
|
413
|
+
</EmptyDescription>
|
|
414
|
+
</EmptyHeader>
|
|
415
|
+
</Empty>
|
|
416
|
+
) : (
|
|
417
|
+
<div
|
|
418
|
+
ref={contentRef}
|
|
419
|
+
className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-4 py-4 md:px-6"
|
|
420
|
+
>
|
|
421
|
+
{(isLoadingMore || isLoadingHistory) && (
|
|
422
|
+
<div className="flex items-center justify-center gap-2 py-1 text-xs text-muted-foreground">
|
|
423
|
+
<Spinner className="size-3" />
|
|
424
|
+
<span>
|
|
425
|
+
{isLoadingHistory
|
|
426
|
+
? "Loading history..."
|
|
427
|
+
: "Loading older messages..."}
|
|
428
|
+
</span>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
{messages.map((message, i) => {
|
|
432
|
+
const isLast = i === messages.length - 1;
|
|
433
|
+
if (message.role === "assistant") {
|
|
434
|
+
return (
|
|
435
|
+
<AssistantBubble
|
|
436
|
+
key={message.id}
|
|
437
|
+
message={message}
|
|
438
|
+
isLast={isLast}
|
|
439
|
+
status={status}
|
|
440
|
+
events={toolEventsByMessage[message.id]}
|
|
441
|
+
regenerate={regenerate}
|
|
442
|
+
onSuggestionClick={(text) => sendMessage({ text })}
|
|
443
|
+
onResolveToolApproval={onResolveToolApproval}
|
|
444
|
+
externalApprovals={pendingApprovalsByMessage[message.id]}
|
|
445
|
+
{...(onExportMessage
|
|
446
|
+
? {
|
|
447
|
+
onExport: (format) => onExportMessage(message, format),
|
|
448
|
+
}
|
|
449
|
+
: {})}
|
|
450
|
+
{...(onFeedback && feedbackByMessage[message.id]
|
|
451
|
+
? {
|
|
452
|
+
onFeedback: (submission) =>
|
|
453
|
+
onFeedback(message, submission),
|
|
454
|
+
...(feedbackByMessage[message.id]?.value
|
|
455
|
+
? {
|
|
456
|
+
feedbackValue:
|
|
457
|
+
feedbackByMessage[message.id]!.value,
|
|
458
|
+
}
|
|
459
|
+
: {}),
|
|
460
|
+
}
|
|
461
|
+
: {})}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return <UserBubble key={message.id} message={message} />;
|
|
466
|
+
})}
|
|
467
|
+
{showWaiting && (
|
|
468
|
+
<div className="flex h-7 items-center gap-2 px-3 text-xs text-muted-foreground">
|
|
469
|
+
<Spinner className="size-3" />
|
|
470
|
+
<span className="animate-pulse">{waitingLabel}</span>
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
{status === "error" && (
|
|
474
|
+
<div className="flex flex-col items-start gap-2">
|
|
475
|
+
<Alert variant="destructive">
|
|
476
|
+
<TriangleAlertIcon className="size-4" />
|
|
477
|
+
<AlertTitle>Something went wrong</AlertTitle>
|
|
478
|
+
<AlertDescription>
|
|
479
|
+
{error
|
|
480
|
+
? errorUtil.errorMessage(error)
|
|
481
|
+
: "The assistant ran into an error. Please try again."}
|
|
482
|
+
</AlertDescription>
|
|
483
|
+
</Alert>
|
|
484
|
+
{regenerate && (
|
|
485
|
+
<Button
|
|
486
|
+
type="button"
|
|
487
|
+
variant="outline"
|
|
488
|
+
size="sm"
|
|
489
|
+
onClick={regenerate}
|
|
490
|
+
className="gap-1.5"
|
|
491
|
+
>
|
|
492
|
+
<RefreshCwIcon className="size-3" />
|
|
493
|
+
Retry
|
|
494
|
+
</Button>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
{!isAtBottom && (
|
|
502
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-4 mx-auto flex w-full max-w-4xl justify-end px-4 md:px-6">
|
|
503
|
+
<Button
|
|
504
|
+
type="button"
|
|
505
|
+
variant="outline"
|
|
506
|
+
size="icon"
|
|
507
|
+
onClick={scrollToBottom}
|
|
508
|
+
className="pointer-events-auto rounded-full shadow"
|
|
509
|
+
>
|
|
510
|
+
<ArrowDownIcon className="size-4" />
|
|
511
|
+
</Button>
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
{messages.length === 0 && (
|
|
517
|
+
<SuggestionPills
|
|
518
|
+
questions={suggestions}
|
|
519
|
+
onSelect={(s) => sendMessage({ text: s })}
|
|
520
|
+
className="mx-auto w-full max-w-4xl px-4 pb-2 md:px-6"
|
|
521
|
+
/>
|
|
522
|
+
)}
|
|
523
|
+
|
|
524
|
+
<form
|
|
525
|
+
onSubmit={handleSubmit}
|
|
526
|
+
className="mx-auto w-full max-w-4xl px-4 pb-4 pt-2 md:px-6"
|
|
527
|
+
>
|
|
528
|
+
<InputGroup>
|
|
529
|
+
<InputGroupTextarea
|
|
530
|
+
value={input}
|
|
531
|
+
onChange={(e) => setInput(e.target.value)}
|
|
532
|
+
onKeyDown={(e) => {
|
|
533
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
534
|
+
e.preventDefault();
|
|
535
|
+
handleSubmit(e as unknown as React.FormEvent);
|
|
536
|
+
}
|
|
537
|
+
}}
|
|
538
|
+
placeholder="Send a message..."
|
|
539
|
+
rows={1}
|
|
540
|
+
/>
|
|
541
|
+
<InputGroupAddon align="inline-end">
|
|
542
|
+
{isRunning && onStop ? (
|
|
543
|
+
<InputGroupButton
|
|
544
|
+
type="button"
|
|
545
|
+
size="icon-sm"
|
|
546
|
+
variant="default"
|
|
547
|
+
onClick={onStop}
|
|
548
|
+
aria-label="Stop response"
|
|
549
|
+
>
|
|
550
|
+
<SquareIcon className="size-3 fill-current" />
|
|
551
|
+
</InputGroupButton>
|
|
552
|
+
) : (
|
|
553
|
+
<InputGroupButton
|
|
554
|
+
type="submit"
|
|
555
|
+
size="icon-sm"
|
|
556
|
+
variant="default"
|
|
557
|
+
disabled={!input.trim() || isRunning}
|
|
558
|
+
aria-label="Send message"
|
|
559
|
+
>
|
|
560
|
+
{isRunning ? (
|
|
561
|
+
<Spinner className="size-3" />
|
|
562
|
+
) : (
|
|
563
|
+
<SendIcon className="size-3" />
|
|
564
|
+
)}
|
|
565
|
+
</InputGroupButton>
|
|
566
|
+
)}
|
|
567
|
+
</InputGroupAddon>
|
|
568
|
+
</InputGroup>
|
|
569
|
+
</form>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</TooltipProvider>
|
|
573
|
+
);
|
|
574
|
+
};
|