@dbx-tools/ui-mastra 0.3.9 → 0.3.10
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 +7 -7
- package/src/react/bubbles.tsx +52 -3
- package/src/react/chat-view.tsx +68 -16
- package/src/react/mastra-chat.tsx +41 -21
- package/src/react/types.ts +9 -0
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-
|
|
31
|
-
"@dbx-tools/
|
|
32
|
-
"@dbx-tools/ui-
|
|
33
|
-
"@dbx-tools/shared-
|
|
34
|
-
"@dbx-tools/
|
|
29
|
+
"@dbx-tools/shared-core": "0.3.10",
|
|
30
|
+
"@dbx-tools/shared-genie": "0.3.10",
|
|
31
|
+
"@dbx-tools/ui-appkit": "0.3.10",
|
|
32
|
+
"@dbx-tools/ui-branding": "0.3.10",
|
|
33
|
+
"@dbx-tools/shared-mastra": "0.3.10",
|
|
34
|
+
"@dbx-tools/shared-model": "0.3.10"
|
|
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.10",
|
|
42
42
|
"types": "index.ts",
|
|
43
43
|
"type": "module",
|
|
44
44
|
"exports": {
|
package/src/react/bubbles.tsx
CHANGED
|
@@ -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={
|
|
426
|
+
onClick={handleCopy}
|
|
427
|
+
aria-label={copied ? "Copied" : "Copy"}
|
|
383
428
|
>
|
|
384
|
-
|
|
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 && (
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -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
|
-
//
|
|
258
|
-
// the
|
|
259
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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 & 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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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,
|
package/src/react/types.ts
CHANGED
|
@@ -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
|