@dbx-tools/ui-mastra 0.3.12 → 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 +7 -6
- package/index.ts +1 -1
- package/package.json +7 -7
- package/src/react/chat-view.tsx +55 -0
- package/src/react/mastra-chat.tsx +71 -10
- package/src/react/types.ts +23 -2
- package/src/support/thread-sessions.ts +19 -0
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:
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
(
|
|
55
|
-
native AppKit chat surface runs one turn at a time and
|
|
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.
|
|
30
|
-
"@dbx-tools/shared-
|
|
31
|
-
"@dbx-tools/
|
|
32
|
-
"@dbx-tools/shared-
|
|
33
|
-
"@dbx-tools/ui-
|
|
34
|
-
"@dbx-tools/
|
|
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.
|
|
41
|
+
"version": "0.3.13",
|
|
42
42
|
"types": "index.ts",
|
|
43
43
|
"type": "module",
|
|
44
44
|
"exports": {
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -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
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
// `
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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,
|
package/src/react/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
95
|
-
*
|
|
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
|