@adecore/agents-react 0.20.1 → 0.21.1

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