@dbx-tools/ui-mastra 0.9.117 → 0.9.119
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 +3 -0
- package/index.ts +4 -1
- package/package.json +8 -7
- package/src/react/_assistant-activity.ts +43 -0
- package/src/react/_assistant-response-status.tsx +18 -0
- package/src/react/_external-link-dialog.tsx +106 -0
- package/src/react/bubbles.tsx +49 -53
- package/src/react/chat-history.ts +9 -1
- package/src/react/chat-thread-layout.tsx +7 -1
- package/src/react/chat-transcript.tsx +11 -4
- package/src/react/chat-view.tsx +4 -0
- package/src/react/markdown.tsx +11 -0
- package/src/react/mastra-assistant.tsx +6 -0
- package/src/react/mastra-chat.tsx +76 -4
- package/src/react/thread-sidebar.tsx +6 -2
- package/src/react/thread-tabs.tsx +19 -2
- package/src/react/tool-pill.tsx +3 -3
- package/src/react/types.ts +5 -1
- package/src/support/active-runs.ts +111 -0
- package/src/support/index.ts +3 -0
- package/src/support/mastra-client.ts +31 -0
package/README.md
CHANGED
|
@@ -22,6 +22,9 @@ Key features:
|
|
|
22
22
|
|
|
23
23
|
- Drop-in `MastraChat` component that discovers the plugin client config and
|
|
24
24
|
wires itself to the default agent.
|
|
25
|
+
- Reload recovery for background turns: active run metadata is persisted per
|
|
26
|
+
agent and thread, the cached stream reconnects after a page reload, and the
|
|
27
|
+
final persisted history is refreshed when recovery completes.
|
|
25
28
|
- Headless `useMastraChat()` driver for apps that want the same transport logic
|
|
26
29
|
with custom layout.
|
|
27
30
|
- Persistent `MastraAssistant` application shell with a floating launcher,
|
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
4
|
|
|
5
5
|
export const PACKAGE_IDENTIFIER = "@dbx-tools/ui-mastra";
|
|
6
|
-
export const PACKAGE_VERSION = "0.9.
|
|
6
|
+
export const PACKAGE_VERSION = "0.9.119";
|
|
7
7
|
export * as reactBubbles from "./src/react/bubbles.tsx";
|
|
8
8
|
export * as reactChatApprovals from "./src/react/chat-approvals.ts";
|
|
9
9
|
export * as reactChatComposer from "./src/react/chat-composer.tsx";
|
|
@@ -27,6 +27,7 @@ export * as reactThreadSidebar from "./src/react/thread-sidebar.tsx";
|
|
|
27
27
|
export * as reactThreadTabs from "./src/react/thread-tabs.tsx";
|
|
28
28
|
export * as reactToolPill from "./src/react/tool-pill.tsx";
|
|
29
29
|
export * as reactTypes from "./src/react/types.ts";
|
|
30
|
+
export * as supportActiveRuns from "./src/support/active-runs.ts";
|
|
30
31
|
export * as supportChartOption from "./src/support/chart-option.ts";
|
|
31
32
|
export * as supportChartTheme from "./src/support/chart-theme.ts";
|
|
32
33
|
export * as supportClipboard from "./src/support/clipboard.ts";
|
|
@@ -65,6 +66,8 @@ export type { ThreadTabsProps, ThreadActionsProps } from "./src/react/thread-tab
|
|
|
65
66
|
export { humanizeToolName, ToolSessionPill } from "./src/react/tool-pill.tsx";
|
|
66
67
|
export type { ToolInputPresentation, WebSearchProgressGroup } from "./src/react/tool-pill.tsx";
|
|
67
68
|
export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, ChatInputMessage, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
|
|
69
|
+
export { StoredActiveRunSchema, activeRunStorageKey } from "./src/support/active-runs.ts";
|
|
70
|
+
export type { StoredActiveRun } from "./src/support/active-runs.ts";
|
|
68
71
|
export { LIGHT_CHART_CHROME } from "./src/support/chart-theme.ts";
|
|
69
72
|
export type { ChartChrome } from "./src/support/chart-theme.ts";
|
|
70
73
|
export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export.ts";
|
package/package.json
CHANGED
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"react-dom": "~19.2.4"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@dbx-tools/shared-core": "^0.9.
|
|
33
|
-
"@dbx-tools/shared-genie": "^0.9.
|
|
34
|
-
"@dbx-tools/shared-mastra": "^0.9.
|
|
35
|
-
"@dbx-tools/shared-model": "^0.9.
|
|
36
|
-
"@dbx-tools/ui": "^0.9.
|
|
32
|
+
"@dbx-tools/shared-core": "^0.9.119",
|
|
33
|
+
"@dbx-tools/shared-genie": "^0.9.119",
|
|
34
|
+
"@dbx-tools/shared-mastra": "^0.9.119",
|
|
35
|
+
"@dbx-tools/shared-model": "^0.9.119",
|
|
36
|
+
"@dbx-tools/ui": "^0.9.119",
|
|
37
37
|
"@mastra/client-js": "1.50.0",
|
|
38
38
|
"@mastra/core": "1.71.0",
|
|
39
39
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -43,13 +43,14 @@
|
|
|
43
43
|
"lucide-react": "^0.554.0",
|
|
44
44
|
"marked": "^18.0.5",
|
|
45
45
|
"sql-formatter": "^15.6.9",
|
|
46
|
-
"streamdown": "^2.5.0"
|
|
46
|
+
"streamdown": "^2.5.0",
|
|
47
|
+
"zod": "4.3.6"
|
|
47
48
|
},
|
|
48
49
|
"license": "Apache-2.0",
|
|
49
50
|
"publishConfig": {
|
|
50
51
|
"access": "public"
|
|
51
52
|
},
|
|
52
|
-
"version": "0.9.
|
|
53
|
+
"version": "0.9.119",
|
|
53
54
|
"dbxToolsConfig": {
|
|
54
55
|
"tags": [
|
|
55
56
|
"ui"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isToolUIPart, type UIMessage } from "ai";
|
|
2
|
+
|
|
3
|
+
import type { ToolEvent } from "./types.ts";
|
|
4
|
+
import { compactReasoningText } from "../support/tool-events.ts";
|
|
5
|
+
|
|
6
|
+
/** One ordered activity entry rendered before an assistant's answer text. */
|
|
7
|
+
export type AssistantActivityItem =
|
|
8
|
+
{ type: "tool"; key: string; event: ToolEvent } | { type: "thought"; key: string; text: string };
|
|
9
|
+
|
|
10
|
+
/** Preserve model reasoning and tool progress in the order they reached the message. */
|
|
11
|
+
export const assistantActivityItems = (
|
|
12
|
+
parts: UIMessage["parts"],
|
|
13
|
+
events: ToolEvent[],
|
|
14
|
+
): AssistantActivityItem[] => {
|
|
15
|
+
const eventById = new Map(events.map((event) => [event.id, event]));
|
|
16
|
+
const renderedEventIds = new Set<string>();
|
|
17
|
+
const items: AssistantActivityItem[] = [];
|
|
18
|
+
|
|
19
|
+
const appendTool = (event: ToolEvent) => {
|
|
20
|
+
if (renderedEventIds.has(event.id)) return;
|
|
21
|
+
renderedEventIds.add(event.id);
|
|
22
|
+
items.push({ type: "tool", key: `tool-${event.id}`, event });
|
|
23
|
+
for (const [index, progress] of (event.progress ?? []).entries()) {
|
|
24
|
+
if (progress.type !== "thinking") continue;
|
|
25
|
+
const text = compactReasoningText(progress.text);
|
|
26
|
+
if (text) items.push({ type: "thought", key: `thought-${event.id}-${index}`, text });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (const [index, part] of parts.entries()) {
|
|
31
|
+
if (part.type === "reasoning") {
|
|
32
|
+
const text = compactReasoningText(part.text);
|
|
33
|
+
if (text) items.push({ type: "thought", key: `reasoning-${index}`, text });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!isToolUIPart(part)) continue;
|
|
37
|
+
const event = eventById.get(part.toolCallId);
|
|
38
|
+
if (event) appendTool(event);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const event of events) appendTool(event);
|
|
42
|
+
return items;
|
|
43
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Spinner } from "@dbx-tools/ui/react";
|
|
2
|
+
|
|
3
|
+
import type { ChatStatus } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Whether an assistant bubble should show its active-response status. */
|
|
6
|
+
export const shouldShowAssistantResponseStatus = (
|
|
7
|
+
isLast: boolean,
|
|
8
|
+
status: ChatStatus,
|
|
9
|
+
hasVisibleResponse: boolean,
|
|
10
|
+
): boolean => isLast && hasVisibleResponse && (status === "submitted" || status === "streaming");
|
|
11
|
+
|
|
12
|
+
/** Compact progress affordance for an active assistant response. */
|
|
13
|
+
export const AssistantResponseSpinner = () => (
|
|
14
|
+
<span role="status" aria-live="polite" className="inline-flex items-center">
|
|
15
|
+
<Spinner className="size-3" />
|
|
16
|
+
<span className="sr-only">Response in progress</span>
|
|
17
|
+
</span>
|
|
18
|
+
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlertDialog,
|
|
3
|
+
AlertDialogAction,
|
|
4
|
+
AlertDialogCancel,
|
|
5
|
+
AlertDialogContent,
|
|
6
|
+
AlertDialogDescription,
|
|
7
|
+
AlertDialogFooter,
|
|
8
|
+
AlertDialogHeader,
|
|
9
|
+
AlertDialogTitle,
|
|
10
|
+
Button,
|
|
11
|
+
} from "@dbx-tools/ui/react";
|
|
12
|
+
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
14
|
+
import type { LinkSafetyModalProps } from "streamdown";
|
|
15
|
+
import { copyText } from "../support/clipboard.ts";
|
|
16
|
+
|
|
17
|
+
/** Display and clipboard values for one confirmed chat link. */
|
|
18
|
+
export interface LinkPresentation {
|
|
19
|
+
displayUrl: string;
|
|
20
|
+
absoluteUrl: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a chat link for display and copying.
|
|
25
|
+
*
|
|
26
|
+
* Same-origin HTTP links are shown as a compact path while clipboard actions
|
|
27
|
+
* keep the absolute URL. External origins and non-HTTP schemes remain fully
|
|
28
|
+
* visible so the confirmation never obscures where the user is going.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveLinkPresentation(url: string, baseUrl?: string): LinkPresentation {
|
|
31
|
+
const fallback = { displayUrl: url, absoluteUrl: url };
|
|
32
|
+
const base = baseUrl ?? (typeof window === "undefined" ? undefined : window.location.href);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const resolved = base ? new URL(url, base) : new URL(url);
|
|
36
|
+
const baseOrigin = base ? new URL(base).origin : undefined;
|
|
37
|
+
const isWebLink = resolved.protocol === "http:" || resolved.protocol === "https:";
|
|
38
|
+
const displayUrl =
|
|
39
|
+
isWebLink && resolved.origin === baseOrigin
|
|
40
|
+
? `${resolved.pathname}${resolved.search}${resolved.hash}` || "/"
|
|
41
|
+
: resolved.href;
|
|
42
|
+
return { displayUrl, absoluteUrl: resolved.href };
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** AppKit-styled confirmation shown before opening a link from assistant markdown. */
|
|
49
|
+
export const ExternalLinkDialog = ({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) => {
|
|
50
|
+
const link = useMemo(() => resolveLinkPresentation(url), [url]);
|
|
51
|
+
const [copied, setCopied] = useState(false);
|
|
52
|
+
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(
|
|
55
|
+
() => () => {
|
|
56
|
+
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
|
57
|
+
},
|
|
58
|
+
[],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const copyLink = () => {
|
|
62
|
+
void copyText(link.absoluteUrl).then((ok) => {
|
|
63
|
+
if (!ok) return;
|
|
64
|
+
setCopied(true);
|
|
65
|
+
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
|
66
|
+
copiedTimer.current = setTimeout(() => setCopied(false), 1500);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
72
|
+
<AlertDialogContent className="border-border/60 p-5 shadow-2xl dark:border-white/10 sm:max-w-md">
|
|
73
|
+
<AlertDialogHeader>
|
|
74
|
+
<AlertDialogTitle className="flex items-center gap-2">
|
|
75
|
+
<ExternalLinkIcon className="size-4" aria-hidden="true" />
|
|
76
|
+
Open external link?
|
|
77
|
+
</AlertDialogTitle>
|
|
78
|
+
<AlertDialogDescription>
|
|
79
|
+
You're about to visit a link outside this chat.
|
|
80
|
+
</AlertDialogDescription>
|
|
81
|
+
</AlertDialogHeader>
|
|
82
|
+
<div
|
|
83
|
+
className="max-h-32 overflow-y-auto break-all rounded-md bg-muted px-3 py-2 font-mono text-sm"
|
|
84
|
+
title={link.absoluteUrl}
|
|
85
|
+
>
|
|
86
|
+
{link.displayUrl}
|
|
87
|
+
</div>
|
|
88
|
+
<AlertDialogFooter>
|
|
89
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
90
|
+
<Button type="button" variant="outline" onClick={copyLink}>
|
|
91
|
+
{copied ? (
|
|
92
|
+
<CheckIcon className="size-4" aria-hidden="true" />
|
|
93
|
+
) : (
|
|
94
|
+
<CopyIcon className="size-4" aria-hidden="true" />
|
|
95
|
+
)}
|
|
96
|
+
{copied ? "Copied" : "Copy link"}
|
|
97
|
+
</Button>
|
|
98
|
+
<AlertDialogAction onClick={onConfirm}>
|
|
99
|
+
<ExternalLinkIcon className="size-4" aria-hidden="true" />
|
|
100
|
+
Open link
|
|
101
|
+
</AlertDialogAction>
|
|
102
|
+
</AlertDialogFooter>
|
|
103
|
+
</AlertDialogContent>
|
|
104
|
+
</AlertDialog>
|
|
105
|
+
);
|
|
106
|
+
};
|
package/src/react/bubbles.tsx
CHANGED
|
@@ -18,7 +18,6 @@ import type { UIMessage } from "ai";
|
|
|
18
18
|
import {
|
|
19
19
|
BugIcon,
|
|
20
20
|
CheckIcon,
|
|
21
|
-
ChevronDownIcon,
|
|
22
21
|
CopyIcon,
|
|
23
22
|
MessageSquareIcon,
|
|
24
23
|
RefreshCcwIcon,
|
|
@@ -26,6 +25,11 @@ import {
|
|
|
26
25
|
XIcon,
|
|
27
26
|
} from "lucide-react";
|
|
28
27
|
import { useState } from "react";
|
|
28
|
+
import { assistantActivityItems } from "./_assistant-activity.ts";
|
|
29
|
+
import {
|
|
30
|
+
AssistantResponseSpinner,
|
|
31
|
+
shouldShowAssistantResponseStatus,
|
|
32
|
+
} from "./_assistant-response-status.tsx";
|
|
29
33
|
import { MarkdownWithEmbeds } from "./embed-slots.tsx";
|
|
30
34
|
import { ExportMenu } from "./export-menu.tsx";
|
|
31
35
|
import { FeedbackControls } from "./feedback-controls.tsx";
|
|
@@ -43,24 +47,12 @@ import type {
|
|
|
43
47
|
ToolEvent,
|
|
44
48
|
} from "./types.ts";
|
|
45
49
|
import { copyText } from "../support/clipboard.ts";
|
|
46
|
-
import {
|
|
47
|
-
compactReasoningText,
|
|
48
|
-
genieReasoningText,
|
|
49
|
-
mergeToolEvents,
|
|
50
|
-
toolEventsFromParts,
|
|
51
|
-
} from "../support/tool-events.ts";
|
|
50
|
+
import { mergeToolEvents, toolEventsFromParts } from "../support/tool-events.ts";
|
|
52
51
|
|
|
53
52
|
// User / assistant message bubbles plus the inline approval card and
|
|
54
53
|
// the helpers that surface approval-gated tool calls out of a message's
|
|
55
54
|
// parts.
|
|
56
55
|
|
|
57
|
-
const getReasoningText = (parts: UIMessage["parts"]): string =>
|
|
58
|
-
parts
|
|
59
|
-
.filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
|
|
60
|
-
.map((p) => compactReasoningText(p.text))
|
|
61
|
-
.filter(Boolean)
|
|
62
|
-
.join("\n");
|
|
63
|
-
|
|
64
56
|
const UserAvatar = () => (
|
|
65
57
|
<Avatar className="size-7">
|
|
66
58
|
<AvatarFallback>
|
|
@@ -69,6 +61,19 @@ const UserAvatar = () => (
|
|
|
69
61
|
</Avatar>
|
|
70
62
|
);
|
|
71
63
|
|
|
64
|
+
const ThoughtsDisclosure = ({ text }: { text: string }) => (
|
|
65
|
+
<Collapsible>
|
|
66
|
+
<CollapsibleTrigger className="w-fit cursor-pointer rounded-md border border-border/40 bg-background/30 px-2 py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
67
|
+
Thoughts
|
|
68
|
+
</CollapsibleTrigger>
|
|
69
|
+
<CollapsibleContent>
|
|
70
|
+
<div className="mt-1 border-l-2 border-border/60 pl-2.5 text-xs text-muted-foreground">
|
|
71
|
+
<ToolMarkdown className="prose-p:my-0 [&_p]:!my-0">{text}</ToolMarkdown>
|
|
72
|
+
</div>
|
|
73
|
+
</CollapsibleContent>
|
|
74
|
+
</Collapsible>
|
|
75
|
+
);
|
|
76
|
+
|
|
72
77
|
/** A tool-call input shaped like an outbound email (the `send_email` tool). */
|
|
73
78
|
type EmailApprovalInput = {
|
|
74
79
|
to?: string | string[];
|
|
@@ -360,18 +365,7 @@ export const AssistantBubble = ({
|
|
|
360
365
|
? "done"
|
|
361
366
|
: undefined;
|
|
362
367
|
const toolEvents = mergeToolEvents(toolEventsFromParts(message.parts), events, terminalToolState);
|
|
363
|
-
const
|
|
364
|
-
.filter(Boolean)
|
|
365
|
-
.join("\n");
|
|
366
|
-
const isReasoningStreaming =
|
|
367
|
-
isLast &&
|
|
368
|
-
status === "streaming" &&
|
|
369
|
-
(message.parts.at(-1)?.type === "reasoning" ||
|
|
370
|
-
toolEvents.some(
|
|
371
|
-
(event) =>
|
|
372
|
-
event.status === "running" &&
|
|
373
|
-
event.progress?.some((progress) => progress.type === "thinking"),
|
|
374
|
-
));
|
|
368
|
+
const activityItems = assistantActivityItems(message.parts, toolEvents);
|
|
375
369
|
const textParts = message.parts.filter(
|
|
376
370
|
(p): p is { type: "text"; text: string } => p.type === "text",
|
|
377
371
|
);
|
|
@@ -394,6 +388,14 @@ export const AssistantBubble = ({
|
|
|
394
388
|
// `error`, or a newer message has taken over the `isLast` slot.
|
|
395
389
|
const isStreamingThisBubble = isLast && (status === "streaming" || status === "submitted");
|
|
396
390
|
const suggestions = isStreamingThisBubble ? [] : collectSuggestions(events);
|
|
391
|
+
const showResponseStatus = shouldShowAssistantResponseStatus(
|
|
392
|
+
isLast,
|
|
393
|
+
status,
|
|
394
|
+
hasText || activityItems.length > 0 || pendingApprovals.length > 0,
|
|
395
|
+
);
|
|
396
|
+
const hasFooterActions =
|
|
397
|
+
hasText && (isLast || Boolean(onExport) || Boolean(onFeedback) || Boolean(mlflowExperimentUrl));
|
|
398
|
+
const showFooter = showResponseStatus || hasFooterActions;
|
|
397
399
|
// Charts and tables are placed at inline marker positions in
|
|
398
400
|
// the assistant's prose. `prepare_chart` / `render_data` mint
|
|
399
401
|
// a `chartId` and the model embeds `[chart:<chartId>]`; for raw
|
|
@@ -412,26 +414,13 @@ export const AssistantBubble = ({
|
|
|
412
414
|
* `min-width: auto`, which sizes to content and overflows.
|
|
413
415
|
*/}
|
|
414
416
|
<ItemContent className="min-w-0 gap-1.5">
|
|
415
|
-
{/*
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
{toolEvents.length > 0 && <ToolSessionPill events={toolEvents} />}
|
|
423
|
-
{reasoning && (
|
|
424
|
-
<Collapsible>
|
|
425
|
-
<CollapsibleTrigger className="group flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
|
426
|
-
<ChevronDownIcon className="size-3 transition-transform group-data-[state=closed]:-rotate-90" />
|
|
427
|
-
<span>{isReasoningStreaming ? "Thinking..." : "Thoughts"}</span>
|
|
428
|
-
</CollapsibleTrigger>
|
|
429
|
-
<CollapsibleContent>
|
|
430
|
-
<div className="mt-1 border-l-2 border-border/60 pl-2.5 text-xs text-muted-foreground">
|
|
431
|
-
<ToolMarkdown className="prose-p:my-0 [&_p]:!my-0">{reasoning}</ToolMarkdown>
|
|
432
|
-
</div>
|
|
433
|
-
</CollapsibleContent>
|
|
434
|
-
</Collapsible>
|
|
417
|
+
{/* Tool calls and their thought events stay in arrival order above the answer text. */}
|
|
418
|
+
{activityItems.map((item) =>
|
|
419
|
+
item.type === "tool" ? (
|
|
420
|
+
<ToolSessionPill key={item.key} events={[item.event]} />
|
|
421
|
+
) : (
|
|
422
|
+
<ThoughtsDisclosure key={item.key} text={item.text} />
|
|
423
|
+
),
|
|
435
424
|
)}
|
|
436
425
|
{/*
|
|
437
426
|
* Render each text part as its own block. A multi-step turn
|
|
@@ -459,9 +448,9 @@ export const AssistantBubble = ({
|
|
|
459
448
|
onResolve={onResolveToolApproval}
|
|
460
449
|
/>
|
|
461
450
|
))}
|
|
462
|
-
{
|
|
463
|
-
<div className="flex items-center gap-1">
|
|
464
|
-
{isLast && regenerate && (
|
|
451
|
+
{showFooter && (
|
|
452
|
+
<div className="flex min-h-7 items-center gap-1">
|
|
453
|
+
{hasText && isLast && regenerate && (
|
|
465
454
|
<Tooltip>
|
|
466
455
|
<TooltipTrigger asChild>
|
|
467
456
|
<Button
|
|
@@ -478,7 +467,7 @@ export const AssistantBubble = ({
|
|
|
478
467
|
<TooltipContent>Retry</TooltipContent>
|
|
479
468
|
</Tooltip>
|
|
480
469
|
)}
|
|
481
|
-
{isLast && (
|
|
470
|
+
{hasText && isLast && (
|
|
482
471
|
<Tooltip>
|
|
483
472
|
<TooltipTrigger asChild>
|
|
484
473
|
<Button
|
|
@@ -495,14 +484,16 @@ export const AssistantBubble = ({
|
|
|
495
484
|
<TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
|
|
496
485
|
</Tooltip>
|
|
497
486
|
)}
|
|
498
|
-
{
|
|
499
|
-
|
|
487
|
+
{hasText && onExport && (
|
|
488
|
+
<ExportMenu onExport={onExport} iconOnly tooltip="Export message" />
|
|
489
|
+
)}
|
|
490
|
+
{hasText && onFeedback && (
|
|
500
491
|
<FeedbackControls
|
|
501
492
|
onSubmit={onFeedback}
|
|
502
493
|
{...(feedbackValue ? { value: feedbackValue } : {})}
|
|
503
494
|
/>
|
|
504
495
|
)}
|
|
505
|
-
{mlflowExperimentUrl && (
|
|
496
|
+
{hasText && mlflowExperimentUrl && (
|
|
506
497
|
<Tooltip>
|
|
507
498
|
<TooltipTrigger asChild>
|
|
508
499
|
<Button asChild type="button" size="icon" variant="ghost" className="size-7">
|
|
@@ -519,6 +510,11 @@ export const AssistantBubble = ({
|
|
|
519
510
|
<TooltipContent>Open MLflow experiment</TooltipContent>
|
|
520
511
|
</Tooltip>
|
|
521
512
|
)}
|
|
513
|
+
{showResponseStatus && (
|
|
514
|
+
<div className="flex items-center text-muted-foreground">
|
|
515
|
+
<AssistantResponseSpinner />
|
|
516
|
+
</div>
|
|
517
|
+
)}
|
|
522
518
|
</div>
|
|
523
519
|
)}
|
|
524
520
|
<SuggestionPills questions={suggestions} onSelect={onSuggestionClick} className="mt-1" />
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from "./chat-sessions.ts";
|
|
10
10
|
import type { PendingApproval } from "./types.ts";
|
|
11
11
|
import type { MastraPluginClient } from "../support/mastra-client.ts";
|
|
12
|
+
import { isSessionRunning } from "../support/thread-sessions.ts";
|
|
12
13
|
|
|
13
14
|
const HISTORY_PAGE_SIZE = 20;
|
|
14
15
|
const logger = log.logger("ui-mastra/chat");
|
|
@@ -126,7 +127,9 @@ export function useChatHistory({
|
|
|
126
127
|
const messages = toChronologicalUiMessages(response);
|
|
127
128
|
updateSession(threadId, (current) => ({
|
|
128
129
|
...current,
|
|
129
|
-
messages
|
|
130
|
+
messages: isSessionRunning(current)
|
|
131
|
+
? mergeCurrentMessages(messages, current.messages)
|
|
132
|
+
: messages,
|
|
130
133
|
historyLoaded: true,
|
|
131
134
|
hasMoreHistory: response.hasMore ?? false,
|
|
132
135
|
historyPage: 1,
|
|
@@ -203,3 +206,8 @@ export function useChatHistory({
|
|
|
203
206
|
loadOlderHistory,
|
|
204
207
|
};
|
|
205
208
|
}
|
|
209
|
+
|
|
210
|
+
function mergeCurrentMessages(history: UIMessage[], current: UIMessage[]): UIMessage[] {
|
|
211
|
+
const ids = new Set(history.map((message) => message.id));
|
|
212
|
+
return [...history, ...current.filter((message) => !ids.has(message.id))];
|
|
213
|
+
}
|
|
@@ -38,6 +38,8 @@ type ChatThreadLayoutProps = {
|
|
|
38
38
|
threads: ChatViewProps["threads"];
|
|
39
39
|
threadPlacement: NonNullable<ChatViewProps["threadPlacement"]>;
|
|
40
40
|
threadBarActions: ChatViewProps["threadBarActions"];
|
|
41
|
+
threadHistoryLeadingActions: ChatViewProps["threadHistoryLeadingActions"];
|
|
42
|
+
threadSidebarHeaderActions: ChatViewProps["threadSidebarHeaderActions"];
|
|
41
43
|
showThreadBarActions: NonNullable<ChatViewProps["showThreadBarActions"]>;
|
|
42
44
|
threadNewIcon: ChatViewProps["threadNewIcon"];
|
|
43
45
|
threadHistoryIcon: ChatViewProps["threadHistoryIcon"];
|
|
@@ -92,6 +94,8 @@ export const ChatThreadLayout = ({
|
|
|
92
94
|
threads,
|
|
93
95
|
threadPlacement,
|
|
94
96
|
threadBarActions,
|
|
97
|
+
threadHistoryLeadingActions,
|
|
98
|
+
threadSidebarHeaderActions,
|
|
95
99
|
showThreadBarActions,
|
|
96
100
|
threadNewIcon,
|
|
97
101
|
threadHistoryIcon,
|
|
@@ -109,7 +113,7 @@ export const ChatThreadLayout = ({
|
|
|
109
113
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
110
114
|
const isNarrow = useIsNarrow(rootRef);
|
|
111
115
|
const showThreads = Boolean(threads && onSelectThread) && threadPlacement !== "disabled";
|
|
112
|
-
const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "
|
|
116
|
+
const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "right") : threadPlacement;
|
|
113
117
|
const tabbedThreads = showThreads && placement === "top";
|
|
114
118
|
const dockedSide = placement === "right" ? "right" : "left";
|
|
115
119
|
const dockedThreads = showThreads && (placement === "left" || placement === "right");
|
|
@@ -144,6 +148,7 @@ export const ChatThreadLayout = ({
|
|
|
144
148
|
...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
|
|
145
149
|
...(onRenameThread ? { onRename: onRenameThread } : {}),
|
|
146
150
|
...(onCancelThread ? { onCancel: onCancelThread } : {}),
|
|
151
|
+
...(threadSidebarHeaderActions ? { headerActions: threadSidebarHeaderActions } : {}),
|
|
147
152
|
};
|
|
148
153
|
|
|
149
154
|
const [openTabIds, setOpenTabIds] = useState<string[]>([]);
|
|
@@ -195,6 +200,7 @@ export const ChatThreadLayout = ({
|
|
|
195
200
|
showActions={showThreadBarActions}
|
|
196
201
|
newIcon={threadNewIcon}
|
|
197
202
|
historyIcon={threadHistoryIcon}
|
|
203
|
+
historyLeadingActions={threadHistoryLeadingActions}
|
|
198
204
|
/>
|
|
199
205
|
)}
|
|
200
206
|
{showSidebarToggle && (
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
EmptyTitle,
|
|
12
12
|
Spinner,
|
|
13
13
|
} from "@dbx-tools/ui/react";
|
|
14
|
+
import { isToolUIPart } from "ai";
|
|
14
15
|
import { ArrowDownIcon, MessageSquareIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
|
|
15
16
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
16
17
|
import { AssistantBubble, UserBubble } from "./bubbles.tsx";
|
|
@@ -185,8 +186,10 @@ export const ChatTranscript = ({
|
|
|
185
186
|
const lastAssistantHasContent =
|
|
186
187
|
lastAssistantParts.some(
|
|
187
188
|
(part) =>
|
|
188
|
-
(part.type === "text" || part.type === "reasoning") &&
|
|
189
|
-
|
|
189
|
+
((part.type === "text" || part.type === "reasoning") &&
|
|
190
|
+
Boolean((part as { text?: string }).text)) ||
|
|
191
|
+
isToolUIPart(part) ||
|
|
192
|
+
part.type === "data-tool-call-approval",
|
|
190
193
|
) || (lastEvents?.length ?? 0) > 0;
|
|
191
194
|
const showWaitingIndicator = isRunning && !lastAssistantHasContent;
|
|
192
195
|
|
|
@@ -255,9 +258,13 @@ export const ChatTranscript = ({
|
|
|
255
258
|
return <UserBubble key={message.id} message={message} />;
|
|
256
259
|
})}
|
|
257
260
|
{showWaitingIndicator && (
|
|
258
|
-
<div
|
|
261
|
+
<div
|
|
262
|
+
role="status"
|
|
263
|
+
aria-live="polite"
|
|
264
|
+
className="flex h-7 items-center px-3 text-muted-foreground"
|
|
265
|
+
>
|
|
259
266
|
<Spinner className="size-3" />
|
|
260
|
-
<span className="
|
|
267
|
+
<span className="sr-only">Response in progress</span>
|
|
261
268
|
</div>
|
|
262
269
|
)}
|
|
263
270
|
{status === "error" && (
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -45,6 +45,8 @@ export const ChatView = ({
|
|
|
45
45
|
threads,
|
|
46
46
|
threadPlacement = "auto",
|
|
47
47
|
threadBarActions,
|
|
48
|
+
threadHistoryLeadingActions,
|
|
49
|
+
threadSidebarHeaderActions,
|
|
48
50
|
showThreadBarActions = true,
|
|
49
51
|
threadNewIcon,
|
|
50
52
|
threadHistoryIcon,
|
|
@@ -79,6 +81,8 @@ export const ChatView = ({
|
|
|
79
81
|
threads={threads}
|
|
80
82
|
threadPlacement={threadPlacement}
|
|
81
83
|
threadBarActions={threadBarActions}
|
|
84
|
+
threadHistoryLeadingActions={threadHistoryLeadingActions}
|
|
85
|
+
threadSidebarHeaderActions={threadSidebarHeaderActions}
|
|
82
86
|
showThreadBarActions={showThreadBarActions}
|
|
83
87
|
threadNewIcon={threadNewIcon}
|
|
84
88
|
threadHistoryIcon={threadHistoryIcon}
|
package/src/react/markdown.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
19
19
|
import React, { useEffect, useRef, useState } from "react";
|
|
20
20
|
import { Streamdown } from "streamdown";
|
|
21
|
+
import { ExternalLinkDialog } from "./_external-link-dialog.tsx";
|
|
21
22
|
import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid.tsx";
|
|
22
23
|
import { copyText } from "../support/clipboard.ts";
|
|
23
24
|
|
|
@@ -183,6 +184,14 @@ const TOOL_MARKDOWN_COMPONENTS = {
|
|
|
183
184
|
*/
|
|
184
185
|
const SHIKI_PLUGIN = { code: createShikiPlugin() };
|
|
185
186
|
|
|
187
|
+
/** Keep Streamdown's link confirmation while matching the chat's AppKit UI. */
|
|
188
|
+
const LINK_SAFETY = {
|
|
189
|
+
enabled: true,
|
|
190
|
+
renderModal: (props: React.ComponentProps<typeof ExternalLinkDialog>) => (
|
|
191
|
+
<ExternalLinkDialog {...props} />
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
186
195
|
/**
|
|
187
196
|
* Per-word fade-in applied while a reply is still streaming. Streamdown
|
|
188
197
|
* wraps each newly arrived token in a `[data-sd-animate]` span and only
|
|
@@ -235,6 +244,7 @@ export const AssistantMarkdown = ({
|
|
|
235
244
|
components={MARKDOWN_COMPONENTS}
|
|
236
245
|
plugins={SHIKI_PLUGIN}
|
|
237
246
|
controls={false}
|
|
247
|
+
linkSafety={LINK_SAFETY}
|
|
238
248
|
animated={animate ? ANIMATE_OPTIONS : false}
|
|
239
249
|
isAnimating={animate}
|
|
240
250
|
className={cn(
|
|
@@ -269,6 +279,7 @@ export const ToolMarkdown = ({ children, className }: { children: string; classN
|
|
|
269
279
|
components={TOOL_MARKDOWN_COMPONENTS}
|
|
270
280
|
plugins={SHIKI_PLUGIN}
|
|
271
281
|
controls={false}
|
|
282
|
+
linkSafety={LINK_SAFETY}
|
|
272
283
|
className={cn(
|
|
273
284
|
"prose prose-sm dark:prose-invert max-w-none break-words",
|
|
274
285
|
"text-xs leading-snug text-muted-foreground",
|
|
@@ -84,6 +84,8 @@ export interface MastraAssistantProps<TValues extends Record<string, unknown>> e
|
|
|
84
84
|
closeIcon?: ReactNode;
|
|
85
85
|
newConversationIcon?: ReactNode;
|
|
86
86
|
historyIcon?: ReactNode;
|
|
87
|
+
/** Host controls rendered immediately before the History action and in the sidebar header. */
|
|
88
|
+
historyLeadingActions?: ReactNode;
|
|
87
89
|
contentClassName?: string;
|
|
88
90
|
panelClassName?: string;
|
|
89
91
|
headerClassName?: string;
|
|
@@ -205,6 +207,7 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
|
|
|
205
207
|
closeIcon,
|
|
206
208
|
newConversationIcon,
|
|
207
209
|
historyIcon,
|
|
210
|
+
historyLeadingActions,
|
|
208
211
|
className,
|
|
209
212
|
contentClassName,
|
|
210
213
|
panelClassName,
|
|
@@ -493,6 +496,7 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
|
|
|
493
496
|
onCancel={chatView.onCancelThread}
|
|
494
497
|
newIcon={newConversationIcon}
|
|
495
498
|
historyIcon={historyIcon}
|
|
499
|
+
historyLeadingActions={historyLeadingActions}
|
|
496
500
|
actions={closeAction}
|
|
497
501
|
/>
|
|
498
502
|
) : (
|
|
@@ -550,6 +554,8 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
|
|
|
550
554
|
threadBarActions={!header ? closeAction : undefined}
|
|
551
555
|
threadNewIcon={newConversationIcon}
|
|
552
556
|
threadHistoryIcon={historyIcon}
|
|
557
|
+
threadHistoryLeadingActions={historyLeadingActions}
|
|
558
|
+
threadSidebarHeaderActions={historyLeadingActions}
|
|
553
559
|
className={cn("min-h-0 flex-1", chatClassName)}
|
|
554
560
|
/>
|
|
555
561
|
{!header && chatView.threadPlacement !== "top" ? (
|
|
@@ -4,6 +4,7 @@ import type { ReasoningEffort } from "@dbx-tools/shared-model";
|
|
|
4
4
|
import { useBrand } from "@dbx-tools/ui/branding/react";
|
|
5
5
|
import type { FileUIPart, UIMessage } from "ai";
|
|
6
6
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { toChronologicalUiMessages } from "./_history-messages.ts";
|
|
7
8
|
import { useChatApprovals } from "./chat-approvals.ts";
|
|
8
9
|
import { useChatFeedback } from "./chat-feedback.ts";
|
|
9
10
|
import { useChatHistory } from "./chat-history.ts";
|
|
@@ -12,6 +13,12 @@ import { useChatStream } from "./chat-stream.ts";
|
|
|
12
13
|
import { ChatView } from "./chat-view.tsx";
|
|
13
14
|
import { dedupeSuggestions } from "./suggestions.ts";
|
|
14
15
|
import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
|
|
16
|
+
import {
|
|
17
|
+
activeRunStorageKey,
|
|
18
|
+
readStoredActiveRuns,
|
|
19
|
+
removeStoredActiveRun,
|
|
20
|
+
storeActiveRun,
|
|
21
|
+
} from "../support/active-runs.ts";
|
|
15
22
|
import type { EmbedResolver, ExportFormat } from "../support/export.ts";
|
|
16
23
|
import {
|
|
17
24
|
useMastraClient,
|
|
@@ -189,7 +196,7 @@ export interface UseMastraChatOptions<
|
|
|
189
196
|
* Where conversation management renders: `"left"` / `"right"` dock the list
|
|
190
197
|
* to that edge, `"top"` shows the open conversations as an editor-style tab
|
|
191
198
|
* strip with a history menu, `"disabled"` turns thread management off, and
|
|
192
|
-
* the default `"auto"` picks `"
|
|
199
|
+
* the default `"auto"` picks `"right"` while the chat is wide enough for a
|
|
193
200
|
* side panel and `"top"` once it gets too narrow for one (measured on the
|
|
194
201
|
* chat's own width, so an embedded panel decides on its own space).
|
|
195
202
|
*/
|
|
@@ -292,6 +299,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
292
299
|
const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
|
|
293
300
|
const { url: mlflowExperimentUrl } = useMastraMlflowExperiment(feedbackAvailable);
|
|
294
301
|
const threadKey = threadStorageKey(mastraClient.basePath, agentId);
|
|
302
|
+
const activeRunsKey = activeRunStorageKey(mastraClient.basePath, agentId);
|
|
295
303
|
const [activeThreadId, setActiveThreadId] = useState<string>(() =>
|
|
296
304
|
enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : hash.id(),
|
|
297
305
|
);
|
|
@@ -510,6 +518,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
510
518
|
};
|
|
511
519
|
});
|
|
512
520
|
if (getSession(threadId).runToken === token) {
|
|
521
|
+
const completedRunId = runIdRef.current;
|
|
522
|
+
if (completedRunId) removeStoredActiveRun(activeRunsKey, completedRunId);
|
|
513
523
|
refreshThreadsSoon();
|
|
514
524
|
// Turn finished on its own: start the oldest queued steer, if any.
|
|
515
525
|
drainQueueRef.current(threadId);
|
|
@@ -531,6 +541,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
531
541
|
runId: runIdRef.current,
|
|
532
542
|
};
|
|
533
543
|
});
|
|
544
|
+
const staleRunId = runIdRef.current;
|
|
545
|
+
if (staleRunId) removeStoredActiveRun(activeRunsKey, staleRunId);
|
|
534
546
|
refreshThreadsSoon();
|
|
535
547
|
drainQueueRef.current(threadId);
|
|
536
548
|
return;
|
|
@@ -549,6 +561,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
549
561
|
},
|
|
550
562
|
[
|
|
551
563
|
agentId,
|
|
564
|
+
activeRunsKey,
|
|
552
565
|
getSession,
|
|
553
566
|
mastraClient,
|
|
554
567
|
processStream,
|
|
@@ -562,6 +575,13 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
562
575
|
(threadId: string, message: UIMessage, requestContext?: MastraRequestContextSnapshot) => {
|
|
563
576
|
const assistantId = hash.id();
|
|
564
577
|
const runId = hash.id();
|
|
578
|
+
storeActiveRun(activeRunsKey, {
|
|
579
|
+
threadId,
|
|
580
|
+
runId,
|
|
581
|
+
assistantId,
|
|
582
|
+
userMessage: message,
|
|
583
|
+
startedAt: Date.now(),
|
|
584
|
+
});
|
|
565
585
|
updateSession(threadId, (session) => ({
|
|
566
586
|
...session,
|
|
567
587
|
assistantId,
|
|
@@ -595,9 +615,50 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
595
615
|
});
|
|
596
616
|
});
|
|
597
617
|
},
|
|
598
|
-
[driveStream, mastraClient, agentId, updateSession],
|
|
618
|
+
[activeRunsKey, driveStream, mastraClient, agentId, updateSession],
|
|
599
619
|
);
|
|
600
620
|
|
|
621
|
+
const recoveredRunsKeyRef = useRef<string | undefined>(undefined);
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
if (!mastraClient.backgroundTurns || recoveredRunsKeyRef.current === activeRunsKey) return;
|
|
624
|
+
recoveredRunsKeyRef.current = activeRunsKey;
|
|
625
|
+
for (const run of readStoredActiveRuns(activeRunsKey)) {
|
|
626
|
+
const session = getSession(run.threadId);
|
|
627
|
+
if (isSessionRunning(session) || session.runId === run.runId) continue;
|
|
628
|
+
updateSession(run.threadId, (current) => ({
|
|
629
|
+
...current,
|
|
630
|
+
messages: current.messages.some((message) => message.id === run.userMessage.id)
|
|
631
|
+
? current.messages
|
|
632
|
+
: [...current.messages, run.userMessage],
|
|
633
|
+
assistantId: run.assistantId,
|
|
634
|
+
runId: run.runId,
|
|
635
|
+
runs: {
|
|
636
|
+
...current.runs,
|
|
637
|
+
[run.runId]: { assistantId: run.assistantId },
|
|
638
|
+
},
|
|
639
|
+
}));
|
|
640
|
+
void driveStream(run.threadId, run.assistantId, (signal) =>
|
|
641
|
+
mastraClient.observeAgentStream({
|
|
642
|
+
agentId,
|
|
643
|
+
runId: run.runId,
|
|
644
|
+
threadId: run.threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : run.threadId,
|
|
645
|
+
signal,
|
|
646
|
+
}),
|
|
647
|
+
).then(async () => {
|
|
648
|
+
if (run.threadId === DEFAULT_THREAD_SESSION_KEY) return;
|
|
649
|
+
const history = await mastraClient
|
|
650
|
+
.history({
|
|
651
|
+
agentId,
|
|
652
|
+
threadId: run.threadId,
|
|
653
|
+
page: 0,
|
|
654
|
+
perPage: 20,
|
|
655
|
+
})
|
|
656
|
+
.catch(() => undefined);
|
|
657
|
+
if (history) writeMessages(run.threadId, toChronologicalUiMessages(history));
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}, [activeRunsKey, agentId, driveStream, getSession, mastraClient, updateSession, writeMessages]);
|
|
661
|
+
|
|
601
662
|
// Start the oldest queued steer as the next turn (FIFO drain). Called from
|
|
602
663
|
// `driveStream` when a turn ends normally; a no-op when the queue is empty.
|
|
603
664
|
// Kept behind `drainQueueRef` so `driveStream` can reach it without a
|
|
@@ -632,6 +693,9 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
632
693
|
runId: current.runId,
|
|
633
694
|
threadId: streamThreadId,
|
|
634
695
|
})
|
|
696
|
+
.then((aborted) => {
|
|
697
|
+
if (aborted && current.runId) removeStoredActiveRun(activeRunsKey, current.runId);
|
|
698
|
+
})
|
|
635
699
|
.catch((error: unknown) => {
|
|
636
700
|
logger.warn("failed to abort background run", {
|
|
637
701
|
runId: current.runId,
|
|
@@ -651,7 +715,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
651
715
|
};
|
|
652
716
|
});
|
|
653
717
|
},
|
|
654
|
-
[activeKey, agentId, getSession, mastraClient, updateSession],
|
|
718
|
+
[activeKey, activeRunsKey, agentId, getSession, mastraClient, updateSession],
|
|
655
719
|
);
|
|
656
720
|
|
|
657
721
|
const handleApproval = useChatApprovals({
|
|
@@ -1155,6 +1219,10 @@ export interface MastraChatProps<
|
|
|
1155
1219
|
modelSelectorActions?: ReactNode;
|
|
1156
1220
|
/** Host controls placed on the left side of the composer footer. */
|
|
1157
1221
|
composerLeadingActions?: ReactNode;
|
|
1222
|
+
/** Host controls rendered immediately before the responsive History action. */
|
|
1223
|
+
threadHistoryLeadingActions?: ReactNode;
|
|
1224
|
+
/** Host controls rendered in the full conversation-list header. */
|
|
1225
|
+
threadSidebarHeaderActions?: ReactNode;
|
|
1158
1226
|
}
|
|
1159
1227
|
|
|
1160
1228
|
/**
|
|
@@ -1167,7 +1235,7 @@ export interface MastraChatProps<
|
|
|
1167
1235
|
* of the resource's threads with select / new / delete, persisted
|
|
1168
1236
|
* across reloads) - all with no host wiring. The model picker is opt-in
|
|
1169
1237
|
* via `showModelPicker`; thread management is on by default, laid out per
|
|
1170
|
-
* `threadPlacement` (`auto` docks it
|
|
1238
|
+
* `threadPlacement` (`auto` docks it right and falls back to a tab strip on a
|
|
1171
1239
|
* narrow chat) and turned off with `threadPlacement: "disabled"`.
|
|
1172
1240
|
*/
|
|
1173
1241
|
export const MastraChat = <TValues extends Record<string, unknown> = Record<string, unknown>>({
|
|
@@ -1175,6 +1243,8 @@ export const MastraChat = <TValues extends Record<string, unknown> = Record<stri
|
|
|
1175
1243
|
composerActions,
|
|
1176
1244
|
modelSelectorActions,
|
|
1177
1245
|
composerLeadingActions,
|
|
1246
|
+
threadHistoryLeadingActions,
|
|
1247
|
+
threadSidebarHeaderActions,
|
|
1178
1248
|
...options
|
|
1179
1249
|
}: MastraChatProps<TValues>) => {
|
|
1180
1250
|
const chat = useMastraChat(options);
|
|
@@ -1185,6 +1255,8 @@ export const MastraChat = <TValues extends Record<string, unknown> = Record<stri
|
|
|
1185
1255
|
composerActions={composerActions}
|
|
1186
1256
|
modelSelectorActions={modelSelectorActions}
|
|
1187
1257
|
composerLeadingActions={composerLeadingActions}
|
|
1258
|
+
threadHistoryLeadingActions={threadHistoryLeadingActions}
|
|
1259
|
+
threadSidebarHeaderActions={threadSidebarHeaderActions}
|
|
1188
1260
|
/>
|
|
1189
1261
|
);
|
|
1190
1262
|
};
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
SquareIcon,
|
|
17
17
|
Trash2Icon,
|
|
18
18
|
} from "lucide-react";
|
|
19
|
-
import { useState } from "react";
|
|
19
|
+
import { type ReactNode, useState } from "react";
|
|
20
20
|
import type { ThreadSummary } from "./types.ts";
|
|
21
21
|
import { relativeTime, threadTitle } from "../support/thread-labels.ts";
|
|
22
22
|
|
|
@@ -52,6 +52,8 @@ export interface ThreadSidebarProps {
|
|
|
52
52
|
onCancel?: (threadId: string) => void;
|
|
53
53
|
/** Collapse the sidebar. Renders the hide button in the header when provided. */
|
|
54
54
|
onHide?: () => void;
|
|
55
|
+
/** Host controls rendered in the conversation-list header. */
|
|
56
|
+
headerActions?: ReactNode;
|
|
55
57
|
/**
|
|
56
58
|
* Edge the sidebar is docked to. Only affects framing - which side carries
|
|
57
59
|
* the divider, and which way the collapse icon points. Defaults to `left`.
|
|
@@ -82,6 +84,7 @@ export const ThreadSidebar = ({
|
|
|
82
84
|
onRename,
|
|
83
85
|
onCancel,
|
|
84
86
|
onHide,
|
|
87
|
+
headerActions,
|
|
85
88
|
side = "left",
|
|
86
89
|
className,
|
|
87
90
|
}: ThreadSidebarProps) => {
|
|
@@ -134,7 +137,7 @@ export const ThreadSidebar = ({
|
|
|
134
137
|
className,
|
|
135
138
|
)}
|
|
136
139
|
>
|
|
137
|
-
{(onNew || onHide) && (
|
|
140
|
+
{(onNew || headerActions || onHide) && (
|
|
138
141
|
<div className="flex items-center gap-2 p-2">
|
|
139
142
|
{onNew && (
|
|
140
143
|
<Button
|
|
@@ -148,6 +151,7 @@ export const ThreadSidebar = ({
|
|
|
148
151
|
New chat
|
|
149
152
|
</Button>
|
|
150
153
|
)}
|
|
154
|
+
{headerActions}
|
|
151
155
|
{onHide && (
|
|
152
156
|
<Tooltip>
|
|
153
157
|
<TooltipTrigger asChild>
|
|
@@ -44,6 +44,8 @@ export interface ThreadTabsProps extends Omit<ThreadSidebarProps, "onHide" | "si
|
|
|
44
44
|
newIcon?: ReactNode;
|
|
45
45
|
/** Replacement for the default history icon. */
|
|
46
46
|
historyIcon?: ReactNode;
|
|
47
|
+
/** Host controls rendered immediately before History. */
|
|
48
|
+
historyLeadingActions?: ReactNode;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/** New/history controls reused by the tab strip and persistent assistant header. */
|
|
@@ -54,10 +56,17 @@ export interface ThreadActionsProps extends Omit<
|
|
|
54
56
|
actions?: ReactNode;
|
|
55
57
|
newIcon?: ReactNode;
|
|
56
58
|
historyIcon?: ReactNode;
|
|
59
|
+
historyLeadingActions?: ReactNode;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** Render New and History controls, plus optional host actions, as one group. */
|
|
60
|
-
export const ThreadActions = ({
|
|
63
|
+
export const ThreadActions = ({
|
|
64
|
+
actions,
|
|
65
|
+
newIcon,
|
|
66
|
+
historyIcon,
|
|
67
|
+
historyLeadingActions,
|
|
68
|
+
...list
|
|
69
|
+
}: ThreadActionsProps) => {
|
|
61
70
|
const { onSelect, onNew } = list;
|
|
62
71
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
63
72
|
return (
|
|
@@ -79,6 +88,7 @@ export const ThreadActions = ({ actions, newIcon, historyIcon, ...list }: Thread
|
|
|
79
88
|
<TooltipContent>New chat</TooltipContent>
|
|
80
89
|
</Tooltip>
|
|
81
90
|
)}
|
|
91
|
+
{historyLeadingActions}
|
|
82
92
|
<Popover open={historyOpen} onOpenChange={setHistoryOpen}>
|
|
83
93
|
<Tooltip>
|
|
84
94
|
<TooltipTrigger asChild>
|
|
@@ -136,6 +146,7 @@ export const ThreadTabs = ({
|
|
|
136
146
|
showActions = true,
|
|
137
147
|
newIcon,
|
|
138
148
|
historyIcon,
|
|
149
|
+
historyLeadingActions,
|
|
139
150
|
className,
|
|
140
151
|
...list
|
|
141
152
|
}: ThreadTabsProps) => {
|
|
@@ -207,7 +218,13 @@ export const ThreadTabs = ({
|
|
|
207
218
|
})}
|
|
208
219
|
</div>
|
|
209
220
|
{showActions ? (
|
|
210
|
-
<ThreadActions
|
|
221
|
+
<ThreadActions
|
|
222
|
+
{...list}
|
|
223
|
+
actions={actions}
|
|
224
|
+
newIcon={newIcon}
|
|
225
|
+
historyIcon={historyIcon}
|
|
226
|
+
historyLeadingActions={historyLeadingActions}
|
|
227
|
+
/>
|
|
211
228
|
) : null}
|
|
212
229
|
</div>
|
|
213
230
|
</div>
|
package/src/react/tool-pill.tsx
CHANGED
|
@@ -124,8 +124,8 @@ type MessageGroup = {
|
|
|
124
124
|
* are resolved out-of-band via the chart cache; suggestions live at
|
|
125
125
|
* message scope.
|
|
126
126
|
*
|
|
127
|
-
* Genie `thinking`
|
|
128
|
-
*
|
|
127
|
+
* Genie `thinking` entries render as interleaved Thoughts disclosures in the
|
|
128
|
+
* assistant activity timeline and are intentionally omitted here.
|
|
129
129
|
*
|
|
130
130
|
* `groups` preserves first-seen order so the rendered detail view
|
|
131
131
|
* walks Genie sub-calls in the order the LLM dispatched them.
|
|
@@ -190,7 +190,7 @@ const summarizeProgress = (progress: ToolProgress[]): ToolDetailSummary => {
|
|
|
190
190
|
break;
|
|
191
191
|
}
|
|
192
192
|
case "thinking": {
|
|
193
|
-
// Reasoning is rendered once in the assistant
|
|
193
|
+
// Reasoning is rendered once in the assistant activity timeline.
|
|
194
194
|
break;
|
|
195
195
|
}
|
|
196
196
|
case "query": {
|
package/src/react/types.ts
CHANGED
|
@@ -265,7 +265,7 @@ export type ChatViewProps = {
|
|
|
265
265
|
/**
|
|
266
266
|
* Where the conversation list renders (or `"disabled"` to hide it even when
|
|
267
267
|
* {@link threads} and {@link onSelectThread} are wired). Defaults to
|
|
268
|
-
* `"auto"`: a
|
|
268
|
+
* `"auto"`: a right side panel while the chat is wide, a tab strip once it
|
|
269
269
|
* is too narrow for one. See {@link ThreadPlacement}.
|
|
270
270
|
*/
|
|
271
271
|
threadPlacement?: ThreadPlacement;
|
|
@@ -274,6 +274,10 @@ export type ChatViewProps = {
|
|
|
274
274
|
* actions. The persistent assistant uses this for its panel Close button.
|
|
275
275
|
*/
|
|
276
276
|
threadBarActions?: ReactNode;
|
|
277
|
+
/** Host controls rendered immediately before the top-row History action. */
|
|
278
|
+
threadHistoryLeadingActions?: ReactNode;
|
|
279
|
+
/** Host controls rendered in the docked or drawer conversation-list header. */
|
|
280
|
+
threadSidebarHeaderActions?: ReactNode;
|
|
277
281
|
/** Whether the top tab row renders its built-in New/History controls. */
|
|
278
282
|
showThreadBarActions?: boolean;
|
|
279
283
|
/** Replacement icon for the top tab row's New action. */
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const ACTIVE_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
5
|
+
|
|
6
|
+
export const StoredActiveRunSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
threadId: z.string().min(1).describe("Mastra thread receiving the background turn."),
|
|
9
|
+
runId: z.string().min(1).describe("Mastra background run identifier."),
|
|
10
|
+
assistantId: z.string().min(1).describe("Client assistant message identifier."),
|
|
11
|
+
userMessage: z
|
|
12
|
+
.object({
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
role: z.literal("user"),
|
|
15
|
+
parts: z.array(z.unknown()),
|
|
16
|
+
})
|
|
17
|
+
.passthrough()
|
|
18
|
+
.describe("Submitted user message restored before stream replay."),
|
|
19
|
+
startedAt: z.number().int().nonnegative().describe("Client epoch time when the run started."),
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.describe("Reload-recoverable Mastra background run metadata.");
|
|
23
|
+
|
|
24
|
+
/** Reload-recoverable metadata for one active Mastra background run. */
|
|
25
|
+
export type StoredActiveRun = Omit<z.infer<typeof StoredActiveRunSchema>, "userMessage"> & {
|
|
26
|
+
userMessage: UIMessage;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const StoredActiveRunFileSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
version: z.literal(1).describe("Active run storage format version."),
|
|
32
|
+
runs: z.array(StoredActiveRunSchema).describe("Background runs awaiting terminal completion."),
|
|
33
|
+
})
|
|
34
|
+
.strict()
|
|
35
|
+
.describe("Browser-persisted Mastra background run registry.");
|
|
36
|
+
|
|
37
|
+
type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
38
|
+
|
|
39
|
+
/** Build the browser storage key for one Mastra agent's active runs. */
|
|
40
|
+
export const activeRunStorageKey = (basePath: string, agentId: string): string =>
|
|
41
|
+
`dbx-mastra-active-runs:${basePath}:${agentId}`;
|
|
42
|
+
|
|
43
|
+
function browserStorage(): StorageLike | undefined {
|
|
44
|
+
try {
|
|
45
|
+
return typeof window === "undefined" ? undefined : window.localStorage;
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read current non-stale background runs, deleting malformed storage. */
|
|
52
|
+
export function readStoredActiveRuns(
|
|
53
|
+
key: string,
|
|
54
|
+
storage: StorageLike | undefined = browserStorage(),
|
|
55
|
+
now = Date.now(),
|
|
56
|
+
): StoredActiveRun[] {
|
|
57
|
+
if (!storage) return [];
|
|
58
|
+
try {
|
|
59
|
+
const serialized = storage.getItem(key);
|
|
60
|
+
if (!serialized) return [];
|
|
61
|
+
const parsed = StoredActiveRunFileSchema.parse(JSON.parse(serialized));
|
|
62
|
+
const parsedRuns: StoredActiveRun[] = parsed.runs.map((run) => ({
|
|
63
|
+
...run,
|
|
64
|
+
userMessage: run.userMessage as UIMessage,
|
|
65
|
+
}));
|
|
66
|
+
const runs = parsedRuns.filter((run) => now - run.startedAt <= ACTIVE_RUN_MAX_AGE_MS);
|
|
67
|
+
if (runs.length !== parsed.runs.length) writeStoredActiveRuns(key, runs, storage);
|
|
68
|
+
return runs as StoredActiveRun[];
|
|
69
|
+
} catch {
|
|
70
|
+
try {
|
|
71
|
+
storage.removeItem(key);
|
|
72
|
+
} catch {}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Add or replace one reload-recoverable background run. */
|
|
78
|
+
export function storeActiveRun(
|
|
79
|
+
key: string,
|
|
80
|
+
run: StoredActiveRun,
|
|
81
|
+
storage: StorageLike | undefined = browserStorage(),
|
|
82
|
+
): void {
|
|
83
|
+
if (!storage) return;
|
|
84
|
+
const runs = readStoredActiveRuns(key, storage).filter(
|
|
85
|
+
(candidate) => candidate.runId !== run.runId,
|
|
86
|
+
);
|
|
87
|
+
writeStoredActiveRuns(key, [...runs, run], storage);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Remove one terminal or explicitly aborted background run. */
|
|
91
|
+
export function removeStoredActiveRun(
|
|
92
|
+
key: string,
|
|
93
|
+
runId: string,
|
|
94
|
+
storage: StorageLike | undefined = browserStorage(),
|
|
95
|
+
): void {
|
|
96
|
+
if (!storage) return;
|
|
97
|
+
const runs = readStoredActiveRuns(key, storage).filter((run) => run.runId !== runId);
|
|
98
|
+
writeStoredActiveRuns(key, runs, storage);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeStoredActiveRuns(key: string, runs: StoredActiveRun[], storage: StorageLike): void {
|
|
102
|
+
try {
|
|
103
|
+
if (runs.length === 0) {
|
|
104
|
+
storage.removeItem(key);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
storage.setItem(key, JSON.stringify({ version: 1, runs }));
|
|
108
|
+
} catch {
|
|
109
|
+
// Browser persistence is best-effort; the live in-memory run remains valid.
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/support/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Regenerated from the exporting modules in ./src.
|
|
3
3
|
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
4
|
|
|
5
|
+
export * as activeRuns from "./active-runs.ts";
|
|
5
6
|
export * as chartOption from "./chart-option.ts";
|
|
6
7
|
export * as chartTheme from "./chart-theme.ts";
|
|
7
8
|
export * as clipboard from "./clipboard.ts";
|
|
@@ -14,6 +15,8 @@ export * as threadLabels from "./thread-labels.ts";
|
|
|
14
15
|
export * as threadSessions from "./thread-sessions.ts";
|
|
15
16
|
export * as threadTabs from "./thread-tabs.ts";
|
|
16
17
|
export * as toolEvents from "./tool-events.ts";
|
|
18
|
+
export { StoredActiveRunSchema, activeRunStorageKey } from "./active-runs.ts";
|
|
19
|
+
export type { StoredActiveRun } from "./active-runs.ts";
|
|
17
20
|
export { LIGHT_CHART_CHROME } from "./chart-theme.ts";
|
|
18
21
|
export type { ChartChrome } from "./chart-theme.ts";
|
|
19
22
|
export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./export.ts";
|
|
@@ -130,6 +130,7 @@ export class MastraPluginClient extends MastraClient {
|
|
|
130
130
|
fetch: async (input, init) => {
|
|
131
131
|
const response = await fetch(input, init);
|
|
132
132
|
headers = new Headers(response.headers);
|
|
133
|
+
await assertChatResponse(response);
|
|
133
134
|
return response;
|
|
134
135
|
},
|
|
135
136
|
});
|
|
@@ -160,6 +161,7 @@ export class MastraPluginClient extends MastraClient {
|
|
|
160
161
|
fetch: async (input, init) => {
|
|
161
162
|
const response = await fetch(input, init);
|
|
162
163
|
headers = new Headers(response.headers);
|
|
164
|
+
await assertChatResponse(response);
|
|
163
165
|
return response;
|
|
164
166
|
},
|
|
165
167
|
});
|
|
@@ -620,6 +622,35 @@ export class MastraPluginClient extends MastraClient {
|
|
|
620
622
|
}
|
|
621
623
|
}
|
|
622
624
|
|
|
625
|
+
async function assertChatResponse(response: Response): Promise<void> {
|
|
626
|
+
if (response.ok) return;
|
|
627
|
+
const body = await response
|
|
628
|
+
.clone()
|
|
629
|
+
.json()
|
|
630
|
+
.catch(() => undefined);
|
|
631
|
+
const message =
|
|
632
|
+
responseErrorMessage(body) ??
|
|
633
|
+
(await response
|
|
634
|
+
.clone()
|
|
635
|
+
.text()
|
|
636
|
+
.catch(() => ""));
|
|
637
|
+
throw new Error(message.trim() || `Chat request failed (${response.status})`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function responseErrorMessage(body: unknown): string | undefined {
|
|
641
|
+
if (!body || typeof body !== "object") return undefined;
|
|
642
|
+
const record = body as Record<string, unknown>;
|
|
643
|
+
for (const field of ["error", "message"] as const) {
|
|
644
|
+
const value = record[field];
|
|
645
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
646
|
+
if (value && typeof value === "object") {
|
|
647
|
+
const nested = (value as Record<string, unknown>).message;
|
|
648
|
+
if (typeof nested === "string" && nested.trim()) return nested.trim();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
623
654
|
/**
|
|
624
655
|
* Read the Mastra plugin's `clientConfig()` payload (`basePath`,
|
|
625
656
|
* default agent, registered agent list). One call per render; values
|