@dbx-tools/ui-mastra 0.6.211 → 0.6.212

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.
@@ -0,0 +1,241 @@
1
+ import { GenieWriterEventSchema, type MastraStreamChunk } from "@dbx-tools/shared-mastra";
2
+ import type { UIMessage } from "ai";
3
+ import type { PendingApproval, ToolEvent } from "./types.ts";
4
+
5
+ /** Pure accumulated state for one assistant response stream. */
6
+ export type ChatStreamState = {
7
+ textSegments: string[];
8
+ reasoning: string;
9
+ toolEvents: ToolEvent[];
10
+ pendingApprovals: PendingApproval[];
11
+ runId: string | null;
12
+ streaming: boolean;
13
+ };
14
+
15
+ /** Session fields changed by one stream reduction. */
16
+ export type ChatStreamChanges = {
17
+ runId: boolean;
18
+ status: boolean;
19
+ toolEvents: boolean;
20
+ pendingApprovals: boolean;
21
+ };
22
+
23
+ /** Result of reducing one validated stream chunk. */
24
+ export type ChatStreamReduction = {
25
+ state: ChatStreamState;
26
+ changes: ChatStreamChanges;
27
+ assistantChanged: boolean;
28
+ error?: string;
29
+ };
30
+
31
+ /** Seed pure stream state from an existing assistant message and session data. */
32
+ export function createChatStreamState(options: {
33
+ existing?: UIMessage;
34
+ pendingApprovals: PendingApproval[];
35
+ runId: string | null;
36
+ toolEvents: ToolEvent[];
37
+ }): ChatStreamState {
38
+ const textSegments: string[] = [];
39
+ let reasoning = "";
40
+ for (const part of options.existing?.parts ?? []) {
41
+ if (part.type === "text") textSegments.push(part.text);
42
+ else if (part.type === "reasoning") reasoning += (part as { text?: string }).text ?? "";
43
+ }
44
+ return {
45
+ textSegments,
46
+ reasoning,
47
+ toolEvents: options.toolEvents,
48
+ pendingApprovals: options.pendingApprovals,
49
+ runId: options.runId,
50
+ streaming: false,
51
+ };
52
+ }
53
+
54
+ /** Materialize the assistant message represented by accumulated stream state. */
55
+ export function chatStreamAssistantMessage(assistantId: string, state: ChatStreamState): UIMessage {
56
+ const parts: UIMessage["parts"] = [];
57
+ if (state.reasoning) parts.push({ type: "reasoning", text: state.reasoning });
58
+ for (const segment of state.textSegments) {
59
+ if (segment.length > 0) parts.push({ type: "text", text: segment });
60
+ }
61
+ return {
62
+ id: assistantId,
63
+ role: "assistant",
64
+ parts: parts.length > 0 ? parts : [{ type: "text", text: "" }],
65
+ };
66
+ }
67
+
68
+ const withRunId = (
69
+ state: ChatStreamState,
70
+ chunk: MastraStreamChunk,
71
+ ): { state: ChatStreamState; changed: boolean } => {
72
+ if (!chunk.runId || state.runId) return { state, changed: false };
73
+ return { state: { ...state, runId: chunk.runId }, changed: true };
74
+ };
75
+
76
+ const reduction = (
77
+ state: ChatStreamState,
78
+ options: {
79
+ runIdChanged: boolean;
80
+ assistantChanged?: boolean;
81
+ statusChanged?: boolean;
82
+ toolEventsChanged?: boolean;
83
+ pendingApprovalsChanged?: boolean;
84
+ error?: string;
85
+ },
86
+ ): ChatStreamReduction => ({
87
+ state,
88
+ changes: {
89
+ runId: options.runIdChanged,
90
+ status: options.statusChanged ?? false,
91
+ toolEvents: options.toolEventsChanged ?? false,
92
+ pendingApprovals: options.pendingApprovalsChanged ?? false,
93
+ },
94
+ assistantChanged: options.assistantChanged ?? false,
95
+ ...(options.error ? { error: options.error } : {}),
96
+ });
97
+
98
+ /** Reduce one validated chunk into immutable assistant and session state. */
99
+ export function reduceChatStreamChunk(
100
+ previous: ChatStreamState,
101
+ chunk: MastraStreamChunk,
102
+ ): ChatStreamReduction {
103
+ const run = withRunId(previous, chunk);
104
+ const state = run.state;
105
+ const markStreaming = !state.streaming;
106
+
107
+ switch (chunk.type) {
108
+ case "unknown":
109
+ case "text-start":
110
+ case "text-end":
111
+ return reduction(state, { runIdChanged: run.changed });
112
+
113
+ case "text-delta": {
114
+ const textSegments = state.textSegments.length > 0 ? [...state.textSegments] : [""];
115
+ textSegments[textSegments.length - 1] += chunk.payload.text;
116
+ return reduction(
117
+ { ...state, textSegments, streaming: true },
118
+ {
119
+ runIdChanged: run.changed,
120
+ assistantChanged: true,
121
+ statusChanged: markStreaming,
122
+ },
123
+ );
124
+ }
125
+
126
+ case "reasoning-delta":
127
+ return reduction(
128
+ {
129
+ ...state,
130
+ reasoning: state.reasoning + chunk.payload.text,
131
+ streaming: true,
132
+ },
133
+ {
134
+ runIdChanged: run.changed,
135
+ assistantChanged: true,
136
+ statusChanged: markStreaming,
137
+ },
138
+ );
139
+
140
+ case "tool-call":
141
+ return reduction(
142
+ {
143
+ ...state,
144
+ streaming: true,
145
+ toolEvents: [
146
+ ...state.toolEvents,
147
+ {
148
+ id: chunk.payload.toolCallId,
149
+ toolName: chunk.payload.toolName,
150
+ status: "running",
151
+ },
152
+ ],
153
+ },
154
+ {
155
+ runIdChanged: run.changed,
156
+ assistantChanged: true,
157
+ statusChanged: markStreaming,
158
+ toolEventsChanged: true,
159
+ },
160
+ );
161
+
162
+ case "tool-call-approval": {
163
+ const approvalRunId = chunk.runId ?? chunk.payload.runId ?? state.runId;
164
+ if (!approvalRunId) {
165
+ return reduction(state, { runIdChanged: run.changed });
166
+ }
167
+ const approval: PendingApproval = {
168
+ toolName: chunk.payload.toolName,
169
+ toolCallId: chunk.payload.toolCallId,
170
+ runId: approvalRunId,
171
+ input: chunk.payload.args,
172
+ };
173
+ const exists = state.pendingApprovals.some(
174
+ (current) => current.toolCallId === approval.toolCallId,
175
+ );
176
+ return reduction(
177
+ {
178
+ ...state,
179
+ streaming: true,
180
+ pendingApprovals: exists ? state.pendingApprovals : [...state.pendingApprovals, approval],
181
+ },
182
+ {
183
+ runIdChanged: run.changed,
184
+ assistantChanged: true,
185
+ statusChanged: markStreaming,
186
+ pendingApprovalsChanged: !exists,
187
+ },
188
+ );
189
+ }
190
+
191
+ case "tool-result":
192
+ case "tool-error":
193
+ return reduction(
194
+ {
195
+ ...state,
196
+ toolEvents: state.toolEvents.map((event) =>
197
+ event.id === chunk.payload.toolCallId
198
+ ? { ...event, status: chunk.type === "tool-result" ? "done" : "error" }
199
+ : event,
200
+ ),
201
+ },
202
+ {
203
+ runIdChanged: run.changed,
204
+ toolEventsChanged: true,
205
+ },
206
+ );
207
+
208
+ case "tool-output": {
209
+ const progress = GenieWriterEventSchema.safeParse(chunk.payload.output);
210
+ if (!progress.success) return reduction(state, { runIdChanged: run.changed });
211
+ return reduction(
212
+ {
213
+ ...state,
214
+ toolEvents: state.toolEvents.map((event) =>
215
+ event.id === chunk.payload.toolCallId
216
+ ? { ...event, progress: [...(event.progress ?? []), progress.data] }
217
+ : event,
218
+ ),
219
+ },
220
+ {
221
+ runIdChanged: run.changed,
222
+ toolEventsChanged: true,
223
+ },
224
+ );
225
+ }
226
+
227
+ case "error": {
228
+ const detail = chunk.payload?.error ?? chunk.payload?.message;
229
+ return reduction(state, {
230
+ runIdChanged: run.changed,
231
+ error:
232
+ typeof detail === "string" && detail ? detail : "The assistant stream reported an error.",
233
+ });
234
+ }
235
+
236
+ default: {
237
+ const exhaustive: never = chunk;
238
+ return exhaustive;
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,117 @@
1
+ import { feedback } from "@dbx-tools/shared-mastra";
2
+ import { useCallback } from "react";
3
+ import type { MastraStreamResponse } from "../support/mastra-stream.ts";
4
+ import type {
5
+ ThreadMessageWriter,
6
+ ThreadSessionReader,
7
+ ThreadSessionUpdater,
8
+ } from "./chat-sessions.ts";
9
+ import {
10
+ chatStreamAssistantMessage,
11
+ createChatStreamState,
12
+ reduceChatStreamChunk,
13
+ } from "./chat-stream-reducer.ts";
14
+
15
+ /** Read the MLflow trace id captured by the server on a stream response. */
16
+ const readMlflowTraceId = (stream: unknown): string | undefined => {
17
+ const headers = (stream as { headers?: { get?: (name: string) => string | null } })?.headers;
18
+ return headers?.get?.(feedback.MLFLOW_TRACE_ID_HEADER)?.trim() || undefined;
19
+ };
20
+
21
+ class StreamAborted extends Error {}
22
+
23
+ interface UseChatStreamOptions {
24
+ getSession: ThreadSessionReader;
25
+ updateSession: ThreadSessionUpdater;
26
+ writeMessages: ThreadMessageWriter;
27
+ }
28
+
29
+ /** Translate one validated Mastra data stream into thread messages and tool state. */
30
+ export function useChatStream({ getSession, updateSession, writeMessages }: UseChatStreamOptions) {
31
+ return useCallback(
32
+ async (
33
+ threadId: string,
34
+ stream: MastraStreamResponse,
35
+ assistantId: string,
36
+ runIdRef: { current: string | null },
37
+ signal: AbortSignal,
38
+ ) => {
39
+ const traceId = readMlflowTraceId(stream);
40
+ if (traceId) {
41
+ updateSession(threadId, (session) =>
42
+ session.feedbackByMessage[assistantId]?.traceId === traceId
43
+ ? session
44
+ : {
45
+ ...session,
46
+ feedbackByMessage: {
47
+ ...session.feedbackByMessage,
48
+ [assistantId]: {
49
+ ...session.feedbackByMessage[assistantId],
50
+ traceId,
51
+ },
52
+ },
53
+ },
54
+ );
55
+ }
56
+
57
+ const session = getSession(threadId);
58
+ const existing = session.messages.find((message) => message.id === assistantId);
59
+ let state = createChatStreamState({
60
+ ...(existing ? { existing } : {}),
61
+ pendingApprovals: session.pendingApprovalsByMessage[assistantId] ?? [],
62
+ runId: runIdRef.current,
63
+ toolEvents: session.toolEventsByMessage[assistantId] ?? [],
64
+ });
65
+
66
+ const upsertAssistant = () => {
67
+ const next = [...getSession(threadId).messages];
68
+ const index = next.findIndex((message) => message.id === assistantId);
69
+ const message = chatStreamAssistantMessage(assistantId, state);
70
+ if (index === -1) next.push(message);
71
+ else next[index] = message;
72
+ writeMessages(threadId, next);
73
+ };
74
+
75
+ try {
76
+ await stream.processDataStream({
77
+ onChunk: async (chunk) => {
78
+ if (signal.aborted) throw new StreamAborted();
79
+ const next = reduceChatStreamChunk(state, chunk);
80
+ state = next.state;
81
+ runIdRef.current = state.runId;
82
+
83
+ if (Object.values(next.changes).some(Boolean)) {
84
+ updateSession(threadId, (current) => ({
85
+ ...current,
86
+ ...(next.changes.runId ? { runId: state.runId } : {}),
87
+ ...(next.changes.status ? { status: "streaming" as const } : {}),
88
+ ...(next.changes.toolEvents
89
+ ? {
90
+ toolEventsByMessage: {
91
+ ...current.toolEventsByMessage,
92
+ [assistantId]: state.toolEvents,
93
+ },
94
+ }
95
+ : {}),
96
+ ...(next.changes.pendingApprovals
97
+ ? {
98
+ pendingApprovalsByMessage: {
99
+ ...current.pendingApprovalsByMessage,
100
+ [assistantId]: state.pendingApprovals,
101
+ },
102
+ }
103
+ : {}),
104
+ }));
105
+ }
106
+ if (next.assistantChanged) upsertAssistant();
107
+ if (next.error) throw new Error(next.error);
108
+ },
109
+ });
110
+ } catch (error) {
111
+ if (error instanceof StreamAborted || signal.aborted) return;
112
+ throw error;
113
+ }
114
+ },
115
+ [getSession, updateSession, writeMessages],
116
+ );
117
+ }
@@ -0,0 +1,214 @@
1
+ import {
2
+ Button,
3
+ Tooltip,
4
+ TooltipContent,
5
+ TooltipProvider,
6
+ TooltipTrigger,
7
+ cn,
8
+ } from "@dbx-tools/ui-appkit/react";
9
+ import { PanelLeftIcon, PanelRightIcon } from "lucide-react";
10
+ import React, { useEffect, useRef, useState } from "react";
11
+ import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar.tsx";
12
+ import { ThreadTabs } from "./thread-tabs.tsx";
13
+ import type { ChatViewProps } from "./types.ts";
14
+ import { closeThreadTab, nextActiveThreadTab, syncThreadTabs } from "../support/thread-tabs.ts";
15
+
16
+ const SIDE_PANEL_MIN_WIDTH_PX = 768;
17
+
18
+ const useIsNarrow = (ref: React.RefObject<HTMLElement | null>): boolean => {
19
+ const [isNarrow, setIsNarrow] = useState(() =>
20
+ typeof window === "undefined" ? false : window.innerWidth < SIDE_PANEL_MIN_WIDTH_PX,
21
+ );
22
+ useEffect(() => {
23
+ const element = ref.current;
24
+ if (!element || typeof ResizeObserver === "undefined") return;
25
+ const observer = new ResizeObserver((entries) => {
26
+ const width = entries[0]?.contentRect.width ?? element.clientWidth;
27
+ if (width > 0) setIsNarrow(width < SIDE_PANEL_MIN_WIDTH_PX);
28
+ });
29
+ observer.observe(element);
30
+ return () => observer.disconnect();
31
+ }, [ref]);
32
+ return isNarrow;
33
+ };
34
+
35
+ type ChatThreadLayoutProps = {
36
+ children: React.ReactNode;
37
+ className: ChatViewProps["className"];
38
+ threads: ChatViewProps["threads"];
39
+ threadPlacement: NonNullable<ChatViewProps["threadPlacement"]>;
40
+ activeThreadId: ChatViewProps["activeThreadId"];
41
+ streamingThreadIds: NonNullable<ChatViewProps["streamingThreadIds"]>;
42
+ isLoadingThreads: NonNullable<ChatViewProps["isLoadingThreads"]>;
43
+ onSelectThread: ChatViewProps["onSelectThread"];
44
+ onNewThread: ChatViewProps["onNewThread"];
45
+ onDeleteThread: ChatViewProps["onDeleteThread"];
46
+ onRenameThread: ChatViewProps["onRenameThread"];
47
+ onCancelThread: ChatViewProps["onCancelThread"];
48
+ sidebarOpen: ChatViewProps["sidebarOpen"];
49
+ onToggleSidebar: ChatViewProps["onToggleSidebar"];
50
+ };
51
+
52
+ type ThreadDrawerProps = {
53
+ list: Omit<ThreadSidebarProps, "onHide" | "side" | "className">;
54
+ side: "left" | "right";
55
+ onClose: () => void;
56
+ onSelectThread: NonNullable<ChatViewProps["onSelectThread"]>;
57
+ onNewThread: ChatViewProps["onNewThread"];
58
+ };
59
+
60
+ const ThreadDrawer = ({ list, side, onClose, onSelectThread, onNewThread }: ThreadDrawerProps) => (
61
+ <div className={cn("fixed inset-0 z-40 flex", side === "right" && "justify-end")}>
62
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
63
+ <ThreadSidebar
64
+ {...list}
65
+ onHide={onClose}
66
+ side={side}
67
+ onSelect={(id) => {
68
+ onSelectThread(id);
69
+ onClose();
70
+ }}
71
+ {...(onNewThread
72
+ ? {
73
+ onNew: () => {
74
+ onNewThread();
75
+ onClose();
76
+ },
77
+ }
78
+ : {})}
79
+ className="relative z-10 w-[85vw] max-w-xs shadow-xl"
80
+ />
81
+ </div>
82
+ );
83
+
84
+ /** Internal responsive shell that owns conversation navigation surfaces. */
85
+ export const ChatThreadLayout = ({
86
+ children,
87
+ className,
88
+ threads,
89
+ threadPlacement,
90
+ activeThreadId,
91
+ streamingThreadIds,
92
+ isLoadingThreads,
93
+ onSelectThread,
94
+ onNewThread,
95
+ onDeleteThread,
96
+ onRenameThread,
97
+ onCancelThread,
98
+ sidebarOpen: sidebarOpenProp,
99
+ onToggleSidebar,
100
+ }: ChatThreadLayoutProps) => {
101
+ const rootRef = useRef<HTMLDivElement>(null);
102
+ const isNarrow = useIsNarrow(rootRef);
103
+ const showThreads = Boolean(threads && onSelectThread) && threadPlacement !== "disabled";
104
+ const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "left") : threadPlacement;
105
+ const tabbedThreads = showThreads && placement === "top";
106
+ const dockedSide = placement === "right" ? "right" : "left";
107
+ const dockedThreads = showThreads && (placement === "left" || placement === "right");
108
+
109
+ const [internalSidebarOpen, setInternalSidebarOpen] = useState(true);
110
+ const inlineSidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
111
+ const toggleInlineSidebar = () => {
112
+ if (onToggleSidebar) onToggleSidebar();
113
+ else setInternalSidebarOpen((open) => !open);
114
+ };
115
+
116
+ const [drawerOpen, setDrawerOpen] = useState(false);
117
+ useEffect(() => {
118
+ if (!isNarrow) setDrawerOpen(false);
119
+ }, [isNarrow]);
120
+
121
+ const sidebarOpen = isNarrow ? drawerOpen : inlineSidebarOpen;
122
+ const toggleSidebar = () => {
123
+ if (isNarrow) setDrawerOpen((open) => !open);
124
+ else toggleInlineSidebar();
125
+ };
126
+ const showSidebarToggle = dockedThreads && (isNarrow || !inlineSidebarOpen);
127
+ const SidebarToggleIcon = dockedSide === "right" ? PanelRightIcon : PanelLeftIcon;
128
+
129
+ const threadListProps: Omit<ThreadSidebarProps, "onHide" | "side" | "className"> = {
130
+ threads: threads ?? [],
131
+ ...(activeThreadId ? { activeThreadId } : {}),
132
+ streamingThreadIds,
133
+ isLoading: isLoadingThreads,
134
+ onSelect: (id) => onSelectThread?.(id),
135
+ ...(onNewThread ? { onNew: onNewThread } : {}),
136
+ ...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
137
+ ...(onRenameThread ? { onRename: onRenameThread } : {}),
138
+ ...(onCancelThread ? { onCancel: onCancelThread } : {}),
139
+ };
140
+
141
+ const [openTabIds, setOpenTabIds] = useState<string[]>([]);
142
+ useEffect(() => {
143
+ if (!tabbedThreads) return;
144
+ setOpenTabIds((previous) => syncThreadTabs(previous, threads ?? [], activeThreadId));
145
+ }, [tabbedThreads, threads, activeThreadId]);
146
+
147
+ const closeTab = (threadId: string) => {
148
+ const fallback = nextActiveThreadTab(openTabIds, threadId);
149
+ setOpenTabIds((previous) => closeThreadTab(previous, threadId));
150
+ if (threadId !== activeThreadId) return;
151
+ if (fallback) onSelectThread?.(fallback);
152
+ else onNewThread?.();
153
+ };
154
+
155
+ return (
156
+ <TooltipProvider delayDuration={200}>
157
+ <div
158
+ ref={rootRef}
159
+ className={cn(
160
+ "flex h-full min-h-0",
161
+ dockedThreads && dockedSide === "right" && "flex-row-reverse",
162
+ className,
163
+ )}
164
+ >
165
+ {dockedThreads &&
166
+ (isNarrow
167
+ ? drawerOpen &&
168
+ onSelectThread && (
169
+ <ThreadDrawer
170
+ list={threadListProps}
171
+ side={dockedSide}
172
+ onClose={toggleSidebar}
173
+ onSelectThread={onSelectThread}
174
+ onNewThread={onNewThread}
175
+ />
176
+ )
177
+ : inlineSidebarOpen && (
178
+ <ThreadSidebar {...threadListProps} onHide={toggleSidebar} side={dockedSide} />
179
+ ))}
180
+ <div className="flex h-full min-w-0 flex-1 flex-col">
181
+ {tabbedThreads && (
182
+ <ThreadTabs {...threadListProps} openThreadIds={openTabIds} onCloseTab={closeTab} />
183
+ )}
184
+ {showSidebarToggle && (
185
+ <div
186
+ className={cn(
187
+ "mx-auto flex w-full max-w-4xl items-center gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6",
188
+ dockedSide === "right" && "justify-end",
189
+ )}
190
+ >
191
+ <Tooltip>
192
+ <TooltipTrigger asChild>
193
+ <Button
194
+ type="button"
195
+ variant="ghost"
196
+ size="icon-sm"
197
+ onClick={toggleSidebar}
198
+ aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
199
+ >
200
+ <SidebarToggleIcon className="size-4" />
201
+ </Button>
202
+ </TooltipTrigger>
203
+ <TooltipContent>
204
+ {sidebarOpen ? "Hide conversations" : "Show conversations"}
205
+ </TooltipContent>
206
+ </Tooltip>
207
+ </div>
208
+ )}
209
+ {children}
210
+ </div>
211
+ </div>
212
+ </TooltipProvider>
213
+ );
214
+ };