@adecore/agents-react 0.37.3 → 0.38.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.
@@ -1,11 +1,11 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
- import { visualThemeFragment, type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
4
+ import { VISUAL_LIMITS, visualThemeFragment, type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
5
5
  import { Spinner, useMeasuredWidth } from '@adecore/ui';
6
6
  import { useChatHost } from '../../host';
7
7
  import { useChatScope } from '../../scope';
8
- import { initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
8
+ import { DEFAULT_HEIGHT_PX, initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
9
9
  import { VisualBridge } from '../visual-bridge';
10
10
  import { VisualViewportController } from '../visual-viewport';
11
11
  import { useVisualTheme } from './visual-theme';
@@ -18,6 +18,25 @@ export interface VisualFrameProps {
18
18
  className?: string;
19
19
  }
20
20
 
21
+ export interface VisualPageFrameProps {
22
+ /* Reads the page's HTML each time the frame opens; the frame opens again when this changes, so keep it the same function while the page is. */
23
+ page: () => Promise<string>;
24
+ /* The frame's accessible name. */
25
+ title: string;
26
+ /* The tallest the page may grow on a host page without the viewport; left out, the limit of every visual. */
27
+ maxHeight?: number;
28
+ /* Fills its positioned parent and lets the page scroll inside it, rather than taking the height the page reports. */
29
+ fill?: boolean;
30
+ className?: string;
31
+ }
32
+
33
+ interface PageFrameProps extends VisualPageFrameProps {
34
+ maxHeight: number;
35
+ /* The height to start at before the page reports one, for a frame `width` pixels wide. */
36
+ initialHeight(width: number): number;
37
+ onHeight?(width: number, height: number): void;
38
+ }
39
+
21
40
  /*
22
41
  * A link the page asks to open counts only while a person acts in the frame: it has the focus, and a
23
42
  * click or a key in it is recent. A page can take the focus by script, so this stops a link opened as
@@ -27,15 +46,43 @@ function personActsIn(frame: HTMLIFrameElement): boolean {
27
46
  return frame.ownerDocument.activeElement === frame && navigator.userActivation?.isActive !== false;
28
47
  }
29
48
 
49
+ const defaultHeight = (): number => DEFAULT_HEIGHT_PX;
50
+
51
+ /* A visual of a chat, its page read from the chat's attachments under the visual's id. */
52
+ export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
53
+ const { attachments } = useChatHost();
54
+ const scope = useChatScope();
55
+ // The frame opens again when its page changes, so the reader keeps its identity while the visual does.
56
+ const page = useCallback(() => attachments.read(scope.id, chatId, visual.id).then((blob) => blob.text()), [attachments, scope.id, chatId, visual.id]);
57
+ return (
58
+ <PageFrame
59
+ page={page}
60
+ title={visual.title}
61
+ maxHeight={visual.maxHeight}
62
+ fill={fill}
63
+ className={className}
64
+ initialHeight={(width) => initialVisualContentHeight(visual, width)}
65
+ onHeight={(width, height) => rememberVisualHeight(visual.id, width, height)}
66
+ />
67
+ );
68
+ }
69
+
30
70
  /*
31
- * One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
32
- * the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
33
- * when the host page is served on the app's own. Its box keeps one height while it loads or fails.
71
+ * A visual's page outside a chat thread, such as one an app kept: the same sandboxed frame, theme and
72
+ * links as in a thread, with the page read through `page`. Draws nothing while the host has no `visuals`.
34
73
  */
35
- export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
36
- const { visuals, attachments } = useChatHost();
74
+ export function VisualPageFrame({ maxHeight = VISUAL_LIMITS.maxHeight, ...props }: VisualPageFrameProps) {
75
+ return <PageFrame {...props} maxHeight={maxHeight} initialHeight={defaultHeight} />;
76
+ }
77
+
78
+ /*
79
+ * One page in a frame that loads the app's sandbox host page; the bridge hands it the page and the
80
+ * theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even when
81
+ * the host page is served on the app's own. Its box keeps one height while it loads or fails.
82
+ */
83
+ function PageFrame({ page, title, maxHeight, fill = false, className, initialHeight, onHeight }: PageFrameProps) {
84
+ const { visuals } = useChatHost();
37
85
  const { t } = useTranslation('agent-chat');
38
- const scope = useChatScope();
39
86
  const frameUrl = visuals?.frameUrl ?? null;
40
87
  const boxRef = useRef<HTMLDivElement | null>(null);
41
88
  const frameRef = useRef<HTMLIFrameElement>(null);
@@ -50,6 +97,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
50
97
  };
51
98
  const theme = useVisualTheme(boxRef);
52
99
  const widthRef = useRef(width);
100
+ const onHeightRef = useRef(onHeight);
53
101
  const [reported, setReported] = useState<number | null>(null);
54
102
  // The appearance the page is drawn in once it runs; until then the frame matches the sandbox host page, which declares none.
55
103
  const [drawn, setDrawn] = useState<VisualAppearance | null>(null);
@@ -59,11 +107,12 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
59
107
  if (opening === null && frameUrl !== null && theme !== null && width > 0) {
60
108
  setOpening({ src: frameUrl + visualThemeFragment(theme), theme });
61
109
  }
62
- const height = fill || width === 0 ? undefined : (reported ?? initialVisualContentHeight(visual, width));
110
+ const height = fill || width === 0 ? undefined : (reported ?? initialHeight(width));
63
111
 
64
112
  useLayoutEffect(() => {
65
113
  widthRef.current = width;
66
- }, [width]);
114
+ onHeightRef.current = onHeight;
115
+ }, [width, onHeight]);
67
116
 
68
117
  useLayoutEffect(() => {
69
118
  const frame = frameRef.current;
@@ -72,8 +121,8 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
72
121
  }
73
122
  const bridge = new VisualBridge({
74
123
  frame: () => frame.contentWindow,
75
- visual: { maxHeight: visual.maxHeight },
76
- page: attachments.read(scope.id, chatId, visual.id).then((blob) => blob.text()),
124
+ visual: { maxHeight },
125
+ page: page(),
77
126
  theme: opening.theme,
78
127
  mayOpenLink: () => personActsIn(frame),
79
128
  openLink: (url) => visuals?.openLink(url),
@@ -82,7 +131,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
82
131
  return;
83
132
  }
84
133
  setReported(next);
85
- rememberVisualHeight(visual.id, widthRef.current, next);
134
+ onHeightRef.current?.(widthRef.current, next);
86
135
  },
87
136
  ...(!fill
88
137
  ? {
@@ -102,7 +151,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
102
151
  bridge.dispose();
103
152
  bridgeRef.current = null;
104
153
  };
105
- }, [opening, scope.id, chatId, visual.id, visual.maxHeight, fill, visuals, attachments]);
154
+ }, [opening, page, maxHeight, fill, visuals]);
106
155
 
107
156
  useLayoutEffect(() => {
108
157
  const box = boxRef.current;
@@ -118,7 +167,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
118
167
  controller.dispose();
119
168
  controllerRef.current = null;
120
169
  };
121
- }, [fill, viewportReady, opening, scope.id, chatId, visual.id, visual.maxHeight]);
170
+ }, [fill, viewportReady, opening, page, maxHeight]);
122
171
 
123
172
  useLayoutEffect(() => controllerRef.current?.refresh(), [height, width]);
124
173
 
@@ -135,7 +184,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
135
184
  <iframe
136
185
  ref={frameRef}
137
186
  src={opening.src}
138
- title={visual.title}
187
+ title={title}
139
188
  loading="lazy"
140
189
  sandbox="allow-scripts allow-forms"
141
190
  className={clsx('block size-full border-0', failed && 'invisible')}
@@ -145,13 +194,11 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
145
194
  />
146
195
  )}
147
196
  {failed ? (
148
- <p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">
149
- {t('visuals.failed', { title: visual.title })}
150
- </p>
197
+ <p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">{t('visuals.failed', { title })}</p>
151
198
  ) : (
152
199
  drawn === null && (
153
200
  <span className="chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint">
154
- <Spinner size={16} label={t('visuals.loading', { title: visual.title })} />
201
+ <Spinner size={16} label={t('visuals.loading', { title })} />
155
202
  </span>
156
203
  )
157
204
  )}
@@ -22,6 +22,7 @@ export { Timeline } from './Timeline';
22
22
  export { TimelineMenuPopup } from './TimelineMenu';
23
23
  export { default as UnifiedDiff } from './UnifiedDiff';
24
24
  export { UploadThumb } from './UploadThumb';
25
+ export { VisualPageFrame, type VisualPageFrameProps } from './VisualFrame';
25
26
  export { WelcomeGreeting } from './Welcome';
26
27
  export { externalChange, composerEditorExtensions } from './composer/editor';
27
28
  export {
@@ -13,6 +13,7 @@ import { attachmentAspect, generatedImageView, type GeneratedImageFailure } from
13
13
  import { toolStartedAt } from '../../logic/tools';
14
14
  import { CHIP_IN_MESSAGE, MENTION_TONE } from '../chips';
15
15
  import { useFileLinkCwd, useOpenFileLink } from '../file-links';
16
+ import { HostMenuItems } from '../HostMenuItems';
16
17
  import { ImageThumb } from '../ImageView';
17
18
  import { ROW_GUTTER } from '../icons';
18
19
  import { RunningFor, WorkRow } from './WorkRows';
@@ -104,6 +105,7 @@ function ReadyImage({ chatId, attachment, prompt, transparent }: { chatId: strin
104
105
  const missing = source.failure !== null;
105
106
  const aspect = attachmentAspect(attachment);
106
107
  const { open, saveToProject } = attachments;
108
+ const extra = attachments.actions?.(scopeId, chatId, attachment) ?? [];
107
109
 
108
110
  const detail = [
109
111
  attachment.width !== undefined && attachment.height !== undefined
@@ -171,13 +173,16 @@ function ReadyImage({ chatId, attachment, prompt, transparent }: { chatId: strin
171
173
  >
172
174
  <ButtonGroup>
173
175
  <IconButton icon={Maximize2} size="sm" label={t('generatedImage.openLarge')} onClick={() => setLightbox(true)} />
174
- {prompt !== null && (
176
+ {(prompt !== null || extra.length > 0) && (
175
177
  <Menu.Root>
176
178
  <IconButton icon={Ellipsis} size="sm" label={t('generatedImage.more')} render={<Menu.Trigger />} />
177
179
  <Menu.Popup align="end">
178
- <Menu.Item onClick={() => copyText(prompt)}>
179
- <Icon icon={Copy} size={14} /> {t('generatedImage.copyPrompt')}
180
- </Menu.Item>
180
+ {prompt !== null && (
181
+ <Menu.Item onClick={() => copyText(prompt)}>
182
+ <Icon icon={Copy} size={14} /> {t('generatedImage.copyPrompt')}
183
+ </Menu.Item>
184
+ )}
185
+ <HostMenuItems actions={extra} />
181
186
  </Menu.Popup>
182
187
  </Menu.Root>
183
188
  )}
@@ -5,6 +5,7 @@ import { ButtonGroup, ErrorBoundary, Icon, IconButton, Menu, Surface } from '@ad
5
5
  import { useChatHost } from '../../../host';
6
6
  import { useChatScope } from '../../../scope';
7
7
  import { useVisualDialog } from '../../visuals';
8
+ import { HostMenuItems } from '../HostMenuItems';
8
9
  import { VisualFrame } from '../VisualFrame';
9
10
 
10
11
  /*
@@ -16,10 +17,11 @@ import { VisualFrame } from '../VisualFrame';
16
17
  export function VisualRow({ chatId, visual }: { chatId: string; visual: ChatVisual }) {
17
18
  const host = useChatHost();
18
19
  const { t } = useTranslation('agent-chat');
19
- const { keyOf } = useChatScope();
20
+ const { id: scopeId, keyOf } = useChatScope();
20
21
  if (host.visuals === null) {
21
22
  return null;
22
23
  }
24
+ const extra = host.visuals.actions?.(scopeId, chatId, visual) ?? [];
23
25
  const open = (kind: 'expand' | 'remove'): void => useVisualDialog.getState().open(keyOf(chatId), visual.id, kind);
24
26
  return (
25
27
  <ErrorBoundary label={t('visuals.renderFailed')} resetKeys={[visual]} compact className="relative rounded-lg">
@@ -35,6 +37,8 @@ export function VisualRow({ chatId, visual }: { chatId: string; visual: ChatVisu
35
37
  <Menu.Root>
36
38
  <IconButton icon={Ellipsis} size="sm" label={t('visuals.more')} render={<Menu.Trigger />} />
37
39
  <Menu.Popup align="end">
40
+ <HostMenuItems actions={extra} />
41
+ {extra.length > 0 && <Menu.Separator />}
38
42
  <Menu.Item onClick={() => open('remove')}>
39
43
  <Icon icon={Trash2} size={14} /> {t('visuals.remove')}
40
44
  </Menu.Item>
@@ -7,13 +7,15 @@ export const FOLLOW_THRESHOLD_PX = 40;
7
7
 
8
8
  /*
9
9
  * The gaps a row wears for where it sits: a question is followed by its answer gap, and a seam where
10
- * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A visual has no
11
- * margin of its own like prose does, so the row under one takes the block gap too. A question
12
- * already carries the gap before it, and the row after one is the answer, so neither takes a seam.
10
+ * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A visual and an
11
+ * app's card have no margin of their own like prose does, so the row under one takes the block gap
12
+ * too. A question already carries the gap before it, and the row after one is the answer, so neither
13
+ * takes a seam.
13
14
  */
14
15
  export function rowRhythm(row: TimelineRow, previous: TimelineRow | null): string {
15
16
  const question = row.kind === 'user';
16
- const seam = !question && previous !== null && previous.kind !== 'user' && (isBlock(row) !== isBlock(previous) || previous.kind === 'visual');
17
+ const bare = previous?.kind === 'visual' || previous?.kind === 'app-card';
18
+ const seam = !question && previous !== null && previous.kind !== 'user' && (isBlock(row) !== isBlock(previous) || bare);
17
19
  return clsx(question && 'pb-(--chat-answer-gap)', seam && 'pt-(--chat-block-gap)');
18
20
  }
19
21
 
package/src/host.ts CHANGED
@@ -2,9 +2,11 @@ import { createContext, createElement, useContext, useState, type ComponentProps
2
2
  import type { Extension } from '@codemirror/state';
3
3
  import type { EditorView } from '@codemirror/view';
4
4
  import type { Highlighter, ThemeRegistration } from 'shiki';
5
+ import type { LucideIcon } from 'lucide-react';
5
6
  import type { EditorEngine } from '@adecore/editor';
6
7
  import type {
7
8
  AgentKind,
9
+ ChatAttachment,
8
10
  ChatCheckpointDiff,
9
11
  ChatConfigurePayload,
10
12
  ChatGoalAction,
@@ -15,6 +17,7 @@ import type {
15
17
  ChatUiQueryReading,
16
18
  ChatUiLinkPayload,
17
19
  ChatUiLinkReading,
20
+ ChatVisual,
18
21
  ProviderPlatform
19
22
  } from '@adecore/agent-contracts';
20
23
  import { isApplePlatform } from '@adecore/ui';
@@ -188,6 +191,19 @@ export interface VisualHost {
188
191
  frameUrl: string;
189
192
  /* Opens an http or https link a person followed in a visual, outside the app. */
190
193
  openLink(url: string): void;
194
+ /* Actions of the app's own in a visual's menu, above Remove; left out, the menu holds Remove alone. */
195
+ actions?(scopeId: string, chatId: string, visual: ChatVisual): readonly HostMenuAction[];
196
+ }
197
+
198
+ /* An action of the app's own in the menu of something a chat made, such as keeping a copy somewhere of the app's. */
199
+ export interface HostMenuAction {
200
+ /* Unique among the actions of one menu. */
201
+ id: string;
202
+ label: string;
203
+ icon?: LucideIcon;
204
+ disabled?: boolean;
205
+ /* Runs once a person picks it; the host shows a failure itself. */
206
+ run(): void;
191
207
  }
192
208
 
193
209
  /* Who writes a chat's replies, as the header over each one names them. */
@@ -329,6 +345,8 @@ export interface ChatHost {
329
345
  * Resolves with the path as a file link opens it, or null when the person cancels; left out, a generated image offers no Save.
330
346
  */
331
347
  saveToProject?(scopeId: string, chatId: string, attachmentId: string, suggestedName: string): Promise<string | null>;
348
+ /* Actions of the app's own in a generated image's menu, after the chat's own; left out, the menu holds those alone. */
349
+ actions?(scopeId: string, chatId: string, attachment: ChatAttachment): readonly HostMenuAction[];
332
350
  };
333
351
  /* The image a tool read, drawn under its row; null draws none. */
334
352
  ReadImage: ComponentType<{ path: string }> | null;
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  type SubagentSlotProps,
13
13
  type ThreadCard,
14
14
  type VisualHost,
15
+ type HostMenuAction,
15
16
  type ReplyAuthor,
16
17
  type ChatHost,
17
18
  type UiMapSource,