@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.
- package/README.md +198 -0
- package/index.ts +32 -0
- package/package.json +62 -0
- package/src/react/bubbles.tsx +428 -0
- package/src/react/chat-view.tsx +574 -0
- package/src/react/data-grid.tsx +342 -0
- package/src/react/embed-slots.tsx +258 -0
- package/src/react/export-menu.tsx +71 -0
- package/src/react/feedback-controls.tsx +139 -0
- package/src/react/index.ts +42 -0
- package/src/react/markdown.tsx +387 -0
- package/src/react/mastra-chat.tsx +1376 -0
- package/src/react/suggestion-pills.tsx +46 -0
- package/src/react/suggestions.ts +109 -0
- package/src/react/thread-sidebar.tsx +315 -0
- package/src/react/tool-pill.tsx +684 -0
- package/src/react/types.ts +295 -0
- package/src/styles.css +22 -0
- package/src/support/chart-option.ts +130 -0
- package/src/support/export.ts +485 -0
- package/src/support/mastra-client.ts +883 -0
- package/src/support/mastra-stream.ts +66 -0
- package/src/support/shiki-plugin.ts +134 -0
- package/src/support/thread-sessions.ts +55 -0
- package/tsconfig.json +43 -0
|
@@ -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
|
+
}
|