@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/LICENSE CHANGED
@@ -6,7 +6,7 @@ FSL-1.1-MIT
6
6
 
7
7
  ## Notice
8
8
 
9
- Copyright 2026 Bas Milius
9
+ Copyright 2026 - present Bas Milius
10
10
 
11
11
  ## Terms and Conditions
12
12
 
package/README.md CHANGED
@@ -1,53 +1,61 @@
1
1
  # @adecore/agents-react
2
2
 
3
- React agent chat threads, a CodeMirror composer, approvals/questions, provider account settings, and usage views. The host supplies a typed connection and adapters for files, permissions, login, task state, and navigation. Backend provider execution stays outside the browser.
3
+ [![npm](https://img.shields.io/npm/v/@adecore/agents-react)](https://www.npmjs.com/package/@adecore/agents-react)
4
+ [![Docs](https://img.shields.io/badge/docs-adecore.dev-blue)](https://adecore.dev/agents-react/)
4
5
 
5
- Import per module. There is no root barrel:
6
+ React views for agent chats: the thread, the composer, approvals and questions, the agent CLIs and their accounts, and what they cost. They talk to a host of chats, such as [`@adecore/agents`](https://adecore.dev/agents/), over the requests of [`@adecore/agent-contracts`](https://adecore.dev/agent-contracts/). Built on React 19, [`@adecore/ui`](https://adecore.dev/ui/) and Tailwind 4.
6
7
 
7
- ```tsx
8
- import { Composer } from '@adecore/agents-react/chat/ui/Composer';
9
- import { Timeline } from '@adecore/agents-react/chat/ui/Timeline';
10
- import { ChatClient } from '@adecore/agents-react/chat/chat-client';
11
- import { portTransport } from '@adecore/agents-react/port-transport';
8
+ **[Documentation with live demos](https://adecore.dev/agents-react/)**
9
+
10
+ ## Install
12
11
 
13
- export const agentViews = { Composer, Timeline, ChatClient, portTransport };
12
+ ```sh
13
+ bun add @adecore/agents-react @adecore/agent-contracts
14
14
  ```
15
15
 
16
- The host brings React/React DOM 19, i18next, react-i18next, and `@adecore/ui` as peers. The package depends on `@adecore/agent-contracts` and uses Zustand, CodeMirror, Shiki, and the diff library. Consult the manifest for exact peer ranges. Import `@adecore/ui/theme.css` before `@adecore/agents-react/theme.css`; use Tailwind 4, scan linked `src` or compiled `dist`, and load the typography plugin. Terminal/find colors need host tokens and utility mappings.
16
+ `@adecore/ui`, React 19, `react-dom`, `i18next` and `react-i18next` are peer dependencies.
17
17
 
18
- Configure `setChatHost({ storage })` before any persistent store use. A `ChatScopeContext` supplies one stable transport/client per backend. The host singleton and persistence configuration are shared across scopes. Use unique chat IDs across scopes because drafts use raw IDs. `portTransport.close()` rejects waiting requests and stops frame listening; the owner also closes its native port. `ChatClient.dispose()` releases subscriptions and detaches views without stopping agents.
18
+ ## Set up
19
19
 
20
- ## Documentation
20
+ Import the theme after the one of `@adecore/ui`, let Tailwind scan the package, and load the typography plugin. The `@source` path is relative to the CSS file.
21
21
 
22
- - [Overview](../../docs/agents-react/index.md)
23
- - [Installation and setup](../../docs/agents-react/getting-started.md)
24
- - [Host adapters](../../docs/agents-react/host.md)
25
- - [Chat state and lifecycle](../../docs/agents-react/chat-lifecycle.md)
26
- - [Composer](../../docs/agents-react/composer.md)
27
- - [Attachments and mentions](../../docs/agents-react/attachments.md)
28
- - [Messages and timeline](../../docs/agents-react/timeline.md)
29
- - [Approvals and tasks](../../docs/agents-react/approvals-tasks.md)
30
- - [Accounts and models](../../docs/agents-react/accounts-models.md)
31
- - [Usage](../../docs/agents-react/usage.md)
32
- - [Persistence](../../docs/agents-react/persistence.md)
33
- - [CSS, theme, and translations](../../docs/agents-react/styling.md)
34
- - [Testing](../../docs/agents-react/testing.md)
35
- - [Migration](../../docs/agents-react/migration.md)
36
- - [Troubleshooting](../../docs/agents-react/troubleshooting.md)
37
- - [Runtime reference](../../docs/agents-react/reference-runtime.md)
38
- - [Chat reference](../../docs/agents-react/reference-chat.md)
39
- - [Settings reference](../../docs/agents-react/reference-settings.md)
22
+ ```css
23
+ @import "tailwindcss";
24
+ @import "@adecore/ui/theme.css";
25
+ @import "@adecore/agents-react/theme.css";
26
+ @plugin "@tailwindcss/typography";
27
+ @source "../node_modules/@adecore/agents-react/dist";
28
+ ```
40
29
 
41
- The reference pages group all preserved module subpaths, including lower-level renderers, prompt/session helpers, account forms, and usage formatters. `DiffPool`, `EditDiff`, and `UnifiedDiff` use default exports.
30
+ Add the words of `AGENTS_LOCALES` to your i18next, call `setChatHost` once, and render the views in a `ChatScopeContext`:
42
31
 
43
- ## Compatibility and verification
32
+ ```tsx
33
+ import { Composer } from '@adecore/agents-react/chat/ui/Composer';
34
+ import { Timeline } from '@adecore/agents-react/chat/ui/Timeline';
35
+ import { ChatScopeContext } from '@adecore/agents-react/scope';
36
+
37
+ <ChatScopeContext.Provider value={scope}>
38
+ <Timeline chatId={chatId} composer={<Composer chatId={chatId} info={info} {...composerProps} />} />
39
+ </ChatScopeContext.Provider>;
40
+ ```
41
+
42
+ [Getting started](https://adecore.dev/agents-react/guide/getting-started) covers the words, the host, the scope and its transport.
44
43
 
45
- Storage keys default to `adecore.chat.drafts`, `adecore.chat.preferences`, `adecore.chat.stash`, and `adecore.usage`. Keep previous keys/namespace in place or configure ordered legacy keys to copy raw records unchanged, retaining their old copies. Setup after hydration throws. Custom synchronous storage and memory-only storage are supported; failed draft writes may omit attachment bytes. See the persistence guide for formats and cross-window listeners.
44
+ Every module has its own subpath, `@adecore/agents-react/<path>`; there is no root import.
46
45
 
47
- Mention drags use `application/x-adecore-mention`. `writeMentionDrag` writes both new and transitional formats by default, and `carriesMentions`/`droppedMentions` read both. Migrate producers and receivers together before disabling legacy writes. The existing space-separated payload still cannot represent whitespace-containing paths as one token. The chat CodeMirror input is separate from the file editor.
46
+ ## Documentation
48
47
 
49
- `AGENTS_LOCALES` loads English/Dutch for `agent-chat`, `agent-prompts`, `agent-providers`, and `agent-usage`. Load them on the default i18next instance used by package helpers and React. `UIProvider` supplies UI words and formatter setup separately.
48
+ | Page | What it covers |
49
+ |---|---|
50
+ | [Getting started](https://adecore.dev/agents-react/guide/getting-started) | Install, CSS, words, the host and a first chat |
51
+ | [Host adapters](https://adecore.dev/agents-react/guide/host) | Everything `setChatHost` takes |
52
+ | [Chat client and state](https://adecore.dev/agents-react/guide/chat-client) | The transport, `ChatClient` and the stores |
53
+ | [Persistence](https://adecore.dev/agents-react/guide/persistence) | Drafts, preferences and their storage keys |
54
+ | [Chat](https://adecore.dev/agents-react/chat/timeline) | The timeline, the composer, prompts and their parts, each with a live demo |
55
+ | [Providers](https://adecore.dev/agents-react/providers/providers-pane) | The settings of the CLIs and their accounts |
56
+ | [Usage](https://adecore.dev/agents-react/usage/usage-page) | Cost, charts and plan limits |
57
+ | [Modules](https://adecore.dev/agents-react/reference) | Every subpath and its exports |
50
58
 
51
- Build dependencies before default-export checks. The [agent-port example](../../examples/agent-port) safely verifies `AgentHost`, a fake CLI, `FramePort`, `portTransport`, `ChatClient`, rendered scope context, locale loading, reply state, and structured refusals. `fixtures/storage.mjs` runs storage compatibility checks in a fresh process. Package typecheck/tests and source/default consumer checks are described in the testing guide.
59
+ ## License
52
60
 
53
- This package remains private at `0.0.0` pending publication setup. Source-condition imports support a linked checkout; default exports require built artifacts. The transferred code retains FSL-1.1-MIT; see [LICENSE](./LICENSE).
61
+ FSL-1.1-MIT. See [LICENSE](./LICENSE).
@@ -1,4 +1,4 @@
1
- import type { AgentKind, ChatAttachmentUpload, ChatAttachResult, ChatBookmark, ChatPreferencesPayload, ChatSkill, ModelSelection, ProviderInfo, RuntimeMode } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ChatAttachmentUpload, ChatAttachResult, ChatBookmark, ChatPreferencesPayload, ChatSkill, ChatVisual, ModelSelection, ProviderInfo, RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { ChatSink } from '../state/chats.js';
3
3
  import { type ChatTransport } from '../transport.js';
4
4
  interface ChatOpenOptions {
@@ -40,6 +40,7 @@ export declare class ChatClient {
40
40
  addBookmark(chatId: string, itemId: string, name?: string): Promise<ChatBookmark[]>;
41
41
  renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]>;
42
42
  removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]>;
43
+ removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]>;
43
44
  listSkills(chatId: string): Promise<ChatSkill[]>;
44
45
  clear(chatId: string, force?: boolean): Promise<void>;
45
46
  kill(chatId: string): Promise<void>;
@@ -28,7 +28,7 @@ export class ChatClient {
28
28
  }
29
29
  }),
30
30
  // Every chat on the machine, attached or not, so a node waiting on a person says so on a view nobody has open.
31
- transport.on('chat.status', ({ chatId, info }) => this.sink.status(chatId, info)), transport.on('chat.bookmarks', ({ chatId, bookmarks }) => this.sink.bookmarks(chatId, bookmarks)), transport.subscribeStatus((status) => this.onStatus(status)));
31
+ transport.on('chat.status', ({ chatId, info }) => this.sink.status(chatId, info)), transport.on('chat.bookmarks', ({ chatId, bookmarks }) => this.sink.bookmarks(chatId, bookmarks)), transport.on('chat.visuals', ({ chatId, visuals }) => this.sink.visuals(chatId, visuals)), transport.subscribeStatus((status) => this.onStatus(status)));
32
32
  if (transport.status === 'open') {
33
33
  void this.loadProviders();
34
34
  void this.loadStatuses();
@@ -133,6 +133,11 @@ export class ChatClient {
133
133
  const { bookmarks } = await this.transport.request('chat.removeBookmark', { chatId, itemId });
134
134
  return bookmarks;
135
135
  }
136
+ /* 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`. */
137
+ async removeVisual(chatId, visualId) {
138
+ const { visuals } = await this.transport.request('chat.removeVisual', { chatId, visualId });
139
+ return visuals;
140
+ }
136
141
  /* What this chat's CLI would run as a skill, for the composer's `$` picker. */
137
142
  async listSkills(chatId) {
138
143
  const { skills } = await this.transport.request('skills.list', { chatId });
@@ -257,8 +262,9 @@ export class ChatClient {
257
262
  const page = result.history === undefined ? undefined : { history: result.history, pending: result.pending ?? [] };
258
263
  this.sink.reset(chatId, result.info, result.items, page);
259
264
  }
260
- // A host without bookmarks sends none, and then this chat has none.
265
+ // A host without bookmarks or visuals sends none, and then this chat has none.
261
266
  this.sink.bookmarks(chatId, result.bookmarks ?? []);
267
+ this.sink.visuals(chatId, result.visuals ?? []);
262
268
  }
263
269
  sendPreferences() {
264
270
  if (this.preferences === null) {
@@ -125,8 +125,8 @@ function applyRendition(state, parameters) {
125
125
  }
126
126
  function styleOf(state) {
127
127
  const style = {};
128
- const color = state.inverse ? (state.background ?? 'var(--term-bg)') : state.foreground;
129
- const background = state.inverse ? (state.foreground ?? 'var(--term-fg)') : state.background;
128
+ const color = state.inverse ? (state.background ?? 'var(--chat-code-bg)') : state.foreground;
129
+ const background = state.inverse ? (state.foreground ?? 'var(--chat-output)') : state.background;
130
130
  if (color !== undefined || state.dim) {
131
131
  style.color = state.dim ? `color-mix(in srgb, ${color ?? 'currentColor'} 65%, transparent)` : color;
132
132
  }
@@ -103,6 +103,8 @@ export function turnIdOfRow(row) {
103
103
  return row.tool.turnId;
104
104
  case 'work-group':
105
105
  return row.tools[0]?.turnId ?? null;
106
+ case 'visual':
107
+ return row.visual.turnId ?? null;
106
108
  case 'user':
107
109
  case 'assistant':
108
110
  case 'thinking':
@@ -1,3 +1,12 @@
1
+ import type { ChatVisual } from '@adecore/agent-contracts';
1
2
  import type { ThreadCard } from '../../host.js';
2
3
  import type { TimelineRow } from './timeline.js';
4
+ export interface TimedRow {
5
+ at: number;
6
+ row: TimelineRow;
7
+ }
8
+ export declare function cardRows(cards: readonly ThreadCard[]): TimedRow[];
9
+ export declare function visualRows(visuals: readonly ChatVisual[], heldFrom?: number | null): TimedRow[];
10
+ export declare function withTimedRows(rows: TimelineRow[], timed: readonly TimedRow[]): TimelineRow[];
11
+ export declare function timedRowsOf(cards: readonly ThreadCard[], visuals: readonly ChatVisual[] | null, heldFrom?: number | null): TimedRow[];
3
12
  export declare function withThreadCards(rows: TimelineRow[], cards: readonly ThreadCard[]): TimelineRow[];
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * When what a row shows began, or null for a row that only follows the one before it: a turn's
3
- * changed files and forks, a note, a report. The working row closes the thread, so every card goes
4
- * above it.
3
+ * changed files and forks, a note, a report. The working row closes the thread, so every timed row
4
+ * goes above it.
5
5
  */
6
6
  function rowTime(row) {
7
7
  switch (row.kind) {
@@ -28,32 +28,54 @@ function rowTime(row) {
28
28
  }
29
29
  }
30
30
  /* Prefixed, so a card never shares a key with a row of the chat's own. */
31
- function cardRow(card) {
32
- return { kind: 'app-card', id: `app-card-${card.id}`, card };
31
+ export function cardRows(cards) {
32
+ return cards.map((card) => ({ at: card.at, row: { kind: 'app-card', id: `app-card-${card.id}`, card } }));
33
33
  }
34
34
  /*
35
- * The thread's rows with an app's cards among them: a card goes right before the first row that
36
- * began after it, so in a folded turn it lands under the fold, and in an open one between its calls.
37
- * Without cards the rows come back as they are, the same array.
35
+ * The visuals as rows, keyed apart from the chat's own rows. `heldFrom` is when the first item this
36
+ * client holds began, while there is a page before it: a visual from before then belongs to that
37
+ * page and waits for it, rather than stacking up at the top of the thread.
38
38
  */
39
- export function withThreadCards(rows, cards) {
40
- if (cards.length === 0) {
39
+ export function visualRows(visuals, heldFrom = null) {
40
+ return visuals
41
+ .filter((visual) => heldFrom === null || visual.at >= heldFrom)
42
+ .map((visual) => ({ at: visual.at, row: { kind: 'visual', id: `visual-${visual.id}`, visual } }));
43
+ }
44
+ /*
45
+ * The thread's rows with timed rows among them: each goes right before the first row that began after
46
+ * it, so in a folded turn it lands under the fold, above the answer that closed the turn, and in an
47
+ * open one between its calls. Rows of the same moment keep the order they were handed in. Without
48
+ * timed rows the rows come back as they are, the same array.
49
+ */
50
+ export function withTimedRows(rows, timed) {
51
+ if (timed.length === 0) {
41
52
  return rows;
42
53
  }
43
- // Sorting is stable, so cards of the same moment keep the order the app gave them.
44
- const pending = [...cards].sort((first, second) => first.at - second.at);
54
+ // Sorting is stable, which is what keeps rows of the same moment in the order they came.
55
+ const pending = [...timed].sort((first, second) => first.at - second.at);
45
56
  const merged = [];
46
57
  let next = 0;
47
58
  for (const row of rows) {
48
59
  const time = rowTime(row);
49
60
  while (time !== null && next < pending.length && pending[next].at < time) {
50
- merged.push(cardRow(pending[next]));
61
+ merged.push(pending[next].row);
51
62
  next += 1;
52
63
  }
53
64
  merged.push(row);
54
65
  }
55
66
  for (; next < pending.length; next++) {
56
- merged.push(cardRow(pending[next]));
67
+ merged.push(pending[next].row);
57
68
  }
58
69
  return merged;
59
70
  }
71
+ /*
72
+ * What goes among a thread's rows by its moment: the visuals, then the app's cards, so a visual comes
73
+ * first among rows of the same moment. `visuals` is null where the host draws none.
74
+ */
75
+ export function timedRowsOf(cards, visuals, heldFrom = null) {
76
+ return [...(visuals === null ? [] : visualRows(visuals, heldFrom)), ...cardRows(cards)];
77
+ }
78
+ /* The thread's rows with an app's cards among them, the way `withTimedRows` places them. */
79
+ export function withThreadCards(rows, cards) {
80
+ return withTimedRows(rows, cardRows(cards));
81
+ }
@@ -1,4 +1,4 @@
1
- import type { ChatApprovalItem, ChatAssistantItem, ChatCheckpointDiff, ChatItem, ChatQuestionItem, ChatSubagentItem, ChatThinkingItem, ChatToolItem, ChatTurnItem, ChatUserItem, ChatWorkflow } from '@adecore/agent-contracts';
1
+ import type { ChatApprovalItem, ChatAssistantItem, ChatCheckpointDiff, ChatItem, ChatQuestionItem, ChatSubagentItem, ChatThinkingItem, ChatToolItem, ChatTurnItem, ChatUserItem, ChatVisual, ChatWorkflow } from '@adecore/agent-contracts';
2
2
  import type { ThreadCard } from '../../host.js';
3
3
  export interface SubagentBranch {
4
4
  id: string;
@@ -94,6 +94,10 @@ export type TimelineRow = {
94
94
  kind: 'app-card';
95
95
  id: string;
96
96
  card: ThreadCard;
97
+ } | {
98
+ kind: 'visual';
99
+ id: string;
100
+ visual: ChatVisual;
97
101
  };
98
102
  interface TimelineOptions {
99
103
  expandedGroups: ReadonlySet<string>;
@@ -9,7 +9,7 @@ import { hasFileChanges, isFileChange } from './tools.js';
9
9
  * against each other; prose and cards are blocks and need room around them. Where the two meet,
10
10
  * the block gap marks the seam, so an answer never looks glued to the call above it.
11
11
  */
12
- const BLOCK_KINDS = new Set(['assistant', 'report', 'thinking', 'changed-files', 'compaction', 'app-card']);
12
+ const BLOCK_KINDS = new Set(['assistant', 'report', 'thinking', 'changed-files', 'compaction', 'app-card', 'visual']);
13
13
  export function isBlock(row) {
14
14
  return BLOCK_KINDS.has(row.kind);
15
15
  }
@@ -0,0 +1,4 @@
1
+ import { type ChatVisual } from '@adecore/agent-contracts/visual';
2
+ export declare function clampVisualHeight(visual: Pick<ChatVisual, 'maxHeight'>, height: number): number;
3
+ export declare function initialVisualHeight(visual: Pick<ChatVisual, 'id' | 'maxHeight' | 'heights'>, width: number): number;
4
+ export declare function rememberVisualHeight(visualId: string, width: number, height: number): void;
@@ -0,0 +1,32 @@
1
+ import { VISUAL_LIMITS, visualFrameHeight } from '@adecore/agent-contracts/visual';
2
+ /* A frame before anything measured its page: room for a chart, never taller than the page may grow. */
3
+ const DEFAULT_HEIGHT_PX = 240;
4
+ /* Plenty for the visuals of a long session; the oldest goes first. */
5
+ const REMEMBERED_HEIGHTS = 500;
6
+ /*
7
+ * The heights frames reported, by visual and width. The thread draws only the rows in view, so a row
8
+ * that scrolls back in mounts a new frame, which starts at the height its page had there before.
9
+ */
10
+ const remembered = new Map();
11
+ function keyOf(visualId, width) {
12
+ return `${visualId}@${Math.round(width)}`;
13
+ }
14
+ /* A height a page reported, held to the visual's own maximum and the limits, in whole pixels. */
15
+ export function clampVisualHeight(visual, height) {
16
+ return Math.min(Math.max(Math.ceil(Math.min(height, visual.maxHeight)), VISUAL_LIMITS.minHeight), VISUAL_LIMITS.maxHeight);
17
+ }
18
+ /*
19
+ * The height a frame `width` CSS pixels wide starts at: what the page reported in a frame that wide
20
+ * before, else what the host measured, else a modest default.
21
+ */
22
+ export function initialVisualHeight(visual, width) {
23
+ return remembered.get(keyOf(visual.id, width)) ?? visualFrameHeight(visual, width) ?? clampVisualHeight(visual, DEFAULT_HEIGHT_PX);
24
+ }
25
+ export function rememberVisualHeight(visualId, width, height) {
26
+ const key = keyOf(visualId, width);
27
+ remembered.delete(key);
28
+ remembered.set(key, height);
29
+ if (remembered.size > REMEMBERED_HEIGHTS) {
30
+ remembered.delete(remembered.keys().next().value);
31
+ }
32
+ }
@@ -112,7 +112,7 @@ export const Scrubber = memo(function Scrubber({ ticks, firstInView, lastInView,
112
112
  const distance = pointing && hoveredSlot !== null ? Math.abs(index - hoveredSlot) : null;
113
113
  // A bookmark is a place to find again from anywhere, so it never dims with the rest.
114
114
  const bright = slot.marked || slotInView(slot, inView) || distance === 0;
115
- return (_jsxs(Fragment, { children: [_jsx("span", { "aria-hidden": true, className: clsx('absolute left-1 rounded-full transition-[width,opacity] duration-100 ease-out motion-reduce:transition-none', slot.marked ? 'bg-accent' : slot.kind === 'wake' ? 'bg-text-muted' : 'bg-text', bright ? 'opacity-100' : 'opacity-30'), style: { top: slot.y, height: TICK_HEIGHT_PX, width: tickWidth(slot.kind, distance) } }), slot.found && (_jsx("span", { "aria-hidden": true, className: clsx('absolute right-0 rounded-full bg-find-current', index === currentSlot ? 'opacity-100' : 'opacity-50'), style: { top: slot.y, height: TICK_HEIGHT_PX, width: FOUND_WIDTH_PX } }))] }, slot.first));
115
+ return (_jsxs(Fragment, { children: [_jsx("span", { "aria-hidden": true, className: clsx('absolute left-1 rounded-full transition-[width,opacity] duration-100 ease-out motion-reduce:transition-none', slot.marked ? 'bg-accent' : slot.kind === 'wake' ? 'bg-text-muted' : 'bg-text', bright ? 'opacity-100' : 'opacity-30'), style: { top: slot.y, height: TICK_HEIGHT_PX, width: tickWidth(slot.kind, distance) } }), slot.found && (_jsx("span", { "aria-hidden": true, className: clsx('absolute right-0 rounded-full bg-chat-find', index === currentSlot ? 'opacity-100' : 'opacity-50'), style: { top: slot.y, height: TICK_HEIGHT_PX, width: FOUND_WIDTH_PX } }))] }, slot.first));
116
116
  }) }), _jsx(PreviewCard.Popup, { anchor: anchor, side: "right", align: "center", sideOffset: 4, className: "w-72", children: hoveredTick !== null && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "px-2.5 pt-1.5 pb-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-xs text-text-faint", children: [hoveredTick.bookmark === null ? (_jsx("span", { children: hoveredTick.kind === 'person' ? t('rows.user.heading') : t('scrubber.tasks') })) : (_jsxs("span", { className: "flex min-w-0 items-center gap-1 self-center text-text-muted", children: [_jsx(Icon, { icon: Bookmark, size: 12, className: "shrink-0 text-accent" }), _jsx("span", { className: "truncate", children: hoveredTick.bookmark.name ?? t('bookmarks.unnamed') })] })), _jsx("time", { className: "tabular-nums", children: timeOf(hoveredTick.createdAt) })] }), _jsx("p", { className: "mt-1 line-clamp-4 text-sm break-words whitespace-pre-line text-text", children: hoveredTick.text.slice(0, PREVIEW_CHARS) || t('pickers.stash.noText') })] }), actions.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, {}), actions.map((action) => (_jsxs("button", { type: "button", className: "menu-item w-full hover:bg-surface-hover", onClick: () => action.run(hoveredTick, chat), children: [_jsx(Icon, { icon: action.icon, size: 14 }), " ", t(action.label)] }, action.label)))] }))] })) })] }) }), _jsxs(ContextMenu.Popup, { children: [menuTick !== null &&
117
117
  actionsFor(menuTick).map((action) => (_jsxs(ContextMenu.Item, { onClick: () => action.run(menuTick, chat), children: [_jsx(Icon, { icon: action.icon, size: 14 }), " ", t(action.label)] }, action.label))), menuTick !== null && actionsFor(menuTick).length > 0 && menuMarkable !== null && _jsx(ContextMenu.Separator, {}), chat !== null && menuIndex !== null && menuMarkable !== null && (_jsx(BookmarkMenuItems, { chatId: chat.chatId, itemId: menuMarkable, onName: () => onPick(menuIndex) }))] })] }));
118
118
  });
@@ -7,7 +7,7 @@ import { LoaderCircle } from 'lucide-react';
7
7
  import { useForkedTurns } from '../forks.js';
8
8
  import { bookmarkRows } from '../logic/bookmarks.js';
9
9
  import { deriveTimelineRows, findSubagentBranch } from '../logic/timeline.js';
10
- import { withThreadCards } from '../logic/thread-cards.js';
10
+ import { timedRowsOf, withTimedRows } from '../logic/thread-cards.js';
11
11
  import { openFromMain, useSubagentTrail } from '../subagent-view.js';
12
12
  import { registerItemJumper, registerMessageStepper, registerTimeline, registerTurnJumper, setTimelineAtEnd, wantsEarlier } from '../timeline-scroll.js';
13
13
  import { useTimelineFlash } from '../timeline-flash.js';
@@ -19,6 +19,7 @@ import { BookmarkMarker } from './BookmarkMarker.js';
19
19
  import { MessageActions } from './MessageActions.js';
20
20
  import { Scrubber } from './Scrubber.js';
21
21
  import { TimelineMenuPopup } from './TimelineMenu.js';
22
+ import { VisualDialogs } from './VisualDialogs.js';
22
23
  import { FOLLOW_THRESHOLD_PX, replyHeader, rowRhythm } from './rows/row-rhythm.js';
23
24
  import { QuoteButton } from './QuoteButton.js';
24
25
  import { QuoteTakerContext } from './quote-selection.js';
@@ -67,6 +68,7 @@ export function Timeline({ chatId, composer, overlay }) {
67
68
  const items = useChatRow(chatId, (row) => row?.structure);
68
69
  const activeTurnId = useChatRow(chatId, (row) => row?.info.activeTurnId ?? null);
69
70
  const bookmarks = useChatRow(chatId, (row) => row?.bookmarks);
71
+ const visuals = useChatRow(chatId, (row) => row?.visuals);
70
72
  const forkedTurns = useForkedTurns(chatId);
71
73
  const info = useChatRow(chatId, (row) => row?.info ?? null);
72
74
  const cursor = useChatRow(chatId, (row) => row?.history?.cursor ?? null);
@@ -127,7 +129,10 @@ export function Timeline({ chatId, composer, overlay }) {
127
129
  return deriveTimelineRows(order.map((id) => items[id]), { expandedGroups: groups.ids, expandedTurns: turns.ids, expandedSubagents: subagents.ids, activeTurnId, forkedTurns });
128
130
  }, [order, items, groups.ids, turns.ids, subagents.ids, activeTurnId, forkedTurns]);
129
131
  const cards = chatHost().useThreadCards(scopeId, chatId);
130
- const rows = useMemo(() => withThreadCards(threadRows, cards), [threadRows, cards]);
132
+ const drawsVisuals = chatHost().visuals !== null;
133
+ // Where the part of the thread this client holds begins, while there is a page before it.
134
+ const heldFrom = cursor === null ? null : (items?.[order?.[0] ?? '']?.createdAt ?? null);
135
+ const rows = useMemo(() => withTimedRows(threadRows, timedRowsOf(cards, drawsVisuals ? (visuals ?? []) : null, heldFrom)), [threadRows, cards, drawsVisuals, visuals, heldFrom]);
131
136
  const welcomes = chatHost().useWelcome(chatId);
132
137
  const empty = rows.length === 0;
133
138
  // A chat that took a turn before is one whose thread has not come in yet, not one to greet.
@@ -420,5 +425,5 @@ export function Timeline({ chatId, composer, overlay }) {
420
425
  return (_jsxs("div", { "data-index": virtualRow.index, "data-item-id": row.id, ref: virtualizer.measureElement, className: clsx('absolute left-0 top-0 w-full', rowRhythm(row, previous), question && virtualRow.index > 0 && 'pt-(--chat-turn-gap)'), style: { transform: `translateY(${virtualRow.start}px)` }, children: [flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && (_jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce)), header !== null && _jsx(ReplyHeader, { chatId: chatId, at: header.at }), _jsx(FindRevealContext.Provider, { value: chatFind.reveal, children: _jsx(MarkableRow, { row: row, chatId: chatId, bookmark: marks.get(row.id) ?? null, children: _jsx(Row, { row: row, chatId: chatId, toggleGroup: groups.toggle, toggleTurn: turns.toggle, toggleSubagent: subagents.toggle, openSubagent: openSubagent, openConversation: openConversation }) }) })] }, row.id));
421
426
  }) })) }), _jsx(QuoteButton, { thread: threadRef, frame: contentRef, taker: quoteTakerRef }), _jsx("div", { ref: composerRef,
422
427
  // The card is translucent so the thread shows through it, but the pb-3 strip under it must stay opaque.
423
- className: clsx('relative z-10 shrink-0', composer && 'px-3 pb-3 pt-3 bg-[linear-gradient(to_bottom,transparent_calc(100%-12px),var(--surface)_calc(100%-12px))]'), style: { position: stickyComposer ? 'sticky' : 'relative', bottom: 0 }, children: _jsx(QuoteTakerContext.Provider, { value: registerQuoteTaker, children: composer }) })] }) }), _jsx(TimelineMenuPopup, { target: target, thread: threadRef, chatId: onMainAgent ? chatId : null })] }), loadingEarlier && !overlay && (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center", role: "status", children: [_jsx(Icon, { icon: LoaderCircle, size: 16, className: "animate-spin text-text-faint" }), _jsx("span", { className: "sr-only", children: t('timeline.loadingEarlier') })] })), showsScrubber && !overlay && (_jsx("div", { className: "absolute top-4", style: { left: STRIP_INSET_PX, width: STRIP_WIDTH_PX, bottom: coveredHeight }, children: _jsx(ErrorBoundary, { label: t('timeline.stripFailed'), resetKeys: [ticks.length], children: _jsx(Scrubber, { ticks: ticks, firstInView: inView?.first ?? null, lastInView: inView?.last ?? null, onPick: pick, chat: cardChat, found: found, foundCurrent: foundCurrent }) }) })), overlay && (_jsx("div", { className: "absolute inset-0 z-5 flex flex-col bg-surface", style: { paddingBottom: coveredHeight }, children: overlay }))] }) }));
428
+ className: clsx('relative z-10 shrink-0', composer && 'px-3 pb-3 pt-3 bg-[linear-gradient(to_bottom,transparent_calc(100%-12px),var(--surface)_calc(100%-12px))]'), style: { position: stickyComposer ? 'sticky' : 'relative', bottom: 0 }, children: _jsx(QuoteTakerContext.Provider, { value: registerQuoteTaker, children: composer }) })] }) }), _jsx(TimelineMenuPopup, { target: target, thread: threadRef, chatId: onMainAgent ? chatId : null })] }), drawsVisuals && _jsx(VisualDialogs, { chatId: chatId }), loadingEarlier && !overlay && (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center", role: "status", children: [_jsx(Icon, { icon: LoaderCircle, size: 16, className: "animate-spin text-text-faint" }), _jsx("span", { className: "sr-only", children: t('timeline.loadingEarlier') })] })), showsScrubber && !overlay && (_jsx("div", { className: "absolute top-4", style: { left: STRIP_INSET_PX, width: STRIP_WIDTH_PX, bottom: coveredHeight }, children: _jsx(ErrorBoundary, { label: t('timeline.stripFailed'), resetKeys: [ticks.length], children: _jsx(Scrubber, { ticks: ticks, firstInView: inView?.first ?? null, lastInView: inView?.last ?? null, onPick: pick, chat: cardChat, found: found, foundCurrent: foundCurrent }) }) })), overlay && (_jsx("div", { className: "absolute inset-0 z-5 flex flex-col bg-surface", style: { paddingBottom: coveredHeight }, children: overlay }))] }) }));
424
429
  }
@@ -32,10 +32,10 @@ pre > [data-code] { flex-grow: 1; align-self: stretch; align-content: start; }
32
32
  `;
33
33
  function lineClass(line) {
34
34
  if (line.startsWith('+')) {
35
- return 'text-term-green';
35
+ return 'text-chat-added';
36
36
  }
37
37
  if (line.startsWith('-')) {
38
- return 'text-term-red';
38
+ return 'text-chat-removed';
39
39
  }
40
40
  return 'text-text-muted';
41
41
  }
@@ -0,0 +1,3 @@
1
+ export declare function VisualDialogs({ chatId }: {
2
+ chatId: string;
3
+ }): import("react").JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { CloseButton, Dialog, ErrorBoundary, PromptDialog } from '@adecore/ui';
5
+ import { useChatScope } from '../../scope.js';
6
+ import { useChatRow } from '../../state/chats.js';
7
+ import { useVisualDialog } from '../visuals.js';
8
+ import { VisualFrame } from './VisualFrame.js';
9
+ /*
10
+ * The large view of one of the chat's visuals and the question before one goes, opened from its row.
11
+ * A visual that goes while either is up closes it. Removing cannot be undone: the page is deleted.
12
+ */
13
+ export function VisualDialogs({ chatId }) {
14
+ const { t } = useTranslation('agent-chat');
15
+ const scope = useChatScope();
16
+ const key = scope.keyOf(chatId);
17
+ const kind = useVisualDialog((s) => (s.chatKey === key ? s.kind : null));
18
+ const visualId = useVisualDialog((s) => (s.chatKey === key ? s.visualId : null));
19
+ const visual = useChatRow(chatId, (row) => (visualId === null ? null : (row?.visuals?.find((candidate) => candidate.id === visualId) ?? null)));
20
+ // The dialog fades out after it closes, and draws what it showed until then.
21
+ const [shown, setShown] = useState(visual);
22
+ if (visual !== null && visual !== shown) {
23
+ setShown(visual);
24
+ }
25
+ const close = () => useVisualDialog.getState().close();
26
+ return (_jsxs(_Fragment, { children: [_jsx(Dialog.Root, { open: kind === 'expand' && visual !== null, onOpenChange: (next) => !next && close(), children: _jsxs(Dialog.Popup, { className: "flex h-[calc(100dvh-96px)] w-[1200px] flex-col overflow-hidden", children: [_jsxs("div", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border pr-2 pl-4", children: [_jsx(Dialog.Title, { className: "min-w-0 grow truncate", children: shown?.title }), _jsx(CloseButton, { label: t('common.action.close'), kbd: "esc", dialog: true })] }), _jsx("div", { className: "min-h-0 grow p-5", children: _jsx("div", { className: "relative size-full", children: shown !== null && (_jsx(ErrorBoundary, { label: t('visuals.renderFailed'), resetKeys: [shown], children: _jsx(VisualFrame, { chatId: chatId, visual: shown, fill: true }) })) }) })] }) }), _jsx(PromptDialog, { open: kind === 'remove' && visual !== null, title: t('visuals.removeTitle'), description: t('visuals.removeDescription', { title: shown?.title ?? '' }), confirmLabel: t('common.action.remove'), danger: true, fallbackMessage: t('visuals.removeFailed'), onConfirm: async () => {
27
+ if (visualId !== null) {
28
+ await scope.chats.removeVisual(chatId, visualId);
29
+ }
30
+ close();
31
+ }, onOpenChange: close })] }));
32
+ }
@@ -0,0 +1,8 @@
1
+ import { type ChatVisual } from '@adecore/agent-contracts/visual';
2
+ export interface VisualFrameProps {
3
+ chatId: string;
4
+ visual: ChatVisual;
5
+ fill?: boolean;
6
+ className?: string;
7
+ }
8
+ export declare function VisualFrame({ chatId, visual, fill, className }: VisualFrameProps): import("react").JSX.Element;
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import clsx from 'clsx';
5
+ import { visualThemeFragment } from '@adecore/agent-contracts/visual';
6
+ import { Spinner, useMeasuredWidth } from '@adecore/ui';
7
+ import { chatHost } from '../../host.js';
8
+ import { useChatScope } from '../../scope.js';
9
+ import { initialVisualHeight, rememberVisualHeight } from '../logic/visual-height.js';
10
+ import { VisualBridge } from '../visual-bridge.js';
11
+ import { useVisualTheme } from './visual-theme.js';
12
+ /*
13
+ * A link the page asks to open counts only while a person acts in the frame: it has the focus, and a
14
+ * click or a key in it is recent. A page can take the focus by script, so this stops a link opened as
15
+ * the page loads, and not one opened while a person works in the frame.
16
+ */
17
+ function personActsIn(frame) {
18
+ return frame.ownerDocument.activeElement === frame && navigator.userActivation?.isActive !== false;
19
+ }
20
+ /*
21
+ * One visual's page in a frame on the thread's own ground. The frame loads the app's sandbox host
22
+ * page and the bridge hands it the page and the theme. The frame never gets `allow-same-origin`,
23
+ * which keeps the page on an opaque origin even when the host page is served on the app's own. Its
24
+ * box keeps one height while the page loads and when it fails, so nothing under it moves.
25
+ */
26
+ export function VisualFrame({ chatId, visual, fill = false, className }) {
27
+ const { t } = useTranslation('agent-chat');
28
+ const scope = useChatScope();
29
+ const frameUrl = chatHost().visuals?.frameUrl ?? null;
30
+ const boxRef = useRef(null);
31
+ const frameRef = useRef(null);
32
+ const bridgeRef = useRef(null);
33
+ const [measure, width] = useMeasuredWidth();
34
+ const setBox = useCallback((node) => {
35
+ boxRef.current = node;
36
+ measure(node);
37
+ }, [measure]);
38
+ const theme = useVisualTheme(boxRef);
39
+ const widthRef = useRef(width);
40
+ const [reported, setReported] = useState(null);
41
+ // The appearance the page is drawn in once it runs; until then the frame matches the sandbox host page, which declares none.
42
+ const [drawn, setDrawn] = useState(null);
43
+ const [failed, setFailed] = useState(false);
44
+ // The frame keeps the address it opened with: a later theme goes over the bridge, since a new address would load the page again.
45
+ const [opening, setOpening] = useState(null);
46
+ if (opening === null && frameUrl !== null && theme !== null && width > 0) {
47
+ setOpening({ src: frameUrl + visualThemeFragment(theme), theme });
48
+ }
49
+ const height = fill || width === 0 ? undefined : (reported ?? initialVisualHeight(visual, width));
50
+ useLayoutEffect(() => {
51
+ widthRef.current = width;
52
+ }, [width]);
53
+ useLayoutEffect(() => {
54
+ const frame = frameRef.current;
55
+ if (opening === null || frame === null) {
56
+ return;
57
+ }
58
+ const bridge = new VisualBridge({
59
+ frame: () => frame.contentWindow,
60
+ visual: { maxHeight: visual.maxHeight },
61
+ page: chatHost()
62
+ .attachments.read(scope.id, chatId, visual.id)
63
+ .then((blob) => blob.text()),
64
+ theme: opening.theme,
65
+ mayOpenLink: () => personActsIn(frame),
66
+ openLink: (url) => chatHost().visuals?.openLink(url),
67
+ onHeight: (next) => {
68
+ if (fill) {
69
+ return;
70
+ }
71
+ setReported(next);
72
+ rememberVisualHeight(visual.id, widthRef.current, next);
73
+ },
74
+ onAppearance: setDrawn,
75
+ onFailure: () => setFailed(true)
76
+ });
77
+ bridgeRef.current = bridge;
78
+ const view = frame.ownerDocument.defaultView ?? window;
79
+ const listen = (event) => bridge.receive(event);
80
+ view.addEventListener('message', listen);
81
+ return () => {
82
+ view.removeEventListener('message', listen);
83
+ bridge.dispose();
84
+ bridgeRef.current = null;
85
+ };
86
+ }, [opening, scope.id, chatId, visual.id, visual.maxHeight, fill]);
87
+ useEffect(() => {
88
+ if (theme !== null) {
89
+ bridgeRef.current?.setTheme(theme);
90
+ }
91
+ }, [theme]);
92
+ return (_jsxs("div", { ref: setBox, className: clsx(fill ? 'absolute inset-0' : 'relative w-full', className), style: { height }, children: [opening !== null && (_jsx("iframe", { ref: frameRef, src: opening.src, title: visual.title, loading: "lazy", sandbox: "allow-scripts allow-forms", className: clsx('block size-full border-0', failed && 'invisible'),
93
+ // A frame whose color scheme differs from its document's paints an opaque ground behind it.
94
+ style: { colorScheme: drawn ?? 'normal' }, onLoad: () => bridgeRef.current?.loaded() })), failed ? (_jsx("p", { className: "absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted", children: t('visuals.failed', { title: visual.title }) })) : (drawn === null && (_jsx("span", { className: "chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint", children: _jsx(Spinner, { size: 16, label: t('visuals.loading', { title: visual.title }) }) })))] }));
95
+ }
@@ -4,6 +4,7 @@ import { ErrorBoundary } from '@adecore/ui';
4
4
  import { AgentTurnRow, ApprovalHistoryRow, AssistantRow, CompactionRow, NoteRow, QuestionHistoryRow, ReportRow, ThinkingRow, UserRow } from './MessageRows.js';
5
5
  import { ForksRow } from './ForksRow.js';
6
6
  import { SubagentBranchRow } from './SubagentRow.js';
7
+ import { VisualRow } from './VisualRow.js';
7
8
  import { WorkflowRow } from './WorkflowRow.js';
8
9
  import { ChangedFilesRow, TurnFoldRow, WorkGroupRow, WorkLiveRow, WorkRow, WorkingRow } from './WorkRows.js';
9
10
  /* Its own component, so a card that throws while it renders is caught by the boundary around it. */
@@ -57,5 +58,7 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
57
58
  return _jsx(WorkingRow, { startedAt: row.startedAt });
58
59
  case 'app-card':
59
60
  return _jsx(AppCardRow, { card: row.card });
61
+ case 'visual':
62
+ return _jsx(VisualRow, { chatId: chatId, visual: row.visual });
60
63
  }
61
64
  }
@@ -0,0 +1,5 @@
1
+ import type { ChatVisual } from '@adecore/agent-contracts';
2
+ export declare function VisualRow({ chatId, visual }: {
3
+ chatId: string;
4
+ visual: ChatVisual;
5
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Ellipsis, Maximize2, Trash2 } from 'lucide-react';
4
+ import { ButtonGroup, ErrorBoundary, Icon, IconButton, Menu, Surface } from '@adecore/ui';
5
+ import { chatHost } from '../../../host.js';
6
+ import { useChatScope } from '../../../scope.js';
7
+ import { useVisualDialog } from '../../visuals.js';
8
+ import { VisualFrame } from '../VisualFrame.js';
9
+ /*
10
+ * A page an agent published, borderless on the thread's ground and as wide as the reply. Its few
11
+ * controls float over the page's top corner while the pointer or the focus is on it, and always on a
12
+ * touch screen, which has no hover. A click inside the page never reaches the thread's menu, so they
13
+ * sit here.
14
+ */
15
+ export function VisualRow({ chatId, visual }) {
16
+ const { t } = useTranslation('agent-chat');
17
+ const { keyOf } = useChatScope();
18
+ if (chatHost().visuals === null) {
19
+ return null;
20
+ }
21
+ const open = (kind) => useVisualDialog.getState().open(keyOf(chatId), visual.id, kind);
22
+ return (_jsx(ErrorBoundary, { label: t('visuals.renderFailed'), resetKeys: [visual], compact: true, className: "relative rounded-lg", children: _jsxs("div", { className: "group/visual relative", children: [_jsx(VisualFrame, { chatId: chatId, visual: visual }), _jsx(Surface, { role: "toolbar", "aria-label": t('visuals.actions', { title: visual.title }), className: "absolute top-2 right-2 rounded-lg p-0.5 opacity-0 transition-opacity group-focus-within/visual:opacity-100 group-hover/visual:opacity-100 has-[[data-popup-open]]:opacity-100 pointer-coarse:opacity-100", children: _jsxs(ButtonGroup, { children: [_jsx(IconButton, { icon: Maximize2, size: "sm", label: t('visuals.expand'), onClick: () => open('expand') }), _jsxs(Menu.Root, { children: [_jsx(IconButton, { icon: Ellipsis, size: "sm", label: t('visuals.more'), render: _jsx(Menu.Trigger, {}) }), _jsx(Menu.Popup, { align: "end", children: _jsxs(Menu.Item, { onClick: () => open('remove'), children: [_jsx(Icon, { icon: Trash2, size: 14 }), " ", t('visuals.remove')] }) })] })] }) })] }) }));
23
+ }