@dbx-tools/ui-mastra 0.3.15 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +7 -7
- package/src/react/chat-view.tsx +127 -55
- package/src/react/mastra-chat.tsx +14 -0
- package/src/react/types.ts +6 -0
- package/src/support/thread-sessions.ts +23 -0
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ Key features:
|
|
|
30
30
|
+ routing, no shared client state).
|
|
31
31
|
- Mid-turn steering queue: messages submitted while a turn streams stack up as
|
|
32
32
|
pending steers (they drain oldest-first when the turn ends); each queued item
|
|
33
|
-
can be sent now (interrupting the current turn) or
|
|
33
|
+
can be sent now (interrupting the current turn), removed, or dragged to
|
|
34
|
+
reorder the queue.
|
|
34
35
|
- Export menu for PDF and Markdown, resolving charts and tables so
|
|
35
36
|
exported conversations remain useful offline.
|
|
36
37
|
|
package/package.json
CHANGED
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
"shiki": "^3.0.0",
|
|
27
27
|
"sql-formatter": "^15.6.9",
|
|
28
28
|
"streamdown": "^2.5.0",
|
|
29
|
-
"@dbx-tools/shared-core": "0.3.
|
|
30
|
-
"@dbx-tools/shared-genie": "0.3.
|
|
31
|
-
"@dbx-tools/shared-
|
|
32
|
-
"@dbx-tools/
|
|
33
|
-
"@dbx-tools/
|
|
34
|
-
"@dbx-tools/ui-branding": "0.3.
|
|
29
|
+
"@dbx-tools/shared-core": "0.3.17",
|
|
30
|
+
"@dbx-tools/shared-genie": "0.3.17",
|
|
31
|
+
"@dbx-tools/shared-model": "0.3.17",
|
|
32
|
+
"@dbx-tools/shared-mastra": "0.3.17",
|
|
33
|
+
"@dbx-tools/ui-appkit": "0.3.17",
|
|
34
|
+
"@dbx-tools/ui-branding": "0.3.17"
|
|
35
35
|
},
|
|
36
36
|
"main": "index.ts",
|
|
37
37
|
"license": "UNLICENSED",
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
|
-
"version": "0.3.
|
|
41
|
+
"version": "0.3.17",
|
|
42
42
|
"types": "index.ts",
|
|
43
43
|
"type": "module",
|
|
44
44
|
"exports": {
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { error as errorUtil } from "@dbx-tools/shared-core";
|
|
36
36
|
import {
|
|
37
37
|
ArrowDownIcon,
|
|
38
|
+
GripVerticalIcon,
|
|
38
39
|
MessageSquareIcon,
|
|
39
40
|
PanelLeftIcon,
|
|
40
41
|
RefreshCwIcon,
|
|
@@ -45,7 +46,7 @@ import {
|
|
|
45
46
|
TriangleAlertIcon,
|
|
46
47
|
XIcon,
|
|
47
48
|
} from "lucide-react";
|
|
48
|
-
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
49
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
49
50
|
import { AssistantBubble, UserBubble } from "./bubbles";
|
|
50
51
|
import { ExportMenu } from "./export-menu";
|
|
51
52
|
import { SuggestionPills } from "./suggestion-pills";
|
|
@@ -105,6 +106,7 @@ export const ChatView = ({
|
|
|
105
106
|
queuedSteers = [],
|
|
106
107
|
onSendSteerNow,
|
|
107
108
|
onRemoveSteer,
|
|
109
|
+
onReorderSteers,
|
|
108
110
|
regenerate,
|
|
109
111
|
onStop,
|
|
110
112
|
className,
|
|
@@ -138,16 +140,22 @@ export const ChatView = ({
|
|
|
138
140
|
onFeedback,
|
|
139
141
|
}: ChatViewProps) => {
|
|
140
142
|
const [input, setInput] = useState("");
|
|
143
|
+
// Id of the queued steer currently being dragged (native HTML5 drag), for
|
|
144
|
+
// the reorder affordance + drop styling. Null when not dragging.
|
|
145
|
+
const [draggingSteerId, setDraggingSteerId] = useState<string | null>(null);
|
|
141
146
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
142
147
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
143
148
|
// Composer textarea, auto-grown with its content up to the CSS `max-h`.
|
|
144
149
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
150
|
+
// `isAtBottom` drives the "jump to latest" button; `pinnedRef` drives the
|
|
151
|
+
// auto-follow. They usually agree, but `pinnedRef` is INTENT (do we want to
|
|
152
|
+
// stick to the bottom?) rather than a measurement, so a fast programmatic
|
|
153
|
+
// pin mid-stream can't be misread as the user scrolling away.
|
|
145
154
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
const
|
|
150
|
-
isAtBottomRef.current = isAtBottom;
|
|
155
|
+
const pinnedRef = useRef(true);
|
|
156
|
+
// Set right before a programmatic `scrollTop` write so the `scroll` event it
|
|
157
|
+
// triggers is ignored by `handleScroll` (only USER scrolls should unpin).
|
|
158
|
+
const programmaticScrollRef = useRef(false);
|
|
151
159
|
// Scroll-anchor state for prepending older messages. When the
|
|
152
160
|
// parent answers an `onLoadMore` call we capture the pre-prepend
|
|
153
161
|
// `scrollHeight`/`scrollTop`; once the new DOM nodes mount we shift
|
|
@@ -159,40 +167,43 @@ export const ChatView = ({
|
|
|
159
167
|
const loadMoreRef = useRef(onLoadMore);
|
|
160
168
|
loadMoreRef.current = onLoadMore;
|
|
161
169
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
// adjust when an older page was just prepended (the anchor restore
|
|
166
|
-
// below owns that case).
|
|
167
|
-
useEffect(() => {
|
|
170
|
+
// Jump the transcript to the bottom, marking the write as programmatic so
|
|
171
|
+
// the resulting scroll event isn't mistaken for the user scrolling away.
|
|
172
|
+
const pinToBottomNow = useCallback(() => {
|
|
168
173
|
const el = scrollRef.current;
|
|
169
174
|
if (!el) return;
|
|
170
|
-
|
|
171
|
-
if (!isAtBottom) return;
|
|
175
|
+
programmaticScrollRef.current = true;
|
|
172
176
|
el.scrollTop = el.scrollHeight;
|
|
173
|
-
}, [
|
|
177
|
+
}, []);
|
|
174
178
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// mid-stream still works. Re-subscribes when the transcript mounts
|
|
183
|
-
// (empty -> first message, or history finishes loading).
|
|
179
|
+
// Follow the bottom as streamed content grows, as long as we're "pinned".
|
|
180
|
+
// A ResizeObserver on the transcript catches every height change - new
|
|
181
|
+
// messages, token-by-token text, async markdown/chart layout, the waiting
|
|
182
|
+
// row - and re-pins reading the FRESH scrollHeight, so the view keeps up
|
|
183
|
+
// with fast streaming. Pinning is intent-driven (`pinnedRef`), not measured
|
|
184
|
+
// off scrollTop, so our own programmatic jumps never look like the user
|
|
185
|
+
// scrolling up. Re-subscribes when the transcript mounts.
|
|
184
186
|
useEffect(() => {
|
|
185
187
|
const el = scrollRef.current;
|
|
186
188
|
const content = contentRef.current;
|
|
187
189
|
if (!el || !content) return;
|
|
188
190
|
const observer = new ResizeObserver(() => {
|
|
189
|
-
if (prependAnchorRef.current || !
|
|
191
|
+
if (prependAnchorRef.current || !pinnedRef.current) return;
|
|
192
|
+
programmaticScrollRef.current = true;
|
|
190
193
|
el.scrollTop = el.scrollHeight;
|
|
191
194
|
});
|
|
192
195
|
observer.observe(content);
|
|
193
196
|
return () => observer.disconnect();
|
|
194
197
|
}, [messages.length, isLoadingHistory]);
|
|
195
198
|
|
|
199
|
+
// A new message reference (a turn starting, or a steer appended) re-pins if
|
|
200
|
+
// we were following, catching growth the observer's first callback might
|
|
201
|
+
// race. Skipped during a prepend (the anchor restore below owns that).
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (prependAnchorRef.current || !pinnedRef.current) return;
|
|
204
|
+
pinToBottomNow();
|
|
205
|
+
}, [messages, toolEventsByMessage, pinToBottomNow]);
|
|
206
|
+
|
|
196
207
|
// Restore the visual scroll position after a prepend. Runs in
|
|
197
208
|
// `useLayoutEffect` so the adjustment happens before the browser
|
|
198
209
|
// paints; an effect would let the new content flash at the top.
|
|
@@ -207,9 +218,17 @@ export const ChatView = ({
|
|
|
207
218
|
|
|
208
219
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
209
220
|
const el = e.currentTarget;
|
|
210
|
-
|
|
211
|
-
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX
|
|
212
|
-
)
|
|
221
|
+
const atBottom =
|
|
222
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
|
|
223
|
+
// A scroll we caused (a pin) shouldn't change intent - only reconcile the
|
|
224
|
+
// button state. A USER scroll sets intent: scrolling up unpins (stop
|
|
225
|
+
// following); scrolling back to the bottom re-pins (resume following).
|
|
226
|
+
if (programmaticScrollRef.current) {
|
|
227
|
+
programmaticScrollRef.current = false;
|
|
228
|
+
} else {
|
|
229
|
+
pinnedRef.current = atBottom;
|
|
230
|
+
}
|
|
231
|
+
setIsAtBottom(atBottom);
|
|
213
232
|
// Lazy-load older messages once the user gets close to the top.
|
|
214
233
|
// Capture the anchor *before* firing the callback so the parent's
|
|
215
234
|
// synchronous state updates don't beat us to the layout effect.
|
|
@@ -227,12 +246,15 @@ export const ChatView = ({
|
|
|
227
246
|
}
|
|
228
247
|
};
|
|
229
248
|
|
|
249
|
+
// The "jump to latest" button: smooth-scroll to the bottom and resume
|
|
250
|
+
// following (re-pin), since the user asked to return to live content.
|
|
230
251
|
const scrollToBottom = () => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
252
|
+
const el = scrollRef.current;
|
|
253
|
+
if (!el) return;
|
|
254
|
+
pinnedRef.current = true;
|
|
255
|
+
setIsAtBottom(true);
|
|
256
|
+
programmaticScrollRef.current = true;
|
|
257
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
236
258
|
};
|
|
237
259
|
|
|
238
260
|
// Grow the composer with its content (up to the textarea's CSS `max-h`,
|
|
@@ -258,26 +280,21 @@ export const ChatView = ({
|
|
|
258
280
|
// to the live run (or interrupts + resends). Idle submits start a turn.
|
|
259
281
|
sendMessage({ text });
|
|
260
282
|
setInput("");
|
|
261
|
-
// Sending is an explicit "I want to see the response"
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
// the double-rAF is a belt-and-suspenders jump that runs AFTER React
|
|
265
|
-
// commits the appended message (a single frame can fire pre-commit).
|
|
266
|
-
setIsAtBottom(true);
|
|
267
|
-
pinToBottom();
|
|
283
|
+
// Sending is an explicit "I want to see the response", so resume following
|
|
284
|
+
// even if the user had scrolled up.
|
|
285
|
+
resumeFollow();
|
|
268
286
|
};
|
|
269
287
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
};
|
|
288
|
+
// Resume auto-following the bottom (after a submit): set intent, then jump
|
|
289
|
+
// across two frames so the pin lands AFTER React commits the appended
|
|
290
|
+
// message (a single frame can fire pre-commit). The ResizeObserver keeps it
|
|
291
|
+
// pinned through the subsequent streaming growth.
|
|
292
|
+
const resumeFollow = () => {
|
|
293
|
+
pinnedRef.current = true;
|
|
294
|
+
setIsAtBottom(true);
|
|
278
295
|
requestAnimationFrame(() => {
|
|
279
|
-
|
|
280
|
-
requestAnimationFrame(
|
|
296
|
+
pinToBottomNow();
|
|
297
|
+
requestAnimationFrame(pinToBottomNow);
|
|
281
298
|
});
|
|
282
299
|
};
|
|
283
300
|
|
|
@@ -500,7 +517,10 @@ export const ChatView = ({
|
|
|
500
517
|
<div
|
|
501
518
|
ref={scrollRef}
|
|
502
519
|
onScroll={handleScroll}
|
|
503
|
-
|
|
520
|
+
// `overflow-anchor:none` stops the browser's scroll anchoring
|
|
521
|
+
// from fighting the programmatic bottom-pin as streamed content
|
|
522
|
+
// grows (it would otherwise lock onto a mid-transcript element).
|
|
523
|
+
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [overflow-anchor:none] [scrollbar-gutter:stable]"
|
|
504
524
|
>
|
|
505
525
|
{messages.length === 0 && !isLoadingHistory ? (
|
|
506
526
|
<Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
|
|
@@ -631,13 +651,64 @@ export const ChatView = ({
|
|
|
631
651
|
{queuedSteers.length > 0 && (
|
|
632
652
|
// Steers submitted while the turn is running, waiting to send.
|
|
633
653
|
// They drain oldest-first when the turn ends; each can be fired
|
|
634
|
-
// now (interrupts) or
|
|
654
|
+
// now (interrupts), removed, or dragged to reorder the queue.
|
|
635
655
|
<div className="mb-2 flex flex-col gap-1">
|
|
636
|
-
{queuedSteers.map((steer) =>
|
|
656
|
+
{queuedSteers.map((steer) => {
|
|
657
|
+
const reorderable = Boolean(onReorderSteers);
|
|
658
|
+
// Move the dragged steer to just before `steer` (or to the
|
|
659
|
+
// end when dropped on itself at the tail) and report the new
|
|
660
|
+
// id order to the driver.
|
|
661
|
+
const dropBefore = () => {
|
|
662
|
+
if (!onReorderSteers || !draggingSteerId || draggingSteerId === steer.id) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const ids = queuedSteers.map((s) => s.id).filter((id) => id !== draggingSteerId);
|
|
666
|
+
const at = ids.indexOf(steer.id);
|
|
667
|
+
ids.splice(at === -1 ? ids.length : at, 0, draggingSteerId);
|
|
668
|
+
onReorderSteers(ids);
|
|
669
|
+
};
|
|
670
|
+
return (
|
|
637
671
|
<div
|
|
638
672
|
key={steer.id}
|
|
639
|
-
|
|
673
|
+
draggable={reorderable}
|
|
674
|
+
onDragStart={
|
|
675
|
+
reorderable
|
|
676
|
+
? (e) => {
|
|
677
|
+
setDraggingSteerId(steer.id);
|
|
678
|
+
e.dataTransfer.effectAllowed = "move";
|
|
679
|
+
}
|
|
680
|
+
: undefined
|
|
681
|
+
}
|
|
682
|
+
onDragOver={
|
|
683
|
+
reorderable
|
|
684
|
+
? (e) => {
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
e.dataTransfer.dropEffect = "move";
|
|
687
|
+
}
|
|
688
|
+
: undefined
|
|
689
|
+
}
|
|
690
|
+
onDrop={
|
|
691
|
+
reorderable
|
|
692
|
+
? (e) => {
|
|
693
|
+
e.preventDefault();
|
|
694
|
+
dropBefore();
|
|
695
|
+
setDraggingSteerId(null);
|
|
696
|
+
}
|
|
697
|
+
: undefined
|
|
698
|
+
}
|
|
699
|
+
onDragEnd={reorderable ? () => setDraggingSteerId(null) : undefined}
|
|
700
|
+
className={cn(
|
|
701
|
+
"flex items-center gap-1.5 rounded-lg border border-border/70 bg-muted/40 px-2 py-1 text-xs",
|
|
702
|
+
reorderable && "cursor-grab active:cursor-grabbing",
|
|
703
|
+
draggingSteerId === steer.id && "opacity-50",
|
|
704
|
+
)}
|
|
640
705
|
>
|
|
706
|
+
{reorderable && (
|
|
707
|
+
<GripVerticalIcon
|
|
708
|
+
className="size-3 shrink-0 text-muted-foreground"
|
|
709
|
+
aria-hidden="true"
|
|
710
|
+
/>
|
|
711
|
+
)}
|
|
641
712
|
<span className="text-muted-foreground">Queued</span>
|
|
642
713
|
<span className="min-w-0 flex-1 truncate">{steer.text}</span>
|
|
643
714
|
{onSendSteerNow && (
|
|
@@ -675,7 +746,8 @@ export const ChatView = ({
|
|
|
675
746
|
</Tooltip>
|
|
676
747
|
)}
|
|
677
748
|
</div>
|
|
678
|
-
|
|
749
|
+
);
|
|
750
|
+
})}
|
|
679
751
|
</div>
|
|
680
752
|
)}
|
|
681
753
|
<InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
enqueueSteer,
|
|
23
23
|
isSessionRunning,
|
|
24
24
|
removeSteer as removeSteerFromQueue,
|
|
25
|
+
reorderSteers as reorderSteerQueue,
|
|
25
26
|
sessionKey,
|
|
26
27
|
terminateRunningToolEvents,
|
|
27
28
|
type ThreadSession,
|
|
@@ -979,6 +980,18 @@ export const useMastraChat = (
|
|
|
979
980
|
[activeKey, updateSession],
|
|
980
981
|
);
|
|
981
982
|
|
|
983
|
+
// Reorder the queue to match a drag-reorder of the pending steers. The queue
|
|
984
|
+
// then drains (and "send now" fires) in the new order.
|
|
985
|
+
const reorderSteers = useCallback(
|
|
986
|
+
(orderedIds: string[]) => {
|
|
987
|
+
updateSession(activeKey, (session) => ({
|
|
988
|
+
...session,
|
|
989
|
+
queuedSteers: reorderSteerQueue(session.queuedSteers, orderedIds),
|
|
990
|
+
}));
|
|
991
|
+
},
|
|
992
|
+
[activeKey, updateSession],
|
|
993
|
+
);
|
|
994
|
+
|
|
982
995
|
/**
|
|
983
996
|
* Wipe the current thread on the server and reset every piece of
|
|
984
997
|
* client-side state that mirrored it. The session cookie that
|
|
@@ -1444,6 +1457,7 @@ export const useMastraChat = (
|
|
|
1444
1457
|
queuedSteers: activeSession.queuedSteers,
|
|
1445
1458
|
onSendSteerNow: sendSteerNow,
|
|
1446
1459
|
onRemoveSteer: removeSteer,
|
|
1460
|
+
onReorderSteers: reorderSteers,
|
|
1447
1461
|
regenerate,
|
|
1448
1462
|
onStop: stop,
|
|
1449
1463
|
suggestions,
|
package/src/react/types.ts
CHANGED
|
@@ -116,6 +116,12 @@ export type ChatViewProps = {
|
|
|
116
116
|
onSendSteerNow?: (steerId: string) => void;
|
|
117
117
|
/** Drop a queued steer without sending it. */
|
|
118
118
|
onRemoveSteer?: (steerId: string) => void;
|
|
119
|
+
/**
|
|
120
|
+
* Reorder the pending steers to match `orderedIds` (a drag-reorder). The
|
|
121
|
+
* queue then drains - and "send now" fires - in the new order. When
|
|
122
|
+
* provided, queued steer chips become drag-reorderable.
|
|
123
|
+
*/
|
|
124
|
+
onReorderSteers?: (orderedIds: string[]) => void;
|
|
119
125
|
regenerate?: () => void;
|
|
120
126
|
/**
|
|
121
127
|
* Abort the in-flight response. When provided and the chat is running
|
|
@@ -69,6 +69,29 @@ export function removeSteer(queue: QueuedSteer[], id: string): QueuedSteer[] {
|
|
|
69
69
|
return queue.filter((steer) => steer.id !== id);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Reorder the queue to match `orderedIds` (a drag-reorder of the pending
|
|
74
|
+
* steers). Ids not present are dropped and unknown ids ignored, so a stale
|
|
75
|
+
* order can't duplicate or resurrect an item; any current steer missing from
|
|
76
|
+
* `orderedIds` is appended in its existing relative order as a safety net.
|
|
77
|
+
*/
|
|
78
|
+
export function reorderSteers(queue: QueuedSteer[], orderedIds: string[]): QueuedSteer[] {
|
|
79
|
+
const byId = new Map(queue.map((steer) => [steer.id, steer]));
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
const next: QueuedSteer[] = [];
|
|
82
|
+
for (const id of orderedIds) {
|
|
83
|
+
const steer = byId.get(id);
|
|
84
|
+
if (steer && !seen.has(id)) {
|
|
85
|
+
next.push(steer);
|
|
86
|
+
seen.add(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const steer of queue) {
|
|
90
|
+
if (!seen.has(steer.id)) next.push(steer);
|
|
91
|
+
}
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
/**
|
|
73
96
|
* Settle any tool-progress pills still marked `running` to `done`. A cancelled
|
|
74
97
|
* or interrupted turn stops delivering the `tool-result` / `tool-error` chunks
|