@dbx-tools/ui-mastra 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -26,19 +26,19 @@
26
26
  "shiki": "^3.0.0",
27
27
  "sql-formatter": "^15.6.9",
28
28
  "streamdown": "^2.5.0",
29
- "@dbx-tools/shared-core": "0.3.8",
30
- "@dbx-tools/shared-mastra": "0.3.8",
31
- "@dbx-tools/shared-model": "0.3.8",
32
- "@dbx-tools/ui-appkit": "0.3.8",
33
- "@dbx-tools/shared-genie": "0.3.8",
34
- "@dbx-tools/ui-branding": "0.3.8"
29
+ "@dbx-tools/shared-genie": "0.3.9",
30
+ "@dbx-tools/shared-mastra": "0.3.9",
31
+ "@dbx-tools/shared-model": "0.3.9",
32
+ "@dbx-tools/ui-appkit": "0.3.9",
33
+ "@dbx-tools/shared-core": "0.3.9",
34
+ "@dbx-tools/ui-branding": "0.3.9"
35
35
  },
36
36
  "main": "index.ts",
37
37
  "license": "UNLICENSED",
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "0.3.8",
41
+ "version": "0.3.9",
42
42
  "types": "index.ts",
43
43
  "type": "module",
44
44
  "exports": {
@@ -47,7 +47,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
47
47
  import { AssistantBubble, UserBubble } from "./bubbles";
48
48
  import { ExportMenu } from "./export-menu";
49
49
  import { SuggestionPills } from "./suggestion-pills";
50
- import { ThreadSidebar } from "./thread-sidebar";
50
+ import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar";
51
51
  import type { ChatViewProps } from "./types";
52
52
 
53
53
  // Controlled, presentational chat shell: the scroll container, header
@@ -247,11 +247,10 @@ export const ChatView = ({
247
247
 
248
248
  const handleSubmit = (e: React.FormEvent) => {
249
249
  e.preventDefault();
250
- // Don't queue a new turn while one is streaming - the Enter-key
251
- // path would otherwise bypass the disabled Send button.
252
- if (isRunning) return;
253
250
  const text = input.trim();
254
251
  if (!text) return;
252
+ // Submitting while a turn streams is a steer: the driver hands the text
253
+ // to the live run (or interrupts + resends). Idle submits start a turn.
255
254
  sendMessage({ text });
256
255
  setInput("");
257
256
  // Sending is an explicit "I want to see the response" action, so
@@ -360,6 +359,23 @@ export const ChatView = ({
360
359
  const showHeader = showSidebarToggle;
361
360
  const showComposerToolbar = showModelDisplay || showExport || showClear;
362
361
 
362
+ // Props shared by the mobile drawer and the desktop inline sidebar - the two
363
+ // render the SAME `ThreadSidebar`, differing only in framing (overlay vs.
364
+ // inline) and, on mobile, closing the drawer after select / new. Building
365
+ // the prop bag once keeps the two call sites from drifting.
366
+ const sidebarProps: ThreadSidebarProps = {
367
+ threads: threads ?? [],
368
+ ...(activeThreadId ? { activeThreadId } : {}),
369
+ streamingThreadIds,
370
+ isLoading: isLoadingThreads,
371
+ onSelect: (id) => onSelectThread?.(id),
372
+ onHide: toggleSidebar,
373
+ ...(onNewThread ? { onNew: onNewThread } : {}),
374
+ ...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
375
+ ...(onRenameThread ? { onRename: onRenameThread } : {}),
376
+ ...(onCancelThread ? { onCancel: onCancelThread } : {}),
377
+ };
378
+
363
379
  // Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
364
380
  // in-flight flag so the DELETE can't be double-fired. `clearing` disables
365
381
  // the confirm action while `onClear` runs; the dialog closes on settle.
@@ -395,9 +411,9 @@ export const ChatView = ({
395
411
  /*
396
412
  * Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
397
413
  * the conversation list never eats horizontal space from the chat
398
- * on a phone. Selecting a thread / starting a new one closes it so
399
- * the transcript comes back into view. Session-only + default
400
- * closed (see `mobileDrawerOpen`).
414
+ * on a phone. Selecting a thread / starting a new one also closes
415
+ * the drawer so the transcript comes back into view. Session-only
416
+ * + default closed (see `mobileDrawerOpen`).
401
417
  */
402
418
  mobileDrawerOpen && (
403
419
  <div className="fixed inset-0 z-40 flex">
@@ -407,10 +423,7 @@ export const ChatView = ({
407
423
  aria-hidden="true"
408
424
  />
409
425
  <ThreadSidebar
410
- threads={threads ?? []}
411
- {...(activeThreadId ? { activeThreadId } : {})}
412
- streamingThreadIds={streamingThreadIds}
413
- isLoading={isLoadingThreads}
426
+ {...sidebarProps}
414
427
  onSelect={(id) => {
415
428
  onSelectThread?.(id);
416
429
  toggleSidebar();
@@ -423,10 +436,6 @@ export const ChatView = ({
423
436
  },
424
437
  }
425
438
  : {})}
426
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
427
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
428
- {...(onCancelThread ? { onCancel: onCancelThread } : {})}
429
- onHide={toggleSidebar}
430
439
  className="relative z-10 w-[85vw] max-w-xs shadow-xl"
431
440
  />
432
441
  </div>
@@ -434,22 +443,11 @@ export const ChatView = ({
434
443
  ) : (
435
444
  /*
436
445
  * Desktop: an inline flex child sharing the row with the chat
437
- * column, using the persisted open/hide preference.
446
+ * column, using the persisted open/hide preference. Same
447
+ * `sidebarProps` as mobile - only the framing + close-on-select
448
+ * differ.
438
449
  */
439
- desktopSidebarOpen && (
440
- <ThreadSidebar
441
- threads={threads ?? []}
442
- {...(activeThreadId ? { activeThreadId } : {})}
443
- streamingThreadIds={streamingThreadIds}
444
- isLoading={isLoadingThreads}
445
- onSelect={(id) => onSelectThread?.(id)}
446
- {...(onNewThread ? { onNew: onNewThread } : {})}
447
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
448
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
449
- {...(onCancelThread ? { onCancel: onCancelThread } : {})}
450
- onHide={toggleSidebar}
451
- />
452
- )
450
+ desktopSidebarOpen && <ThreadSidebar {...sidebarProps} />
453
451
  ))}
454
452
  <div className="flex h-full min-w-0 flex-1 flex-col">
455
453
  {showHeader && (
@@ -628,7 +626,8 @@ export const ChatView = ({
628
626
  className="max-h-48 text-base md:text-sm"
629
627
  />
630
628
  <InputGroupAddon align="inline-end">
631
- {isRunning && onStop ? (
629
+ {isRunning && onStop && !input.trim() ? (
630
+ // Running with an empty composer: the button stops the turn.
632
631
  <InputGroupButton
633
632
  type="button"
634
633
  size="icon-sm"
@@ -639,18 +638,16 @@ export const ChatView = ({
639
638
  <SquareIcon className="size-3 fill-current" />
640
639
  </InputGroupButton>
641
640
  ) : (
641
+ // Idle, or running with typed text: the button sends. A send
642
+ // mid-run steers the live turn (see the driver's sendMessage).
642
643
  <InputGroupButton
643
644
  type="submit"
644
645
  size="icon-sm"
645
646
  variant="default"
646
- disabled={!input.trim() || isRunning}
647
- aria-label="Send message"
647
+ disabled={!input.trim()}
648
+ aria-label={isRunning ? "Steer response" : "Send message"}
648
649
  >
649
- {isRunning ? (
650
- <Spinner className="size-3" />
651
- ) : (
652
- <SendIcon className="size-3" />
653
- )}
650
+ <SendIcon className="size-3" />
654
651
  </InputGroupButton>
655
652
  )}
656
653
  </InputGroupAddon>
@@ -21,6 +21,7 @@ import {
21
21
  DEFAULT_THREAD_SESSION_KEY,
22
22
  isSessionRunning,
23
23
  sessionKey,
24
+ terminateRunningToolEvents,
24
25
  type ThreadSession,
25
26
  } from "../support/thread-sessions";
26
27
  import { ChatView } from "./chat-view";
@@ -710,6 +711,9 @@ export const useMastraChat = (
710
711
  error: null,
711
712
  status: "submitted",
712
713
  runToken: token,
714
+ // Superseding an in-flight run (a steer that interrupts, or a rapid
715
+ // re-send) stops its stream, so close any pills it left running.
716
+ toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
713
717
  };
714
718
  });
715
719
  const runIdRef = { current: getSession(threadId).runId };
@@ -798,6 +802,9 @@ export const useMastraChat = (
798
802
  runToken: session.runToken + 1,
799
803
  error: null,
800
804
  status: "ready",
805
+ // Cancelling stops the stream, so close any tool pills still marked
806
+ // running - the closing chunks will never arrive.
807
+ toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
801
808
  };
802
809
  });
803
810
  },
@@ -885,8 +892,30 @@ export const useMastraChat = (
885
892
  }
886
893
  }
887
894
  }
888
- const next = [...getSession(threadId).messages, makeUserMessage(text)];
895
+ const before = getSession(threadId);
896
+ const next = [...before.messages, makeUserMessage(text)];
889
897
  writeMessages(threadId, next);
898
+ // Steering: if a turn is already streaming on this thread, try to hand
899
+ // the message to the live run so the agent folds it into the current
900
+ // turn (no restart). If the server won't deliver it, fall back to
901
+ // interrupting the run and starting a fresh turn with the message
902
+ // included, so a steer never silently drops.
903
+ if (isSessionRunning(before) && before.runId) {
904
+ const runId = before.runId;
905
+ const steerThreadId =
906
+ threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
907
+ void mastraClient
908
+ .queueMessage({ agentId, runId, threadId: steerThreadId, message: text })
909
+ .then((accepted) => {
910
+ if (accepted) {
911
+ logger.info("steer:queued", { runId });
912
+ return;
913
+ }
914
+ logger.info("steer:fallback-interrupt", { runId });
915
+ void runStream(threadId, getSession(threadId).messages);
916
+ });
917
+ return;
918
+ }
890
919
  void runStream(threadId, next);
891
920
  },
892
921
  [
@@ -895,6 +924,8 @@ export const useMastraChat = (
895
924
  activeThreadId,
896
925
  activeKey,
897
926
  getSession,
927
+ mastraClient,
928
+ agentId,
898
929
  noteThreadActivity,
899
930
  updateSession,
900
931
  ],
@@ -123,6 +123,50 @@ export class MastraPluginClient extends MastraClient {
123
123
  return asMastraStreamResponse(response);
124
124
  }
125
125
 
126
+ /**
127
+ * Steer a live run by handing it another message mid-turn ("queue"), via
128
+ * the agent `/queue-message` route. `behavior` controls what happens when
129
+ * the run is active: `deliver` (default) folds the message into the current
130
+ * turn so the agent picks it up without restarting; `persist` holds it for
131
+ * the next turn; `discard` drops it. Routed per call (own thread header, no
132
+ * shared client state) like {@link streamAgent}.
133
+ *
134
+ * Resolves `true` when the server accepted the message for delivery, `false`
135
+ * otherwise (unknown run / route unsupported / non-2xx) - never throws for a
136
+ * rejected steer, so the caller can fall back to interrupting + resending.
137
+ * Queue / signal routes are `@experimental` in Mastra.
138
+ */
139
+ async queueMessage(params: {
140
+ agentId: string;
141
+ runId: string;
142
+ threadId?: string;
143
+ message: string;
144
+ behavior?: "deliver" | "persist" | "discard";
145
+ }): Promise<boolean> {
146
+ const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/queue-message`;
147
+ try {
148
+ const response = await fetch(url, {
149
+ method: "POST",
150
+ credentials: "include",
151
+ headers: {
152
+ "Content-Type": "application/json",
153
+ ...this.#routingHeaders({ threadId: params.threadId }),
154
+ },
155
+ body: JSON.stringify({
156
+ runId: params.runId,
157
+ ...(params.threadId ? { threadId: params.threadId } : {}),
158
+ message: params.message,
159
+ ifActive: { behavior: params.behavior ?? "deliver" },
160
+ }),
161
+ });
162
+ if (!response.ok) return false;
163
+ const body = (await response.json().catch(() => null)) as { accepted?: boolean } | null;
164
+ return body?.accepted === true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
126
170
  /**
127
171
  * Resume a suspended `requireApproval` tool via `approve-tool-call`.
128
172
  * Reads SSE directly instead of `agent.approveToolCall()` so the
@@ -50,6 +50,28 @@ export function isSessionRunning(session: ThreadSession): boolean {
50
50
  return session.status === "submitted" || session.status === "streaming";
51
51
  }
52
52
 
53
+ /**
54
+ * Settle any tool-progress pills still marked `running` to `done`. A cancelled
55
+ * or interrupted turn stops delivering the `tool-result` / `tool-error` chunks
56
+ * that would otherwise close them, so without this a Genie / chart pill would
57
+ * spin forever after the user hits stop. Returns the same map when nothing was
58
+ * running so callers can skip a needless state update.
59
+ */
60
+ export function terminateRunningToolEvents(
61
+ toolEventsByMessage: Record<string, ToolEvent[]>,
62
+ ): Record<string, ToolEvent[]> {
63
+ let changed = false;
64
+ const next: Record<string, ToolEvent[]> = {};
65
+ for (const [messageId, events] of Object.entries(toolEventsByMessage)) {
66
+ next[messageId] = events.map((event) => {
67
+ if (event.status !== "running") return event;
68
+ changed = true;
69
+ return { ...event, status: "done" as const };
70
+ });
71
+ }
72
+ return changed ? next : toolEventsByMessage;
73
+ }
74
+
53
75
  export function sessionKey(activeThreadId: string | undefined): string {
54
76
  return activeThreadId ?? DEFAULT_THREAD_SESSION_KEY;
55
77
  }