@dbx-tools/ui-mastra 0.3.8 → 0.3.9
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/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-
|
|
30
|
-
"@dbx-tools/shared-mastra": "0.3.
|
|
31
|
-
"@dbx-tools/shared-model": "0.3.
|
|
32
|
-
"@dbx-tools/ui-appkit": "0.3.
|
|
33
|
-
"@dbx-tools/shared-
|
|
34
|
-
"@dbx-tools/ui-branding": "0.3.
|
|
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"
|
|
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.9",
|
|
42
42
|
"types": "index.ts",
|
|
43
43
|
"type": "module",
|
|
44
44
|
"exports": {
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -47,7 +47,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
|
47
47
|
import { AssistantBubble, UserBubble } from "./bubbles";
|
|
48
48
|
import { ExportMenu } from "./export-menu";
|
|
49
49
|
import { SuggestionPills } from "./suggestion-pills";
|
|
50
|
-
import { ThreadSidebar } from "./thread-sidebar";
|
|
50
|
+
import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar";
|
|
51
51
|
import type { ChatViewProps } from "./types";
|
|
52
52
|
|
|
53
53
|
// Controlled, presentational chat shell: the scroll container, header
|
|
@@ -247,11 +247,10 @@ export const ChatView = ({
|
|
|
247
247
|
|
|
248
248
|
const handleSubmit = (e: React.FormEvent) => {
|
|
249
249
|
e.preventDefault();
|
|
250
|
-
// Don't queue a new turn while one is streaming - the Enter-key
|
|
251
|
-
// path would otherwise bypass the disabled Send button.
|
|
252
|
-
if (isRunning) return;
|
|
253
250
|
const text = input.trim();
|
|
254
251
|
if (!text) return;
|
|
252
|
+
// Submitting while a turn streams is a steer: the driver hands the text
|
|
253
|
+
// to the live run (or interrupts + resends). Idle submits start a turn.
|
|
255
254
|
sendMessage({ text });
|
|
256
255
|
setInput("");
|
|
257
256
|
// Sending is an explicit "I want to see the response" action, so
|
|
@@ -360,6 +359,23 @@ export const ChatView = ({
|
|
|
360
359
|
const showHeader = showSidebarToggle;
|
|
361
360
|
const showComposerToolbar = showModelDisplay || showExport || showClear;
|
|
362
361
|
|
|
362
|
+
// Props shared by the mobile drawer and the desktop inline sidebar - the two
|
|
363
|
+
// render the SAME `ThreadSidebar`, differing only in framing (overlay vs.
|
|
364
|
+
// inline) and, on mobile, closing the drawer after select / new. Building
|
|
365
|
+
// the prop bag once keeps the two call sites from drifting.
|
|
366
|
+
const sidebarProps: ThreadSidebarProps = {
|
|
367
|
+
threads: threads ?? [],
|
|
368
|
+
...(activeThreadId ? { activeThreadId } : {}),
|
|
369
|
+
streamingThreadIds,
|
|
370
|
+
isLoading: isLoadingThreads,
|
|
371
|
+
onSelect: (id) => onSelectThread?.(id),
|
|
372
|
+
onHide: toggleSidebar,
|
|
373
|
+
...(onNewThread ? { onNew: onNewThread } : {}),
|
|
374
|
+
...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
|
|
375
|
+
...(onRenameThread ? { onRename: onRenameThread } : {}),
|
|
376
|
+
...(onCancelThread ? { onCancel: onCancelThread } : {}),
|
|
377
|
+
};
|
|
378
|
+
|
|
363
379
|
// Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
|
|
364
380
|
// in-flight flag so the DELETE can't be double-fired. `clearing` disables
|
|
365
381
|
// the confirm action while `onClear` runs; the dialog closes on settle.
|
|
@@ -395,9 +411,9 @@ export const ChatView = ({
|
|
|
395
411
|
/*
|
|
396
412
|
* Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
|
|
397
413
|
* the conversation list never eats horizontal space from the chat
|
|
398
|
-
* on a phone. Selecting a thread / starting a new one closes
|
|
399
|
-
* the transcript comes back into view. Session-only
|
|
400
|
-
* closed (see `mobileDrawerOpen`).
|
|
414
|
+
* on a phone. Selecting a thread / starting a new one also closes
|
|
415
|
+
* the drawer so the transcript comes back into view. Session-only
|
|
416
|
+
* + default closed (see `mobileDrawerOpen`).
|
|
401
417
|
*/
|
|
402
418
|
mobileDrawerOpen && (
|
|
403
419
|
<div className="fixed inset-0 z-40 flex">
|
|
@@ -407,10 +423,7 @@ export const ChatView = ({
|
|
|
407
423
|
aria-hidden="true"
|
|
408
424
|
/>
|
|
409
425
|
<ThreadSidebar
|
|
410
|
-
|
|
411
|
-
{...(activeThreadId ? { activeThreadId } : {})}
|
|
412
|
-
streamingThreadIds={streamingThreadIds}
|
|
413
|
-
isLoading={isLoadingThreads}
|
|
426
|
+
{...sidebarProps}
|
|
414
427
|
onSelect={(id) => {
|
|
415
428
|
onSelectThread?.(id);
|
|
416
429
|
toggleSidebar();
|
|
@@ -423,10 +436,6 @@ export const ChatView = ({
|
|
|
423
436
|
},
|
|
424
437
|
}
|
|
425
438
|
: {})}
|
|
426
|
-
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
427
|
-
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
428
|
-
{...(onCancelThread ? { onCancel: onCancelThread } : {})}
|
|
429
|
-
onHide={toggleSidebar}
|
|
430
439
|
className="relative z-10 w-[85vw] max-w-xs shadow-xl"
|
|
431
440
|
/>
|
|
432
441
|
</div>
|
|
@@ -434,22 +443,11 @@ export const ChatView = ({
|
|
|
434
443
|
) : (
|
|
435
444
|
/*
|
|
436
445
|
* Desktop: an inline flex child sharing the row with the chat
|
|
437
|
-
* column, using the persisted open/hide preference.
|
|
446
|
+
* column, using the persisted open/hide preference. Same
|
|
447
|
+
* `sidebarProps` as mobile - only the framing + close-on-select
|
|
448
|
+
* differ.
|
|
438
449
|
*/
|
|
439
|
-
desktopSidebarOpen &&
|
|
440
|
-
<ThreadSidebar
|
|
441
|
-
threads={threads ?? []}
|
|
442
|
-
{...(activeThreadId ? { activeThreadId } : {})}
|
|
443
|
-
streamingThreadIds={streamingThreadIds}
|
|
444
|
-
isLoading={isLoadingThreads}
|
|
445
|
-
onSelect={(id) => onSelectThread?.(id)}
|
|
446
|
-
{...(onNewThread ? { onNew: onNewThread } : {})}
|
|
447
|
-
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
448
|
-
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
449
|
-
{...(onCancelThread ? { onCancel: onCancelThread } : {})}
|
|
450
|
-
onHide={toggleSidebar}
|
|
451
|
-
/>
|
|
452
|
-
)
|
|
450
|
+
desktopSidebarOpen && <ThreadSidebar {...sidebarProps} />
|
|
453
451
|
))}
|
|
454
452
|
<div className="flex h-full min-w-0 flex-1 flex-col">
|
|
455
453
|
{showHeader && (
|
|
@@ -628,7 +626,8 @@ export const ChatView = ({
|
|
|
628
626
|
className="max-h-48 text-base md:text-sm"
|
|
629
627
|
/>
|
|
630
628
|
<InputGroupAddon align="inline-end">
|
|
631
|
-
{isRunning && onStop ? (
|
|
629
|
+
{isRunning && onStop && !input.trim() ? (
|
|
630
|
+
// Running with an empty composer: the button stops the turn.
|
|
632
631
|
<InputGroupButton
|
|
633
632
|
type="button"
|
|
634
633
|
size="icon-sm"
|
|
@@ -639,18 +638,16 @@ export const ChatView = ({
|
|
|
639
638
|
<SquareIcon className="size-3 fill-current" />
|
|
640
639
|
</InputGroupButton>
|
|
641
640
|
) : (
|
|
641
|
+
// Idle, or running with typed text: the button sends. A send
|
|
642
|
+
// mid-run steers the live turn (see the driver's sendMessage).
|
|
642
643
|
<InputGroupButton
|
|
643
644
|
type="submit"
|
|
644
645
|
size="icon-sm"
|
|
645
646
|
variant="default"
|
|
646
|
-
disabled={!input.trim()
|
|
647
|
-
aria-label="Send message"
|
|
647
|
+
disabled={!input.trim()}
|
|
648
|
+
aria-label={isRunning ? "Steer response" : "Send message"}
|
|
648
649
|
>
|
|
649
|
-
|
|
650
|
-
<Spinner className="size-3" />
|
|
651
|
-
) : (
|
|
652
|
-
<SendIcon className="size-3" />
|
|
653
|
-
)}
|
|
650
|
+
<SendIcon className="size-3" />
|
|
654
651
|
</InputGroupButton>
|
|
655
652
|
)}
|
|
656
653
|
</InputGroupAddon>
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
DEFAULT_THREAD_SESSION_KEY,
|
|
22
22
|
isSessionRunning,
|
|
23
23
|
sessionKey,
|
|
24
|
+
terminateRunningToolEvents,
|
|
24
25
|
type ThreadSession,
|
|
25
26
|
} from "../support/thread-sessions";
|
|
26
27
|
import { ChatView } from "./chat-view";
|
|
@@ -710,6 +711,9 @@ export const useMastraChat = (
|
|
|
710
711
|
error: null,
|
|
711
712
|
status: "submitted",
|
|
712
713
|
runToken: token,
|
|
714
|
+
// Superseding an in-flight run (a steer that interrupts, or a rapid
|
|
715
|
+
// re-send) stops its stream, so close any pills it left running.
|
|
716
|
+
toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
|
|
713
717
|
};
|
|
714
718
|
});
|
|
715
719
|
const runIdRef = { current: getSession(threadId).runId };
|
|
@@ -798,6 +802,9 @@ export const useMastraChat = (
|
|
|
798
802
|
runToken: session.runToken + 1,
|
|
799
803
|
error: null,
|
|
800
804
|
status: "ready",
|
|
805
|
+
// Cancelling stops the stream, so close any tool pills still marked
|
|
806
|
+
// running - the closing chunks will never arrive.
|
|
807
|
+
toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
|
|
801
808
|
};
|
|
802
809
|
});
|
|
803
810
|
},
|
|
@@ -885,8 +892,30 @@ export const useMastraChat = (
|
|
|
885
892
|
}
|
|
886
893
|
}
|
|
887
894
|
}
|
|
888
|
-
const
|
|
895
|
+
const before = getSession(threadId);
|
|
896
|
+
const next = [...before.messages, makeUserMessage(text)];
|
|
889
897
|
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.
|
|
903
|
+
if (isSessionRunning(before) && before.runId) {
|
|
904
|
+
const runId = before.runId;
|
|
905
|
+
const steerThreadId =
|
|
906
|
+
threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
|
|
907
|
+
void mastraClient
|
|
908
|
+
.queueMessage({ agentId, runId, threadId: steerThreadId, message: text })
|
|
909
|
+
.then((accepted) => {
|
|
910
|
+
if (accepted) {
|
|
911
|
+
logger.info("steer:queued", { runId });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
logger.info("steer:fallback-interrupt", { runId });
|
|
915
|
+
void runStream(threadId, getSession(threadId).messages);
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
890
919
|
void runStream(threadId, next);
|
|
891
920
|
},
|
|
892
921
|
[
|
|
@@ -895,6 +924,8 @@ export const useMastraChat = (
|
|
|
895
924
|
activeThreadId,
|
|
896
925
|
activeKey,
|
|
897
926
|
getSession,
|
|
927
|
+
mastraClient,
|
|
928
|
+
agentId,
|
|
898
929
|
noteThreadActivity,
|
|
899
930
|
updateSession,
|
|
900
931
|
],
|
|
@@ -123,6 +123,50 @@ 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
|
+
|
|
126
170
|
/**
|
|
127
171
|
* Resume a suspended `requireApproval` tool via `approve-tool-call`.
|
|
128
172
|
* Reads SSE directly instead of `agent.approveToolCall()` so the
|
|
@@ -50,6 +50,28 @@ export function isSessionRunning(session: ThreadSession): boolean {
|
|
|
50
50
|
return session.status === "submitted" || session.status === "streaming";
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Settle any tool-progress pills still marked `running` to `done`. A cancelled
|
|
55
|
+
* or interrupted turn stops delivering the `tool-result` / `tool-error` chunks
|
|
56
|
+
* that would otherwise close them, so without this a Genie / chart pill would
|
|
57
|
+
* spin forever after the user hits stop. Returns the same map when nothing was
|
|
58
|
+
* running so callers can skip a needless state update.
|
|
59
|
+
*/
|
|
60
|
+
export function terminateRunningToolEvents(
|
|
61
|
+
toolEventsByMessage: Record<string, ToolEvent[]>,
|
|
62
|
+
): Record<string, ToolEvent[]> {
|
|
63
|
+
let changed = false;
|
|
64
|
+
const next: Record<string, ToolEvent[]> = {};
|
|
65
|
+
for (const [messageId, events] of Object.entries(toolEventsByMessage)) {
|
|
66
|
+
next[messageId] = events.map((event) => {
|
|
67
|
+
if (event.status !== "running") return event;
|
|
68
|
+
changed = true;
|
|
69
|
+
return { ...event, status: "done" as const };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return changed ? next : toolEventsByMessage;
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
export function sessionKey(activeThreadId: string | undefined): string {
|
|
54
76
|
return activeThreadId ?? DEFAULT_THREAD_SESSION_KEY;
|
|
55
77
|
}
|