@dbx-tools/ui-mastra 0.6.224 → 0.6.226

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
@@ -147,7 +147,9 @@ export function App() {
147
147
  `MastraChat` is the quickest client for the AppKit-Mastra plugin. It reads the
148
148
  plugin's published client config, creates a `MastraPluginClient`, streams turns
149
149
  through Mastra's official AI SDK chat route, hydrates native memory, and renders
150
- the controlled `ChatView`.
150
+ the controlled `ChatView`. When Mastra returns persisted `messages` without a
151
+ precomputed `uiMessages` projection, history hydration uses Mastra's native
152
+ `convertMessages(...).to("AIV5.UI")` conversion.
151
153
 
152
154
  Useful options:
153
155
 
@@ -155,6 +157,9 @@ Useful options:
155
157
  - `showModelPicker` fetches `/models` and sends `X-Mastra-Model` overrides.
156
158
  - `composerActions` places host-owned per-turn controls beside the model
157
159
  selector without replacing the built-in composer.
160
+ - `modelSelectorActions` places host-owned controls inside the model menu after
161
+ the endpoint choices. Supply it only when the active agent supports that
162
+ option, such as a Genie transport toggle.
158
163
  - `composerLeadingActions` places attachment or app actions on the left side of
159
164
  the fixed composer footer. The textarea grows above it to a cap, then scrolls;
160
165
  model and Send/Stop stay pinned bottom-right.
@@ -198,6 +203,11 @@ starts a fresh conversation when it was the last tab open. The open set is
198
203
  session state; the strip reseeds from the most recent conversations on the next
199
204
  load.
200
205
 
206
+ Selecting a conversation fetches only the newest 20 native memory messages.
207
+ Scrolling upward requests the next page and prepends it without moving the
208
+ reader's viewport. Agent turns still send only the new user message; Mastra
209
+ loads the remembered transcript server-side.
210
+
201
211
  `auto` measures the chat's own element rather than the viewport, so a chat
202
212
  embedded in a split view or side panel switches to tabs on the space it actually
203
213
  has instead of waiting for the window to shrink.
@@ -258,6 +268,16 @@ export function ReviewChat(props: ChatViewProps) {
258
268
  sidebar, transcript, tool progress, approval cards, suggestions, export controls,
259
269
  feedback controls, and composer from props. Use it when your app already has a
260
270
  transport or needs to combine Mastra messages with another state model.
271
+ `MastraChat` shows a spinner in the model control while its live default model
272
+ is being resolved. The picker groups endpoints into
273
+ Rust-derived family submenus. Its compact first popup shows only the current
274
+ model, host-owned actions such as Genie Agent Mode, and a reasoning slider when
275
+ the selected endpoint supports it. The current model opens the family picker,
276
+ so the long endpoint catalogue stays out of the primary control popup. Family
277
+ and model menus use the same compact utility type as the composer actions,
278
+ omit repeated headers, and omit the already-selected model. The model menu puts
279
+ `Default (<resolved model>)` first, keeps `Other` last, and makes a one-model
280
+ family directly selectable instead of opening another submenu.
261
281
 
262
282
  ## Call Plugin Routes Directly
263
283
 
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.224";
6
+ export const PACKAGE_VERSION = "0.6.226";
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
@@ -31,13 +31,14 @@
31
31
  "react-dom": "~19.2.4"
32
32
  },
33
33
  "dependencies": {
34
- "@dbx-tools/shared-core": "0.6.224",
35
- "@dbx-tools/shared-genie": "0.6.224",
36
- "@dbx-tools/shared-mastra": "0.6.224",
37
- "@dbx-tools/shared-model": "0.6.224",
38
- "@dbx-tools/ui-appkit": "0.6.224",
39
- "@dbx-tools/ui-branding": "0.6.224",
34
+ "@dbx-tools/shared-core": "0.6.226",
35
+ "@dbx-tools/shared-genie": "0.6.226",
36
+ "@dbx-tools/shared-mastra": "0.6.226",
37
+ "@dbx-tools/shared-model": "0.6.226",
38
+ "@dbx-tools/ui-appkit": "0.6.226",
39
+ "@dbx-tools/ui-branding": "0.6.226",
40
40
  "@mastra/client-js": "1.50.0",
41
+ "@mastra/core": "1.71.0",
41
42
  "@tanstack/react-table": "^8.21.3",
42
43
  "ai": "^5.0.0",
43
44
  "echarts": "^6.0.0",
@@ -52,7 +53,7 @@
52
53
  "publishConfig": {
53
54
  "access": "public"
54
55
  },
55
- "version": "0.6.224",
56
+ "version": "0.6.226",
56
57
  "type": "module",
57
58
  "exports": {
58
59
  "./react": "./src/react/index.ts",
@@ -0,0 +1,11 @@
1
+ import { convertMessages } from "@mastra/core/agent/message-list";
2
+ import type { ListMemoryThreadMessagesResponse } from "@mastra/client-js";
3
+ import type { UIMessage } from "ai";
4
+
5
+ /** Convert a newest-first native memory page to chronological AI SDK UI messages. */
6
+ export function toChronologicalUiMessages(response: ListMemoryThreadMessagesResponse): UIMessage[] {
7
+ if (response.uiMessages) {
8
+ return [...response.uiMessages].reverse() as UIMessage[];
9
+ }
10
+ return convertMessages(response.messages).to("AIV5.UI") as UIMessage[];
11
+ }
@@ -0,0 +1,292 @@
1
+ import { string } from "@dbx-tools/shared-core";
2
+ import {
3
+ Button,
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuLabel,
8
+ DropdownMenuPortal,
9
+ DropdownMenuRadioGroup,
10
+ DropdownMenuRadioItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuSub,
13
+ DropdownMenuSubContent,
14
+ DropdownMenuSubTrigger,
15
+ DropdownMenuTrigger,
16
+ Slider,
17
+ Spinner,
18
+ } from "@dbx-tools/ui-appkit/react";
19
+ import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
20
+ import { useMemo, useState, type ReactNode } from "react";
21
+ import type { ChatModelOption, ChatViewProps } from "./types.ts";
22
+
23
+ const OTHER_FAMILY = "other";
24
+
25
+ interface ModelSelectorProps {
26
+ models: ChatModelOption[];
27
+ model?: string;
28
+ defaultModelName?: string;
29
+ defaultModelId?: string;
30
+ defaultModelLoading?: boolean;
31
+ reasoningEffort: ChatViewProps["reasoningEffort"];
32
+ onReasoningEffortChange: ChatViewProps["onReasoningEffortChange"];
33
+ onModelChange: NonNullable<ChatViewProps["onModelChange"]>;
34
+ actions?: ReactNode;
35
+ }
36
+
37
+ /** Convert a normalized family token into a compact menu label. */
38
+ function familyLabel(family: string): string {
39
+ return family.length <= 3 ? family.toUpperCase() : string.toLabel(family);
40
+ }
41
+
42
+ /** Resolve the label shown for a model endpoint. */
43
+ function modelLabel(model: ChatModelOption): string {
44
+ return model.displayName || model.name;
45
+ }
46
+
47
+ /** Reasoning selector backed only by efforts published for the active model. */
48
+ const ReasoningControl = ({
49
+ efforts,
50
+ value,
51
+ onChange,
52
+ }: {
53
+ efforts: string[];
54
+ value: ChatViewProps["reasoningEffort"];
55
+ onChange: NonNullable<ChatViewProps["onReasoningEffortChange"]>;
56
+ }) => {
57
+ const selectedIndex = value === undefined ? 0 : Math.max(0, efforts.indexOf(value) + 1);
58
+ const selectedLabel = value === undefined ? "Auto" : string.toLabel(value);
59
+
60
+ return (
61
+ <div className="space-y-2 px-2 py-1.5">
62
+ <div className="flex items-center justify-between gap-3 text-xs">
63
+ <span className="font-medium text-foreground">Reasoning</span>
64
+ <span className="text-muted-foreground">{selectedLabel}</span>
65
+ </div>
66
+ <Slider
67
+ aria-label="Reasoning effort"
68
+ min={0}
69
+ max={efforts.length}
70
+ step={1}
71
+ value={[selectedIndex]}
72
+ onValueChange={([index]) => onChange(index === 0 ? undefined : efforts[index - 1])}
73
+ />
74
+ </div>
75
+ );
76
+ };
77
+
78
+ /** Family menu that opens one scoped endpoint submenu at a time. */
79
+ const FamilyPicker = ({
80
+ families,
81
+ selectedModelId,
82
+ model,
83
+ defaultModelName,
84
+ showLoading,
85
+ activeFamily,
86
+ onActiveFamilyChange,
87
+ onModelChange,
88
+ }: {
89
+ families: Array<[string, ChatModelOption[]]>;
90
+ selectedModelId?: string;
91
+ model?: string;
92
+ defaultModelName?: string;
93
+ showLoading: boolean;
94
+ activeFamily?: string;
95
+ onActiveFamilyChange: (family: string | undefined) => void;
96
+ onModelChange: NonNullable<ChatViewProps["onModelChange"]>;
97
+ }) => {
98
+ if (activeFamily) {
99
+ const options = families.find(([family]) => family === activeFamily)?.[1] ?? [];
100
+ const choices = options.filter((option) => option.name !== selectedModelId);
101
+ return (
102
+ <>
103
+ <button
104
+ type="button"
105
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs font-medium outline-none hover:bg-accent focus-visible:bg-accent"
106
+ onClick={() => onActiveFamilyChange(undefined)}
107
+ >
108
+ <ChevronLeftIcon className="size-3" />
109
+ Models
110
+ </button>
111
+ <DropdownMenuSeparator />
112
+ <DropdownMenuRadioGroup onValueChange={onModelChange}>
113
+ {choices.map((option) => (
114
+ <DropdownMenuRadioItem key={option.name} value={option.name} className="text-xs">
115
+ {modelLabel(option)}
116
+ </DropdownMenuRadioItem>
117
+ ))}
118
+ </DropdownMenuRadioGroup>
119
+ </>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <>
125
+ <DropdownMenuLabel className="text-xs">Models</DropdownMenuLabel>
126
+ <DropdownMenuItem className="gap-2 text-xs" onSelect={() => onModelChange("")}>
127
+ {showLoading ? (
128
+ <>
129
+ <Spinner className="size-3 shrink-0" />
130
+ <span className="sr-only">Loading default model</span>
131
+ </>
132
+ ) : (
133
+ <span>{defaultModelName ? `Default (${defaultModelName})` : "Default"}</span>
134
+ )}
135
+ {!model && !showLoading && <CheckIcon className="ml-auto size-3" />}
136
+ </DropdownMenuItem>
137
+ <DropdownMenuSeparator />
138
+ {families.map(([family, options]) => {
139
+ if (options.length === 1) {
140
+ const [option] = options;
141
+ if (!option) return null;
142
+ return (
143
+ <DropdownMenuItem
144
+ key={family}
145
+ className="gap-2 text-xs"
146
+ onSelect={() => onModelChange(option.name)}
147
+ >
148
+ <span>{modelLabel(option)}</span>
149
+ {option.name === selectedModelId && <CheckIcon className="ml-auto size-3" />}
150
+ </DropdownMenuItem>
151
+ );
152
+ }
153
+ const choices = options.filter((option) => option.name !== selectedModelId);
154
+ if (choices.length === 0) return null;
155
+ return (
156
+ <button
157
+ key={family}
158
+ type="button"
159
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none hover:bg-accent focus-visible:bg-accent"
160
+ onClick={() => onActiveFamilyChange(family)}
161
+ >
162
+ <span>{familyLabel(family)}</span>
163
+ <span className="ml-auto tabular-nums text-muted-foreground">{options.length}</span>
164
+ <ChevronRightIcon className="size-3 text-muted-foreground" />
165
+ </button>
166
+ );
167
+ })}
168
+ </>
169
+ );
170
+ };
171
+
172
+ /**
173
+ * Family-first model picker with fixed model controls above the scrollable
174
+ * family submenus.
175
+ */
176
+ export const ModelSelector = ({
177
+ models,
178
+ model,
179
+ defaultModelName,
180
+ defaultModelId,
181
+ defaultModelLoading = false,
182
+ reasoningEffort,
183
+ onReasoningEffortChange,
184
+ onModelChange,
185
+ actions,
186
+ }: ModelSelectorProps) => {
187
+ const [open, setOpen] = useState(false);
188
+ const [modelPickerOpen, setModelPickerOpen] = useState(false);
189
+ const [activeFamily, setActiveFamily] = useState<string>();
190
+ const selectedModelId = model || defaultModelId;
191
+ const selectedModel = models.find((option) => option.name === selectedModelId);
192
+ const reasoningEfforts = selectedModel?.reasoningEfforts ?? [];
193
+ const showLoading = defaultModelLoading && !model;
194
+ const triggerLabel = model
195
+ ? modelLabel(models.find((option) => option.name === model) ?? { name: model })
196
+ : defaultModelName || "Default";
197
+ const families = useMemo(() => {
198
+ const grouped = new Map<string, ChatModelOption[]>();
199
+ for (const option of models) {
200
+ const family = option.family || OTHER_FAMILY;
201
+ const current = grouped.get(family);
202
+ if (current) current.push(option);
203
+ else grouped.set(family, [option]);
204
+ }
205
+ return [...grouped.entries()].sort(([left], [right]) => {
206
+ if (left === OTHER_FAMILY) return 1;
207
+ if (right === OTHER_FAMILY) return -1;
208
+ return 0;
209
+ });
210
+ }, [models]);
211
+
212
+ return (
213
+ <DropdownMenu
214
+ open={open}
215
+ onOpenChange={(next) => {
216
+ setOpen(next);
217
+ if (!next) {
218
+ setModelPickerOpen(false);
219
+ setActiveFamily(undefined);
220
+ }
221
+ }}
222
+ >
223
+ <DropdownMenuTrigger asChild>
224
+ <Button
225
+ type="button"
226
+ variant="ghost"
227
+ size="sm"
228
+ className="h-7 max-w-[190px] gap-1.5 px-2 text-xs font-normal"
229
+ aria-label={showLoading ? "Loading default model" : `Model: ${triggerLabel}`}
230
+ >
231
+ {showLoading ? (
232
+ <>
233
+ <Spinner className="size-3 shrink-0" />
234
+ <span className="sr-only">Loading default model</span>
235
+ </>
236
+ ) : (
237
+ <>
238
+ <span className="truncate">{triggerLabel}</span>
239
+ <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground" />
240
+ </>
241
+ )}
242
+ </Button>
243
+ </DropdownMenuTrigger>
244
+ <DropdownMenuContent align="end" className="w-48">
245
+ <DropdownMenuSub
246
+ open={modelPickerOpen}
247
+ onOpenChange={(next) => {
248
+ if (!next && open) return;
249
+ setModelPickerOpen(next);
250
+ if (!next) setActiveFamily(undefined);
251
+ }}
252
+ >
253
+ <DropdownMenuSubTrigger className="mx-1 justify-center rounded-lg bg-accent/60 px-3 py-2 text-xs font-medium">
254
+ {showLoading ? (
255
+ <>
256
+ <Spinner className="size-3 shrink-0" />
257
+ <span className="sr-only">Loading default model</span>
258
+ </>
259
+ ) : (
260
+ triggerLabel
261
+ )}
262
+ </DropdownMenuSubTrigger>
263
+ <DropdownMenuPortal>
264
+ <DropdownMenuSubContent className="w-48">
265
+ <FamilyPicker
266
+ families={families}
267
+ selectedModelId={selectedModelId}
268
+ model={model}
269
+ defaultModelName={defaultModelName}
270
+ showLoading={showLoading}
271
+ activeFamily={activeFamily}
272
+ onActiveFamilyChange={setActiveFamily}
273
+ onModelChange={onModelChange}
274
+ />
275
+ </DropdownMenuSubContent>
276
+ </DropdownMenuPortal>
277
+ </DropdownMenuSub>
278
+ {(actions || (reasoningEfforts.length > 0 && onReasoningEffortChange)) && (
279
+ <DropdownMenuSeparator />
280
+ )}
281
+ {actions && <div className="px-1">{actions}</div>}
282
+ {reasoningEfforts.length > 0 && onReasoningEffortChange && (
283
+ <ReasoningControl
284
+ efforts={reasoningEfforts}
285
+ value={reasoningEffort}
286
+ onChange={onReasoningEffortChange}
287
+ />
288
+ )}
289
+ </DropdownMenuContent>
290
+ </DropdownMenu>
291
+ );
292
+ };
@@ -12,11 +12,6 @@ import {
12
12
  InputGroupAddon,
13
13
  InputGroupButton,
14
14
  InputGroupTextarea,
15
- Select,
16
- SelectContent,
17
- SelectItem,
18
- SelectTrigger,
19
- SelectValue,
20
15
  Spinner,
21
16
  Tooltip,
22
17
  TooltipContent,
@@ -34,11 +29,10 @@ import {
34
29
  import { useCallback, useId, useLayoutEffect, useRef, useState } from "react";
35
30
  import { autosizeComposerTextarea, observeComposerWidth } from "./_composer-autosize.ts";
36
31
  import { ExportMenu } from "./export-menu.tsx";
32
+ import { ModelSelector } from "./_model-selector.tsx";
37
33
  import { SuggestionPills } from "./suggestion-pills.tsx";
38
34
  import type { ChatViewProps } from "./types.ts";
39
35
 
40
- const DEFAULT_MODEL_VALUE = "__default__";
41
-
42
36
  type QueuedSteerListProps = {
43
37
  queuedSteers: NonNullable<ChatViewProps["queuedSteers"]>;
44
38
  onSendSteerNow: ChatViewProps["onSendSteerNow"];
@@ -250,7 +244,12 @@ type ChatComposerProps = {
250
244
  model: ChatViewProps["model"];
251
245
  onModelChange: ChatViewProps["onModelChange"];
252
246
  defaultModelName: ChatViewProps["defaultModelName"];
247
+ defaultModelId: ChatViewProps["defaultModelId"];
248
+ defaultModelLoading: ChatViewProps["defaultModelLoading"];
249
+ reasoningEffort: ChatViewProps["reasoningEffort"];
250
+ onReasoningEffortChange: ChatViewProps["onReasoningEffortChange"];
253
251
  composerActions: ChatViewProps["composerActions"];
252
+ modelSelectorActions: ChatViewProps["modelSelectorActions"];
254
253
  composerLeadingActions: ChatViewProps["composerLeadingActions"];
255
254
  isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
256
255
  onClear: ChatViewProps["onClear"];
@@ -273,7 +272,12 @@ export const ChatComposer = ({
273
272
  model,
274
273
  onModelChange,
275
274
  defaultModelName,
275
+ defaultModelId,
276
+ defaultModelLoading,
277
+ reasoningEffort,
278
+ onReasoningEffortChange,
276
279
  composerActions,
280
+ modelSelectorActions,
277
281
  composerLeadingActions,
278
282
  isLoadingHistory,
279
283
  onClear,
@@ -308,24 +312,13 @@ export const ChatComposer = ({
308
312
  onResumeFollow();
309
313
  };
310
314
 
311
- const showModelDisplay = Boolean(onModelChange);
312
315
  const modelChangeable = Boolean(models && models.length > 0);
313
- const defaultOptionLabel = defaultModelName || "Default";
314
- const currentModelLabel =
315
- (model ? models?.find((option) => option.name === model)?.displayName : undefined) ||
316
- defaultOptionLabel;
317
- const sortedModels = [...(models ?? [])].sort((left, right) =>
318
- (left.displayName || left.name).localeCompare(right.displayName || right.name, undefined, {
319
- sensitivity: "base",
320
- }),
321
- );
322
316
  return (
323
317
  <>
324
- {isEmpty && (
318
+ {isEmpty && !isLoadingHistory && (
325
319
  <SuggestionPills
326
320
  questions={suggestions}
327
321
  onSelect={(text) => sendMessage({ text })}
328
- disabled={isLoadingHistory}
329
322
  className="mx-auto w-full max-w-4xl px-4 pb-2 md:px-6"
330
323
  />
331
324
  )}
@@ -358,14 +351,13 @@ export const ChatComposer = ({
358
351
  submit();
359
352
  }
360
353
  }}
361
- placeholder={isLoadingHistory ? "Loading history..." : "Send a message..."}
354
+ placeholder="Send a message..."
362
355
  rows={1}
363
- disabled={isLoadingHistory}
364
- className="field-sizing-fixed max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-base md:text-sm"
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"
365
357
  />
366
358
  <InputGroupAddon
367
359
  align="block-end"
368
- className="flex w-full flex-row flex-wrap items-center justify-between gap-2 px-3 pb-3 pt-0"
360
+ className="flex w-full flex-row flex-wrap items-center justify-between gap-2 px-3 pb-3 pt-1"
369
361
  >
370
362
  <div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
371
363
  {composerLeadingActions}
@@ -382,35 +374,29 @@ export const ChatComposer = ({
382
374
  </div>
383
375
  <div className="ml-auto flex min-w-0 max-w-full items-center gap-1.5">
384
376
  {composerActions}
385
- {showModelDisplay &&
377
+ {onModelChange &&
386
378
  (modelChangeable ? (
387
- <Select
388
- name="mastra-model"
389
- value={model ? model : DEFAULT_MODEL_VALUE}
390
- onValueChange={(value) =>
391
- onModelChange?.(value === DEFAULT_MODEL_VALUE ? "" : value)
392
- }
393
- disabled={isLoadingHistory}
394
- >
395
- <SelectTrigger
396
- size="sm"
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"
398
- >
399
- <span className="sr-only">Model:</span>
400
- <SelectValue placeholder={defaultOptionLabel} />
401
- </SelectTrigger>
402
- <SelectContent>
403
- <SelectItem value={DEFAULT_MODEL_VALUE}>{defaultOptionLabel}</SelectItem>
404
- {sortedModels.map((option) => (
405
- <SelectItem key={option.name} value={option.name}>
406
- {option.displayName || option.name}
407
- </SelectItem>
408
- ))}
409
- </SelectContent>
410
- </Select>
379
+ <ModelSelector
380
+ models={models ?? []}
381
+ model={model}
382
+ defaultModelName={defaultModelName}
383
+ defaultModelId={defaultModelId}
384
+ defaultModelLoading={defaultModelLoading}
385
+ reasoningEffort={reasoningEffort}
386
+ onReasoningEffortChange={onReasoningEffortChange}
387
+ onModelChange={onModelChange}
388
+ actions={modelSelectorActions}
389
+ />
411
390
  ) : (
412
- <span className="max-w-[180px] truncate px-2 text-xs text-muted-foreground">
413
- {currentModelLabel}
391
+ <span className="flex max-w-[180px] items-center gap-1.5 truncate px-2 text-xs text-muted-foreground">
392
+ {defaultModelLoading ? (
393
+ <>
394
+ <Spinner className="size-3 shrink-0" />
395
+ <span className="sr-only">Loading default model</span>
396
+ </>
397
+ ) : (
398
+ defaultModelName || "Default"
399
+ )}
414
400
  </span>
415
401
  ))}
416
402
  {isRunning && onStop && !input.trim() ? (
@@ -1,5 +1,4 @@
1
1
  import { error as sharedError, log } from "@dbx-tools/shared-core";
2
- import type { ListMemoryThreadMessagesResponse } from "@mastra/client-js";
3
2
  import type { UIMessage } from "ai";
4
3
  import { useCallback, useEffect, useRef, useState } from "react";
5
4
  import type {
@@ -8,6 +7,7 @@ import type {
8
7
  ThreadSessionUpdater,
9
8
  } from "./chat-sessions.ts";
10
9
  import type { PendingApproval } from "./types.ts";
10
+ import { toChronologicalUiMessages } from "./_history-messages.ts";
11
11
  import type { MastraPluginClient } from "../support/mastra-client.ts";
12
12
 
13
13
  const HISTORY_PAGE_SIZE = 20;
@@ -26,11 +26,6 @@ interface UseChatHistoryOptions {
26
26
 
27
27
  type SuspendedRuns = Awaited<ReturnType<MastraPluginClient["suspendedRuns"]>>["runs"];
28
28
 
29
- /** Read a newest-first native memory page as chronological UI messages. */
30
- function toChronologicalUiMessages(response: ListMemoryThreadMessagesResponse): UIMessage[] {
31
- return [...(response.uiMessages ?? [])].reverse() as UIMessage[];
32
- }
33
-
34
29
  /** Recover actionable approval cards from Mastra's persisted suspended runs. */
35
30
  function pendingApprovals(
36
31
  messages: UIMessage[],
@@ -27,8 +27,13 @@ export const ChatView = ({
27
27
  models,
28
28
  model,
29
29
  onModelChange,
30
+ reasoningEffort,
31
+ onReasoningEffortChange,
30
32
  defaultModelName,
33
+ defaultModelId,
34
+ defaultModelLoading = false,
31
35
  composerActions,
36
+ modelSelectorActions,
32
37
  composerLeadingActions,
33
38
  onLoadMore,
34
39
  isLoadingMore = false,
@@ -117,8 +122,13 @@ export const ChatView = ({
117
122
  models={models}
118
123
  model={model}
119
124
  onModelChange={onModelChange}
125
+ reasoningEffort={reasoningEffort}
126
+ onReasoningEffortChange={onReasoningEffortChange}
120
127
  defaultModelName={defaultModelName}
128
+ defaultModelId={defaultModelId}
129
+ defaultModelLoading={defaultModelLoading}
121
130
  composerActions={composerActions}
131
+ modelSelectorActions={modelSelectorActions}
122
132
  composerLeadingActions={composerLeadingActions}
123
133
  isLoadingHistory={isLoadingHistory}
124
134
  onClear={onClear}
@@ -1,4 +1,5 @@
1
1
  import { error as sharedError, hash, log } from "@dbx-tools/shared-core";
2
+ import type { ReasoningEffort } from "@dbx-tools/shared-model";
2
3
  import { useBrand } from "@dbx-tools/ui-branding/react";
3
4
  import type { UIMessage } from "ai";
4
5
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -39,6 +40,7 @@ import {
39
40
  } from "../support/thread-sessions.ts";
40
41
 
41
42
  const _loadChatExport = () => import("../support/export.ts");
43
+ const EMPTY_REASONING_EFFORTS: readonly ReasoningEffort[] = [];
42
44
 
43
45
  // Self-contained drop-in chat. `useMastraChat` drives the conversation
44
46
  // over `@mastra/client-js`: `agent.stream()` returns a Response
@@ -239,6 +241,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
239
241
  const showModelPicker = Boolean(options.showModelPicker);
240
242
  const modelKey = modelStorageKey(mastraClient.basePath, agentId);
241
243
  const [model, setModel] = useState(() => (showModelPicker ? readStoredModel(modelKey) : ""));
244
+ const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort | undefined>();
242
245
  const handleModelChange = useCallback(
243
246
  (nextModel: string) => {
244
247
  setModel(nextModel);
@@ -256,6 +259,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
256
259
  // `model` to its dependency list.
257
260
  const modelRef = useRef(model);
258
261
  modelRef.current = model;
262
+ const reasoningEffortRef = useRef(reasoningEffort);
263
+ reasoningEffortRef.current = reasoningEffort;
259
264
  const requestContextRef = useRef(options.requestContext);
260
265
  requestContextRef.current = options.requestContext;
261
266
  // Built-in conversation management. When on, the chat always drives an
@@ -355,7 +360,19 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
355
360
  // The humanized name of the model the active agent falls back to when no
356
361
  // model is pinned, so the picker can label its default option. Fetched only
357
362
  // when the picker is shown; `null` when the agent's model is dynamic.
358
- const { defaultModel: defaultModelName } = useMastraDefaultModel(agentId, showModelPicker);
363
+ const {
364
+ defaultModel: defaultModelName,
365
+ defaultModelId,
366
+ loading: defaultModelLoading,
367
+ } = useMastraDefaultModel(agentId, showModelPicker);
368
+ const activeModel = model || defaultModelId || undefined;
369
+ const activeModelOption = models.find((option) => option.name === activeModel);
370
+ const reasoningEfforts = activeModelOption?.reasoningEfforts ?? EMPTY_REASONING_EFFORTS;
371
+ useEffect(() => {
372
+ if (reasoningEffort && !reasoningEfforts.includes(reasoningEffort)) {
373
+ setReasoningEffort(undefined);
374
+ }
375
+ }, [reasoningEffort, reasoningEfforts]);
359
376
  // Starter suggestions: an explicit `options.suggestions` always
360
377
  // wins (including `[]` to force none) and is rendered verbatim;
361
378
  // otherwise auto-source the agent's Genie space sample questions.
@@ -490,6 +507,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
490
507
  // threads or changes the model picker while it streams.
491
508
  const streamThreadId = threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
492
509
  const model = modelRef.current || undefined;
510
+ const turnReasoningEffort = reasoningEffortRef.current;
493
511
  return driveStream(threadId, assistantId, (signal) => {
494
512
  return mastraClient.streamAgent({
495
513
  agentId,
@@ -497,6 +515,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
497
515
  runId,
498
516
  threadId: streamThreadId,
499
517
  model,
518
+ reasoningEffort: turnReasoningEffort,
500
519
  requestContext,
501
520
  signal,
502
521
  });
@@ -970,7 +989,11 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
970
989
  models: showModelPicker ? models : undefined,
971
990
  model,
972
991
  onModelChange: showModelPicker ? handleModelChange : undefined,
992
+ reasoningEffort,
993
+ onReasoningEffortChange: showModelPicker ? setReasoningEffort : undefined,
973
994
  defaultModelName: showModelPicker ? (defaultModelName ?? undefined) : undefined,
995
+ defaultModelId: showModelPicker ? (defaultModelId ?? undefined) : undefined,
996
+ defaultModelLoading: showModelPicker && defaultModelLoading,
974
997
  onLoadMore: loadOlderHistory,
975
998
  isLoadingMore,
976
999
  hasMore: activeSession.hasMoreHistory,
@@ -1028,6 +1051,8 @@ export interface MastraChatProps<
1028
1051
  className?: string;
1029
1052
  /** Host controls placed beside the model picker in the composer footer. */
1030
1053
  composerActions?: ReactNode;
1054
+ /** Host controls shown inside the model selector after its endpoint choices. */
1055
+ modelSelectorActions?: ReactNode;
1031
1056
  /** Host controls placed on the left side of the composer footer. */
1032
1057
  composerLeadingActions?: ReactNode;
1033
1058
  }
@@ -1048,6 +1073,7 @@ export interface MastraChatProps<
1048
1073
  export const MastraChat = <TValues extends Record<string, unknown> = Record<string, unknown>>({
1049
1074
  className,
1050
1075
  composerActions,
1076
+ modelSelectorActions,
1051
1077
  composerLeadingActions,
1052
1078
  ...options
1053
1079
  }: MastraChatProps<TValues>) => {
@@ -1057,6 +1083,7 @@ export const MastraChat = <TValues extends Record<string, unknown> = Record<stri
1057
1083
  {...chat}
1058
1084
  className={className}
1059
1085
  composerActions={composerActions}
1086
+ modelSelectorActions={modelSelectorActions}
1060
1087
  composerLeadingActions={composerLeadingActions}
1061
1088
  />
1062
1089
  );
@@ -184,6 +184,10 @@ export const ThreadSidebar = ({
184
184
  const isStreaming = streamingThreadIds.includes(thread.id);
185
185
  const isConfirming = confirmDeleteId === thread.id;
186
186
  const isEditing = editingId === thread.id;
187
+ const actionCount =
188
+ Number(Boolean(isStreaming && onCancel)) +
189
+ Number(Boolean(onRename)) +
190
+ Number(Boolean(onDelete));
187
191
  if (isEditing) {
188
192
  return (
189
193
  <li key={thread.id}>
@@ -213,8 +217,9 @@ export const ThreadSidebar = ({
213
217
  <li key={thread.id}>
214
218
  <div
215
219
  className={cn(
216
- "group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
220
+ "group relative flex items-center rounded-md px-2 py-1.5 text-sm",
217
221
  "hover:bg-accent hover:text-accent-foreground",
222
+ "focus-within:bg-accent focus-within:text-accent-foreground",
218
223
  isActive && "bg-accent text-accent-foreground",
219
224
  )}
220
225
  >
@@ -222,7 +227,15 @@ export const ThreadSidebar = ({
222
227
  type="button"
223
228
  onClick={() => onSelect(thread.id)}
224
229
  aria-current={isActive ? "page" : undefined}
225
- className="min-w-0 flex-1 cursor-pointer text-left"
230
+ className={cn(
231
+ "min-w-0 flex-1 cursor-pointer text-left transition-[padding] duration-150",
232
+ actionCount === 1 && "group-hover:pr-8 group-focus-within:pr-8",
233
+ actionCount === 2 && "group-hover:pr-14 group-focus-within:pr-14",
234
+ actionCount >= 3 && "group-hover:pr-20 group-focus-within:pr-20",
235
+ (isStreaming || isConfirming) && actionCount === 1 && "pr-8",
236
+ (isStreaming || isConfirming) && actionCount === 2 && "pr-14",
237
+ (isStreaming || isConfirming) && actionCount >= 3 && "pr-20",
238
+ )}
226
239
  >
227
240
  <div className="flex items-center gap-1.5 truncate">
228
241
  {isStreaming && !onCancel ? (
@@ -239,73 +252,81 @@ export const ThreadSidebar = ({
239
252
  </div>
240
253
  )}
241
254
  </button>
242
- {isStreaming && onCancel ? (
243
- <Tooltip>
244
- <TooltipTrigger asChild>
245
- <Button
246
- type="button"
247
- variant="ghost"
248
- size="icon"
249
- onClick={() => onCancel(thread.id)}
250
- aria-label="Stop generating"
251
- className="size-6 shrink-0 text-primary"
252
- >
253
- <Loader2Icon className="size-3 animate-spin group-hover:hidden" />
254
- <SquareIcon className="hidden size-3 fill-current group-hover:block" />
255
- </Button>
256
- </TooltipTrigger>
257
- <TooltipContent>Stop generating</TooltipContent>
258
- </Tooltip>
259
- ) : null}
260
- {onRename && (
261
- <Tooltip>
262
- <TooltipTrigger asChild>
263
- <Button
264
- type="button"
265
- variant="ghost"
266
- size="icon"
267
- onClick={(e) => startEdit(e, thread)}
268
- aria-label="Rename conversation"
269
- className={cn(
270
- "size-6 shrink-0",
271
- "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
272
- )}
273
- >
274
- <PencilIcon className="size-3.5" />
275
- </Button>
276
- </TooltipTrigger>
277
- <TooltipContent>Rename conversation</TooltipContent>
278
- </Tooltip>
279
- )}
280
- {onDelete && (
281
- <Tooltip>
282
- <TooltipTrigger asChild>
283
- <Button
284
- type="button"
285
- variant={isConfirming ? "destructive" : "ghost"}
286
- size="icon"
287
- onClick={(e) => handleDeleteClick(e, thread.id)}
288
- onBlur={() =>
289
- setConfirmDeleteId((id) => (id === thread.id ? null : id))
290
- }
291
- aria-label={
292
- isConfirming ? "Confirm delete conversation" : "Delete conversation"
293
- }
294
- className={cn(
295
- "size-6 shrink-0",
296
- !isConfirming &&
297
- "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
298
- )}
299
- >
300
- <Trash2Icon className="size-3.5" />
301
- </Button>
302
- </TooltipTrigger>
303
- <TooltipContent>
304
- {isConfirming
305
- ? "Click again to delete this conversation"
306
- : "Delete conversation"}
307
- </TooltipContent>
308
- </Tooltip>
255
+ {actionCount > 0 && (
256
+ <div
257
+ className={cn(
258
+ "pointer-events-none absolute inset-y-0 right-1 flex items-center gap-0.5",
259
+ "bg-gradient-to-l from-accent via-accent to-transparent pl-3",
260
+ "opacity-0 transition-opacity duration-150",
261
+ "group-hover:pointer-events-auto group-hover:opacity-100",
262
+ "group-focus-within:pointer-events-auto group-focus-within:opacity-100",
263
+ (isStreaming || isConfirming) && "pointer-events-auto opacity-100",
264
+ )}
265
+ >
266
+ {isStreaming && onCancel ? (
267
+ <Tooltip>
268
+ <TooltipTrigger asChild>
269
+ <Button
270
+ type="button"
271
+ variant="ghost"
272
+ size="icon"
273
+ onClick={() => onCancel(thread.id)}
274
+ aria-label="Stop generating"
275
+ className="size-6 text-primary"
276
+ >
277
+ <Loader2Icon className="size-3 animate-spin group-hover:hidden" />
278
+ <SquareIcon className="hidden size-3 fill-current group-hover:block" />
279
+ </Button>
280
+ </TooltipTrigger>
281
+ <TooltipContent>Stop generating</TooltipContent>
282
+ </Tooltip>
283
+ ) : null}
284
+ {onRename && (
285
+ <Tooltip>
286
+ <TooltipTrigger asChild>
287
+ <Button
288
+ type="button"
289
+ variant="ghost"
290
+ size="icon"
291
+ onClick={(e) => startEdit(e, thread)}
292
+ aria-label="Rename conversation"
293
+ className="size-6"
294
+ >
295
+ <PencilIcon className="size-3.5" />
296
+ </Button>
297
+ </TooltipTrigger>
298
+ <TooltipContent>Rename conversation</TooltipContent>
299
+ </Tooltip>
300
+ )}
301
+ {onDelete && (
302
+ <Tooltip>
303
+ <TooltipTrigger asChild>
304
+ <Button
305
+ type="button"
306
+ variant={isConfirming ? "destructive" : "ghost"}
307
+ size="icon"
308
+ onClick={(e) => handleDeleteClick(e, thread.id)}
309
+ onBlur={() =>
310
+ setConfirmDeleteId((id) => (id === thread.id ? null : id))
311
+ }
312
+ aria-label={
313
+ isConfirming
314
+ ? "Confirm delete conversation"
315
+ : "Delete conversation"
316
+ }
317
+ className="size-6"
318
+ >
319
+ <Trash2Icon className="size-3.5" />
320
+ </Button>
321
+ </TooltipTrigger>
322
+ <TooltipContent>
323
+ {isConfirming
324
+ ? "Click again to delete this conversation"
325
+ : "Delete conversation"}
326
+ </TooltipContent>
327
+ </Tooltip>
328
+ )}
329
+ </div>
309
330
  )}
310
331
  </div>
311
332
  </li>
@@ -1,4 +1,5 @@
1
1
  import type { GenieWriterEvent } from "@dbx-tools/shared-mastra";
2
+ import type { ReasoningEffort } from "@dbx-tools/shared-model";
2
3
  import type { UIMessage } from "ai";
3
4
  import type { ReactNode } from "react";
4
5
  import type { ExportFormat } from "../support/export.ts";
@@ -44,7 +45,12 @@ export type ToolProgress = GenieWriterEvent;
44
45
  * `/models` endpoint derives (Databricks-provided name, else a title-cased
45
46
  * rendering of `name`). The picker shows `displayName ?? name`.
46
47
  */
47
- export type ChatModelOption = { name: string; displayName?: string };
48
+ export type ChatModelOption = {
49
+ name: string;
50
+ displayName?: string;
51
+ family?: string;
52
+ reasoningEfforts?: ReasoningEffort[];
53
+ };
48
54
 
49
55
  /**
50
56
  * A steer message submitted while a turn was already streaming. It waits in a
@@ -168,6 +174,10 @@ export type ChatViewProps = {
168
174
  /** Currently selected model name; empty string means "use server default". */
169
175
  model?: string;
170
176
  onModelChange?: (model: string) => void;
177
+ /** Optional reasoning effort applied to the next turn. Undefined uses the provider default. */
178
+ reasoningEffort?: ReasoningEffort;
179
+ /** Update the per-turn reasoning effort. */
180
+ onReasoningEffortChange?: (effort: ReasoningEffort | undefined) => void;
171
181
  /**
172
182
  * The serving-endpoint fallback the server uses when no model is pinned,
173
183
  * already humanized (the server's `displayName`, e.g. "Claude Sonnet 4.6").
@@ -175,12 +185,21 @@ export type ChatViewProps = {
175
185
  * a neutral "Default". Never a raw endpoint id.
176
186
  */
177
187
  defaultModelName?: string;
188
+ /** Endpoint id behind the unpinned default, used to resolve family and reasoning metadata. */
189
+ defaultModelId?: string;
190
+ /** True while the server resolves the unpinned agent's live default model. */
191
+ defaultModelLoading?: boolean;
178
192
  /**
179
193
  * Host-owned controls rendered in the composer footer immediately before the
180
194
  * model selector. Use this for per-turn options that belong beside model
181
195
  * routing without forking the chat surface.
182
196
  */
183
197
  composerActions?: ReactNode;
198
+ /**
199
+ * Host-owned controls rendered inside the model selector after the endpoint
200
+ * choices. Omit when the active agent has no matching model-adjacent option.
201
+ */
202
+ modelSelectorActions?: ReactNode;
184
203
  /**
185
204
  * Host-owned actions rendered on the left side of the composer's fixed
186
205
  * footer, before Export and Clear.
@@ -6,12 +6,13 @@ import {
6
6
  thread,
7
7
  wire,
8
8
  type Chart,
9
+ type DefaultModelResponse,
9
10
  type MastraClientConfig,
10
11
  type MastraFeedbackRequest,
11
12
  type MastraFeedbackResponse,
12
13
  type StatementData,
13
14
  } from "@dbx-tools/shared-mastra";
14
- import type { ServingEndpointSummary } from "@dbx-tools/shared-model";
15
+ import type { ReasoningEffort, ServingEndpointSummary } from "@dbx-tools/shared-model";
15
16
  import { usePluginClientConfig } from "@dbx-tools/ui-appkit/react";
16
17
  import {
17
18
  MastraClient,
@@ -166,6 +167,7 @@ export class MastraPluginClient extends MastraClient {
166
167
  runId: string;
167
168
  threadId?: string;
168
169
  model?: string;
170
+ reasoningEffort?: ReasoningEffort;
169
171
  requestContext?: MastraRequestContextSnapshot;
170
172
  signal?: AbortSignal;
171
173
  }): Promise<MastraStreamResponse> {
@@ -175,6 +177,9 @@ export class MastraPluginClient extends MastraClient {
175
177
  messages: params.messages,
176
178
  body: {
177
179
  runId: params.runId,
180
+ ...(params.reasoningEffort
181
+ ? { providerOptions: { openai: { reasoningEffort: params.reasoningEffort } } }
182
+ : {}),
178
183
  ...(params.requestContext ? { requestContext: params.requestContext } : {}),
179
184
  },
180
185
  routing: params,
@@ -271,12 +276,18 @@ export class MastraPluginClient extends MastraClient {
271
276
  * arrives. Defaults to the server's default agent when `agentId` is omitted.
272
277
  */
273
278
  async defaultModel(agentId?: string, signal?: AbortSignal): Promise<string | null> {
279
+ const payload = await this.defaultModelDetails(agentId, signal);
280
+ return payload.displayName ?? payload.model;
281
+ }
282
+
283
+ /** Fetch the complete resolved default-model descriptor for an agent. */
284
+ async defaultModelDetails(agentId?: string, signal?: AbortSignal): Promise<DefaultModelResponse> {
274
285
  const payload = await this.#getJson(
275
286
  this.#agentScoped(routes.MASTRA_ROUTES.defaultModel, agentId),
276
287
  wire.DefaultModelResponseSchema,
277
288
  signal,
278
289
  );
279
- return payload.displayName ?? payload.model;
290
+ return payload;
280
291
  }
281
292
 
282
293
  /**
@@ -619,27 +630,34 @@ export const useMastraModels = (
619
630
  export const useMastraDefaultModel = (
620
631
  agentId?: string,
621
632
  enabled = true,
622
- ): { defaultModel: string | null; loading: boolean } => {
633
+ ): { defaultModel: string | null; defaultModelId: string | null; loading: boolean } => {
623
634
  const client = useMastraClient();
624
635
  const [model, setModel] = useState<string | null>(null);
636
+ const [modelId, setModelId] = useState<string | null>(null);
625
637
  const [loading, setLoading] = useState(enabled);
626
638
 
627
639
  useEffect(() => {
628
640
  if (!enabled) {
629
641
  setModel(null);
642
+ setModelId(null);
630
643
  setLoading(false);
631
644
  return;
632
645
  }
633
646
  const controller = new AbortController();
634
647
  setLoading(true);
635
648
  client
636
- .defaultModel(agentId, controller.signal)
637
- .then((m) => {
638
- if (!controller.signal.aborted) setModel(m);
649
+ .defaultModelDetails(agentId, controller.signal)
650
+ .then((details) => {
651
+ if (controller.signal.aborted) return;
652
+ setModel(details.displayName ?? details.model);
653
+ setModelId(details.model);
639
654
  })
640
655
  .catch(() => {
641
656
  // Non-critical: a missing default just yields a plain "Default".
642
- if (!controller.signal.aborted) setModel(null);
657
+ if (!controller.signal.aborted) {
658
+ setModel(null);
659
+ setModelId(null);
660
+ }
643
661
  })
644
662
  .finally(() => {
645
663
  if (!controller.signal.aborted) setLoading(false);
@@ -647,7 +665,7 @@ export const useMastraDefaultModel = (
647
665
  return () => controller.abort();
648
666
  }, [client, agentId, enabled]);
649
667
 
650
- return { defaultModel: model, loading };
668
+ return { defaultModel: model, defaultModelId: modelId, loading };
651
669
  };
652
670
 
653
671
  /**