@dbx-tools/ui-mastra 0.3.12 → 0.3.14

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
@@ -28,9 +28,9 @@ Key features:
28
28
  - Concurrent threads: run several conversations at once, switch between them
29
29
  while each keeps streaming, and cancel any one independently (per-thread abort
30
30
  + routing, no shared client state).
31
- - Mid-turn steering: submit a message while a turn streams to "send now" - it
32
- interrupts the in-flight run and starts a fresh turn with your message
33
- immediately.
31
+ - Mid-turn steering queue: messages submitted while a turn streams stack up as
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.
34
34
  - Export menu for PDF and Markdown, resolving charts and tables so
35
35
  exported conversations remain useful offline.
36
36
 
@@ -50,9 +50,10 @@ understand Mastra-specific behavior:
50
50
  answer.
51
51
  - `[chart:<id>]` and `[data:<id>]` assistant markers rendered as ECharts charts
52
52
  and sortable tables.
53
- - Concurrent multi-thread streaming, per-thread cancel, and mid-turn steering
54
- ("send now" interrupts the live run and restarts with your message) - the
55
- native AppKit chat surface runs one turn at a time and has no steering.
53
+ - Concurrent multi-thread streaming, per-thread cancel, and a mid-turn steering
54
+ queue (submit while running to enqueue; drain oldest-first, or send any item
55
+ now to interrupt) - the native AppKit chat surface runs one turn at a time and
56
+ has no steering.
56
57
  - Conversation export that resolves those embeds into Markdown or PDF.
57
58
 
58
59
  ## Add The Styles
package/index.ts CHANGED
@@ -25,7 +25,7 @@ export type { DataRow } from "./src/react/data-grid";
25
25
  export type { UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-chat";
26
26
  export type { SuggestionPillsProps } from "./src/react/suggestion-pills";
27
27
  export type { ThreadSidebarProps } from "./src/react/thread-sidebar";
28
- export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types";
28
+ export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types";
29
29
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export";
30
30
  export type { ByIdFetchState } from "./src/support/mastra-client";
31
31
  export type { MastraStreamChunk, MastraStreamResponse } from "./src/support/mastra-stream";
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.12",
30
- "@dbx-tools/shared-model": "0.3.12",
31
- "@dbx-tools/ui-appkit": "0.3.12",
32
- "@dbx-tools/shared-mastra": "0.3.12",
33
- "@dbx-tools/ui-branding": "0.3.12",
34
- "@dbx-tools/shared-genie": "0.3.12"
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"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.12",
41
+ "version": "0.3.14",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -38,10 +38,12 @@ import {
38
38
  MessageSquareIcon,
39
39
  PanelLeftIcon,
40
40
  RefreshCwIcon,
41
+ SendHorizontalIcon,
41
42
  SendIcon,
42
43
  SquareIcon,
43
44
  Trash2Icon,
44
45
  TriangleAlertIcon,
46
+ XIcon,
45
47
  } from "lucide-react";
46
48
  import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
47
49
  import { AssistantBubble, UserBubble } from "./bubbles";
@@ -100,6 +102,9 @@ export const ChatView = ({
100
102
  status,
101
103
  error,
102
104
  sendMessage,
105
+ queuedSteers = [],
106
+ onSendSteerNow,
107
+ onRemoveSteer,
103
108
  regenerate,
104
109
  onStop,
105
110
  className,
@@ -623,6 +628,56 @@ export const ChatView = ({
623
628
  onSubmit={handleSubmit}
624
629
  className="mx-auto w-full max-w-4xl px-3 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6"
625
630
  >
631
+ {queuedSteers.length > 0 && (
632
+ // Steers submitted while the turn is running, waiting to send.
633
+ // They drain oldest-first when the turn ends; each can be fired
634
+ // now (interrupts) or removed.
635
+ <div className="mb-2 flex flex-col gap-1">
636
+ {queuedSteers.map((steer) => (
637
+ <div
638
+ 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"
640
+ >
641
+ <span className="text-muted-foreground">Queued</span>
642
+ <span className="min-w-0 flex-1 truncate">{steer.text}</span>
643
+ {onSendSteerNow && (
644
+ <Tooltip>
645
+ <TooltipTrigger asChild>
646
+ <Button
647
+ type="button"
648
+ variant="ghost"
649
+ size="icon"
650
+ className="size-6 shrink-0"
651
+ onClick={() => onSendSteerNow(steer.id)}
652
+ aria-label="Send now (interrupts current turn)"
653
+ >
654
+ <SendHorizontalIcon className="size-3" />
655
+ </Button>
656
+ </TooltipTrigger>
657
+ <TooltipContent>Send now — interrupts</TooltipContent>
658
+ </Tooltip>
659
+ )}
660
+ {onRemoveSteer && (
661
+ <Tooltip>
662
+ <TooltipTrigger asChild>
663
+ <Button
664
+ type="button"
665
+ variant="ghost"
666
+ size="icon"
667
+ className="size-6 shrink-0"
668
+ onClick={() => onRemoveSteer(steer.id)}
669
+ aria-label="Remove queued message"
670
+ >
671
+ <XIcon className="size-3" />
672
+ </Button>
673
+ </TooltipTrigger>
674
+ <TooltipContent>Remove</TooltipContent>
675
+ </Tooltip>
676
+ )}
677
+ </div>
678
+ ))}
679
+ </div>
680
+ )}
626
681
  <InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
627
682
  <InputGroupTextarea
628
683
  ref={textareaRef}
@@ -19,7 +19,9 @@ import type { MastraStreamResponse } from "../support/mastra-stream";
19
19
  import {
20
20
  createThreadSession,
21
21
  DEFAULT_THREAD_SESSION_KEY,
22
+ enqueueSteer,
22
23
  isSessionRunning,
24
+ removeSteer as removeSteerFromQueue,
23
25
  sessionKey,
24
26
  terminateRunningToolEvents,
25
27
  type ThreadSession,
@@ -409,6 +411,11 @@ export const useMastraChat = (
409
411
  const historyInFlightRef = useRef(false);
410
412
  const feedbackByMessageRef = useRef<Record<string, MessageFeedback>>({});
411
413
  feedbackByMessageRef.current = activeSession.feedbackByMessage;
414
+ // Drains the next queued steer when a turn ends. Held in a ref because it
415
+ // closes over `runStream`, which is defined below and itself calls
416
+ // `driveStream` (which invokes this) - the ref breaks that cycle without a
417
+ // stale-closure hazard (assigned each render, same pattern as `loadMoreRef`).
418
+ const drainQueueRef = useRef<(threadId: string) => void>(() => {});
412
419
 
413
420
  const writeMessages = useCallback(
414
421
  (threadId: string, next: UIMessage[]) => {
@@ -732,6 +739,8 @@ export const useMastraChat = (
732
739
  });
733
740
  if (getSession(threadId).runToken === token) {
734
741
  refreshThreadsSoon();
742
+ // Turn finished on its own: start the oldest queued steer, if any.
743
+ drainQueueRef.current(threadId);
735
744
  }
736
745
  } catch (caught) {
737
746
  if (getSession(threadId).runToken !== token) return;
@@ -785,6 +794,22 @@ export const useMastraChat = (
785
794
  [driveStream, mastraClient, agentId, updateSession],
786
795
  );
787
796
 
797
+ // Start the oldest queued steer as the next turn (FIFO drain). Called from
798
+ // `driveStream` when a turn ends normally; a no-op when the queue is empty.
799
+ // Kept behind `drainQueueRef` so `driveStream` can reach it without a
800
+ // definition cycle.
801
+ drainQueueRef.current = (threadId: string) => {
802
+ const queued = getSession(threadId).queuedSteers;
803
+ if (queued.length === 0) return;
804
+ const [head] = queued;
805
+ updateSession(threadId, (session) => ({
806
+ ...session,
807
+ queuedSteers: removeSteerFromQueue(session.queuedSteers, head.id),
808
+ }));
809
+ const { next } = appendUserMessage(threadId, head.text);
810
+ void runStream(threadId, next);
811
+ };
812
+
788
813
  // Cancel a thread's in-flight run. Defaults to the active thread (the
789
814
  // composer stop button), but takes an explicit thread id so the sidebar
790
815
  // can cancel a background thread without switching to it. Aborting one
@@ -901,24 +926,57 @@ export const useMastraChat = (
901
926
  [activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
902
927
  );
903
928
 
904
- // Send a message, steering the active thread. Submitting while a turn is
905
- // already streaming is a "send now": it interrupts the live run and starts a
906
- // fresh turn that includes the new message, immediately. `runStream` /
907
- // `driveStream` supersede the prior run (abort + runToken bump + settle any
908
- // running tool pills), so the interrupted turn stops cleanly and the new one
909
- // takes over. An idle submit just starts a turn.
929
+ // Send a message on the active thread. Submitting while a turn is already
930
+ // streaming ENQUEUES the message as a steer (it waits, no interrupt) - the
931
+ // queue drains oldest-first when the turn ends, or the user fires one early
932
+ // with `sendSteerNow`. An idle submit starts a turn right away.
910
933
  const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
911
934
  (message) => {
912
935
  const text = message.text ?? "";
913
936
  if (!text) return;
914
937
  const threadId = activeKey;
915
- const { before, next } = appendUserMessage(threadId, text);
916
- if (isSessionRunning(before)) {
917
- logger.info("steer:interrupt", { threadId });
938
+ if (isSessionRunning(getSession(threadId))) {
939
+ updateSession(threadId, (session) => ({
940
+ ...session,
941
+ queuedSteers: enqueueSteer(session.queuedSteers, { id: nanoid(), text }),
942
+ }));
943
+ return;
918
944
  }
945
+ const { next } = appendUserMessage(threadId, text);
919
946
  void runStream(threadId, next);
920
947
  },
921
- [appendUserMessage, runStream, activeKey],
948
+ [appendUserMessage, runStream, activeKey, getSession, updateSession],
949
+ );
950
+
951
+ // Fire a queued steer immediately, out of order: remove it from the queue,
952
+ // append it, and start a turn. `runStream`/`driveStream` supersede any
953
+ // in-flight run (abort + runToken bump + settle running pills), so this
954
+ // interrupts the current turn and sends the chosen steer now.
955
+ const sendSteerNow = useCallback(
956
+ (steerId: string) => {
957
+ const threadId = activeKey;
958
+ const steer = getSession(threadId).queuedSteers.find((s) => s.id === steerId);
959
+ if (!steer) return;
960
+ updateSession(threadId, (session) => ({
961
+ ...session,
962
+ queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
963
+ }));
964
+ logger.info("steer:send-now", { threadId });
965
+ const { next } = appendUserMessage(threadId, steer.text);
966
+ void runStream(threadId, next);
967
+ },
968
+ [activeKey, appendUserMessage, getSession, runStream, updateSession],
969
+ );
970
+
971
+ // Drop a queued steer without sending it.
972
+ const removeSteer = useCallback(
973
+ (steerId: string) => {
974
+ updateSession(activeKey, (session) => ({
975
+ ...session,
976
+ queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
977
+ }));
978
+ },
979
+ [activeKey, updateSession],
922
980
  );
923
981
 
924
982
  /**
@@ -1383,6 +1441,9 @@ export const useMastraChat = (
1383
1441
  status: activeSession.status,
1384
1442
  error: activeSession.error,
1385
1443
  sendMessage,
1444
+ queuedSteers: activeSession.queuedSteers,
1445
+ onSendSteerNow: sendSteerNow,
1446
+ onRemoveSteer: removeSteer,
1386
1447
  regenerate,
1387
1448
  onStop: stop,
1388
1449
  suggestions,
@@ -41,6 +41,14 @@ export type ToolProgress = GenieWriterEvent;
41
41
  */
42
42
  export type ChatModelOption = { name: string; displayName?: string };
43
43
 
44
+ /**
45
+ * A steer message submitted while a turn was already streaming. It waits in a
46
+ * per-thread queue until the running turn ends (auto-sent oldest-first) or the
47
+ * user fires it early with "send now". `id` is a stable key for rendering /
48
+ * removal.
49
+ */
50
+ export type QueuedSteer = { id: string; text: string };
51
+
44
52
  /** Thumbs reaction a user can leave on an assistant turn. */
45
53
  export type FeedbackValue = "up" | "down";
46
54
 
@@ -91,10 +99,23 @@ export type ChatViewProps = {
91
99
  error?: Error | null;
92
100
  /**
93
101
  * Send a message on the active thread. Submitting while a turn is streaming
94
- * is a "send now": it interrupts the in-flight run and starts a fresh turn
95
- * including this message immediately.
102
+ * ENQUEUES the message as a steer (it waits, no interrupt); the queue drains
103
+ * oldest-first when the turn ends. An idle submit starts a turn immediately.
96
104
  */
97
105
  sendMessage: (message: { text: string }) => void;
106
+ /**
107
+ * Steers submitted mid-turn that are waiting to run (oldest first). When
108
+ * non-empty the composer shows them as chips above the input, each with a
109
+ * "send now" and a remove action.
110
+ */
111
+ queuedSteers?: QueuedSteer[];
112
+ /**
113
+ * Fire a queued steer immediately, out of order: interrupt the current turn
114
+ * and start a fresh turn with that steer (removing it from the queue).
115
+ */
116
+ onSendSteerNow?: (steerId: string) => void;
117
+ /** Drop a queued steer without sending it. */
118
+ onRemoveSteer?: (steerId: string) => void;
98
119
  regenerate?: () => void;
99
120
  /**
100
121
  * Abort the in-flight response. When provided and the chat is running
@@ -3,9 +3,12 @@ import type {
3
3
  ChatStatus,
4
4
  MessageFeedback,
5
5
  PendingApproval,
6
+ QueuedSteer,
6
7
  ToolEvent,
7
8
  } from "../react/types";
8
9
 
10
+ export type { QueuedSteer } from "../react/types";
11
+
9
12
  /** Session-scoped transcript + stream state for one conversation thread. */
10
13
  export type ThreadSession = {
11
14
  messages: UIMessage[];
@@ -22,6 +25,8 @@ export type ThreadSession = {
22
25
  hasMoreHistory: boolean;
23
26
  historyPage: number;
24
27
  lastUserText: string | null;
28
+ /** Steers submitted mid-turn, waiting to run (oldest first). */
29
+ queuedSteers: QueuedSteer[];
25
30
  };
26
31
 
27
32
  /** Map key for the classic single-thread chat (no explicit thread id). */
@@ -43,6 +48,7 @@ export function createThreadSession(): ThreadSession {
43
48
  hasMoreHistory: false,
44
49
  historyPage: 0,
45
50
  lastUserText: null,
51
+ queuedSteers: [],
46
52
  };
47
53
  }
48
54
 
@@ -50,6 +56,19 @@ export function isSessionRunning(session: ThreadSession): boolean {
50
56
  return session.status === "submitted" || session.status === "streaming";
51
57
  }
52
58
 
59
+ /** Append a steer to the queue (oldest first). Returns a new array. */
60
+ export function enqueueSteer(
61
+ queue: QueuedSteer[],
62
+ steer: QueuedSteer,
63
+ ): QueuedSteer[] {
64
+ return [...queue, steer];
65
+ }
66
+
67
+ /** Remove the steer with `id` from the queue. Returns a new array. */
68
+ export function removeSteer(queue: QueuedSteer[], id: string): QueuedSteer[] {
69
+ return queue.filter((steer) => steer.id !== id);
70
+ }
71
+
53
72
  /**
54
73
  * Settle any tool-progress pills still marked `running` to `done`. A cancelled
55
74
  * or interrupted turn stops delivering the `tool-result` / `tool-error` chunks