@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.
- package/dist/components/Markdown.d.ts +7 -0
- package/dist/components/MessageContent.d.ts +4 -0
- package/dist/components/ui/tool-ui.d.ts +52 -3
- package/dist/elements.cjs +1 -1
- package/dist/elements.css +1 -1
- package/dist/elements.js +26 -22
- package/dist/hooks/useMCPTools.d.ts +1 -1
- package/dist/{index-BdBraSNn.js → index--UMkUr53.js} +27921 -22336
- package/dist/index--UMkUr53.js.map +1 -0
- package/dist/index-Cz9y5YHw.cjs +222 -0
- package/dist/index-Cz9y5YHw.cjs.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/lib/messageConverter.d.ts +2 -0
- package/dist/{profiler-Cl-9cG3B.js → profiler-BHXyuGiY.js} +2 -2
- package/dist/{profiler-Cl-9cG3B.js.map → profiler-BHXyuGiY.js.map} +1 -1
- package/dist/{profiler-ttCkbP-N.cjs → profiler-jAEvoPXB.cjs} +2 -2
- package/dist/{profiler-ttCkbP-N.cjs.map → profiler-jAEvoPXB.cjs.map} +1 -1
- package/dist/{startRecording-C41DbnxY.js → startRecording-D8IbKhJo.js} +2 -2
- package/dist/{startRecording-C41DbnxY.js.map → startRecording-D8IbKhJo.js.map} +1 -1
- package/dist/{startRecording-DLCeKyz9.cjs → startRecording-Dw4aGDrV.cjs} +2 -2
- package/dist/{startRecording-DLCeKyz9.cjs.map → startRecording-Dw4aGDrV.cjs.map} +1 -1
- package/package.json +11 -13
- package/src/components/Markdown.tsx +210 -0
- package/src/components/MessageContent.tsx +9 -0
- package/src/components/ui/tool-ui.tsx +360 -7
- package/src/contexts/ElementsProvider.tsx +2 -1
- package/src/hooks/useGramThreadListAdapter.tsx +63 -17
- package/src/hooks/useMCPTools.ts +1 -1
- package/src/index.ts +18 -0
- package/src/lib/messageConverter.ts +5 -0
- package/src/lib/tools.test.ts +24 -12
- package/dist/index-BdBraSNn.js.map +0 -1
- package/dist/index-Bl5cH0sz.cjs +0 -194
- 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">
|
|
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
|
-
{
|
|
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="
|
|
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 =
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
239
|
+
const chat = await loadFullChat(
|
|
240
|
+
this.apiUrl,
|
|
241
|
+
remoteId,
|
|
242
|
+
await this.getHeaders(),
|
|
194
243
|
);
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
271
|
+
const chat = await loadFullChat(
|
|
272
|
+
this.apiUrl,
|
|
273
|
+
remoteId,
|
|
274
|
+
await this.getHeaders(),
|
|
228
275
|
);
|
|
229
|
-
|
|
230
|
-
|
|
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
|
},
|
package/src/hooks/useMCPTools.ts
CHANGED
|
@@ -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 {
|
|
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
|
/**
|