@dbx-tools/ui-mastra 0.6.211 → 0.6.212

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,419 @@
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogAction,
4
+ AlertDialogCancel,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogFooter,
8
+ AlertDialogHeader,
9
+ AlertDialogTitle,
10
+ Button,
11
+ InputGroup,
12
+ InputGroupAddon,
13
+ InputGroupButton,
14
+ InputGroupTextarea,
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ Spinner,
21
+ Tooltip,
22
+ TooltipContent,
23
+ TooltipTrigger,
24
+ cn,
25
+ } from "@dbx-tools/ui-appkit/react";
26
+ import {
27
+ GripVerticalIcon,
28
+ SendHorizontalIcon,
29
+ SendIcon,
30
+ SquareIcon,
31
+ Trash2Icon,
32
+ XIcon,
33
+ } from "lucide-react";
34
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
35
+ import { ExportMenu } from "./export-menu.tsx";
36
+ import { SuggestionPills } from "./suggestion-pills.tsx";
37
+ import type { ChatViewProps } from "./types.ts";
38
+
39
+ const DEFAULT_MODEL_VALUE = "__default__";
40
+
41
+ type QueuedSteerListProps = {
42
+ queuedSteers: NonNullable<ChatViewProps["queuedSteers"]>;
43
+ onSendSteerNow: ChatViewProps["onSendSteerNow"];
44
+ onRemoveSteer: ChatViewProps["onRemoveSteer"];
45
+ onReorderSteers: ChatViewProps["onReorderSteers"];
46
+ };
47
+
48
+ const QueuedSteerList = ({
49
+ queuedSteers,
50
+ onSendSteerNow,
51
+ onRemoveSteer,
52
+ onReorderSteers,
53
+ }: QueuedSteerListProps) => {
54
+ const [draggingSteerId, setDraggingSteerId] = useState<string | null>(null);
55
+ const steerChipRefs = useRef(new Map<string, HTMLDivElement>());
56
+ const draggingIdRef = useRef<string | null>(null);
57
+ const queuedSteersRef = useRef(queuedSteers);
58
+ queuedSteersRef.current = queuedSteers;
59
+
60
+ const reorderSteersByPointer = useCallback(
61
+ (draggingId: string, pointerY: number) => {
62
+ if (!onReorderSteers) return;
63
+ const order = queuedSteersRef.current.map((steer) => steer.id);
64
+ const rest = order.filter((id) => id !== draggingId);
65
+ let insertAt = rest.length;
66
+ for (let index = 0; index < rest.length; index += 1) {
67
+ const chip = steerChipRefs.current.get(rest[index]);
68
+ if (!chip) continue;
69
+ const bounds = chip.getBoundingClientRect();
70
+ if (pointerY < bounds.top + bounds.height / 2) {
71
+ insertAt = index;
72
+ break;
73
+ }
74
+ }
75
+ const next = [...rest];
76
+ next.splice(insertAt, 0, draggingId);
77
+ if (next.length === order.length && next.every((id, index) => id === order[index])) return;
78
+ onReorderSteers(next);
79
+ },
80
+ [onReorderSteers],
81
+ );
82
+
83
+ if (queuedSteers.length === 0) return null;
84
+
85
+ return (
86
+ <div className="mb-2 flex flex-col gap-1">
87
+ {queuedSteers.map((steer) => {
88
+ const reorderable = Boolean(onReorderSteers);
89
+ return (
90
+ <div
91
+ key={steer.id}
92
+ ref={(element) => {
93
+ if (element) steerChipRefs.current.set(steer.id, element);
94
+ else steerChipRefs.current.delete(steer.id);
95
+ }}
96
+ className={cn(
97
+ "flex items-center gap-1.5 rounded-lg border border-border/70 bg-muted/40 px-2 py-1 text-xs",
98
+ draggingSteerId === steer.id && "opacity-50",
99
+ )}
100
+ >
101
+ {reorderable && (
102
+ <span
103
+ role="button"
104
+ tabIndex={-1}
105
+ aria-label="Drag to reorder"
106
+ className="-m-1 shrink-0 cursor-grab touch-none p-1 text-muted-foreground active:cursor-grabbing"
107
+ onPointerDown={(event) => {
108
+ event.preventDefault();
109
+ event.currentTarget.setPointerCapture(event.pointerId);
110
+ draggingIdRef.current = steer.id;
111
+ setDraggingSteerId(steer.id);
112
+ }}
113
+ onPointerMove={(event) => {
114
+ if (draggingIdRef.current !== steer.id) return;
115
+ reorderSteersByPointer(steer.id, event.clientY);
116
+ }}
117
+ onPointerUp={(event) => {
118
+ event.currentTarget.releasePointerCapture(event.pointerId);
119
+ draggingIdRef.current = null;
120
+ setDraggingSteerId(null);
121
+ }}
122
+ onPointerCancel={() => {
123
+ draggingIdRef.current = null;
124
+ setDraggingSteerId(null);
125
+ }}
126
+ >
127
+ <GripVerticalIcon className="size-3" aria-hidden="true" />
128
+ </span>
129
+ )}
130
+ <span className="text-muted-foreground">Queued</span>
131
+ <span className="min-w-0 flex-1 truncate">{steer.text}</span>
132
+ {onSendSteerNow && (
133
+ <Tooltip>
134
+ <TooltipTrigger asChild>
135
+ <Button
136
+ type="button"
137
+ variant="ghost"
138
+ size="icon"
139
+ className="size-6 shrink-0"
140
+ onClick={() => onSendSteerNow(steer.id)}
141
+ aria-label="Send now (interrupts current turn)"
142
+ >
143
+ <SendHorizontalIcon className="size-3" />
144
+ </Button>
145
+ </TooltipTrigger>
146
+ <TooltipContent>Send now - interrupts</TooltipContent>
147
+ </Tooltip>
148
+ )}
149
+ {onRemoveSteer && (
150
+ <Tooltip>
151
+ <TooltipTrigger asChild>
152
+ <Button
153
+ type="button"
154
+ variant="ghost"
155
+ size="icon"
156
+ className="size-6 shrink-0"
157
+ onClick={() => onRemoveSteer(steer.id)}
158
+ aria-label="Remove queued message"
159
+ >
160
+ <XIcon className="size-3" />
161
+ </Button>
162
+ </TooltipTrigger>
163
+ <TooltipContent>Remove</TooltipContent>
164
+ </Tooltip>
165
+ )}
166
+ </div>
167
+ );
168
+ })}
169
+ </div>
170
+ );
171
+ };
172
+
173
+ type ClearConversationActionProps = {
174
+ onClear: NonNullable<ChatViewProps["onClear"]>;
175
+ isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
176
+ };
177
+
178
+ const ClearConversationAction = ({ onClear, isLoadingHistory }: ClearConversationActionProps) => {
179
+ const [open, setOpen] = useState(false);
180
+ const [clearing, setClearing] = useState(false);
181
+
182
+ const handleConfirm = async () => {
183
+ if (clearing) return;
184
+ setClearing(true);
185
+ try {
186
+ await onClear();
187
+ setOpen(false);
188
+ } finally {
189
+ setClearing(false);
190
+ }
191
+ };
192
+
193
+ return (
194
+ <>
195
+ <Tooltip>
196
+ <TooltipTrigger asChild>
197
+ <Button
198
+ type="button"
199
+ variant="outline"
200
+ size="sm"
201
+ onClick={() => setOpen(true)}
202
+ disabled={isLoadingHistory}
203
+ className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
204
+ >
205
+ <Trash2Icon className="size-3" />
206
+ Clear
207
+ </Button>
208
+ </TooltipTrigger>
209
+ <TooltipContent>Clear chat history for this thread</TooltipContent>
210
+ </Tooltip>
211
+ <AlertDialog open={open} onOpenChange={setOpen}>
212
+ <AlertDialogContent>
213
+ <AlertDialogHeader>
214
+ <AlertDialogTitle>Clear this conversation?</AlertDialogTitle>
215
+ <AlertDialogDescription>
216
+ This permanently deletes the chat history for this thread. This can&apos;t be undone.
217
+ </AlertDialogDescription>
218
+ </AlertDialogHeader>
219
+ <AlertDialogFooter>
220
+ <AlertDialogCancel disabled={clearing}>Cancel</AlertDialogCancel>
221
+ <AlertDialogAction
222
+ onClick={(event) => {
223
+ event.preventDefault();
224
+ void handleConfirm();
225
+ }}
226
+ disabled={clearing}
227
+ >
228
+ {clearing ? <Spinner className="size-3" /> : null}
229
+ {clearing ? "Clearing..." : "Clear"}
230
+ </AlertDialogAction>
231
+ </AlertDialogFooter>
232
+ </AlertDialogContent>
233
+ </AlertDialog>
234
+ </>
235
+ );
236
+ };
237
+
238
+ type ChatComposerProps = {
239
+ isEmpty: boolean;
240
+ status: ChatViewProps["status"];
241
+ sendMessage: ChatViewProps["sendMessage"];
242
+ queuedSteers: NonNullable<ChatViewProps["queuedSteers"]>;
243
+ onSendSteerNow: ChatViewProps["onSendSteerNow"];
244
+ onRemoveSteer: ChatViewProps["onRemoveSteer"];
245
+ onReorderSteers: ChatViewProps["onReorderSteers"];
246
+ onStop: ChatViewProps["onStop"];
247
+ suggestions: NonNullable<ChatViewProps["suggestions"]>;
248
+ models: ChatViewProps["models"];
249
+ model: ChatViewProps["model"];
250
+ onModelChange: ChatViewProps["onModelChange"];
251
+ defaultModelName: ChatViewProps["defaultModelName"];
252
+ isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
253
+ onClear: ChatViewProps["onClear"];
254
+ onExportConversation: ChatViewProps["onExportConversation"];
255
+ onResumeFollow: () => void;
256
+ };
257
+
258
+ /** Internal message composer with queued steers and conversation actions. */
259
+ export const ChatComposer = ({
260
+ isEmpty,
261
+ status,
262
+ sendMessage,
263
+ queuedSteers,
264
+ onSendSteerNow,
265
+ onRemoveSteer,
266
+ onReorderSteers,
267
+ onStop,
268
+ suggestions,
269
+ models,
270
+ model,
271
+ onModelChange,
272
+ defaultModelName,
273
+ isLoadingHistory,
274
+ onClear,
275
+ onExportConversation,
276
+ onResumeFollow,
277
+ }: ChatComposerProps) => {
278
+ const [input, setInput] = useState("");
279
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
280
+
281
+ useLayoutEffect(() => {
282
+ const element = textareaRef.current;
283
+ if (!element) return;
284
+ element.style.height = "auto";
285
+ element.style.height = `${element.scrollHeight}px`;
286
+ }, [input]);
287
+
288
+ const isRunning = status === "submitted" || status === "streaming";
289
+ const submit = () => {
290
+ const text = input.trim();
291
+ if (!text || isLoadingHistory) return;
292
+ sendMessage({ text });
293
+ setInput("");
294
+ onResumeFollow();
295
+ };
296
+
297
+ const showModelDisplay = Boolean(onModelChange);
298
+ const modelChangeable = Boolean(models && models.length > 0);
299
+ const defaultOptionLabel = defaultModelName || "Default";
300
+ const currentModelLabel =
301
+ (model ? models?.find((option) => option.name === model)?.displayName : undefined) ||
302
+ defaultOptionLabel;
303
+ const sortedModels = [...(models ?? [])].sort((left, right) =>
304
+ (left.displayName || left.name).localeCompare(right.displayName || right.name, undefined, {
305
+ sensitivity: "base",
306
+ }),
307
+ );
308
+ const showToolbar = showModelDisplay || Boolean(onExportConversation) || Boolean(onClear);
309
+
310
+ return (
311
+ <>
312
+ {isEmpty && (
313
+ <SuggestionPills
314
+ questions={suggestions}
315
+ onSelect={(text) => sendMessage({ text })}
316
+ disabled={isLoadingHistory}
317
+ className="mx-auto w-full max-w-4xl px-4 pb-2 md:px-6"
318
+ />
319
+ )}
320
+ <form
321
+ onSubmit={(event) => {
322
+ event.preventDefault();
323
+ submit();
324
+ }}
325
+ className="mx-auto w-full max-w-4xl px-3 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6"
326
+ >
327
+ <QueuedSteerList
328
+ queuedSteers={queuedSteers}
329
+ onSendSteerNow={onSendSteerNow}
330
+ onRemoveSteer={onRemoveSteer}
331
+ onReorderSteers={onReorderSteers}
332
+ />
333
+ <InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
334
+ <InputGroupTextarea
335
+ ref={textareaRef}
336
+ value={input}
337
+ onChange={(event) => setInput(event.target.value)}
338
+ onKeyDown={(event) => {
339
+ if (event.key === "Enter" && !event.shiftKey) {
340
+ event.preventDefault();
341
+ submit();
342
+ }
343
+ }}
344
+ placeholder={isLoadingHistory ? "Loading history..." : "Send a message..."}
345
+ rows={1}
346
+ disabled={isLoadingHistory}
347
+ className="max-h-48 text-base md:text-sm"
348
+ />
349
+ <InputGroupAddon align="inline-end">
350
+ {isRunning && onStop && !input.trim() ? (
351
+ <InputGroupButton
352
+ type="button"
353
+ size="icon-sm"
354
+ variant="default"
355
+ onClick={() => onStop()}
356
+ aria-label="Stop response"
357
+ >
358
+ <SquareIcon className="size-3 fill-current" />
359
+ </InputGroupButton>
360
+ ) : (
361
+ <InputGroupButton
362
+ type="submit"
363
+ size="icon-sm"
364
+ variant="default"
365
+ disabled={!input.trim() || isLoadingHistory}
366
+ aria-label={isRunning ? "Send now (interrupts)" : "Send message"}
367
+ >
368
+ <SendIcon className="size-3" />
369
+ </InputGroupButton>
370
+ )}
371
+ </InputGroupAddon>
372
+ </InputGroup>
373
+ {showToolbar && (
374
+ <div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
375
+ {showModelDisplay &&
376
+ (modelChangeable ? (
377
+ <Select
378
+ value={model ? model : DEFAULT_MODEL_VALUE}
379
+ onValueChange={(value) =>
380
+ onModelChange?.(value === DEFAULT_MODEL_VALUE ? "" : value)
381
+ }
382
+ disabled={isLoadingHistory}
383
+ >
384
+ <SelectTrigger
385
+ size="sm"
386
+ className="h-7 w-auto max-w-[200px] gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
387
+ >
388
+ <SelectValue placeholder={defaultOptionLabel} />
389
+ </SelectTrigger>
390
+ <SelectContent>
391
+ <SelectItem value={DEFAULT_MODEL_VALUE}>{defaultOptionLabel}</SelectItem>
392
+ {sortedModels.map((option) => (
393
+ <SelectItem key={option.name} value={option.name}>
394
+ {option.displayName || option.name}
395
+ </SelectItem>
396
+ ))}
397
+ </SelectContent>
398
+ </Select>
399
+ ) : (
400
+ <span className="max-w-[200px] truncate px-2.5 text-xs text-muted-foreground">
401
+ {currentModelLabel}
402
+ </span>
403
+ ))}
404
+ {onExportConversation && (
405
+ <ExportMenu
406
+ onExport={(format) => void onExportConversation(format)}
407
+ tooltip="Export conversation"
408
+ disabled={isLoadingHistory}
409
+ />
410
+ )}
411
+ {onClear && (
412
+ <ClearConversationAction onClear={onClear} isLoadingHistory={isLoadingHistory} />
413
+ )}
414
+ </div>
415
+ )}
416
+ </form>
417
+ </>
418
+ );
419
+ };
@@ -0,0 +1,60 @@
1
+ import { error as sharedError, log } from "@dbx-tools/shared-core";
2
+ import type { UIMessage } from "ai";
3
+ import { useCallback, useRef } from "react";
4
+ import type { MastraPluginClient } from "../support/mastra-client.ts";
5
+ import type { FeedbackSubmission, MessageFeedback } from "./types.ts";
6
+ import type { ThreadSessionUpdater } from "./chat-sessions.ts";
7
+
8
+ const logger = log.logger("ui-mastra/chat");
9
+
10
+ interface UseChatFeedbackOptions {
11
+ activeKey: string;
12
+ feedbackByMessage: Record<string, MessageFeedback>;
13
+ mastraClient: MastraPluginClient;
14
+ updateSession: ThreadSessionUpdater;
15
+ }
16
+
17
+ /** Submit trace-scoped MLflow feedback with optimistic thumbs state. */
18
+ export function useChatFeedback({
19
+ activeKey,
20
+ feedbackByMessage,
21
+ mastraClient,
22
+ updateSession,
23
+ }: UseChatFeedbackOptions) {
24
+ const feedbackByMessageRef = useRef<Record<string, MessageFeedback>>({});
25
+ feedbackByMessageRef.current = feedbackByMessage;
26
+
27
+ return useCallback(
28
+ async (message: UIMessage, submission: FeedbackSubmission) => {
29
+ const traceId = feedbackByMessageRef.current[message.id]?.traceId;
30
+ if (!traceId) return;
31
+ if (submission.value) {
32
+ updateSession(activeKey, (session) => ({
33
+ ...session,
34
+ feedbackByMessage: {
35
+ ...session.feedbackByMessage,
36
+ [message.id]: { traceId, value: submission.value },
37
+ },
38
+ }));
39
+ }
40
+ try {
41
+ const result = await mastraClient.feedback({
42
+ traceId,
43
+ ...(submission.value !== undefined ? { value: submission.value === "up" } : {}),
44
+ ...(submission.comment ? { comment: submission.comment } : {}),
45
+ });
46
+ if (!result.ok) {
47
+ logger.warn("feedback not recorded (trace may still be exporting)", {
48
+ traceId,
49
+ });
50
+ }
51
+ } catch (error) {
52
+ logger.error("feedback error", {
53
+ traceId,
54
+ error: sharedError.errorMessage(error),
55
+ });
56
+ }
57
+ },
58
+ [activeKey, mastraClient, updateSession],
59
+ );
60
+ }
@@ -0,0 +1,141 @@
1
+ import { error as sharedError, log } from "@dbx-tools/shared-core";
2
+ import type { UIMessage } from "ai";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { MastraPluginClient } from "../support/mastra-client.ts";
5
+ import type {
6
+ ThreadMessageWriter,
7
+ ThreadSessionReader,
8
+ ThreadSessionUpdater,
9
+ } from "./chat-sessions.ts";
10
+
11
+ const HISTORY_PAGE_SIZE = 20;
12
+ const logger = log.logger("ui-mastra/chat");
13
+
14
+ interface UseChatHistoryOptions {
15
+ activeKey: string;
16
+ activeThreadId: string | undefined;
17
+ agentId: string;
18
+ getSession: ThreadSessionReader;
19
+ mastraClient: MastraPluginClient;
20
+ updateSession: ThreadSessionUpdater;
21
+ writeMessages: ThreadMessageWriter;
22
+ }
23
+
24
+ /** Hydrate and page the active thread while other thread sessions keep running. */
25
+ export function useChatHistory({
26
+ activeKey,
27
+ activeThreadId,
28
+ agentId,
29
+ getSession,
30
+ mastraClient,
31
+ updateSession,
32
+ writeMessages,
33
+ }: UseChatHistoryOptions) {
34
+ const [isLoadingHistory, setIsLoadingHistory] = useState(true);
35
+ const [loadingMoreThreads, setLoadingMoreThreads] = useState<ReadonlySet<string>>(
36
+ () => new Set(),
37
+ );
38
+ const historyInFlightRef = useRef(new Set<string>());
39
+
40
+ useEffect(() => {
41
+ const threadId = activeKey;
42
+ const session = getSession(threadId);
43
+ if (session.historyLoaded) {
44
+ setIsLoadingHistory(false);
45
+ return;
46
+ }
47
+
48
+ let cancelled = false;
49
+ const controller = new AbortController();
50
+ historyInFlightRef.current.add(threadId);
51
+ setIsLoadingHistory(true);
52
+ mastraClient
53
+ .history({
54
+ agentId,
55
+ threadId: activeThreadId,
56
+ page: 0,
57
+ perPage: HISTORY_PAGE_SIZE,
58
+ signal: controller.signal,
59
+ })
60
+ .then((response) => {
61
+ if (cancelled) return;
62
+ updateSession(threadId, (current) => ({
63
+ ...current,
64
+ messages: response.uiMessages as unknown as UIMessage[],
65
+ historyLoaded: true,
66
+ hasMoreHistory: response.hasMore,
67
+ historyPage: 1,
68
+ toolEventsByMessage: {},
69
+ pendingApprovalsByMessage: {},
70
+ feedbackByMessage: {},
71
+ }));
72
+ })
73
+ .catch((error: unknown) => {
74
+ if (cancelled || (error as { name?: string }).name === "AbortError") return;
75
+ logger.error("history load error", {
76
+ error: sharedError.errorMessage(error),
77
+ });
78
+ updateSession(threadId, (current) => ({
79
+ ...current,
80
+ historyLoaded: true,
81
+ hasMoreHistory: false,
82
+ }));
83
+ })
84
+ .finally(() => {
85
+ historyInFlightRef.current.delete(threadId);
86
+ if (!cancelled) setIsLoadingHistory(false);
87
+ });
88
+ return () => {
89
+ cancelled = true;
90
+ controller.abort();
91
+ };
92
+ }, [activeKey, activeThreadId, agentId, getSession, mastraClient, updateSession]);
93
+
94
+ const loadOlderHistory = useCallback(() => {
95
+ const threadId = activeKey;
96
+ const session = getSession(threadId);
97
+ if (historyInFlightRef.current.has(threadId) || !session.hasMoreHistory) return;
98
+ historyInFlightRef.current.add(threadId);
99
+ setLoadingMoreThreads((current) => new Set(current).add(threadId));
100
+ const page = session.historyPage;
101
+ updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
102
+ mastraClient
103
+ .history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
104
+ .then((response) => {
105
+ const uiMessages = response.uiMessages as unknown as UIMessage[];
106
+ if (uiMessages.length > 0) {
107
+ writeMessages(threadId, [...uiMessages, ...getSession(threadId).messages]);
108
+ }
109
+ updateSession(threadId, (current) => ({
110
+ ...current,
111
+ hasMoreHistory: response.hasMore,
112
+ }));
113
+ })
114
+ .catch((error: unknown) => {
115
+ logger.error("history load-more error", {
116
+ page,
117
+ error: sharedError.errorMessage(error),
118
+ });
119
+ updateSession(threadId, (current) => ({
120
+ ...current,
121
+ historyPage: page,
122
+ hasMoreHistory: false,
123
+ }));
124
+ })
125
+ .finally(() => {
126
+ historyInFlightRef.current.delete(threadId);
127
+ setLoadingMoreThreads((current) => {
128
+ if (!current.has(threadId)) return current;
129
+ const next = new Set(current);
130
+ next.delete(threadId);
131
+ return next;
132
+ });
133
+ });
134
+ }, [activeKey, activeThreadId, agentId, getSession, mastraClient, updateSession, writeMessages]);
135
+
136
+ return {
137
+ isLoadingHistory,
138
+ isLoadingMore: loadingMoreThreads.has(activeKey),
139
+ loadOlderHistory,
140
+ };
141
+ }
@@ -0,0 +1,76 @@
1
+ import type { UIMessage } from "ai";
2
+ import { useCallback, useMemo, useRef, useState } from "react";
3
+ import {
4
+ createThreadSession,
5
+ DEFAULT_THREAD_SESSION_KEY,
6
+ isSessionRunning,
7
+ sessionKey,
8
+ type ThreadSession,
9
+ } from "../support/thread-sessions.ts";
10
+
11
+ export type ThreadSessionReader = (threadId: string) => ThreadSession;
12
+
13
+ export type ThreadSessionUpdater = (
14
+ threadId: string,
15
+ updater: (session: ThreadSession) => ThreadSession,
16
+ ) => void;
17
+
18
+ export type ThreadMessageWriter = (threadId: string, messages: UIMessage[]) => void;
19
+
20
+ /** Own the mutable per-thread session registry behind the public chat hook. */
21
+ export function useChatSessions(activeThreadId: string | undefined) {
22
+ const [revision, setRevision] = useState(0);
23
+ const sessionsRef = useRef<Map<string, ThreadSession>>(new Map());
24
+ const getSession = useCallback<ThreadSessionReader>((threadId) => {
25
+ let session = sessionsRef.current.get(threadId);
26
+ if (!session) {
27
+ session = createThreadSession();
28
+ sessionsRef.current.set(threadId, session);
29
+ }
30
+ return session;
31
+ }, []);
32
+ const updateSession = useCallback<ThreadSessionUpdater>(
33
+ (threadId, updater) => {
34
+ sessionsRef.current.set(threadId, updater(getSession(threadId)));
35
+ setRevision((current) => current + 1);
36
+ },
37
+ [getSession],
38
+ );
39
+ const writeMessages = useCallback<ThreadMessageWriter>(
40
+ (threadId, messages) => {
41
+ updateSession(threadId, (session) => ({ ...session, messages }));
42
+ },
43
+ [updateSession],
44
+ );
45
+ const resetSession = useCallback((threadId: string, historyLoaded = false) => {
46
+ sessionsRef.current.get(threadId)?.abortController?.abort();
47
+ sessionsRef.current.set(threadId, { ...createThreadSession(), historyLoaded });
48
+ setRevision((current) => current + 1);
49
+ }, []);
50
+ const removeSession = useCallback((threadId: string) => {
51
+ sessionsRef.current.get(threadId)?.abortController?.abort();
52
+ sessionsRef.current.delete(threadId);
53
+ setRevision((current) => current + 1);
54
+ }, []);
55
+ const activeKey = sessionKey(activeThreadId);
56
+ const activeSession = useMemo(() => getSession(activeKey), [activeKey, getSession, revision]);
57
+ const streamingThreadIds = useMemo(() => {
58
+ const ids: string[] = [];
59
+ for (const [id, session] of sessionsRef.current.entries()) {
60
+ if (id === DEFAULT_THREAD_SESSION_KEY) continue;
61
+ if (isSessionRunning(session)) ids.push(id);
62
+ }
63
+ return ids;
64
+ }, [revision]);
65
+
66
+ return {
67
+ activeKey,
68
+ activeSession,
69
+ getSession,
70
+ removeSession,
71
+ resetSession,
72
+ streamingThreadIds,
73
+ updateSession,
74
+ writeMessages,
75
+ };
76
+ }