@adecore/agents-react 0.20.0 → 0.21.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/chat/chat-client.d.ts +1 -0
- package/dist/chat/logic/generated-image.d.ts +27 -0
- package/dist/chat/logic/generated-image.js +32 -0
- package/dist/chat/logic/timeline-target.d.ts +19 -1
- package/dist/chat/logic/timeline-target.js +49 -3
- package/dist/chat/logic/timeline.js +25 -17
- package/dist/chat/subagent-conversation.d.ts +2 -1
- package/dist/chat/subagent-conversation.js +7 -1
- package/dist/chat/timeline-flash.d.ts +3 -1
- package/dist/chat/timeline-flash.js +2 -2
- package/dist/chat/ui/CodeBlock.d.ts +3 -1
- package/dist/chat/ui/CodeBlock.js +38 -7
- package/dist/chat/ui/Composer.js +13 -3
- package/dist/chat/ui/ImageView.d.ts +8 -2
- package/dist/chat/ui/ImageView.js +12 -6
- package/dist/chat/ui/Markdown.d.ts +7 -1
- package/dist/chat/ui/Markdown.js +80 -23
- package/dist/chat/ui/MessageActions.js +6 -7
- package/dist/chat/ui/SubagentTimeline.js +7 -3
- package/dist/chat/ui/Timeline.js +68 -8
- package/dist/chat/ui/TimelineMenu.js +7 -3
- package/dist/chat/ui/file-links.d.ts +2 -1
- package/dist/chat/ui/file-links.js +8 -3
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
- package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
- package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
- package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
- package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
- package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
- package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
- package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/registry.js +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
- package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
- package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
- package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
- package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
- package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
- package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
- package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
- package/dist/chat/ui/intelligent-ui/tones.js +31 -0
- package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
- package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
- package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
- package/dist/chat/ui/reply-context.d.ts +8 -0
- package/dist/chat/ui/reply-context.js +1 -0
- package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
- package/dist/chat/ui/rows/MessageRows.js +27 -7
- package/dist/chat/ui/rows/Rows.js +4 -2
- package/dist/chat/ui/rows/SubagentRow.js +5 -0
- package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -3
- package/dist/host.d.ts +25 -3
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +89 -1
- package/dist/locales/nl/agent-chat.json +89 -1
- package/dist/state/chats.js +9 -0
- package/dist/theme.css +38 -2
- package/package.json +133 -7
- package/src/chat/chat-client.ts +1 -0
- package/src/chat/logic/generated-image.ts +48 -0
- package/src/chat/logic/timeline-target.ts +69 -4
- package/src/chat/logic/timeline.ts +28 -17
- package/src/chat/subagent-conversation.ts +13 -2
- package/src/chat/timeline-flash.ts +5 -3
- package/src/chat/ui/CodeBlock.tsx +70 -21
- package/src/chat/ui/Composer.tsx +22 -5
- package/src/chat/ui/ImageView.tsx +51 -8
- package/src/chat/ui/Markdown.tsx +123 -26
- package/src/chat/ui/MessageActions.tsx +15 -8
- package/src/chat/ui/SubagentTimeline.tsx +26 -1
- package/src/chat/ui/Timeline.tsx +98 -22
- package/src/chat/ui/TimelineMenu.tsx +6 -3
- package/src/chat/ui/file-links.ts +9 -3
- package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
- package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
- package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
- package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
- package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
- package/src/chat/ui/intelligent-ui/registry.ts +52 -0
- package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
- package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
- package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
- package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
- package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
- package/src/chat/ui/intelligent-ui/tones.ts +37 -0
- package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
- package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
- package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
- package/src/chat/ui/reply-context.ts +7 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
- package/src/chat/ui/rows/MessageRows.tsx +69 -7
- package/src/chat/ui/rows/Rows.tsx +4 -2
- package/src/chat/ui/rows/SubagentRow.tsx +5 -0
- package/src/chat/ui/rows/WorkRows.tsx +20 -5
- package/src/host.ts +46 -3
- package/src/locales/en/agent-chat.json +89 -1
- package/src/locales/nl/agent-chat.json +89 -1
- package/src/state/chats.ts +9 -0
- package/src/theme.css +38 -2
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
createContext,
|
|
4
|
+
Fragment,
|
|
5
|
+
memo,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
type ComponentProps,
|
|
12
|
+
type ReactNode
|
|
13
|
+
} from 'react';
|
|
14
|
+
import ReactMarkdown, { type Components } from 'react-markdown';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
import {
|
|
17
|
+
evaluateUiBlock,
|
|
18
|
+
uiInputValues,
|
|
19
|
+
isUiComponent,
|
|
20
|
+
UI_CATALOG_VERSION,
|
|
21
|
+
UiState,
|
|
22
|
+
type UiBlock,
|
|
23
|
+
type UiNode,
|
|
24
|
+
type UiViewNode
|
|
25
|
+
} from '@adecore/intelligent-ui';
|
|
26
|
+
import { ErrorBoundary } from '@adecore/ui';
|
|
27
|
+
import type { ChatUiAnswer, ChatUiQueryState } from '@adecore/agent-contracts';
|
|
28
|
+
import { chatHost } from '../../../host';
|
|
29
|
+
import { UiReplyNavigationContext } from '../reply-context';
|
|
30
|
+
import { ReplyMarkdown } from '../Markdown';
|
|
31
|
+
import { rehypeFadeWords } from '../rehype-fade';
|
|
32
|
+
import { UI_RENDERERS } from './registry';
|
|
33
|
+
import type { UiRenderContext, UiRenderer } from './render-context';
|
|
34
|
+
import { UiBlockFrame } from './UiBlockFrame';
|
|
35
|
+
import { UiFallbackPart } from './UiFallbackPart';
|
|
36
|
+
import { useUiLinks } from './use-ui-links';
|
|
37
|
+
import { useUiQueries, useUiLiveValues } from './use-ui-queries';
|
|
38
|
+
import { UiSourceCitation } from './renderers/content';
|
|
39
|
+
import { uiChildrenOf, revealUiSource } from './node-text';
|
|
40
|
+
import { uiReplyParts } from './reply-parts';
|
|
41
|
+
|
|
42
|
+
const localStates = new Map<string, UiState>();
|
|
43
|
+
const LOCAL_STATE_LIMIT = 64;
|
|
44
|
+
|
|
45
|
+
function localState(key: string, block: UiBlock, answered?: ChatUiAnswer): UiState {
|
|
46
|
+
let state = localStates.get(key);
|
|
47
|
+
localStates.delete(key);
|
|
48
|
+
if (!state) {
|
|
49
|
+
state = new UiState(block);
|
|
50
|
+
if (answered && answered.revision === block.revision) {
|
|
51
|
+
for (const [name, value] of Object.entries(answered.values ?? {})) {
|
|
52
|
+
state.set(name, value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
localStates.set(key, state);
|
|
57
|
+
if (localStates.size > LOCAL_STATE_LIMIT) {
|
|
58
|
+
localStates.delete(localStates.keys().next().value!);
|
|
59
|
+
}
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* The block itself, as the parent of its top-level nodes. */
|
|
64
|
+
const BLOCK = '$block';
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* Parents whose text is read as written: code, a short tag, and the labels of controls, whose
|
|
68
|
+
* accessible name and sent text are that same string.
|
|
69
|
+
*/
|
|
70
|
+
const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Column']);
|
|
71
|
+
|
|
72
|
+
/* Parents that stack their children, where a run of text and inline nodes is one paragraph. */
|
|
73
|
+
const PARAGRAPHS: Readonly<Record<string, string>> = {
|
|
74
|
+
[BLOCK]: 'px-2 text-xs text-text',
|
|
75
|
+
Tab: 'px-2 text-xs text-text',
|
|
76
|
+
Section: 'text-xs text-text-muted'
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const LINKS = new Set(['File', 'Diff', 'Commit', 'Node']);
|
|
80
|
+
|
|
81
|
+
/* Constructs that would start a block or load an image stay text; without GFM a table never forms. */
|
|
82
|
+
const BLOCK_CONSTRUCTS = [
|
|
83
|
+
'blockQuote',
|
|
84
|
+
'codeFenced',
|
|
85
|
+
'codeIndented',
|
|
86
|
+
'definition',
|
|
87
|
+
'headingAtx',
|
|
88
|
+
'htmlFlow',
|
|
89
|
+
'htmlText',
|
|
90
|
+
'labelStartImage',
|
|
91
|
+
'list',
|
|
92
|
+
'setextUnderline',
|
|
93
|
+
'thematicBreak'
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
interface ProseTree {
|
|
97
|
+
type: string;
|
|
98
|
+
children?: ProseTree[];
|
|
99
|
+
value?: string;
|
|
100
|
+
data?: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Prose between the tags of a block is inline: only emphasis, code and links are Markdown, and a blank line is a line break. */
|
|
104
|
+
function remarkInlineProse(this: { data(): object }) {
|
|
105
|
+
// The field remark-parse reads its syntax extensions from, as remark-gfm adds its own.
|
|
106
|
+
const data = this.data() as { micromarkExtensions?: unknown[] };
|
|
107
|
+
data.micromarkExtensions = [...(data.micromarkExtensions ?? []), { disable: { null: BLOCK_CONSTRUCTS } }];
|
|
108
|
+
return (tree: ProseTree) => {
|
|
109
|
+
tree.children = [
|
|
110
|
+
{
|
|
111
|
+
type: 'paragraph',
|
|
112
|
+
children: (tree.children ?? []).flatMap((paragraph, index) => [...(index > 0 ? [{ type: 'break' }] : []), ...(paragraph.children ?? [])])
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function remarkCitations() {
|
|
119
|
+
return (tree: ProseTree) => {
|
|
120
|
+
const visit = (parent: ProseTree) => {
|
|
121
|
+
if (['link', 'inlineCode', 'code'].includes(parent.type)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
parent.children = parent.children?.flatMap((child) => {
|
|
125
|
+
if (child.type !== 'text' || !child.value) {
|
|
126
|
+
visit(child);
|
|
127
|
+
return [child];
|
|
128
|
+
}
|
|
129
|
+
const parts: ProseTree[] = [];
|
|
130
|
+
let start = 0;
|
|
131
|
+
for (const match of child.value.matchAll(/\[(\d{1,3})\]/g)) {
|
|
132
|
+
parts.push({ type: 'text', value: child.value.slice(start, match.index) });
|
|
133
|
+
parts.push({
|
|
134
|
+
type: 'citation',
|
|
135
|
+
data: { hName: 'span', hProperties: { 'data-ui-citation': match[1] } },
|
|
136
|
+
children: [{ type: 'text', value: match[0] }]
|
|
137
|
+
});
|
|
138
|
+
start = match.index + match[0].length;
|
|
139
|
+
}
|
|
140
|
+
parts.push({ type: 'text', value: child.value.slice(start) });
|
|
141
|
+
return parts;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
visit(tree);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface CitationTarget {
|
|
149
|
+
id: string;
|
|
150
|
+
title: string;
|
|
151
|
+
url: string;
|
|
152
|
+
ancestors: string[];
|
|
153
|
+
}
|
|
154
|
+
const CitationContext = createContext<{ sources: ReadonlyMap<number, CitationTarget>; reveal?: (source: CitationTarget) => void }>({ sources: new Map() });
|
|
155
|
+
|
|
156
|
+
function ProseCitation({ number, children }: { number: number; children?: ReactNode }) {
|
|
157
|
+
const { sources, reveal } = useContext(CitationContext);
|
|
158
|
+
const source = sources.get(number);
|
|
159
|
+
return source ? <UiSourceCitation number={number} title={source.title} url={source.url} onReveal={reveal ? () => reveal(source) : undefined} /> : children;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sourceTargets(nodes: readonly UiViewNode[]): Map<number, CitationTarget> {
|
|
163
|
+
const lists: CitationTarget[][] = [];
|
|
164
|
+
const visit = (nodes: readonly UiViewNode[], ancestors: string[] = []) => {
|
|
165
|
+
for (const node of nodes) {
|
|
166
|
+
const path = ['Tab', 'Section'].includes(node.type) ? [...ancestors, node.id] : ancestors;
|
|
167
|
+
if (node.type === 'Sources') {
|
|
168
|
+
lists.push(
|
|
169
|
+
uiChildrenOf(node, 'Source').map((source) => ({ id: source.id, title: source.props.title, url: source.props.url, ancestors: path }))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
visit(node.children, path);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
visit(nodes);
|
|
176
|
+
// Numbers restart in each Sources list, so multiple lists have no unambiguous prose target.
|
|
177
|
+
return new Map(lists.length === 1 ? lists[0].map((source, index) => [index + 1, source]) : []);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const PROSE_PLUGINS = [remarkInlineProse, remarkCitations];
|
|
181
|
+
const PROSE_ELEMENTS = ['p', 'br', 'strong', 'em', 'code', 'a', 'span'];
|
|
182
|
+
// Raw HTML never parses here, so the only spans are the words the fade wraps.
|
|
183
|
+
const FADING_PROSE_ELEMENTS = [...PROSE_ELEMENTS, 'span'];
|
|
184
|
+
const FADE_PLUGINS = [rehypeFadeWords];
|
|
185
|
+
const NO_PLUGINS: typeof FADE_PLUGINS = [];
|
|
186
|
+
|
|
187
|
+
const ProseUrlContext = createContext<((url: string) => void) | undefined>(undefined);
|
|
188
|
+
|
|
189
|
+
/* Through the host only, like a Source: the address is the model's, so nothing here navigates by itself. */
|
|
190
|
+
function ProseLink({ href, children }: { href?: string; children?: ReactNode }) {
|
|
191
|
+
const openUrl = useContext(ProseUrlContext);
|
|
192
|
+
if (openUrl === undefined || href === undefined || !/^https?:\/\//i.test(href)) {
|
|
193
|
+
return children;
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
<button type="button" className="cursor-pointer text-accent underline underline-offset-2 select-text" onClick={() => openUrl(href)}>
|
|
197
|
+
{children}
|
|
198
|
+
</button>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const PROSE_COMPONENTS: Components = {
|
|
203
|
+
p: ({ children }) => children,
|
|
204
|
+
code: ({ children }) => <code className="rounded-sm bg-surface-sunken px-1 py-px font-mono text-code">{children}</code>,
|
|
205
|
+
a: ({ href, children }) => <ProseLink href={href}>{children}</ProseLink>,
|
|
206
|
+
span: ({ node, children, className }) =>
|
|
207
|
+
node?.properties['data-ui-citation'] !== undefined ? (
|
|
208
|
+
<ProseCitation number={Number(node.properties['data-ui-citation'])}>{children}</ProseCitation>
|
|
209
|
+
) : (
|
|
210
|
+
<span className={className}>{children}</span>
|
|
211
|
+
)
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* Text an agent wrote in a block, as inline Markdown without raw HTML. Markdown trims a paragraph,
|
|
216
|
+
* so the whitespace at either end stays text: it is the space between a word and the tag beside it.
|
|
217
|
+
* While the block streams every new word fades in, as in the prose of the reply around it.
|
|
218
|
+
*/
|
|
219
|
+
const UiProse = memo(function UiProse({ text, fade, openUrl }: { text: string; fade: boolean; openUrl?: (url: string) => void }) {
|
|
220
|
+
const [, lead = '', body = '', trail = ''] = /^(\s*)([\s\S]*?)(\s*)$/.exec(text) ?? [];
|
|
221
|
+
if (body === '') {
|
|
222
|
+
return text;
|
|
223
|
+
}
|
|
224
|
+
return (
|
|
225
|
+
<ProseUrlContext value={openUrl}>
|
|
226
|
+
{lead}
|
|
227
|
+
<ReactMarkdown
|
|
228
|
+
remarkPlugins={PROSE_PLUGINS}
|
|
229
|
+
rehypePlugins={fade ? FADE_PLUGINS : NO_PLUGINS}
|
|
230
|
+
allowedElements={fade ? FADING_PROSE_ELEMENTS : PROSE_ELEMENTS}
|
|
231
|
+
unwrapDisallowed
|
|
232
|
+
components={PROSE_COMPONENTS}
|
|
233
|
+
>
|
|
234
|
+
{body}
|
|
235
|
+
</ReactMarkdown>
|
|
236
|
+
{trail}
|
|
237
|
+
</ProseUrlContext>
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/* Which text nodes of a block the agent wrote out, by id; the value of an expression is data and stays as it is. */
|
|
242
|
+
function writtenTexts(nodes: readonly UiNode[], into = new Map<string, boolean>()): Map<string, boolean> {
|
|
243
|
+
for (const node of nodes) {
|
|
244
|
+
if (node.type === '$text') {
|
|
245
|
+
into.set(node.id, node.expressions.text === undefined);
|
|
246
|
+
}
|
|
247
|
+
writtenTexts(node.children, into);
|
|
248
|
+
}
|
|
249
|
+
return into;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isInline(node: UiViewNode): boolean {
|
|
253
|
+
if (node.type === '$text') {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return node.error === undefined && (node.type === 'Tag' || (LINKS.has(node.type) && node.children.length === 0));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isBlank(node: UiViewNode): boolean {
|
|
260
|
+
return node.type === '$text' && String(node.props.text ?? '').trim() === '';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface UiNodesProps {
|
|
264
|
+
nodes: readonly UiViewNode[];
|
|
265
|
+
parent: string;
|
|
266
|
+
texts: ReadonlyMap<string, boolean>;
|
|
267
|
+
context: UiRenderContext;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* The children of one parent, each in its own boundary. Where the parent stacks them, a run of text and inline nodes becomes a paragraph. */
|
|
271
|
+
function UiNodes({ nodes, parent, texts, context }: UiNodesProps): ReactNode {
|
|
272
|
+
const drawn = (node: UiViewNode) => (
|
|
273
|
+
<UiNodeBoundary key={node.id} node={node}>
|
|
274
|
+
<UiNodeBody node={node} parent={parent} texts={texts} context={context} />
|
|
275
|
+
</UiNodeBoundary>
|
|
276
|
+
);
|
|
277
|
+
const paragraph = PARAGRAPHS[parent];
|
|
278
|
+
if (paragraph === undefined) {
|
|
279
|
+
return nodes.map(drawn);
|
|
280
|
+
}
|
|
281
|
+
const output: ReactNode[] = [];
|
|
282
|
+
let run: UiViewNode[] = [];
|
|
283
|
+
const close = () => {
|
|
284
|
+
if (run.some((node) => !isBlank(node))) {
|
|
285
|
+
output.push(
|
|
286
|
+
<div key={`run:${run[0]!.id}`} className={paragraph}>
|
|
287
|
+
{run.map(drawn)}
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
run = [];
|
|
292
|
+
};
|
|
293
|
+
for (const node of nodes) {
|
|
294
|
+
if (isInline(node)) {
|
|
295
|
+
run.push(node);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
close();
|
|
299
|
+
output.push(drawn(node));
|
|
300
|
+
}
|
|
301
|
+
close();
|
|
302
|
+
return output;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface UiNodeBoundaryProps {
|
|
306
|
+
node: UiViewNode;
|
|
307
|
+
children: ReactNode;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface UiNodeBoundaryState {
|
|
311
|
+
failed: boolean;
|
|
312
|
+
node: UiViewNode;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
class UiNodeBoundary extends Component<UiNodeBoundaryProps, UiNodeBoundaryState> {
|
|
316
|
+
state = { failed: false, node: this.props.node };
|
|
317
|
+
|
|
318
|
+
static getDerivedStateFromError(): { failed: boolean } {
|
|
319
|
+
return { failed: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
static getDerivedStateFromProps(props: UiNodeBoundaryProps, state: UiNodeBoundaryState): UiNodeBoundaryState | null {
|
|
323
|
+
return props.node === state.node ? null : { failed: false, node: props.node };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
render(): ReactNode {
|
|
327
|
+
return this.state.failed ? <UiFallbackPart fallback={this.props.node.fallback} problem={{ kind: 'failed' }} /> : this.props.children;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function UiNodeBody({
|
|
332
|
+
node,
|
|
333
|
+
parent,
|
|
334
|
+
texts,
|
|
335
|
+
context
|
|
336
|
+
}: {
|
|
337
|
+
node: UiViewNode;
|
|
338
|
+
parent: string;
|
|
339
|
+
texts: ReadonlyMap<string, boolean>;
|
|
340
|
+
context: UiRenderContext;
|
|
341
|
+
}): ReactNode {
|
|
342
|
+
if (node.type === '$text') {
|
|
343
|
+
const text = String(node.props.text ?? '');
|
|
344
|
+
return LITERAL_PARENTS.has(parent) || texts.get(node.sourceId ?? node.id) !== true ? (
|
|
345
|
+
text
|
|
346
|
+
) : (
|
|
347
|
+
<UiProse text={text} fade={context.phase === 'streaming'} openUrl={context.openUrl} />
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (node.error !== undefined) {
|
|
351
|
+
return <UiFallbackPart fallback={node.fallback} problem={{ kind: 'failed' }} />;
|
|
352
|
+
}
|
|
353
|
+
if (!isUiComponent(node.type)) {
|
|
354
|
+
return <UiFallbackPart fallback={node.fallback} problem={{ kind: 'unknown', component: node.type }} />;
|
|
355
|
+
}
|
|
356
|
+
const Renderer = UI_RENDERERS[node.type] as UiRenderer<Record<string, unknown>> | null;
|
|
357
|
+
if (Renderer === null) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return (
|
|
361
|
+
<Renderer node={node} context={context}>
|
|
362
|
+
<UiNodes nodes={node.children} parent={node.type} texts={texts} context={context} />
|
|
363
|
+
</Renderer>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function UiBlockBody({
|
|
368
|
+
block,
|
|
369
|
+
context,
|
|
370
|
+
answered,
|
|
371
|
+
frozen
|
|
372
|
+
}: {
|
|
373
|
+
block: UiBlock;
|
|
374
|
+
context: UiRenderContext;
|
|
375
|
+
answered?: ChatUiAnswer;
|
|
376
|
+
frozen?: ChatUiQueryState['blocks'][string];
|
|
377
|
+
}): ReactNode {
|
|
378
|
+
const { t } = useTranslation('agent-chat');
|
|
379
|
+
const key = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id]);
|
|
380
|
+
const [state] = useState(() => localState(key, block, answered));
|
|
381
|
+
state.sync(block);
|
|
382
|
+
useSyncExternalStore(
|
|
383
|
+
(listener) => state.subscribe(listener),
|
|
384
|
+
() => state.snapshot(),
|
|
385
|
+
() => state.snapshot()
|
|
386
|
+
);
|
|
387
|
+
const [optimistic, setOptimistic] = useState<{ revision: string; choiceId: string; state: 'sending' | 'sent' | 'queued' } | null>(null);
|
|
388
|
+
const [failedChoiceId, setFailedChoiceId] = useState<string | null>(null);
|
|
389
|
+
const pending = useRef(false);
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (answered && answered.revision === block.revision && answered.values) {
|
|
392
|
+
for (const [name, value] of Object.entries(answered.values)) {
|
|
393
|
+
state.set(name, value);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}, [state, answered, block.revision]);
|
|
397
|
+
const answer =
|
|
398
|
+
answered && answered.revision === block.revision
|
|
399
|
+
? { choiceId: answered.choiceId, state: answered.queued ? ('queued' as const) : ('sent' as const) }
|
|
400
|
+
: optimistic?.revision === block.revision
|
|
401
|
+
? optimistic
|
|
402
|
+
: context.answer;
|
|
403
|
+
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
|
404
|
+
const navigation = useContext(UiReplyNavigationContext);
|
|
405
|
+
const flash = navigation?.flash;
|
|
406
|
+
const flashing =
|
|
407
|
+
navigation?.chatId === context.chatId && flash?.itemId === context.itemId && flash.blockId === block.id && flash.revision === block.revision;
|
|
408
|
+
const queries = useUiQueries(block, state, context, element, frozen);
|
|
409
|
+
const links = useUiLinks(block, state, context, queries.reads, frozen);
|
|
410
|
+
const evaluation = evaluateUiBlock(block, state);
|
|
411
|
+
const liveValue = useUiLiveValues(block, evaluation.nodes, queries.currentReadings);
|
|
412
|
+
const sendChoice = chatHost().intelligentUi?.sendChoice;
|
|
413
|
+
const rendering: UiRenderContext = {
|
|
414
|
+
...context,
|
|
415
|
+
...links,
|
|
416
|
+
answer,
|
|
417
|
+
failedChoiceId,
|
|
418
|
+
live: queries.live ?? context.live,
|
|
419
|
+
liveValue: liveValue ?? context.liveValue,
|
|
420
|
+
onChoose:
|
|
421
|
+
!queries.ready || queries.reading
|
|
422
|
+
? undefined
|
|
423
|
+
: sendChoice && block.revision && block.complete && context.phase === 'final'
|
|
424
|
+
? (choiceId) => {
|
|
425
|
+
if (pending.current || answer || queries.reading) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const values = uiInputValues(block, state);
|
|
429
|
+
const reads = queries.readsFor(values);
|
|
430
|
+
if (reads === null) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
pending.current = true;
|
|
434
|
+
setFailedChoiceId(null);
|
|
435
|
+
const revision = block.revision!;
|
|
436
|
+
setOptimistic({ revision, choiceId, state: 'sending' });
|
|
437
|
+
void sendChoice(context.scopeId, {
|
|
438
|
+
chatId: context.chatId,
|
|
439
|
+
itemId: context.itemId,
|
|
440
|
+
blockId: block.id,
|
|
441
|
+
revision,
|
|
442
|
+
choiceId,
|
|
443
|
+
reads,
|
|
444
|
+
values
|
|
445
|
+
})
|
|
446
|
+
.then((state) => setOptimistic({ revision, choiceId, state }))
|
|
447
|
+
.catch(() => {
|
|
448
|
+
setOptimistic(null);
|
|
449
|
+
setFailedChoiceId(choiceId);
|
|
450
|
+
})
|
|
451
|
+
.finally(() => {
|
|
452
|
+
pending.current = false;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
: context.onChoose
|
|
456
|
+
};
|
|
457
|
+
const unknown = block.catalogVersion !== UI_CATALOG_VERSION;
|
|
458
|
+
const failed = !unknown && !block.nodes.length && block.diagnostics.length > 0;
|
|
459
|
+
const shownAsText = unknown ? t('blocks.unreadable') : failed ? block.diagnostics[0].message : undefined;
|
|
460
|
+
return (
|
|
461
|
+
<div ref={setElement} className="relative" data-ui-block={block.id} data-ui-revision={block.revision} tabIndex={-1}>
|
|
462
|
+
{flashing && <span key={flash.nonce} className="chat-flash pointer-events-none absolute inset-0 rounded-lg" aria-hidden />}
|
|
463
|
+
<UiBlockFrame
|
|
464
|
+
live={queries.live}
|
|
465
|
+
nodes={evaluation.nodes}
|
|
466
|
+
phase={context.phase}
|
|
467
|
+
fixes={[...block.diagnostics, ...evaluation.diagnostics]}
|
|
468
|
+
shownAsText={shownAsText}
|
|
469
|
+
answered={answered}
|
|
470
|
+
>
|
|
471
|
+
{unknown || failed ? (
|
|
472
|
+
<ReplyMarkdown text={block.fallback} streaming={false} />
|
|
473
|
+
) : (
|
|
474
|
+
<CitationContext
|
|
475
|
+
value={{
|
|
476
|
+
sources: sourceTargets(evaluation.nodes),
|
|
477
|
+
reveal: element
|
|
478
|
+
? (source) => {
|
|
479
|
+
void revealUiSource(element, source.id, source.ancestors);
|
|
480
|
+
}
|
|
481
|
+
: undefined
|
|
482
|
+
}}
|
|
483
|
+
>
|
|
484
|
+
<UiNodes nodes={evaluation.nodes} parent={BLOCK} texts={writtenTexts(block.nodes)} context={rendering} />
|
|
485
|
+
</CitationContext>
|
|
486
|
+
)}
|
|
487
|
+
</UiBlockFrame>
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* Prose and cards a block gap apart, as the rows of a turn are; text that is only the newline between two fences draws nothing. */
|
|
493
|
+
export function UiReply({
|
|
494
|
+
text,
|
|
495
|
+
blocks,
|
|
496
|
+
context,
|
|
497
|
+
answers,
|
|
498
|
+
queries,
|
|
499
|
+
reply
|
|
500
|
+
}: {
|
|
501
|
+
text: string;
|
|
502
|
+
blocks: readonly UiBlock[];
|
|
503
|
+
context: Omit<UiRenderContext, 'blockId'>;
|
|
504
|
+
answers?: Readonly<Record<string, ChatUiAnswer>>;
|
|
505
|
+
queries?: ChatUiQueryState;
|
|
506
|
+
reply?: ComponentProps<typeof ReplyMarkdown>['reply'];
|
|
507
|
+
}): ReactNode {
|
|
508
|
+
return (
|
|
509
|
+
<div className="flex flex-col gap-(--chat-block-gap)">
|
|
510
|
+
{uiReplyParts(text, blocks, context.phase === 'streaming').map((part, index) =>
|
|
511
|
+
part.kind === 'text' ? (
|
|
512
|
+
part.text.trim() !== '' && (
|
|
513
|
+
<Fragment key={`text:${index}`}>
|
|
514
|
+
<ReplyMarkdown text={part.text} streaming={context.phase === 'streaming'} reply={reply} />
|
|
515
|
+
</Fragment>
|
|
516
|
+
)
|
|
517
|
+
) : (
|
|
518
|
+
<ErrorBoundary key={part.block.id} label="UI block" resetKeys={[context.scopeId, context.chatId, context.itemId, part.block.id]}>
|
|
519
|
+
<UiBlockBody
|
|
520
|
+
key={JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])}
|
|
521
|
+
block={part.block}
|
|
522
|
+
frozen={queries?.blocks[part.block.id]}
|
|
523
|
+
answered={answers?.[part.block.id]}
|
|
524
|
+
context={{ ...context, blockId: part.block.id }}
|
|
525
|
+
/>
|
|
526
|
+
</ErrorBoundary>
|
|
527
|
+
)
|
|
528
|
+
)}
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { UiRenderContext } from './render-context';
|
|
3
|
+
|
|
4
|
+
// Which tab or section a person opened, so a block the thread unmounts while scrolling comes back as they left it.
|
|
5
|
+
const remembered = new Map<string, unknown>();
|
|
6
|
+
|
|
7
|
+
/* State of one node that lasts as long as the page, such as a fold; local input state belongs to the runtime instead. */
|
|
8
|
+
export function useBlockLocal<Value>(context: UiRenderContext, nodeId: string, initial: () => Value): [Value, (next: Value) => void] {
|
|
9
|
+
const key = `${context.scopeId}\n${context.chatId}\n${context.itemId}\n${context.blockId}\n${nodeId}`;
|
|
10
|
+
const [value, setValue] = useState<Value>(() => (remembered.has(key) ? (remembered.get(key) as Value) : initial()));
|
|
11
|
+
const set = (next: Value): void => {
|
|
12
|
+
remembered.set(key, next);
|
|
13
|
+
setValue(next);
|
|
14
|
+
};
|
|
15
|
+
return [value, set];
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { UiProps } from '@adecore/intelligent-ui';
|
|
2
|
+
|
|
3
|
+
/* The catalog promises up to six series, one per chart color. */
|
|
4
|
+
export const UI_CHART_SERIES = 6;
|
|
5
|
+
/* Categories a chart draws; more would not fit a column of a thread. */
|
|
6
|
+
export const UI_CHART_ROWS = 60;
|
|
7
|
+
|
|
8
|
+
export interface UiChartSeries {
|
|
9
|
+
key: string;
|
|
10
|
+
/* One per label, null where a row has no number for this series. */
|
|
11
|
+
values: (number | null)[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UiChartData {
|
|
15
|
+
labels: string[];
|
|
16
|
+
series: UiChartSeries[];
|
|
17
|
+
/* The range the axis covers, from zero or below it, never empty. */
|
|
18
|
+
min: number;
|
|
19
|
+
max: number;
|
|
20
|
+
/* Whether rows past `UI_CHART_ROWS` were left out. */
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* The series a chart can draw: every key besides `label` with a finite number in some row, the
|
|
26
|
+
* first six of them. A value that is missing or no finite number is a gap. Null when there is no
|
|
27
|
+
* row or no series, which draws the fallback.
|
|
28
|
+
*/
|
|
29
|
+
export function uiChartData(data: UiProps<'Chart'>['data'], kind: UiProps<'Chart'>['kind']): UiChartData | null {
|
|
30
|
+
const rows = data.slice(0, UI_CHART_ROWS);
|
|
31
|
+
const keys: string[] = [];
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
for (const [key, value] of Object.entries(row)) {
|
|
34
|
+
if (key !== 'label' && !keys.includes(key) && keys.length < UI_CHART_SERIES && typeof value === 'number' && Number.isFinite(value)) {
|
|
35
|
+
keys.push(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (rows.length === 0 || keys.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const series = keys.map((key) => ({
|
|
43
|
+
key,
|
|
44
|
+
values: rows.map((row) => {
|
|
45
|
+
const value = row[key];
|
|
46
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
47
|
+
})
|
|
48
|
+
}));
|
|
49
|
+
const labels = rows.map((row) => (typeof row.label === 'string' || typeof row.label === 'number' ? String(row.label) : ''));
|
|
50
|
+
const extents =
|
|
51
|
+
kind === 'stacked'
|
|
52
|
+
? labels.map((_, index) => series.reduce((sum, { values }) => sum + Math.max(0, values[index] ?? 0), 0))
|
|
53
|
+
: series.flatMap(({ values }) => values.filter((value) => value !== null));
|
|
54
|
+
const min = Math.min(0, ...extents);
|
|
55
|
+
const max = Math.max(0, ...extents);
|
|
56
|
+
return { labels, series, min, max: max === min ? min + 1 : max, truncated: data.length > rows.length };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* A round number at or above `value`, for the top of an axis: 1, 2, 2.5 or 5 times a power of ten. */
|
|
60
|
+
export function niceCeiling(value: number): number {
|
|
61
|
+
if (value <= 0) {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const power = 10 ** Math.floor(Math.log10(value));
|
|
65
|
+
const step = [1, 2, 2.5, 5, 10].find((factor) => factor * power >= value) ?? 10;
|
|
66
|
+
return step * power;
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
|
|
2
|
+
|
|
3
|
+
/* The text a node holds, every `$text` below it joined as written, such as the code of a CodeBlock. */
|
|
4
|
+
export function uiNodeText(node: UiViewNode): string {
|
|
5
|
+
return node.children.map((child) => (child.type === '$text' ? String(child.props.text ?? '') : uiNodeText(child))).join('');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* The same text on one line, for a name a control or a screen reader needs as a string. */
|
|
9
|
+
export function uiNodeLabel(node: UiViewNode): string {
|
|
10
|
+
return uiNodeText(node).replace(/\s+/g, ' ').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* The valid children of one kind, which a parent reads as metadata: the columns of a table, the tabs of a strip. */
|
|
14
|
+
export function uiChildrenOf<Name extends UiComponentName>(node: UiViewNode, type: Name): UiViewNode<UiProps<Name>>[] {
|
|
15
|
+
return node.children.filter((child) => child.type === type && child.error === undefined) as UiViewNode<UiProps<Name>>[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* A Summary draws its text as inline Markdown, so the name it gives its block leaves out the code ticks, the strong marks and the address of a link. */
|
|
19
|
+
function withoutMarks(text: string): string {
|
|
20
|
+
return text
|
|
21
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
22
|
+
.replace(/\*\*|`/g, '')
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* The Summary that heads a block, which is its first node; a Summary further down is a subheading. */
|
|
27
|
+
export function uiBlockHead(nodes: readonly UiViewNode[]): { id: string; label: string } | null {
|
|
28
|
+
const first = nodes[0];
|
|
29
|
+
if (first === undefined || first.type !== 'Summary' || first.error !== undefined) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { id: first.id, label: withoutMarks(uiNodeLabel(first)) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Scrolls the Source row of `nodeId` under `root` into view and focuses it, which lights the row once.
|
|
37
|
+
* Opens enclosing tabs and sections first; false when the source is absent.
|
|
38
|
+
*/
|
|
39
|
+
export async function revealUiSource(root: ParentNode, nodeId: string, ancestors: readonly string[] = []): Promise<boolean> {
|
|
40
|
+
for (const id of ancestors) {
|
|
41
|
+
const control = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-reveal]')].find((element) => element.dataset.uiReveal === id);
|
|
42
|
+
if (control && (control.getAttribute('aria-expanded') === 'false' || control.getAttribute('aria-selected') === 'false')) {
|
|
43
|
+
control.click();
|
|
44
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const row = [...root.querySelectorAll<HTMLElement>('[data-ui-source]')].find((element) => element.dataset.uiSource === nodeId);
|
|
48
|
+
if (row === undefined) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
// The light runs when focus arrives, so a row that already has it lets go first.
|
|
52
|
+
if (row.ownerDocument.activeElement === row) {
|
|
53
|
+
row.blur();
|
|
54
|
+
}
|
|
55
|
+
row.focus({ preventScroll: true });
|
|
56
|
+
const reduced = row.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
|
|
57
|
+
row.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' });
|
|
58
|
+
return true;
|
|
59
|
+
}
|