@dbx-tools/ui-mastra 0.1.9

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.
@@ -0,0 +1,46 @@
1
+ import { Button, cn } from "@dbx-tools/ui-appkit/react";
2
+
3
+ // Shared rendering for suggested questions. Both the initial starter
4
+ // suggestions (above the composer when the transcript is empty) and
5
+ // the per-message follow-up suggestions use this so the two look and
6
+ // behave identically.
7
+
8
+ export interface SuggestionPillsProps {
9
+ /** Questions to render as clickable pills. Empty renders nothing. */
10
+ questions: string[];
11
+ /** Invoked with the question text when a pill is clicked. */
12
+ onSelect?: (question: string) => void;
13
+ /** Extra classes for the wrapping flex row (layout / spacing). */
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * Render a flex-wrapped row of suggestion pills. Pills grow vertically
19
+ * for long questions (`h-auto` + `whitespace-normal`) and keep a
20
+ * capsule shape that scales cleanly when text wraps to multiple lines.
21
+ * Disabled when no `onSelect` is provided.
22
+ */
23
+ export const SuggestionPills = ({
24
+ questions,
25
+ onSelect,
26
+ className,
27
+ }: SuggestionPillsProps) => {
28
+ if (questions.length === 0) return null;
29
+ return (
30
+ <div className={cn("flex flex-wrap gap-1.5", className)}>
31
+ {questions.map((q) => (
32
+ <Button
33
+ key={q}
34
+ type="button"
35
+ size="sm"
36
+ variant="outline"
37
+ className="h-auto max-w-full whitespace-normal rounded-2xl px-3 py-1.5 text-left text-xs font-normal leading-snug"
38
+ onClick={() => onSelect?.(q)}
39
+ disabled={!onSelect}
40
+ >
41
+ {q}
42
+ </Button>
43
+ ))}
44
+ </div>
45
+ );
46
+ };
@@ -0,0 +1,109 @@
1
+ import type { ToolEvent, ToolProgress } from "./types";
2
+
3
+ // Suggested follow-up question extraction: dedupe + cap the
4
+ // `suggested_questions` events Genie tools emit so the assistant
5
+ // bubble surfaces a short, varied list of next questions.
6
+
7
+ /**
8
+ * Hard cap on how many suggested follow-ups surface under one
9
+ * assistant message - several Genie queries each emitting a handful
10
+ * would otherwise flood the bubble.
11
+ */
12
+ const MAX_SUGGESTIONS = 4;
13
+
14
+ /**
15
+ * Token-set Jaccard threshold above which two suggestions are treated
16
+ * as the same question and the later one is dropped. Tuned to fold
17
+ * trivial rewordings ("Show me revenue by region" vs "Show revenue by
18
+ * region") while keeping genuinely distinct questions that happen to
19
+ * share filler words.
20
+ */
21
+ const SUGGESTION_SIMILARITY = 0.6;
22
+
23
+ /** Lowercased, punctuation-stripped word set used for similarity comparison. */
24
+ function suggestionTokens(question: string): Set<string> {
25
+ return new Set(
26
+ question
27
+ .toLowerCase()
28
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
29
+ .split(/\s+/)
30
+ .filter(Boolean),
31
+ );
32
+ }
33
+
34
+ /** Jaccard similarity (0..1) of two token sets; 0 when either is empty. */
35
+ function tokenSimilarity(a: Set<string>, b: Set<string>): number {
36
+ if (a.size === 0 || b.size === 0) return 0;
37
+ let intersection = 0;
38
+ for (const token of a) if (b.has(token)) intersection++;
39
+ return intersection / (a.size + b.size - intersection);
40
+ }
41
+
42
+ /**
43
+ * Dedupe + cap an ordered list of question lists into the short,
44
+ * varied list the UI renders. Each inner list is one source's ordered
45
+ * questions; we round-robin by position so every source contributes
46
+ * its *top* question before any source contributes a second -
47
+ * favoring breadth over depth. Near-duplicates (see
48
+ * {@link SUGGESTION_SIMILARITY}) are skipped and the result is capped
49
+ * at {@link MAX_SUGGESTIONS}. Exact-duplicate questions across
50
+ * sources also fold via the similarity check.
51
+ */
52
+ function pickSuggestions(lists: string[][]): string[] {
53
+ const accepted: string[] = [];
54
+ const acceptedTokens: Set<string>[] = [];
55
+ const consider = (question: string): void => {
56
+ if (accepted.length >= MAX_SUGGESTIONS) return;
57
+ const tokens = suggestionTokens(question);
58
+ if (tokens.size === 0) return;
59
+ const isDuplicate = acceptedTokens.some(
60
+ (t) => tokenSimilarity(t, tokens) >= SUGGESTION_SIMILARITY,
61
+ );
62
+ if (isDuplicate) return;
63
+ accepted.push(question);
64
+ acceptedTokens.push(tokens);
65
+ };
66
+
67
+ const maxLen = lists.reduce((m, l) => Math.max(m, l.length), 0);
68
+ for (let i = 0; i < maxLen && accepted.length < MAX_SUGGESTIONS; i++) {
69
+ for (const list of lists) {
70
+ const question = list[i];
71
+ if (question !== undefined) consider(question);
72
+ }
73
+ }
74
+ return accepted;
75
+ }
76
+
77
+ /**
78
+ * 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.
82
+ */
83
+ export const dedupeSuggestions = (questions: string[] | undefined): string[] =>
84
+ questions && questions.length > 0 ? pickSuggestions([questions]) : [];
85
+
86
+ /**
87
+ * 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}.
92
+ */
93
+ export const collectSuggestions = (events: ToolEvent[] | undefined): string[] => {
94
+ if (!events || events.length === 0) return [];
95
+
96
+ // One ordered question list per event that emitted any.
97
+ 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
+ }
107
+
108
+ return pickSuggestions(lists);
109
+ };
@@ -0,0 +1,315 @@
1
+ import {
2
+ Button,
3
+ Input,
4
+ Spinner,
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipTrigger,
8
+ cn,
9
+ } from "@dbx-tools/ui-appkit/react";
10
+ import { wire } from "@dbx-tools/shared-mastra";
11
+ import {
12
+ Loader2Icon,
13
+ MessageSquarePlusIcon,
14
+ PanelLeftIcon,
15
+ PencilIcon,
16
+ Trash2Icon,
17
+ } from "lucide-react";
18
+ import { useState } from "react";
19
+ import type { ThreadSummary } from "./types";
20
+
21
+ // Presentational conversation list. Renders the threads a resource owns
22
+ // so the user can switch between them, start a new one, rename one, and
23
+ // delete one. All state is owned by the caller (the `useMastraChat`
24
+ // driver) and fed in through props; this component only reports intent
25
+ // back out (rename is edited inline against local draft state here).
26
+
27
+ /** Props for {@link ThreadSidebar}. */
28
+ export interface ThreadSidebarProps {
29
+ /** Threads to list, newest first. */
30
+ threads: ThreadSummary[];
31
+ /** Id of the active thread, rendered highlighted. */
32
+ activeThreadId?: string;
33
+ /** Thread ids with a generation still in flight in the background. */
34
+ streamingThreadIds?: string[];
35
+ /** True while the initial list loads (shows a spinner in place of the list). */
36
+ isLoading?: boolean;
37
+ /** Switch to a thread. */
38
+ onSelect: (threadId: string) => void;
39
+ /** Start a fresh conversation. Hidden when omitted. */
40
+ onNew?: () => void;
41
+ /** Delete a thread. Per-row trash affordance hidden when omitted. */
42
+ onDelete?: (threadId: string) => void;
43
+ /** Rename a thread. Per-row edit affordance (inline text field) hidden when omitted. */
44
+ onRename?: (threadId: string, title: string) => void;
45
+ /** Collapse the sidebar. Renders the hide button in the header when provided. */
46
+ onHide?: () => void;
47
+ /** Extra classes merged onto the sidebar root. */
48
+ className?: string;
49
+ }
50
+
51
+ /**
52
+ * Conversation sidebar: a "New chat" button over a scrollable list of
53
+ * the caller's threads. Each row shows the thread title (or a
54
+ * placeholder for an as-yet-untitled new thread) and a relative
55
+ * last-activity hint, with a hover rename affordance (inline text field,
56
+ * commit on Enter / blur, cancel on Escape) and a two-click delete latch
57
+ * so a stray click can't drop a conversation. Pair with
58
+ * {@link ThreadSidebarProps} from the `useMastraChat` driver, which owns
59
+ * the data and selection.
60
+ */
61
+ export const ThreadSidebar = ({
62
+ threads,
63
+ activeThreadId,
64
+ streamingThreadIds = [],
65
+ isLoading = false,
66
+ onSelect,
67
+ onNew,
68
+ onDelete,
69
+ onRename,
70
+ onHide,
71
+ className,
72
+ }: ThreadSidebarProps) => {
73
+ // Thread id armed for deletion (first trash click). A second click on
74
+ // the same row confirms; clicking elsewhere / another row resets it.
75
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
76
+ // Thread id whose title is being edited inline, and the working draft
77
+ // for that row's text field. Only ever one row edits at a time.
78
+ const [editingId, setEditingId] = useState<string | null>(null);
79
+ const [draftTitle, setDraftTitle] = useState("");
80
+
81
+ const handleDeleteClick = (e: React.MouseEvent, threadId: string) => {
82
+ e.stopPropagation();
83
+ if (!onDelete) return;
84
+ if (confirmDeleteId !== threadId) {
85
+ setConfirmDeleteId(threadId);
86
+ return;
87
+ }
88
+ setConfirmDeleteId(null);
89
+ onDelete(threadId);
90
+ };
91
+
92
+ // Enter edit mode for a row, seeding the draft with its current title
93
+ // (blank for an as-yet-untitled thread). Cancels any armed delete.
94
+ const startEdit = (e: React.MouseEvent, thread: ThreadSummary) => {
95
+ e.stopPropagation();
96
+ setConfirmDeleteId(null);
97
+ setEditingId(thread.id);
98
+ setDraftTitle(thread.title?.trim() ?? "");
99
+ };
100
+
101
+ // Commit the draft, firing `onRename` only when it's non-empty and
102
+ // actually changed, then leave edit mode. Called on Enter and on blur.
103
+ const commitEdit = (threadId: string) => {
104
+ const title = draftTitle.trim();
105
+ setEditingId(null);
106
+ if (!onRename || !title) return;
107
+ const current = threads.find((t) => t.id === threadId)?.title?.trim() ?? "";
108
+ if (title !== current) onRename(threadId, title);
109
+ };
110
+
111
+ return (
112
+ <div
113
+ className={cn(
114
+ "flex h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30",
115
+ className,
116
+ )}
117
+ >
118
+ {(onNew || onHide) && (
119
+ <div className="flex items-center gap-2 p-2">
120
+ {onNew && (
121
+ <Button
122
+ type="button"
123
+ variant="outline"
124
+ size="sm"
125
+ onClick={onNew}
126
+ className="flex-1 justify-start gap-2"
127
+ >
128
+ <MessageSquarePlusIcon className="size-4" />
129
+ New chat
130
+ </Button>
131
+ )}
132
+ {onHide && (
133
+ <Tooltip>
134
+ <TooltipTrigger asChild>
135
+ <Button
136
+ type="button"
137
+ variant="ghost"
138
+ size="icon"
139
+ onClick={onHide}
140
+ aria-label="Hide conversations"
141
+ className={cn("size-8 shrink-0", !onNew && "ml-auto")}
142
+ >
143
+ <PanelLeftIcon className="size-4" />
144
+ </Button>
145
+ </TooltipTrigger>
146
+ <TooltipContent>Hide conversations</TooltipContent>
147
+ </Tooltip>
148
+ )}
149
+ </div>
150
+ )}
151
+ <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 [scrollbar-gutter:stable]">
152
+ {isLoading && threads.length === 0 ? (
153
+ <div className="flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground">
154
+ <Spinner className="size-3" />
155
+ <span>Loading conversations...</span>
156
+ </div>
157
+ ) : threads.length === 0 ? (
158
+ <p className="px-2 py-6 text-center text-xs text-muted-foreground">
159
+ No conversations yet.
160
+ </p>
161
+ ) : (
162
+ <ul className="flex flex-col gap-0.5">
163
+ {threads.map((thread) => {
164
+ const isActive = thread.id === activeThreadId;
165
+ const isStreaming = streamingThreadIds.includes(thread.id);
166
+ const isConfirming = confirmDeleteId === thread.id;
167
+ const isEditing = editingId === thread.id;
168
+ if (isEditing) {
169
+ return (
170
+ <li key={thread.id}>
171
+ <div className="px-2 py-1">
172
+ <Input
173
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- focus the field the user just opened
174
+ autoFocus
175
+ value={draftTitle}
176
+ maxLength={wire.MASTRA_THREAD_TITLE_MAX}
177
+ aria-label="Conversation name"
178
+ onChange={(e) => setDraftTitle(e.target.value)}
179
+ onKeyDown={(e) => {
180
+ if (e.key === "Enter") {
181
+ e.preventDefault();
182
+ commitEdit(thread.id);
183
+ } else if (e.key === "Escape") {
184
+ e.preventDefault();
185
+ setEditingId(null);
186
+ }
187
+ }}
188
+ onBlur={() => commitEdit(thread.id)}
189
+ className="h-8 text-sm"
190
+ />
191
+ </div>
192
+ </li>
193
+ );
194
+ }
195
+ return (
196
+ <li key={thread.id}>
197
+ <div
198
+ role="button"
199
+ tabIndex={0}
200
+ onClick={() => onSelect(thread.id)}
201
+ onKeyDown={(e) => {
202
+ if (e.key === "Enter" || e.key === " ") {
203
+ e.preventDefault();
204
+ onSelect(thread.id);
205
+ }
206
+ }}
207
+ className={cn(
208
+ "group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm",
209
+ "hover:bg-accent hover:text-accent-foreground",
210
+ isActive && "bg-accent text-accent-foreground",
211
+ )}
212
+ >
213
+ <div className="min-w-0 flex-1">
214
+ <div className="flex items-center gap-1.5 truncate">
215
+ {isStreaming && (
216
+ <Loader2Icon
217
+ aria-label="Streaming"
218
+ className="size-3 shrink-0 animate-spin text-primary"
219
+ />
220
+ )}
221
+ <span className="truncate">{threadTitle(thread)}</span>
222
+ </div>
223
+ {thread.updatedAt && (
224
+ <div className="truncate text-xs text-muted-foreground">
225
+ {relativeTime(thread.updatedAt)}
226
+ </div>
227
+ )}
228
+ </div>
229
+ {onRename && (
230
+ <Tooltip>
231
+ <TooltipTrigger asChild>
232
+ <Button
233
+ type="button"
234
+ variant="ghost"
235
+ size="icon"
236
+ onClick={(e) => startEdit(e, thread)}
237
+ aria-label="Rename conversation"
238
+ className={cn(
239
+ "size-6 shrink-0",
240
+ "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
241
+ )}
242
+ >
243
+ <PencilIcon className="size-3.5" />
244
+ </Button>
245
+ </TooltipTrigger>
246
+ <TooltipContent>Rename conversation</TooltipContent>
247
+ </Tooltip>
248
+ )}
249
+ {onDelete && (
250
+ <Tooltip>
251
+ <TooltipTrigger asChild>
252
+ <Button
253
+ type="button"
254
+ variant={isConfirming ? "destructive" : "ghost"}
255
+ size="icon"
256
+ onClick={(e) => handleDeleteClick(e, thread.id)}
257
+ onBlur={() =>
258
+ setConfirmDeleteId((id) => (id === thread.id ? null : id))
259
+ }
260
+ aria-label={
261
+ isConfirming
262
+ ? "Confirm delete conversation"
263
+ : "Delete conversation"
264
+ }
265
+ className={cn(
266
+ "size-6 shrink-0",
267
+ !isConfirming &&
268
+ "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
269
+ )}
270
+ >
271
+ <Trash2Icon className="size-3.5" />
272
+ </Button>
273
+ </TooltipTrigger>
274
+ <TooltipContent>
275
+ {isConfirming
276
+ ? "Click again to delete this conversation"
277
+ : "Delete conversation"}
278
+ </TooltipContent>
279
+ </Tooltip>
280
+ )}
281
+ </div>
282
+ </li>
283
+ );
284
+ })}
285
+ </ul>
286
+ )}
287
+ </div>
288
+ </div>
289
+ );
290
+ };
291
+
292
+ /** Title for a thread row, falling back to a placeholder when unnamed. */
293
+ function threadTitle(thread: ThreadSummary): string {
294
+ const title = thread.title?.trim();
295
+ return title && title.length > 0 ? title : "New conversation";
296
+ }
297
+
298
+ /**
299
+ * Render an ISO-8601 timestamp as a coarse "time ago" hint
300
+ * (`just now`, `5m ago`, `3h ago`, `2d ago`, or a locale date for
301
+ * anything older than a week). Invalid input renders nothing.
302
+ */
303
+ function relativeTime(iso: string): string {
304
+ const then = new Date(iso).getTime();
305
+ if (Number.isNaN(then)) return "";
306
+ const diffMs = Date.now() - then;
307
+ const minutes = Math.floor(diffMs / 60_000);
308
+ if (minutes < 1) return "just now";
309
+ if (minutes < 60) return `${minutes}m ago`;
310
+ const hours = Math.floor(minutes / 60);
311
+ if (hours < 24) return `${hours}h ago`;
312
+ const days = Math.floor(hours / 24);
313
+ if (days < 7) return `${days}d ago`;
314
+ return new Date(then).toLocaleDateString();
315
+ }