@dbx-tools/ui-mastra 0.6.211 → 0.6.213

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,119 +1,15 @@
1
- import { error as sharedError } from "@dbx-tools/shared-core";
2
- import {
3
- Alert,
4
- AlertDescription,
5
- AlertDialog,
6
- AlertDialogAction,
7
- AlertDialogCancel,
8
- AlertDialogContent,
9
- AlertDialogDescription,
10
- AlertDialogFooter,
11
- AlertDialogHeader,
12
- AlertDialogTitle,
13
- AlertTitle,
14
- Button,
15
- Empty,
16
- EmptyDescription,
17
- EmptyHeader,
18
- EmptyMedia,
19
- EmptyTitle,
20
- InputGroup,
21
- InputGroupAddon,
22
- InputGroupButton,
23
- InputGroupTextarea,
24
- Select,
25
- SelectContent,
26
- SelectItem,
27
- SelectTrigger,
28
- SelectValue,
29
- Spinner,
30
- Tooltip,
31
- TooltipContent,
32
- TooltipProvider,
33
- TooltipTrigger,
34
- cn,
35
- } from "@dbx-tools/ui-appkit/react";
36
- import {
37
- ArrowDownIcon,
38
- GripVerticalIcon,
39
- MessageSquareIcon,
40
- PanelLeftIcon,
41
- PanelRightIcon,
42
- RefreshCwIcon,
43
- SendHorizontalIcon,
44
- SendIcon,
45
- SquareIcon,
46
- Trash2Icon,
47
- TriangleAlertIcon,
48
- XIcon,
49
- } from "lucide-react";
50
- import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
51
- import { AssistantBubble, UserBubble } from "./bubbles.tsx";
52
- import { ExportMenu } from "./export-menu.tsx";
53
- import { SuggestionPills } from "./suggestion-pills.tsx";
54
- import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar.tsx";
55
- import { ThreadTabs } from "./thread-tabs.tsx";
1
+ import { ChatComposer } from "./chat-composer.tsx";
2
+ import { ChatThreadLayout } from "./chat-thread-layout.tsx";
3
+ import { ChatTranscript, useChatTranscriptController } from "./chat-transcript.tsx";
56
4
  import type { ChatViewProps } from "./types.ts";
57
- import { closeThreadTab, nextActiveThreadTab, syncThreadTabs } from "../support/thread-tabs.ts";
58
-
59
- // Controlled, presentational chat shell: the scroll container, header
60
- // (model picker + clear), empty state, transcript of message bubbles,
61
- // and the composer. All conversation state is owned by the caller and
62
- // fed in through props - this component renders it and reports user
63
- // intent back out (send, regenerate, load-more, clear, approve).
64
-
65
- const BOTTOM_THRESHOLD_PX = 24;
66
- /**
67
- * Distance from the top of the scroll container at which we trigger
68
- * `onLoadMore`. Sized to give the lazy fetch a head-start before the
69
- * user actually hits the top so the reveal feels seamless.
70
- */
71
- const TOP_LOAD_MORE_THRESHOLD_PX = 120;
72
-
73
- /**
74
- * Sentinel for "no explicit model" in the Select. Radix's `SelectItem`
75
- * forbids an empty string `value`, so we map `""` <-> `__default__`
76
- * across the dropdown boundary.
77
- */
78
- const DEFAULT_MODEL_VALUE = "__default__";
79
-
80
- /**
81
- * Width (px) below which the chat is too narrow to give up a column to a
82
- * docked conversation list. Matches Tailwind's `md` breakpoint. Under it, a
83
- * `left` / `right` placement collapses to an overlay drawer and `auto`
84
- * switches to the `top` tab strip.
85
- */
86
- const SIDE_PANEL_MIN_WIDTH_PX = 768;
87
5
 
88
6
  /**
89
- * `true` while the element behind `ref` is narrower than
90
- * {@link SIDE_PANEL_MIN_WIDTH_PX}. Measured with a `ResizeObserver` on the
91
- * chat itself rather than `matchMedia` on the viewport, so a chat embedded in
92
- * a panel or split view reacts to the space it actually has - a wide window
93
- * with a 400px chat column is narrow as far as the layout is concerned.
94
- * Falls back to the viewport width until the first measurement lands, and is
95
- * SSR-safe (assumes wide when `window` is unavailable).
7
+ * Controlled chat facade for hosts that own message and transport state.
8
+ *
9
+ * Rendering responsibilities are delegated to focused internal thread,
10
+ * transcript, and composer components while this public prop contract remains
11
+ * stable.
96
12
  */
97
- const useIsNarrow = (ref: React.RefObject<HTMLElement | null>): boolean => {
98
- const [isNarrow, setIsNarrow] = useState(() =>
99
- typeof window === "undefined" ? false : window.innerWidth < SIDE_PANEL_MIN_WIDTH_PX,
100
- );
101
- useEffect(() => {
102
- const el = ref.current;
103
- if (!el || typeof ResizeObserver === "undefined") return;
104
- const observer = new ResizeObserver((entries) => {
105
- const width = entries[0]?.contentRect.width ?? el.clientWidth;
106
- // A zero width means the chat is detached or hidden (a closed tab
107
- // panel); keep the last real measurement rather than flipping layout
108
- // behind the user's back.
109
- if (width > 0) setIsNarrow(width < SIDE_PANEL_MIN_WIDTH_PX);
110
- });
111
- observer.observe(el);
112
- return () => observer.disconnect();
113
- }, [ref]);
114
- return isNarrow;
115
- };
116
-
117
13
  export const ChatView = ({
118
14
  messages,
119
15
  status,
@@ -149,865 +45,74 @@ export const ChatView = ({
149
45
  onDeleteThread,
150
46
  onRenameThread,
151
47
  onCancelThread,
152
- sidebarOpen: sidebarOpenProp,
48
+ sidebarOpen,
153
49
  onToggleSidebar,
154
50
  onExportConversation,
155
51
  onExportMessage,
156
52
  feedbackByMessage = {},
157
53
  onFeedback,
158
54
  }: ChatViewProps) => {
159
- const [input, setInput] = useState("");
160
- // Id of the queued steer currently being dragged (pointer drag), for the
161
- // reorder affordance + drop styling. Null when not dragging. Pointer Events
162
- // (not native HTML5 drag) so the grip works on touch as well as mouse -
163
- // `draggable`/`onDrag*` never fire on a touchscreen.
164
- const [draggingSteerId, setDraggingSteerId] = useState<string | null>(null);
165
- // Live DOM refs to each queued-steer chip, keyed by steer id, so a pointer
166
- // drag can hit-test the pointer's Y against each chip's midpoint and reorder
167
- // as the finger/cursor moves over a neighbour.
168
- const steerChipRefs = useRef(new Map<string, HTMLDivElement>());
169
- // Id of the steer under an active pointer drag, mirrored in a ref so the
170
- // pointermove handler reads it synchronously. React state (`draggingSteerId`)
171
- // only drives styling and lags a render behind the pointerdown, which on
172
- // touch dropped the first moves and made the drag feel dead.
173
- const draggingIdRef = useRef<string | null>(null);
174
- // Root layout element, measured to decide whether the chat has room for a
175
- // docked conversation list (see `useIsNarrow`).
176
- const rootRef = useRef<HTMLDivElement>(null);
177
- const scrollRef = useRef<HTMLDivElement>(null);
178
- const contentRef = useRef<HTMLDivElement>(null);
179
- // Composer textarea, auto-grown with its content up to the CSS `max-h`.
180
- const textareaRef = useRef<HTMLTextAreaElement>(null);
181
- // `isAtBottom` drives the "jump to latest" button; `pinnedRef` drives the
182
- // auto-follow. They usually agree, but `pinnedRef` is INTENT (do we want to
183
- // stick to the bottom?) rather than a measurement, so a fast programmatic
184
- // pin mid-stream can't be misread as the user scrolling away.
185
- const [isAtBottom, setIsAtBottom] = useState(true);
186
- const pinnedRef = useRef(true);
187
- // Set right before a programmatic `scrollTop` write so the `scroll` event it
188
- // triggers is ignored by `handleScroll` (only USER scrolls should unpin).
189
- const programmaticScrollRef = useRef(false);
190
- // Scroll-anchor state for prepending older messages. When the
191
- // parent answers an `onLoadMore` call we capture the pre-prepend
192
- // `scrollHeight`/`scrollTop`; once the new DOM nodes mount we shift
193
- // `scrollTop` so the previously-visible content stays in place
194
- // (instead of jumping to the bottom of the new transcript).
195
- const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
196
- const loadMoreRef = useRef(onLoadMore);
197
- loadMoreRef.current = onLoadMore;
198
- // Latest queued steers, read by the pointer-drag move handler so it reorders
199
- // against the current queue rather than the order captured when the drag began.
200
- const queuedSteersRef = useRef(queuedSteers);
201
- queuedSteersRef.current = queuedSteers;
202
-
203
- // Jump the transcript to the bottom, marking the write as programmatic so
204
- // the resulting scroll event isn't mistaken for the user scrolling away.
205
- const pinToBottomNow = useCallback(() => {
206
- const el = scrollRef.current;
207
- if (!el) return;
208
- programmaticScrollRef.current = true;
209
- el.scrollTop = el.scrollHeight;
210
- }, []);
211
-
212
- // Reorder the queued steers to place `draggingId` at the slot whose chip the
213
- // pointer is currently over, hit-testing the pointer Y against each chip's
214
- // vertical midpoint. Called continuously during a pointer drag so the queue
215
- // reflows live under the finger/cursor, then committed via `onReorderSteers`.
216
- const reorderSteersByPointer = useCallback(
217
- (draggingId: string, pointerY: number) => {
218
- if (!onReorderSteers) return;
219
- const order = queuedSteersRef.current.map((s) => s.id);
220
- // Build the target order: everything except the dragged id, then insert
221
- // the dragged id before the first chip whose midpoint is below the
222
- // pointer (or at the end if the pointer is past them all).
223
- const rest = order.filter((id) => id !== draggingId);
224
- let insertAt = rest.length;
225
- for (let i = 0; i < rest.length; i += 1) {
226
- const chip = steerChipRefs.current.get(rest[i]);
227
- if (!chip) continue;
228
- const box = chip.getBoundingClientRect();
229
- if (pointerY < box.top + box.height / 2) {
230
- insertAt = i;
231
- break;
232
- }
233
- }
234
- const next = [...rest];
235
- next.splice(insertAt, 0, draggingId);
236
- // Skip the commit when the order is unchanged, so we don't thrash the
237
- // parent state on every pointermove tick.
238
- if (next.length === order.length && next.every((id, i) => id === order[i])) {
239
- return;
240
- }
241
- onReorderSteers(next);
242
- },
243
- [onReorderSteers],
244
- );
245
-
246
- // Follow the bottom as streamed content grows, as long as we're "pinned".
247
- // A ResizeObserver on the transcript catches every height change - new
248
- // messages, token-by-token text, async markdown/chart layout, the waiting
249
- // row - and re-pins reading the FRESH scrollHeight, so the view keeps up
250
- // with fast streaming. Pinning is intent-driven (`pinnedRef`), not measured
251
- // off scrollTop, so our own programmatic jumps never look like the user
252
- // scrolling up. Re-subscribes when the transcript mounts.
253
- useEffect(() => {
254
- const el = scrollRef.current;
255
- const content = contentRef.current;
256
- if (!el || !content) return;
257
- const observer = new ResizeObserver(() => {
258
- if (prependAnchorRef.current || !pinnedRef.current) return;
259
- programmaticScrollRef.current = true;
260
- el.scrollTop = el.scrollHeight;
261
- });
262
- observer.observe(content);
263
- return () => observer.disconnect();
264
- }, [messages.length, isLoadingHistory]);
265
-
266
- // A new message reference (a turn starting, or a steer appended) re-pins if
267
- // we were following, catching growth the observer's first callback might
268
- // race. Skipped during a prepend (the anchor restore below owns that).
269
- useEffect(() => {
270
- if (prependAnchorRef.current || !pinnedRef.current) return;
271
- pinToBottomNow();
272
- }, [messages, toolEventsByMessage, pinToBottomNow]);
273
-
274
- // Restore the visual scroll position after a prepend. Runs in
275
- // `useLayoutEffect` so the adjustment happens before the browser
276
- // paints; an effect would let the new content flash at the top.
277
- useLayoutEffect(() => {
278
- const el = scrollRef.current;
279
- const anchor = prependAnchorRef.current;
280
- prependAnchorRef.current = null;
281
- if (!el || !anchor) return;
282
- const delta = el.scrollHeight - anchor.scrollHeight;
283
- el.scrollTop = anchor.scrollTop + delta;
284
- }, [messages]);
285
-
286
- const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
287
- const el = e.currentTarget;
288
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
289
- // A scroll we caused (a pin) shouldn't change intent - only reconcile the
290
- // button state. A USER scroll sets intent: scrolling up unpins (stop
291
- // following); scrolling back to the bottom re-pins (resume following).
292
- if (programmaticScrollRef.current) {
293
- programmaticScrollRef.current = false;
294
- } else {
295
- pinnedRef.current = atBottom;
296
- }
297
- setIsAtBottom(atBottom);
298
- // Lazy-load older messages once the user gets close to the top.
299
- // Capture the anchor *before* firing the callback so the parent's
300
- // synchronous state updates don't beat us to the layout effect.
301
- if (
302
- el.scrollTop <= TOP_LOAD_MORE_THRESHOLD_PX &&
303
- hasMore &&
304
- !isLoadingMore &&
305
- loadMoreRef.current
306
- ) {
307
- prependAnchorRef.current = {
308
- scrollHeight: el.scrollHeight,
309
- scrollTop: el.scrollTop,
310
- };
311
- loadMoreRef.current();
312
- }
313
- };
314
-
315
- // The "jump to latest" button: smooth-scroll to the bottom and resume
316
- // following (re-pin), since the user asked to return to live content.
317
- const scrollToBottom = () => {
318
- const el = scrollRef.current;
319
- if (!el) return;
320
- pinnedRef.current = true;
321
- setIsAtBottom(true);
322
- programmaticScrollRef.current = true;
323
- el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
324
- };
325
-
326
- // Grow the composer with its content (up to the textarea's CSS `max-h`,
327
- // after which it scrolls internally), then shrink back as text is removed.
328
- // Runs on every `input` change so paste / multi-line typing feels native.
329
- useLayoutEffect(() => {
330
- const el = textareaRef.current;
331
- if (!el) return;
332
- el.style.height = "auto";
333
- el.style.height = `${el.scrollHeight}px`;
334
- }, [input]);
335
-
336
- // A turn is in flight from the moment the run opens (`submitted`)
337
- // until the server signals done (`ready`/`error`). Used to gate new
338
- // submissions and to swap the composer's Send button for Stop.
339
- const isRunning = status === "submitted" || status === "streaming";
340
-
341
- const handleSubmit = (e: React.FormEvent) => {
342
- e.preventDefault();
343
- const text = input.trim();
344
- if (!text) return;
345
- // The composer is disabled while history loads, but Enter-to-send routes
346
- // here directly and a disabled textarea still receives no keydown only as
347
- // long as the browser agrees - so gate the action itself too. Sending now
348
- // would race the fetch and append a turn to a transcript that is about to
349
- // be replaced by the loaded one.
350
- if (isLoadingHistory) return;
351
- // Submitting while a turn streams is a steer: the driver hands the text
352
- // to the live run (or interrupts + resends). Idle submits start a turn.
353
- sendMessage({ text });
354
- setInput("");
355
- // Sending is an explicit "I want to see the response", so resume following
356
- // even if the user had scrolled up.
357
- resumeFollow();
358
- };
359
-
360
- // Resume auto-following the bottom (after a submit): set intent, then jump
361
- // across two frames so the pin lands AFTER React commits the appended
362
- // message (a single frame can fire pre-commit). The ResizeObserver keeps it
363
- // pinned through the subsequent streaming growth.
364
- const resumeFollow = () => {
365
- pinnedRef.current = true;
366
- setIsAtBottom(true);
367
- requestAnimationFrame(() => {
368
- pinToBottomNow();
369
- requestAnimationFrame(pinToBottomNow);
370
- });
371
- };
372
-
373
- const lastMessage = messages.at(-1);
374
- const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
375
- // Single in-flight indicator for the whole turn: visible from the
376
- // moment the agent run opens (`status === "submitted"`) until the
377
- // server signals done (`status === "ready"` / `"error"`). The label
378
- // refines based on what the turn is currently doing so the user
379
- // gets a finer-grained hint without the spinner blinking on/off
380
- // between text, tool, and "between-step" phases.
381
- const lastAssistantParts = lastMessage?.role === "assistant" ? lastMessage.parts : [];
382
- const lastAssistantHasContent =
383
- lastAssistantParts.some(
384
- (p) =>
385
- (p.type === "text" || p.type === "reasoning") && Boolean((p as { text?: string }).text),
386
- ) || (lastEvents?.length ?? 0) > 0;
387
- const hasRunningTool = (lastEvents ?? []).some((e) => e.status === "running");
388
- const showWaiting = isRunning;
389
- const waitingLabel = !lastAssistantHasContent
390
- ? "Thinking..."
391
- : hasRunningTool
392
- ? "Working..."
393
- : "Composing response...";
394
-
395
- // Model display is intent-driven, not load-driven: as soon as the host
396
- // wires `onModelChange` we reserve the row and show the current model,
397
- // so it never "pops in" once the async catalogue lands. It renders as a
398
- // clickable picker only when there's an actual choice (models loaded),
399
- // otherwise as static text.
400
- const showModelDisplay = Boolean(onModelChange);
401
- const modelChangeable = Boolean(models && models.length > 0);
402
- // Human-readable name for a pinned endpoint id, using the catalogue's
403
- // `displayName`. Returns undefined until the catalogue has an entry for it,
404
- // so we never fall back to the raw id (no raw-name flash on load).
405
- const modelLabel = (name?: string): string | undefined => {
406
- if (!name) return undefined;
407
- return models?.find((m) => m.name === name)?.displayName;
408
- };
409
- // The default option shows the server's fallback model by its already-
410
- // humanized name (`defaultModelName` is the server's `displayName`), else a
411
- // neutral "Default". No "server default" phrasing, no raw id.
412
- const defaultOptionLabel = defaultModelName || "Default";
413
- // Label the current model by its human-readable name when a model is pinned
414
- // and the catalogue has resolved it; else the default-option label. Never
415
- // shows a raw endpoint id.
416
- const currentModelLabel = modelLabel(model) || defaultOptionLabel;
417
- // Picker entries sorted by their human-readable label (case-insensitive).
418
- const sortedModels = [...(models ?? [])].sort((a, b) =>
419
- (a.displayName || a.name).localeCompare(b.displayName || b.name, undefined, {
420
- sensitivity: "base",
421
- }),
422
- );
423
- const showClear = Boolean(onClear);
424
- const showExport = Boolean(onExportConversation);
425
- // Conversation management turns on once the host wires both the thread list
426
- // and a selection handler, and the placement isn't `disabled`. Where it
427
- // renders is `threadPlacement`, resolved below.
428
- const showThreads = Boolean(threads && onSelectThread) && threadPlacement !== "disabled";
429
- // Is the chat itself too narrow for a docked list? Measured on the root
430
- // element, so an embedded/split-pane chat decides on its own width.
431
- const isNarrow = useIsNarrow(rootRef);
432
- // `auto` picks the layout for the space available: a left panel while the
433
- // chat is wide, the tab strip once it is too narrow to spare a column.
434
- // Every other value is honoured verbatim.
435
- const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "left") : threadPlacement;
436
- const tabbedThreads = showThreads && placement === "top";
437
- // A docked list. Narrow chats render it as an overlay drawer on the same
438
- // edge instead of an inline column, so the transcript keeps its width.
439
- const dockedSide = placement === "right" ? "right" : "left";
440
- const dockedThreads = showThreads && (placement === "left" || placement === "right");
441
- // Docked-panel open state, controlled when the caller supplies
442
- // `sidebarOpen` + `onToggleSidebar` (the driver does this and persists the
443
- // choice); otherwise the view manages a session-only flag. Defaults to open.
444
- const [internalSidebarOpen, setInternalSidebarOpen] = useState(true);
445
- const inlineSidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
446
- const toggleInlineSidebar = () => {
447
- if (onToggleSidebar) onToggleSidebar();
448
- else setInternalSidebarOpen((open) => !open);
449
- };
450
- // The overlay drawer is SESSION-only and default-closed so a persisted
451
- // "open" preference never auto-opens a drawer over a narrow chat. Reset
452
- // closed whenever the chat widens back out to an inline panel.
453
- const [drawerOpen, setDrawerOpen] = useState(false);
454
- useEffect(() => {
455
- if (!isNarrow) setDrawerOpen(false);
456
- }, [isNarrow]);
457
- // Unified state/handlers the render + header use, resolved by width.
458
- const sidebarOpen = isNarrow ? drawerOpen : inlineSidebarOpen;
459
- const toggleSidebar = () => {
460
- if (isNarrow) setDrawerOpen((open) => !open);
461
- else toggleInlineSidebar();
462
- };
463
- // The top bar carries only the sidebar toggle; the model picker, export, and
464
- // clear controls live in a toolbar row below the composer, closer to where
465
- // the user is typing. The toggle is a narrow-layout hamburger, or a "show"
466
- // affordance while the inline panel is collapsed - so the bar renders only
467
- // when that toggle would actually be visible (an open inline panel has its
468
- // own hide button, leaving nothing for the bar to hold, and the tab strip
469
- // needs no toggle at all).
470
- const showSidebarToggle = dockedThreads && (isNarrow || !inlineSidebarOpen);
471
- const showHeader = showSidebarToggle;
472
- const showComposerToolbar = showModelDisplay || showExport || showClear;
473
- // Collapse icon points at the edge the panel lives on, matching the hide
474
- // button inside the panel itself.
475
- const SidebarToggleIcon = dockedSide === "right" ? PanelRightIcon : PanelLeftIcon;
476
-
477
- // The conversation list itself, shared by all three thread surfaces - the
478
- // overlay drawer, the inline panel, and the tab strip's history menu all
479
- // render the SAME `ThreadSidebar`, differing only in framing (and, for the
480
- // transient ones, closing themselves after select / new). Building the bag
481
- // once keeps the call sites from drifting; framing props (`onHide`, `side`,
482
- // `className`) are added per site.
483
- const threadListProps: Omit<ThreadSidebarProps, "onHide" | "side" | "className"> = {
484
- threads: threads ?? [],
485
- ...(activeThreadId ? { activeThreadId } : {}),
486
- streamingThreadIds,
487
- isLoading: isLoadingThreads,
488
- onSelect: (id) => onSelectThread?.(id),
489
- ...(onNewThread ? { onNew: onNewThread } : {}),
490
- ...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
491
- ...(onRenameThread ? { onRename: onRenameThread } : {}),
492
- ...(onCancelThread ? { onCancel: onCancelThread } : {}),
493
- };
494
-
495
- // Which conversations are open as tabs in the `top` placement (session
496
- // state; the strip reseeds from the newest conversations on the next load).
497
- // The sync keeps the list in step with the thread list and the selection,
498
- // and returns the same array when nothing changed so this effect settles.
499
- const [openTabIds, setOpenTabIds] = useState<string[]>([]);
500
- useEffect(() => {
501
- if (!tabbedThreads) return;
502
- setOpenTabIds((prev) => syncThreadTabs(prev, threads ?? [], activeThreadId));
503
- }, [tabbedThreads, threads, activeThreadId]);
504
-
505
- // Close a tab. Closing the ACTIVE one has to move the selection too, or the
506
- // sync above would immediately reopen it: switch to a neighbouring tab, or
507
- // start a fresh conversation when that was the last one open.
508
- const closeTab = (threadId: string) => {
509
- const fallback = nextActiveThreadTab(openTabIds, threadId);
510
- setOpenTabIds((prev) => closeThreadTab(prev, threadId));
511
- if (threadId !== activeThreadId) return;
512
- if (fallback) onSelectThread?.(fallback);
513
- else onNewThread?.();
514
- };
515
-
516
- // Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
517
- // in-flight flag so the DELETE can't be double-fired. `clearing` disables
518
- // the confirm action while `onClear` runs; the dialog closes on settle.
519
- const [clearOpen, setClearOpen] = useState(false);
520
- const [clearing, setClearing] = useState(false);
521
-
522
- const handleClearConfirm = async () => {
523
- if (clearing || !onClear) return;
524
- setClearing(true);
525
- try {
526
- await onClear();
527
- setClearOpen(false);
528
- } finally {
529
- setClearing(false);
530
- }
531
- };
55
+ const transcript = useChatTranscriptController({
56
+ messages,
57
+ toolEventsByMessage,
58
+ onLoadMore,
59
+ isLoadingMore,
60
+ hasMore,
61
+ isLoadingHistory,
62
+ });
532
63
 
533
64
  return (
534
- <TooltipProvider delayDuration={200}>
535
- {/*
536
- * Outer row hosts the optional docked conversation list beside the
537
- * chat column (`flex-row-reverse` puts it on the right edge without a
538
- * second render path). The chat column owns the vertical layout and the
539
- * scroll; the centered `max-w-4xl` framing lives on each section
540
- * (tabs, header, transcript, suggestions, composer) instead of the
541
- * outer shell, so the scroll area's scrollbar sits at the far
542
- * right - outside the centered column - and the composer lines up
543
- * with the message column regardless of whether a scrollbar is
544
- * showing.
545
- */}
546
- <div
547
- ref={rootRef}
548
- className={cn(
549
- "flex h-full min-h-0",
550
- dockedThreads && dockedSide === "right" && "flex-row-reverse",
551
- className,
552
- )}
553
- >
554
- {dockedThreads &&
555
- (isNarrow
556
- ? /*
557
- * Narrow: a fixed overlay drawer on the docked edge with a
558
- * tap-to-close backdrop, so the conversation list never eats
559
- * horizontal space from an already-cramped chat. Selecting a
560
- * thread / starting a new one also closes the drawer so the
561
- * transcript comes back into view. Session-only + default closed
562
- * (see `drawerOpen`).
563
- */
564
- drawerOpen && (
565
- <div
566
- className={cn("fixed inset-0 z-40 flex", dockedSide === "right" && "justify-end")}
567
- >
568
- <div
569
- className="absolute inset-0 bg-black/50"
570
- onClick={toggleSidebar}
571
- aria-hidden="true"
572
- />
573
- <ThreadSidebar
574
- {...threadListProps}
575
- onHide={toggleSidebar}
576
- side={dockedSide}
577
- onSelect={(id) => {
578
- onSelectThread?.(id);
579
- toggleSidebar();
580
- }}
581
- {...(onNewThread
582
- ? {
583
- onNew: () => {
584
- onNewThread();
585
- toggleSidebar();
586
- },
587
- }
588
- : {})}
589
- className="relative z-10 w-[85vw] max-w-xs shadow-xl"
590
- />
591
- </div>
592
- )
593
- : /*
594
- * Wide: an inline flex child sharing the row with the chat
595
- * column, using the persisted open/hide preference. Same list as
596
- * the drawer - only the framing + close-on-select differ.
597
- */
598
- inlineSidebarOpen && (
599
- <ThreadSidebar {...threadListProps} onHide={toggleSidebar} side={dockedSide} />
600
- ))}
601
- <div className="flex h-full min-w-0 flex-1 flex-col">
602
- {tabbedThreads && (
603
- /*
604
- * `top` placement: the open conversations as an editor-style tab
605
- * strip, with the rest reachable through its history menu. Takes
606
- * the place of both the docked panel and the header toggle.
607
- */
608
- <ThreadTabs {...threadListProps} openThreadIds={openTabIds} onCloseTab={closeTab} />
609
- )}
610
- {showHeader && (
611
- /*
612
- * Slim top bar holding the docked panel's toggle. On a narrow chat
613
- * it's a persistent hamburger (the overlay drawer has no
614
- * always-visible hide button); otherwise it's a "show" affordance
615
- * rendered only while the inline panel is collapsed (an open panel
616
- * has its own hide button). `showHeader` already tracks that
617
- * visibility, so the bar never renders empty.
618
- */
619
- <div
620
- className={cn(
621
- "mx-auto flex w-full max-w-4xl items-center gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6",
622
- // Keep the toggle on the same edge as the panel it opens.
623
- dockedSide === "right" && "justify-end",
624
- )}
625
- >
626
- <Tooltip>
627
- <TooltipTrigger asChild>
628
- <Button
629
- type="button"
630
- variant="ghost"
631
- size="icon-sm"
632
- onClick={toggleSidebar}
633
- aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
634
- >
635
- <SidebarToggleIcon className="size-4" />
636
- </Button>
637
- </TooltipTrigger>
638
- <TooltipContent>
639
- {sidebarOpen ? "Hide conversations" : "Show conversations"}
640
- </TooltipContent>
641
- </Tooltip>
642
- </div>
643
- )}
644
- <div className="relative flex flex-1 flex-col overflow-hidden">
645
- <div
646
- ref={scrollRef}
647
- onScroll={handleScroll}
648
- // `overflow-anchor:none` stops the browser's scroll anchoring
649
- // from fighting the programmatic bottom-pin as streamed content
650
- // grows (it would otherwise lock onto a mid-transcript element).
651
- className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [overflow-anchor:none] [scrollbar-gutter:stable]"
652
- >
653
- {messages.length === 0 && !isLoadingHistory ? (
654
- <Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
655
- <EmptyHeader>
656
- <EmptyMedia variant="icon">
657
- <MessageSquareIcon className="size-5" />
658
- </EmptyMedia>
659
- <EmptyTitle>Start a conversation</EmptyTitle>
660
- <EmptyDescription>
661
- {suggestions.length > 0
662
- ? "Ask anything, or pick a suggestion below."
663
- : "Ask anything to get started."}
664
- </EmptyDescription>
665
- </EmptyHeader>
666
- </Empty>
667
- ) : (
668
- <div
669
- ref={contentRef}
670
- className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-4 py-4 md:px-6"
671
- >
672
- {(isLoadingMore || isLoadingHistory) && (
673
- <div className="flex items-center justify-center gap-2 py-1 text-xs text-muted-foreground">
674
- <Spinner className="size-3" />
675
- <span>
676
- {isLoadingHistory ? "Loading history..." : "Loading older messages..."}
677
- </span>
678
- </div>
679
- )}
680
- {messages.map((message, i) => {
681
- const isLast = i === messages.length - 1;
682
- if (message.role === "assistant") {
683
- return (
684
- <AssistantBubble
685
- key={message.id}
686
- message={message}
687
- isLast={isLast}
688
- status={status}
689
- events={toolEventsByMessage[message.id]}
690
- regenerate={regenerate}
691
- onSuggestionClick={(text) => sendMessage({ text })}
692
- onResolveToolApproval={onResolveToolApproval}
693
- externalApprovals={pendingApprovalsByMessage[message.id]}
694
- {...(onExportMessage
695
- ? {
696
- onExport: (format) => onExportMessage(message, format),
697
- }
698
- : {})}
699
- {...(onFeedback && feedbackByMessage[message.id]
700
- ? {
701
- onFeedback: (submission) => onFeedback(message, submission),
702
- ...(feedbackByMessage[message.id]?.value
703
- ? {
704
- feedbackValue: feedbackByMessage[message.id]!.value,
705
- }
706
- : {}),
707
- }
708
- : {})}
709
- />
710
- );
711
- }
712
- return <UserBubble key={message.id} message={message} />;
713
- })}
714
- {showWaiting && (
715
- <div className="flex h-7 items-center gap-2 px-3 text-xs text-muted-foreground">
716
- <Spinner className="size-3" />
717
- <span className="animate-pulse">{waitingLabel}</span>
718
- </div>
719
- )}
720
- {status === "error" && (
721
- <div className="flex flex-col items-start gap-2">
722
- <Alert variant="destructive">
723
- <TriangleAlertIcon className="size-4" />
724
- <AlertTitle>Something went wrong</AlertTitle>
725
- <AlertDescription>
726
- {error
727
- ? sharedError.errorMessage(error)
728
- : "The assistant ran into an error. Please try again."}
729
- </AlertDescription>
730
- </Alert>
731
- {regenerate && (
732
- <Button
733
- type="button"
734
- variant="outline"
735
- size="sm"
736
- onClick={regenerate}
737
- className="gap-1.5"
738
- >
739
- <RefreshCwIcon className="size-3" />
740
- Retry
741
- </Button>
742
- )}
743
- </div>
744
- )}
745
- </div>
746
- )}
747
- </div>
748
- {!isAtBottom && (
749
- <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">
750
- <Button
751
- type="button"
752
- variant="outline"
753
- size="icon"
754
- onClick={scrollToBottom}
755
- className="pointer-events-auto rounded-full shadow"
756
- >
757
- <ArrowDownIcon className="size-4" />
758
- </Button>
759
- </div>
760
- )}
761
- </div>
762
-
763
- {messages.length === 0 && (
764
- <SuggestionPills
765
- questions={suggestions}
766
- onSelect={(s) => sendMessage({ text: s })}
767
- disabled={isLoadingHistory}
768
- className="mx-auto w-full max-w-4xl px-4 pb-2 md:px-6"
769
- />
770
- )}
771
-
772
- <form
773
- onSubmit={handleSubmit}
774
- className="mx-auto w-full max-w-4xl px-3 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6"
775
- >
776
- {queuedSteers.length > 0 && (
777
- // Steers submitted while the turn is running, waiting to send.
778
- // They drain oldest-first when the turn ends; each can be fired
779
- // now (interrupts), removed, or dragged to reorder the queue.
780
- <div className="mb-2 flex flex-col gap-1">
781
- {queuedSteers.map((steer) => {
782
- const reorderable = Boolean(onReorderSteers);
783
- return (
784
- <div
785
- key={steer.id}
786
- ref={(el) => {
787
- if (el) steerChipRefs.current.set(steer.id, el);
788
- else steerChipRefs.current.delete(steer.id);
789
- }}
790
- className={cn(
791
- "flex items-center gap-1.5 rounded-lg border border-border/70 bg-muted/40 px-2 py-1 text-xs",
792
- draggingSteerId === steer.id && "opacity-50",
793
- )}
794
- >
795
- {reorderable && (
796
- // Drag handle. Pointer Events (not native HTML5 drag) so
797
- // it works on touch: `touch-none` (touch-action: none)
798
- // stops the browser treating the drag as a scroll, and
799
- // pointer capture keeps move/up events flowing to the grip
800
- // even as the finger slides over sibling chips. The active
801
- // id lives in a ref (`draggingIdRef`) so the first
802
- // pointermove isn't dropped waiting for a state re-render.
803
- // `-m-1 p-1` enlarges the tap target to ~28px without
804
- // widening the visible grip - a 12px icon is too small to
805
- // reliably grab on touch.
806
- <span
807
- role="button"
808
- tabIndex={-1}
809
- aria-label="Drag to reorder"
810
- className="-m-1 shrink-0 cursor-grab touch-none p-1 text-muted-foreground active:cursor-grabbing"
811
- onPointerDown={(e) => {
812
- e.preventDefault();
813
- e.currentTarget.setPointerCapture(e.pointerId);
814
- draggingIdRef.current = steer.id;
815
- setDraggingSteerId(steer.id);
816
- }}
817
- onPointerMove={(e) => {
818
- if (draggingIdRef.current !== steer.id) return;
819
- reorderSteersByPointer(steer.id, e.clientY);
820
- }}
821
- onPointerUp={(e) => {
822
- e.currentTarget.releasePointerCapture(e.pointerId);
823
- draggingIdRef.current = null;
824
- setDraggingSteerId(null);
825
- }}
826
- onPointerCancel={() => {
827
- draggingIdRef.current = null;
828
- setDraggingSteerId(null);
829
- }}
830
- >
831
- <GripVerticalIcon className="size-3" aria-hidden="true" />
832
- </span>
833
- )}
834
- <span className="text-muted-foreground">Queued</span>
835
- <span className="min-w-0 flex-1 truncate">{steer.text}</span>
836
- {onSendSteerNow && (
837
- <Tooltip>
838
- <TooltipTrigger asChild>
839
- <Button
840
- type="button"
841
- variant="ghost"
842
- size="icon"
843
- className="size-6 shrink-0"
844
- onClick={() => onSendSteerNow(steer.id)}
845
- aria-label="Send now (interrupts current turn)"
846
- >
847
- <SendHorizontalIcon className="size-3" />
848
- </Button>
849
- </TooltipTrigger>
850
- <TooltipContent>Send now — interrupts</TooltipContent>
851
- </Tooltip>
852
- )}
853
- {onRemoveSteer && (
854
- <Tooltip>
855
- <TooltipTrigger asChild>
856
- <Button
857
- type="button"
858
- variant="ghost"
859
- size="icon"
860
- className="size-6 shrink-0"
861
- onClick={() => onRemoveSteer(steer.id)}
862
- aria-label="Remove queued message"
863
- >
864
- <XIcon className="size-3" />
865
- </Button>
866
- </TooltipTrigger>
867
- <TooltipContent>Remove</TooltipContent>
868
- </Tooltip>
869
- )}
870
- </div>
871
- );
872
- })}
873
- </div>
874
- )}
875
- <InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
876
- <InputGroupTextarea
877
- ref={textareaRef}
878
- value={input}
879
- onChange={(e) => setInput(e.target.value)}
880
- onKeyDown={(e) => {
881
- if (e.key === "Enter" && !e.shiftKey) {
882
- e.preventDefault();
883
- handleSubmit(e as unknown as React.FormEvent);
884
- }
885
- }}
886
- placeholder={isLoadingHistory ? "Loading history..." : "Send a message..."}
887
- rows={1}
888
- disabled={isLoadingHistory}
889
- className="max-h-48 text-base md:text-sm"
890
- />
891
- <InputGroupAddon align="inline-end">
892
- {isRunning && onStop && !input.trim() ? (
893
- // Running with an empty composer: the button stops the turn.
894
- <InputGroupButton
895
- type="button"
896
- size="icon-sm"
897
- variant="default"
898
- onClick={() => onStop()}
899
- aria-label="Stop response"
900
- >
901
- <SquareIcon className="size-3 fill-current" />
902
- </InputGroupButton>
903
- ) : (
904
- <>
905
- {/*
906
- * The primary button sends. Submitting while a turn is
907
- * running is a "send now": it interrupts the live run and
908
- * starts a fresh turn with this message immediately (see
909
- * the driver's sendMessage). Idle, it just sends.
910
- */}
911
- <InputGroupButton
912
- type="submit"
913
- size="icon-sm"
914
- variant="default"
915
- disabled={!input.trim() || isLoadingHistory}
916
- aria-label={isRunning ? "Send now (interrupts)" : "Send message"}
917
- >
918
- <SendIcon className="size-3" />
919
- </InputGroupButton>
920
- </>
921
- )}
922
- </InputGroupAddon>
923
- </InputGroup>
924
- {showComposerToolbar && (
925
- <div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
926
- {showModelDisplay &&
927
- (modelChangeable ? (
928
- <Select
929
- value={model ? model : DEFAULT_MODEL_VALUE}
930
- onValueChange={(v) => onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)}
931
- disabled={isLoadingHistory}
932
- >
933
- <SelectTrigger
934
- size="sm"
935
- className="h-7 w-auto max-w-[200px] gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
936
- >
937
- <SelectValue placeholder={defaultOptionLabel} />
938
- </SelectTrigger>
939
- <SelectContent>
940
- <SelectItem value={DEFAULT_MODEL_VALUE}>{defaultOptionLabel}</SelectItem>
941
- {sortedModels.map((m) => (
942
- <SelectItem key={m.name} value={m.name}>
943
- {m.displayName || m.name}
944
- </SelectItem>
945
- ))}
946
- </SelectContent>
947
- </Select>
948
- ) : (
949
- <span className="max-w-[200px] truncate px-2.5 text-xs text-muted-foreground">
950
- {currentModelLabel}
951
- </span>
952
- ))}
953
- {showExport && (
954
- <ExportMenu
955
- onExport={(format) => void onExportConversation?.(format)}
956
- tooltip="Export conversation"
957
- disabled={isLoadingHistory}
958
- />
959
- )}
960
- {showClear && (
961
- <>
962
- <Tooltip>
963
- <TooltipTrigger asChild>
964
- <Button
965
- type="button"
966
- variant="outline"
967
- size="sm"
968
- onClick={() => setClearOpen(true)}
969
- disabled={isLoadingHistory}
970
- className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
971
- >
972
- <Trash2Icon className="size-3" />
973
- Clear
974
- </Button>
975
- </TooltipTrigger>
976
- <TooltipContent>Clear chat history for this thread</TooltipContent>
977
- </Tooltip>
978
- <AlertDialog open={clearOpen} onOpenChange={setClearOpen}>
979
- <AlertDialogContent>
980
- <AlertDialogHeader>
981
- <AlertDialogTitle>Clear this conversation?</AlertDialogTitle>
982
- <AlertDialogDescription>
983
- This permanently deletes the chat history for this thread. This
984
- can&apos;t be undone.
985
- </AlertDialogDescription>
986
- </AlertDialogHeader>
987
- <AlertDialogFooter>
988
- <AlertDialogCancel disabled={clearing}>Cancel</AlertDialogCancel>
989
- <AlertDialogAction
990
- onClick={(e) => {
991
- // Keep the dialog open while the DELETE runs; we
992
- // close it ourselves once `onClear` settles.
993
- e.preventDefault();
994
- void handleClearConfirm();
995
- }}
996
- disabled={clearing}
997
- >
998
- {clearing ? <Spinner className="size-3" /> : null}
999
- {clearing ? "Clearing..." : "Clear"}
1000
- </AlertDialogAction>
1001
- </AlertDialogFooter>
1002
- </AlertDialogContent>
1003
- </AlertDialog>
1004
- </>
1005
- )}
1006
- </div>
1007
- )}
1008
- </form>
1009
- </div>
1010
- </div>
1011
- </TooltipProvider>
65
+ <ChatThreadLayout
66
+ className={className}
67
+ threads={threads}
68
+ threadPlacement={threadPlacement}
69
+ activeThreadId={activeThreadId}
70
+ streamingThreadIds={streamingThreadIds}
71
+ isLoadingThreads={isLoadingThreads}
72
+ onSelectThread={onSelectThread}
73
+ onNewThread={onNewThread}
74
+ onDeleteThread={onDeleteThread}
75
+ onRenameThread={onRenameThread}
76
+ onCancelThread={onCancelThread}
77
+ sidebarOpen={sidebarOpen}
78
+ onToggleSidebar={onToggleSidebar}
79
+ >
80
+ <ChatTranscript
81
+ controller={transcript}
82
+ messages={messages}
83
+ status={status}
84
+ error={error}
85
+ sendMessage={sendMessage}
86
+ suggestions={suggestions}
87
+ toolEventsByMessage={toolEventsByMessage}
88
+ regenerate={regenerate}
89
+ isLoadingMore={isLoadingMore}
90
+ isLoadingHistory={isLoadingHistory}
91
+ onResolveToolApproval={onResolveToolApproval}
92
+ pendingApprovalsByMessage={pendingApprovalsByMessage}
93
+ onExportMessage={onExportMessage}
94
+ feedbackByMessage={feedbackByMessage}
95
+ onFeedback={onFeedback}
96
+ />
97
+ <ChatComposer
98
+ isEmpty={messages.length === 0}
99
+ status={status}
100
+ sendMessage={sendMessage}
101
+ queuedSteers={queuedSteers}
102
+ onSendSteerNow={onSendSteerNow}
103
+ onRemoveSteer={onRemoveSteer}
104
+ onReorderSteers={onReorderSteers}
105
+ onStop={onStop}
106
+ suggestions={suggestions}
107
+ models={models}
108
+ model={model}
109
+ onModelChange={onModelChange}
110
+ defaultModelName={defaultModelName}
111
+ isLoadingHistory={isLoadingHistory}
112
+ onClear={onClear}
113
+ onExportConversation={onExportConversation}
114
+ onResumeFollow={transcript.resumeFollow}
115
+ />
116
+ </ChatThreadLayout>
1012
117
  );
1013
118
  };