@dbx-tools/ui-mastra 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -26,18 +26,18 @@
26
26
  "shiki": "^3.0.0",
27
27
  "sql-formatter": "^15.6.9",
28
28
  "streamdown": "^2.5.0",
29
- "@dbx-tools/shared-core": "0.1.22",
30
- "@dbx-tools/shared-genie": "0.1.22",
31
- "@dbx-tools/shared-mastra": "0.1.22",
32
- "@dbx-tools/shared-model": "0.1.22",
33
- "@dbx-tools/ui-appkit": "0.1.22"
29
+ "@dbx-tools/shared-core": "0.1.23",
30
+ "@dbx-tools/shared-mastra": "0.1.23",
31
+ "@dbx-tools/ui-appkit": "0.1.23",
32
+ "@dbx-tools/shared-genie": "0.1.23",
33
+ "@dbx-tools/shared-model": "0.1.23"
34
34
  },
35
35
  "main": "index.ts",
36
36
  "license": "UNLICENSED",
37
37
  "publishConfig": {
38
38
  "access": "public"
39
39
  },
40
- "version": "0.1.22",
40
+ "version": "0.1.23",
41
41
  "types": "index.ts",
42
42
  "type": "module",
43
43
  "exports": {
@@ -63,6 +63,30 @@ const TOP_LOAD_MORE_THRESHOLD_PX = 120;
63
63
  */
64
64
  const DEFAULT_MODEL_VALUE = "__default__";
65
65
 
66
+ /** Tailwind's `md` breakpoint (px). Below this the sidebar becomes a drawer. */
67
+ const MOBILE_BREAKPOINT_PX = 768;
68
+
69
+ /**
70
+ * `true` on a phone-width viewport (< {@link MOBILE_BREAKPOINT_PX}). Tracks
71
+ * `matchMedia` so the layout switches live on resize/rotate. SSR-safe: defaults
72
+ * to `false` (desktop) when `window` is unavailable.
73
+ */
74
+ const useIsMobile = (): boolean => {
75
+ const query = `(max-width: ${MOBILE_BREAKPOINT_PX - 1}px)`;
76
+ const [isMobile, setIsMobile] = useState(() =>
77
+ typeof window === "undefined" ? false : window.matchMedia(query).matches,
78
+ );
79
+ useEffect(() => {
80
+ if (typeof window === "undefined") return;
81
+ const mql = window.matchMedia(query);
82
+ const onChange = () => setIsMobile(mql.matches);
83
+ onChange();
84
+ mql.addEventListener("change", onChange);
85
+ return () => mql.removeEventListener("change", onChange);
86
+ }, [query]);
87
+ return isMobile;
88
+ };
89
+
66
90
  export const ChatView = ({
67
91
  messages,
68
92
  status,
@@ -101,6 +125,8 @@ export const ChatView = ({
101
125
  const [input, setInput] = useState("");
102
126
  const scrollRef = useRef<HTMLDivElement>(null);
103
127
  const contentRef = useRef<HTMLDivElement>(null);
128
+ // Composer textarea, auto-grown with its content up to the CSS `max-h`.
129
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
104
130
  const [isAtBottom, setIsAtBottom] = useState(true);
105
131
  // Mirror `isAtBottom` into a ref so the ResizeObserver below (created
106
132
  // per transcript mount, not per render) reads the latest value
@@ -194,6 +220,16 @@ export const ChatView = ({
194
220
  });
195
221
  };
196
222
 
223
+ // Grow the composer with its content (up to the textarea's CSS `max-h`,
224
+ // after which it scrolls internally), then shrink back as text is removed.
225
+ // Runs on every `input` change so paste / multi-line typing feels native.
226
+ useLayoutEffect(() => {
227
+ const el = textareaRef.current;
228
+ if (!el) return;
229
+ el.style.height = "auto";
230
+ el.style.height = `${el.scrollHeight}px`;
231
+ }, [input]);
232
+
197
233
  // A turn is in flight from the moment the run opens (`submitted`)
198
234
  // until the server signals done (`ready`/`error`). Used to gate new
199
235
  // submissions and to swap the composer's Send button for Stop.
@@ -208,6 +244,15 @@ export const ChatView = ({
208
244
  if (!text) return;
209
245
  sendMessage({ text });
210
246
  setInput("");
247
+ // Sending is an explicit "I want to see the response" action, so
248
+ // re-pin to the bottom even if the user had scrolled up. Without this
249
+ // the freshly-appended user turn + waiting row can leave `isAtBottom`
250
+ // false, suppressing auto-scroll and forcing a manual scroll down.
251
+ setIsAtBottom(true);
252
+ requestAnimationFrame(() => {
253
+ const el = scrollRef.current;
254
+ if (el) el.scrollTop = el.scrollHeight;
255
+ });
211
256
  };
212
257
 
213
258
  const lastMessage = messages.at(-1);
@@ -233,7 +278,18 @@ export const ChatView = ({
233
278
  ? "Working..."
234
279
  : "Composing response...";
235
280
 
236
- const showModelPicker = Boolean(models && models.length > 0 && onModelChange);
281
+ // Model display is intent-driven, not load-driven: as soon as the host
282
+ // wires `onModelChange` we reserve the row and show the current model,
283
+ // so it never "pops in" once the async catalogue lands. It renders as a
284
+ // clickable picker only when there's an actual choice (models loaded),
285
+ // otherwise as static text.
286
+ const showModelDisplay = Boolean(onModelChange);
287
+ const modelChangeable = Boolean(models && models.length > 0);
288
+ // Label the current model by its human-readable name when the catalogue
289
+ // carries one (`displayName`), falling back to the raw endpoint id, then
290
+ // to "Server default" when no model is pinned.
291
+ const currentModelLabel =
292
+ models?.find((m) => m.name === model)?.displayName || model || "Server default";
237
293
  const showClear = Boolean(onClear);
238
294
  const showExport = Boolean(onExportConversation);
239
295
  // The conversation sidebar turns on once the host wires both the
@@ -244,12 +300,35 @@ export const ChatView = ({
244
300
  // open flag. Defaults to open.
245
301
  const showSidebar = Boolean(threads && onSelectThread);
246
302
  const [internalSidebarOpen, setInternalSidebarOpen] = useState(true);
247
- const sidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
248
- const toggleSidebar = () => {
303
+ // Desktop inline-sidebar open state (persisted by the driver when it
304
+ // supplies `sidebarOpen`/`onToggleSidebar`, else a session-only flag).
305
+ const desktopSidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
306
+ const toggleDesktopSidebar = () => {
249
307
  if (onToggleSidebar) onToggleSidebar();
250
308
  else setInternalSidebarOpen((open) => !open);
251
309
  };
252
- const showHeader = showModelPicker || showClear || showSidebar || showExport;
310
+ // Are we on a phone-width viewport (< md / 768px)? Drives whether the
311
+ // sidebar renders inline (desktop) or as an overlay drawer (mobile), and
312
+ // which open-state the header toggle flips.
313
+ const isMobile = useIsMobile();
314
+ // Mobile drawer is a SESSION-only, default-closed state so a persisted
315
+ // "open" desktop preference never auto-opens the drawer over the chat on a
316
+ // phone. Reset closed whenever we drop back to a mobile viewport.
317
+ const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
318
+ useEffect(() => {
319
+ if (!isMobile) setMobileDrawerOpen(false);
320
+ }, [isMobile]);
321
+ // Unified state/handlers the render + header use, resolved by viewport.
322
+ const sidebarOpen = isMobile ? mobileDrawerOpen : desktopSidebarOpen;
323
+ const toggleSidebar = () => {
324
+ if (isMobile) setMobileDrawerOpen((open) => !open);
325
+ else toggleDesktopSidebar();
326
+ };
327
+ // Top bar carries only the sidebar toggle + Clear now; the model picker and
328
+ // export live in a toolbar row just above the composer (Janna-style), closer
329
+ // to where the user is typing.
330
+ const showHeader = showClear || showSidebar;
331
+ const showComposerToolbar = showModelDisplay || showExport;
253
332
 
254
333
  // Local "in-flight" + confirm latch for the clear button so the
255
334
  // user can't double-fire the DELETE and so a stray click doesn't
@@ -283,29 +362,77 @@ export const ChatView = ({
283
362
  * with the message column regardless of whether a scrollbar is
284
363
  * showing.
285
364
  */}
286
- <div className={cn("flex h-full", className)}>
287
- {showSidebar && sidebarOpen && (
288
- <ThreadSidebar
289
- threads={threads ?? []}
290
- {...(activeThreadId ? { activeThreadId } : {})}
291
- streamingThreadIds={streamingThreadIds}
292
- isLoading={isLoadingThreads}
293
- onSelect={(id) => onSelectThread?.(id)}
294
- {...(onNewThread ? { onNew: onNewThread } : {})}
295
- {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
296
- {...(onRenameThread ? { onRename: onRenameThread } : {})}
297
- onHide={toggleSidebar}
298
- />
299
- )}
300
- <div className="flex h-full min-w-0 flex-1 flex-col py-0 md:py-6">
365
+ <div className={cn("flex h-full min-h-0", className)}>
366
+ {showSidebar &&
367
+ (isMobile ? (
368
+ /*
369
+ * Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
370
+ * the conversation list never eats horizontal space from the chat
371
+ * on a phone. Selecting a thread / starting a new one closes it so
372
+ * the transcript comes back into view. Session-only + default
373
+ * closed (see `mobileDrawerOpen`).
374
+ */
375
+ mobileDrawerOpen && (
376
+ <div className="fixed inset-0 z-40 flex">
377
+ <div
378
+ className="absolute inset-0 bg-black/50"
379
+ onClick={toggleSidebar}
380
+ aria-hidden="true"
381
+ />
382
+ <ThreadSidebar
383
+ threads={threads ?? []}
384
+ {...(activeThreadId ? { activeThreadId } : {})}
385
+ streamingThreadIds={streamingThreadIds}
386
+ isLoading={isLoadingThreads}
387
+ onSelect={(id) => {
388
+ onSelectThread?.(id);
389
+ toggleSidebar();
390
+ }}
391
+ {...(onNewThread
392
+ ? {
393
+ onNew: () => {
394
+ onNewThread();
395
+ toggleSidebar();
396
+ },
397
+ }
398
+ : {})}
399
+ {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
400
+ {...(onRenameThread ? { onRename: onRenameThread } : {})}
401
+ onHide={toggleSidebar}
402
+ className="relative z-10 w-[85vw] max-w-xs shadow-xl"
403
+ />
404
+ </div>
405
+ )
406
+ ) : (
407
+ /*
408
+ * Desktop: an inline flex child sharing the row with the chat
409
+ * column, using the persisted open/hide preference.
410
+ */
411
+ desktopSidebarOpen && (
412
+ <ThreadSidebar
413
+ threads={threads ?? []}
414
+ {...(activeThreadId ? { activeThreadId } : {})}
415
+ streamingThreadIds={streamingThreadIds}
416
+ isLoading={isLoadingThreads}
417
+ onSelect={(id) => onSelectThread?.(id)}
418
+ {...(onNewThread ? { onNew: onNewThread } : {})}
419
+ {...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
420
+ {...(onRenameThread ? { onRename: onRenameThread } : {})}
421
+ onHide={toggleSidebar}
422
+ />
423
+ )
424
+ ))}
425
+ <div className="flex h-full min-w-0 flex-1 flex-col">
301
426
  {showHeader && (
302
- <div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-3 px-4 pb-2 pt-1 text-xs text-muted-foreground md:px-6">
303
- <div className="flex items-center gap-2">
427
+ <div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6">
428
+ <div className="flex shrink-0 items-center gap-2">
304
429
  {/*
305
- * The hide control lives on the sidebar itself; the header
306
- * only offers a "show" toggle while the sidebar is collapsed.
430
+ * Sidebar toggle. On mobile it's a persistent hamburger (the
431
+ * overlay drawer has no always-visible hide button). On desktop
432
+ * the panel hides itself, so the header only needs a "show"
433
+ * affordance while the inline sidebar is collapsed.
307
434
  */}
308
- {showSidebar && !sidebarOpen && (
435
+ {showSidebar && (isMobile || !desktopSidebarOpen) && (
309
436
  <Tooltip>
310
437
  <TooltipTrigger asChild>
311
438
  <Button
@@ -313,47 +440,18 @@ export const ChatView = ({
313
440
  variant="ghost"
314
441
  size="icon-sm"
315
442
  onClick={toggleSidebar}
316
- aria-label="Show conversations"
443
+ aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
317
444
  >
318
445
  <PanelLeftIcon className="size-4" />
319
446
  </Button>
320
447
  </TooltipTrigger>
321
- <TooltipContent>Show conversations</TooltipContent>
448
+ <TooltipContent>
449
+ {sidebarOpen ? "Hide conversations" : "Show conversations"}
450
+ </TooltipContent>
322
451
  </Tooltip>
323
452
  )}
324
453
  </div>
325
- <div className="flex items-center gap-3">
326
- {showModelPicker && (
327
- <div className="flex items-center gap-2">
328
- <span>Model</span>
329
- <Select
330
- value={model ? model : DEFAULT_MODEL_VALUE}
331
- onValueChange={(v) =>
332
- onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)
333
- }
334
- >
335
- <SelectTrigger size="sm" className="w-[260px]">
336
- <SelectValue placeholder="Server default" />
337
- </SelectTrigger>
338
- <SelectContent>
339
- <SelectItem value={DEFAULT_MODEL_VALUE}>
340
- Server default
341
- </SelectItem>
342
- {models!.map((m) => (
343
- <SelectItem key={m.name} value={m.name}>
344
- {m.name}
345
- </SelectItem>
346
- ))}
347
- </SelectContent>
348
- </Select>
349
- </div>
350
- )}
351
- {showExport && (
352
- <ExportMenu
353
- onExport={(format) => void onExportConversation?.(format)}
354
- tooltip="Export conversation"
355
- />
356
- )}
454
+ <div className="flex min-w-0 items-center justify-end gap-2 md:gap-3">
357
455
  {showClear && (
358
456
  <Tooltip>
359
457
  <TooltipTrigger asChild>
@@ -397,7 +495,7 @@ export const ChatView = ({
397
495
  <div
398
496
  ref={scrollRef}
399
497
  onScroll={handleScroll}
400
- className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
498
+ className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [scrollbar-gutter:stable]"
401
499
  >
402
500
  {messages.length === 0 && !isLoadingHistory ? (
403
501
  <Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
@@ -499,7 +597,7 @@ export const ChatView = ({
499
597
  )}
500
598
  </div>
501
599
  {!isAtBottom && (
502
- <div className="pointer-events-none absolute inset-x-0 bottom-4 mx-auto flex w-full max-w-4xl justify-end px-4 md:px-6">
600
+ <div className="pointer-events-none absolute inset-x-0 bottom-4 z-20 mx-auto flex w-full max-w-4xl justify-end px-4 md:px-6">
503
601
  <Button
504
602
  type="button"
505
603
  variant="outline"
@@ -523,10 +621,11 @@ export const ChatView = ({
523
621
 
524
622
  <form
525
623
  onSubmit={handleSubmit}
526
- className="mx-auto w-full max-w-4xl px-4 pb-4 pt-2 md:px-6"
624
+ className="mx-auto w-full max-w-4xl px-3 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6"
527
625
  >
528
- <InputGroup>
626
+ <InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
529
627
  <InputGroupTextarea
628
+ ref={textareaRef}
530
629
  value={input}
531
630
  onChange={(e) => setInput(e.target.value)}
532
631
  onKeyDown={(e) => {
@@ -537,6 +636,7 @@ export const ChatView = ({
537
636
  }}
538
637
  placeholder="Send a message..."
539
638
  rows={1}
639
+ className="max-h-48 text-base md:text-sm"
540
640
  />
541
641
  <InputGroupAddon align="inline-end">
542
642
  {isRunning && onStop ? (
@@ -566,6 +666,44 @@ export const ChatView = ({
566
666
  )}
567
667
  </InputGroupAddon>
568
668
  </InputGroup>
669
+ {showComposerToolbar && (
670
+ <div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
671
+ {showModelDisplay &&
672
+ (modelChangeable ? (
673
+ <Select
674
+ value={model ? model : DEFAULT_MODEL_VALUE}
675
+ onValueChange={(v) =>
676
+ onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)
677
+ }
678
+ >
679
+ <SelectTrigger
680
+ size="sm"
681
+ className="h-7 w-auto max-w-[200px] gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
682
+ >
683
+ <SelectValue placeholder="Server default" />
684
+ </SelectTrigger>
685
+ <SelectContent>
686
+ <SelectItem value={DEFAULT_MODEL_VALUE}>Server default</SelectItem>
687
+ {models!.map((m) => (
688
+ <SelectItem key={m.name} value={m.name}>
689
+ {m.displayName || m.name}
690
+ </SelectItem>
691
+ ))}
692
+ </SelectContent>
693
+ </Select>
694
+ ) : (
695
+ <span className="max-w-[200px] truncate px-2.5 text-xs text-muted-foreground">
696
+ {currentModelLabel}
697
+ </span>
698
+ ))}
699
+ {showExport && (
700
+ <ExportMenu
701
+ onExport={(format) => void onExportConversation?.(format)}
702
+ tooltip="Export conversation"
703
+ />
704
+ )}
705
+ </div>
706
+ )}
569
707
  </form>
570
708
  </div>
571
709
  </div>
@@ -41,7 +41,12 @@ export const ExportMenu = ({
41
41
  <DownloadIcon className="size-3" />
42
42
  </Button>
43
43
  ) : (
44
- <Button type="button" size="sm" variant="outline" className="gap-1.5">
44
+ <Button
45
+ type="button"
46
+ size="sm"
47
+ variant="outline"
48
+ className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
49
+ >
45
50
  <DownloadIcon className="size-3" />
46
51
  Export
47
52
  </Button>
@@ -111,7 +111,7 @@ export const ThreadSidebar = ({
111
111
  return (
112
112
  <div
113
113
  className={cn(
114
- "flex h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30",
114
+ "flex h-full w-64 shrink-0 flex-col border-r border-border bg-background",
115
115
  className,
116
116
  )}
117
117
  >
@@ -33,8 +33,13 @@ export type ToolEvent = {
33
33
  */
34
34
  export type ToolProgress = GenieWriterEvent;
35
35
 
36
- /** Subset of a Model Serving endpoint surfaced in the model picker. */
37
- export type ChatModelOption = { name: string };
36
+ /**
37
+ * Subset of a Model Serving endpoint surfaced in the model picker. `name`
38
+ * is the invoke id; `displayName` is the optional human-readable label the
39
+ * `/models` endpoint derives (Databricks-provided name, else a title-cased
40
+ * rendering of `name`). The picker shows `displayName ?? name`.
41
+ */
42
+ export type ChatModelOption = { name: string; displayName?: string };
38
43
 
39
44
  /** Thumbs reaction a user can leave on an assistant turn. */
40
45
  export type FeedbackValue = "up" | "down";