@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,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
+ };