@dbx-tools/ui-mastra 0.9.99 → 0.9.105

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
@@ -2,11 +2,21 @@
2
2
 
3
3
  React chat UI for the AppKit-Mastra plugin.
4
4
 
5
- Import this package when a Databricks App needs a production-ready chat surface
6
- for [`@dbx-tools/appkit-mastra`](../../node/appkit-mastra): streaming
7
- assistant responses, model selection, Genie progress events, inline charts/data
8
- tables, tool approvals, conversation history, thread management, export, and
9
- MLflow feedback.
5
+ Add a complete chat surface for
6
+ [`@dbx-tools/appkit-mastra`](../../node/appkit-mastra), including streaming,
7
+ model selection, Genie progress, charts, tables, approvals, history, threads,
8
+ export, and MLflow feedback.
9
+
10
+ ## Quick Start
11
+
12
+ ```tsx
13
+ import { MastraChat } from "@dbx-tools/ui-mastra/react";
14
+ import "@dbx-tools/ui-mastra/styles.css";
15
+
16
+ export function Assistant() {
17
+ return <MastraChat showModelPicker enableExport />;
18
+ }
19
+ ```
10
20
 
11
21
  Key features:
12
22
 
@@ -47,7 +57,7 @@ Key features:
47
57
  - Export menu for PDF and Markdown, resolving charts and tables so
48
58
  exported conversations remain useful offline.
49
59
 
50
- ## Why Not Just AppKit UI?
60
+ ## Choose This UI Or AppKit UI
51
61
 
52
62
  Use native `@databricks/appkit-ui` when you need its general primitives, Genie
53
63
  chat component, or Model Serving hooks directly against native AppKit plugins.
@@ -69,7 +79,7 @@ understand Mastra-specific behavior:
69
79
  has no steering.
70
80
  - Conversation export that resolves those embeds into Markdown or PDF.
71
81
 
72
- ## Add The Styles
82
+ ## Stylesheet Setup
73
83
 
74
84
  ```css
75
85
  @import "@databricks/appkit-ui/styles.css";
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.99";
6
+ export const PACKAGE_VERSION = "0.9.105";
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";
@@ -71,7 +71,7 @@ export type { ChartChrome } from "./src/support/chart-theme.ts";
71
71
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export.ts";
72
72
  export { MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./src/support/mastra-client.ts";
73
73
  export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from "./src/support/mastra-client.ts";
74
- export { modelStorageKey, readStoredModel, storeSelectedModel } from "./src/support/model-selection.ts";
74
+ export { defaultReasoningEffort, modelStorageKey, readStoredModel, storeSelectedModel } from "./src/support/model-selection.ts";
75
75
  export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./src/support/request-context.ts";
76
76
  export { DEFAULT_THREAD_SESSION_KEY } from "./src/support/thread-sessions.ts";
77
77
  export type { SessionQueuedSteer, SessionRun, ThreadSession } from "./src/support/thread-sessions.ts";
package/package.json CHANGED
@@ -29,15 +29,15 @@
29
29
  "react-dom": "~19.2.4"
30
30
  },
31
31
  "dependencies": {
32
- "@dbx-tools/shared-core": "^0.9.99",
33
- "@dbx-tools/shared-genie": "^0.9.99",
34
- "@dbx-tools/shared-mastra": "^0.9.99",
35
- "@dbx-tools/shared-model": "^0.9.99",
36
- "@dbx-tools/ui": "^0.9.99",
32
+ "@dbx-tools/shared-core": "^0.9.105",
33
+ "@dbx-tools/shared-genie": "^0.9.105",
34
+ "@dbx-tools/shared-mastra": "^0.9.105",
35
+ "@dbx-tools/shared-model": "^0.9.105",
36
+ "@dbx-tools/ui": "^0.9.105",
37
37
  "@mastra/client-js": "1.50.0",
38
38
  "@mastra/core": "1.71.0",
39
39
  "@tanstack/react-table": "^8.21.3",
40
- "ai": "^5.0.0",
40
+ "ai": "^7.0.0",
41
41
  "echarts": "^6.0.0",
42
42
  "echarts-for-react": "^3.0.2",
43
43
  "lucide-react": "^0.554.0",
@@ -50,7 +50,7 @@
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
- "version": "0.9.99",
53
+ "version": "0.9.105",
54
54
  "dbxToolsConfig": {
55
55
  "tags": [
56
56
  "ui"
@@ -349,11 +349,17 @@ export const AssistantBubble = ({
349
349
  feedbackValue,
350
350
  mlflowExperimentUrl,
351
351
  }: AssistantBubbleProps) => {
352
- const toolEvents = mergeToolEvents(
353
- toolEventsFromParts(message.parts),
354
- events,
355
- isLast && status === "error",
352
+ const pendingApprovals = mergePendingApprovals(
353
+ collectPendingApprovals(message.parts),
354
+ externalApprovals,
356
355
  );
356
+ const terminalToolState =
357
+ isLast && status === "error"
358
+ ? "error"
359
+ : pendingApprovals.length === 0 && (!isLast || status === "ready")
360
+ ? "done"
361
+ : undefined;
362
+ const toolEvents = mergeToolEvents(toolEventsFromParts(message.parts), events, terminalToolState);
357
363
  const reasoning = [getReasoningText(message.parts), genieReasoningText(toolEvents)]
358
364
  .filter(Boolean)
359
365
  .join("\n\n");
@@ -397,13 +403,8 @@ export const AssistantBubble = ({
397
403
  // {@link DataSlot} inline; unknown / expired ids resolve as
398
404
  // nothing so the prose flows unaffected. Suggested questions
399
405
  // stay gated on settle to avoid pop-in mid-stream.
400
- const pendingApprovals = mergePendingApprovals(
401
- collectPendingApprovals(message.parts),
402
- externalApprovals,
403
- );
404
-
405
406
  return (
406
- <Item className="items-start gap-3 border-none bg-transparent p-0">
407
+ <Item className="items-start gap-2.5 border-none bg-transparent p-0">
407
408
  <ItemMedia>
408
409
  <RoleAvatar role="assistant" />
409
410
  </ItemMedia>
@@ -413,7 +414,7 @@ export const AssistantBubble = ({
413
414
  * bubble past the chat's `max-w-4xl`. Flex children default to
414
415
  * `min-width: auto`, which sizes to content and overflows.
415
416
  */}
416
- <ItemContent className="min-w-0 gap-2">
417
+ <ItemContent className="min-w-0 gap-1.5">
417
418
  {/*
418
419
  * Tool session pill leads the message. Charts and tables are
419
420
  * embedded inline in the prose (via `[chart:<id>]` / `[data:<id>]`
@@ -535,7 +536,7 @@ export const UserBubble = ({ message }: { message: UIMessage }) => {
535
536
  .map((p) => p.text)
536
537
  .join("");
537
538
  return (
538
- <Item className="items-start gap-3 border-none bg-transparent p-0">
539
+ <Item className="items-start gap-2.5 border-none bg-transparent p-0">
539
540
  <ItemMedia>
540
541
  <RoleAvatar role="user" />
541
542
  </ItemMedia>
@@ -353,7 +353,7 @@ export const ChatComposer = ({
353
353
  }}
354
354
  placeholder="Send a message..."
355
355
  rows={1}
356
- className="field-sizing-fixed min-h-10 max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-xs"
356
+ className="field-sizing-fixed min-h-10 max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-base md:text-xs"
357
357
  />
358
358
  <InputGroupAddon
359
359
  align="block-end"
@@ -1,7 +1,7 @@
1
1
  import { errorUtils, log, stringUtils } from "@dbx-tools/shared-core";
2
2
  import { feedback } from "@dbx-tools/shared-mastra";
3
3
  import { isStaleMastraResumeError } from "@dbx-tools/shared-mastra/resume";
4
- import { readUIMessageStream, type UIMessage } from "ai";
4
+ import { readUIMessageStream, type UIMessage, type UIMessageChunk } from "ai";
5
5
  import { useCallback } from "react";
6
6
  import type {
7
7
  ThreadMessageWriter,
@@ -20,6 +20,43 @@ const readMlflowTraceId = (stream: unknown): string | undefined => {
20
20
 
21
21
  class StreamAborted extends Error {}
22
22
 
23
+ /** Stop reading a background run once the AI SDK has delivered its terminal chunk. */
24
+ export function closeOnTerminalChunk(
25
+ stream: ReadableStream<UIMessageChunk>,
26
+ ): ReadableStream<UIMessageChunk> {
27
+ let reader: ReadableStreamDefaultReader<UIMessageChunk> | undefined;
28
+ let cancelled = false;
29
+ return new ReadableStream<UIMessageChunk>({
30
+ async start(controller) {
31
+ reader = stream.getReader();
32
+ try {
33
+ while (true) {
34
+ const { done, value } = await reader.read();
35
+ if (done) {
36
+ controller.close();
37
+ return;
38
+ }
39
+ controller.enqueue(value);
40
+ if (value.type === "finish" || value.type === "abort") {
41
+ controller.close();
42
+ await reader.cancel("terminal AI SDK chunk received").catch(() => undefined);
43
+ return;
44
+ }
45
+ }
46
+ } catch (error) {
47
+ if (!cancelled) controller.error(error);
48
+ } finally {
49
+ reader.releaseLock();
50
+ reader = undefined;
51
+ }
52
+ },
53
+ cancel(reason) {
54
+ cancelled = true;
55
+ return reader?.cancel(reason);
56
+ },
57
+ });
58
+ }
59
+
23
60
  /** Replace or append one assistant message without disturbing the transcript. */
24
61
  function upsertAssistant(messages: UIMessage[], assistant: UIMessage): UIMessage[] {
25
62
  const next = [...messages];
@@ -69,7 +106,7 @@ export function useChatStream({ getSession, updateSession, writeMessages }: UseC
69
106
  );
70
107
  for await (const message of readUIMessageStream({
71
108
  ...(existing ? { message: existing } : {}),
72
- stream: stream.stream,
109
+ stream: closeOnTerminalChunk(stream.stream),
73
110
  terminateOnError: true,
74
111
  })) {
75
112
  if (signal.aborted) throw new StreamAborted();
@@ -219,7 +219,7 @@ export const ChatTranscript = ({
219
219
  ) : (
220
220
  <div
221
221
  ref={controller.contentRef}
222
- className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-4 py-4 md:px-6"
222
+ className="mx-auto flex w-full max-w-4xl flex-col gap-3 px-4 py-3 md:px-6"
223
223
  >
224
224
  {(isLoadingMore || isLoadingHistory) && (
225
225
  <div className="flex items-center justify-center gap-2 py-1 text-xs text-muted-foreground">
@@ -258,6 +258,8 @@ export const AssistantMarkdown = ({
258
258
  isAnimating={animate}
259
259
  className={cn(
260
260
  "prose prose-sm dark:prose-invert max-w-none break-words",
261
+ "leading-relaxed prose-p:my-1.5 prose-p:leading-relaxed",
262
+ "prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5",
261
263
  "prose-headings:font-semibold prose-headings:tracking-tight",
262
264
  "prose-h1:text-lg prose-h1:mt-4 prose-h1:mb-2",
263
265
  "prose-h2:text-base prose-h2:mt-4 prose-h2:mb-2",
@@ -288,13 +290,13 @@ export const ToolMarkdown = ({ children }: { children: string }) => (
288
290
  controls={false}
289
291
  className={cn(
290
292
  "prose prose-sm dark:prose-invert max-w-none break-words",
291
- "text-[11px] leading-snug text-muted-foreground",
293
+ "text-xs leading-snug text-muted-foreground",
292
294
  "prose-p:my-0.5 prose-p:leading-snug",
293
295
  "prose-ul:my-0.5 prose-ul:pl-4 prose-ol:my-0.5 prose-ol:pl-4",
294
296
  "prose-li:my-0 prose-li:leading-snug prose-li:marker:text-muted-foreground/60",
295
297
  "prose-headings:my-1 prose-headings:text-xs prose-headings:font-semibold",
296
298
  "prose-strong:text-foreground/90 prose-strong:font-medium",
297
- "prose-code:text-[10px] prose-code:font-medium",
299
+ "prose-code:text-xs prose-code:font-medium",
298
300
  )}
299
301
  >
300
302
  {children}
@@ -384,11 +386,11 @@ export const SqlBlock = ({ sql }: { sql: string }) => {
384
386
  value={formatted}
385
387
  className="absolute right-1.5 top-1.5 z-10 bg-background/70 opacity-0 backdrop-blur transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
386
388
  />
387
- <pre className="max-w-full overflow-x-auto rounded-md border border-border bg-background p-3 font-mono text-[11px] leading-relaxed">
389
+ <pre className="max-w-full overflow-x-auto rounded-md border border-border bg-background p-3 font-mono text-xs leading-relaxed">
388
390
  {html === null ? (
389
391
  <code>{formatted}</code>
390
392
  ) : (
391
- <code dangerouslySetInnerHTML={{ __html: html }} />
393
+ <code data-dbx-highlighted-code dangerouslySetInnerHTML={{ __html: html }} />
392
394
  )}
393
395
  </pre>
394
396
  </div>
@@ -404,11 +406,15 @@ export const JsonBlock = ({ json, className }: { json: string; className?: strin
404
406
  return (
405
407
  <pre
406
408
  className={cn(
407
- "max-w-full overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed",
409
+ "max-w-full overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed",
408
410
  className,
409
411
  )}
410
412
  >
411
- {html === null ? <code>{json}</code> : <code dangerouslySetInnerHTML={{ __html: html }} />}
413
+ {html === null ? (
414
+ <code>{json}</code>
415
+ ) : (
416
+ <code data-dbx-highlighted-code dangerouslySetInnerHTML={{ __html: html }} />
417
+ )}
412
418
  </pre>
413
419
  );
414
420
  };
@@ -24,6 +24,7 @@ import {
24
24
  type MastraStreamResponse,
25
25
  } from "../support/mastra-client.ts";
26
26
  import {
27
+ defaultReasoningEffort,
27
28
  modelStorageKey,
28
29
  readStoredModel,
29
30
  storeSelectedModel,
@@ -368,11 +369,14 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
368
369
  const activeModel = model || defaultModelId || undefined;
369
370
  const activeModelOption = models.find((option) => option.name === activeModel);
370
371
  const reasoningEfforts = activeModelOption?.reasoningEfforts ?? EMPTY_REASONING_EFFORTS;
372
+ const reasoningDefault = defaultReasoningEffort(reasoningEfforts);
373
+ const reasoningModelKey = `${activeModel ?? ""}:${reasoningEfforts.join(",")}`;
374
+ const initializedReasoningModelRef = useRef<string | undefined>(undefined);
371
375
  useEffect(() => {
372
- if (reasoningEffort && !reasoningEfforts.includes(reasoningEffort)) {
373
- setReasoningEffort(undefined);
374
- }
375
- }, [reasoningEffort, reasoningEfforts]);
376
+ if (initializedReasoningModelRef.current === reasoningModelKey) return;
377
+ initializedReasoningModelRef.current = reasoningModelKey;
378
+ setReasoningEffort(reasoningDefault);
379
+ }, [reasoningDefault, reasoningModelKey]);
376
380
  // Starter suggestions: an explicit `options.suggestions` always
377
381
  // wins (including `[]` to force none) and is rendered verbatim;
378
382
  // otherwise auto-source the agent's Genie space sample questions.
@@ -450,8 +454,42 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
450
454
  });
451
455
  const runIdRef = { current: getSession(threadId).runId };
452
456
  try {
453
- const stream = await open(controller.signal);
454
- await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
457
+ try {
458
+ const stream = await open(controller.signal);
459
+ await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
460
+ } catch (initialError) {
461
+ const runId = runIdRef.current;
462
+ if (
463
+ controller.signal.aborted ||
464
+ getSession(threadId).runToken !== token ||
465
+ !mastraClient.backgroundTurns ||
466
+ !runId
467
+ ) {
468
+ throw initialError;
469
+ }
470
+ logger.warn("chat stream disconnected; reconnecting to background run", {
471
+ runId,
472
+ error: errorUtils.errorMessage(initialError),
473
+ });
474
+ const messagesBeforeReplay = getSession(threadId).messages;
475
+ writeMessages(
476
+ threadId,
477
+ messagesBeforeReplay.filter((message) => message.id !== assistantId),
478
+ );
479
+ const streamThreadId = threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
480
+ try {
481
+ const stream = await mastraClient.observeAgentStream({
482
+ agentId,
483
+ runId,
484
+ threadId: streamThreadId,
485
+ signal: controller.signal,
486
+ });
487
+ await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
488
+ } catch (reconnectError) {
489
+ writeMessages(threadId, messagesBeforeReplay);
490
+ throw reconnectError;
491
+ }
492
+ }
455
493
  updateSession(threadId, (session) => {
456
494
  if (session.runToken !== token) return session;
457
495
  return {
@@ -500,7 +538,15 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
500
538
  }));
501
539
  }
502
540
  },
503
- [getSession, processStream, refreshThreadsSoon, updateSession],
541
+ [
542
+ agentId,
543
+ getSession,
544
+ mastraClient,
545
+ processStream,
546
+ refreshThreadsSoon,
547
+ updateSession,
548
+ writeMessages,
549
+ ],
504
550
  );
505
551
 
506
552
  const runStream = useCallback(
@@ -567,9 +613,26 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
567
613
  const stop = useCallback(
568
614
  (threadId?: string) => {
569
615
  const key = threadId ?? activeKey;
616
+ const current = getSession(key);
617
+ if (!isSessionRunning(current)) return;
618
+ const streamThreadId = key === DEFAULT_THREAD_SESSION_KEY ? undefined : key;
619
+ if (mastraClient.backgroundTurns && current.runId) {
620
+ void mastraClient
621
+ .abortAgentRun({
622
+ agentId,
623
+ runId: current.runId,
624
+ threadId: streamThreadId,
625
+ })
626
+ .catch((error: unknown) => {
627
+ logger.warn("failed to abort background run", {
628
+ runId: current.runId,
629
+ error: errorUtils.errorMessage(error),
630
+ });
631
+ });
632
+ }
633
+ current.abortController?.abort();
570
634
  updateSession(key, (session) => {
571
635
  if (!isSessionRunning(session)) return session;
572
- session.abortController?.abort();
573
636
  return {
574
637
  ...session,
575
638
  abortController: null,
@@ -579,7 +642,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
579
642
  };
580
643
  });
581
644
  },
582
- [activeKey, updateSession],
645
+ [activeKey, agentId, getSession, mastraClient, updateSession],
583
646
  );
584
647
 
585
648
  const handleApproval = useChatApprovals({
@@ -277,7 +277,7 @@ const MessageGroupBody = ({
277
277
  return (
278
278
  <div className="flex flex-col gap-1.5">
279
279
  {!omitQuestion && group.question && (
280
- <div className="rounded border-l-2 border-primary/40 bg-background/40 px-2 py-1 text-[11px] italic leading-snug text-muted-foreground">
280
+ <div className="rounded border-l-2 border-primary/40 bg-background/40 px-2 py-1 text-xs italic leading-snug text-muted-foreground">
281
281
  {group.question}
282
282
  </div>
283
283
  )}
@@ -290,13 +290,11 @@ const MessageGroupBody = ({
290
290
  // for the same reason: code is the heaviest content here,
291
291
  // and most readers only want a glance.
292
292
  <Collapsible key={bucket.key} className="rounded border border-border/60 bg-background/40">
293
- <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] uppercase tracking-wide text-muted-foreground hover:text-foreground">
293
+ <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
294
294
  <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
295
295
  <span>{queryBuckets.length > 1 ? `Query ${i + 1}` : "Query"}</span>
296
296
  {bucket.query?.title ? (
297
- <span className="min-w-0 flex-1 truncate normal-case text-muted-foreground/70">
298
- {bucket.query.title}
299
- </span>
297
+ <span className="min-w-0 flex-1 truncate normal-case">{bucket.query.title}</span>
300
298
  ) : null}
301
299
  </CollapsibleTrigger>
302
300
  <CollapsibleContent>
@@ -387,7 +385,7 @@ export function formatRawToolPayload(value: unknown): string {
387
385
 
388
386
  const RawToolPayload = ({ label, value }: { label: "Request" | "Response"; value: unknown }) => (
389
387
  <Collapsible className="rounded border border-border/60 bg-background/40">
390
- <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] uppercase tracking-wide text-muted-foreground hover:text-foreground">
388
+ <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
391
389
  <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
392
390
  <span>{label}</span>
393
391
  </CollapsibleTrigger>
@@ -458,7 +456,7 @@ const WebSearchProgressDetails = ({ event }: { event: ToolEvent }) => {
458
456
  return (
459
457
  <div
460
458
  key={group.key}
461
- className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4"
459
+ className="flex min-w-0 items-center gap-1.5 text-xs leading-4"
462
460
  aria-label={done ? [query, results].filter(Boolean).join(", ") : `Searching ${query}`}
463
461
  >
464
462
  {done ? (
@@ -605,7 +603,7 @@ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
605
603
  return (
606
604
  <div
607
605
  className={cn(
608
- "rounded-md border bg-background/30 px-2 py-1.5 transition-colors",
606
+ "rounded-md border bg-background/30 px-2 py-1 transition-colors",
609
607
  // Loud-but-not-jarring "in flight" treatment when any row
610
608
  // is running: primary border + soft ring. Failed sessions
611
609
  // (no longer running) pick up the destructive border;
@@ -634,7 +632,7 @@ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
634
632
  )}
635
633
  >
636
634
  {verb}
637
- {countSuffix && <span className="text-muted-foreground/70">{countSuffix}</span>}
635
+ {countSuffix}
638
636
  </span>
639
637
  {/*
640
638
  * Trailing "live" pip on the right edge while any tool is
@@ -650,7 +648,7 @@ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
650
648
  )}
651
649
  </CollapsibleTrigger>
652
650
  <CollapsibleContent>
653
- <div className="mt-2 flex flex-col gap-1.5">
651
+ <div className="mt-1.5 flex flex-col gap-1">
654
652
  {events.map((event) => (
655
653
  <ToolCallRow key={event.id} event={event} />
656
654
  ))}
@@ -14,12 +14,13 @@ import python from "shiki/langs/python.mjs";
14
14
  import sql from "shiki/langs/sql.mjs";
15
15
  import typescript from "shiki/langs/typescript.mjs";
16
16
  import yaml from "shiki/langs/yaml.mjs";
17
- import githubLight from "shiki/themes/github-light.mjs";
17
+ import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs";
18
+ import githubLightHighContrast from "shiki/themes/github-light-high-contrast.mjs";
18
19
 
19
20
  /** Create the shared highlighter with the exact configured language set. */
20
21
  export function createHighlighter() {
21
22
  return createHighlighterCore({
22
- themes: [githubLight],
23
+ themes: [githubLightHighContrast, githubDarkHighContrast],
23
24
  langs: [sql, python, typescript, javascript, json, bash, yaml, markdown],
24
25
  engine: createOnigurumaEngine(import("shiki/wasm")),
25
26
  });
@@ -72,6 +72,8 @@ export class MastraPluginClient extends MastraClient {
72
72
  * mode) keeps the OBO-gated behavior.
73
73
  */
74
74
  readonly chatAlwaysAvailable: boolean;
75
+ /** Whether turns continue server-side after the initiating stream disconnects. */
76
+ readonly backgroundTurns: boolean;
75
77
 
76
78
  constructor(config: MastraClientConfig) {
77
79
  super({
@@ -83,8 +85,9 @@ export class MastraPluginClient extends MastraClient {
83
85
  this.basePath = config.basePath;
84
86
  this.defaultAgent = config.defaultAgent;
85
87
  this.agents = config.agents;
86
- this.feedbackEnabled = config.feedbackEnabled;
87
- this.chatAlwaysAvailable = config.chatAlwaysAvailable;
88
+ this.feedbackEnabled = config.feedbackEnabled ?? false;
89
+ this.chatAlwaysAvailable = config.chatAlwaysAvailable ?? false;
90
+ this.backgroundTurns = config.backgroundTurns ?? false;
88
91
  }
89
92
 
90
93
  /**
@@ -137,6 +140,33 @@ export class MastraPluginClient extends MastraClient {
137
140
  return { headers, stream };
138
141
  }
139
142
 
143
+ async #uiReconnect(options: {
144
+ path: string;
145
+ chatId: string;
146
+ routing: { threadId?: string; model?: string };
147
+ signal?: AbortSignal;
148
+ }): Promise<MastraStreamResponse> {
149
+ let headers = new Headers();
150
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
151
+ const transport = new DefaultChatTransport<UIMessage>({
152
+ api: new URL(options.path, origin).toString(),
153
+ credentials: "include",
154
+ headers: this.#routingHeaders(options.routing),
155
+ prepareReconnectToStreamRequest: ({ api }) => ({ api }),
156
+ fetch: async (input, init) => {
157
+ const response = await fetch(input, init);
158
+ headers = new Headers(response.headers);
159
+ return response;
160
+ },
161
+ });
162
+ const stream = await transport.reconnectToStream({
163
+ chatId: options.chatId,
164
+ abortSignal: options.signal,
165
+ });
166
+ if (!stream) throw new Error(`Run ${options.chatId} is no longer available`);
167
+ return { headers, stream };
168
+ }
169
+
140
170
  #requestClient(
141
171
  routing: { threadId?: string; model?: string },
142
172
  signal?: AbortSignal,
@@ -188,6 +218,45 @@ export class MastraPluginClient extends MastraClient {
188
218
  });
189
219
  }
190
220
 
221
+ /** Reconnect to the cached event stream for one background agent run. */
222
+ async observeAgentStream(params: {
223
+ agentId: string;
224
+ runId: string;
225
+ threadId?: string;
226
+ model?: string;
227
+ signal?: AbortSignal;
228
+ }): Promise<MastraStreamResponse> {
229
+ return this.#uiReconnect({
230
+ path: `${this.basePath}${routes.MASTRA_ROUTES.chat}/${encodeURIComponent(params.agentId)}${routes.MASTRA_ROUTES.runs}/${encodeURIComponent(params.runId)}`,
231
+ chatId: params.runId,
232
+ routing: params,
233
+ signal: params.signal,
234
+ });
235
+ }
236
+
237
+ /** Explicitly stop one background agent run on the server. */
238
+ async abortAgentRun(params: {
239
+ agentId: string;
240
+ runId: string;
241
+ threadId?: string;
242
+ signal?: AbortSignal;
243
+ }): Promise<boolean> {
244
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
245
+ const response = await this.#mutateJson(
246
+ new URL(
247
+ `${this.basePath}${routes.MASTRA_ROUTES.chat}/${encodeURIComponent(params.agentId)}${routes.MASTRA_ROUTES.runs}/${encodeURIComponent(params.runId)}${routes.MASTRA_ROUTES.abort}`,
248
+ origin,
249
+ ).toString(),
250
+ "POST",
251
+ wire.MastraRunAbortResponseSchema,
252
+ {
253
+ headers: this.#routingHeaders({ threadId: params.threadId }),
254
+ signal: params.signal,
255
+ },
256
+ );
257
+ return response.aborted;
258
+ }
259
+
191
260
  /**
192
261
  * Resume a suspended `requireApproval` tool through the same official chat
193
262
  * route using Mastra's native `resumeData` contract.
@@ -1,5 +1,18 @@
1
+ import {
2
+ ReasoningEffort as ReasoningEffortValue,
3
+ type ReasoningEffort,
4
+ } from "@dbx-tools/shared-model";
5
+
1
6
  type StorageLike = Pick<Storage, "getItem" | "setItem">;
2
7
 
8
+ /** Default reasoning on for capable models while preserving an explicit off choice. */
9
+ export const defaultReasoningEffort = (
10
+ efforts: readonly ReasoningEffort[],
11
+ ): ReasoningEffort | undefined =>
12
+ efforts.includes(ReasoningEffortValue.Medium)
13
+ ? ReasoningEffortValue.Medium
14
+ : efforts.find((effort) => effort !== ReasoningEffortValue.None);
15
+
3
16
  /** Namespace the last-selected model by plugin mount and agent. */
4
17
  export const modelStorageKey = (basePath: string, agentId: string): string =>
5
18
  `dbx-mastra-model:${basePath}:${agentId}`;
@@ -27,13 +27,11 @@ const LANGUAGES = [
27
27
  "markdown",
28
28
  ] as const;
29
29
 
30
- /**
31
- * Single light theme. The demo has no dark-mode toggle, and a single
32
- * theme guarantees every token carries a concrete `color` (shiki's
33
- * dual-theme mode emits CSS-variable-only tokens that need extra CSS
34
- * wiring to paint).
35
- */
36
- const THEME: BundledTheme = "github-light";
30
+ /** High-contrast theme pair used for every syntax surface. */
31
+ const THEMES = [
32
+ "github-light-high-contrast",
33
+ "github-dark-high-contrast",
34
+ ] as const satisfies readonly [BundledTheme, BundledTheme];
37
35
 
38
36
  /** Languages we can tokenize, as a set for O(1) support checks. */
39
37
  const SUPPORTED = new Set<string>(LANGUAGES);
@@ -52,13 +50,40 @@ function loadHighlighter(): Promise<HighlighterCore> {
52
50
  return _loading;
53
51
  }
54
52
 
55
- /** Tokenize `code` with the active theme into Streamdown's result shape. */
53
+ function splitThemeColor(
54
+ value: string | undefined,
55
+ darkVariable: "--shiki-dark" | "--shiki-dark-bg",
56
+ ): { light?: string; dark?: string } {
57
+ if (!value) return {};
58
+ const marker = `;${darkVariable}:`;
59
+ const index = value.indexOf(marker);
60
+ if (index < 0) return { light: value };
61
+ return {
62
+ light: value.slice(0, index),
63
+ dark: value.slice(index + marker.length),
64
+ };
65
+ }
66
+
67
+ /** Tokenize `code` with the active themes into Streamdown's result shape. */
56
68
  function highlightTokens(h: HighlighterCore, code: string, language: string) {
57
69
  const { tokens, fg, bg, rootStyle } = h.codeToTokens(code, {
58
70
  lang: language as BundledLanguage,
59
- theme: THEME,
71
+ themes: { light: THEMES[0], dark: THEMES[1] },
60
72
  });
61
- return { tokens, fg, bg, rootStyle };
73
+ const foreground = splitThemeColor(fg, "--shiki-dark");
74
+ const background = splitThemeColor(bg, "--shiki-dark-bg");
75
+ const darkStyles = [
76
+ foreground.dark ? `--shiki-dark:${foreground.dark}` : "",
77
+ background.dark ? `--shiki-dark-bg:${background.dark}` : "",
78
+ ]
79
+ .filter(Boolean)
80
+ .join(";");
81
+ return {
82
+ tokens,
83
+ fg: foreground.light,
84
+ bg: background.light,
85
+ rootStyle: [rootStyle, darkStyles].filter(Boolean).join(";"),
86
+ };
62
87
  }
63
88
 
64
89
  /** Escape HTML-significant characters (from the shared string utils). */
@@ -76,17 +101,16 @@ export async function highlightToHtml(code: string, language: string): Promise<s
76
101
  if (!SUPPORTED.has(language)) return escapeHtml(code);
77
102
  const h = await loadHighlighter();
78
103
  try {
79
- const { tokens } = h.codeToTokens(code, {
80
- lang: language as BundledLanguage,
81
- theme: THEME,
82
- });
104
+ const { tokens } = highlightTokens(h, code, language);
83
105
  return tokens
84
106
  .map((line) =>
85
107
  line
86
- .map(
87
- (token) =>
88
- `<span style="color:${token.color ?? "inherit"}">${escapeHtml(token.content)}</span>`,
89
- )
108
+ .map((token) => {
109
+ const light = token.htmlStyle?.color ?? token.color ?? "inherit";
110
+ const dark = token.htmlStyle?.["--shiki-dark"] ?? light;
111
+ const style = `--sdm-c:${light};--shiki-dark:${dark}`;
112
+ return `<span style="${escapeHtml(style)}">${escapeHtml(token.content)}</span>`;
113
+ })
90
114
  .join(""),
91
115
  )
92
116
  .join("\n");
@@ -109,7 +133,7 @@ export function createShikiPlugin(): CodeHighlighterPlugin {
109
133
  name: "shiki",
110
134
  type: "code-highlighter",
111
135
  getSupportedLanguages: () => [...LANGUAGES],
112
- getThemes: () => [THEME, THEME],
136
+ getThemes: () => [...THEMES],
113
137
  supportsLanguage: (language) => isSupported(language),
114
138
  highlight: (options, callback) => {
115
139
  if (!isSupported(options.language)) return null;
@@ -4,7 +4,7 @@ import {
4
4
  TOOL_PROGRESS_PART_TYPE,
5
5
  ToolProgressPartDataSchema,
6
6
  } from "@dbx-tools/shared-mastra/wire";
7
- import { getToolOrDynamicToolName, isToolOrDynamicToolUIPart, type UIMessage } from "ai";
7
+ import { getToolOrDynamicToolName, isToolUIPart, type UIMessage } from "ai";
8
8
  import type { ToolEvent } from "../react/types.ts";
9
9
 
10
10
  /** Collapse one reasoning event into a compact paragraph. */
@@ -32,7 +32,7 @@ export function genieReasoningText(events: ToolEvent[]): string {
32
32
  if (text) sections.push(text);
33
33
  }
34
34
  }
35
- return sections.join("\n\n");
35
+ return sections.join("\n");
36
36
  }
37
37
 
38
38
  /** Drop markdown table rows so query samples never land in Thoughts. */
@@ -46,7 +46,7 @@ function stripMarkdownTables(text: string): string {
46
46
 
47
47
  /** Project native AI SDK tool parts from persisted messages onto pill state. */
48
48
  export function toolEventsFromParts(parts: UIMessage["parts"]): ToolEvent[] {
49
- const events = parts.filter(isToolOrDynamicToolUIPart).map<ToolEvent>((part) => {
49
+ const events = parts.filter(isToolUIPart).map<ToolEvent>((part) => {
50
50
  const error = part.state === "output-error";
51
51
  const done = part.state === "output-available";
52
52
  return {
@@ -78,21 +78,33 @@ export function toolEventsFromParts(parts: UIMessage["parts"]): ToolEvent[] {
78
78
  export function mergeToolEvents(
79
79
  persisted: ToolEvent[],
80
80
  live: ToolEvent[] | undefined,
81
- terminalError = false,
81
+ terminalState?: "done" | "error",
82
82
  ): ToolEvent[] {
83
83
  const merged = new Map(persisted.map((event) => [event.id, event]));
84
84
  for (const event of live ?? []) {
85
85
  const prior = merged.get(event.id);
86
- merged.set(event.id, prior ? { ...prior, ...event } : event);
86
+ merged.set(
87
+ event.id,
88
+ prior
89
+ ? {
90
+ ...prior,
91
+ ...event,
92
+ status: prior.status === "running" ? event.status : prior.status,
93
+ ...(prior.output !== undefined ? { output: prior.output } : {}),
94
+ }
95
+ : event,
96
+ );
87
97
  }
88
98
  const events = [...merged.values()];
89
- if (!terminalError) return events;
99
+ if (!terminalState) return events;
90
100
  return events.map((event) =>
91
101
  event.status === "running"
92
102
  ? {
93
103
  ...event,
94
- status: "error",
95
- output: { error: "The assistant stream ended before this tool returned." },
104
+ status: terminalState,
105
+ ...(terminalState === "error"
106
+ ? { output: { error: "The assistant stream ended before this tool returned." } }
107
+ : {}),
96
108
  }
97
109
  : event,
98
110
  );