@dbx-tools/ui-mastra 0.9.141 → 0.9.144

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
@@ -210,6 +210,14 @@ Configure the client with these options:
210
210
  id. Set `enableFeedback={false}` to hide them. Viewers with `CAN_MANAGE` on
211
211
  the experiment also get an experiment debug link at the end of the action row.
212
212
 
213
+ Post-turn follow-ups follow the policy published by AppKit-Mastra. Native Genie
214
+ questions and persisted `data-follow-up-suggestions` parts use the same
215
+ near-duplicate filter and source round-robin. After a turn settles, the latest
216
+ questions render through `SuggestionPills` at the end of the scrollable
217
+ transcript. They disappear when the next user turn starts and remain available
218
+ after history hydration because generated suggestions are persisted on the
219
+ assistant message.
220
+
213
221
  ## Use direct Genie chat
214
222
 
215
223
  Use `genieSpace` when the application needs the same chat shell without a
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.141";
6
+ export const PACKAGE_VERSION = "0.9.144";
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";
package/package.json CHANGED
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@databricks/appkit-ui": "0.81.0",
33
- "@dbx-tools/shared-core": "^0.9.141",
34
- "@dbx-tools/shared-genie": "^0.9.141",
35
- "@dbx-tools/shared-mastra": "^0.9.141",
36
- "@dbx-tools/shared-model": "^0.9.141",
37
- "@dbx-tools/ui": "^0.9.141",
33
+ "@dbx-tools/shared-core": "^0.9.144",
34
+ "@dbx-tools/shared-genie": "^0.9.144",
35
+ "@dbx-tools/shared-mastra": "^0.9.144",
36
+ "@dbx-tools/shared-model": "^0.9.144",
37
+ "@dbx-tools/ui": "^0.9.144",
38
38
  "@mastra/client-js": "1.50.0",
39
39
  "@mastra/core": "1.71.0",
40
40
  "@tanstack/react-table": "^8.21.3",
@@ -51,7 +51,7 @@
51
51
  "publishConfig": {
52
52
  "access": "public"
53
53
  },
54
- "version": "0.9.141",
54
+ "version": "0.9.144",
55
55
  "dbxToolsConfig": {
56
56
  "tags": [
57
57
  "ui"
@@ -35,8 +35,6 @@ import { MarkdownWithEmbeds } from "./embed-slots.tsx";
35
35
  import { ExportMenu } from "./export-menu.tsx";
36
36
  import { FeedbackControls } from "./feedback-controls.tsx";
37
37
  import { ToolMarkdown } from "./markdown.tsx";
38
- import { SuggestionPills } from "./suggestion-pills.tsx";
39
- import { collectSuggestions } from "./suggestions.ts";
40
38
  import { ToolSessionPill, humanizeToolName } from "./tool-pill.tsx";
41
39
  import type {
42
40
  ApprovalDecision,
@@ -316,8 +314,6 @@ type AssistantBubbleProps = {
316
314
  status: ChatStatus;
317
315
  events?: ToolEvent[];
318
316
  regenerate?: () => void;
319
- /** Click handler for tool-suggested follow-up questions. */
320
- onSuggestionClick?: (question: string) => void;
321
317
  /** Approve / deny handler for inline {@link ToolApprovalCard}s. */
322
318
  onResolveToolApproval?: (decision: ApprovalDecision) => void | Promise<void>;
323
319
  /**
@@ -353,7 +349,6 @@ export const AssistantBubble = ({
353
349
  status,
354
350
  events,
355
351
  regenerate,
356
- onSuggestionClick,
357
352
  onResolveToolApproval,
358
353
  externalApprovals,
359
354
  onExport,
@@ -396,7 +391,6 @@ export const AssistantBubble = ({
396
391
  // streaming target - either the agent has returned to `ready` /
397
392
  // `error`, or a newer message has taken over the `isLast` slot.
398
393
  const isStreamingThisBubble = isLast && (status === "streaming" || status === "submitted");
399
- const suggestions = isStreamingThisBubble ? [] : collectSuggestions(events);
400
394
  const showResponseStatus = shouldShowAssistantResponseStatus(
401
395
  isLast,
402
396
  status,
@@ -528,7 +522,6 @@ export const AssistantBubble = ({
528
522
  )}
529
523
  </div>
530
524
  )}
531
- <SuggestionPills questions={suggestions} onSelect={onSuggestionClick} className="mt-1" />
532
525
  </ItemContent>
533
526
  </Item>
534
527
  );
@@ -16,6 +16,7 @@ import { ArrowDownIcon, MessageSquareIcon, RefreshCwIcon, TriangleAlertIcon } fr
16
16
  import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
17
17
  import { AssistantBubble, UserBubble } from "./bubbles.tsx";
18
18
  import { genieFeedbackTarget } from "./chat-feedback.ts";
19
+ import { SuggestionPills } from "./suggestion-pills.tsx";
19
20
  import type { ChatViewProps } from "./types.ts";
20
21
  import { messageText } from "../support/export.ts";
21
22
 
@@ -148,8 +149,9 @@ type ChatTranscriptProps = {
148
149
  messages: ChatViewProps["messages"];
149
150
  status: ChatViewProps["status"];
150
151
  error: ChatViewProps["error"];
151
- sendMessage: ChatViewProps["sendMessage"];
152
152
  suggestions: NonNullable<ChatViewProps["suggestions"]>;
153
+ followUpQuestions: string[];
154
+ onSelectFollowUp: (question: string) => void;
153
155
  toolEventsByMessage: NonNullable<ChatViewProps["toolEventsByMessage"]>;
154
156
  regenerate: ChatViewProps["regenerate"];
155
157
  isLoadingMore: NonNullable<ChatViewProps["isLoadingMore"]>;
@@ -169,8 +171,9 @@ export const ChatTranscript = ({
169
171
  messages,
170
172
  status,
171
173
  error,
172
- sendMessage,
173
174
  suggestions,
175
+ followUpQuestions,
176
+ onSelectFollowUp,
174
177
  toolEventsByMessage,
175
178
  regenerate,
176
179
  isLoadingMore,
@@ -252,7 +255,6 @@ export const ChatTranscript = ({
252
255
  status={status}
253
256
  events={toolEventsByMessage[message.id]}
254
257
  regenerate={regenerate}
255
- onSuggestionClick={(text) => sendMessage({ text })}
256
258
  onResolveToolApproval={onResolveToolApproval}
257
259
  externalApprovals={pendingApprovalsByMessage[message.id]}
258
260
  {...(onExportMessage
@@ -274,6 +276,11 @@ export const ChatTranscript = ({
274
276
  }
275
277
  return <UserBubble key={message.id} message={message} />;
276
278
  })}
279
+ <SuggestionPills
280
+ questions={followUpQuestions}
281
+ onSelect={onSelectFollowUp}
282
+ className="pb-2 pt-1"
283
+ />
277
284
  {showWaitingIndicator && (
278
285
  <div
279
286
  role="status"
@@ -1,6 +1,7 @@
1
1
  import { ChatComposer } from "./chat-composer.tsx";
2
2
  import { ChatThreadLayout } from "./chat-thread-layout.tsx";
3
3
  import { ChatTranscript, useChatTranscriptController } from "./chat-transcript.tsx";
4
+ import { collectSuggestions } from "./suggestions.ts";
4
5
  import type { ChatViewProps } from "./types.ts";
5
6
 
6
7
  /**
@@ -23,6 +24,8 @@ export const ChatView = ({
23
24
  onStop,
24
25
  className,
25
26
  suggestions = [],
27
+ followUpSuggestions = "none",
28
+ followUpSuggestionLimit = 3,
26
29
  toolEventsByMessage = {},
27
30
  models,
28
31
  model,
@@ -76,6 +79,18 @@ export const ChatView = ({
76
79
  hasMore,
77
80
  isLoadingHistory,
78
81
  });
82
+ const latestMessage = messages[messages.length - 1];
83
+ const latestAssistant = latestMessage?.role === "assistant" ? latestMessage : undefined;
84
+ const running = status === "submitted" || status === "streaming";
85
+ const followUpQuestions =
86
+ latestAssistant && !running
87
+ ? collectSuggestions(
88
+ toolEventsByMessage[latestAssistant.id],
89
+ latestAssistant,
90
+ followUpSuggestions,
91
+ followUpSuggestionLimit,
92
+ )
93
+ : [];
79
94
 
80
95
  return (
81
96
  <ChatThreadLayout
@@ -104,8 +119,9 @@ export const ChatView = ({
104
119
  messages={messages}
105
120
  status={status}
106
121
  error={error}
107
- sendMessage={sendMessage}
108
122
  suggestions={suggestions}
123
+ followUpQuestions={followUpQuestions}
124
+ onSelectFollowUp={(text) => sendMessage({ text })}
109
125
  toolEventsByMessage={toolEventsByMessage}
110
126
  regenerate={regenerate}
111
127
  isLoadingMore={isLoadingMore}
@@ -105,12 +105,13 @@ const ChartSlot = ({ chartId }: { chartId: string }) => {
105
105
  // planner spec before rendering. Memoized on the resolved option and
106
106
  // the theme, so a light/dark switch recolors the chart in place.
107
107
  const option = useMemo(
108
- () => (chart?.result ? normalizeChartOption(chart.result.option, chrome) : undefined),
108
+ () => (chart?.result && chrome ? normalizeChartOption(chart.result.option, chrome) : undefined),
109
109
  [chart?.result, chrome],
110
110
  );
111
+ const waiting = loading || chrome === undefined;
111
112
  // Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
112
113
  // Hard-error / non-terminal payloads render nothing.
113
- if (!option && !loading) {
114
+ if (!option && !waiting) {
114
115
  return !error && chart === undefined ? <ExpiredSlot type="chart" /> : null;
115
116
  }
116
117
  // One frame for both states: the chart's footprint is known ahead of
@@ -7,6 +7,7 @@ import {
7
7
  TableHead,
8
8
  TableHeader,
9
9
  TableRow,
10
+ Spinner,
10
11
  Tooltip,
11
12
  TooltipContent,
12
13
  TooltipTrigger,
@@ -180,6 +181,20 @@ const MARKDOWN_COMPONENTS = {
180
181
  },
181
182
  };
182
183
 
184
+ /** Hold streaming tables until their rows and right-aligned values are complete. */
185
+ const STREAMING_MARKDOWN_COMPONENTS = {
186
+ ...MARKDOWN_COMPONENTS,
187
+ table: () => (
188
+ <div
189
+ role="status"
190
+ className="my-2 flex h-10 items-center gap-2 rounded-md border border-border/70 bg-muted/30 px-3 text-xs text-muted-foreground"
191
+ >
192
+ <Spinner className="size-3" />
193
+ <span>Building table...</span>
194
+ </div>
195
+ ),
196
+ };
197
+
183
198
  /**
184
199
  * Markdown component map for tool-detail copy (Genie summaries, SQL
185
200
  * descriptions). Same cell/section overrides, but tables stay static
@@ -258,7 +273,7 @@ export const AssistantMarkdown = ({
258
273
  animate?: boolean;
259
274
  }) => (
260
275
  <Streamdown
261
- components={MARKDOWN_COMPONENTS}
276
+ components={animate ? STREAMING_MARKDOWN_COMPONENTS : MARKDOWN_COMPONENTS}
262
277
  plugins={SHIKI_PLUGIN}
263
278
  controls={false}
264
279
  linkSafety={LINK_SAFETY}
@@ -1436,6 +1436,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
1436
1436
  regenerate,
1437
1437
  onStop: stop,
1438
1438
  suggestions,
1439
+ followUpSuggestions: mastraClient.followUpSuggestions,
1440
+ followUpSuggestionLimit: mastraClient.followUpSuggestionLimit,
1439
1441
  pendingApprovalsByMessage: activeSession.pendingApprovalsByMessage,
1440
1442
  onResolveToolApproval: handleApproval,
1441
1443
  // Picker is opt-in: only hand ChatView the catalogue + change
@@ -1,3 +1,9 @@
1
+ import {
2
+ FOLLOW_UP_SUGGESTIONS_PART_TYPE,
3
+ FollowUpSuggestionsPartDataSchema,
4
+ type FollowUpSuggestionMode,
5
+ } from "@dbx-tools/shared-mastra/wire";
6
+ import type { UIMessage } from "ai";
1
7
  import type { ToolEvent, ToolProgress } from "./types.ts";
2
8
 
3
9
  // Suggested follow-up question extraction: dedupe + cap the
@@ -9,7 +15,7 @@ import type { ToolEvent, ToolProgress } from "./types.ts";
9
15
  * assistant message - several Genie queries each emitting a handful
10
16
  * would otherwise flood the bubble.
11
17
  */
12
- const MAX_SUGGESTIONS = 4;
18
+ const MAX_STARTER_SUGGESTIONS = 4;
13
19
 
14
20
  /**
15
21
  * Token-set Jaccard threshold above which two suggestions are treated
@@ -46,14 +52,14 @@ function tokenSimilarity(a: Set<string>, b: Set<string>): number {
46
52
  * its *top* question before any source contributes a second -
47
53
  * favoring breadth over depth. Near-duplicates (see
48
54
  * {@link SUGGESTION_SIMILARITY}) are skipped and the result is capped
49
- * at {@link MAX_SUGGESTIONS}. Exact-duplicate questions across
55
+ * at the caller's limit. Exact-duplicate questions across
50
56
  * sources also fold via the similarity check.
51
57
  */
52
- function pickSuggestions(lists: string[][]): string[] {
58
+ function pickSuggestions(lists: string[][], limit: number): string[] {
53
59
  const accepted: string[] = [];
54
60
  const acceptedTokens: Set<string>[] = [];
55
61
  const consider = (question: string): void => {
56
- if (accepted.length >= MAX_SUGGESTIONS) return;
62
+ if (accepted.length >= limit) return;
57
63
  const tokens = suggestionTokens(question);
58
64
  if (tokens.size === 0) return;
59
65
  const isDuplicate = acceptedTokens.some(
@@ -65,7 +71,7 @@ function pickSuggestions(lists: string[][]): string[] {
65
71
  };
66
72
 
67
73
  const maxLen = lists.reduce((m, l) => Math.max(m, l.length), 0);
68
- for (let i = 0; i < maxLen && accepted.length < MAX_SUGGESTIONS; i++) {
74
+ for (let i = 0; i < maxLen && accepted.length < limit; i++) {
69
75
  for (const list of lists) {
70
76
  const question = list[i];
71
77
  if (question !== undefined) consider(question);
@@ -76,34 +82,49 @@ function pickSuggestions(lists: string[][]): string[] {
76
82
 
77
83
  /**
78
84
  * Dedupe + cap a single flat list of suggested questions (e.g. the
79
- * initial Genie sample questions). Shares the similarity dedupe and
80
- * {@link MAX_SUGGESTIONS} cap with the follow-up extractor so initial
81
- * and follow-up suggestions behave identically.
85
+ * initial Genie sample questions). Uses the same similarity dedupe as
86
+ * the follow-up extractor.
82
87
  */
83
88
  export const dedupeSuggestions = (questions: string[] | undefined): string[] =>
84
- questions && questions.length > 0 ? pickSuggestions([questions]) : [];
89
+ questions && questions.length > 0 ? pickSuggestions([questions], MAX_STARTER_SUGGESTIONS) : [];
85
90
 
86
91
  /**
87
92
  * Build the short, deduped list of suggested follow-up questions for
88
- * an assistant message. Within each tool event the **last**
89
- * `suggested` progress entry wins (Genie publishes an evolving list;
90
- * the final one is the refined version). Delegates dedupe + cap to
91
- * {@link pickSuggestions}.
93
+ * an assistant message. Within each Genie tool event the last suggested
94
+ * progress entry wins; persisted generic data parts are merged according to
95
+ * the server-published source policy.
92
96
  */
93
- export const collectSuggestions = (events: ToolEvent[] | undefined): string[] => {
94
- if (!events || events.length === 0) return [];
95
-
97
+ export const collectSuggestions = (
98
+ events: ToolEvent[] | undefined,
99
+ message: UIMessage,
100
+ mode: FollowUpSuggestionMode,
101
+ limit: number,
102
+ ): string[] => {
103
+ if (mode === "none") return [];
96
104
  // One ordered question list per event that emitted any.
97
105
  const lists: string[][] = [];
98
- for (const event of events) {
99
- const last = [...(event.progress ?? [])]
100
- .reverse()
101
- .find(
102
- (p): p is Extract<ToolProgress, { type: "suggested_questions" }> =>
103
- p.type === "suggested_questions",
104
- );
105
- if (last && last.questions.length > 0) lists.push(last.questions);
106
+ if (mode === "genie" || mode === "auto") {
107
+ for (const event of events ?? []) {
108
+ const last = [...(event.progress ?? [])]
109
+ .reverse()
110
+ .find(
111
+ (p): p is Extract<ToolProgress, { type: "suggested_questions" }> =>
112
+ p.type === "suggested_questions",
113
+ );
114
+ if (last && last.questions.length > 0) lists.push(last.questions);
115
+ }
106
116
  }
107
-
108
- return pickSuggestions(lists);
117
+ for (const part of message.parts) {
118
+ if (part.type !== FOLLOW_UP_SUGGESTIONS_PART_TYPE) continue;
119
+ const parsed = FollowUpSuggestionsPartDataSchema.safeParse(part.data);
120
+ if (!parsed.success) continue;
121
+ if (
122
+ (mode === "generated" && parsed.data.source !== "model") ||
123
+ (mode === "genie" && parsed.data.source !== "genie")
124
+ ) {
125
+ continue;
126
+ }
127
+ lists.push(parsed.data.questions);
128
+ }
129
+ return pickSuggestions(lists, Math.min(10, Math.max(1, limit)));
109
130
  };
@@ -1,4 +1,4 @@
1
- import type { ToolProgressEvent } from "@dbx-tools/shared-mastra/wire";
1
+ import type { FollowUpSuggestionMode, ToolProgressEvent } from "@dbx-tools/shared-mastra/wire";
2
2
  import type { ReasoningEffort, ServingEndpointSummary } from "@dbx-tools/shared-model";
3
3
  import type { FileUIPart, UIMessage } from "ai";
4
4
  import type { ReactNode } from "react";
@@ -171,6 +171,10 @@ export type ChatViewProps = {
171
171
  * questions when the caller doesn't pass an explicit list.
172
172
  */
173
173
  suggestions?: string[];
174
+ /** Enabled post-turn follow-up sources published by the server. */
175
+ followUpSuggestions?: FollowUpSuggestionMode;
176
+ /** Maximum post-turn follow-up questions shown under one response. */
177
+ followUpSuggestionLimit?: number;
174
178
  toolEventsByMessage?: Record<string, ToolEvent[]>;
175
179
  /** Available model endpoints. Pass an empty array (or omit) to hide the picker. */
176
180
  models?: ChatModelOption[];
@@ -110,12 +110,15 @@ const THEME_ATTRIBUTES = ["class", "style", "data-theme", "data-brand"];
110
110
  * structurally before being stored, so an unrelated attribute change
111
111
  * does not hand callers a new object and re-render every chart.
112
112
  *
113
- * Returns {@link LIGHT_CHART_CHROME} on the first render - the effect
114
- * that reads the real tokens runs after mount, by which point the chart
115
- * itself is usually still long-polling its spec.
113
+ * Returns `undefined` in the browser until the chart element is mounted and its
114
+ * inherited tokens have been read. This prevents Echarts from drawing once
115
+ * with fallback colors and immediately drawing again with the resolved theme.
116
+ * Server rendering retains {@link LIGHT_CHART_CHROME}.
116
117
  */
117
- export function useChartChrome(ref: RefObject<Element | null>): ChartChrome {
118
- const [chrome, setChrome] = useState<ChartChrome>(LIGHT_CHART_CHROME);
118
+ export function useChartChrome(ref: RefObject<Element | null>): ChartChrome | undefined {
119
+ const [chrome, setChrome] = useState<ChartChrome | undefined>(() =>
120
+ typeof window === "undefined" ? LIGHT_CHART_CHROME : undefined,
121
+ );
119
122
  useEffect(() => {
120
123
  const read = () => {
121
124
  const next = resolveChartChrome(ref.current);
@@ -28,6 +28,7 @@ export class GenieChatClient extends MastraPluginClient {
28
28
  override readonly feedbackMode = "none";
29
29
  override readonly threadRename = false;
30
30
  override readonly attachments = false;
31
+ override readonly followUpSuggestions: "none" | "genie";
31
32
  /** Thread ids known to be Genie conversations (listed, loaded, or announced). */
32
33
  readonly #conversations = new Set<string>();
33
34
  /** Transcript message id to Genie message id, for regenerate cleanup. */
@@ -36,6 +37,10 @@ export class GenieChatClient extends MastraPluginClient {
36
37
  constructor(config: MastraClientConfig, alias: string) {
37
38
  super(config);
38
39
  this.alias = alias;
40
+ this.followUpSuggestions =
41
+ config.followUpSuggestions === "genie" || config.followUpSuggestions === "auto"
42
+ ? "genie"
43
+ : "none";
39
44
  }
40
45
 
41
46
  #url(...parts: string[]): string {
@@ -91,6 +91,10 @@ export class MastraPluginClient extends MastraClient {
91
91
  readonly chatAlwaysAvailable: boolean;
92
92
  /** Whether turns continue server-side after the initiating stream disconnects. */
93
93
  readonly backgroundTurns: boolean;
94
+ /** Enabled post-turn follow-up sources. */
95
+ readonly followUpSuggestions: "none" | "genie" | "generated" | "auto";
96
+ /** Maximum follow-up questions rendered after one turn. */
97
+ readonly followUpSuggestionLimit: number;
94
98
  /** Whether {@link renameThread} is supported by this client's thread store. */
95
99
  readonly threadRename: boolean = true;
96
100
  /** Whether a chat may attach files to a turn. */
@@ -109,6 +113,8 @@ export class MastraPluginClient extends MastraClient {
109
113
  this.feedbackMode = config.feedbackMode ?? "none";
110
114
  this.chatAlwaysAvailable = config.chatAlwaysAvailable ?? false;
111
115
  this.backgroundTurns = config.backgroundTurns ?? false;
116
+ this.followUpSuggestions = config.followUpSuggestions ?? "none";
117
+ this.followUpSuggestionLimit = config.followUpSuggestionLimit ?? 3;
112
118
  }
113
119
 
114
120
  /**