@gram-ai/elements 1.37.1 → 1.38.0

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.
Files changed (34) hide show
  1. package/dist/components/Markdown.d.ts +7 -0
  2. package/dist/components/MessageContent.d.ts +4 -0
  3. package/dist/components/ui/tool-ui.d.ts +52 -3
  4. package/dist/elements.cjs +1 -1
  5. package/dist/elements.css +1 -1
  6. package/dist/elements.js +26 -22
  7. package/dist/hooks/useMCPTools.d.ts +1 -1
  8. package/dist/{index-BdBraSNn.js → index--UMkUr53.js} +27921 -22336
  9. package/dist/index--UMkUr53.js.map +1 -0
  10. package/dist/index-Cz9y5YHw.cjs +222 -0
  11. package/dist/index-Cz9y5YHw.cjs.map +1 -0
  12. package/dist/index.d.ts +4 -0
  13. package/dist/lib/messageConverter.d.ts +2 -0
  14. package/dist/{profiler-Cl-9cG3B.js → profiler-BHXyuGiY.js} +2 -2
  15. package/dist/{profiler-Cl-9cG3B.js.map → profiler-BHXyuGiY.js.map} +1 -1
  16. package/dist/{profiler-ttCkbP-N.cjs → profiler-jAEvoPXB.cjs} +2 -2
  17. package/dist/{profiler-ttCkbP-N.cjs.map → profiler-jAEvoPXB.cjs.map} +1 -1
  18. package/dist/{startRecording-C41DbnxY.js → startRecording-D8IbKhJo.js} +2 -2
  19. package/dist/{startRecording-C41DbnxY.js.map → startRecording-D8IbKhJo.js.map} +1 -1
  20. package/dist/{startRecording-DLCeKyz9.cjs → startRecording-Dw4aGDrV.cjs} +2 -2
  21. package/dist/{startRecording-DLCeKyz9.cjs.map → startRecording-Dw4aGDrV.cjs.map} +1 -1
  22. package/package.json +11 -13
  23. package/src/components/Markdown.tsx +210 -0
  24. package/src/components/MessageContent.tsx +9 -0
  25. package/src/components/ui/tool-ui.tsx +360 -7
  26. package/src/contexts/ElementsProvider.tsx +2 -1
  27. package/src/hooks/useGramThreadListAdapter.tsx +63 -17
  28. package/src/hooks/useMCPTools.ts +1 -1
  29. package/src/index.ts +18 -0
  30. package/src/lib/messageConverter.ts +5 -0
  31. package/src/lib/tools.test.ts +24 -12
  32. package/dist/index-BdBraSNn.js.map +0 -1
  33. package/dist/index-Bl5cH0sz.cjs +0 -194
  34. package/dist/index-Bl5cH0sz.cjs.map +0 -1
@@ -7,7 +7,11 @@ import {
7
7
  ChevronRightIcon,
8
8
  ChevronUpIcon,
9
9
  CopyIcon,
10
+ EyeIcon,
11
+ EyeOffIcon,
10
12
  LoaderIcon,
13
+ SearchIcon,
14
+ TriangleAlertIcon,
11
15
  XIcon,
12
16
  } from "lucide-react";
13
17
  import { cn } from "@/lib/utils";
@@ -65,6 +69,33 @@ interface ToolAnnotations {
65
69
  openWorldHint?: boolean;
66
70
  }
67
71
 
72
+ /** Marks a tool section (arguments/output) as containing flagged substrings,
73
+ * so the section header shows a warning and the expanded body lets you jump
74
+ * between matches. */
75
+ /** One flagged finding within a tool section. */
76
+ interface SectionMatch {
77
+ /** Literal substring to highlight and step to. */
78
+ value: string;
79
+ /** Short rule label shown when this match is active (e.g. "pii.phone_number"). */
80
+ label?: string;
81
+ /** Optional action for this finding, surfaced as a button while it is the
82
+ * active match (e.g. open the create-exclusion flow). */
83
+ onExclude?: () => void;
84
+ }
85
+
86
+ interface SectionHighlight {
87
+ /** Findings to highlight and step through with the next/prev controls. */
88
+ matches: SectionMatch[];
89
+ /** Dot out the matched characters until the viewer reveals them (secrets). */
90
+ masked?: boolean;
91
+ /** Optional host-supplied badge rendered in the section header (e.g. a risk
92
+ * pill). Replaces the default warning icon when present. */
93
+ headerBadge?: React.ReactNode;
94
+ /** Mark colour: "risk" (red, default) for findings, "search" (yellow) for a
95
+ * text-search hit. */
96
+ tone?: "risk" | "search";
97
+ }
98
+
68
99
  interface ToolUIProps {
69
100
  /** Display name of the tool */
70
101
  name: string;
@@ -80,6 +111,16 @@ interface ToolUIProps {
80
111
  result?: string | Record<string, unknown> | { content: ContentItem[] };
81
112
  /** Whether the tool card starts expanded */
82
113
  defaultExpanded?: boolean;
114
+ /** Flag matches inside the arguments (risk review). */
115
+ requestHighlight?: SectionHighlight;
116
+ /** Flag matches inside the output (risk review). */
117
+ resultHighlight?: SectionHighlight;
118
+ /** When set, highlight occurrences of this query (case-insensitive) in the
119
+ * tool name — e.g. a thread search for "customer" lights up `get_customer`. */
120
+ nameQuery?: string;
121
+ /** Whether this tool holds the active thread-search match: bright highlights
122
+ * (name + sections) when true, pale when false. */
123
+ searchActive?: boolean;
83
124
  /** Additional class names */
84
125
  className?: string;
85
126
  /** MCP tool annotations */
@@ -101,6 +142,11 @@ interface ToolUISectionProps {
101
142
  highlightSyntax?: boolean;
102
143
  /** Language hint for syntax highlighting */
103
144
  language?: BundledLanguage;
145
+ /** Flagged substrings — renders a navigable highlighted view + header icon. */
146
+ highlight?: SectionHighlight;
147
+ /** Search tone only: whether this tool holds the active thread match (bright
148
+ * vs pale marks). */
149
+ searchActive?: boolean;
104
150
  }
105
151
 
106
152
  /* -----------------------------------------------------------------------------
@@ -255,7 +301,7 @@ function SyntaxHighlightedCode({
255
301
  {
256
302
  pre(node) {
257
303
  node.properties.class =
258
- "w-full py-3 px-4 max-h-[300px] overflow-y-auto whitespace-pre-wrap text-left text-sm";
304
+ "w-full py-3 px-4 max-h-[300px] overflow-y-auto whitespace-pre-wrap break-all text-left text-sm";
259
305
  },
260
306
  },
261
307
  ],
@@ -280,7 +326,7 @@ function SyntaxHighlightedCode({
280
326
  if (!canHighlight || !highlightedCode) {
281
327
  return (
282
328
  <div className={cn("w-full", className)}>
283
- <pre className="max-h-[300px] w-full overflow-y-auto bg-slate-800/90 px-4 py-3 text-sm whitespace-pre-wrap text-slate-100">
329
+ <pre className="max-h-[300px] w-full overflow-y-auto bg-slate-800/90 px-4 py-3 text-sm break-all whitespace-pre-wrap text-slate-100">
284
330
  {displayText}
285
331
  </pre>
286
332
  {showMoreButton}
@@ -299,6 +345,234 @@ function SyntaxHighlightedCode({
299
345
  );
300
346
  }
301
347
 
348
+ /* -----------------------------------------------------------------------------
349
+ * HighlightedCode - plain code view with flagged matches you can step through
350
+ * -------------------------------------------------------------------------- */
351
+
352
+ interface MatchHit {
353
+ start: number;
354
+ end: number;
355
+ /** Index into the `matches` array that produced this hit. */
356
+ matchIndex: number;
357
+ }
358
+
359
+ function findMatchHits(
360
+ text: string,
361
+ values: string[],
362
+ caseInsensitive = false,
363
+ ): MatchHit[] {
364
+ // Risk findings match an exact value; a text-search hit matches case-
365
+ // insensitively (the server search is ILIKE). Tool content is monospace
366
+ // code/JSON, so lowercasing doesn't shift offsets in practice.
367
+ const haystack = caseInsensitive ? text.toLowerCase() : text;
368
+ const hits: MatchHit[] = [];
369
+ values.forEach((value, matchIndex) => {
370
+ if (!value) return;
371
+ const needle = caseInsensitive ? value.toLowerCase() : value;
372
+ let from = 0;
373
+ let idx = haystack.indexOf(needle, from);
374
+ while (idx !== -1) {
375
+ hits.push({ start: idx, end: idx + value.length, matchIndex });
376
+ from = idx + value.length;
377
+ idx = haystack.indexOf(needle, from);
378
+ }
379
+ });
380
+ hits.sort((a, b) => a.start - b.start);
381
+ // Coalesce overlapping ranges so the renderer's sequential, non-overlapping
382
+ // slice walk stays correct. A merged range keeps the first hit's matchIndex
383
+ // (overlapping distinct findings are rare; correct rendering wins).
384
+ const merged: MatchHit[] = [];
385
+ for (const hit of hits) {
386
+ const last = merged[merged.length - 1];
387
+ if (last && hit.start <= last.end) last.end = Math.max(last.end, hit.end);
388
+ else merged.push({ ...hit });
389
+ }
390
+ return merged;
391
+ }
392
+
393
+ function maskMatch(value: string): string {
394
+ // Mask character-for-character so toggling reveal doesn't change the length
395
+ // (the tool view is monospace, so equal length means zero layout shift).
396
+ return "•".repeat(value.length);
397
+ }
398
+
399
+ function HighlightedCode({
400
+ text,
401
+ matches,
402
+ masked,
403
+ tone = "risk",
404
+ searchActive = false,
405
+ }: {
406
+ text: string;
407
+ matches: SectionMatch[];
408
+ masked?: boolean;
409
+ tone?: "risk" | "search";
410
+ /** Search tone only: whether this tool holds the active thread match. Active
411
+ * → bright marks; inactive → pale. (Risk tone steps per-section instead.) */
412
+ searchActive?: boolean;
413
+ }): React.JSX.Element {
414
+ const hits = React.useMemo(
415
+ () =>
416
+ findMatchHits(
417
+ text,
418
+ matches.map((m) => m.value),
419
+ tone === "search",
420
+ ),
421
+ [text, matches, tone],
422
+ );
423
+ const count = hits.length;
424
+ const [active, setActive] = useState(0);
425
+ const [revealed, setRevealed] = useState(!masked);
426
+ const markRefs = React.useRef<Array<HTMLElement | null>>([]);
427
+ const preRef = React.useRef<HTMLPreElement>(null);
428
+
429
+ useEffect(() => {
430
+ if (active >= count && count > 0) setActive(0);
431
+ }, [count, active]);
432
+ // Center the active match within the code block *only* — adjust the <pre>'s
433
+ // own scrollTop rather than scrollIntoView(), which would also yank the
434
+ // surrounding sheet. Runs on mount (focus the first match) and on each step.
435
+ useEffect(() => {
436
+ const pre = preRef.current;
437
+ const mark = markRefs.current[active];
438
+ if (!pre || !mark) return;
439
+ const markRect = mark.getBoundingClientRect();
440
+ const preRect = pre.getBoundingClientRect();
441
+ pre.scrollTop +=
442
+ markRect.top - preRect.top - pre.clientHeight / 2 + markRect.height / 2;
443
+ }, [active, count]);
444
+
445
+ const go = (delta: number) => {
446
+ if (count === 0) return;
447
+ setActive((a) => (a + delta + count) % count);
448
+ };
449
+
450
+ const activeMatch = hits[active]
451
+ ? matches[hits[active]!.matchIndex]
452
+ : undefined;
453
+
454
+ const segments: React.ReactNode[] = [];
455
+ let pos = 0;
456
+ hits.forEach((hit, i) => {
457
+ if (hit.start > pos)
458
+ segments.push(<span key={`t${i}`}>{text.slice(pos, hit.start)}</span>);
459
+ const value = text.slice(hit.start, hit.end);
460
+ segments.push(
461
+ <mark
462
+ key={`m${i}`}
463
+ ref={(el) => {
464
+ markRefs.current[i] = el;
465
+ }}
466
+ className={cn(
467
+ // Fixed-width mono chip, lightened for the dark code surface. The active
468
+ // (currently navigated) match pops so prev/next navigation + auto-scroll
469
+ // have a visible target; the rest stay a darker shade. Risk findings are
470
+ // red; a plain text-search hit is yellow.
471
+ "rounded-sm px-0.5 font-mono ring-1",
472
+ tone === "search"
473
+ ? // Search nav is per-row, so all occurrences here share the row's
474
+ // active state: bright when this tool is the active match, else pale.
475
+ searchActive
476
+ ? "bg-yellow-400 text-yellow-950 ring-yellow-300"
477
+ : "bg-yellow-800/50 text-yellow-200/90 ring-yellow-700/50"
478
+ : i === active
479
+ ? "bg-red-700 text-red-50 ring-red-400"
480
+ : "bg-red-900 text-red-300 ring-red-800",
481
+ )}
482
+ >
483
+ {masked && !revealed ? maskMatch(value) : value}
484
+ </mark>,
485
+ );
486
+ pos = hit.end;
487
+ });
488
+ if (pos < text.length)
489
+ segments.push(<span key="tail">{text.slice(pos)}</span>);
490
+
491
+ return (
492
+ <div className="w-full">
493
+ {count > 0 && (
494
+ <div className="flex items-center justify-between gap-3 bg-slate-900 px-4 py-2 text-xs text-slate-300">
495
+ <div className="flex min-w-0 items-center gap-2">
496
+ {tone === "search" ? (
497
+ <span className="flex shrink-0 items-center gap-1.5 font-medium text-yellow-300">
498
+ <SearchIcon className="size-3.5" />
499
+ {count} {count === 1 ? "match" : "matches"}
500
+ </span>
501
+ ) : (
502
+ <span className="flex shrink-0 items-center gap-1.5 font-medium text-amber-400">
503
+ <TriangleAlertIcon className="size-3.5" />
504
+ {count} flagged {count === 1 ? "match" : "matches"}
505
+ </span>
506
+ )}
507
+ {activeMatch?.label && (
508
+ <span className="truncate rounded bg-slate-700/60 px-1.5 py-0.5 font-mono text-slate-300">
509
+ {activeMatch.label}
510
+ </span>
511
+ )}
512
+ {activeMatch?.onExclude && (
513
+ <button
514
+ type="button"
515
+ onClick={activeMatch.onExclude}
516
+ title="Create an exclusion for this finding"
517
+ className="shrink-0 rounded px-1.5 py-0.5 text-slate-300 transition-colors hover:bg-slate-700 hover:text-white"
518
+ >
519
+ Create exclusion
520
+ </button>
521
+ )}
522
+ </div>
523
+ <div className="flex shrink-0 items-center gap-3 text-slate-400">
524
+ {masked && (
525
+ <button
526
+ type="button"
527
+ onClick={() => setRevealed((r) => !r)}
528
+ className="inline-flex items-center gap-1 transition-colors hover:text-slate-100"
529
+ >
530
+ {revealed ? (
531
+ <EyeIcon className="size-3.5" />
532
+ ) : (
533
+ <EyeOffIcon className="size-3.5" />
534
+ )}
535
+ {revealed ? "Hide" : "Reveal"}
536
+ </button>
537
+ )}
538
+ {count >= 1 && (
539
+ <div className="flex items-center gap-0.5">
540
+ <button
541
+ type="button"
542
+ onClick={() => go(-1)}
543
+ disabled={count <= 1}
544
+ className="rounded p-1 transition-colors hover:bg-slate-700 hover:text-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
545
+ aria-label="Previous match"
546
+ >
547
+ <ChevronUpIcon className="size-3.5" />
548
+ </button>
549
+ <span className="text-slate-300 tabular-nums">
550
+ {active + 1}/{count}
551
+ </span>
552
+ <button
553
+ type="button"
554
+ onClick={() => go(1)}
555
+ disabled={count <= 1}
556
+ className="rounded p-1 transition-colors hover:bg-slate-700 hover:text-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
557
+ aria-label="Next match"
558
+ >
559
+ <ChevronDownIcon className="size-3.5" />
560
+ </button>
561
+ </div>
562
+ )}
563
+ </div>
564
+ </div>
565
+ )}
566
+ <pre
567
+ ref={preRef}
568
+ className="max-h-[300px] w-full overflow-y-auto bg-slate-800/90 px-4 py-3 text-sm break-all whitespace-pre-wrap text-slate-100"
569
+ >
570
+ {segments}
571
+ </pre>
572
+ </div>
573
+ );
574
+ }
575
+
302
576
  /* -----------------------------------------------------------------------------
303
577
  * ImageContent - Display base64 encoded images with checkerboard background
304
578
  * -------------------------------------------------------------------------- */
@@ -355,7 +629,7 @@ function StructuredResultContent({
355
629
  return (
356
630
  <pre
357
631
  key={index}
358
- className="px-4 py-3 text-sm whitespace-pre-wrap"
632
+ className="px-4 py-3 text-sm break-all whitespace-pre-wrap"
359
633
  >
360
634
  {JSON.stringify(item, null, 2)}
361
635
  </pre>
@@ -376,6 +650,8 @@ function ToolUISection({
376
650
  defaultExpanded = false,
377
651
  highlightSyntax = true,
378
652
  language = "json",
653
+ highlight,
654
+ searchActive = false,
379
655
  }: ToolUISectionProps): React.JSX.Element {
380
656
  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
381
657
 
@@ -387,13 +663,28 @@ function ToolUISection({
387
663
  ? content
388
664
  : JSON.stringify(content, null, 2);
389
665
 
666
+ const matchCount = highlight?.matches?.length ?? 0;
667
+
668
+ let headerIndicator: React.ReactNode = null;
669
+ if (highlight?.headerBadge) headerIndicator = highlight.headerBadge;
670
+ else if (matchCount > 0)
671
+ headerIndicator =
672
+ highlight?.tone === "search" ? (
673
+ <SearchIcon className="size-3.5 text-yellow-500" />
674
+ ) : (
675
+ <TriangleAlertIcon className="size-3.5 text-amber-500" />
676
+ );
677
+
390
678
  return (
391
679
  <div data-slot="tool-ui-section" className="border-t border-border">
392
680
  <button
393
681
  onClick={() => setIsExpanded(!isExpanded)}
394
682
  className="flex w-full cursor-pointer items-center justify-between px-5 py-2.5 text-left transition-colors hover:bg-accent/50"
395
683
  >
396
- <span className="text-sm text-muted-foreground">{title}</span>
684
+ <span className="flex items-center gap-2 text-sm text-muted-foreground">
685
+ {title}
686
+ {headerIndicator}
687
+ </span>
397
688
  <div className="flex items-center gap-1">
398
689
  <CopyButton content={contentString} />
399
690
  <ChevronRightIcon
@@ -406,12 +697,22 @@ function ToolUISection({
406
697
  </button>
407
698
  {isExpanded && (
408
699
  <div className="border-t border-border">
409
- {isStructured ? (
700
+ {matchCount > 0 ? (
701
+ // Flagged content must go through the masked/highlighted view even
702
+ // when it's structured, otherwise secrets render in clear text.
703
+ <HighlightedCode
704
+ text={contentString}
705
+ matches={highlight!.matches}
706
+ masked={highlight?.masked}
707
+ tone={highlight?.tone}
708
+ searchActive={searchActive}
709
+ />
710
+ ) : isStructured ? (
410
711
  <StructuredResultContent content={content} />
411
712
  ) : highlightSyntax ? (
412
713
  <SyntaxHighlightedCode text={contentString} language={language} />
413
714
  ) : (
414
- <pre className="overflow-x-auto px-4 py-3 text-sm whitespace-pre-wrap text-foreground">
715
+ <pre className="px-4 py-3 text-sm break-all whitespace-pre-wrap text-foreground">
415
716
  {contentString}
416
717
  </pre>
417
718
  )}
@@ -427,6 +728,42 @@ type ApprovalMode = "one-time" | "for-session";
427
728
  * ToolUI - Main component
428
729
  * -------------------------------------------------------------------------- */
429
730
 
731
+ // Highlight every case-insensitive occurrence of `query` in a short label (the
732
+ // tool name), preserving original casing. Matches over the original string so
733
+ // offsets stay aligned; escapes regex metacharacters in the user query.
734
+ function highlightLabel(
735
+ text: string,
736
+ query?: string,
737
+ active = false,
738
+ ): React.ReactNode {
739
+ const q = query?.trim();
740
+ if (!q) return text;
741
+ const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
742
+ // Active match bright, others pale.
743
+ const markClass = active
744
+ ? "rounded-sm bg-yellow-300/80 px-0.5 text-foreground"
745
+ : "rounded-sm bg-yellow-200/30 px-0.5 text-foreground";
746
+ const nodes: React.ReactNode[] = [];
747
+ let pos = 0;
748
+ let k = 0;
749
+ for (let m = re.exec(text); m !== null; m = re.exec(text)) {
750
+ if (m[0].length === 0) {
751
+ re.lastIndex++;
752
+ continue;
753
+ }
754
+ if (m.index > pos) nodes.push(text.slice(pos, m.index));
755
+ nodes.push(
756
+ <mark key={k++} className={markClass}>
757
+ {m[0]}
758
+ </mark>,
759
+ );
760
+ pos = m.index + m[0].length;
761
+ }
762
+ if (pos === 0) return text;
763
+ if (pos < text.length) nodes.push(text.slice(pos));
764
+ return nodes;
765
+ }
766
+
430
767
  function ToolUI({
431
768
  name,
432
769
  icon,
@@ -435,6 +772,10 @@ function ToolUI({
435
772
  request,
436
773
  result,
437
774
  defaultExpanded = false,
775
+ requestHighlight,
776
+ resultHighlight,
777
+ nameQuery,
778
+ searchActive = false,
438
779
  className,
439
780
  annotations,
440
781
  onApproveOnce,
@@ -520,7 +861,7 @@ function ToolUI({
520
861
  !provider && isApprovalPending && "shimmer",
521
862
  )}
522
863
  >
523
- {displayName}
864
+ {highlightLabel(displayName, nameQuery, searchActive)}
524
865
  </span>
525
866
  {hasContent && (
526
867
  <ChevronDownIcon
@@ -542,6 +883,9 @@ function ToolUI({
542
883
  content={request}
543
884
  highlightSyntax
544
885
  language="json"
886
+ highlight={requestHighlight}
887
+ searchActive={searchActive}
888
+ defaultExpanded={(requestHighlight?.matches?.length ?? 0) > 0}
545
889
  />
546
890
  )}
547
891
  {/* Hide output when approval is pending */}
@@ -551,6 +895,9 @@ function ToolUI({
551
895
  content={result}
552
896
  highlightSyntax
553
897
  language="json"
898
+ highlight={resultHighlight}
899
+ searchActive={searchActive}
900
+ defaultExpanded={(resultHighlight?.matches?.length ?? 0) > 0}
554
901
  />
555
902
  )}
556
903
  </div>
@@ -797,6 +1144,10 @@ function ToolUIGroup({
797
1144
  * Exports
798
1145
  * -------------------------------------------------------------------------- */
799
1146
 
1147
+ ToolUI.displayName = "ToolUI";
1148
+ ToolUISection.displayName = "ToolUISection";
1149
+ SyntaxHighlightedCode.displayName = "SyntaxHighlightedCode";
1150
+
800
1151
  export {
801
1152
  ToolUI,
802
1153
  ToolUISection,
@@ -811,4 +1162,6 @@ export type {
811
1162
  ToolUIGroupProps,
812
1163
  ToolStatus,
813
1164
  ContentItem,
1165
+ SectionHighlight,
1166
+ SectionMatch,
814
1167
  };
@@ -438,7 +438,8 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
438
438
  const nonSystemMessages = cleanedMessages.filter(
439
439
  (m) => m.role !== "system",
440
440
  );
441
- const rawModelMessages = convertToModelMessages(nonSystemMessages);
441
+ const rawModelMessages =
442
+ await convertToModelMessages(nonSystemMessages);
442
443
 
443
444
  // Auto-compact older turns if the estimated input is approaching
444
445
  // the model's context window. System prompt + last few turns are
@@ -135,6 +135,54 @@ async function resolveAdapterHeaders(
135
135
  return options.getHeaders ? options.getHeaders() : options.headers;
136
136
  }
137
137
 
138
+ // chat.load paginates by seq keyset and returns only the newest page by default.
139
+ // assistant-ui's history adapter is one-shot (it imports the whole thread at
140
+ // once), so page backwards to the start and return the chat with every message
141
+ // in ascending order. Without this a long thread would render only its newest
142
+ // page with no way to reach the rest.
143
+ const FULL_LOAD_PAGE_SIZE = 200;
144
+
145
+ async function loadFullChat(
146
+ apiUrl: string,
147
+ chatId: string,
148
+ headers: Record<string, string>,
149
+ ): Promise<GramChat | null> {
150
+ let beforeSeq: number | undefined;
151
+ let all: GramChatMessage[] = [];
152
+ let base: GramChat | null = null;
153
+
154
+ for (;;) {
155
+ const cursor = beforeSeq !== undefined ? `&before_seq=${beforeSeq}` : "";
156
+ const response = await fetch(
157
+ `${apiUrl}/rpc/chat.load?id=${encodeURIComponent(chatId)}&limit=${FULL_LOAD_PAGE_SIZE}${cursor}`,
158
+ { headers },
159
+ );
160
+ if (!response.ok) {
161
+ // First page failed → nothing to show. A later page failing means we'd
162
+ // silently truncate the thread, so log loudly rather than passing partial
163
+ // history off as complete.
164
+ if (!base) {
165
+ console.error("Failed to load chat:", response.status);
166
+ return null;
167
+ }
168
+ console.error(
169
+ `Failed to load full chat history (status ${response.status}); ` +
170
+ `returning ${all.length} of ${base.numMessages ?? "?"} messages — transcript is truncated.`,
171
+ );
172
+ return { ...base, messages: all };
173
+ }
174
+ const page = (await response.json()) as GramChat;
175
+ if (!base) base = page;
176
+ // Each page is ascending; older pages prepend.
177
+ all = [...page.messages, ...all];
178
+ const oldest = page.messages[0];
179
+ if (!page.has_more_before || !oldest || oldest.seq === undefined) break;
180
+ beforeSeq = oldest.seq;
181
+ }
182
+
183
+ return base ? { ...base, messages: all } : null;
184
+ }
185
+
138
186
  /**
139
187
  * Thread history adapter that loads messages from Gram API.
140
188
  * Note: We use `as ThreadHistoryAdapter` cast because the withFormat generic
@@ -188,17 +236,15 @@ class GramThreadHistoryAdapter {
188
236
  }
189
237
 
190
238
  try {
191
- const response = await fetch(
192
- `${this.apiUrl}/rpc/chat.load?id=${encodeURIComponent(remoteId)}`,
193
- { headers: await this.getHeaders() },
239
+ const chat = await loadFullChat(
240
+ this.apiUrl,
241
+ remoteId,
242
+ await this.getHeaders(),
194
243
  );
195
-
196
- if (!response.ok) {
197
- console.error("Failed to load chat:", response.status);
244
+ if (!chat) {
245
+ console.error("Failed to load chat");
198
246
  return { messages: [], headId: null };
199
247
  }
200
-
201
- const chat = (await response.json()) as GramChat;
202
248
  return convertGramMessagesToExported(this.applyTransform(chat.messages));
203
249
  } catch (error) {
204
250
  console.error("Error loading chat:", error);
@@ -222,17 +268,15 @@ class GramThreadHistoryAdapter {
222
268
  return { messages: [], headId: null };
223
269
  }
224
270
 
225
- const response = await fetch(
226
- `${this.apiUrl}/rpc/chat.load?id=${encodeURIComponent(remoteId)}`,
227
- { headers: await this.getHeaders() },
271
+ const chat = await loadFullChat(
272
+ this.apiUrl,
273
+ remoteId,
274
+ await this.getHeaders(),
228
275
  );
229
-
230
- if (!response.ok) {
231
- console.error("Failed to load chat (withFormat):", response.status);
276
+ if (!chat) {
277
+ console.error("Failed to load chat (withFormat)");
232
278
  return { messages: [], headId: null };
233
279
  }
234
-
235
- const chat = (await response.json()) as GramChat;
236
280
  return convertGramMessagesToUIMessages(
237
281
  this.applyTransform(chat.messages),
238
282
  );
@@ -509,8 +553,10 @@ export function useGramThreadListAdapter(
509
553
  }
510
554
 
511
555
  try {
556
+ // Only chat metadata (id/title) is needed here, so request the
557
+ // smallest page instead of a full message page.
512
558
  const response = await fetch(
513
- `${optionsRef.current.apiUrl}/rpc/chat.load?id=${encodeURIComponent(threadId)}`,
559
+ `${optionsRef.current.apiUrl}/rpc/chat.load?id=${encodeURIComponent(threadId)}&limit=1`,
514
560
  {
515
561
  headers: await resolveAdapterHeaders(optionsRef.current),
516
562
  },
@@ -1,7 +1,7 @@
1
1
  import { assert } from "@/lib/utils";
2
2
  import type { MCPServerEntry } from "@/types";
3
3
  import { ToolsFilter } from "@/types";
4
- import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp";
4
+ import { createMCPClient } from "@ai-sdk/mcp";
5
5
  import { useQuery, type UseQueryResult } from "@tanstack/react-query";
6
6
  import { useMemo, useRef } from "react";
7
7
  import { trackError } from "@/lib/errorTracking";
package/src/index.ts CHANGED
@@ -20,6 +20,24 @@ export type { ShareButtonProps } from "@/components/ShareButton";
20
20
  export { ToolFallback } from "@/components/assistant-ui/tool-fallback";
21
21
  export { MessageContent } from "@/components/MessageContent";
22
22
  export type { MessageContentProps } from "@/components/MessageContent";
23
+ export { Markdown } from "@/components/Markdown";
24
+ export type { MarkdownProps } from "@/components/Markdown";
25
+
26
+ // Static presentation primitives — render with no ElementsProvider/runtime, so
27
+ // the dashboard's chat detail panel can reuse the elements tool UI directly.
28
+ export {
29
+ ToolUI,
30
+ ToolUISection,
31
+ SyntaxHighlightedCode,
32
+ } from "@/components/ui/tool-ui";
33
+ export type {
34
+ ToolUIProps,
35
+ ToolUISectionProps,
36
+ ToolStatus,
37
+ ContentItem,
38
+ SectionHighlight,
39
+ SectionMatch,
40
+ } from "@/components/ui/tool-ui";
23
41
 
24
42
  // Replay
25
43
  export { Replay } from "@/components/Replay";
@@ -71,6 +71,8 @@ export type GramChatContent = string | GramChatContentPart[];
71
71
  */
72
72
  export interface GramChatMessage {
73
73
  id: string;
74
+ // Monotonic sequence number; used as the keyset cursor when paging chat.load.
75
+ seq?: number;
74
76
  model: string;
75
77
  created_at: Date | string;
76
78
  role: "system" | "developer" | "user" | "assistant" | "tool";
@@ -92,6 +94,9 @@ export interface GramChat {
92
94
  messages: GramChatMessage[];
93
95
  createdAt: Date | string;
94
96
  updatedAt: Date | string;
97
+ // chat.load paginates by seq keyset; true when older messages remain before
98
+ // the first message in `messages` (snake_case to match the wire payload).
99
+ has_more_before?: boolean;
95
100
  }
96
101
 
97
102
  /**