@dbx-tools/ui-mastra 0.6.210 → 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.
@@ -1,20 +1,16 @@
1
1
  import { error as sharedError, hash, log } from "@dbx-tools/shared-core";
2
- import { feedback, type MastraThread } from "@dbx-tools/shared-mastra";
2
+ import type { MastraThread } from "@dbx-tools/shared-mastra";
3
3
  import { useBrand } from "@dbx-tools/ui-branding/react";
4
4
  import type { UIMessage } from "ai";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { useChatApprovals } from "./chat-approvals.ts";
6
7
  import { ChatView } from "./chat-view.tsx";
8
+ import { useChatFeedback } from "./chat-feedback.ts";
9
+ import { useChatHistory } from "./chat-history.ts";
10
+ import { useChatSessions } from "./chat-sessions.ts";
11
+ import { useChatStream } from "./chat-stream.ts";
7
12
  import { dedupeSuggestions } from "./suggestions.ts";
8
- import type {
9
- ApprovalDecision,
10
- ChatViewProps,
11
- FeedbackSubmission,
12
- MessageFeedback,
13
- ThreadPlacement,
14
- ThreadSummary,
15
- ToolEvent,
16
- ToolProgress,
17
- } from "./types.ts";
13
+ import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
18
14
  import type { EmbedResolver, ExportFormat } from "../support/export.ts";
19
15
  import {
20
16
  useMastraClient,
@@ -30,13 +26,11 @@ import {
30
26
  storeSelectedModel,
31
27
  } from "../support/model-selection.ts";
32
28
  import {
33
- createThreadSession,
34
29
  DEFAULT_THREAD_SESSION_KEY,
35
30
  enqueueSteer,
36
31
  isSessionRunning,
37
32
  removeSteer as removeSteerFromQueue,
38
33
  reorderSteers as reorderSteerQueue,
39
- sessionKey,
40
34
  terminateRunningToolEvents,
41
35
  type ThreadSession,
42
36
  } from "../support/thread-sessions.ts";
@@ -65,8 +59,6 @@ const _loadChatExport = () => import("../support/export.ts");
65
59
 
66
60
  const logger = log.logger("ui-mastra/chat");
67
61
 
68
- const HISTORY_PAGE_SIZE = 20;
69
-
70
62
  const makeUserMessage = (text: string): UIMessage => ({
71
63
  id: hash.id(),
72
64
  role: "user",
@@ -149,27 +141,6 @@ const storeStoredSidebarOpen = (key: string, open: boolean): void => {
149
141
  }
150
142
  };
151
143
 
152
- /**
153
- * Pull the MLflow trace id (`tr-<hex>`) the server stamped on a stream
154
- * response, if present. `@mastra/client-js`'s `agent.stream()` returns
155
- * a Response-shaped object, so the header is read defensively (the
156
- * shape isn't guaranteed across client versions). Returns `undefined`
157
- * when absent - which is the "no feedback for this turn" signal.
158
- */
159
- const readMlflowTraceId = (stream: unknown): string | undefined => {
160
- const headers = (stream as { headers?: { get?: (name: string) => string | null } })?.headers;
161
- const raw = headers?.get?.(feedback.MLFLOW_TRACE_ID_HEADER);
162
- return raw?.trim() || undefined;
163
- };
164
-
165
- // `tool-output` chunks carry arbitrary tool-defined payloads; only the
166
- // `{type: ...}` shape we know how to render in `ToolSessionPill` is
167
- // surfaced. Anything else (other tools, raw data, etc.) is ignored.
168
- const isToolProgress = (value: unknown): value is ToolProgress =>
169
- typeof value === "object" &&
170
- value !== null &&
171
- typeof (value as { type?: unknown }).type === "string";
172
-
173
144
  /** Options for {@link useMastraChat}. */
174
145
  export interface UseMastraChatOptions {
175
146
  /**
@@ -237,13 +208,6 @@ export interface UseMastraChatOptions {
237
208
  enableFeedback?: boolean;
238
209
  }
239
210
 
240
- /**
241
- * Thrown out of the chunk handler to unwind `processDataStream` when
242
- * the user stops a turn. Callers treat it as a clean stop, not an
243
- * error, so the composer just returns to idle.
244
- */
245
- class StreamAborted extends Error {}
246
-
247
211
  /**
248
212
  * Headless driver for the Mastra chat experience. Owns the full
249
213
  * conversation lifecycle (streaming, tool-event tracking, approvals,
@@ -397,327 +361,38 @@ export const useMastraChat = (
397
361
  () => explicitSuggestions ?? dedupeSuggestions(genieSuggestions),
398
362
  [explicitSuggestions, genieSuggestions],
399
363
  );
400
- const [sessionsTick, setSessionsTick] = useState(0);
401
- const sessionsRef = useRef<Map<string, ThreadSession>>(new Map());
402
- const notifySessions = useCallback(() => {
403
- setSessionsTick((tick) => tick + 1);
404
- }, []);
405
- const getSession = useCallback((threadId: string): ThreadSession => {
406
- let session = sessionsRef.current.get(threadId);
407
- if (!session) {
408
- session = createThreadSession();
409
- sessionsRef.current.set(threadId, session);
410
- }
411
- return session;
412
- }, []);
413
- const updateSession = useCallback(
414
- (threadId: string, updater: (session: ThreadSession) => ThreadSession) => {
415
- const next = updater(getSession(threadId));
416
- sessionsRef.current.set(threadId, next);
417
- notifySessions();
418
- },
419
- [getSession, notifySessions],
420
- );
421
- const activeKey = sessionKey(activeThreadId);
422
- const activeSession = useMemo(() => getSession(activeKey), [activeKey, getSession, sessionsTick]);
423
- const streamingThreadIds = useMemo(() => {
424
- const ids: string[] = [];
425
- for (const [id, session] of sessionsRef.current.entries()) {
426
- if (id === DEFAULT_THREAD_SESSION_KEY) continue;
427
- if (isSessionRunning(session)) ids.push(id);
428
- }
429
- return ids;
430
- }, [sessionsTick]);
431
- const [isLoadingHistory, setIsLoadingHistory] = useState(true);
432
- const [loadingMoreThreads, setLoadingMoreThreads] = useState<ReadonlySet<string>>(
433
- () => new Set(),
434
- );
435
- const isLoadingMore = loadingMoreThreads.has(activeKey);
436
- const historyInFlightRef = useRef(new Set<string>());
437
- const feedbackByMessageRef = useRef<Record<string, MessageFeedback>>({});
438
- feedbackByMessageRef.current = activeSession.feedbackByMessage;
364
+ const {
365
+ activeKey,
366
+ activeSession,
367
+ getSession,
368
+ removeSession,
369
+ resetSession,
370
+ streamingThreadIds,
371
+ updateSession,
372
+ writeMessages,
373
+ } = useChatSessions(activeThreadId);
374
+ const { isLoadingHistory, isLoadingMore, loadOlderHistory } = useChatHistory({
375
+ activeKey,
376
+ activeThreadId,
377
+ agentId,
378
+ getSession,
379
+ mastraClient,
380
+ updateSession,
381
+ writeMessages,
382
+ });
383
+ const submitFeedback = useChatFeedback({
384
+ activeKey,
385
+ feedbackByMessage: activeSession.feedbackByMessage,
386
+ mastraClient,
387
+ updateSession,
388
+ });
439
389
  // Drains the next queued steer when a turn ends. Held in a ref because it
440
390
  // closes over `runStream`, which is defined below and itself calls
441
391
  // `driveStream` (which invokes this) - the ref breaks that cycle without a
442
392
  // stale-closure hazard (assigned each render, same pattern as `loadMoreRef`).
443
393
  const drainQueueRef = useRef<(threadId: string) => void>(() => {});
444
394
 
445
- const writeMessages = useCallback(
446
- (threadId: string, next: UIMessage[]) => {
447
- updateSession(threadId, (session) => ({ ...session, messages: next }));
448
- },
449
- [updateSession],
450
- );
451
-
452
- /**
453
- * Pipe a Mastra stream Response through the same chunk handler used
454
- * for the initial turn. `assistantId` identifies the in-progress
455
- * assistant message so resumed streams (from approveToolCall /
456
- * declineToolCall) keep mutating the same bubble instead of
457
- * spawning a new one. `runId` is captured in a ref so the approval
458
- * handler can later resume the suspended workflow.
459
- */
460
- const processStream = useCallback(
461
- async (
462
- threadId: string,
463
- stream: MastraStreamResponse,
464
- assistantId: string,
465
- runIdRef: { current: string | null },
466
- signal: AbortSignal,
467
- ) => {
468
- const traceId = readMlflowTraceId(stream);
469
- if (traceId) {
470
- updateSession(threadId, (session) =>
471
- session.feedbackByMessage[assistantId]?.traceId === traceId
472
- ? session
473
- : {
474
- ...session,
475
- feedbackByMessage: {
476
- ...session.feedbackByMessage,
477
- [assistantId]: {
478
- ...session.feedbackByMessage[assistantId],
479
- traceId,
480
- },
481
- },
482
- },
483
- );
484
- }
485
- const existing = getSession(threadId).messages.find((m) => m.id === assistantId);
486
- // Text is tracked as ordered segments, one per `text-start` the
487
- // agent emits. In a multi-step turn the model opens a fresh text
488
- // block in each step (a short preamble before each tool call),
489
- // and those blocks read as distinct "updates". Keeping them as
490
- // separate segments lets the bubble render each as its own block
491
- // instead of mashing "...summary.This is..." into one paragraph.
492
- const textSegments: string[] = [];
493
- let assistantReasoning = "";
494
- if (existing) {
495
- for (const part of existing.parts) {
496
- if (part.type === "text") {
497
- textSegments.push(part.text);
498
- } else if (part.type === "reasoning") {
499
- assistantReasoning += (part as { text?: string }).text ?? "";
500
- }
501
- }
502
- }
503
- // Append a text delta to the current (most recent) segment,
504
- // opening one if none exists yet (defensive: a provider could
505
- // stream deltas without a leading `text-start`).
506
- const appendText = (delta: string) => {
507
- if (textSegments.length === 0) textSegments.push("");
508
- textSegments[textSegments.length - 1] += delta;
509
- };
510
-
511
- const upsertAssistant = () => {
512
- const prev = getSession(threadId).messages;
513
- const next = [...prev];
514
- const idx = next.findIndex((m) => m.id === assistantId);
515
- const parts: UIMessage["parts"] = [];
516
- if (assistantReasoning) {
517
- parts.push({ type: "reasoning", text: assistantReasoning });
518
- }
519
- for (const segment of textSegments) {
520
- if (segment.length > 0) parts.push({ type: "text", text: segment });
521
- }
522
- const message: UIMessage = {
523
- id: assistantId,
524
- role: "assistant",
525
- parts: parts.length > 0 ? parts : [{ type: "text", text: "" }],
526
- };
527
- if (idx === -1) next.push(message);
528
- else next[idx] = message;
529
- writeMessages(threadId, next);
530
- };
531
-
532
- const patchToolEvents = (update: (list: ToolEvent[]) => ToolEvent[]) => {
533
- updateSession(threadId, (session) => ({
534
- ...session,
535
- toolEventsByMessage: {
536
- ...session.toolEventsByMessage,
537
- [assistantId]: update(session.toolEventsByMessage[assistantId] ?? []),
538
- },
539
- }));
540
- };
541
-
542
- let started = false;
543
- const markStreaming = () => {
544
- if (started) return;
545
- started = true;
546
- updateSession(threadId, (session) =>
547
- session.status === "streaming" ? session : { ...session, status: "streaming" },
548
- );
549
- };
550
-
551
- try {
552
- await stream.processDataStream({
553
- onChunk: async (chunk: { type: string; payload?: any; runId?: string }) => {
554
- // The user hit Stop: unwind the read loop. Throwing (rather
555
- // than returning) is what actually stops `processDataStream`
556
- // from pulling the next chunk; the wrapper below swallows it.
557
- if (signal.aborted) throw new StreamAborted();
558
- // Mastra stamps the stream's runId on most chunks. Capturing
559
- // it the first time we see it (rather than relying on a
560
- // separate API) keeps approve/decline calls correct even if
561
- // the client-supplied runId got overridden server-side.
562
- if (chunk.runId && !runIdRef.current) {
563
- runIdRef.current = chunk.runId;
564
- updateSession(threadId, (session) => ({
565
- ...session,
566
- runId: chunk.runId!,
567
- }));
568
- }
569
- switch (chunk.type) {
570
- case "text-start":
571
- // Open a new text segment so each step's preamble stays
572
- // a separate part (and thus a separate rendered block).
573
- textSegments.push("");
574
- break;
575
- case "text-delta":
576
- appendText(chunk.payload?.text ?? "");
577
- upsertAssistant();
578
- markStreaming();
579
- break;
580
- case "text-end":
581
- // Segment boundary is driven by `text-start`; nothing to
582
- // do on end - the next start opens the next segment.
583
- break;
584
- case "reasoning-delta":
585
- assistantReasoning += chunk.payload?.text ?? "";
586
- upsertAssistant();
587
- markStreaming();
588
- break;
589
- case "tool-call": {
590
- const { toolCallId, toolName } = chunk.payload ?? {};
591
- if (typeof toolCallId !== "string") break;
592
- patchToolEvents((list) => [
593
- ...list,
594
- { id: toolCallId, toolName, status: "running" },
595
- ]);
596
- // Make sure the assistant message exists in `messages`
597
- // even when the model goes straight to a tool call with
598
- // no preceding text, so the bubble (and its inline
599
- // tool indicator) renders right away.
600
- upsertAssistant();
601
- markStreaming();
602
- break;
603
- }
604
- case "tool-call-approval": {
605
- // Mastra paused the agent loop on a `requireApproval`
606
- // tool call. The chunk carries the runId we'll need to
607
- // resume the suspended workflow later. We surface the
608
- // approval card via `pendingApprovalsByMessage` so the
609
- // existing ChatView UI lights up without us having to
610
- // inject a synthetic data part.
611
- const { toolCallId, toolName, args } = chunk.payload ?? {};
612
- const approvalRunId = chunk.runId ?? runIdRef.current;
613
- if (
614
- typeof toolCallId !== "string" ||
615
- typeof toolName !== "string" ||
616
- !approvalRunId
617
- ) {
618
- logger.warn("malformed tool-call-approval chunk", {
619
- toolCallId,
620
- toolName,
621
- hasRunId: Boolean(approvalRunId),
622
- });
623
- break;
624
- }
625
- updateSession(threadId, (session) => {
626
- const existingApprovals = session.pendingApprovalsByMessage[assistantId] ?? [];
627
- if (existingApprovals.some((a) => a.toolCallId === toolCallId)) {
628
- return session;
629
- }
630
- return {
631
- ...session,
632
- pendingApprovalsByMessage: {
633
- ...session.pendingApprovalsByMessage,
634
- [assistantId]: [
635
- ...existingApprovals,
636
- {
637
- toolName,
638
- toolCallId,
639
- runId: approvalRunId,
640
- input: args,
641
- },
642
- ],
643
- },
644
- };
645
- });
646
- upsertAssistant();
647
- markStreaming();
648
- break;
649
- }
650
- case "tool-result": {
651
- const toolCallId = chunk.payload?.toolCallId;
652
- if (typeof toolCallId !== "string") break;
653
- // Charts resolve from `[chart:<id>]` markers in the
654
- // assistant's prose (the model embeds the id returned
655
- // by `prepare_chart`), so the tool-result payload is
656
- // opaque here - we only need it to flip the pill.
657
- // Genie tools (`ask_genie`, `get_statement`,
658
- // `prepare_chart`) stream their entire progress
659
- // surface through `ctx.writer` and arrive on this
660
- // page via the `tool-output` path. The settled
661
- // tool-result return value is opaque to the UI -
662
- // we only need it to flip the pill to `done`.
663
- patchToolEvents((list) =>
664
- list.map((e) => (e.id === toolCallId ? { ...e, status: "done" } : e)),
665
- );
666
- break;
667
- }
668
- case "tool-error": {
669
- const toolCallId = chunk.payload?.toolCallId;
670
- if (typeof toolCallId !== "string") break;
671
- patchToolEvents((list) =>
672
- list.map((e) => (e.id === toolCallId ? { ...e, status: "error" } : e)),
673
- );
674
- break;
675
- }
676
- case "tool-output": {
677
- // Mid-flight progress pushed by a tool via `ctx.writer`
678
- // (e.g. genie.ts forwarding `status`/`sql`/`data` events
679
- // from the Genie space). Append to the matching pill so
680
- // the user sees SQL/row info as soon as Genie publishes
681
- // it, not only when the LLM call completes.
682
- const { toolCallId, output } = chunk.payload ?? {};
683
- if (typeof toolCallId !== "string") break;
684
- if (!isToolProgress(output)) break;
685
- patchToolEvents((list) =>
686
- list.map((e) =>
687
- e.id === toolCallId ? { ...e, progress: [...(e.progress ?? []), output] } : e,
688
- ),
689
- );
690
- break;
691
- }
692
- case "error": {
693
- // Surface a stream-reported error through the same path as
694
- // a thrown one: throwing here propagates out of
695
- // `processDataStream` to `driveStream`, which records the
696
- // message and pins `status` to "error" (a plain
697
- // setStatus would be clobbered by the clean-close "ready").
698
- const detail = chunk.payload?.error ?? chunk.payload?.message;
699
- throw new Error(
700
- typeof detail === "string" && detail
701
- ? detail
702
- : "The assistant stream reported an error.",
703
- );
704
- }
705
- default:
706
- break;
707
- }
708
- },
709
- });
710
- } catch (error) {
711
- // A stop (signal aborted) unwinds the loop cleanly - not a
712
- // failure. Anything else is a real stream error and propagates
713
- // to the driver's catch.
714
- if (error instanceof StreamAborted || signal.aborted) return;
715
- throw error;
716
- }
717
- },
718
- [getSession, updateSession, writeMessages],
719
- );
720
-
395
+ const processStream = useChatStream({ getSession, updateSession, writeMessages });
721
396
  const driveStream = useCallback(
722
397
  async (
723
398
  threadId: string,
@@ -853,68 +528,14 @@ export const useMastraChat = (
853
528
  [activeKey, updateSession],
854
529
  );
855
530
 
856
- /**
857
- * Approve or deny an in-flight `requireApproval` tool call. The
858
- * suspended workflow lives on the server keyed by `runId`; we resume
859
- * via {@link MastraPluginClient.approveToolCallStream} /
860
- * `declineToolCallStream` and pipe the SSE chunk stream back into
861
- * the same bubble `runStream` was building.
862
- */
863
- const handleApproval = useCallback(
864
- async (decision: ApprovalDecision) => {
865
- const { runId: decisionRunId, toolCallId, toolName } = decision;
866
- const session = getSession(activeKey);
867
- const assistantId = session.assistantId;
868
- const runId = decisionRunId ?? session.runId;
869
- if (!runId || !assistantId) {
870
- logger.warn("approval missing runId or assistantId, cannot resume", {
871
- tool: toolName,
872
- toolCallId,
873
- hasRunId: Boolean(runId),
874
- hasAssistantId: Boolean(assistantId),
875
- });
876
- return;
877
- }
878
- updateSession(activeKey, (current) => {
879
- const existing = current.pendingApprovalsByMessage[assistantId];
880
- if (!existing) return current;
881
- const next = existing.filter((a) => a.toolCallId !== toolCallId);
882
- if (next.length === 0) {
883
- const { [assistantId]: _drop, ...rest } = current.pendingApprovalsByMessage;
884
- return { ...current, pendingApprovalsByMessage: rest };
885
- }
886
- return {
887
- ...current,
888
- pendingApprovalsByMessage: {
889
- ...current.pendingApprovalsByMessage,
890
- [assistantId]: next,
891
- },
892
- };
893
- });
894
- logger.info(decision.approved ? "approved" : "denied", {
895
- tool: toolName,
896
- toolCallId,
897
- runId,
898
- });
899
- const streamThreadId = activeKey === DEFAULT_THREAD_SESSION_KEY ? undefined : activeKey;
900
- await driveStream(activeKey, assistantId, (signal) =>
901
- decision.approved
902
- ? mastraClient.approveToolCallStream(agentId, {
903
- runId,
904
- toolCallId,
905
- threadId: streamThreadId,
906
- signal,
907
- })
908
- : mastraClient.declineToolCallStream(agentId, {
909
- runId,
910
- toolCallId,
911
- threadId: streamThreadId,
912
- signal,
913
- }),
914
- );
915
- },
916
- [activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
917
- );
531
+ const handleApproval = useChatApprovals({
532
+ activeKey,
533
+ agentId,
534
+ driveStream,
535
+ getSession,
536
+ mastraClient,
537
+ updateSession,
538
+ });
918
539
 
919
540
  // Append a user message to a thread's transcript, stamping `lastUserText`,
920
541
  // thread-activity, and a provisional title for a brand-new thread. Returns
@@ -1025,12 +646,7 @@ export const useMastraChat = (
1025
646
  error: sharedError.errorMessage(error),
1026
647
  });
1027
648
  }
1028
- const session = getSession(threadId);
1029
- session.abortController?.abort();
1030
- updateSession(threadId, () => ({
1031
- ...createThreadSession(),
1032
- historyLoaded: true,
1033
- }));
649
+ resetSession(threadId, true);
1034
650
  if (activeThreadId) {
1035
651
  setOptimisticThreads((prev) => {
1036
652
  if (!prev[activeThreadId]) return prev;
@@ -1044,15 +660,7 @@ export const useMastraChat = (
1044
660
  });
1045
661
  }
1046
662
  refreshThreadsSoon();
1047
- }, [
1048
- mastraClient,
1049
- agentId,
1050
- activeKey,
1051
- activeThreadId,
1052
- getSession,
1053
- refreshThreadsSoon,
1054
- updateSession,
1055
- ]);
663
+ }, [mastraClient, agentId, activeKey, activeThreadId, refreshThreadsSoon, resetSession]);
1056
664
 
1057
665
  const selectThread = useCallback(
1058
666
  (threadId: string) => {
@@ -1064,10 +672,9 @@ export const useMastraChat = (
1064
672
 
1065
673
  const newThread = useCallback(() => {
1066
674
  const id = hash.id();
1067
- sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
1068
- notifySessions();
675
+ resetSession(id, true);
1069
676
  setActiveThreadId(id);
1070
- }, [notifySessions]);
677
+ }, [resetSession]);
1071
678
 
1072
679
  const deleteThread = useCallback(
1073
680
  async (threadId: string) => {
@@ -1080,10 +687,7 @@ export const useMastraChat = (
1080
687
  error: sharedError.errorMessage(error),
1081
688
  });
1082
689
  }
1083
- const session = sessionsRef.current.get(threadId);
1084
- session?.abortController?.abort();
1085
- sessionsRef.current.delete(threadId);
1086
- notifySessions();
690
+ removeSession(threadId);
1087
691
  setOptimisticThreads((prev) => {
1088
692
  if (!prev[threadId]) return prev;
1089
693
  const { [threadId]: _drop, ...rest } = prev;
@@ -1096,13 +700,12 @@ export const useMastraChat = (
1096
700
  });
1097
701
  if (threadId === activeThreadId) {
1098
702
  const id = hash.id();
1099
- sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
1100
- notifySessions();
703
+ resetSession(id, true);
1101
704
  setActiveThreadId(id);
1102
705
  }
1103
706
  refreshThreads();
1104
707
  },
1105
- [mastraClient, agentId, activeThreadId, notifySessions, refreshThreads],
708
+ [activeThreadId, agentId, mastraClient, refreshThreads, removeSession, resetSession],
1106
709
  );
1107
710
 
1108
711
  /**
@@ -1164,107 +767,6 @@ export const useMastraChat = (
1164
767
  void runStream(threadId, trimmed);
1165
768
  }, [activeKey, getSession, runStream, updateSession, writeMessages]);
1166
769
 
1167
- // Hydrate the active thread from the server when it has no local
1168
- // session yet. In-flight streams keep updating their session in the
1169
- // background, so switching back shows live partial text without
1170
- // refetching or aborting other threads' runs.
1171
- useEffect(() => {
1172
- const threadId = activeKey;
1173
- const session = getSession(threadId);
1174
- if (session.historyLoaded) {
1175
- setIsLoadingHistory(false);
1176
- return;
1177
- }
1178
-
1179
- let cancelled = false;
1180
- const controller = new AbortController();
1181
- historyInFlightRef.current.add(threadId);
1182
- setIsLoadingHistory(true);
1183
- mastraClient
1184
- .history({
1185
- agentId,
1186
- threadId: activeThreadId,
1187
- page: 0,
1188
- perPage: HISTORY_PAGE_SIZE,
1189
- signal: controller.signal,
1190
- })
1191
- .then((response) => {
1192
- if (cancelled) return;
1193
- updateSession(threadId, (current) => ({
1194
- ...current,
1195
- messages: response.uiMessages as unknown as UIMessage[],
1196
- historyLoaded: true,
1197
- hasMoreHistory: response.hasMore,
1198
- historyPage: 1,
1199
- toolEventsByMessage: {},
1200
- pendingApprovalsByMessage: {},
1201
- feedbackByMessage: {},
1202
- }));
1203
- })
1204
- .catch((error: unknown) => {
1205
- if (cancelled || (error as { name?: string }).name === "AbortError") return;
1206
- logger.error("history load error", {
1207
- error: sharedError.errorMessage(error),
1208
- });
1209
- updateSession(threadId, (current) => ({
1210
- ...current,
1211
- historyLoaded: true,
1212
- hasMoreHistory: false,
1213
- }));
1214
- })
1215
- .finally(() => {
1216
- historyInFlightRef.current.delete(threadId);
1217
- if (!cancelled) setIsLoadingHistory(false);
1218
- });
1219
- return () => {
1220
- cancelled = true;
1221
- controller.abort();
1222
- };
1223
- }, [mastraClient, agentId, activeThreadId, activeKey, getSession, updateSession]);
1224
-
1225
- const loadOlderHistory = useCallback(() => {
1226
- const threadId = activeKey;
1227
- const session = getSession(threadId);
1228
- if (historyInFlightRef.current.has(threadId) || !session.hasMoreHistory) return;
1229
- historyInFlightRef.current.add(threadId);
1230
- setLoadingMoreThreads((current) => new Set(current).add(threadId));
1231
- const page = session.historyPage;
1232
- updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
1233
- mastraClient
1234
- .history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
1235
- .then((response) => {
1236
- const uiMessages = response.uiMessages as unknown as UIMessage[];
1237
- if (uiMessages.length > 0) {
1238
- const currentMessages = getSession(threadId).messages;
1239
- writeMessages(threadId, [...uiMessages, ...currentMessages]);
1240
- }
1241
- updateSession(threadId, (current) => ({
1242
- ...current,
1243
- hasMoreHistory: response.hasMore,
1244
- }));
1245
- })
1246
- .catch((error: unknown) => {
1247
- logger.error("history load-more error", {
1248
- page,
1249
- error: sharedError.errorMessage(error),
1250
- });
1251
- updateSession(threadId, (current) => ({
1252
- ...current,
1253
- historyPage: page,
1254
- hasMoreHistory: false,
1255
- }));
1256
- })
1257
- .finally(() => {
1258
- historyInFlightRef.current.delete(threadId);
1259
- setLoadingMoreThreads((current) => {
1260
- if (!current.has(threadId)) return current;
1261
- const next = new Set(current);
1262
- next.delete(threadId);
1263
- return next;
1264
- });
1265
- });
1266
- }, [activeKey, activeThreadId, getSession, mastraClient, agentId, updateSession, writeMessages]);
1267
-
1268
770
  // Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
1269
771
  // straight off the client so the export inlines the same charts /
1270
772
  // tables the UI renders. Handlers are defined unconditionally (rules of
@@ -1345,47 +847,6 @@ export const useMastraChat = (
1345
847
  [exportResolver, exportUserLabel, exportBrand],
1346
848
  );
1347
849
 
1348
- // Submit thumbs / comment feedback for an assistant message to MLflow
1349
- // via the plugin's feedback route. The message's captured trace id
1350
- // scopes the assessment; without one there's nothing to attach to, so
1351
- // the call is skipped. A thumbs value is reflected optimistically so
1352
- // the active button highlights immediately; a soft "not recorded"
1353
- // (e.g. the trace is still exporting) is logged, not surfaced as an
1354
- // error, to keep the chat calm.
1355
- const submitFeedback = useCallback(
1356
- async (message: UIMessage, submission: FeedbackSubmission) => {
1357
- const traceId = feedbackByMessageRef.current[message.id]?.traceId;
1358
- if (!traceId) return;
1359
- if (submission.value) {
1360
- updateSession(activeKey, (session) => ({
1361
- ...session,
1362
- feedbackByMessage: {
1363
- ...session.feedbackByMessage,
1364
- [message.id]: { traceId, value: submission.value },
1365
- },
1366
- }));
1367
- }
1368
- try {
1369
- const result = await mastraClient.feedback({
1370
- traceId,
1371
- ...(submission.value !== undefined ? { value: submission.value === "up" } : {}),
1372
- ...(submission.comment ? { comment: submission.comment } : {}),
1373
- });
1374
- if (!result.ok) {
1375
- logger.warn("feedback not recorded (trace may still be exporting)", {
1376
- traceId,
1377
- });
1378
- }
1379
- } catch (error) {
1380
- logger.error("feedback error", {
1381
- traceId,
1382
- error: sharedError.errorMessage(error),
1383
- });
1384
- }
1385
- },
1386
- [activeKey, mastraClient, updateSession],
1387
- );
1388
-
1389
850
  // Merge optimistic rows over the server list, newest first, dropping any
1390
851
  // optimistic entry the server already returns so a thread is never listed
1391
852
  // twice.