@adecore/agents-react 0.17.0-beta.1 → 0.17.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 (59) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +44 -36
  3. package/dist/chat/chat-client.d.ts +2 -1
  4. package/dist/chat/chat-client.js +8 -2
  5. package/dist/chat/logic/ansi.js +2 -2
  6. package/dist/chat/logic/fork.js +2 -0
  7. package/dist/chat/logic/thread-cards.d.ts +9 -0
  8. package/dist/chat/logic/thread-cards.js +35 -13
  9. package/dist/chat/logic/timeline.d.ts +5 -1
  10. package/dist/chat/logic/timeline.js +1 -1
  11. package/dist/chat/logic/visual-height.d.ts +4 -0
  12. package/dist/chat/logic/visual-height.js +32 -0
  13. package/dist/chat/ui/Scrubber.js +1 -1
  14. package/dist/chat/ui/Timeline.js +8 -3
  15. package/dist/chat/ui/UnifiedDiff.js +2 -2
  16. package/dist/chat/ui/VisualDialogs.d.ts +3 -0
  17. package/dist/chat/ui/VisualDialogs.js +32 -0
  18. package/dist/chat/ui/VisualFrame.d.ts +8 -0
  19. package/dist/chat/ui/VisualFrame.js +95 -0
  20. package/dist/chat/ui/rows/Rows.js +3 -0
  21. package/dist/chat/ui/rows/VisualRow.d.ts +5 -0
  22. package/dist/chat/ui/rows/VisualRow.js +23 -0
  23. package/dist/chat/ui/rows/WorkRows.js +4 -4
  24. package/dist/chat/ui/visual-theme.d.ts +10 -0
  25. package/dist/chat/ui/visual-theme.js +172 -0
  26. package/dist/chat/visual-bridge.d.ts +37 -0
  27. package/dist/chat/visual-bridge.js +129 -0
  28. package/dist/chat/visuals.d.ts +9 -0
  29. package/dist/chat/visuals.js +8 -0
  30. package/dist/host.d.ts +5 -0
  31. package/dist/host.js +2 -1
  32. package/dist/locales/en/agent-chat.json +12 -0
  33. package/dist/locales/nl/agent-chat.json +12 -0
  34. package/dist/state/chats.d.ts +4 -1
  35. package/dist/state/chats.js +14 -4
  36. package/dist/theme.css +32 -1
  37. package/package.json +38 -3
  38. package/src/chat/chat-client.ts +10 -1
  39. package/src/chat/logic/ansi.ts +2 -2
  40. package/src/chat/logic/fork.ts +2 -0
  41. package/src/chat/logic/thread-cards.ts +46 -13
  42. package/src/chat/logic/timeline.ts +5 -2
  43. package/src/chat/logic/visual-height.ts +38 -0
  44. package/src/chat/ui/Scrubber.tsx +1 -4
  45. package/src/chat/ui/Timeline.tsx +11 -2
  46. package/src/chat/ui/UnifiedDiff.tsx +2 -2
  47. package/src/chat/ui/VisualDialogs.tsx +64 -0
  48. package/src/chat/ui/VisualFrame.tsx +136 -0
  49. package/src/chat/ui/rows/Rows.tsx +3 -0
  50. package/src/chat/ui/rows/VisualRow.tsx +47 -0
  51. package/src/chat/ui/rows/WorkRows.tsx +6 -6
  52. package/src/chat/ui/visual-theme.ts +197 -0
  53. package/src/chat/visual-bridge.ts +176 -0
  54. package/src/chat/visuals.ts +22 -0
  55. package/src/host.ts +12 -1
  56. package/src/locales/en/agent-chat.json +12 -0
  57. package/src/locales/nl/agent-chat.json +12 -0
  58. package/src/state/chats.ts +19 -5
  59. package/src/theme.css +32 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.17.0-beta.1",
3
+ "version": "0.17.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -184,6 +184,11 @@
184
184
  "types": "./dist/chat/logic/tools.d.ts",
185
185
  "default": "./dist/chat/logic/tools.js"
186
186
  },
187
+ "./chat/logic/visual-height": {
188
+ "source": "./src/chat/logic/visual-height.ts",
189
+ "types": "./dist/chat/logic/visual-height.d.ts",
190
+ "default": "./dist/chat/logic/visual-height.js"
191
+ },
187
192
  "./chat/logic/welcome": {
188
193
  "source": "./src/chat/logic/welcome.ts",
189
194
  "types": "./dist/chat/logic/welcome.d.ts",
@@ -269,6 +274,16 @@
269
274
  "types": "./dist/chat/timeline-scroll.d.ts",
270
275
  "default": "./dist/chat/timeline-scroll.js"
271
276
  },
277
+ "./chat/visual-bridge": {
278
+ "source": "./src/chat/visual-bridge.ts",
279
+ "types": "./dist/chat/visual-bridge.d.ts",
280
+ "default": "./dist/chat/visual-bridge.js"
281
+ },
282
+ "./chat/visuals": {
283
+ "source": "./src/chat/visuals.ts",
284
+ "types": "./dist/chat/visuals.d.ts",
285
+ "default": "./dist/chat/visuals.js"
286
+ },
272
287
  "./chat/ui/AccountPill": {
273
288
  "source": "./src/chat/ui/AccountPill.tsx",
274
289
  "types": "./dist/chat/ui/AccountPill.d.ts",
@@ -419,6 +434,16 @@
419
434
  "types": "./dist/chat/ui/UploadThumb.d.ts",
420
435
  "default": "./dist/chat/ui/UploadThumb.js"
421
436
  },
437
+ "./chat/ui/VisualDialogs": {
438
+ "source": "./src/chat/ui/VisualDialogs.tsx",
439
+ "types": "./dist/chat/ui/VisualDialogs.d.ts",
440
+ "default": "./dist/chat/ui/VisualDialogs.js"
441
+ },
442
+ "./chat/ui/VisualFrame": {
443
+ "source": "./src/chat/ui/VisualFrame.tsx",
444
+ "types": "./dist/chat/ui/VisualFrame.d.ts",
445
+ "default": "./dist/chat/ui/VisualFrame.js"
446
+ },
422
447
  "./chat/ui/Welcome": {
423
448
  "source": "./src/chat/ui/Welcome.tsx",
424
449
  "types": "./dist/chat/ui/Welcome.d.ts",
@@ -549,6 +574,11 @@
549
574
  "types": "./dist/chat/ui/rows/TaskRow.d.ts",
550
575
  "default": "./dist/chat/ui/rows/TaskRow.js"
551
576
  },
577
+ "./chat/ui/rows/VisualRow": {
578
+ "source": "./src/chat/ui/rows/VisualRow.tsx",
579
+ "types": "./dist/chat/ui/rows/VisualRow.d.ts",
580
+ "default": "./dist/chat/ui/rows/VisualRow.js"
581
+ },
552
582
  "./chat/ui/rows/WorkRows": {
553
583
  "source": "./src/chat/ui/rows/WorkRows.tsx",
554
584
  "types": "./dist/chat/ui/rows/WorkRows.d.ts",
@@ -569,6 +599,11 @@
569
599
  "types": "./dist/chat/ui/useToggleSet.d.ts",
570
600
  "default": "./dist/chat/ui/useToggleSet.js"
571
601
  },
602
+ "./chat/ui/visual-theme": {
603
+ "source": "./src/chat/ui/visual-theme.ts",
604
+ "types": "./dist/chat/ui/visual-theme.d.ts",
605
+ "default": "./dist/chat/ui/visual-theme.js"
606
+ },
572
607
  "./host": {
573
608
  "source": "./src/host.ts",
574
609
  "types": "./dist/host.d.ts",
@@ -848,7 +883,7 @@
848
883
  "@lezer/highlight": "^1.2.3",
849
884
  "@lezer/markdown": "^1.7.2",
850
885
  "@pierre/diffs": "^1.4.3",
851
- "@adecore/agent-contracts": "0.17.0-beta.1",
886
+ "@adecore/agent-contracts": "0.17.0",
852
887
  "@tanstack/react-virtual": "^3.14.13",
853
888
  "clsx": "^2.1.1",
854
889
  "lucide-react": "^1.47.0",
@@ -861,7 +896,7 @@
861
896
  "zustand": "^5.0.15"
862
897
  },
863
898
  "peerDependencies": {
864
- "@adecore/ui": "^0.17.0-beta.1",
899
+ "@adecore/ui": "^0.17.0",
865
900
  "i18next": "^26.4.2",
866
901
  "react": "^19.3.0",
867
902
  "react-dom": "^19.3.0",
@@ -5,6 +5,7 @@ import type {
5
5
  ChatBookmark,
6
6
  ChatPreferencesPayload,
7
7
  ChatSkill,
8
+ ChatVisual,
8
9
  ModelSelection,
9
10
  ProviderInfo,
10
11
  RuntimeMode
@@ -78,6 +79,7 @@ export class ChatClient {
78
79
  // Every chat on the machine, attached or not, so a node waiting on a person says so on a view nobody has open.
79
80
  transport.on('chat.status', ({ chatId, info }) => this.sink.status(chatId, info)),
80
81
  transport.on('chat.bookmarks', ({ chatId, bookmarks }) => this.sink.bookmarks(chatId, bookmarks)),
82
+ transport.on('chat.visuals', ({ chatId, visuals }) => this.sink.visuals(chatId, visuals)),
81
83
  transport.subscribeStatus((status) => this.onStatus(status))
82
84
  );
83
85
  if (transport.status === 'open') {
@@ -192,6 +194,12 @@ export class ChatClient {
192
194
  return bookmarks;
193
195
  }
194
196
 
197
+ /* Takes a visual and its page out of the chat for good; the list every client of the chat holds comes back as `chat.visuals`. */
198
+ async removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]> {
199
+ const { visuals } = await this.transport.request('chat.removeVisual', { chatId, visualId });
200
+ return visuals;
201
+ }
202
+
195
203
  /* What this chat's CLI would run as a skill, for the composer's `$` picker. */
196
204
  async listSkills(chatId: string): Promise<ChatSkill[]> {
197
205
  const { skills } = await this.transport.request('skills.list', { chatId });
@@ -321,8 +329,9 @@ export class ChatClient {
321
329
  const page = result.history === undefined ? undefined : { history: result.history, pending: result.pending ?? [] };
322
330
  this.sink.reset(chatId, result.info, result.items, page);
323
331
  }
324
- // A host without bookmarks sends none, and then this chat has none.
332
+ // A host without bookmarks or visuals sends none, and then this chat has none.
325
333
  this.sink.bookmarks(chatId, result.bookmarks ?? []);
334
+ this.sink.visuals(chatId, result.visuals ?? []);
326
335
  }
327
336
 
328
337
  private sendPreferences(): void {
@@ -144,8 +144,8 @@ function applyRendition(state: AnsiState, parameters: string): AnsiState {
144
144
 
145
145
  function styleOf(state: AnsiState): CSSProperties {
146
146
  const style: CSSProperties = {};
147
- const color = state.inverse ? (state.background ?? 'var(--term-bg)') : state.foreground;
148
- const background = state.inverse ? (state.foreground ?? 'var(--term-fg)') : state.background;
147
+ const color = state.inverse ? (state.background ?? 'var(--chat-code-bg)') : state.foreground;
148
+ const background = state.inverse ? (state.foreground ?? 'var(--chat-output)') : state.background;
149
149
  if (color !== undefined || state.dim) {
150
150
  style.color = state.dim ? `color-mix(in srgb, ${color ?? 'currentColor'} 65%, transparent)` : color;
151
151
  }
@@ -128,6 +128,8 @@ export function turnIdOfRow(row: TimelineRow): string | null {
128
128
  return row.tool.turnId;
129
129
  case 'work-group':
130
130
  return row.tools[0]?.turnId ?? null;
131
+ case 'visual':
132
+ return row.visual.turnId ?? null;
131
133
  case 'user':
132
134
  case 'assistant':
133
135
  case 'thinking':
@@ -1,10 +1,18 @@
1
+ import type { ChatVisual } from '@adecore/agent-contracts';
1
2
  import type { ThreadCard } from '../../host';
2
3
  import type { TimelineRow } from './timeline';
3
4
 
5
+ /* A row that belongs to a moment rather than to an item of the thread: an app's card, or a visual an agent published. */
6
+ export interface TimedRow {
7
+ /* In milliseconds on the clock of a chat item's `createdAt`. */
8
+ at: number;
9
+ row: TimelineRow;
10
+ }
11
+
4
12
  /*
5
13
  * When what a row shows began, or null for a row that only follows the one before it: a turn's
6
- * changed files and forks, a note, a report. The working row closes the thread, so every card goes
7
- * above it.
14
+ * changed files and forks, a note, a report. The working row closes the thread, so every timed row
15
+ * goes above it.
8
16
  */
9
17
  function rowTime(row: TimelineRow): number | null {
10
18
  switch (row.kind) {
@@ -32,33 +40,58 @@ function rowTime(row: TimelineRow): number | null {
32
40
  }
33
41
 
34
42
  /* Prefixed, so a card never shares a key with a row of the chat's own. */
35
- function cardRow(card: ThreadCard): TimelineRow {
36
- return { kind: 'app-card', id: `app-card-${card.id}`, card };
43
+ export function cardRows(cards: readonly ThreadCard[]): TimedRow[] {
44
+ return cards.map((card) => ({ at: card.at, row: { kind: 'app-card', id: `app-card-${card.id}`, card } }));
37
45
  }
38
46
 
39
47
  /*
40
- * The thread's rows with an app's cards among them: a card goes right before the first row that
41
- * began after it, so in a folded turn it lands under the fold, and in an open one between its calls.
42
- * Without cards the rows come back as they are, the same array.
48
+ * The visuals as rows, keyed apart from the chat's own rows. `heldFrom` is when the first item this
49
+ * client holds began, while there is a page before it: a visual from before then belongs to that
50
+ * page and waits for it, rather than stacking up at the top of the thread.
43
51
  */
44
- export function withThreadCards(rows: TimelineRow[], cards: readonly ThreadCard[]): TimelineRow[] {
45
- if (cards.length === 0) {
52
+ export function visualRows(visuals: readonly ChatVisual[], heldFrom: number | null = null): TimedRow[] {
53
+ return visuals
54
+ .filter((visual) => heldFrom === null || visual.at >= heldFrom)
55
+ .map((visual) => ({ at: visual.at, row: { kind: 'visual', id: `visual-${visual.id}`, visual } }));
56
+ }
57
+
58
+ /*
59
+ * The thread's rows with timed rows among them: each goes right before the first row that began after
60
+ * it, so in a folded turn it lands under the fold, above the answer that closed the turn, and in an
61
+ * open one between its calls. Rows of the same moment keep the order they were handed in. Without
62
+ * timed rows the rows come back as they are, the same array.
63
+ */
64
+ export function withTimedRows(rows: TimelineRow[], timed: readonly TimedRow[]): TimelineRow[] {
65
+ if (timed.length === 0) {
46
66
  return rows;
47
67
  }
48
- // Sorting is stable, so cards of the same moment keep the order the app gave them.
49
- const pending = [...cards].sort((first, second) => first.at - second.at);
68
+ // Sorting is stable, which is what keeps rows of the same moment in the order they came.
69
+ const pending = [...timed].sort((first, second) => first.at - second.at);
50
70
  const merged: TimelineRow[] = [];
51
71
  let next = 0;
52
72
  for (const row of rows) {
53
73
  const time = rowTime(row);
54
74
  while (time !== null && next < pending.length && pending[next]!.at < time) {
55
- merged.push(cardRow(pending[next]!));
75
+ merged.push(pending[next]!.row);
56
76
  next += 1;
57
77
  }
58
78
  merged.push(row);
59
79
  }
60
80
  for (; next < pending.length; next++) {
61
- merged.push(cardRow(pending[next]!));
81
+ merged.push(pending[next]!.row);
62
82
  }
63
83
  return merged;
64
84
  }
85
+
86
+ /*
87
+ * What goes among a thread's rows by its moment: the visuals, then the app's cards, so a visual comes
88
+ * first among rows of the same moment. `visuals` is null where the host draws none.
89
+ */
90
+ export function timedRowsOf(cards: readonly ThreadCard[], visuals: readonly ChatVisual[] | null, heldFrom: number | null = null): TimedRow[] {
91
+ return [...(visuals === null ? [] : visualRows(visuals, heldFrom)), ...cardRows(cards)];
92
+ }
93
+
94
+ /* The thread's rows with an app's cards among them, the way `withTimedRows` places them. */
95
+ export function withThreadCards(rows: TimelineRow[], cards: readonly ThreadCard[]): TimelineRow[] {
96
+ return withTimedRows(rows, cardRows(cards));
97
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  ChatToolItem,
11
11
  ChatTurnItem,
12
12
  ChatUserItem,
13
+ ChatVisual,
13
14
  ChatWorkflow
14
15
  } from '@adecore/agent-contracts';
15
16
  import { abortedByMachine } from '@adecore/agent-contracts';
@@ -55,7 +56,9 @@ export type TimelineRow =
55
56
  | { kind: 'forks'; id: string; turnId: string }
56
57
  | { kind: 'working'; id: string; startedAt: number }
57
58
  // A card of the app's own (`thread-cards.ts`), never derived from an item.
58
- | { kind: 'app-card'; id: string; card: ThreadCard };
59
+ | { kind: 'app-card'; id: string; card: ThreadCard }
60
+ // A page an agent published, placed by when it was published (`thread-cards.ts`), never derived from an item.
61
+ | { kind: 'visual'; id: string; visual: ChatVisual };
59
62
 
60
63
  interface TimelineOptions {
61
64
  expandedGroups: ReadonlySet<string>;
@@ -71,7 +74,7 @@ interface TimelineOptions {
71
74
  * against each other; prose and cards are blocks and need room around them. Where the two meet,
72
75
  * the block gap marks the seam, so an answer never looks glued to the call above it.
73
76
  */
74
- const BLOCK_KINDS = new Set<TimelineRow['kind']>(['assistant', 'report', 'thinking', 'changed-files', 'compaction', 'app-card']);
77
+ const BLOCK_KINDS = new Set<TimelineRow['kind']>(['assistant', 'report', 'thinking', 'changed-files', 'compaction', 'app-card', 'visual']);
75
78
 
76
79
  export function isBlock(row: TimelineRow): boolean {
77
80
  return BLOCK_KINDS.has(row.kind);
@@ -0,0 +1,38 @@
1
+ import { VISUAL_LIMITS, visualFrameHeight, type ChatVisual } from '@adecore/agent-contracts/visual';
2
+
3
+ /* A frame before anything measured its page: room for a chart, never taller than the page may grow. */
4
+ const DEFAULT_HEIGHT_PX = 240;
5
+ /* Plenty for the visuals of a long session; the oldest goes first. */
6
+ const REMEMBERED_HEIGHTS = 500;
7
+
8
+ /*
9
+ * The heights frames reported, by visual and width. The thread draws only the rows in view, so a row
10
+ * that scrolls back in mounts a new frame, which starts at the height its page had there before.
11
+ */
12
+ const remembered = new Map<string, number>();
13
+
14
+ function keyOf(visualId: string, width: number): string {
15
+ return `${visualId}@${Math.round(width)}`;
16
+ }
17
+
18
+ /* A height a page reported, held to the visual's own maximum and the limits, in whole pixels. */
19
+ export function clampVisualHeight(visual: Pick<ChatVisual, 'maxHeight'>, height: number): number {
20
+ return Math.min(Math.max(Math.ceil(Math.min(height, visual.maxHeight)), VISUAL_LIMITS.minHeight), VISUAL_LIMITS.maxHeight);
21
+ }
22
+
23
+ /*
24
+ * The height a frame `width` CSS pixels wide starts at: what the page reported in a frame that wide
25
+ * before, else what the host measured, else a modest default.
26
+ */
27
+ export function initialVisualHeight(visual: Pick<ChatVisual, 'id' | 'maxHeight' | 'heights'>, width: number): number {
28
+ return remembered.get(keyOf(visual.id, width)) ?? visualFrameHeight(visual, width) ?? clampVisualHeight(visual, DEFAULT_HEIGHT_PX);
29
+ }
30
+
31
+ export function rememberVisualHeight(visualId: string, width: number, height: number): void {
32
+ const key = keyOf(visualId, width);
33
+ remembered.delete(key);
34
+ remembered.set(key, height);
35
+ if (remembered.size > REMEMBERED_HEIGHTS) {
36
+ remembered.delete(remembered.keys().next().value!);
37
+ }
38
+ }
@@ -198,10 +198,7 @@ export const Scrubber = memo(function Scrubber({
198
198
  {slot.found && (
199
199
  <span
200
200
  aria-hidden
201
- className={clsx(
202
- 'absolute right-0 rounded-full bg-find-current',
203
- index === currentSlot ? 'opacity-100' : 'opacity-50'
204
- )}
201
+ className={clsx('absolute right-0 rounded-full bg-chat-find', index === currentSlot ? 'opacity-100' : 'opacity-50')}
205
202
  style={{ top: slot.y, height: TICK_HEIGHT_PX, width: FOUND_WIDTH_PX }}
206
203
  />
207
204
  )}
@@ -7,7 +7,7 @@ import type { ChatBookmark } from '@adecore/agent-contracts';
7
7
  import { useForkedTurns } from '../forks';
8
8
  import { bookmarkRows } from '../logic/bookmarks';
9
9
  import { deriveTimelineRows, findSubagentBranch, type TimelineRow } from '../logic/timeline';
10
- import { withThreadCards } from '../logic/thread-cards';
10
+ import { timedRowsOf, withTimedRows } from '../logic/thread-cards';
11
11
  import { openFromMain, useSubagentTrail, type SubagentStep } from '../subagent-view';
12
12
  import {
13
13
  registerItemJumper,
@@ -38,6 +38,7 @@ import { BookmarkMarker } from './BookmarkMarker';
38
38
  import { MessageActions } from './MessageActions';
39
39
  import { Scrubber, type CardChat } from './Scrubber';
40
40
  import { TimelineMenuPopup } from './TimelineMenu';
41
+ import { VisualDialogs } from './VisualDialogs';
41
42
  import { FOLLOW_THRESHOLD_PX, replyHeader, rowRhythm } from './rows/row-rhythm';
42
43
  import { QuoteButton } from './QuoteButton';
43
44
  import { QuoteTakerContext, type QuoteTaker } from './quote-selection';
@@ -123,6 +124,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
123
124
  const items = useChatRow(chatId, (row) => row?.structure);
124
125
  const activeTurnId = useChatRow(chatId, (row) => row?.info.activeTurnId ?? null);
125
126
  const bookmarks = useChatRow(chatId, (row) => row?.bookmarks);
127
+ const visuals = useChatRow(chatId, (row) => row?.visuals);
126
128
  const forkedTurns = useForkedTurns(chatId);
127
129
  const info = useChatRow(chatId, (row) => row?.info ?? null);
128
130
  const cursor = useChatRow(chatId, (row) => row?.history?.cursor ?? null);
@@ -188,7 +190,13 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
188
190
  );
189
191
  }, [order, items, groups.ids, turns.ids, subagents.ids, activeTurnId, forkedTurns]);
190
192
  const cards = chatHost().useThreadCards(scopeId, chatId);
191
- const rows = useMemo(() => withThreadCards(threadRows, cards), [threadRows, cards]);
193
+ const drawsVisuals = chatHost().visuals !== null;
194
+ // Where the part of the thread this client holds begins, while there is a page before it.
195
+ const heldFrom = cursor === null ? null : (items?.[order?.[0] ?? '']?.createdAt ?? null);
196
+ const rows = useMemo(
197
+ () => withTimedRows(threadRows, timedRowsOf(cards, drawsVisuals ? (visuals ?? []) : null, heldFrom)),
198
+ [threadRows, cards, drawsVisuals, visuals, heldFrom]
199
+ );
192
200
  const welcomes = chatHost().useWelcome(chatId);
193
201
 
194
202
  const empty = rows.length === 0;
@@ -596,6 +604,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
596
604
  </div>
597
605
  <TimelineMenuPopup target={target} thread={threadRef} chatId={onMainAgent ? chatId : null} />
598
606
  </ContextMenu.Root>
607
+ {drawsVisuals && <VisualDialogs chatId={chatId} />}
599
608
  {loadingEarlier && !overlay && (
600
609
  <div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center" role="status">
601
610
  <Icon icon={LoaderCircle} size={16} className="animate-spin text-text-faint" />
@@ -35,10 +35,10 @@ pre > [data-code] { flex-grow: 1; align-self: stretch; align-content: start; }
35
35
 
36
36
  function lineClass(line: string): string {
37
37
  if (line.startsWith('+')) {
38
- return 'text-term-green';
38
+ return 'text-chat-added';
39
39
  }
40
40
  if (line.startsWith('-')) {
41
- return 'text-term-red';
41
+ return 'text-chat-removed';
42
42
  }
43
43
  return 'text-text-muted';
44
44
  }
@@ -0,0 +1,64 @@
1
+ import { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import type { ChatVisual } from '@adecore/agent-contracts';
4
+ import { CloseButton, Dialog, ErrorBoundary, PromptDialog } from '@adecore/ui';
5
+ import { useChatScope } from '../../scope';
6
+ import { useChatRow } from '../../state/chats';
7
+ import { useVisualDialog } from '../visuals';
8
+ import { VisualFrame } from './VisualFrame';
9
+
10
+ /*
11
+ * The large view of one of the chat's visuals and the question before one goes, opened from its row.
12
+ * A visual that goes while either is up closes it. Removing cannot be undone: the page is deleted.
13
+ */
14
+ export function VisualDialogs({ chatId }: { chatId: string }) {
15
+ const { t } = useTranslation('agent-chat');
16
+ const scope = useChatScope();
17
+ const key = scope.keyOf(chatId);
18
+ const kind = useVisualDialog((s) => (s.chatKey === key ? s.kind : null));
19
+ const visualId = useVisualDialog((s) => (s.chatKey === key ? s.visualId : null));
20
+ const visual = useChatRow(chatId, (row) => (visualId === null ? null : (row?.visuals?.find((candidate) => candidate.id === visualId) ?? null)));
21
+ // The dialog fades out after it closes, and draws what it showed until then.
22
+ const [shown, setShown] = useState<ChatVisual | null>(visual);
23
+ if (visual !== null && visual !== shown) {
24
+ setShown(visual);
25
+ }
26
+ const close = (): void => useVisualDialog.getState().close();
27
+
28
+ return (
29
+ <>
30
+ <Dialog.Root open={kind === 'expand' && visual !== null} onOpenChange={(next) => !next && close()}>
31
+ <Dialog.Popup className="flex h-[calc(100dvh-96px)] w-[1200px] flex-col overflow-hidden">
32
+ <div className="flex h-11 shrink-0 items-center gap-2 border-b border-border pr-2 pl-4">
33
+ <Dialog.Title className="min-w-0 grow truncate">{shown?.title}</Dialog.Title>
34
+ <CloseButton label={t('common.action.close')} kbd="esc" dialog />
35
+ </div>
36
+ <div className="min-h-0 grow p-5">
37
+ <div className="relative size-full">
38
+ {shown !== null && (
39
+ <ErrorBoundary label={t('visuals.renderFailed')} resetKeys={[shown]}>
40
+ <VisualFrame chatId={chatId} visual={shown} fill />
41
+ </ErrorBoundary>
42
+ )}
43
+ </div>
44
+ </div>
45
+ </Dialog.Popup>
46
+ </Dialog.Root>
47
+ <PromptDialog
48
+ open={kind === 'remove' && visual !== null}
49
+ title={t('visuals.removeTitle')}
50
+ description={t('visuals.removeDescription', { title: shown?.title ?? '' })}
51
+ confirmLabel={t('common.action.remove')}
52
+ danger
53
+ fallbackMessage={t('visuals.removeFailed')}
54
+ onConfirm={async () => {
55
+ if (visualId !== null) {
56
+ await scope.chats.removeVisual(chatId, visualId);
57
+ }
58
+ close();
59
+ }}
60
+ onOpenChange={close}
61
+ />
62
+ </>
63
+ );
64
+ }
@@ -0,0 +1,136 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import clsx from 'clsx';
4
+ import { visualThemeFragment, type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
5
+ import { Spinner, useMeasuredWidth } from '@adecore/ui';
6
+ import { chatHost } from '../../host';
7
+ import { useChatScope } from '../../scope';
8
+ import { initialVisualHeight, rememberVisualHeight } from '../logic/visual-height';
9
+ import { VisualBridge } from '../visual-bridge';
10
+ import { useVisualTheme } from './visual-theme';
11
+
12
+ export interface VisualFrameProps {
13
+ chatId: string;
14
+ visual: ChatVisual;
15
+ /* Fills its positioned parent and lets the page scroll inside it, rather than taking the height the page reports. */
16
+ fill?: boolean;
17
+ className?: string;
18
+ }
19
+
20
+ /*
21
+ * A link the page asks to open counts only while a person acts in the frame: it has the focus, and a
22
+ * click or a key in it is recent. A page can take the focus by script, so this stops a link opened as
23
+ * the page loads, and not one opened while a person works in the frame.
24
+ */
25
+ function personActsIn(frame: HTMLIFrameElement): boolean {
26
+ return frame.ownerDocument.activeElement === frame && navigator.userActivation?.isActive !== false;
27
+ }
28
+
29
+ /*
30
+ * One visual's page in a frame on the thread's own ground. The frame loads the app's sandbox host
31
+ * page and the bridge hands it the page and the theme. The frame never gets `allow-same-origin`,
32
+ * which keeps the page on an opaque origin even when the host page is served on the app's own. Its
33
+ * box keeps one height while the page loads and when it fails, so nothing under it moves.
34
+ */
35
+ export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
36
+ const { t } = useTranslation('agent-chat');
37
+ const scope = useChatScope();
38
+ const frameUrl = chatHost().visuals?.frameUrl ?? null;
39
+ const boxRef = useRef<HTMLDivElement | null>(null);
40
+ const frameRef = useRef<HTMLIFrameElement>(null);
41
+ const bridgeRef = useRef<VisualBridge | null>(null);
42
+ const [measure, width] = useMeasuredWidth();
43
+ const setBox = useCallback(
44
+ (node: HTMLDivElement | null) => {
45
+ boxRef.current = node;
46
+ measure(node);
47
+ },
48
+ [measure]
49
+ );
50
+ const theme = useVisualTheme(boxRef);
51
+ const widthRef = useRef(width);
52
+ const [reported, setReported] = useState<number | null>(null);
53
+ // The appearance the page is drawn in once it runs; until then the frame matches the sandbox host page, which declares none.
54
+ const [drawn, setDrawn] = useState<VisualAppearance | null>(null);
55
+ const [failed, setFailed] = useState(false);
56
+ // The frame keeps the address it opened with: a later theme goes over the bridge, since a new address would load the page again.
57
+ const [opening, setOpening] = useState<{ src: string; theme: VisualTheme } | null>(null);
58
+ if (opening === null && frameUrl !== null && theme !== null && width > 0) {
59
+ setOpening({ src: frameUrl + visualThemeFragment(theme), theme });
60
+ }
61
+ const height = fill || width === 0 ? undefined : (reported ?? initialVisualHeight(visual, width));
62
+
63
+ useLayoutEffect(() => {
64
+ widthRef.current = width;
65
+ }, [width]);
66
+
67
+ useLayoutEffect(() => {
68
+ const frame = frameRef.current;
69
+ if (opening === null || frame === null) {
70
+ return;
71
+ }
72
+ const bridge = new VisualBridge({
73
+ frame: () => frame.contentWindow,
74
+ visual: { maxHeight: visual.maxHeight },
75
+ page: chatHost()
76
+ .attachments.read(scope.id, chatId, visual.id)
77
+ .then((blob) => blob.text()),
78
+ theme: opening.theme,
79
+ mayOpenLink: () => personActsIn(frame),
80
+ openLink: (url) => chatHost().visuals?.openLink(url),
81
+ onHeight: (next) => {
82
+ if (fill) {
83
+ return;
84
+ }
85
+ setReported(next);
86
+ rememberVisualHeight(visual.id, widthRef.current, next);
87
+ },
88
+ onAppearance: setDrawn,
89
+ onFailure: () => setFailed(true)
90
+ });
91
+ bridgeRef.current = bridge;
92
+ const view = frame.ownerDocument.defaultView ?? window;
93
+ const listen = (event: MessageEvent): void => bridge.receive(event);
94
+ view.addEventListener('message', listen);
95
+ return () => {
96
+ view.removeEventListener('message', listen);
97
+ bridge.dispose();
98
+ bridgeRef.current = null;
99
+ };
100
+ }, [opening, scope.id, chatId, visual.id, visual.maxHeight, fill]);
101
+
102
+ useEffect(() => {
103
+ if (theme !== null) {
104
+ bridgeRef.current?.setTheme(theme);
105
+ }
106
+ }, [theme]);
107
+
108
+ return (
109
+ <div ref={setBox} className={clsx(fill ? 'absolute inset-0' : 'relative w-full', className)} style={{ height }}>
110
+ {opening !== null && (
111
+ <iframe
112
+ ref={frameRef}
113
+ src={opening.src}
114
+ title={visual.title}
115
+ loading="lazy"
116
+ sandbox="allow-scripts allow-forms"
117
+ className={clsx('block size-full border-0', failed && 'invisible')}
118
+ // A frame whose color scheme differs from its document's paints an opaque ground behind it.
119
+ style={{ colorScheme: drawn ?? 'normal' }}
120
+ onLoad={() => bridgeRef.current?.loaded()}
121
+ />
122
+ )}
123
+ {failed ? (
124
+ <p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">
125
+ {t('visuals.failed', { title: visual.title })}
126
+ </p>
127
+ ) : (
128
+ drawn === null && (
129
+ <span className="chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint">
130
+ <Spinner size={16} label={t('visuals.loading', { title: visual.title })} />
131
+ </span>
132
+ )
133
+ )}
134
+ </div>
135
+ );
136
+ }
@@ -6,6 +6,7 @@ import type { SubagentStep } from '../../subagent-view';
6
6
  import { AgentTurnRow, ApprovalHistoryRow, AssistantRow, CompactionRow, NoteRow, QuestionHistoryRow, ReportRow, ThinkingRow, UserRow } from './MessageRows';
7
7
  import { ForksRow } from './ForksRow';
8
8
  import { SubagentBranchRow } from './SubagentRow';
9
+ import { VisualRow } from './VisualRow';
9
10
  import { WorkflowRow } from './WorkflowRow';
10
11
  import { ChangedFilesRow, TurnFoldRow, WorkGroupRow, WorkLiveRow, WorkRow, WorkingRow } from './WorkRows';
11
12
 
@@ -77,5 +78,7 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
77
78
  return <WorkingRow startedAt={row.startedAt} />;
78
79
  case 'app-card':
79
80
  return <AppCardRow card={row.card} />;
81
+ case 'visual':
82
+ return <VisualRow chatId={chatId} visual={row.visual} />;
80
83
  }
81
84
  }