@dbx-tools/ui-mastra 0.6.218 → 0.6.220

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.
@@ -9,7 +9,7 @@ import {
9
9
  cn,
10
10
  } from "@dbx-tools/ui-appkit/react";
11
11
  import { HistoryIcon, Loader2Icon, PlusIcon, XIcon } from "lucide-react";
12
- import { useEffect, useRef, useState } from "react";
12
+ import { type ReactNode, useEffect, useRef, useState } from "react";
13
13
  import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar.tsx";
14
14
  import { threadTitle } from "../support/thread-labels.ts";
15
15
 
@@ -36,8 +36,90 @@ export interface ThreadTabsProps extends Omit<ThreadSidebarProps, "onHide" | "si
36
36
  * affordance hidden when omitted.
37
37
  */
38
38
  onCloseTab?: (threadId: string) => void;
39
+ /** Host actions appended after New and History. */
40
+ actions?: ReactNode;
41
+ /** Render New/History/actions in this row. Defaults to true. */
42
+ showActions?: boolean;
43
+ /** Replacement for the default plus icon. */
44
+ newIcon?: ReactNode;
45
+ /** Replacement for the default history icon. */
46
+ historyIcon?: ReactNode;
39
47
  }
40
48
 
49
+ /** New/history controls reused by the tab strip and persistent assistant header. */
50
+ export interface ThreadActionsProps extends Omit<
51
+ ThreadSidebarProps,
52
+ "onHide" | "side" | "className"
53
+ > {
54
+ actions?: ReactNode;
55
+ newIcon?: ReactNode;
56
+ historyIcon?: ReactNode;
57
+ }
58
+
59
+ /** Render New and History controls, plus optional host actions, as one group. */
60
+ export const ThreadActions = ({ actions, newIcon, historyIcon, ...list }: ThreadActionsProps) => {
61
+ const { onSelect, onNew } = list;
62
+ const [historyOpen, setHistoryOpen] = useState(false);
63
+ return (
64
+ <>
65
+ {onNew && (
66
+ <Tooltip>
67
+ <TooltipTrigger asChild>
68
+ <Button
69
+ type="button"
70
+ variant="ghost"
71
+ size="icon"
72
+ onClick={onNew}
73
+ aria-label="New chat"
74
+ className="size-7 shrink-0"
75
+ >
76
+ {newIcon ?? <PlusIcon className="size-4" />}
77
+ </Button>
78
+ </TooltipTrigger>
79
+ <TooltipContent>New chat</TooltipContent>
80
+ </Tooltip>
81
+ )}
82
+ <Popover open={historyOpen} onOpenChange={setHistoryOpen}>
83
+ <Tooltip>
84
+ <TooltipTrigger asChild>
85
+ <PopoverTrigger asChild>
86
+ <Button
87
+ type="button"
88
+ variant="ghost"
89
+ size="icon"
90
+ aria-label="Conversation history"
91
+ className="size-7 shrink-0"
92
+ >
93
+ {historyIcon ?? <HistoryIcon className="size-4" />}
94
+ </Button>
95
+ </PopoverTrigger>
96
+ </TooltipTrigger>
97
+ <TooltipContent>Conversation history</TooltipContent>
98
+ </Tooltip>
99
+ <PopoverContent align="end" aria-label="Conversation history" className="w-72 p-0">
100
+ <ThreadSidebar
101
+ {...list}
102
+ onSelect={(id) => {
103
+ onSelect(id);
104
+ setHistoryOpen(false);
105
+ }}
106
+ {...(onNew
107
+ ? {
108
+ onNew: () => {
109
+ onNew();
110
+ setHistoryOpen(false);
111
+ },
112
+ }
113
+ : {})}
114
+ className="h-96 w-full border-none"
115
+ />
116
+ </PopoverContent>
117
+ </Popover>
118
+ {actions}
119
+ </>
120
+ );
121
+ };
122
+
41
123
  /**
42
124
  * Tab strip for the `top` thread placement. Each open conversation is a tab
43
125
  * showing its title, a spinner while it streams in the background, and a close
@@ -47,11 +129,17 @@ export interface ThreadTabsProps extends Omit<ThreadSidebarProps, "onHide" | "si
47
129
  * into view when the selection changes, so switching from history never leaves
48
130
  * the current conversation off-screen.
49
131
  */
50
- export const ThreadTabs = ({ openThreadIds, onCloseTab, className, ...list }: ThreadTabsProps) => {
51
- const { threads, activeThreadId, streamingThreadIds = [], onSelect, onNew } = list;
52
- // Controlled so picking a conversation (or starting one) dismisses the menu
53
- // instead of leaving it open over the transcript.
54
- const [historyOpen, setHistoryOpen] = useState(false);
132
+ export const ThreadTabs = ({
133
+ openThreadIds,
134
+ onCloseTab,
135
+ actions,
136
+ showActions = true,
137
+ newIcon,
138
+ historyIcon,
139
+ className,
140
+ ...list
141
+ }: ThreadTabsProps) => {
142
+ const { threads, activeThreadId, streamingThreadIds = [], onSelect } = list;
55
143
  const activeTabRef = useRef<HTMLDivElement | null>(null);
56
144
  useEffect(() => {
57
145
  activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
@@ -62,129 +150,65 @@ export const ThreadTabs = ({ openThreadIds, onCloseTab, className, ...list }: Th
62
150
  // in the same centered column as the transcript and composer.
63
151
  <div className={cn("w-full border-b border-border text-xs", className)}>
64
152
  <div className="mx-auto flex w-full max-w-4xl items-center gap-1 px-2 py-1 md:px-4">
65
- <div role="tablist" className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
153
+ <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
66
154
  {openThreadIds.map((id) => {
67
155
  const thread = threads.find((t) => t.id === id) ?? { id };
68
156
  const isActive = id === activeThreadId;
69
157
  const isStreaming = streamingThreadIds.includes(id);
70
158
  const title = threadTitle(thread);
71
159
  return (
72
- <Tooltip key={id}>
73
- <TooltipTrigger asChild>
74
- <div
75
- ref={isActive ? activeTabRef : undefined}
76
- role="tab"
77
- aria-selected={isActive}
78
- tabIndex={0}
79
- onClick={() => onSelect(id)}
80
- onKeyDown={(e) => {
81
- if (e.key === "Enter" || e.key === " ") {
82
- e.preventDefault();
83
- onSelect(id);
84
- }
85
- }}
160
+ <div
161
+ key={id}
162
+ ref={isActive ? activeTabRef : undefined}
163
+ className={cn(
164
+ "group flex max-w-[12rem] shrink-0 items-center rounded-md border border-transparent",
165
+ "hover:bg-accent hover:text-accent-foreground",
166
+ isActive && "border-border bg-accent text-accent-foreground",
167
+ )}
168
+ >
169
+ <Tooltip>
170
+ <TooltipTrigger asChild>
171
+ <button
172
+ type="button"
173
+ onClick={() => onSelect(id)}
174
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 px-2 py-1 text-left"
175
+ aria-current={isActive ? "page" : undefined}
176
+ >
177
+ {isStreaming && (
178
+ <Loader2Icon
179
+ aria-label="Streaming"
180
+ className="size-3 shrink-0 animate-spin text-primary"
181
+ />
182
+ )}
183
+ <span className="truncate">{title}</span>
184
+ </button>
185
+ </TooltipTrigger>
186
+ <TooltipContent>{title}</TooltipContent>
187
+ </Tooltip>
188
+ {onCloseTab && (
189
+ <Button
190
+ type="button"
191
+ variant="ghost"
192
+ size="icon"
193
+ onClick={() => onCloseTab(id)}
194
+ aria-label="Close tab"
86
195
  className={cn(
87
- "group flex max-w-[12rem] shrink-0 cursor-pointer items-center gap-1.5",
88
- "rounded-md border border-transparent px-2 py-1",
89
- "hover:bg-accent hover:text-accent-foreground",
90
- isActive && "border-border bg-accent text-accent-foreground",
196
+ "mr-1 size-[24px] shrink-0",
197
+ // The active tab always shows its close button; the rest
198
+ // reveal one on hover / keyboard focus.
199
+ !isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
91
200
  )}
92
201
  >
93
- {isStreaming && (
94
- <Loader2Icon
95
- aria-label="Streaming"
96
- className="size-3 shrink-0 animate-spin text-primary"
97
- />
98
- )}
99
- <span className="truncate">{title}</span>
100
- {onCloseTab && (
101
- <Button
102
- type="button"
103
- variant="ghost"
104
- size="icon"
105
- onClick={(e) => {
106
- e.stopPropagation();
107
- onCloseTab(id);
108
- }}
109
- aria-label="Close tab"
110
- className={cn(
111
- "size-4 shrink-0",
112
- // The active tab always shows its close button; the
113
- // rest reveal one on hover / keyboard focus so the
114
- // strip stays quiet.
115
- !isActive &&
116
- "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
117
- )}
118
- >
119
- <XIcon className="size-3" />
120
- </Button>
121
- )}
122
- </div>
123
- </TooltipTrigger>
124
- <TooltipContent>{title}</TooltipContent>
125
- </Tooltip>
202
+ <XIcon className="size-3" />
203
+ </Button>
204
+ )}
205
+ </div>
126
206
  );
127
207
  })}
128
208
  </div>
129
- {onNew && (
130
- <Tooltip>
131
- <TooltipTrigger asChild>
132
- <Button
133
- type="button"
134
- variant="ghost"
135
- size="icon"
136
- onClick={onNew}
137
- aria-label="New chat"
138
- className="size-7 shrink-0"
139
- >
140
- <PlusIcon className="size-4" />
141
- </Button>
142
- </TooltipTrigger>
143
- <TooltipContent>New chat</TooltipContent>
144
- </Tooltip>
145
- )}
146
- <Popover open={historyOpen} onOpenChange={setHistoryOpen}>
147
- <Tooltip>
148
- <TooltipTrigger asChild>
149
- <PopoverTrigger asChild>
150
- <Button
151
- type="button"
152
- variant="ghost"
153
- size="icon"
154
- aria-label="Conversation history"
155
- className="size-7 shrink-0"
156
- >
157
- <HistoryIcon className="size-4" />
158
- </Button>
159
- </PopoverTrigger>
160
- </TooltipTrigger>
161
- <TooltipContent>Conversation history</TooltipContent>
162
- </Tooltip>
163
- <PopoverContent align="end" className="w-72 p-0">
164
- {/*
165
- * The same list the docked placements render, just framed as a menu:
166
- * selecting (or starting) a conversation opens it as a tab and
167
- * dismisses the popover, while rename / delete / cancel work in
168
- * place.
169
- */}
170
- <ThreadSidebar
171
- {...list}
172
- onSelect={(id) => {
173
- onSelect(id);
174
- setHistoryOpen(false);
175
- }}
176
- {...(onNew
177
- ? {
178
- onNew: () => {
179
- onNew();
180
- setHistoryOpen(false);
181
- },
182
- }
183
- : {})}
184
- className="h-96 w-full border-none"
185
- />
186
- </PopoverContent>
187
- </Popover>
209
+ {showActions ? (
210
+ <ThreadActions {...list} actions={actions} newIcon={newIcon} historyIcon={historyIcon} />
211
+ ) : null}
188
212
  </div>
189
213
  </div>
190
214
  );
@@ -452,6 +452,26 @@ const ToolStatusIcon = ({ status }: { status: "running" | "done" | "error" }) =>
452
452
  return <CheckIcon className="size-3 text-muted-foreground" />;
453
453
  };
454
454
 
455
+ /** Pretty-print a complete tool payload without slicing or summarizing it. */
456
+ export function formatRawToolPayload(value: unknown): string {
457
+ if (typeof value === "string") return value;
458
+ return JSON.stringify(value, null, 2) ?? String(value);
459
+ }
460
+
461
+ const RawToolPayload = ({ label, value }: { label: "Request" | "Response"; value: unknown }) => (
462
+ <Collapsible className="rounded border border-border/60 bg-background/40">
463
+ <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">
464
+ <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
465
+ <span>{label}</span>
466
+ </CollapsibleTrigger>
467
+ <CollapsibleContent>
468
+ <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words border-t border-border/50 px-2 py-2 font-mono text-[11px] leading-relaxed text-foreground">
469
+ {formatRawToolPayload(value)}
470
+ </pre>
471
+ </CollapsibleContent>
472
+ </Collapsible>
473
+ );
474
+
455
475
  /**
456
476
  * True when a tool event has anything worth expanding for. The
457
477
  * question text already rides on the row header (see
@@ -460,6 +480,7 @@ const ToolStatusIcon = ({ status }: { status: "running" | "done" | "error" }) =>
460
480
  */
461
481
  const hasExpandableDetails = (event: ToolEvent): boolean => {
462
482
  if (event.status === "running") return true;
483
+ if ("input" in event || "output" in event) return true;
463
484
  const summary = summarizeProgress(event.progress ?? []);
464
485
  return summary.groups.some(
465
486
  (g) => g.attachments.some(isAttachmentRenderable) || g.errors.length > 0,
@@ -531,8 +552,10 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
531
552
  {header}
532
553
  </CollapsibleTrigger>
533
554
  <CollapsibleContent>
534
- <div className="px-2 pb-2">
555
+ <div className="flex flex-col gap-2 px-2 pb-2">
535
556
  <ToolProgressDetails summary={summary} omitQuestion />
557
+ {"input" in event ? <RawToolPayload label="Request" value={event.input} /> : null}
558
+ {"output" in event ? <RawToolPayload label="Response" value={event.output} /> : null}
536
559
  </div>
537
560
  </CollapsibleContent>
538
561
  </Collapsible>
@@ -22,6 +22,10 @@ export type ToolEvent = {
22
22
  id: string;
23
23
  toolName: string;
24
24
  status: "running" | "done" | "error";
25
+ /** Complete tool-call arguments as emitted by Mastra. */
26
+ input?: unknown;
27
+ /** Complete tool result/error as emitted by Mastra. */
28
+ output?: unknown;
25
29
  progress?: ToolProgress[];
26
30
  };
27
31
 
@@ -172,11 +176,16 @@ export type ChatViewProps = {
172
176
  */
173
177
  defaultModelName?: string;
174
178
  /**
175
- * Host-owned controls rendered in the composer toolbar immediately after the
179
+ * Host-owned controls rendered in the composer footer immediately before the
176
180
  * model selector. Use this for per-turn options that belong beside model
177
181
  * routing without forking the chat surface.
178
182
  */
179
183
  composerActions?: ReactNode;
184
+ /**
185
+ * Host-owned actions rendered on the left side of the composer's fixed
186
+ * footer, before Export and Clear.
187
+ */
188
+ composerLeadingActions?: ReactNode;
180
189
  /**
181
190
  * Optional infinite-scroll-up handler. Fired when the user scrolls
182
191
  * within `TOP_LOAD_MORE_THRESHOLD_PX` of the top of the
@@ -202,23 +211,16 @@ export type ChatViewProps = {
202
211
  * stream Response and pipe it through the same chunk handler
203
212
  * (this is exactly what `useMastraChat` does).
204
213
  *
205
- * It requires the `runId` Mastra emitted with the approval
206
- * chunk - the field is always populated when the card was rendered
207
- * from a live `data-tool-call-approval` part or an out-of-band
208
- * `pendingApprovalsByMessage` entry. It will be missing only for
209
- * approvals reconstructed from history (where the original runId
210
- * is lost), in which case the handler should surface a "this
211
- * approval is stale, please re-ask the model" message rather than
212
- * trying to resume a workflow that no longer exists.
214
+ * It requires the `runId` Mastra emitted with the approval chunk.
215
+ * `useMastraChat` also restores run ids from native suspended-run
216
+ * storage when history is reloaded.
213
217
  */
214
218
  onResolveToolApproval?: (args: ApprovalDecision) => void | Promise<void>;
215
219
  /**
216
- * Out-of-band approval requests keyed by assistant message id, for
217
- * transports that don't surface approvals as `UIMessage` parts.
218
- * The `/stream` page populates this from Mastra's
219
- * `tool-call-approval` chunk so the same `ToolApprovalCard`
220
- * UI works without injecting synthetic data parts. Each entry is
221
- * merged with any approvals already discovered in `message.parts`.
220
+ * Approval requests keyed by assistant message id. This restores
221
+ * persisted suspended runs after a reload and also supports transports
222
+ * that do not surface approvals as `UIMessage` parts. Entries merge with
223
+ * approvals discovered in `message.parts`.
222
224
  */
223
225
  pendingApprovalsByMessage?: Record<string, PendingApproval[]>;
224
226
  /**
@@ -246,6 +248,17 @@ export type ChatViewProps = {
246
248
  * is too narrow for one. See {@link ThreadPlacement}.
247
249
  */
248
250
  threadPlacement?: ThreadPlacement;
251
+ /**
252
+ * Host-owned controls appended after the top tab strip's New and History
253
+ * actions. The persistent assistant uses this for its panel Close button.
254
+ */
255
+ threadBarActions?: ReactNode;
256
+ /** Whether the top tab row renders its built-in New/History controls. */
257
+ showThreadBarActions?: boolean;
258
+ /** Replacement icon for the top tab row's New action. */
259
+ threadNewIcon?: ReactNode;
260
+ /** Replacement icon for the top tab row's History action. */
261
+ threadHistoryIcon?: ReactNode;
249
262
  /** Id of the currently-active thread, highlighted in the sidebar. */
250
263
  activeThreadId?: string;
251
264
  /**
@@ -339,10 +352,11 @@ export type ApprovalDecision =
339
352
  approved: true;
340
353
  toolName: string;
341
354
  toolCallId: string;
355
+ /** Assistant UI message that owns the approval card. */
356
+ messageId?: string;
342
357
  /**
343
- * Mastra run id from the approval chunk. Required to resume
344
- * the suspended workflow; absent only when the card was
345
- * reconstructed from history (no live runId available).
358
+ * Mastra run id required to resume the suspended workflow. Native
359
+ * suspended-run discovery restores it for persisted approvals.
346
360
  */
347
361
  runId?: string;
348
362
  input: unknown;
@@ -351,6 +365,8 @@ export type ApprovalDecision =
351
365
  approved: false;
352
366
  toolName: string;
353
367
  toolCallId: string;
368
+ /** Assistant UI message that owns the approval card. */
369
+ messageId?: string;
354
370
  runId?: string;
355
371
  input: unknown;
356
372
  reason: string;
@@ -359,10 +375,8 @@ export type ApprovalDecision =
359
375
  /**
360
376
  * One approval-gated tool call paused mid-turn. `runId` is the
361
377
  * Mastra workflow id needed to resume; it's always present when the
362
- * card was constructed from a live source (the stream's
363
- * `data-tool-call-approval` part or `pendingApprovalsByMessage`),
364
- * and absent only for approvals reconstructed from a history load
365
- * where the original runId is lost.
378
+ * card came from a live approval part or native suspended-run storage.
379
+ * It remains optional for controlled transports that cannot provide it.
366
380
  */
367
381
  export type PendingApproval = {
368
382
  toolName: string;