@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.
Files changed (134) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/logic/generated-image.d.ts +27 -0
  3. package/dist/chat/logic/generated-image.js +32 -0
  4. package/dist/chat/logic/timeline-target.d.ts +19 -1
  5. package/dist/chat/logic/timeline-target.js +49 -3
  6. package/dist/chat/logic/timeline.js +25 -17
  7. package/dist/chat/subagent-conversation.d.ts +2 -1
  8. package/dist/chat/subagent-conversation.js +7 -1
  9. package/dist/chat/timeline-flash.d.ts +3 -1
  10. package/dist/chat/timeline-flash.js +2 -2
  11. package/dist/chat/ui/CodeBlock.d.ts +3 -1
  12. package/dist/chat/ui/CodeBlock.js +38 -7
  13. package/dist/chat/ui/Composer.js +13 -3
  14. package/dist/chat/ui/ImageView.d.ts +8 -2
  15. package/dist/chat/ui/ImageView.js +12 -6
  16. package/dist/chat/ui/Markdown.d.ts +7 -1
  17. package/dist/chat/ui/Markdown.js +80 -23
  18. package/dist/chat/ui/MessageActions.js +6 -7
  19. package/dist/chat/ui/SubagentTimeline.js +7 -3
  20. package/dist/chat/ui/Timeline.js +68 -8
  21. package/dist/chat/ui/TimelineMenu.js +7 -3
  22. package/dist/chat/ui/file-links.d.ts +2 -1
  23. package/dist/chat/ui/file-links.js +8 -3
  24. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
  25. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
  26. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
  27. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
  28. package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
  29. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
  30. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
  31. package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
  32. package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
  33. package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
  34. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
  35. package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
  36. package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
  37. package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
  38. package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
  39. package/dist/chat/ui/intelligent-ui/registry.js +50 -0
  40. package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
  41. package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
  48. package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
  49. package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
  50. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
  51. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
  52. package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
  53. package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
  54. package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
  55. package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
  56. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
  57. package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
  58. package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
  59. package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
  60. package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
  61. package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
  62. package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
  63. package/dist/chat/ui/intelligent-ui/tones.js +31 -0
  64. package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
  65. package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
  66. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
  67. package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
  68. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
  69. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
  70. package/dist/chat/ui/reply-context.d.ts +8 -0
  71. package/dist/chat/ui/reply-context.js +1 -0
  72. package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
  73. package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
  74. package/dist/chat/ui/rows/MessageRows.js +27 -7
  75. package/dist/chat/ui/rows/Rows.js +4 -2
  76. package/dist/chat/ui/rows/SubagentRow.js +5 -0
  77. package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
  78. package/dist/chat/ui/rows/WorkRows.js +4 -3
  79. package/dist/host.d.ts +25 -3
  80. package/dist/host.js +1 -0
  81. package/dist/locales/en/agent-chat.json +89 -1
  82. package/dist/locales/nl/agent-chat.json +89 -1
  83. package/dist/state/chats.js +9 -0
  84. package/dist/theme.css +38 -2
  85. package/package.json +133 -7
  86. package/src/chat/chat-client.ts +1 -0
  87. package/src/chat/logic/generated-image.ts +48 -0
  88. package/src/chat/logic/timeline-target.ts +69 -4
  89. package/src/chat/logic/timeline.ts +28 -17
  90. package/src/chat/subagent-conversation.ts +13 -2
  91. package/src/chat/timeline-flash.ts +5 -3
  92. package/src/chat/ui/CodeBlock.tsx +70 -21
  93. package/src/chat/ui/Composer.tsx +22 -5
  94. package/src/chat/ui/ImageView.tsx +51 -8
  95. package/src/chat/ui/Markdown.tsx +123 -26
  96. package/src/chat/ui/MessageActions.tsx +15 -8
  97. package/src/chat/ui/SubagentTimeline.tsx +26 -1
  98. package/src/chat/ui/Timeline.tsx +98 -22
  99. package/src/chat/ui/TimelineMenu.tsx +6 -3
  100. package/src/chat/ui/file-links.ts +9 -3
  101. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
  102. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
  103. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
  104. package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
  105. package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
  106. package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
  107. package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
  108. package/src/chat/ui/intelligent-ui/registry.ts +52 -0
  109. package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
  110. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
  111. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
  112. package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
  113. package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
  114. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
  115. package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
  116. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
  117. package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
  118. package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
  119. package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
  120. package/src/chat/ui/intelligent-ui/tones.ts +37 -0
  121. package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
  122. package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
  123. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
  124. package/src/chat/ui/reply-context.ts +7 -0
  125. package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
  126. package/src/chat/ui/rows/MessageRows.tsx +69 -7
  127. package/src/chat/ui/rows/Rows.tsx +4 -2
  128. package/src/chat/ui/rows/SubagentRow.tsx +5 -0
  129. package/src/chat/ui/rows/WorkRows.tsx +20 -5
  130. package/src/host.ts +46 -3
  131. package/src/locales/en/agent-chat.json +89 -1
  132. package/src/locales/nl/agent-chat.json +89 -1
  133. package/src/state/chats.ts +9 -0
  134. package/src/theme.css +38 -2
@@ -0,0 +1,282 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { ChatUiQueryReading, ChatUiQueryState } from '@adecore/agent-contracts';
3
+ import { uiInputValues, UI_CATALOG_VERSION, UiState, type UiBlock, type UiViewNode, type UiNode } from '@adecore/intelligent-ui';
4
+ import { chatHost } from '../../../host';
5
+ import type { UiLiveValue, UiLiveStatus, UiRenderContext } from './render-context';
6
+
7
+ interface LocalReading {
8
+ identity: string;
9
+ reading: ChatUiQueryReading;
10
+ successful?: { inputs: string; reading: ChatUiQueryReading };
11
+ }
12
+
13
+ function documentVisible(): boolean {
14
+ return document.visibilityState !== 'hidden';
15
+ }
16
+
17
+ export function useUiQueries(
18
+ block: UiBlock,
19
+ state: UiState,
20
+ context: UiRenderContext,
21
+ element: HTMLDivElement | null,
22
+ frozen?: ChatUiQueryState['blocks'][string]
23
+ ) {
24
+ const [readings, setReadings] = useState<Record<string, LocalReading>>({});
25
+ const [busyInput, setBusyInput] = useState<string | null>(null);
26
+ const lastReadAt = useRef({ identity: '', at: 0 });
27
+ const names = Object.keys(block.queries);
28
+ const inputs = JSON.stringify(uiInputValues(block, state));
29
+ const identity = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id, block.revision]);
30
+ const inputKey = JSON.stringify([identity, inputs]);
31
+ const reading = busyInput === inputKey;
32
+ const defaultInputs = JSON.stringify(uiInputValues(block, new UiState(block)));
33
+ const initial = frozen && frozen.revision === block.revision ? frozen.readings : {};
34
+ const successfulFor = (name: string, signature: string): ChatUiQueryReading | undefined => {
35
+ const local = readings[name];
36
+ if (local?.identity === identity && local.successful?.inputs === signature) {
37
+ return local.successful.reading;
38
+ }
39
+ return signature === defaultInputs && initial[name]?.state === 'fresh' ? initial[name] : undefined;
40
+ };
41
+ const readsFor = (values: Readonly<Record<string, unknown>>): Record<string, string> | null => {
42
+ const signature = JSON.stringify(values);
43
+ const reads: Record<string, string> = {};
44
+ for (const name of names) {
45
+ const local = readings[name];
46
+ const successful = successfulFor(name, signature);
47
+ if (!successful?.readId || (local?.identity === identity && local.reading.state === 'refused')) {
48
+ return null;
49
+ }
50
+ reads[name] = successful.readId;
51
+ }
52
+ return reads;
53
+ };
54
+ const reads = readsFor(JSON.parse(inputs));
55
+ const ready = reads !== null;
56
+ const query = chatHost().intelligentUi?.query;
57
+ const subscribe = chatHost().intelligentUi?.subscribe;
58
+ useEffect(() => {
59
+ const scope = state.scope();
60
+ for (const name of names) {
61
+ const successful = successfulFor(name, inputs);
62
+ if (successful && JSON.stringify(scope[name]) !== JSON.stringify(successful.value)) {
63
+ state.setQuery(name, successful.value, block);
64
+ }
65
+ }
66
+ });
67
+ useEffect(() => {
68
+ if (
69
+ !query ||
70
+ !element ||
71
+ !block.complete ||
72
+ block.catalogVersion !== UI_CATALOG_VERSION ||
73
+ !block.revision ||
74
+ !Object.keys(block.queries).length ||
75
+ context.phase !== 'final'
76
+ ) {
77
+ return;
78
+ }
79
+ let stopped = false;
80
+ let visible = typeof IntersectionObserver === 'undefined';
81
+ let busy = false;
82
+
83
+ let timer: ReturnType<typeof setTimeout> | null = null;
84
+ const refresh = async () => {
85
+ if (stopped || !visible || !documentVisible() || busy) {
86
+ return;
87
+ }
88
+ const wait = lastReadAt.current.identity === identity ? 10_000 - (Date.now() - lastReadAt.current.at) : 0;
89
+ if (wait > 0) {
90
+ if (timer === null) {
91
+ if (!visible || !documentVisible()) {
92
+ return;
93
+ }
94
+ timer = setTimeout(() => {
95
+ timer = null;
96
+ void refresh();
97
+ }, wait);
98
+ }
99
+ return;
100
+ }
101
+ busy = true;
102
+ lastReadAt.current = { identity, at: Date.now() };
103
+ setBusyInput(inputKey);
104
+ for (const name of Object.keys(block.queries).slice(0, 8)) {
105
+ if (!visible || !documentVisible()) {
106
+ break;
107
+ }
108
+ try {
109
+ const reading = await query(context.scopeId, {
110
+ chatId: context.chatId,
111
+ itemId: context.itemId,
112
+ blockId: block.id,
113
+ revision: block.revision!,
114
+ query: name,
115
+ values: JSON.parse(inputs)
116
+ });
117
+ if (stopped) {
118
+ return;
119
+ }
120
+ if (reading.state === 'fresh') {
121
+ state.setQuery(name, reading.value, block);
122
+ }
123
+ setReadings((previous) => ({
124
+ ...previous,
125
+ [name]: {
126
+ identity,
127
+ reading,
128
+ successful:
129
+ reading.state === 'fresh' ? { inputs, reading } : previous[name]?.identity === identity ? previous[name].successful : undefined
130
+ }
131
+ }));
132
+ } catch (error) {
133
+ if (stopped) {
134
+ return;
135
+ }
136
+ setReadings((previous) => ({
137
+ ...previous,
138
+ [name]: {
139
+ identity,
140
+ reading: { state: 'failed', readAt: Date.now(), reason: error instanceof Error ? error.message : 'This query could not be read.' },
141
+ successful: previous[name]?.identity === identity ? previous[name].successful : undefined
142
+ }
143
+ }));
144
+ }
145
+ }
146
+ busy = false;
147
+ if (stopped) {
148
+ return;
149
+ }
150
+ setBusyInput(null);
151
+ timer = setTimeout(() => {
152
+ timer = null;
153
+ void refresh();
154
+ }, 10_000);
155
+ };
156
+ const observer =
157
+ typeof IntersectionObserver === 'undefined'
158
+ ? null
159
+ : new IntersectionObserver((entries) => {
160
+ visible = entries.some((entry) => entry.isIntersecting);
161
+ if (visible) {
162
+ void refresh();
163
+ } else if (timer !== null) {
164
+ clearTimeout(timer);
165
+ timer = null;
166
+ }
167
+ });
168
+ if (element) {
169
+ observer?.observe(element);
170
+ }
171
+ const focus = () => {
172
+ void refresh();
173
+ };
174
+ document.addEventListener('visibilitychange', focus);
175
+ const unsubscribe = subscribe?.(context.scopeId, context.chatId, focus);
176
+ void refresh();
177
+ return () => {
178
+ stopped = true;
179
+ observer?.disconnect();
180
+ unsubscribe?.();
181
+ document.removeEventListener('visibilitychange', focus);
182
+ if (timer !== null) {
183
+ clearTimeout(timer);
184
+ }
185
+ };
186
+ }, [block, state, context.scopeId, context.chatId, context.itemId, context.phase, inputs, query, subscribe, element, identity, inputKey]);
187
+ const currentReadings = Object.fromEntries(
188
+ names.flatMap((name) => {
189
+ const local = readings[name];
190
+ const reading = local?.identity === identity ? local.reading : initial[name];
191
+ return reading ? [[name, reading]] : [];
192
+ })
193
+ );
194
+ const failed = Object.entries(currentReadings).find(([, value]) => value.state !== 'fresh');
195
+ const live: UiLiveStatus | undefined = names.length
196
+ ? {
197
+ state: reading || (!ready && !failed) ? 'reading' : (failed?.[1].state ?? 'fresh'),
198
+ sources: names.map((name) => block.queries[name].source),
199
+ readAt: Object.values(currentReadings)
200
+ .filter((reading) => reading.state === 'fresh')
201
+ .reduce<number | null>((at, value) => (at === null ? value.readAt : Math.min(at, value.readAt)), null),
202
+ source: failed ? block.queries[failed[0]]?.source : undefined,
203
+ reason: failed?.[1].reason
204
+ }
205
+ : undefined;
206
+ return { live, reads: reads ?? {}, reading, ready, readsFor, currentReadings };
207
+ }
208
+
209
+ export function useUiLiveValues(block: UiBlock, nodes: readonly UiViewNode[], readings: Readonly<Record<string, ChatUiQueryReading>>) {
210
+ const [committed, setCommitted] = useState<{ token: string; values: Map<string, Record<string, unknown>>; previous: Map<string, Record<string, unknown>> }>(
211
+ {
212
+ token: '',
213
+ values: new Map(),
214
+ previous: new Map()
215
+ }
216
+ );
217
+ const dependencies = new Map<string, Set<string>>();
218
+ const names = new Set(Object.keys(block.queries));
219
+ const references = (value: unknown, into: Set<string>) => {
220
+ if (!value || typeof value !== 'object') {
221
+ return;
222
+ }
223
+ const expression = value as Record<string, unknown>;
224
+ if (expression.kind === 'reference' && names.has(String(expression.name))) {
225
+ into.add(String(expression.name));
226
+ }
227
+ for (const child of Object.values(expression)) {
228
+ if (Array.isArray(child)) {
229
+ child.forEach((item) => references(item, into));
230
+ } else if (typeof child === 'object') {
231
+ references(child, into);
232
+ }
233
+ }
234
+ };
235
+ const written = (nodes: readonly UiNode[], inherited = new Set<string>()) => {
236
+ for (const node of nodes) {
237
+ const related = new Set(inherited);
238
+ references(node.expressions, related);
239
+ dependencies.set(node.id, related);
240
+ written(node.children, node.type === 'Each' ? related : inherited);
241
+ }
242
+ };
243
+ written(block.nodes);
244
+ const values = new Map<string, Record<string, unknown>>();
245
+ const sources = new Map<string, Set<string>>();
246
+ const collect = (nodes: readonly UiViewNode[]) => {
247
+ for (const node of nodes) {
248
+ const related = dependencies.get(node.sourceId ?? node.id);
249
+ if (related?.size && !node.error) {
250
+ values.set(node.id, node.props);
251
+ sources.set(node.id, related);
252
+ }
253
+ collect(node.children);
254
+ }
255
+ };
256
+ collect(nodes);
257
+ const token = JSON.stringify([block.revision, Object.entries(readings).map(([name, reading]) => [name, reading.state, reading.readId, reading.readAt])]);
258
+ const previous = committed.token === token ? committed.previous : committed.values;
259
+ if (committed.token !== token) {
260
+ setCommitted({ token, values, previous });
261
+ }
262
+ if (!names.size) {
263
+ return undefined;
264
+ }
265
+ return (id: string, prop: string, path?: readonly [number, string]): UiLiveValue | undefined => {
266
+ const related = sources.get(id);
267
+ if (!related) {
268
+ return undefined;
269
+ }
270
+ const get = (props?: Record<string, unknown>): unknown => {
271
+ const value = props?.[prop];
272
+ return path && Array.isArray(value) ? value[path[0]]?.[path[1]] : value;
273
+ };
274
+ const before = get(previous.get(id));
275
+ const value = get(values.get(id));
276
+ return {
277
+ previous: before,
278
+ changed: before !== undefined && JSON.stringify(before) !== JSON.stringify(value),
279
+ stale: [...related].some((name) => readings[name]?.state === 'failed' || readings[name]?.state === 'refused')
280
+ };
281
+ };
282
+ }
@@ -1,4 +1,11 @@
1
1
  import { createContext } from 'react';
2
+ import type { UiReplyTarget } from '../logic/timeline-target';
2
3
  import type { ChatInfo } from '@adecore/agent-contracts';
3
4
 
4
5
  export const ReplyContext = createContext<{ provider: ChatInfo['provider']; chatId?: string } | null>(null);
6
+
7
+ export const UiReplyNavigationContext = createContext<{
8
+ chatId: string;
9
+ reveal(target: UiReplyTarget): void;
10
+ flash: (UiReplyTarget & { nonce: number }) | null;
11
+ } | null>(null);
@@ -0,0 +1,244 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { TFunction } from 'i18next';
3
+ import clsx from 'clsx';
4
+ import { Trans, useTranslation } from 'react-i18next';
5
+ import { Check, Copy, Ellipsis, ExternalLink, FolderDown, ImageIcon, Maximize2 } from 'lucide-react';
6
+ import type { ChatAttachment, ChatToolItem } from '@adecore/agent-contracts';
7
+ import { Button, ButtonGroup, copyText, ErrorBoundary, FileIcon, Icon, IconButton, Menu, Surface } from '@adecore/ui';
8
+ import { chatHost } from '../../../host';
9
+ import { useChatScope } from '../../../scope';
10
+ import { useCurrentItem } from '../../../state/chats';
11
+ import { formatBytes } from '../../attachments';
12
+ import { attachmentAspect, generatedImageView, type GeneratedImageFailure } from '../../logic/generated-image';
13
+ import { toolStartedAt } from '../../logic/tools';
14
+ import { CHIP_IN_MESSAGE, MENTION_TONE } from '../chips';
15
+ import { openFileLink, useFileLinkCwd } from '../file-links';
16
+ import { ImageThumb } from '../ImageView';
17
+ import { ROW_GUTTER } from '../icons';
18
+ import { RunningFor, WorkRow } from './WorkRows';
19
+
20
+ const SAVED_FLASH_MS = 2000;
21
+
22
+ // Where each image went, so a row the thread unmounts while scrolling still says so when it comes back.
23
+ const savedPaths = new Map<string, string>();
24
+
25
+ /* True for `ms` after `start()`, for a check mark that says an action landed. */
26
+ function useFlash(ms: number): [boolean, () => void] {
27
+ const [on, setOn] = useState(false);
28
+ useEffect(() => {
29
+ if (!on) {
30
+ return;
31
+ }
32
+ const timer = setTimeout(() => setOn(false), ms);
33
+ return () => clearTimeout(timer);
34
+ }, [on, ms]);
35
+ return [on, () => setOn(true)];
36
+ }
37
+
38
+ function failureText(failure: GeneratedImageFailure, t: TFunction<'agent-chat'>): string | undefined {
39
+ switch (failure.kind) {
40
+ case 'provider':
41
+ return failure.message ?? undefined;
42
+ case 'empty':
43
+ return t('generatedImage.empty');
44
+ case 'not-image':
45
+ return t('generatedImage.notImage');
46
+ case 'too-large':
47
+ return t('generatedImage.tooLarge', { size: formatBytes(failure.limit) });
48
+ }
49
+ }
50
+
51
+ /* The line over the image, in the measures of a tool line but nothing to fold. */
52
+ function HeadLine({ label, detail, live, startedAt }: { label: string; detail?: string; live?: boolean; startedAt?: number }) {
53
+ return (
54
+ <div className="-mx-1 mb-0.5 flex h-7 items-center gap-2 px-1 text-xs text-text-muted">
55
+ <span className={clsx(ROW_GUTTER, live && 'text-accent')}>
56
+ <Icon icon={ImageIcon} size={12} />
57
+ </span>
58
+ <span className={clsx('shrink-0', live && 'chat-live-text')}>{label}</span>
59
+ {detail && <span className="min-w-0 truncate text-text-faint">{detail}</span>}
60
+ <span className="grow" />
61
+ {startedAt !== undefined && <RunningFor startedAt={startedAt} />}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /* The file a saved image became, which opens where the app opens a file link. */
67
+ function SavedFile({ path }: { path: string }) {
68
+ const cwd = useFileLinkCwd();
69
+ const { id } = useChatScope();
70
+ const chip = (
71
+ <>
72
+ <FileIcon path={path} size={14} />
73
+ <span className="truncate">{path}</span>
74
+ </>
75
+ );
76
+ if (chatHost().fileLinks === null) {
77
+ return <span className={clsx(CHIP_IN_MESSAGE, MENTION_TONE)}>{chip}</span>;
78
+ }
79
+ return (
80
+ <button
81
+ type="button"
82
+ className={clsx(CHIP_IN_MESSAGE, MENTION_TONE, 'hover:brightness-95')}
83
+ data-file-path={path}
84
+ onClick={() => openFileLink(cwd, { path, directory: false }, id)}
85
+ >
86
+ {chip}
87
+ </button>
88
+ );
89
+ }
90
+
91
+ function ReadyImage({ chatId, attachment, prompt, transparent }: { chatId: string; attachment: ChatAttachment; prompt: string | null; transparent: boolean }) {
92
+ const { t } = useTranslation('agent-chat');
93
+ const { id: scopeId } = useChatScope();
94
+ const { attachments } = chatHost();
95
+ const source = attachments.useUrl(scopeId, chatId, attachment.id);
96
+ const savedKey = `${scopeId}\n${chatId}\n${attachment.id}`;
97
+ const [lightbox, setLightbox] = useState(false);
98
+ const [saving, setSaving] = useState(false);
99
+ const [savedPath, setSavedPath] = useState(() => savedPaths.get(savedKey) ?? null);
100
+ const [savedFlash, flashSaved] = useFlash(SAVED_FLASH_MS);
101
+ const missing = source.failure !== null;
102
+ const aspect = attachmentAspect(attachment);
103
+ const { open, saveToProject } = attachments;
104
+
105
+ const detail = [
106
+ attachment.width !== undefined && attachment.height !== undefined
107
+ ? t('generatedImage.dimensions', { width: attachment.width, height: attachment.height })
108
+ : null,
109
+ formatBytes(attachment.size),
110
+ transparent ? t('generatedImage.transparent') : null
111
+ ]
112
+ .filter((part) => part !== null)
113
+ .join(' · ');
114
+
115
+ const openFile = open === undefined ? undefined : () => open(scopeId, chatId, attachment.id, attachment.name);
116
+
117
+ const save =
118
+ saveToProject === undefined
119
+ ? undefined
120
+ : async (): Promise<void> => {
121
+ setSaving(true);
122
+ try {
123
+ const path = await saveToProject(scopeId, chatId, attachment.id, attachment.name);
124
+ if (path !== null) {
125
+ savedPaths.set(savedKey, path);
126
+ setSavedPath(path);
127
+ flashSaved();
128
+ }
129
+ } catch {
130
+ // The host's dialog shows why; the row stays as it was.
131
+ } finally {
132
+ setSaving(false);
133
+ }
134
+ };
135
+
136
+ const lightboxActions = (
137
+ <>
138
+ {openFile !== undefined && <IconButton icon={ExternalLink} size="sm" label={t('generatedImage.open')} onClick={openFile} />}
139
+ {save !== undefined && <IconButton icon={FolderDown} size="sm" label={t('generatedImage.save')} disabled={saving} onClick={() => void save()} />}
140
+ </>
141
+ );
142
+
143
+ return (
144
+ <div>
145
+ <HeadLine label={t('generatedImage.label')} detail={detail} />
146
+ <div className="flex max-w-90 flex-col gap-1.5">
147
+ <div
148
+ className={clsx(
149
+ 'group/image relative',
150
+ aspect === undefined && 'w-fit',
151
+ transparent && source.url !== null && 'chat-checkerboard rounded-lg'
152
+ )}
153
+ >
154
+ <ImageThumb
155
+ source={source}
156
+ alt={attachment.name}
157
+ aspect={aspect}
158
+ className={aspect === undefined ? 'max-h-90 max-w-full object-contain' : undefined}
159
+ actions={lightboxActions}
160
+ open={lightbox}
161
+ onOpenChange={setLightbox}
162
+ />
163
+ {source.url !== null && (
164
+ <Surface
165
+ role="toolbar"
166
+ aria-label={t('generatedImage.actions')}
167
+ className="absolute top-2 right-2 rounded-lg p-0.5 opacity-0 transition-opacity group-focus-within/image:opacity-100 group-hover/image:opacity-100 has-[[data-popup-open]]:opacity-100 pointer-coarse:opacity-100"
168
+ >
169
+ <ButtonGroup>
170
+ <IconButton icon={Maximize2} size="sm" label={t('generatedImage.openLarge')} onClick={() => setLightbox(true)} />
171
+ {prompt !== null && (
172
+ <Menu.Root>
173
+ <IconButton icon={Ellipsis} size="sm" label={t('generatedImage.more')} render={<Menu.Trigger />} />
174
+ <Menu.Popup align="end">
175
+ <Menu.Item onClick={() => copyText(prompt)}>
176
+ <Icon icon={Copy} size={14} /> {t('generatedImage.copyPrompt')}
177
+ </Menu.Item>
178
+ </Menu.Popup>
179
+ </Menu.Root>
180
+ )}
181
+ </ButtonGroup>
182
+ </Surface>
183
+ )}
184
+ </div>
185
+ {(openFile !== undefined || save !== undefined) && (
186
+ <div className="flex items-center gap-1.5">
187
+ {openFile !== undefined && (
188
+ <Button size="xs" variant="secondary" disabled={missing} onClick={openFile}>
189
+ {t('generatedImage.open')}
190
+ </Button>
191
+ )}
192
+ {save !== undefined && (
193
+ <Button size="xs" variant="secondary" disabled={missing || saving || savedFlash} onClick={() => void save()}>
194
+ {savedFlash ? (
195
+ <>
196
+ <Icon icon={Check} size={12} /> {t('generatedImage.saved')}
197
+ </>
198
+ ) : (
199
+ t('generatedImage.save')
200
+ )}
201
+ </Button>
202
+ )}
203
+ </div>
204
+ )}
205
+ {savedPath !== null && !savedFlash && (
206
+ <div className="flex min-w-0 items-center gap-1.5 text-xs text-text-muted">
207
+ <Trans t={t} i18nKey="generatedImage.savedTo" values={{ path: savedPath }} components={{ file: <SavedFile path={savedPath} /> }} />
208
+ </div>
209
+ )}
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ /*
216
+ * The row of an image generation. Not a fold like other tool calls: the image is the result a person
217
+ * came for, so it stays open, opens large in the lightbox and offers what the host lets a person do
218
+ * with it. A failure is an ordinary failed tool line, with the reason as its detail.
219
+ */
220
+ export function GeneratedImageRow({ chatId, tool: derived }: { chatId: string; tool: ChatToolItem }) {
221
+ const { t } = useTranslation('agent-chat');
222
+ const tool = useCurrentItem(chatId, derived);
223
+ const view = generatedImageView(tool);
224
+ switch (view.state) {
225
+ case 'generating':
226
+ return <HeadLine label={t('generatedImage.generating')} live startedAt={toolStartedAt(tool)} />;
227
+ case 'failed':
228
+ return (
229
+ <WorkRow
230
+ tool={tool}
231
+ icon={<Icon icon={ImageIcon} size={12} />}
232
+ label={t('generatedImage.failed')}
233
+ detail={failureText(view.failure, t) ?? ''}
234
+ failed
235
+ />
236
+ );
237
+ case 'ready':
238
+ return (
239
+ <ErrorBoundary label={t('generatedImage.renderFailed')} resetKeys={[view.attachment]} compact className="relative rounded-lg">
240
+ <ReadyImage chatId={chatId} attachment={view.attachment} prompt={view.prompt} transparent={view.transparent} />
241
+ </ErrorBoundary>
242
+ );
243
+ }
244
+ }
@@ -1,8 +1,16 @@
1
- import { useContext, useState, type ReactNode } from 'react';
1
+ import { useContext, useMemo, useState, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { Bot, Brain, Check, ChevronDown, CircleAlert, File, Info, MessageCircleQuestionMark, TriangleAlert, X } from 'lucide-react';
5
- import type { ChatApprovalItem, ChatAttachment, ChatAssistantItem, ChatQuestionItem, ChatThinkingItem, ChatUserItem } from '@adecore/agent-contracts';
4
+ import { Bot, Brain, Check, ChevronDown, CircleAlert, File, Info, MessageCircleQuestionMark, Reply, TriangleAlert, X } from 'lucide-react';
5
+ import type {
6
+ ChatApprovalItem,
7
+ ChatAttachment,
8
+ ChatAssistantItem,
9
+ ChatQuestionItem,
10
+ ChatThinkingItem,
11
+ ChatUiChoiceOrigin,
12
+ ChatUserItem
13
+ } from '@adecore/agent-contracts';
6
14
  import { fileBadge, formatBytes, isImageAttachment } from '../../attachments';
7
15
  import { useChatRow, useCurrentItem } from '../../../state/chats';
8
16
  import { chatHost } from '../../../host';
@@ -15,12 +23,13 @@ import { FadingWords } from '../FadingWords';
15
23
  import { settledBlocksText } from '../markdown-blocks';
16
24
  import { WHOLE_FADE_CLASS } from '../rehype-fade';
17
25
  import { useRevealedText } from '../reveal';
18
- import { formatAgo, formatElapsedShort, formatTokens } from '@adecore/ui/format';
26
+ import { formatAgo, formatElapsedShort, formatMoment, formatTokens } from '@adecore/ui/format';
19
27
  import { Icon, Tooltip, useTickingText } from '@adecore/ui';
20
28
  import { toolSummary } from '../../logic/tools';
21
29
  import { ROW_GUTTER } from '../icons';
22
30
  import { useOpenForFind } from '../find-reveal';
23
- import { ReplyContext } from '../reply-context';
31
+ import { ReplyContext, UiReplyNavigationContext } from '../reply-context';
32
+ import { UiReply } from '../intelligent-ui/UiReply';
24
33
 
25
34
  // A long prompt folds so the answer stays in view; the reader can open it.
26
35
  const USER_FOLD_LINES = 8;
@@ -99,6 +108,7 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
99
108
  ))}
100
109
  </div>
101
110
  )}
111
+ {item.uiChoice !== undefined && <ChoiceLine chatId={chatId} choice={item.uiChoice} />}
102
112
  {item.text !== '' && (
103
113
  <div className="relative max-w-[80%] rounded-2xl bg-surface-active px-3.5 py-2.5 text-sm wrap-anywhere text-text select-text">
104
114
  <div data-find-field="text" className={clsx(long && !open && FOLD)}>
@@ -116,6 +126,34 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
116
126
  );
117
127
  }
118
128
 
129
+ /*
130
+ * The choice a message answers. The label stays out of the message, which holds exactly what the agent
131
+ * read, and out of a copied selection; a screen reader hears it between the heading and the message.
132
+ */
133
+ function ChoiceLine({ chatId, choice }: { chatId: string; choice: ChatUiChoiceOrigin }) {
134
+ const { t } = useTranslation('agent-chat');
135
+ const inherited = useContext(UiReplyNavigationContext);
136
+ const navigation = inherited?.chatId === chatId ? inherited : null;
137
+ const Tag = navigation ? 'button' : 'div';
138
+ return (
139
+ <Tag
140
+ type={navigation ? 'button' : undefined}
141
+ onClick={navigation ? () => navigation.reveal(choice) : undefined}
142
+ className={clsx(
143
+ 'mb-1 flex max-w-[80%] items-start gap-1.5 px-1 text-left text-xs text-text-muted select-none',
144
+ navigation && 'rounded-md hover:text-text focus-visible:outline-2 focus-visible:outline-accent'
145
+ )}
146
+ >
147
+ <Icon icon={Reply} size={12} className="mt-1 shrink-0" />
148
+ <span className="min-w-0 wrap-anywhere">
149
+ {choice.older
150
+ ? t('blocks.choseOlder', { label: choice.label, time: formatMoment(choice.sourceAt) })
151
+ : t('blocks.chose', { label: choice.label })}
152
+ </span>
153
+ </Tag>
154
+ );
155
+ }
156
+
119
157
  /*
120
158
  * Who wrote a message, for a screen reader only: one heading per message is what lets VoiceOver's
121
159
  * rotor walk a thread from message to message. `select-none`, so a selection copied across two
@@ -188,11 +226,35 @@ function Writing() {
188
226
  */
189
227
  export function AssistantRow({ chatId, item: derived }: { chatId: string; item: ChatAssistantItem }) {
190
228
  const item = useCurrentItem(chatId, derived);
229
+ const scope = useChatScope();
230
+ const context = useContext(ReplyContext);
231
+ const reply = useMemo(
232
+ () => (context === null && !item.parentToolUseId ? { scopeId: scope.id, chatId, itemId: item.id, streaming: item.streaming } : undefined),
233
+ [context, item.parentToolUseId, scope.id, chatId, item.id, item.streaming]
234
+ );
191
235
  const mode = chatHost().useStreaming();
192
236
  const [sawWriting] = useState(item.streaming);
193
237
  const live = mode === 'words' && item.streaming;
194
238
  const reveal = useRevealedText(item.text, live);
195
239
 
240
+ if (item.ui?.length && !item.parentToolUseId && !(mode === 'whole' && item.streaming)) {
241
+ return (
242
+ <div data-find-item={item.id} className="-mx-1 px-1 pb-2">
243
+ <ReplyHeading chatId={chatId} />
244
+ <div data-find-field="text" data-quote-answer className="contents">
245
+ <UiReply
246
+ text={item.text}
247
+ blocks={item.ui}
248
+ answers={item.uiAnswers}
249
+ queries={item.uiQueries}
250
+ reply={reply}
251
+ context={{ scopeId: scope.id, chatId, itemId: item.id, phase: item.streaming ? 'streaming' : 'final', answer: null }}
252
+ />
253
+ </div>
254
+ </div>
255
+ );
256
+ }
257
+
196
258
  if (mode === 'blocks') {
197
259
  const settled = item.streaming ? settledBlocksText(item.text) : item.text;
198
260
  return (
@@ -200,7 +262,7 @@ export function AssistantRow({ chatId, item: derived }: { chatId: string; item:
200
262
  <ReplyHeading chatId={chatId} />
201
263
  {settled !== '' && (
202
264
  <div data-find-field="text" data-quote-answer className="contents">
203
- <ReplyMarkdown text={settled} streaming={false} arriving={sawWriting} />
265
+ <ReplyMarkdown text={settled} streaming={false} arriving={sawWriting} reply={reply} />
204
266
  </div>
205
267
  )}
206
268
  {item.streaming && <Writing />}
@@ -219,7 +281,7 @@ export function AssistantRow({ chatId, item: derived }: { chatId: string; item:
219
281
  <div data-find-item={item.id} className={clsx('-mx-1 px-1 pb-2', mode === 'whole' && sawWriting && WHOLE_FADE_CLASS)}>
220
282
  <ReplyHeading chatId={chatId} />
221
283
  <div data-find-field="text" data-quote-answer className="contents">
222
- <ReplyMarkdown text={reveal.text} streaming={reveal.active} />
284
+ <ReplyMarkdown text={reveal.text} streaming={reveal.active} reply={reply} />
223
285
  </div>
224
286
  {live && item.text === '' && <span className="inline-block h-3.5 w-1.5 animate-pulse rounded-sm bg-text-faint align-middle" />}
225
287
  </div>