@dbx-tools/ui-mastra 0.3.9 → 0.3.11

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
@@ -24,7 +24,12 @@ Key features:
24
24
  - Inline embed rendering for `[chart:<id>]` and `[data:<id>]` markers produced by
25
25
  the server plugin.
26
26
  - Conversation sidebar with new, select, rename, delete, active-thread, and
27
- background-streaming states.
27
+ background-streaming states, plus a per-row cancel for a running thread.
28
+ - Concurrent threads: run several conversations at once, switch between them
29
+ while each keeps streaming, and cancel any one independently (per-thread abort
30
+ + routing, no shared client state).
31
+ - Mid-turn steering: submit a message while a turn streams to fold it into the
32
+ live run (Mastra queue-message), with an immediate interrupt-and-resend option.
28
33
  - Export menu for PDF and Markdown, resolving charts and tables so
29
34
  exported conversations remain useful offline.
30
35
 
@@ -44,6 +49,9 @@ understand Mastra-specific behavior:
44
49
  answer.
45
50
  - `[chart:<id>]` and `[data:<id>]` assistant markers rendered as ECharts charts
46
51
  and sortable tables.
52
+ - Concurrent multi-thread streaming, per-thread cancel, and mid-turn steering
53
+ (queue-message with interrupt fallback) - the native AppKit chat surface runs
54
+ one turn at a time and has no steering.
47
55
  - Conversation export that resolves those embeds into Markdown or PDF.
48
56
 
49
57
  ## Add The Styles
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-genie": "0.3.9",
30
- "@dbx-tools/shared-mastra": "0.3.9",
31
- "@dbx-tools/shared-model": "0.3.9",
32
- "@dbx-tools/ui-appkit": "0.3.9",
33
- "@dbx-tools/shared-core": "0.3.9",
34
- "@dbx-tools/ui-branding": "0.3.9"
29
+ "@dbx-tools/shared-core": "0.3.11",
30
+ "@dbx-tools/shared-genie": "0.3.11",
31
+ "@dbx-tools/shared-mastra": "0.3.11",
32
+ "@dbx-tools/shared-model": "0.3.11",
33
+ "@dbx-tools/ui-appkit": "0.3.11",
34
+ "@dbx-tools/ui-branding": "0.3.11"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.9",
41
+ "version": "0.3.11",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -44,6 +44,39 @@ import type {
44
44
  // the helpers that surface approval-gated tool calls out of a message's
45
45
  // parts.
46
46
 
47
+ /**
48
+ * Copy `text` to the clipboard, resolving `true` on success. Prefers the
49
+ * async Clipboard API, but that is only available in a secure context
50
+ * (HTTPS / localhost) - over plain HTTP (e.g. a LAN / ZeroTier IP) it is
51
+ * `undefined`, which is why a tap on mobile could silently do nothing. Falls
52
+ * back to a hidden `<textarea>` + `document.execCommand("copy")` so copy works
53
+ * off a non-secure origin too.
54
+ */
55
+ async function copyText(text: string): Promise<boolean> {
56
+ try {
57
+ if (navigator.clipboard?.writeText) {
58
+ await navigator.clipboard.writeText(text);
59
+ return true;
60
+ }
61
+ } catch {
62
+ // Fall through to the execCommand path.
63
+ }
64
+ try {
65
+ const area = document.createElement("textarea");
66
+ area.value = text;
67
+ area.setAttribute("readonly", "");
68
+ area.style.position = "fixed";
69
+ area.style.opacity = "0";
70
+ document.body.appendChild(area);
71
+ area.select();
72
+ const ok = document.execCommand("copy");
73
+ area.remove();
74
+ return ok;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
47
80
  const getReasoningText = (parts: UIMessage["parts"]): string =>
48
81
  parts
49
82
  .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
@@ -274,6 +307,17 @@ export const AssistantBubble = ({
274
307
  );
275
308
  const fullText = textParts.map((p) => p.text).join("");
276
309
  const hasText = fullText.length > 0;
310
+ // Brief "copied" acknowledgement on the copy button (swaps the icon to a
311
+ // check for ~1.5s), so a tap gives visible feedback on touch where there's
312
+ // no hover/tooltip.
313
+ const [copied, setCopied] = useState(false);
314
+ const handleCopy = () => {
315
+ void copyText(fullText).then((ok) => {
316
+ if (!ok) return;
317
+ setCopied(true);
318
+ window.setTimeout(() => setCopied(false), 1500);
319
+ });
320
+ };
277
321
  // Suggestions are deferred until the turn is settled so they don't
278
322
  // pop in mid-stream. A bubble is "settled" if it isn't the active
279
323
  // streaming target - either the agent has returned to `ready` /
@@ -379,12 +423,17 @@ export const AssistantBubble = ({
379
423
  size="icon"
380
424
  variant="ghost"
381
425
  className="size-7"
382
- onClick={() => navigator.clipboard.writeText(fullText)}
426
+ onClick={handleCopy}
427
+ aria-label={copied ? "Copied" : "Copy"}
383
428
  >
384
- <CopyIcon className="size-3" />
429
+ {copied ? (
430
+ <CheckIcon className="size-3" />
431
+ ) : (
432
+ <CopyIcon className="size-3" />
433
+ )}
385
434
  </Button>
386
435
  </TooltipTrigger>
387
- <TooltipContent>Copy</TooltipContent>
436
+ <TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
388
437
  </Tooltip>
389
438
  )}
390
439
  {onExport && (
@@ -35,6 +35,7 @@ import {
35
35
  import { error as errorUtil } from "@dbx-tools/shared-core";
36
36
  import {
37
37
  ArrowDownIcon,
38
+ FastForwardIcon,
38
39
  MessageSquareIcon,
39
40
  PanelLeftIcon,
40
41
  RefreshCwIcon,
@@ -100,6 +101,7 @@ export const ChatView = ({
100
101
  status,
101
102
  error,
102
103
  sendMessage,
104
+ onInterrupt,
103
105
  regenerate,
104
106
  onStop,
105
107
  className,
@@ -253,17 +255,40 @@ export const ChatView = ({
253
255
  // to the live run (or interrupts + resends). Idle submits start a turn.
254
256
  sendMessage({ text });
255
257
  setInput("");
256
- // Sending is an explicit "I want to see the response" action, so
257
- // re-pin to the bottom even if the user had scrolled up. Without this
258
- // the freshly-appended user turn + waiting row can leave `isAtBottom`
259
- // false, suppressing auto-scroll and forcing a manual scroll down.
258
+ // Sending is an explicit "I want to see the response" action, so re-pin to
259
+ // the bottom even if the user had scrolled up. `setIsAtBottom(true)` makes
260
+ // the message-dep + ResizeObserver auto-scroll effects follow the new turn;
261
+ // the double-rAF is a belt-and-suspenders jump that runs AFTER React
262
+ // commits the appended message (a single frame can fire pre-commit).
260
263
  setIsAtBottom(true);
261
- requestAnimationFrame(() => {
264
+ pinToBottom();
265
+ };
266
+
267
+ // Re-pin the transcript to the bottom after a submit. `setIsAtBottom(true)`
268
+ // drives the auto-scroll effects; the double-rAF jump runs after React
269
+ // commits the appended message (a single frame can fire pre-commit).
270
+ const pinToBottom = () => {
271
+ const pin = () => {
262
272
  const el = scrollRef.current;
263
273
  if (el) el.scrollTop = el.scrollHeight;
274
+ };
275
+ requestAnimationFrame(() => {
276
+ pin();
277
+ requestAnimationFrame(pin);
264
278
  });
265
279
  };
266
280
 
281
+ // Interrupt-submit: force an immediate course-correction (abort the current
282
+ // run, resend with this text) rather than queuing to the live turn.
283
+ const handleInterrupt = () => {
284
+ const text = input.trim();
285
+ if (!text || !onInterrupt) return;
286
+ onInterrupt({ text });
287
+ setInput("");
288
+ setIsAtBottom(true);
289
+ pinToBottom();
290
+ };
291
+
267
292
  const lastMessage = messages.at(-1);
268
293
  const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
269
294
  // Single in-flight indicator for the whole turn: visible from the
@@ -638,17 +663,44 @@ export const ChatView = ({
638
663
  <SquareIcon className="size-3 fill-current" />
639
664
  </InputGroupButton>
640
665
  ) : (
641
- // Idle, or running with typed text: the button sends. A send
642
- // mid-run steers the live turn (see the driver's sendMessage).
643
- <InputGroupButton
644
- type="submit"
645
- size="icon-sm"
646
- variant="default"
647
- disabled={!input.trim()}
648
- aria-label={isRunning ? "Steer response" : "Send message"}
649
- >
650
- <SendIcon className="size-3" />
651
- </InputGroupButton>
666
+ <>
667
+ {/*
668
+ * Running with typed text: offer an immediate interrupt
669
+ * alongside the steer Send. Interrupt aborts the current
670
+ * turn and resends now; Send folds the message into the
671
+ * live turn (queue-steer).
672
+ */}
673
+ {isRunning && onInterrupt && input.trim() && (
674
+ <Tooltip>
675
+ <TooltipTrigger asChild>
676
+ <InputGroupButton
677
+ type="button"
678
+ size="icon-sm"
679
+ variant="outline"
680
+ onClick={handleInterrupt}
681
+ aria-label="Interrupt and send now"
682
+ >
683
+ <FastForwardIcon className="size-3" />
684
+ </InputGroupButton>
685
+ </TooltipTrigger>
686
+ <TooltipContent>Interrupt &amp; send now</TooltipContent>
687
+ </Tooltip>
688
+ )}
689
+ {/*
690
+ * Idle, or running with typed text: the primary button
691
+ * sends. A send mid-run steers the live turn (see the
692
+ * driver's sendMessage).
693
+ */}
694
+ <InputGroupButton
695
+ type="submit"
696
+ size="icon-sm"
697
+ variant="default"
698
+ disabled={!input.trim()}
699
+ aria-label={isRunning ? "Steer response" : "Send message"}
700
+ >
701
+ <SendIcon className="size-3" />
702
+ </InputGroupButton>
703
+ </>
652
704
  )}
653
705
  </InputGroupAddon>
654
706
  </InputGroup>
@@ -875,11 +875,12 @@ export const useMastraChat = (
875
875
  [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
876
876
  );
877
877
 
878
- const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
879
- (message) => {
880
- const text = message.text ?? "";
881
- if (!text) return;
882
- const threadId = activeKey;
878
+ // Append a user message to a thread's transcript, stamping `lastUserText`,
879
+ // thread-activity, and a provisional title for a brand-new thread. Returns
880
+ // the pre-append session (so callers can see whether a run was in flight)
881
+ // and the new message list. Shared by send + interrupt.
882
+ const appendUserMessage = useCallback(
883
+ (threadId: string, text: string): { before: ThreadSession; next: UIMessage[] } => {
883
884
  updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
884
885
  if (activeThreadId) {
885
886
  noteThreadActivity(activeThreadId);
@@ -895,11 +896,21 @@ export const useMastraChat = (
895
896
  const before = getSession(threadId);
896
897
  const next = [...before.messages, makeUserMessage(text)];
897
898
  writeMessages(threadId, next);
898
- // Steering: if a turn is already streaming on this thread, try to hand
899
- // the message to the live run so the agent folds it into the current
900
- // turn (no restart). If the server won't deliver it, fall back to
901
- // interrupting the run and starting a fresh turn with the message
902
- // included, so a steer never silently drops.
899
+ return { before, next };
900
+ },
901
+ [activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
902
+ );
903
+
904
+ const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
905
+ (message) => {
906
+ const text = message.text ?? "";
907
+ if (!text) return;
908
+ const threadId = activeKey;
909
+ const { before, next } = appendUserMessage(threadId, text);
910
+ // Steering: if a turn is already streaming on this thread, hand the
911
+ // message to the live run so the agent folds it into the current turn
912
+ // (no restart). If the server won't deliver it, fall back to
913
+ // interrupting + resending so a steer never silently drops.
903
914
  if (isSessionRunning(before) && before.runId) {
904
915
  const runId = before.runId;
905
916
  const steerThreadId =
@@ -918,17 +929,25 @@ export const useMastraChat = (
918
929
  }
919
930
  void runStream(threadId, next);
920
931
  },
921
- [
922
- runStream,
923
- writeMessages,
924
- activeThreadId,
925
- activeKey,
926
- getSession,
927
- mastraClient,
928
- agentId,
929
- noteThreadActivity,
930
- updateSession,
931
- ],
932
+ [appendUserMessage, runStream, activeKey, getSession, mastraClient, agentId],
933
+ );
934
+
935
+ // Interrupt-and-resend: unconditionally abort any in-flight run on the
936
+ // active thread and start a fresh turn with `text` included immediately.
937
+ // The composer offers this alongside the queue-steer send so the user can
938
+ // force an immediate course-correction rather than waiting for the current
939
+ // turn to fold in a queued message. `runStream`/`driveStream` supersede the
940
+ // prior run (abort + runToken bump + pill cleanup).
941
+ const interrupt = useCallback(
942
+ (message: { text?: string }) => {
943
+ const text = message.text ?? "";
944
+ if (!text) return;
945
+ const threadId = activeKey;
946
+ const { next } = appendUserMessage(threadId, text);
947
+ logger.info("steer:interrupt", { threadId });
948
+ void runStream(threadId, next);
949
+ },
950
+ [appendUserMessage, runStream, activeKey],
932
951
  );
933
952
 
934
953
  /**
@@ -1393,6 +1412,7 @@ export const useMastraChat = (
1393
1412
  status: activeSession.status,
1394
1413
  error: activeSession.error,
1395
1414
  sendMessage,
1415
+ onInterrupt: interrupt,
1396
1416
  regenerate,
1397
1417
  onStop: stop,
1398
1418
  suggestions,
@@ -90,6 +90,15 @@ export type ChatViewProps = {
90
90
  */
91
91
  error?: Error | null;
92
92
  sendMessage: (message: { text: string }) => void;
93
+ /**
94
+ * Send a message that interrupts the in-flight turn immediately: abort the
95
+ * current run and start a fresh turn with this message. When provided and
96
+ * the chat is running, the composer shows a small "interrupt" action next
97
+ * to the queue-steer Send, so the user can force an immediate
98
+ * course-correction instead of folding the message into the live turn.
99
+ * Omit to offer only the queue-steer send while running.
100
+ */
101
+ onInterrupt?: (message: { text: string }) => void;
93
102
  regenerate?: () => void;
94
103
  /**
95
104
  * Abort the in-flight response. When provided and the chat is running