@dbx-tools/ui-mastra 0.3.11 → 0.3.13

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,8 +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 fold it into the
32
- live run (Mastra queue-message), with an immediate interrupt-and-resend option.
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.
33
34
  - Export menu for PDF and Markdown, resolving charts and tables so
34
35
  exported conversations remain useful offline.
35
36
 
@@ -49,9 +50,10 @@ understand Mastra-specific behavior:
49
50
  answer.
50
51
  - `[chart:<id>]` and `[data:<id>]` assistant markers rendered as ECharts charts
51
52
  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.
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.
55
57
  - Conversation export that resolves those embeds into Markdown or PDF.
56
58
 
57
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.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"
29
+ "@dbx-tools/shared-core": "0.3.13",
30
+ "@dbx-tools/shared-mastra": "0.3.13",
31
+ "@dbx-tools/shared-model": "0.3.13",
32
+ "@dbx-tools/shared-genie": "0.3.13",
33
+ "@dbx-tools/ui-appkit": "0.3.13",
34
+ "@dbx-tools/ui-branding": "0.3.13"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.11",
41
+ "version": "0.3.13",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -35,14 +35,15 @@ import {
35
35
  import { error as errorUtil } from "@dbx-tools/shared-core";
36
36
  import {
37
37
  ArrowDownIcon,
38
- FastForwardIcon,
39
38
  MessageSquareIcon,
40
39
  PanelLeftIcon,
41
40
  RefreshCwIcon,
41
+ SendHorizontalIcon,
42
42
  SendIcon,
43
43
  SquareIcon,
44
44
  Trash2Icon,
45
45
  TriangleAlertIcon,
46
+ XIcon,
46
47
  } from "lucide-react";
47
48
  import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
48
49
  import { AssistantBubble, UserBubble } from "./bubbles";
@@ -101,7 +102,9 @@ export const ChatView = ({
101
102
  status,
102
103
  error,
103
104
  sendMessage,
104
- onInterrupt,
105
+ queuedSteers = [],
106
+ onSendSteerNow,
107
+ onRemoveSteer,
105
108
  regenerate,
106
109
  onStop,
107
110
  className,
@@ -278,16 +281,6 @@ export const ChatView = ({
278
281
  });
279
282
  };
280
283
 
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
284
 
292
285
  const lastMessage = messages.at(-1);
293
286
  const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
@@ -635,6 +628,56 @@ export const ChatView = ({
635
628
  onSubmit={handleSubmit}
636
629
  className="mx-auto w-full max-w-4xl px-3 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6"
637
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
+ )}
638
681
  <InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
639
682
  <InputGroupTextarea
640
683
  ref={textareaRef}
@@ -665,38 +708,17 @@ export const ChatView = ({
665
708
  ) : (
666
709
  <>
667
710
  {/*
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).
711
+ * The primary button sends. Submitting while a turn is
712
+ * running is a "send now": it interrupts the live run and
713
+ * starts a fresh turn with this message immediately (see
714
+ * the driver's sendMessage). Idle, it just sends.
693
715
  */}
694
716
  <InputGroupButton
695
717
  type="submit"
696
718
  size="icon-sm"
697
719
  variant="default"
698
720
  disabled={!input.trim()}
699
- aria-label={isRunning ? "Steer response" : "Send message"}
721
+ aria-label={isRunning ? "Send now (interrupts)" : "Send message"}
700
722
  >
701
723
  <SendIcon className="size-3" />
702
724
  </InputGroupButton>
@@ -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,53 +926,57 @@ export const useMastraChat = (
901
926
  [activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
902
927
  );
903
928
 
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.
904
933
  const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
905
934
  (message) => {
906
935
  const text = message.text ?? "";
907
936
  if (!text) return;
908
937
  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.
914
- if (isSessionRunning(before) && before.runId) {
915
- const runId = before.runId;
916
- const steerThreadId =
917
- threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
918
- void mastraClient
919
- .queueMessage({ agentId, runId, threadId: steerThreadId, message: text })
920
- .then((accepted) => {
921
- if (accepted) {
922
- logger.info("steer:queued", { runId });
923
- return;
924
- }
925
- logger.info("steer:fallback-interrupt", { runId });
926
- void runStream(threadId, getSession(threadId).messages);
927
- });
938
+ if (isSessionRunning(getSession(threadId))) {
939
+ updateSession(threadId, (session) => ({
940
+ ...session,
941
+ queuedSteers: enqueueSteer(session.queuedSteers, { id: nanoid(), text }),
942
+ }));
928
943
  return;
929
944
  }
945
+ const { next } = appendUserMessage(threadId, text);
930
946
  void runStream(threadId, next);
931
947
  },
932
- [appendUserMessage, runStream, activeKey, getSession, mastraClient, agentId],
948
+ [appendUserMessage, runStream, activeKey, getSession, updateSession],
933
949
  );
934
950
 
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;
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) => {
945
957
  const threadId = activeKey;
946
- const { next } = appendUserMessage(threadId, text);
947
- logger.info("steer:interrupt", { threadId });
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);
948
966
  void runStream(threadId, next);
949
967
  },
950
- [appendUserMessage, runStream, activeKey],
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],
951
980
  );
952
981
 
953
982
  /**
@@ -1412,7 +1441,9 @@ export const useMastraChat = (
1412
1441
  status: activeSession.status,
1413
1442
  error: activeSession.error,
1414
1443
  sendMessage,
1415
- onInterrupt: interrupt,
1444
+ queuedSteers: activeSession.queuedSteers,
1445
+ onSendSteerNow: sendSteerNow,
1446
+ onRemoveSteer: removeSteer,
1416
1447
  regenerate,
1417
1448
  onStop: stop,
1418
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
 
@@ -89,16 +97,25 @@ export type ChatViewProps = {
89
97
  * a host driving `ChatView` itself supplies its own.
90
98
  */
91
99
  error?: Error | null;
100
+ /**
101
+ * Send a message on the active thread. Submitting while a turn is streaming
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.
104
+ */
92
105
  sendMessage: (message: { text: string }) => void;
93
106
  /**
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.
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).
100
115
  */
101
- onInterrupt?: (message: { text: string }) => void;
116
+ onSendSteerNow?: (steerId: string) => void;
117
+ /** Drop a queued steer without sending it. */
118
+ onRemoveSteer?: (steerId: string) => void;
102
119
  regenerate?: () => void;
103
120
  /**
104
121
  * Abort the in-flight response. When provided and the chat is running
@@ -123,50 +123,6 @@ export class MastraPluginClient extends MastraClient {
123
123
  return asMastraStreamResponse(response);
124
124
  }
125
125
 
126
- /**
127
- * Steer a live run by handing it another message mid-turn ("queue"), via
128
- * the agent `/queue-message` route. `behavior` controls what happens when
129
- * the run is active: `deliver` (default) folds the message into the current
130
- * turn so the agent picks it up without restarting; `persist` holds it for
131
- * the next turn; `discard` drops it. Routed per call (own thread header, no
132
- * shared client state) like {@link streamAgent}.
133
- *
134
- * Resolves `true` when the server accepted the message for delivery, `false`
135
- * otherwise (unknown run / route unsupported / non-2xx) - never throws for a
136
- * rejected steer, so the caller can fall back to interrupting + resending.
137
- * Queue / signal routes are `@experimental` in Mastra.
138
- */
139
- async queueMessage(params: {
140
- agentId: string;
141
- runId: string;
142
- threadId?: string;
143
- message: string;
144
- behavior?: "deliver" | "persist" | "discard";
145
- }): Promise<boolean> {
146
- const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/queue-message`;
147
- try {
148
- const response = await fetch(url, {
149
- method: "POST",
150
- credentials: "include",
151
- headers: {
152
- "Content-Type": "application/json",
153
- ...this.#routingHeaders({ threadId: params.threadId }),
154
- },
155
- body: JSON.stringify({
156
- runId: params.runId,
157
- ...(params.threadId ? { threadId: params.threadId } : {}),
158
- message: params.message,
159
- ifActive: { behavior: params.behavior ?? "deliver" },
160
- }),
161
- });
162
- if (!response.ok) return false;
163
- const body = (await response.json().catch(() => null)) as { accepted?: boolean } | null;
164
- return body?.accepted === true;
165
- } catch {
166
- return false;
167
- }
168
- }
169
-
170
126
  /**
171
127
  * Resume a suspended `requireApproval` tool via `approve-tool-call`.
172
128
  * Reads SSE directly instead of `agent.approveToolCall()` so the
@@ -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