@dbx-tools/ui-mastra 0.6.211 → 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.
- package/LICENSE +202 -0
- package/index.ts +17 -2
- package/package.json +15 -10
- package/src/react/chat-approvals.ts +92 -0
- package/src/react/chat-composer.tsx +419 -0
- package/src/react/chat-feedback.ts +60 -0
- package/src/react/chat-history.ts +141 -0
- package/src/react/chat-sessions.ts +76 -0
- package/src/react/chat-stream-reducer.ts +241 -0
- package/src/react/chat-stream.ts +117 -0
- package/src/react/chat-thread-layout.tsx +214 -0
- package/src/react/chat-transcript.tsx +309 -0
- package/src/react/chat-view.tsx +69 -964
- package/src/react/mastra-chat.tsx +48 -587
- package/src/support/mastra-stream.ts +4 -8
package/src/react/chat-view.tsx
CHANGED
|
@@ -1,119 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
48
|
+
sidebarOpen,
|
|
153
49
|
onToggleSidebar,
|
|
154
50
|
onExportConversation,
|
|
155
51
|
onExportMessage,
|
|
156
52
|
feedbackByMessage = {},
|
|
157
53
|
onFeedback,
|
|
158
54
|
}: ChatViewProps) => {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
<
|
|
535
|
-
{
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
{
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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'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
|
};
|