@gram-ai/elements 1.37.0 → 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/contexts/ThreadMetaContext.d.ts +20 -0
- 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-Em1Ot0b6.js → index--UMkUr53.js} +27562 -21884
- 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-BnInDjd4.js → profiler-BHXyuGiY.js} +2 -2
- package/dist/{profiler-BnInDjd4.js.map → profiler-BHXyuGiY.js.map} +1 -1
- package/dist/{profiler-DIwReaSQ.cjs → profiler-jAEvoPXB.cjs} +2 -2
- package/dist/{profiler-DIwReaSQ.cjs.map → profiler-jAEvoPXB.cjs.map} +1 -1
- package/dist/{startRecording-P_J6QFPD.js → startRecording-D8IbKhJo.js} +2 -2
- package/dist/{startRecording-P_J6QFPD.js.map → startRecording-D8IbKhJo.js.map} +1 -1
- package/dist/{startRecording-Cg4fxzWw.cjs → startRecording-Dw4aGDrV.cjs} +2 -2
- package/dist/{startRecording-Cg4fxzWw.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/assistant-ui/thinking-indicator.tsx +42 -0
- package/src/components/assistant-ui/thread-list.tsx +50 -5
- package/src/components/ui/tool-ui.tsx +360 -7
- package/src/contexts/ElementsProvider.tsx +2 -1
- package/src/contexts/ThreadMetaContext.ts +27 -0
- package/src/hooks/useGramThreadListAdapter.tsx +101 -20
- 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-Dpk3C8VH.cjs +0 -194
- package/dist/index-Dpk3C8VH.cjs.map +0 -1
- package/dist/index-Em1Ot0b6.js.map +0 -1
|
@@ -4,13 +4,31 @@ import {
|
|
|
4
4
|
ThreadListPrimitive,
|
|
5
5
|
useAssistantState,
|
|
6
6
|
} from "@assistant-ui/react";
|
|
7
|
-
import { PlusIcon } from "lucide-react";
|
|
7
|
+
import { MessageSquareTextIcon, PlusIcon } from "lucide-react";
|
|
8
8
|
|
|
9
9
|
import { Button } from "@/components/ui/button";
|
|
10
10
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
11
11
|
import { useRadius } from "@/hooks/useRadius";
|
|
12
12
|
import { cn } from "@/lib/utils";
|
|
13
13
|
import { useDensity } from "@/hooks/useDensity";
|
|
14
|
+
import { useThreadMeta } from "@/contexts/ThreadMetaContext";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Formats a chat's creation date for the list row: "Jun 14" within the current
|
|
18
|
+
* year, "Jun 14, 2025" otherwise. Returns null for missing/invalid dates so
|
|
19
|
+
* the row simply omits the date rather than rendering "Invalid Date".
|
|
20
|
+
*/
|
|
21
|
+
function formatThreadCreatedAt(iso: string | undefined): string | null {
|
|
22
|
+
if (!iso) return null;
|
|
23
|
+
const date = new Date(iso);
|
|
24
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
25
|
+
const sameYear = date.getFullYear() === new Date().getFullYear();
|
|
26
|
+
return date.toLocaleDateString(undefined, {
|
|
27
|
+
month: "short",
|
|
28
|
+
day: "numeric",
|
|
29
|
+
...(sameYear ? {} : { year: "numeric" }),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
14
32
|
|
|
15
33
|
interface ThreadListProps {
|
|
16
34
|
className?: string;
|
|
@@ -107,12 +125,21 @@ const ThreadListItem: FC = () => {
|
|
|
107
125
|
>
|
|
108
126
|
<ThreadListItemPrimitive.Trigger
|
|
109
127
|
className={cn(
|
|
110
|
-
|
|
111
|
-
|
|
128
|
+
// px-sm (not px-lg) so the row icon's left edge lines up with the
|
|
129
|
+
// "New Thread" + icon above: that button's padding resolves to the
|
|
130
|
+
// p-sm value, which equals px-sm at every density tier.
|
|
131
|
+
"aui-thread-list-item-trigger flex min-w-0 grow cursor-pointer items-center gap-2.5 text-start",
|
|
132
|
+
d("px-sm"),
|
|
112
133
|
d("py-sm"),
|
|
113
134
|
)}
|
|
114
135
|
>
|
|
115
|
-
<
|
|
136
|
+
<span className="aui-thread-list-item-icon flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-card text-muted-foreground">
|
|
137
|
+
<MessageSquareTextIcon className="size-3.5" />
|
|
138
|
+
</span>
|
|
139
|
+
<span className="flex min-w-0 flex-col">
|
|
140
|
+
<ThreadListItemTitle />
|
|
141
|
+
<ThreadListItemDate />
|
|
142
|
+
</span>
|
|
116
143
|
</ThreadListItemPrimitive.Trigger>
|
|
117
144
|
{/* Archive button hidden until feature is implemented */}
|
|
118
145
|
{/* <ThreadListItemArchive /> */}
|
|
@@ -122,8 +149,26 @@ const ThreadListItem: FC = () => {
|
|
|
122
149
|
|
|
123
150
|
const ThreadListItemTitle: FC = () => {
|
|
124
151
|
return (
|
|
125
|
-
<span className="aui-thread-list-item-title text-sm
|
|
152
|
+
<span className="aui-thread-list-item-title block truncate text-sm text-foreground">
|
|
126
153
|
<ThreadListItemPrimitive.Title fallback="New Chat" />
|
|
127
154
|
</span>
|
|
128
155
|
);
|
|
129
156
|
};
|
|
157
|
+
|
|
158
|
+
const ThreadListItemDate: FC = () => {
|
|
159
|
+
// Both remoteId and externalId equal the chat id in the Gram adapter; the
|
|
160
|
+
// side-channel map is keyed by that id. New local threads have neither yet,
|
|
161
|
+
// so they simply render no date.
|
|
162
|
+
const id = useAssistantState(
|
|
163
|
+
({ threadListItem }) =>
|
|
164
|
+
threadListItem.remoteId ?? threadListItem.externalId,
|
|
165
|
+
);
|
|
166
|
+
const meta = useThreadMeta(id ?? undefined);
|
|
167
|
+
const label = formatThreadCreatedAt(meta?.createdAt);
|
|
168
|
+
if (!label) return null;
|
|
169
|
+
return (
|
|
170
|
+
<span className="aui-thread-list-item-date text-xs text-muted-foreground">
|
|
171
|
+
{label}
|
|
172
|
+
</span>
|
|
173
|
+
);
|
|
174
|
+
};
|
|
@@ -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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-chat metadata that assistant-ui's thread list can't carry: its
|
|
5
|
+
* RemoteThreadMetadata is a closed shape ({@link
|
|
6
|
+
* https://github.com/Yonom/assistant-ui status/remoteId/externalId/title}), so
|
|
7
|
+
* fields like the creation date are dropped at the runtime boundary.
|
|
8
|
+
*
|
|
9
|
+
* The Gram thread-list adapter populates this side channel from `chat.list`
|
|
10
|
+
* (keyed by chat id, which equals the item's remoteId/externalId) and
|
|
11
|
+
* `ThreadListItem` reads it to render the date. React context crosses the
|
|
12
|
+
* shadow-root boundary the thread list renders into, so the provider lives up
|
|
13
|
+
* in the Elements runtime tree.
|
|
14
|
+
*/
|
|
15
|
+
export interface ThreadMeta {
|
|
16
|
+
/** ISO timestamp of when the chat was created. */
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const ThreadMetaContext = createContext<Record<string, ThreadMeta>>({});
|
|
21
|
+
|
|
22
|
+
/** Reads the metadata for one chat id, or undefined when unknown (e.g. a
|
|
23
|
+
* brand-new local thread that isn't in `chat.list` yet). */
|
|
24
|
+
export function useThreadMeta(id: string | undefined): ThreadMeta | undefined {
|
|
25
|
+
const map = useContext(ThreadMetaContext);
|
|
26
|
+
return id ? map[id] : undefined;
|
|
27
|
+
}
|