@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.
- package/dist/chat/logic/visual-height.d.ts +1 -0
- package/dist/chat/logic/visual-height.js +1 -1
- package/dist/chat/ui/HostMenuItems.d.ts +4 -0
- package/dist/chat/ui/HostMenuItems.js +15 -0
- package/dist/chat/ui/VisualFrame.d.ts +8 -0
- package/dist/chat/ui/VisualFrame.js +217 -115
- package/dist/chat/ui/index.d.ts +1 -0
- package/dist/chat/ui/index.js +1 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +8 -4
- package/dist/chat/ui/rows/VisualRow.js +184 -142
- package/dist/chat/ui/rows/row-rhythm.js +6 -4
- package/dist/host.d.ts +11 -1
- package/dist/index.d.ts +1 -1
- package/package.json +8 -8
- package/src/chat/logic/visual-height.ts +1 -1
- package/src/chat/ui/HostMenuItems.tsx +11 -0
- package/src/chat/ui/VisualFrame.tsx +68 -21
- package/src/chat/ui/index.ts +1 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +9 -4
- package/src/chat/ui/rows/VisualRow.tsx +5 -1
- package/src/chat/ui/rows/row-rhythm.ts +6 -4
- package/src/host.ts +18 -0
- package/src/index.ts +1 -0
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
36
|
-
|
|
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 ??
|
|
110
|
+
const height = fill || width === 0 ? undefined : (reported ?? initialHeight(width));
|
|
63
111
|
|
|
64
112
|
useLayoutEffect(() => {
|
|
65
113
|
widthRef.current = width;
|
|
66
|
-
|
|
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
|
|
76
|
-
page:
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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={
|
|
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
|
|
201
|
+
<Spinner size={16} label={t('visuals.loading', { title })} />
|
|
155
202
|
</span>
|
|
156
203
|
)
|
|
157
204
|
)}
|
package/src/chat/ui/index.ts
CHANGED
|
@@ -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
|
-
|
|
179
|
-
<
|
|
180
|
-
|
|
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
|
|
11
|
-
* margin of
|
|
12
|
-
* already carries the gap before it, and the row after one is the answer, so neither
|
|
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
|
|
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;
|