@dbx-tools/ui-mastra 0.6.219 → 0.6.221

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 CHANGED
@@ -121,7 +121,9 @@ lock size, `launcher={false}` when every launch comes from
121
121
  the conversation pills. With the header enabled those controls move into it and
122
122
  the conversation pills remain on their own second row. Replace `icon`, `closeIcon`,
123
123
  `newConversationIcon`, `historyIcon`, and `launcher.icon`. Below
124
- `mobileBreakpoint` the panel becomes a full overlay.
124
+ `mobileBreakpoint` the panel becomes a focus-contained modal overlay with Escape
125
+ dismissal while the covered application is inert. A `storageKey` retains
126
+ horizontal and vertical sizes independently.
125
127
 
126
128
  ## Render A Drop-In Chat
127
129
 
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.6.219";
6
+ export const PACKAGE_VERSION = "0.6.221";
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";
@@ -73,5 +73,5 @@ export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from ".
73
73
  export { modelStorageKey, readStoredModel, storeSelectedModel } from "./src/support/model-selection.ts";
74
74
  export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./src/support/request-context.ts";
75
75
  export { DEFAULT_THREAD_SESSION_KEY } from "./src/support/thread-sessions.ts";
76
- export type { SessionQueuedSteer, ThreadSession } from "./src/support/thread-sessions.ts";
76
+ export type { SessionQueuedSteer, SessionRun, ThreadSession } from "./src/support/thread-sessions.ts";
77
77
  export { THREAD_TAB_SEED_MAX } from "./src/support/thread-tabs.ts";
package/package.json CHANGED
@@ -31,12 +31,12 @@
31
31
  "react-dom": "~19.2.4"
32
32
  },
33
33
  "dependencies": {
34
- "@dbx-tools/shared-core": "0.6.219",
35
- "@dbx-tools/shared-genie": "0.6.219",
36
- "@dbx-tools/shared-mastra": "0.6.219",
37
- "@dbx-tools/shared-model": "0.6.219",
38
- "@dbx-tools/ui-appkit": "0.6.219",
39
- "@dbx-tools/ui-branding": "0.6.219",
34
+ "@dbx-tools/shared-core": "0.6.221",
35
+ "@dbx-tools/shared-genie": "0.6.221",
36
+ "@dbx-tools/shared-mastra": "0.6.221",
37
+ "@dbx-tools/shared-model": "0.6.221",
38
+ "@dbx-tools/ui-appkit": "0.6.221",
39
+ "@dbx-tools/ui-branding": "0.6.221",
40
40
  "@mastra/client-js": "1.50.0",
41
41
  "@tanstack/react-table": "^8.21.3",
42
42
  "ai": "^5.0.0",
@@ -52,7 +52,7 @@
52
52
  "publishConfig": {
53
53
  "access": "public"
54
54
  },
55
- "version": "0.6.219",
55
+ "version": "0.6.221",
56
56
  "type": "module",
57
57
  "exports": {
58
58
  "./react": "./src/react/index.ts",
@@ -135,6 +135,7 @@ const EmailApprovalPreview = ({ email }: { email: EmailApprovalInput }) => {
135
135
  * {@link ApprovalDecision} to the parent resume handler.
136
136
  */
137
137
  const ToolApprovalCard = ({
138
+ messageId,
138
139
  toolName,
139
140
  toolCallId,
140
141
  runId,
@@ -142,6 +143,7 @@ const ToolApprovalCard = ({
142
143
  onResolve,
143
144
  disabled,
144
145
  }: {
146
+ messageId: string;
145
147
  toolName: string;
146
148
  toolCallId: string;
147
149
  runId?: string;
@@ -158,9 +160,10 @@ const ToolApprovalCard = ({
158
160
  setDecision(approved ? "approved" : "denied");
159
161
  Promise.resolve(
160
162
  approved
161
- ? onResolve({ approved: true, toolName, toolCallId, runId, input })
163
+ ? onResolve({ approved: true, messageId, toolName, toolCallId, runId, input })
162
164
  : onResolve({
163
165
  approved: false,
166
+ messageId,
164
167
  toolName,
165
168
  toolCallId,
166
169
  runId,
@@ -428,6 +431,7 @@ export const AssistantBubble = ({
428
431
  {pendingApprovals.map((p) => (
429
432
  <ToolApprovalCard
430
433
  key={p.toolCallId}
434
+ messageId={message.id}
431
435
  toolName={p.toolName}
432
436
  toolCallId={p.toolCallId}
433
437
  runId={p.runId}
@@ -3,7 +3,7 @@ import { useCallback } from "react";
3
3
  import type { ThreadSessionReader, ThreadSessionUpdater } from "./chat-sessions.ts";
4
4
  import type { ApprovalDecision } from "./types.ts";
5
5
  import type { MastraPluginClient, MastraStreamResponse } from "../support/mastra-client.ts";
6
- import { DEFAULT_THREAD_SESSION_KEY } from "../support/thread-sessions.ts";
6
+ import { DEFAULT_THREAD_SESSION_KEY, type ThreadSession } from "../support/thread-sessions.ts";
7
7
 
8
8
  const logger = log.logger("ui-mastra/chat");
9
9
 
@@ -22,6 +22,35 @@ interface UseChatApprovalsOptions {
22
22
  updateSession: ThreadSessionUpdater;
23
23
  }
24
24
 
25
+ /** Resolve one approval against the exact run and assistant message that created it. */
26
+ export function resolveApprovalContinuation(
27
+ session: ThreadSession,
28
+ decision: ApprovalDecision,
29
+ ):
30
+ | {
31
+ assistantId: string;
32
+ runId: string;
33
+ requestContext: ThreadSession["runRequestContext"];
34
+ }
35
+ | undefined {
36
+ const runId = decision.runId ?? session.runId;
37
+ if (!runId) return undefined;
38
+ const run = session.runs[runId];
39
+ const assistantId =
40
+ decision.messageId ??
41
+ run?.assistantId ??
42
+ Object.entries(session.pendingApprovalsByMessage).find(([, approvals]) =>
43
+ approvals.some((approval) => approval.toolCallId === decision.toolCallId),
44
+ )?.[0] ??
45
+ session.assistantId;
46
+ if (!assistantId) return undefined;
47
+ return {
48
+ assistantId,
49
+ runId,
50
+ requestContext: run?.requestContext,
51
+ };
52
+ }
53
+
25
54
  /** Resume an approval-gated tool call and remove its pending UI state. */
26
55
  export function useChatApprovals({
27
56
  activeKey,
@@ -33,22 +62,19 @@ export function useChatApprovals({
33
62
  }: UseChatApprovalsOptions) {
34
63
  return useCallback(
35
64
  async (decision: ApprovalDecision) => {
36
- const { runId: decisionRunId, toolCallId, toolName } = decision;
65
+ const { toolCallId, toolName } = decision;
37
66
  const session = getSession(activeKey);
38
- const assistantId =
39
- Object.entries(session.pendingApprovalsByMessage).find(([, approvals]) =>
40
- approvals.some((approval) => approval.toolCallId === toolCallId),
41
- )?.[0] ?? session.assistantId;
42
- const runId = decisionRunId ?? session.runId;
43
- if (!runId || !assistantId) {
67
+ const continuation = resolveApprovalContinuation(session, decision);
68
+ if (!continuation) {
44
69
  logger.warn("approval missing runId or assistantId, cannot resume", {
45
70
  tool: toolName,
46
71
  toolCallId,
47
- hasRunId: Boolean(runId),
48
- hasAssistantId: Boolean(assistantId),
72
+ hasRunId: Boolean(decision.runId ?? session.runId),
73
+ hasAssistantId: Boolean(decision.messageId ?? session.assistantId),
49
74
  });
50
75
  return;
51
76
  }
77
+ const { assistantId, runId, requestContext } = continuation;
52
78
 
53
79
  updateSession(activeKey, (current) => {
54
80
  const existing = current.pendingApprovalsByMessage[assistantId];
@@ -79,7 +105,7 @@ export function useChatApprovals({
79
105
  runId,
80
106
  toolCallId,
81
107
  threadId: streamThreadId,
82
- requestContext: session.runRequestContext,
108
+ requestContext,
83
109
  signal,
84
110
  })
85
111
  : mastraClient.declineToolCallStream(agentId, {
@@ -87,7 +113,7 @@ export function useChatApprovals({
87
113
  toolCallId,
88
114
  threadId: streamThreadId,
89
115
  reason: decision.reason,
90
- requestContext: session.runRequestContext,
116
+ requestContext,
91
117
  signal,
92
118
  }),
93
119
  );
@@ -394,9 +394,9 @@ export const ChatComposer = ({
394
394
  >
395
395
  <SelectTrigger
396
396
  size="sm"
397
- aria-label="Model"
398
397
  className="h-7 w-auto max-w-[180px] gap-1 truncate border-0 bg-transparent px-2 text-xs shadow-none hover:bg-accent [&_svg]:size-3"
399
398
  >
399
+ <span className="sr-only">Model:</span>
400
400
  <SelectValue placeholder={defaultOptionLabel} />
401
401
  </SelectTrigger>
402
402
  <SelectContent>
@@ -430,7 +430,7 @@ export const ChatComposer = ({
430
430
  size="icon-sm"
431
431
  variant="default"
432
432
  disabled={!input.trim() || isLoadingHistory}
433
- aria-label={isRunning ? "Send now (interrupts)" : "Send message"}
433
+ aria-label={isRunning ? "Queue message" : "Send message"}
434
434
  className="shrink-0 rounded-full"
435
435
  >
436
436
  <SendIcon className="size-3" />
@@ -15,6 +15,7 @@ import {
15
15
  useCallback,
16
16
  useContext,
17
17
  useEffect,
18
+ useLayoutEffect,
18
19
  useMemo,
19
20
  useRef,
20
21
  useState,
@@ -157,6 +158,15 @@ const panelClosedClasses: Record<MastraAssistantSide, string> = {
157
158
  left: "-translate-x-full",
158
159
  };
159
160
 
161
+ const FOCUSABLE_SELECTOR = [
162
+ "a[href]",
163
+ "button:not([disabled])",
164
+ "input:not([disabled])",
165
+ "select:not([disabled])",
166
+ "textarea:not([disabled])",
167
+ '[tabindex]:not([tabindex="-1"])',
168
+ ].join(",");
169
+
160
170
  const resizeHandleClasses: Record<MastraAssistantSide, string> = {
161
171
  top: "-bottom-1 inset-x-0 h-2 cursor-row-resize",
162
172
  right: "-left-1 inset-y-0 w-2 cursor-col-resize",
@@ -167,6 +177,8 @@ const resizeHandleClasses: Record<MastraAssistantSide, string> = {
167
177
  type ResizeState = {
168
178
  pointer: number;
169
179
  size: number;
180
+ bodyCursor: string;
181
+ bodyUserSelect: string;
170
182
  } | null;
171
183
 
172
184
  /**
@@ -201,21 +213,27 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
201
213
  ...rootProps
202
214
  }: MastraAssistantProps<TValues>) {
203
215
  const rootRef = useRef<HTMLDivElement>(null);
216
+ const panelRef = useRef<HTMLElement>(null);
217
+ const previousFocusRef = useRef<HTMLElement | null>(null);
204
218
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
205
- const isOpen = available && (openProp ?? internalOpen);
219
+ const requestedOpen = openProp ?? internalOpen;
220
+ const isOpen = available && requestedOpen;
206
221
  const [launchContext, setLaunchContext] = useState<
207
222
  MastraRequestContextInput<TValues> | undefined
208
223
  >();
209
224
  const requestContext = launchContext ?? requestContextProp;
210
225
  const resizeOptions = useMemo(() => (resizable === true ? {} : resizable || {}), [resizable]);
211
226
  const vertical = sideIsVertical(side);
227
+ const sizeStorageKey = resizeOptions.storageKey
228
+ ? `${resizeOptions.storageKey}:${vertical ? "vertical" : "horizontal"}`
229
+ : undefined;
212
230
  const defaultSize = resizeOptions.defaultSize ?? (vertical ? 360 : 432);
213
- const [panelSize, setPanelSize] = useState(
214
- () => readStoredSize(resizeOptions.storageKey) ?? defaultSize,
215
- );
231
+ const [panelSize, setPanelSize] = useState(() => readStoredSize(sizeStorageKey) ?? defaultSize);
216
232
  const [availableSize, setAvailableSize] = useState(0);
217
233
  const [isNarrow, setIsNarrow] = useState(false);
218
234
  const resizeState = useRef<ResizeState>(null);
235
+ const sizeConfigRef = useRef(sizeStorageKey);
236
+ const skipSizePersistenceRef = useRef(false);
219
237
 
220
238
  const setOpen = useCallback(
221
239
  (next: boolean) => {
@@ -232,7 +250,7 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
232
250
  [setOpen],
233
251
  );
234
252
  const close = useCallback(() => setOpen(false), [setOpen]);
235
- const toggle = useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);
253
+ const toggle = useCallback(() => setOpen(!requestedOpen), [requestedOpen, setOpen]);
236
254
 
237
255
  const controller = useMemo<MastraAssistantController<TValues>>(
238
256
  () => ({
@@ -268,13 +286,76 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
268
286
  }, [mobileBreakpoint, vertical]);
269
287
 
270
288
  useEffect(() => {
271
- if (!resizeOptions.storageKey || typeof window === "undefined") return;
289
+ if (!isNarrow || !isOpen) return;
290
+ const panel = panelRef.current;
291
+ if (!panel) return;
292
+ previousFocusRef.current =
293
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
294
+ const focusFrame = window.requestAnimationFrame(() => panel.focus());
295
+ const handleKeyDown = (event: KeyboardEvent) => {
296
+ if (event.key === "Escape") {
297
+ event.preventDefault();
298
+ close();
299
+ return;
300
+ }
301
+ if (event.key !== "Tab") return;
302
+ const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
303
+ (element) => !element.hidden && element.getClientRects().length > 0,
304
+ );
305
+ if (focusable.length === 0) {
306
+ event.preventDefault();
307
+ panel.focus();
308
+ return;
309
+ }
310
+ const first = focusable[0]!;
311
+ const last = focusable.at(-1)!;
312
+ if (event.shiftKey && document.activeElement === first) {
313
+ event.preventDefault();
314
+ last.focus();
315
+ } else if (!event.shiftKey && document.activeElement === last) {
316
+ event.preventDefault();
317
+ first.focus();
318
+ }
319
+ };
320
+ document.addEventListener("keydown", handleKeyDown);
321
+ return () => {
322
+ window.cancelAnimationFrame(focusFrame);
323
+ document.removeEventListener("keydown", handleKeyDown);
324
+ const previous = previousFocusRef.current;
325
+ previousFocusRef.current = null;
326
+ window.requestAnimationFrame(() => {
327
+ window.requestAnimationFrame(() => {
328
+ if (previous?.isConnected) {
329
+ previous.focus();
330
+ } else {
331
+ rootRef.current
332
+ ?.querySelector<HTMLElement>("[data-mastra-assistant-launcher]")
333
+ ?.focus();
334
+ }
335
+ });
336
+ });
337
+ };
338
+ }, [close, isNarrow, isOpen]);
339
+
340
+ useLayoutEffect(() => {
341
+ if (sizeConfigRef.current === sizeStorageKey) return;
342
+ sizeConfigRef.current = sizeStorageKey;
343
+ skipSizePersistenceRef.current = true;
344
+ setPanelSize(readStoredSize(sizeStorageKey) ?? defaultSize);
345
+ }, [defaultSize, sizeStorageKey]);
346
+
347
+ useEffect(() => {
348
+ if (!sizeStorageKey || typeof window === "undefined") return;
349
+ if (skipSizePersistenceRef.current) {
350
+ skipSizePersistenceRef.current = false;
351
+ return;
352
+ }
272
353
  try {
273
- window.localStorage.setItem(resizeOptions.storageKey, String(panelSize));
354
+ window.localStorage.setItem(sizeStorageKey, String(panelSize));
274
355
  } catch {
275
356
  // Size persistence is best-effort; the panel remains usable without storage.
276
357
  }
277
- }, [panelSize, resizeOptions.storageKey]);
358
+ }, [panelSize, sizeStorageKey]);
278
359
 
279
360
  useEffect(() => {
280
361
  const move = (event: PointerEvent) => {
@@ -292,17 +373,21 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
292
373
  );
293
374
  };
294
375
  const finish = () => {
376
+ const current = resizeState.current;
377
+ if (!current) return;
295
378
  resizeState.current = null;
296
- document.body.style.removeProperty("user-select");
297
- document.body.style.removeProperty("cursor");
379
+ document.body.style.userSelect = current.bodyUserSelect;
380
+ document.body.style.cursor = current.bodyCursor;
298
381
  };
299
382
  window.addEventListener("pointermove", move);
300
383
  window.addEventListener("pointerup", finish);
301
384
  window.addEventListener("pointercancel", finish);
385
+ window.addEventListener("blur", finish);
302
386
  return () => {
303
387
  window.removeEventListener("pointermove", move);
304
388
  window.removeEventListener("pointerup", finish);
305
389
  window.removeEventListener("pointercancel", finish);
390
+ window.removeEventListener("blur", finish);
306
391
  finish();
307
392
  };
308
393
  }, [availableSize, resizeOptions, side, vertical]);
@@ -314,6 +399,8 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
314
399
  resizeState.current = {
315
400
  pointer: vertical ? event.clientY : event.clientX,
316
401
  size: panelSize,
402
+ bodyCursor: document.body.style.cursor,
403
+ bodyUserSelect: document.body.style.userSelect,
317
404
  };
318
405
  document.body.style.userSelect = "none";
319
406
  document.body.style.cursor = vertical ? "row-resize" : "col-resize";
@@ -414,9 +501,14 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
414
501
 
415
502
  const panel = (
416
503
  <aside
504
+ key="assistant-panel"
505
+ ref={panelRef}
506
+ role={isNarrow ? "dialog" : undefined}
507
+ aria-modal={isNarrow && isOpen ? true : undefined}
417
508
  aria-label={typeof title === "string" ? title : "Assistant"}
418
509
  aria-hidden={!isOpen}
419
510
  inert={!isOpen ? true : undefined}
511
+ tabIndex={isNarrow ? -1 : undefined}
420
512
  style={panelStyle}
421
513
  className={cn(
422
514
  "relative z-40 flex min-h-0 min-w-0 flex-col bg-background text-foreground shadow-xl",
@@ -471,6 +563,7 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
471
563
  const launchButton =
472
564
  available && !isOpen && launcherOptions ? (
473
565
  <Button
566
+ data-mastra-assistant-launcher
474
567
  type="button"
475
568
  size="icon"
476
569
  aria-label={launcherOptions.label ?? "Open assistant"}
@@ -486,7 +579,16 @@ export function MastraAssistant<TValues extends Record<string, unknown> = Record
486
579
  </Button>
487
580
  ) : null;
488
581
 
489
- const content = <div className={cn("min-h-0 min-w-0 flex-1", contentClassName)}>{children}</div>;
582
+ const content = (
583
+ <div
584
+ key="assistant-content"
585
+ aria-hidden={isNarrow && isOpen ? true : undefined}
586
+ inert={isNarrow && isOpen ? true : undefined}
587
+ className={cn("min-h-0 min-w-0 flex-1", contentClassName)}
588
+ >
589
+ {children}
590
+ </div>
591
+ );
490
592
  const rootDirection = vertical ? "flex-col" : "flex-row";
491
593
  const panelFirst = side === "top" || side === "left";
492
594
 
@@ -477,6 +477,13 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
477
477
  runId,
478
478
  runRequestContext: requestContext,
479
479
  lastRequestContext: requestContext,
480
+ runs: {
481
+ ...session.runs,
482
+ [runId]: {
483
+ assistantId,
484
+ ...(requestContext ? { requestContext } : {}),
485
+ },
486
+ },
480
487
  }));
481
488
  // Capture this run's thread + model at send time and pass them per
482
489
  // call, so a run keeps its own routing even if the user switches
@@ -352,6 +352,8 @@ export type ApprovalDecision =
352
352
  approved: true;
353
353
  toolName: string;
354
354
  toolCallId: string;
355
+ /** Assistant UI message that owns the approval card. */
356
+ messageId?: string;
355
357
  /**
356
358
  * Mastra run id required to resume the suspended workflow. Native
357
359
  * suspended-run discovery restores it for persisted approvals.
@@ -363,6 +365,8 @@ export type ApprovalDecision =
363
365
  approved: false;
364
366
  toolName: string;
365
367
  toolCallId: string;
368
+ /** Assistant UI message that owns the approval card. */
369
+ messageId?: string;
366
370
  runId?: string;
367
371
  input: unknown;
368
372
  reason: string;
@@ -9,6 +9,12 @@ export type SessionQueuedSteer = QueuedSteer & {
9
9
  requestContext?: MastraRequestContextSnapshot;
10
10
  };
11
11
 
12
+ /** UI target and immutable application context captured for one Mastra run. */
13
+ export type SessionRun = {
14
+ assistantId: string;
15
+ requestContext?: MastraRequestContextSnapshot;
16
+ };
17
+
12
18
  /** Session-scoped transcript + stream state for one conversation thread. */
13
19
  export type ThreadSession = {
14
20
  messages: UIMessage[];
@@ -20,6 +26,8 @@ export type ThreadSession = {
20
26
  runToken: number;
21
27
  assistantId: string | null;
22
28
  runId: string | null;
29
+ /** Live runs retained by id so older approval cards resume their own context. */
30
+ runs: Record<string, SessionRun>;
23
31
  historyLoaded: boolean;
24
32
  hasMoreHistory: boolean;
25
33
  historyPage: number;
@@ -45,6 +53,7 @@ export function createThreadSession(): ThreadSession {
45
53
  runToken: 0,
46
54
  assistantId: null,
47
55
  runId: null,
56
+ runs: {},
48
57
  historyLoaded: false,
49
58
  hasMoreHistory: false,
50
59
  historyPage: 0,