@dbx-tools/ui-mastra 0.3.14 → 0.3.16

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 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 removed.
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.14",
30
- "@dbx-tools/shared-genie": "0.3.14",
31
- "@dbx-tools/shared-model": "0.3.14",
32
- "@dbx-tools/ui-appkit": "0.3.14",
33
- "@dbx-tools/shared-mastra": "0.3.14",
34
- "@dbx-tools/ui-branding": "0.3.14"
29
+ "@dbx-tools/shared-genie": "0.3.16",
30
+ "@dbx-tools/shared-core": "0.3.16",
31
+ "@dbx-tools/shared-mastra": "0.3.16",
32
+ "@dbx-tools/shared-model": "0.3.16",
33
+ "@dbx-tools/ui-appkit": "0.3.16",
34
+ "@dbx-tools/ui-branding": "0.3.16"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.14",
41
+ "version": "0.3.16",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -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
- // Mirror `isAtBottom` into a ref so the ResizeObserver below (created
147
- // per transcript mount, not per render) reads the latest value
148
- // without re-subscribing every time the flag toggles.
149
- const isAtBottomRef = useRef(isAtBottom);
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
- // Auto-scroll to bottom whenever a new chunk lands, but only while the
163
- // user is already pinned to the bottom. Lets them scroll up to read
164
- // history mid-stream without the view yanking them back. Skip the
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
- if (prependAnchorRef.current) return;
171
- if (!isAtBottom) return;
175
+ programmaticScrollRef.current = true;
172
176
  el.scrollTop = el.scrollHeight;
173
- }, [messages, toolEventsByMessage, isAtBottom]);
177
+ }, []);
174
178
 
175
- // Keep the view pinned to the bottom as streamed content grows. The
176
- // `messages`-dep effect above only fires when the array reference
177
- // changes; a ResizeObserver on the transcript also catches height
178
- // growth that lands without a new `messages` ref - token-by-token
179
- // text, async markdown/syntax layout, the in-flight waiting row - so
180
- // the scroll keeps up while the user is pinned to the bottom. Only
181
- // pins when already at the bottom, so scrolling up to read history
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 || !isAtBottomRef.current) return;
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
- setIsAtBottom(
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
- if (!scrollRef.current) return;
232
- scrollRef.current.scrollTo({
233
- top: scrollRef.current.scrollHeight,
234
- behavior: "smooth",
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" action, so re-pin to
262
- // the bottom even if the user had scrolled up. `setIsAtBottom(true)` makes
263
- // the message-dep + ResizeObserver auto-scroll effects follow the new turn;
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
- // Re-pin the transcript to the bottom after a submit. `setIsAtBottom(true)`
271
- // drives the auto-scroll effects; the double-rAF jump runs after React
272
- // commits the appended message (a single frame can fire pre-commit).
273
- const pinToBottom = () => {
274
- const pin = () => {
275
- const el = scrollRef.current;
276
- if (el) el.scrollTop = el.scrollHeight;
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
- pin();
280
- requestAnimationFrame(pin);
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
- className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [scrollbar-gutter:stable]"
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 removed.
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
- className="flex items-center gap-1.5 rounded-lg border border-border/70 bg-muted/40 px-2 py-1 text-xs"
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,
@@ -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
@@ -19,6 +19,8 @@
19
19
  *
20
20
  * It only fills gaps: any field the spec already sets (an explicit
21
21
  * `axisLabel.formatter`, `nameLocation`, etc.) is preserved.
22
+ *
23
+ * @module
22
24
  */
23
25
  import { object } from "@dbx-tools/shared-core";
24
26
 
@@ -19,6 +19,8 @@
19
19
  * export carries the chart itself rather than a placeholder.
20
20
  * `[data:<id>]` markers resolve to a real table. Unresolved / expired
21
21
  * ids are skipped so the surrounding prose stays clean.
22
+ *
23
+ * @module
22
24
  */
23
25
 
24
26
  import {
@@ -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