@adecore/agents-react 0.20.1 → 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 (116) 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/UiBlockFooter.d.ts +15 -0
  15. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
  16. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
  17. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
  18. package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
  19. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
  20. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
  21. package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
  22. package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
  23. package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
  24. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
  25. package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
  26. package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
  27. package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
  28. package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
  29. package/dist/chat/ui/intelligent-ui/registry.js +50 -0
  30. package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
  31. package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
  32. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
  33. package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
  34. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
  35. package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
  36. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
  37. package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
  38. package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
  39. package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
  40. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
  41. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
  48. package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
  49. package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
  50. package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
  51. package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
  52. package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
  53. package/dist/chat/ui/intelligent-ui/tones.js +31 -0
  54. package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
  55. package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
  56. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
  57. package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
  58. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
  59. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
  60. package/dist/chat/ui/reply-context.d.ts +8 -0
  61. package/dist/chat/ui/reply-context.js +1 -0
  62. package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
  63. package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
  64. package/dist/chat/ui/rows/MessageRows.js +21 -4
  65. package/dist/chat/ui/rows/Rows.js +4 -2
  66. package/dist/chat/ui/rows/SubagentRow.js +5 -0
  67. package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
  68. package/dist/chat/ui/rows/WorkRows.js +4 -3
  69. package/dist/host.d.ts +11 -1
  70. package/dist/locales/en/agent-chat.json +82 -0
  71. package/dist/locales/nl/agent-chat.json +82 -0
  72. package/dist/state/chats.js +9 -0
  73. package/dist/theme.css +19 -0
  74. package/package.json +133 -7
  75. package/src/chat/logic/generated-image.ts +48 -0
  76. package/src/chat/logic/timeline-target.ts +41 -1
  77. package/src/chat/logic/timeline.ts +19 -10
  78. package/src/chat/subagent-conversation.ts +13 -2
  79. package/src/chat/timeline-flash.ts +5 -3
  80. package/src/chat/ui/ImageView.tsx +51 -8
  81. package/src/chat/ui/SubagentTimeline.tsx +24 -1
  82. package/src/chat/ui/Timeline.tsx +97 -21
  83. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
  84. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
  85. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
  86. package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
  87. package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
  88. package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
  89. package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
  90. package/src/chat/ui/intelligent-ui/registry.ts +52 -0
  91. package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
  92. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
  93. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
  94. package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
  95. package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
  96. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
  97. package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
  98. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
  99. package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
  100. package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
  101. package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
  102. package/src/chat/ui/intelligent-ui/tones.ts +37 -0
  103. package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
  104. package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
  105. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
  106. package/src/chat/ui/reply-context.ts +7 -0
  107. package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
  108. package/src/chat/ui/rows/MessageRows.tsx +60 -4
  109. package/src/chat/ui/rows/Rows.tsx +4 -2
  110. package/src/chat/ui/rows/SubagentRow.tsx +5 -0
  111. package/src/chat/ui/rows/WorkRows.tsx +20 -5
  112. package/src/host.ts +27 -1
  113. package/src/locales/en/agent-chat.json +82 -0
  114. package/src/locales/nl/agent-chat.json +82 -0
  115. package/src/state/chats.ts +9 -0
  116. package/src/theme.css +19 -0
@@ -0,0 +1,259 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { uiInputValues, UI_CATALOG_VERSION, UiState } from '@adecore/intelligent-ui';
3
+ import { chatHost } from '../../../host.js';
4
+ function documentVisible() {
5
+ return document.visibilityState !== 'hidden';
6
+ }
7
+ export function useUiQueries(block, state, context, element, frozen) {
8
+ const [readings, setReadings] = useState({});
9
+ const [busyInput, setBusyInput] = useState(null);
10
+ const lastReadAt = useRef({ identity: '', at: 0 });
11
+ const names = Object.keys(block.queries);
12
+ const inputs = JSON.stringify(uiInputValues(block, state));
13
+ const identity = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id, block.revision]);
14
+ const inputKey = JSON.stringify([identity, inputs]);
15
+ const reading = busyInput === inputKey;
16
+ const defaultInputs = JSON.stringify(uiInputValues(block, new UiState(block)));
17
+ const initial = frozen && frozen.revision === block.revision ? frozen.readings : {};
18
+ const successfulFor = (name, signature) => {
19
+ const local = readings[name];
20
+ if (local?.identity === identity && local.successful?.inputs === signature) {
21
+ return local.successful.reading;
22
+ }
23
+ return signature === defaultInputs && initial[name]?.state === 'fresh' ? initial[name] : undefined;
24
+ };
25
+ const readsFor = (values) => {
26
+ const signature = JSON.stringify(values);
27
+ const reads = {};
28
+ for (const name of names) {
29
+ const local = readings[name];
30
+ const successful = successfulFor(name, signature);
31
+ if (!successful?.readId || (local?.identity === identity && local.reading.state === 'refused')) {
32
+ return null;
33
+ }
34
+ reads[name] = successful.readId;
35
+ }
36
+ return reads;
37
+ };
38
+ const reads = readsFor(JSON.parse(inputs));
39
+ const ready = reads !== null;
40
+ const query = chatHost().intelligentUi?.query;
41
+ const subscribe = chatHost().intelligentUi?.subscribe;
42
+ useEffect(() => {
43
+ const scope = state.scope();
44
+ for (const name of names) {
45
+ const successful = successfulFor(name, inputs);
46
+ if (successful && JSON.stringify(scope[name]) !== JSON.stringify(successful.value)) {
47
+ state.setQuery(name, successful.value, block);
48
+ }
49
+ }
50
+ });
51
+ useEffect(() => {
52
+ if (!query ||
53
+ !element ||
54
+ !block.complete ||
55
+ block.catalogVersion !== UI_CATALOG_VERSION ||
56
+ !block.revision ||
57
+ !Object.keys(block.queries).length ||
58
+ context.phase !== 'final') {
59
+ return;
60
+ }
61
+ let stopped = false;
62
+ let visible = typeof IntersectionObserver === 'undefined';
63
+ let busy = false;
64
+ let timer = null;
65
+ const refresh = async () => {
66
+ if (stopped || !visible || !documentVisible() || busy) {
67
+ return;
68
+ }
69
+ const wait = lastReadAt.current.identity === identity ? 10_000 - (Date.now() - lastReadAt.current.at) : 0;
70
+ if (wait > 0) {
71
+ if (timer === null) {
72
+ if (!visible || !documentVisible()) {
73
+ return;
74
+ }
75
+ timer = setTimeout(() => {
76
+ timer = null;
77
+ void refresh();
78
+ }, wait);
79
+ }
80
+ return;
81
+ }
82
+ busy = true;
83
+ lastReadAt.current = { identity, at: Date.now() };
84
+ setBusyInput(inputKey);
85
+ for (const name of Object.keys(block.queries).slice(0, 8)) {
86
+ if (!visible || !documentVisible()) {
87
+ break;
88
+ }
89
+ try {
90
+ const reading = await query(context.scopeId, {
91
+ chatId: context.chatId,
92
+ itemId: context.itemId,
93
+ blockId: block.id,
94
+ revision: block.revision,
95
+ query: name,
96
+ values: JSON.parse(inputs)
97
+ });
98
+ if (stopped) {
99
+ return;
100
+ }
101
+ if (reading.state === 'fresh') {
102
+ state.setQuery(name, reading.value, block);
103
+ }
104
+ setReadings((previous) => ({
105
+ ...previous,
106
+ [name]: {
107
+ identity,
108
+ reading,
109
+ successful: reading.state === 'fresh' ? { inputs, reading } : previous[name]?.identity === identity ? previous[name].successful : undefined
110
+ }
111
+ }));
112
+ }
113
+ catch (error) {
114
+ if (stopped) {
115
+ return;
116
+ }
117
+ setReadings((previous) => ({
118
+ ...previous,
119
+ [name]: {
120
+ identity,
121
+ reading: { state: 'failed', readAt: Date.now(), reason: error instanceof Error ? error.message : 'This query could not be read.' },
122
+ successful: previous[name]?.identity === identity ? previous[name].successful : undefined
123
+ }
124
+ }));
125
+ }
126
+ }
127
+ busy = false;
128
+ if (stopped) {
129
+ return;
130
+ }
131
+ setBusyInput(null);
132
+ timer = setTimeout(() => {
133
+ timer = null;
134
+ void refresh();
135
+ }, 10_000);
136
+ };
137
+ const observer = typeof IntersectionObserver === 'undefined'
138
+ ? null
139
+ : new IntersectionObserver((entries) => {
140
+ visible = entries.some((entry) => entry.isIntersecting);
141
+ if (visible) {
142
+ void refresh();
143
+ }
144
+ else if (timer !== null) {
145
+ clearTimeout(timer);
146
+ timer = null;
147
+ }
148
+ });
149
+ if (element) {
150
+ observer?.observe(element);
151
+ }
152
+ const focus = () => {
153
+ void refresh();
154
+ };
155
+ document.addEventListener('visibilitychange', focus);
156
+ const unsubscribe = subscribe?.(context.scopeId, context.chatId, focus);
157
+ void refresh();
158
+ return () => {
159
+ stopped = true;
160
+ observer?.disconnect();
161
+ unsubscribe?.();
162
+ document.removeEventListener('visibilitychange', focus);
163
+ if (timer !== null) {
164
+ clearTimeout(timer);
165
+ }
166
+ };
167
+ }, [block, state, context.scopeId, context.chatId, context.itemId, context.phase, inputs, query, subscribe, element, identity, inputKey]);
168
+ const currentReadings = Object.fromEntries(names.flatMap((name) => {
169
+ const local = readings[name];
170
+ const reading = local?.identity === identity ? local.reading : initial[name];
171
+ return reading ? [[name, reading]] : [];
172
+ }));
173
+ const failed = Object.entries(currentReadings).find(([, value]) => value.state !== 'fresh');
174
+ const live = names.length
175
+ ? {
176
+ state: reading || (!ready && !failed) ? 'reading' : (failed?.[1].state ?? 'fresh'),
177
+ sources: names.map((name) => block.queries[name].source),
178
+ readAt: Object.values(currentReadings)
179
+ .filter((reading) => reading.state === 'fresh')
180
+ .reduce((at, value) => (at === null ? value.readAt : Math.min(at, value.readAt)), null),
181
+ source: failed ? block.queries[failed[0]]?.source : undefined,
182
+ reason: failed?.[1].reason
183
+ }
184
+ : undefined;
185
+ return { live, reads: reads ?? {}, reading, ready, readsFor, currentReadings };
186
+ }
187
+ export function useUiLiveValues(block, nodes, readings) {
188
+ const [committed, setCommitted] = useState({
189
+ token: '',
190
+ values: new Map(),
191
+ previous: new Map()
192
+ });
193
+ const dependencies = new Map();
194
+ const names = new Set(Object.keys(block.queries));
195
+ const references = (value, into) => {
196
+ if (!value || typeof value !== 'object') {
197
+ return;
198
+ }
199
+ const expression = value;
200
+ if (expression.kind === 'reference' && names.has(String(expression.name))) {
201
+ into.add(String(expression.name));
202
+ }
203
+ for (const child of Object.values(expression)) {
204
+ if (Array.isArray(child)) {
205
+ child.forEach((item) => references(item, into));
206
+ }
207
+ else if (typeof child === 'object') {
208
+ references(child, into);
209
+ }
210
+ }
211
+ };
212
+ const written = (nodes, inherited = new Set()) => {
213
+ for (const node of nodes) {
214
+ const related = new Set(inherited);
215
+ references(node.expressions, related);
216
+ dependencies.set(node.id, related);
217
+ written(node.children, node.type === 'Each' ? related : inherited);
218
+ }
219
+ };
220
+ written(block.nodes);
221
+ const values = new Map();
222
+ const sources = new Map();
223
+ const collect = (nodes) => {
224
+ for (const node of nodes) {
225
+ const related = dependencies.get(node.sourceId ?? node.id);
226
+ if (related?.size && !node.error) {
227
+ values.set(node.id, node.props);
228
+ sources.set(node.id, related);
229
+ }
230
+ collect(node.children);
231
+ }
232
+ };
233
+ collect(nodes);
234
+ const token = JSON.stringify([block.revision, Object.entries(readings).map(([name, reading]) => [name, reading.state, reading.readId, reading.readAt])]);
235
+ const previous = committed.token === token ? committed.previous : committed.values;
236
+ if (committed.token !== token) {
237
+ setCommitted({ token, values, previous });
238
+ }
239
+ if (!names.size) {
240
+ return undefined;
241
+ }
242
+ return (id, prop, path) => {
243
+ const related = sources.get(id);
244
+ if (!related) {
245
+ return undefined;
246
+ }
247
+ const get = (props) => {
248
+ const value = props?.[prop];
249
+ return path && Array.isArray(value) ? value[path[0]]?.[path[1]] : value;
250
+ };
251
+ const before = get(previous.get(id));
252
+ const value = get(values.get(id));
253
+ return {
254
+ previous: before,
255
+ changed: before !== undefined && JSON.stringify(before) !== JSON.stringify(value),
256
+ stale: [...related].some((name) => readings[name]?.state === 'failed' || readings[name]?.state === 'refused')
257
+ };
258
+ };
259
+ }
@@ -1,5 +1,13 @@
1
+ import type { UiReplyTarget } from '../logic/timeline-target.js';
1
2
  import type { ChatInfo } from '@adecore/agent-contracts';
2
3
  export declare const ReplyContext: import("react").Context<{
3
4
  provider: ChatInfo['provider'];
4
5
  chatId?: string;
5
6
  } | null>;
7
+ export declare const UiReplyNavigationContext: import("react").Context<{
8
+ chatId: string;
9
+ reveal(target: UiReplyTarget): void;
10
+ flash: (UiReplyTarget & {
11
+ nonce: number;
12
+ }) | null;
13
+ } | null>;
@@ -1,2 +1,3 @@
1
1
  import { createContext } from 'react';
2
2
  export const ReplyContext = createContext(null);
3
+ export const UiReplyNavigationContext = createContext(null);
@@ -0,0 +1,5 @@
1
+ import type { ChatToolItem } from '@adecore/agent-contracts';
2
+ export declare function GeneratedImageRow({ chatId, tool: derived }: {
3
+ chatId: string;
4
+ tool: ChatToolItem;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,121 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
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 { Button, ButtonGroup, copyText, ErrorBoundary, FileIcon, Icon, IconButton, Menu, Surface } from '@adecore/ui';
7
+ import { chatHost } from '../../../host.js';
8
+ import { useChatScope } from '../../../scope.js';
9
+ import { useCurrentItem } from '../../../state/chats.js';
10
+ import { formatBytes } from '../../attachments.js';
11
+ import { attachmentAspect, generatedImageView } from '../../logic/generated-image.js';
12
+ import { toolStartedAt } from '../../logic/tools.js';
13
+ import { CHIP_IN_MESSAGE, MENTION_TONE } from '../chips.js';
14
+ import { openFileLink, useFileLinkCwd } from '../file-links.js';
15
+ import { ImageThumb } from '../ImageView.js';
16
+ import { ROW_GUTTER } from '../icons.js';
17
+ import { RunningFor, WorkRow } from './WorkRows.js';
18
+ const SAVED_FLASH_MS = 2000;
19
+ // Where each image went, so a row the thread unmounts while scrolling still says so when it comes back.
20
+ const savedPaths = new Map();
21
+ /* True for `ms` after `start()`, for a check mark that says an action landed. */
22
+ function useFlash(ms) {
23
+ const [on, setOn] = useState(false);
24
+ useEffect(() => {
25
+ if (!on) {
26
+ return;
27
+ }
28
+ const timer = setTimeout(() => setOn(false), ms);
29
+ return () => clearTimeout(timer);
30
+ }, [on, ms]);
31
+ return [on, () => setOn(true)];
32
+ }
33
+ function failureText(failure, t) {
34
+ switch (failure.kind) {
35
+ case 'provider':
36
+ return failure.message ?? undefined;
37
+ case 'empty':
38
+ return t('generatedImage.empty');
39
+ case 'not-image':
40
+ return t('generatedImage.notImage');
41
+ case 'too-large':
42
+ return t('generatedImage.tooLarge', { size: formatBytes(failure.limit) });
43
+ }
44
+ }
45
+ /* The line over the image, in the measures of a tool line but nothing to fold. */
46
+ function HeadLine({ label, detail, live, startedAt }) {
47
+ return (_jsxs("div", { className: "-mx-1 mb-0.5 flex h-7 items-center gap-2 px-1 text-xs text-text-muted", children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: _jsx(Icon, { icon: ImageIcon, size: 12 }) }), _jsx("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: label }), detail && _jsx("span", { className: "min-w-0 truncate text-text-faint", children: detail }), _jsx("span", { className: "grow" }), startedAt !== undefined && _jsx(RunningFor, { startedAt: startedAt })] }));
48
+ }
49
+ /* The file a saved image became, which opens where the app opens a file link. */
50
+ function SavedFile({ path }) {
51
+ const cwd = useFileLinkCwd();
52
+ const { id } = useChatScope();
53
+ const chip = (_jsxs(_Fragment, { children: [_jsx(FileIcon, { path: path, size: 14 }), _jsx("span", { className: "truncate", children: path })] }));
54
+ if (chatHost().fileLinks === null) {
55
+ return _jsx("span", { className: clsx(CHIP_IN_MESSAGE, MENTION_TONE), children: chip });
56
+ }
57
+ return (_jsx("button", { type: "button", className: clsx(CHIP_IN_MESSAGE, MENTION_TONE, 'hover:brightness-95'), "data-file-path": path, onClick: () => openFileLink(cwd, { path, directory: false }, id), children: chip }));
58
+ }
59
+ function ReadyImage({ chatId, attachment, prompt, transparent }) {
60
+ const { t } = useTranslation('agent-chat');
61
+ const { id: scopeId } = useChatScope();
62
+ const { attachments } = chatHost();
63
+ const source = attachments.useUrl(scopeId, chatId, attachment.id);
64
+ const savedKey = `${scopeId}\n${chatId}\n${attachment.id}`;
65
+ const [lightbox, setLightbox] = useState(false);
66
+ const [saving, setSaving] = useState(false);
67
+ const [savedPath, setSavedPath] = useState(() => savedPaths.get(savedKey) ?? null);
68
+ const [savedFlash, flashSaved] = useFlash(SAVED_FLASH_MS);
69
+ const missing = source.failure !== null;
70
+ const aspect = attachmentAspect(attachment);
71
+ const { open, saveToProject } = attachments;
72
+ const detail = [
73
+ attachment.width !== undefined && attachment.height !== undefined
74
+ ? t('generatedImage.dimensions', { width: attachment.width, height: attachment.height })
75
+ : null,
76
+ formatBytes(attachment.size),
77
+ transparent ? t('generatedImage.transparent') : null
78
+ ]
79
+ .filter((part) => part !== null)
80
+ .join(' · ');
81
+ const openFile = open === undefined ? undefined : () => open(scopeId, chatId, attachment.id, attachment.name);
82
+ const save = saveToProject === undefined
83
+ ? undefined
84
+ : async () => {
85
+ setSaving(true);
86
+ try {
87
+ const path = await saveToProject(scopeId, chatId, attachment.id, attachment.name);
88
+ if (path !== null) {
89
+ savedPaths.set(savedKey, path);
90
+ setSavedPath(path);
91
+ flashSaved();
92
+ }
93
+ }
94
+ catch {
95
+ // The host's dialog shows why; the row stays as it was.
96
+ }
97
+ finally {
98
+ setSaving(false);
99
+ }
100
+ };
101
+ const lightboxActions = (_jsxs(_Fragment, { children: [openFile !== undefined && _jsx(IconButton, { icon: ExternalLink, size: "sm", label: t('generatedImage.open'), onClick: openFile }), save !== undefined && _jsx(IconButton, { icon: FolderDown, size: "sm", label: t('generatedImage.save'), disabled: saving, onClick: () => void save() })] }));
102
+ return (_jsxs("div", { children: [_jsx(HeadLine, { label: t('generatedImage.label'), detail: detail }), _jsxs("div", { className: "flex max-w-90 flex-col gap-1.5", children: [_jsxs("div", { className: clsx('group/image relative', aspect === undefined && 'w-fit', transparent && source.url !== null && 'chat-checkerboard rounded-lg'), children: [_jsx(ImageThumb, { source: source, alt: attachment.name, aspect: aspect, className: aspect === undefined ? 'max-h-90 max-w-full object-contain' : undefined, actions: lightboxActions, open: lightbox, onOpenChange: setLightbox }), source.url !== null && (_jsx(Surface, { role: "toolbar", "aria-label": t('generatedImage.actions'), 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", children: _jsxs(ButtonGroup, { children: [_jsx(IconButton, { icon: Maximize2, size: "sm", label: t('generatedImage.openLarge'), onClick: () => setLightbox(true) }), prompt !== null && (_jsxs(Menu.Root, { children: [_jsx(IconButton, { icon: Ellipsis, size: "sm", label: t('generatedImage.more'), render: _jsx(Menu.Trigger, {}) }), _jsx(Menu.Popup, { align: "end", children: _jsxs(Menu.Item, { onClick: () => copyText(prompt), children: [_jsx(Icon, { icon: Copy, size: 14 }), " ", t('generatedImage.copyPrompt')] }) })] }))] }) }))] }), (openFile !== undefined || save !== undefined) && (_jsxs("div", { className: "flex items-center gap-1.5", children: [openFile !== undefined && (_jsx(Button, { size: "xs", variant: "secondary", disabled: missing, onClick: openFile, children: t('generatedImage.open') })), save !== undefined && (_jsx(Button, { size: "xs", variant: "secondary", disabled: missing || saving || savedFlash, onClick: () => void save(), children: savedFlash ? (_jsxs(_Fragment, { children: [_jsx(Icon, { icon: Check, size: 12 }), " ", t('generatedImage.saved')] })) : (t('generatedImage.save')) }))] })), savedPath !== null && !savedFlash && (_jsx("div", { className: "flex min-w-0 items-center gap-1.5 text-xs text-text-muted", children: _jsx(Trans, { t: t, i18nKey: "generatedImage.savedTo", values: { path: savedPath }, components: { file: _jsx(SavedFile, { path: savedPath }) } }) }))] })] }));
103
+ }
104
+ /*
105
+ * The row of an image generation. Not a fold like other tool calls: the image is the result a person
106
+ * came for, so it stays open, opens large in the lightbox and offers what the host lets a person do
107
+ * with it. A failure is an ordinary failed tool line, with the reason as its detail.
108
+ */
109
+ export function GeneratedImageRow({ chatId, tool: derived }) {
110
+ const { t } = useTranslation('agent-chat');
111
+ const tool = useCurrentItem(chatId, derived);
112
+ const view = generatedImageView(tool);
113
+ switch (view.state) {
114
+ case 'generating':
115
+ return _jsx(HeadLine, { label: t('generatedImage.generating'), live: true, startedAt: toolStartedAt(tool) });
116
+ case 'failed':
117
+ return (_jsx(WorkRow, { tool: tool, icon: _jsx(Icon, { icon: ImageIcon, size: 12 }), label: t('generatedImage.failed'), detail: failureText(view.failure, t) ?? '', failed: true }));
118
+ case 'ready':
119
+ return (_jsx(ErrorBoundary, { label: t('generatedImage.renderFailed'), resetKeys: [view.attachment], compact: true, className: "relative rounded-lg", children: _jsx(ReadyImage, { chatId: chatId, attachment: view.attachment, prompt: view.prompt, transparent: view.transparent }) }));
120
+ }
121
+ }
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useContext, useMemo, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Bot, Brain, Check, ChevronDown, CircleAlert, File, Info, MessageCircleQuestionMark, TriangleAlert, X } from 'lucide-react';
5
+ import { Bot, Brain, Check, ChevronDown, CircleAlert, File, Info, MessageCircleQuestionMark, Reply, TriangleAlert, X } from 'lucide-react';
6
6
  import { fileBadge, formatBytes, isImageAttachment } from '../../attachments.js';
7
7
  import { useChatRow, useCurrentItem } from '../../../state/chats.js';
8
8
  import { chatHost } from '../../../host.js';
@@ -15,12 +15,13 @@ import { FadingWords } from '../FadingWords.js';
15
15
  import { settledBlocksText } from '../markdown-blocks.js';
16
16
  import { WHOLE_FADE_CLASS } from '../rehype-fade.js';
17
17
  import { useRevealedText } from '../reveal.js';
18
- import { formatAgo, formatElapsedShort, formatTokens } from '@adecore/ui/format';
18
+ import { formatAgo, formatElapsedShort, formatMoment, formatTokens } from '@adecore/ui/format';
19
19
  import { Icon, Tooltip, useTickingText } from '@adecore/ui';
20
20
  import { toolSummary } from '../../logic/tools.js';
21
21
  import { ROW_GUTTER } from '../icons.js';
22
22
  import { useOpenForFind } from '../find-reveal.js';
23
- import { ReplyContext } from '../reply-context.js';
23
+ import { ReplyContext, UiReplyNavigationContext } from '../reply-context.js';
24
+ import { UiReply } from '../intelligent-ui/UiReply.js';
24
25
  // A long prompt folds so the answer stays in view; the reader can open it.
25
26
  const USER_FOLD_LINES = 8;
26
27
  const USER_FOLD_CHARS = 600;
@@ -55,7 +56,20 @@ export function UserRow({ chatId, item }) {
55
56
  const attachments = item.attachments ?? [];
56
57
  const chats = item.chats ?? [];
57
58
  useOpenForFind(item.id, 'text', setOpen);
58
- return (_jsxs("div", { "data-find-item": item.id, className: "flex flex-col items-end", children: [_jsx(MessageHeading, { children: t('rows.user.heading') }), attachments.length > 0 && (_jsx("div", { className: "mb-1.5 flex max-w-[80%] flex-wrap justify-end gap-1.5", children: attachments.map((attachment) => isImageAttachment(attachment.mime) ? (_jsx(AttachmentThumb, { chatId: chatId, attachment: attachment }, attachment.id)) : (_jsx(AttachmentLink, { chatId: chatId, attachment: attachment }, attachment.id))) })), chats.length > 0 && (_jsx("div", { className: "mb-1.5 flex max-w-[80%] flex-wrap justify-end gap-1.5", children: chats.map((id) => (_jsx(ChatReferenceChip, { chatId: id }, id))) })), item.text !== '' && (_jsxs("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", children: [_jsx("div", { "data-find-field": "text", className: clsx(long && !open && FOLD), children: _jsx(MessageMarkdown, { text: item.text, mentions: item.mentions, skills: item.skills }) }), long && (_jsxs("button", { className: "mt-1 flex items-center gap-1 text-xs text-text-muted hover:text-text", onClick: () => setOpen((o) => !o), children: [_jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('transition-transform', open && 'rotate-180') }), ' ', open ? t('rows.user.showLess') : t('rows.user.showAll')] }))] }))] }));
59
+ return (_jsxs("div", { "data-find-item": item.id, className: "flex flex-col items-end", children: [_jsx(MessageHeading, { children: t('rows.user.heading') }), attachments.length > 0 && (_jsx("div", { className: "mb-1.5 flex max-w-[80%] flex-wrap justify-end gap-1.5", children: attachments.map((attachment) => isImageAttachment(attachment.mime) ? (_jsx(AttachmentThumb, { chatId: chatId, attachment: attachment }, attachment.id)) : (_jsx(AttachmentLink, { chatId: chatId, attachment: attachment }, attachment.id))) })), chats.length > 0 && (_jsx("div", { className: "mb-1.5 flex max-w-[80%] flex-wrap justify-end gap-1.5", children: chats.map((id) => (_jsx(ChatReferenceChip, { chatId: id }, id))) })), item.uiChoice !== undefined && _jsx(ChoiceLine, { chatId: chatId, choice: item.uiChoice }), item.text !== '' && (_jsxs("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", children: [_jsx("div", { "data-find-field": "text", className: clsx(long && !open && FOLD), children: _jsx(MessageMarkdown, { text: item.text, mentions: item.mentions, skills: item.skills }) }), long && (_jsxs("button", { className: "mt-1 flex items-center gap-1 text-xs text-text-muted hover:text-text", onClick: () => setOpen((o) => !o), children: [_jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('transition-transform', open && 'rotate-180') }), ' ', open ? t('rows.user.showLess') : t('rows.user.showAll')] }))] }))] }));
60
+ }
61
+ /*
62
+ * The choice a message answers. The label stays out of the message, which holds exactly what the agent
63
+ * read, and out of a copied selection; a screen reader hears it between the heading and the message.
64
+ */
65
+ function ChoiceLine({ chatId, choice }) {
66
+ const { t } = useTranslation('agent-chat');
67
+ const inherited = useContext(UiReplyNavigationContext);
68
+ const navigation = inherited?.chatId === chatId ? inherited : null;
69
+ const Tag = navigation ? 'button' : 'div';
70
+ return (_jsxs(Tag, { type: navigation ? 'button' : undefined, onClick: navigation ? () => navigation.reveal(choice) : undefined, className: clsx('mb-1 flex max-w-[80%] items-start gap-1.5 px-1 text-left text-xs text-text-muted select-none', navigation && 'rounded-md hover:text-text focus-visible:outline-2 focus-visible:outline-accent'), children: [_jsx(Icon, { icon: Reply, size: 12, className: "mt-1 shrink-0" }), _jsx("span", { className: "min-w-0 wrap-anywhere", children: choice.older
71
+ ? t('blocks.choseOlder', { label: choice.label, time: formatMoment(choice.sourceAt) })
72
+ : t('blocks.chose', { label: choice.label }) })] }));
59
73
  }
60
74
  /*
61
75
  * Who wrote a message, for a screen reader only: one heading per message is what lets VoiceOver's
@@ -114,6 +128,9 @@ export function AssistantRow({ chatId, item: derived }) {
114
128
  const [sawWriting] = useState(item.streaming);
115
129
  const live = mode === 'words' && item.streaming;
116
130
  const reveal = useRevealedText(item.text, live);
131
+ if (item.ui?.length && !item.parentToolUseId && !(mode === 'whole' && item.streaming)) {
132
+ return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), _jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(UiReply, { text: item.text, blocks: item.ui, answers: item.uiAnswers, queries: item.uiQueries, reply: reply, context: { scopeId: scope.id, chatId, itemId: item.id, phase: item.streaming ? 'streaming' : 'final', answer: null } }) })] }));
133
+ }
117
134
  if (mode === 'blocks') {
118
135
  const settled = item.streaming ? settledBlocksText(item.text) : item.text;
119
136
  return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), settled !== '' && (_jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(ReplyMarkdown, { text: settled, streaming: false, arriving: sawWriting, reply: reply }) })), item.streaming && _jsx(Writing, {})] }));
@@ -2,7 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { ErrorBoundary } from '@adecore/ui';
4
4
  import { AgentTurnRow, ApprovalHistoryRow, AssistantRow, CompactionRow, NoteRow, QuestionHistoryRow, ReportRow, ThinkingRow, UserRow } from './MessageRows.js';
5
+ import { isImageGeneration } from '../../logic/generated-image.js';
5
6
  import { ForksRow } from './ForksRow.js';
7
+ import { GeneratedImageRow } from './GeneratedImageRow.js';
6
8
  import { SubagentBranchRow } from './SubagentRow.js';
7
9
  import { VisualRow } from './VisualRow.js';
8
10
  import { WorkflowRow } from './WorkflowRow.js';
@@ -31,9 +33,9 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
31
33
  case 'thinking':
32
34
  return _jsx(ThinkingRow, { chatId: chatId, item: row.item });
33
35
  case 'work':
34
- return _jsx(WorkRow, { tool: row.tool });
36
+ return isImageGeneration(row.tool) ? _jsx(GeneratedImageRow, { chatId: chatId, tool: row.tool }) : _jsx(WorkRow, { tool: row.tool });
35
37
  case 'work-live':
36
- return _jsx(WorkLiveRow, { chatId: chatId, tool: row.tool });
38
+ return isImageGeneration(row.tool) ? _jsx(GeneratedImageRow, { chatId: chatId, tool: row.tool }) : _jsx(WorkLiveRow, { chatId: chatId, tool: row.tool });
37
39
  case 'workflow':
38
40
  return _jsx(WorkflowRow, { chatId: chatId, tool: row.tool, workflow: row.workflow, onOpenAgent: openConversation });
39
41
  case 'work-group':
@@ -8,6 +8,8 @@ import { Icon, useNow } from '@adecore/ui';
8
8
  import { useOpenForFind } from '../find-reveal.js';
9
9
  import { Markdown } from '../Markdown.js';
10
10
  import { TaskRow } from './TaskRow.js';
11
+ import { isImageGeneration } from '../../logic/generated-image.js';
12
+ import { GeneratedImageRow } from './GeneratedImageRow.js';
11
13
  import { RunningFor, ToggleLine, WorkLiveRow, WorkRow } from './WorkRows.js';
12
14
  import { entryTimeOf, statusWordOf, taskIdOf } from '../../subagent-list.js';
13
15
  import { useSubagentSupport } from '../../subagent-support.js';
@@ -24,6 +26,9 @@ const CHILDREN_MAX_PX = 320;
24
26
  */
25
27
  function ChildRow({ chatId, item }) {
26
28
  if (item.kind === 'tool') {
29
+ if (isImageGeneration(item)) {
30
+ return _jsx(GeneratedImageRow, { chatId: chatId, tool: item });
31
+ }
27
32
  return item.state === 'running' ? _jsx(WorkLiveRow, { chatId: chatId, tool: item }) : _jsx(WorkRow, { tool: item });
28
33
  }
29
34
  if (item.kind === 'assistant') {
@@ -10,10 +10,13 @@ export declare function ToggleLine({ icon, label, detail, open, onToggle, failed
10
10
  trailing?: React.ReactNode;
11
11
  className?: string;
12
12
  }): import("react").JSX.Element;
13
- export declare function WorkRow({ tool, nested, detail }: {
13
+ export declare function WorkRow({ tool, nested, detail, icon, label, failed }: {
14
14
  tool: ChatToolItem;
15
15
  nested?: boolean;
16
16
  detail?: string;
17
+ icon?: React.ReactNode;
18
+ label?: string;
19
+ failed?: boolean;
17
20
  }): import("react").JSX.Element;
18
21
  export declare function RunningFor({ startedAt }: {
19
22
  startedAt: number;
@@ -37,13 +37,14 @@ function ToolBody({ tool }) {
37
37
  return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
38
38
  }
39
39
  /* One settled tool call: a line, and its input and output behind it. An image the call looked at
40
- is drawn under the line, because a picture says more about that read than its path does. */
41
- export function WorkRow({ tool, nested, detail }) {
40
+ is drawn under the line, because a picture says more about that read than its path does.
41
+ `icon`, `label` and `failed` let a row that knows the call better name it in its own words. */
42
+ export function WorkRow({ tool, nested, detail, icon, label, failed }) {
42
43
  const [open, setOpen] = useState(false);
43
44
  const image = tool.state === 'done' ? readImagePath(tool.name, tool.input) : null;
44
45
  const { ReadImage } = chatHost();
45
46
  useOpenForFind(tool.id, 'output', setOpen);
46
- return (_jsxs("div", { "data-find-item": tool.id, className: nested ? 'ml-6' : undefined, children: [_jsx(ToggleLine, { icon: toolIcon(tool.name), label: tool.name, detail: detail ?? toolSummary(tool.name, tool.input), open: open, onToggle: () => setOpen((o) => !o), failed: tool.state === 'error' }), image !== null && ReadImage !== null && _jsx(ReadImage, { path: image }), open && _jsx(ToolBody, { tool: tool })] }));
47
+ return (_jsxs("div", { "data-find-item": tool.id, className: nested ? 'ml-6' : undefined, children: [_jsx(ToggleLine, { icon: icon ?? toolIcon(tool.name), label: label ?? tool.name, detail: detail ?? toolSummary(tool.name, tool.input), open: open, onToggle: () => setOpen((o) => !o), failed: failed ?? tool.state === 'error' }), image !== null && ReadImage !== null && _jsx(ReadImage, { path: image }), open && _jsx(ToolBody, { tool: tool })] }));
47
48
  }
48
49
  /* "running for 12s" next to a live call. */
49
50
  export function RunningFor({ startedAt }) {
package/dist/host.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react'
2
2
  import type { Extension } from '@codemirror/state';
3
3
  import type { EditorView } from '@codemirror/view';
4
4
  import type { ThemeRegistration } from 'shiki';
5
- import type { AgentKind, ChatCheckpointDiff, ChatConfigurePayload, ChatItem, ChatSubagentItem } from '@adecore/agent-contracts';
5
+ import type { AgentKind, ChatCheckpointDiff, ChatConfigurePayload, ChatItem, ChatSubagentItem, ChatUiChoicePayload, ChatUiQueryPayload, ChatUiQueryReading, ChatUiLinkPayload, ChatUiLinkReading } from '@adecore/agent-contracts';
6
6
  import type { TimelineRow } from './chat/logic/timeline.js';
7
7
  import type { FindReveal } from './chat/ui/find-reveal.js';
8
8
  import type { PromptAction } from './prompts/logic/prompts.js';
@@ -126,6 +126,14 @@ export interface ReplyAuthor {
126
126
  }
127
127
  export interface ChatHost {
128
128
  storage?: ChatStorageOptions;
129
+ intelligentUi?: {
130
+ subscribe?(scopeId: string, chatId: string, changed: () => void): () => void;
131
+ link?(scopeId: string, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading>;
132
+ openLink?(scopeId: string, reading: ChatUiLinkReading): void;
133
+ openUrl?(scopeId: string, url: string): void;
134
+ query?(scopeId: string, payload: ChatUiQueryPayload): Promise<ChatUiQueryReading>;
135
+ sendChoice(scopeId: string, payload: ChatUiChoicePayload): Promise<'sent' | 'queued'>;
136
+ };
129
137
  accents: {
130
138
  all: readonly AccentChoice[];
131
139
  featured: readonly string[];
@@ -140,6 +148,8 @@ export interface ChatHost {
140
148
  attachments: {
141
149
  useUrl(scopeId: string, chatId: string, attachmentId: string): ResourceUrl;
142
150
  read(scopeId: string, chatId: string, attachmentId: string): Promise<Blob>;
151
+ open?(scopeId: string, chatId: string, attachmentId: string, suggestedName: string): void;
152
+ saveToProject?(scopeId: string, chatId: string, attachmentId: string, suggestedName: string): Promise<string | null>;
143
153
  };
144
154
  ReadImage: ComponentType<{
145
155
  path: string;