@adecore/agents-react 0.18.4 → 0.19.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/scrubber.d.ts +1 -0
- package/dist/chat/logic/scrubber.js +1 -1
- package/dist/chat/logic/visual-height.d.ts +2 -0
- package/dist/chat/logic/visual-height.js +14 -0
- package/dist/chat/logic/visual-viewport.d.ts +5 -0
- package/dist/chat/logic/visual-viewport.js +4 -0
- package/dist/chat/ui/Timeline.js +8 -6
- package/dist/chat/ui/VisualFrame.js +31 -5
- package/dist/chat/ui/rows/RowContainer.d.ts +11 -0
- package/dist/chat/ui/rows/RowContainer.js +9 -0
- package/dist/chat/ui/rows/VisualRow.js +1 -1
- package/dist/chat/ui/rows/row-rhythm.d.ts +2 -0
- package/dist/chat/ui/rows/row-rhythm.js +4 -0
- package/dist/chat/visual-bridge.d.ts +6 -1
- package/dist/chat/visual-bridge.js +25 -3
- package/dist/chat/visual-viewport.d.ts +22 -0
- package/dist/chat/visual-viewport.js +91 -0
- package/dist/theme.css +2 -0
- package/package.json +20 -4
- package/src/chat/logic/scrubber.ts +1 -1
- package/src/chat/logic/visual-height.ts +16 -0
- package/src/chat/logic/visual-viewport.ts +9 -0
- package/src/chat/ui/Timeline.tsx +22 -17
- package/src/chat/ui/VisualFrame.tsx +56 -26
- package/src/chat/ui/rows/RowContainer.tsx +34 -0
- package/src/chat/ui/rows/VisualRow.tsx +1 -1
- package/src/chat/ui/rows/row-rhythm.ts +6 -0
- package/src/chat/visual-bridge.ts +32 -2
- package/src/chat/visual-viewport.ts +99 -0
- package/src/theme.css +2 -0
|
@@ -14,6 +14,7 @@ export declare const SCRUBBER_MIN_TICKS = 3;
|
|
|
14
14
|
export declare const TICK_HEIGHT_PX = 2;
|
|
15
15
|
export declare const STRIP_WIDTH_PX = 24;
|
|
16
16
|
export declare const STRIP_INSET_PX = 8;
|
|
17
|
+
export declare const STRIP_CLEARANCE_PX: number;
|
|
17
18
|
export declare function ticksOf(rows: readonly TimelineRow[], marks?: ReadonlyMap<string, ChatBookmark>): ScrubberTick[];
|
|
18
19
|
export interface TickSlot {
|
|
19
20
|
first: number;
|
|
@@ -9,7 +9,7 @@ const THREAD_PADDING_PX = 16;
|
|
|
9
9
|
export const STRIP_WIDTH_PX = 24;
|
|
10
10
|
/* The strip's distance from the view's left edge, and the air it keeps between itself and the thread's text. */
|
|
11
11
|
export const STRIP_INSET_PX = 8;
|
|
12
|
-
const STRIP_CLEARANCE_PX = STRIP_INSET_PX + STRIP_WIDTH_PX + STRIP_INSET_PX;
|
|
12
|
+
export const STRIP_CLEARANCE_PX = STRIP_INSET_PX + STRIP_WIDTH_PX + STRIP_INSET_PX;
|
|
13
13
|
const WIDTH_HOVERED_PX = 16;
|
|
14
14
|
const WIDTH_NEAR_PERSON_PX = 12;
|
|
15
15
|
const WIDTH_NEAR_WAKE_PX = 8;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ChatVisual } from '@adecore/agent-contracts/visual';
|
|
2
2
|
export declare function clampVisualHeight(visual: Pick<ChatVisual, 'maxHeight'>, height: number): number;
|
|
3
|
+
export declare function visualContentHeight(height: number): number;
|
|
4
|
+
export declare function initialVisualContentHeight(visual: Pick<ChatVisual, 'id' | 'heights'>, width: number): number;
|
|
3
5
|
export declare function initialVisualHeight(visual: Pick<ChatVisual, 'id' | 'maxHeight' | 'heights'>, width: number): number;
|
|
4
6
|
export declare function rememberVisualHeight(visualId: string, width: number, height: number): void;
|
|
@@ -3,6 +3,8 @@ import { VISUAL_LIMITS, visualFrameHeight } from '@adecore/agent-contracts/visua
|
|
|
3
3
|
const DEFAULT_HEIGHT_PX = 240;
|
|
4
4
|
/* Plenty for the visuals of a long session; the oldest goes first. */
|
|
5
5
|
const REMEMBERED_HEIGHTS = 500;
|
|
6
|
+
// Stay within browser layout coordinates even if a page reports a bogus finite size.
|
|
7
|
+
const MAX_CONTENT_HEIGHT_PX = 10_000_000;
|
|
6
8
|
/*
|
|
7
9
|
* The heights frames reported, by visual and width. The thread draws only the rows in view, so a row
|
|
8
10
|
* that scrolls back in mounts a new frame, which starts at the height its page had there before.
|
|
@@ -15,6 +17,18 @@ function keyOf(visualId, width) {
|
|
|
15
17
|
export function clampVisualHeight(visual, height) {
|
|
16
18
|
return Math.min(Math.max(Math.ceil(Math.min(height, visual.maxHeight)), VISUAL_LIMITS.minHeight), VISUAL_LIMITS.maxHeight);
|
|
17
19
|
}
|
|
20
|
+
export function visualContentHeight(height) {
|
|
21
|
+
return Math.min(MAX_CONTENT_HEIGHT_PX, Math.max(VISUAL_LIMITS.minHeight, Math.ceil(height)));
|
|
22
|
+
}
|
|
23
|
+
export function initialVisualContentHeight(visual, width) {
|
|
24
|
+
const cached = remembered.get(keyOf(visual.id, width));
|
|
25
|
+
if (cached !== undefined) {
|
|
26
|
+
return cached;
|
|
27
|
+
}
|
|
28
|
+
const below = visual.heights?.filter(([measured]) => measured <= width).sort((left, right) => right[0] - left[0])[0];
|
|
29
|
+
const above = visual.heights?.filter(([measured]) => measured >= width).sort((left, right) => left[0] - right[0])[0];
|
|
30
|
+
return below || above ? visualContentHeight(Math.max(below?.[1] ?? 0, above?.[1] ?? 0)) : DEFAULT_HEIGHT_PX;
|
|
31
|
+
}
|
|
18
32
|
/*
|
|
19
33
|
* The height a frame `width` CSS pixels wide starts at: what the page reported in a frame that wide
|
|
20
34
|
* before, else what the host measured, else a modest default.
|
package/dist/chat/ui/Timeline.js
CHANGED
|
@@ -11,7 +11,7 @@ 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';
|
|
14
|
-
import { SCRUBBER_MIN_TICKS, STRIP_INSET_PX, STRIP_WIDTH_PX, messagesInView, stepMessage, threadPaddingLeft, tickOfRow, ticksOf, ticksWithHits } from '../logic/scrubber.js';
|
|
14
|
+
import { SCRUBBER_MIN_TICKS, STRIP_INSET_PX, STRIP_WIDTH_PX, messagesInView, stepMessage, threadPaddingLeft, STRIP_CLEARANCE_PX, tickOfRow, ticksOf, ticksWithHits } from '../logic/scrubber.js';
|
|
15
15
|
import { EMPTY_TARGET, readTimelineTarget, withCurrentText } from '../logic/timeline-target.js';
|
|
16
16
|
import { forkRefusal } from '../logic/fork.js';
|
|
17
17
|
import { FindRevealContext } from './find-reveal.js';
|
|
@@ -20,12 +20,13 @@ import { MessageActions } from './MessageActions.js';
|
|
|
20
20
|
import { Scrubber } from './Scrubber.js';
|
|
21
21
|
import { TimelineMenuPopup } from './TimelineMenu.js';
|
|
22
22
|
import { VisualDialogs } from './VisualDialogs.js';
|
|
23
|
-
import { FOLLOW_THRESHOLD_PX, replyHeader
|
|
23
|
+
import { FOLLOW_THRESHOLD_PX, replyHeader } from './rows/row-rhythm.js';
|
|
24
24
|
import { QuoteButton } from './QuoteButton.js';
|
|
25
25
|
import { QuoteTakerContext } from './quote-selection.js';
|
|
26
26
|
import { useToggleSet } from './useToggleSet.js';
|
|
27
27
|
import { WelcomeGreeting } from './Welcome.js';
|
|
28
28
|
import { Row } from './rows/Rows.js';
|
|
29
|
+
import { RowContainer } from './rows/RowContainer.js';
|
|
29
30
|
import { ReplyHeader } from './rows/MessageRows.js';
|
|
30
31
|
import { useChatRow, useChats } from '../../state/chats.js';
|
|
31
32
|
import { chatHost } from '../../host.js';
|
|
@@ -416,13 +417,14 @@ export function Timeline({ chatId, composer, overlay }) {
|
|
|
416
417
|
if (wantsEarlier(el)) {
|
|
417
418
|
loadEarlier();
|
|
418
419
|
}
|
|
419
|
-
}, children: _jsxs("div", { ref: contentRef, className: clsx('relative flex min-h-full flex-col', welcome && 'justify-center'), children: [_jsx(ContextMenu.Trigger, { ref: threadRef, className: clsx('chat-thread flex flex-col px-4 pt-4', !welcome && 'grow'), style: {
|
|
420
|
+
}, children: _jsxs("div", { ref: contentRef, className: clsx('relative flex min-h-full flex-col', welcome && 'justify-center'), children: [_jsx(ContextMenu.Trigger, { ref: threadRef, className: clsx('chat-thread flex flex-col px-4 pt-4', !welcome && 'grow'), style: {
|
|
421
|
+
paddingLeft,
|
|
422
|
+
'--chat-wide-inset': `${2 * (showsScrubber ? STRIP_CLEARANCE_PX : paddingLeft) - paddingLeft}px`
|
|
423
|
+
}, inert: overlay ? true : undefined, onContextMenu: (e) => setTarget(readTimelineTarget(e.target, threadRef.current, withCurrentText(rows, fullItems()))), children: welcome ? (_jsx(WelcomeGreeting, { chatId: chatId })) : empty ? (_jsx(EmptyThread, { chatId: chatId })) : (_jsx("div", { className: "relative w-full shrink-0", style: { height: virtualizer.getTotalSize() }, children: virtualizer.getVirtualItems().map((virtualRow) => {
|
|
420
424
|
const row = rows[virtualRow.index];
|
|
421
|
-
// Put turn gaps inside the measured row so the virtualizer includes them.
|
|
422
|
-
const question = row.kind === 'user';
|
|
423
425
|
const previous = virtualRow.index > 0 ? rows[virtualRow.index - 1] : null;
|
|
424
426
|
const header = replyHeader(row, previous);
|
|
425
|
-
return (_jsxs(
|
|
427
|
+
return (_jsxs(RowContainer, { row: row, previous: previous, index: virtualRow.index, top: virtualRow.start, ref: virtualizer.measureElement, 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("div", { className: "chat-column-content w-full", children: _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));
|
|
426
428
|
}) })) }), _jsx(QuoteButton, { thread: threadRef, frame: contentRef, taker: quoteTakerRef }), _jsx("div", { ref: composerRef,
|
|
427
429
|
// The card is translucent so the thread shows through it, but the pb-3 strip under it must stay opaque.
|
|
428
430
|
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 }))] }) }));
|
|
@@ -6,8 +6,9 @@ import { visualThemeFragment } from '@adecore/agent-contracts/visual';
|
|
|
6
6
|
import { Spinner, useMeasuredWidth } from '@adecore/ui';
|
|
7
7
|
import { chatHost } from '../../host.js';
|
|
8
8
|
import { useChatScope } from '../../scope.js';
|
|
9
|
-
import {
|
|
9
|
+
import { initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height.js';
|
|
10
10
|
import { VisualBridge } from '../visual-bridge.js';
|
|
11
|
+
import { VisualViewportController } from '../visual-viewport.js';
|
|
11
12
|
import { useVisualTheme } from './visual-theme.js';
|
|
12
13
|
/*
|
|
13
14
|
* A link the page asks to open counts only while a person acts in the frame: it has the focus, and a
|
|
@@ -30,6 +31,9 @@ export function VisualFrame({ chatId, visual, fill = false, className }) {
|
|
|
30
31
|
const boxRef = useRef(null);
|
|
31
32
|
const frameRef = useRef(null);
|
|
32
33
|
const bridgeRef = useRef(null);
|
|
34
|
+
const viewportRef = useRef(null);
|
|
35
|
+
const controllerRef = useRef(null);
|
|
36
|
+
const [viewportReady, setViewportReady] = useState(false);
|
|
33
37
|
const [measure, width] = useMeasuredWidth();
|
|
34
38
|
const setBox = useCallback((node) => {
|
|
35
39
|
boxRef.current = node;
|
|
@@ -46,7 +50,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }) {
|
|
|
46
50
|
if (opening === null && frameUrl !== null && theme !== null && width > 0) {
|
|
47
51
|
setOpening({ src: frameUrl + visualThemeFragment(theme), theme });
|
|
48
52
|
}
|
|
49
|
-
const height = fill || width === 0 ? undefined : (reported ??
|
|
53
|
+
const height = fill || width === 0 ? undefined : (reported ?? initialVisualContentHeight(visual, width));
|
|
50
54
|
useLayoutEffect(() => {
|
|
51
55
|
widthRef.current = width;
|
|
52
56
|
}, [width]);
|
|
@@ -71,6 +75,12 @@ export function VisualFrame({ chatId, visual, fill = false, className }) {
|
|
|
71
75
|
setReported(next);
|
|
72
76
|
rememberVisualHeight(visual.id, widthRef.current, next);
|
|
73
77
|
},
|
|
78
|
+
...(!fill
|
|
79
|
+
? {
|
|
80
|
+
onViewportReady: () => setViewportReady(true),
|
|
81
|
+
onScroll: (request) => controllerRef.current?.scroll(request)
|
|
82
|
+
}
|
|
83
|
+
: {}),
|
|
74
84
|
onAppearance: setDrawn,
|
|
75
85
|
onFailure: () => setFailed(true)
|
|
76
86
|
});
|
|
@@ -84,12 +94,28 @@ export function VisualFrame({ chatId, visual, fill = false, className }) {
|
|
|
84
94
|
bridgeRef.current = null;
|
|
85
95
|
};
|
|
86
96
|
}, [opening, scope.id, chatId, visual.id, visual.maxHeight, fill]);
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
const box = boxRef.current;
|
|
99
|
+
const viewport = viewportRef.current;
|
|
100
|
+
const bridge = bridgeRef.current;
|
|
101
|
+
const scroller = box?.closest('.chat-scroll');
|
|
102
|
+
if (fill || !viewportReady || !box || !viewport || !bridge || !scroller) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const controller = new VisualViewportController(box, viewport, scroller, bridge);
|
|
106
|
+
controllerRef.current = controller;
|
|
107
|
+
return () => {
|
|
108
|
+
controller.dispose();
|
|
109
|
+
controllerRef.current = null;
|
|
110
|
+
};
|
|
111
|
+
}, [fill, viewportReady, opening, scope.id, chatId, visual.id, visual.maxHeight]);
|
|
112
|
+
useLayoutEffect(() => controllerRef.current?.refresh(), [height, width]);
|
|
87
113
|
useEffect(() => {
|
|
88
114
|
if (theme !== null) {
|
|
89
115
|
bridgeRef.current?.setTheme(theme);
|
|
90
116
|
}
|
|
91
117
|
}, [theme]);
|
|
92
|
-
return (
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
return (_jsx("div", { ref: setBox, className: clsx(fill ? 'absolute inset-0' : 'relative w-full', className), style: { height }, children: _jsxs("div", { ref: viewportRef, className: fill ? 'absolute inset-0' : 'sticky top-0 w-full', style: fill ? undefined : { height: '100%' }, 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'),
|
|
119
|
+
// A frame whose color scheme differs from its document's paints an opaque ground behind it.
|
|
120
|
+
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
121
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react';
|
|
2
|
+
import type { TimelineRow } from '../../logic/timeline.js';
|
|
3
|
+
export interface RowContainerProps {
|
|
4
|
+
row: TimelineRow;
|
|
5
|
+
previous: TimelineRow | null;
|
|
6
|
+
index: number;
|
|
7
|
+
top: number;
|
|
8
|
+
ref?: Ref<HTMLDivElement>;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
export declare function RowContainer({ row, previous, index, top, ref, children }: RowContainerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { rowPosition, rowRhythm } from './row-rhythm.js';
|
|
4
|
+
export function RowContainer({ row, previous, index, top, ref, children }) {
|
|
5
|
+
const wide = row.kind === 'visual' && row.visual.layout === 'wide';
|
|
6
|
+
return (_jsx("div", { "data-index": index, "data-item-id": row.id, ref: ref, className: clsx('absolute inset-x-0 top-0 mx-auto w-full', wide ? 'chat-wide-content' : 'chat-column-content', rowRhythm(row, previous),
|
|
7
|
+
// Turn gaps belong inside the measurement used by the virtualizer.
|
|
8
|
+
row.kind === 'user' && index > 0 && 'pt-(--chat-turn-gap)'), style: rowPosition(row.kind, top), children: children }));
|
|
9
|
+
}
|
|
@@ -7,7 +7,7 @@ import { useChatScope } from '../../../scope.js';
|
|
|
7
7
|
import { useVisualDialog } from '../../visuals.js';
|
|
8
8
|
import { VisualFrame } from '../VisualFrame.js';
|
|
9
9
|
/*
|
|
10
|
-
* A page an agent published, borderless on the thread's ground
|
|
10
|
+
* A page an agent published, borderless on the thread's ground, with its width set by the row. Its few
|
|
11
11
|
* controls float over the page's top corner while the pointer or the focus is on it, and always on a
|
|
12
12
|
* touch screen, which has no hover. A click inside the page never reaches the thread's menu, so they
|
|
13
13
|
* sit here.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
1
2
|
import { type TimelineRow } from '../../logic/timeline.js';
|
|
2
3
|
export declare const FOLLOW_THRESHOLD_PX = 40;
|
|
3
4
|
export declare function rowRhythm(row: TimelineRow, previous: TimelineRow | null): string;
|
|
4
5
|
export declare function replyHeader(row: TimelineRow, previous: TimelineRow | null): {
|
|
5
6
|
at: number | null;
|
|
6
7
|
} | null;
|
|
8
|
+
export declare function rowPosition(kind: TimelineRow['kind'], top: number): CSSProperties;
|
|
@@ -32,3 +32,7 @@ export function replyHeader(row, previous) {
|
|
|
32
32
|
}
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
|
+
// A transform also shifts a descendant's sticky position after it was resolved against the scrollport.
|
|
36
|
+
export function rowPosition(kind, top) {
|
|
37
|
+
return kind === 'visual' ? { top } : { transform: `translateY(${top}px)` };
|
|
38
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
|
|
1
|
+
import { type ChatVisual, type VisualAppearance, type VisualScrollRequest, type VisualViewport, type VisualTheme } from '@adecore/agent-contracts/visual';
|
|
2
2
|
export interface VisualFrameWindow {
|
|
3
3
|
postMessage(message: unknown, targetOrigin: string): void;
|
|
4
4
|
}
|
|
@@ -10,6 +10,8 @@ export interface VisualBridgeOptions {
|
|
|
10
10
|
mayOpenLink(): boolean;
|
|
11
11
|
openLink(url: string): void;
|
|
12
12
|
onHeight(height: number): void;
|
|
13
|
+
onViewportReady?(): void;
|
|
14
|
+
onScroll?(request: VisualScrollRequest): void;
|
|
13
15
|
onAppearance(appearance: VisualAppearance): void;
|
|
14
16
|
onFailure(): void;
|
|
15
17
|
after?(ms: number, run: () => void): () => void;
|
|
@@ -22,6 +24,8 @@ export declare class VisualBridge {
|
|
|
22
24
|
private waiting;
|
|
23
25
|
private sent;
|
|
24
26
|
private live;
|
|
27
|
+
private viewportReady;
|
|
28
|
+
private viewport;
|
|
25
29
|
private disposed;
|
|
26
30
|
private cancelSilence;
|
|
27
31
|
constructor(options: VisualBridgeOptions);
|
|
@@ -29,6 +33,7 @@ export declare class VisualBridge {
|
|
|
29
33
|
source: unknown;
|
|
30
34
|
data: unknown;
|
|
31
35
|
}): void;
|
|
36
|
+
setViewport(viewport: VisualViewport | null): void;
|
|
32
37
|
setTheme(theme: VisualTheme): void;
|
|
33
38
|
loaded(): void;
|
|
34
39
|
dispose(): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { VISUAL_BRIDGE_METHODS, parseVisualMessage, visualHostContextMessage, visualOpenLinkResult, visualResourceReadyMessage } from '@adecore/agent-contracts/visual';
|
|
2
|
-
import { clampVisualHeight } from './logic/visual-height.js';
|
|
1
|
+
import { VISUAL_BRIDGE_METHODS, parseVisualMessage, visualHostContextMessage, visualOpenLinkResult, visualResourceReadyMessage, visualViewportMessage } from '@adecore/agent-contracts/visual';
|
|
2
|
+
import { clampVisualHeight, visualContentHeight } from './logic/visual-height.js';
|
|
3
3
|
/*
|
|
4
4
|
* How long a frame that finished loading may stay silent before it counts as failed. The sandbox host
|
|
5
5
|
* page speaks while it loads, so silence means the address served something else; the margin is for
|
|
@@ -28,6 +28,8 @@ export class VisualBridge {
|
|
|
28
28
|
sent = null;
|
|
29
29
|
/* Whether the page reported a size since it was sent, which says it runs and is drawn. */
|
|
30
30
|
live = false;
|
|
31
|
+
viewportReady = false;
|
|
32
|
+
viewport = null;
|
|
31
33
|
disposed = false;
|
|
32
34
|
cancelSilence = () => undefined;
|
|
33
35
|
constructor(options) {
|
|
@@ -56,6 +58,7 @@ export class VisualBridge {
|
|
|
56
58
|
this.waiting = true;
|
|
57
59
|
this.sent = null;
|
|
58
60
|
this.live = false;
|
|
61
|
+
this.viewportReady = false;
|
|
59
62
|
this.cancelSilence();
|
|
60
63
|
this.sendPage();
|
|
61
64
|
return;
|
|
@@ -67,7 +70,20 @@ export class VisualBridge {
|
|
|
67
70
|
this.live = true;
|
|
68
71
|
this.options.onAppearance(this.sent.appearance);
|
|
69
72
|
}
|
|
70
|
-
this.options.onHeight(clampVisualHeight(this.options.visual, message.height));
|
|
73
|
+
this.options.onHeight(this.options.onViewportReady ? visualContentHeight(message.height) : clampVisualHeight(this.options.visual, message.height));
|
|
74
|
+
return;
|
|
75
|
+
case VISUAL_BRIDGE_METHODS.viewportReady:
|
|
76
|
+
if (this.sent === null) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.viewportReady = true;
|
|
80
|
+
this.options.onViewportReady?.();
|
|
81
|
+
this.setViewport(this.viewport);
|
|
82
|
+
return;
|
|
83
|
+
case VISUAL_BRIDGE_METHODS.scrollRequest:
|
|
84
|
+
if (this.sent !== null && this.viewportReady && this.viewport !== null) {
|
|
85
|
+
this.options.onScroll?.(message.request);
|
|
86
|
+
}
|
|
71
87
|
return;
|
|
72
88
|
case VISUAL_BRIDGE_METHODS.openLink:
|
|
73
89
|
if (this.sent === null) {
|
|
@@ -82,6 +98,12 @@ export class VisualBridge {
|
|
|
82
98
|
return;
|
|
83
99
|
}
|
|
84
100
|
}
|
|
101
|
+
setViewport(viewport) {
|
|
102
|
+
this.viewport = viewport;
|
|
103
|
+
if (!this.disposed && this.sent !== null && this.viewportReady) {
|
|
104
|
+
this.options.frame()?.postMessage(visualViewportMessage(viewport), '*');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
85
107
|
/* The app's theme changed; a page that was sent follows it at once, without loading again. */
|
|
86
108
|
setTheme(theme) {
|
|
87
109
|
this.theme = theme;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { VisualScrollRequest } from '@adecore/agent-contracts/visual';
|
|
2
|
+
import type { VisualBridge } from './visual-bridge.js';
|
|
3
|
+
/** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
|
|
4
|
+
export declare class VisualViewportController {
|
|
5
|
+
private readonly box;
|
|
6
|
+
private readonly viewport;
|
|
7
|
+
private readonly scroller;
|
|
8
|
+
private readonly bridge;
|
|
9
|
+
private readonly observer;
|
|
10
|
+
private readonly attributes;
|
|
11
|
+
private readonly view;
|
|
12
|
+
private frame;
|
|
13
|
+
private disposed;
|
|
14
|
+
private last;
|
|
15
|
+
private pendingDelta;
|
|
16
|
+
private readonly onScroll;
|
|
17
|
+
constructor(box: HTMLElement, viewport: HTMLElement, scroller: HTMLElement, bridge: VisualBridge);
|
|
18
|
+
refresh(): void;
|
|
19
|
+
scroll(request: VisualScrollRequest): void;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
private schedule;
|
|
22
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { visualViewportGeometry } from './logic/visual-viewport.js';
|
|
2
|
+
/** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
|
|
3
|
+
export class VisualViewportController {
|
|
4
|
+
box;
|
|
5
|
+
viewport;
|
|
6
|
+
scroller;
|
|
7
|
+
bridge;
|
|
8
|
+
observer;
|
|
9
|
+
attributes;
|
|
10
|
+
view;
|
|
11
|
+
frame = 0;
|
|
12
|
+
disposed = false;
|
|
13
|
+
last = '';
|
|
14
|
+
pendingDelta = 0;
|
|
15
|
+
onScroll;
|
|
16
|
+
constructor(box, viewport, scroller, bridge) {
|
|
17
|
+
this.box = box;
|
|
18
|
+
this.viewport = viewport;
|
|
19
|
+
this.scroller = scroller;
|
|
20
|
+
this.bridge = bridge;
|
|
21
|
+
this.view = box.ownerDocument.defaultView;
|
|
22
|
+
this.onScroll = () => this.refresh();
|
|
23
|
+
this.observer = new ResizeObserver(this.onScroll);
|
|
24
|
+
this.observer.observe(box);
|
|
25
|
+
this.observer.observe(scroller);
|
|
26
|
+
this.attributes = new MutationObserver(this.onScroll);
|
|
27
|
+
const row = box.closest('[data-index]');
|
|
28
|
+
if (row) {
|
|
29
|
+
this.attributes.observe(row, { attributes: true, attributeFilter: ['style'] });
|
|
30
|
+
}
|
|
31
|
+
scroller.addEventListener('scroll', this.onScroll, { passive: true });
|
|
32
|
+
this.refresh();
|
|
33
|
+
}
|
|
34
|
+
refresh() {
|
|
35
|
+
if (this.disposed) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const outer = this.scroller.getBoundingClientRect();
|
|
39
|
+
const box = this.box.getBoundingClientRect();
|
|
40
|
+
// A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
|
|
41
|
+
const scale = outer.height / this.scroller.offsetHeight || 1;
|
|
42
|
+
const top = (box.top - outer.top) / scale - this.scroller.clientTop;
|
|
43
|
+
const available = this.scroller.clientHeight;
|
|
44
|
+
const geometry = visualViewportGeometry(this.box.offsetHeight, available, top);
|
|
45
|
+
this.viewport.style.height = `${geometry.height}px`;
|
|
46
|
+
const key = `${geometry.top}:${geometry.height}`;
|
|
47
|
+
if (key !== this.last) {
|
|
48
|
+
this.last = key;
|
|
49
|
+
this.bridge.setViewport(geometry);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
scroll(request) {
|
|
53
|
+
const outer = this.scroller.getBoundingClientRect();
|
|
54
|
+
const box = this.box.getBoundingClientRect();
|
|
55
|
+
if (this.disposed || box.bottom <= outer.top || box.top >= outer.bottom) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if ('by' in request) {
|
|
59
|
+
this.pendingDelta += request.by;
|
|
60
|
+
this.schedule();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.pendingDelta = 0;
|
|
64
|
+
const scale = outer.height / this.scroller.offsetHeight || 1;
|
|
65
|
+
const start = this.scroller.scrollTop + (box.top - outer.top) / scale - this.scroller.clientTop;
|
|
66
|
+
const top = 'to' in request ? start + request.to : request.edge === 'start' ? 0 : this.scroller.scrollHeight;
|
|
67
|
+
this.scroller.scrollTo({ top, behavior: 'instant' });
|
|
68
|
+
this.refresh();
|
|
69
|
+
}
|
|
70
|
+
dispose() {
|
|
71
|
+
this.disposed = true;
|
|
72
|
+
this.view.cancelAnimationFrame(this.frame);
|
|
73
|
+
this.observer.disconnect();
|
|
74
|
+
this.attributes.disconnect();
|
|
75
|
+
this.scroller.removeEventListener('scroll', this.onScroll);
|
|
76
|
+
this.bridge.setViewport(null);
|
|
77
|
+
}
|
|
78
|
+
schedule() {
|
|
79
|
+
if (this.frame || this.disposed) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.frame = this.view.requestAnimationFrame(() => {
|
|
83
|
+
this.frame = 0;
|
|
84
|
+
if (this.pendingDelta !== 0) {
|
|
85
|
+
this.scroller.scrollBy({ top: this.pendingDelta, behavior: 'instant' });
|
|
86
|
+
this.pendingDelta = 0;
|
|
87
|
+
}
|
|
88
|
+
this.refresh();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/theme.css
CHANGED
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
* thread grows past a screenful; only the rows and the composer inside it are centered.
|
|
97
97
|
*/
|
|
98
98
|
.chat-column .chat-column-content { margin-inline: auto; max-width: 768px; }
|
|
99
|
+
/* Wide pages clear the scrubber even when the reply column needs no extra padding. */
|
|
100
|
+
.chat-wide-content { max-width: calc(100% - 2 * var(--chat-wide-inset, 0px)); }
|
|
99
101
|
/* Wider by the input's inset (`.composer-input .cm-content` padding plus the card's border), so what is typed lines up with the thread. */
|
|
100
102
|
.chat-column .chat-composer-content { margin-inline: auto; max-width: calc(768px + 2 * 21px); }
|
|
101
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -559,6 +559,11 @@
|
|
|
559
559
|
"types": "./dist/chat/ui/rows/MessageRows.d.ts",
|
|
560
560
|
"default": "./dist/chat/ui/rows/MessageRows.js"
|
|
561
561
|
},
|
|
562
|
+
"./chat/ui/rows/RowContainer": {
|
|
563
|
+
"source": "./src/chat/ui/rows/RowContainer.tsx",
|
|
564
|
+
"types": "./dist/chat/ui/rows/RowContainer.d.ts",
|
|
565
|
+
"default": "./dist/chat/ui/rows/RowContainer.js"
|
|
566
|
+
},
|
|
562
567
|
"./chat/ui/rows/Rows": {
|
|
563
568
|
"source": "./src/chat/ui/rows/Rows.tsx",
|
|
564
569
|
"types": "./dist/chat/ui/rows/Rows.d.ts",
|
|
@@ -867,12 +872,23 @@
|
|
|
867
872
|
"source": "./src/storage.ts",
|
|
868
873
|
"types": "./dist/storage.d.ts",
|
|
869
874
|
"default": "./dist/storage.js"
|
|
875
|
+
},
|
|
876
|
+
"./chat/logic/visual-viewport": {
|
|
877
|
+
"source": "./src/chat/logic/visual-viewport.ts",
|
|
878
|
+
"types": "./dist/chat/logic/visual-viewport.d.ts",
|
|
879
|
+
"default": "./dist/chat/logic/visual-viewport.js"
|
|
880
|
+
},
|
|
881
|
+
"./chat/visual-viewport": {
|
|
882
|
+
"source": "./src/chat/visual-viewport.ts",
|
|
883
|
+
"types": "./dist/chat/visual-viewport.d.ts",
|
|
884
|
+
"default": "./dist/chat/visual-viewport.js"
|
|
870
885
|
}
|
|
871
886
|
},
|
|
872
887
|
"scripts": {
|
|
873
888
|
"typecheck": "tsc --noEmit",
|
|
874
889
|
"build": "bun scripts/build.ts",
|
|
875
|
-
"test": "bun --conditions=source test"
|
|
890
|
+
"test": "bun --conditions=source test",
|
|
891
|
+
"test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
|
|
876
892
|
},
|
|
877
893
|
"dependencies": {
|
|
878
894
|
"@codemirror/commands": "^6.11.1",
|
|
@@ -883,7 +899,7 @@
|
|
|
883
899
|
"@lezer/highlight": "^1.2.3",
|
|
884
900
|
"@lezer/markdown": "^1.7.2",
|
|
885
901
|
"@pierre/diffs": "^1.4.3",
|
|
886
|
-
"@adecore/agent-contracts": "0.
|
|
902
|
+
"@adecore/agent-contracts": "0.19.0",
|
|
887
903
|
"@tanstack/react-virtual": "^3.14.13",
|
|
888
904
|
"clsx": "^2.1.1",
|
|
889
905
|
"lucide-react": "^1.47.0",
|
|
@@ -896,7 +912,7 @@
|
|
|
896
912
|
"zustand": "^5.0.15"
|
|
897
913
|
},
|
|
898
914
|
"peerDependencies": {
|
|
899
|
-
"@adecore/ui": "^0.
|
|
915
|
+
"@adecore/ui": "^0.19.0",
|
|
900
916
|
"i18next": "^26.4.2",
|
|
901
917
|
"react": "^19.3.0",
|
|
902
918
|
"react-dom": "^19.3.0",
|
|
@@ -36,7 +36,7 @@ const THREAD_PADDING_PX = 16;
|
|
|
36
36
|
export const STRIP_WIDTH_PX = 24;
|
|
37
37
|
/* The strip's distance from the view's left edge, and the air it keeps between itself and the thread's text. */
|
|
38
38
|
export const STRIP_INSET_PX = 8;
|
|
39
|
-
const STRIP_CLEARANCE_PX = STRIP_INSET_PX + STRIP_WIDTH_PX + STRIP_INSET_PX;
|
|
39
|
+
export const STRIP_CLEARANCE_PX = STRIP_INSET_PX + STRIP_WIDTH_PX + STRIP_INSET_PX;
|
|
40
40
|
|
|
41
41
|
const WIDTH_HOVERED_PX = 16;
|
|
42
42
|
const WIDTH_NEAR_PERSON_PX = 12;
|
|
@@ -4,6 +4,8 @@ import { VISUAL_LIMITS, visualFrameHeight, type ChatVisual } from '@adecore/agen
|
|
|
4
4
|
const DEFAULT_HEIGHT_PX = 240;
|
|
5
5
|
/* Plenty for the visuals of a long session; the oldest goes first. */
|
|
6
6
|
const REMEMBERED_HEIGHTS = 500;
|
|
7
|
+
// Stay within browser layout coordinates even if a page reports a bogus finite size.
|
|
8
|
+
const MAX_CONTENT_HEIGHT_PX = 10_000_000;
|
|
7
9
|
|
|
8
10
|
/*
|
|
9
11
|
* The heights frames reported, by visual and width. The thread draws only the rows in view, so a row
|
|
@@ -20,6 +22,20 @@ export function clampVisualHeight(visual: Pick<ChatVisual, 'maxHeight'>, height:
|
|
|
20
22
|
return Math.min(Math.max(Math.ceil(Math.min(height, visual.maxHeight)), VISUAL_LIMITS.minHeight), VISUAL_LIMITS.maxHeight);
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export function visualContentHeight(height: number): number {
|
|
26
|
+
return Math.min(MAX_CONTENT_HEIGHT_PX, Math.max(VISUAL_LIMITS.minHeight, Math.ceil(height)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function initialVisualContentHeight(visual: Pick<ChatVisual, 'id' | 'heights'>, width: number): number {
|
|
30
|
+
const cached = remembered.get(keyOf(visual.id, width));
|
|
31
|
+
if (cached !== undefined) {
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
34
|
+
const below = visual.heights?.filter(([measured]) => measured <= width).sort((left, right) => right[0] - left[0])[0];
|
|
35
|
+
const above = visual.heights?.filter(([measured]) => measured >= width).sort((left, right) => left[0] - right[0])[0];
|
|
36
|
+
return below || above ? visualContentHeight(Math.max(below?.[1] ?? 0, above?.[1] ?? 0)) : DEFAULT_HEIGHT_PX;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/*
|
|
24
40
|
* The height a frame `width` CSS pixels wide starts at: what the page reported in a frame that wide
|
|
25
41
|
* before, else what the host measured, else a modest default.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface VisualViewportGeometry {
|
|
2
|
+
height: number;
|
|
3
|
+
top: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function visualViewportGeometry(contentHeight: number, availableHeight: number, top: number): VisualViewportGeometry {
|
|
7
|
+
const height = Math.max(1, Math.min(contentHeight, availableHeight));
|
|
8
|
+
return { height, top: Math.max(0, Math.min(-top, contentHeight - height)) };
|
|
9
|
+
}
|
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
1
|
+
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
4
|
import clsx from 'clsx';
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
messagesInView,
|
|
28
28
|
stepMessage,
|
|
29
29
|
threadPaddingLeft,
|
|
30
|
+
STRIP_CLEARANCE_PX,
|
|
30
31
|
tickOfRow,
|
|
31
32
|
ticksOf,
|
|
32
33
|
ticksWithHits
|
|
@@ -39,12 +40,13 @@ import { MessageActions } from './MessageActions';
|
|
|
39
40
|
import { Scrubber, type CardChat } from './Scrubber';
|
|
40
41
|
import { TimelineMenuPopup } from './TimelineMenu';
|
|
41
42
|
import { VisualDialogs } from './VisualDialogs';
|
|
42
|
-
import { FOLLOW_THRESHOLD_PX, replyHeader
|
|
43
|
+
import { FOLLOW_THRESHOLD_PX, replyHeader } from './rows/row-rhythm';
|
|
43
44
|
import { QuoteButton } from './QuoteButton';
|
|
44
45
|
import { QuoteTakerContext, type QuoteTaker } from './quote-selection';
|
|
45
46
|
import { useToggleSet } from './useToggleSet';
|
|
46
47
|
import { WelcomeGreeting } from './Welcome';
|
|
47
48
|
import { Row } from './rows/Rows';
|
|
49
|
+
import { RowContainer } from './rows/RowContainer';
|
|
48
50
|
import { ReplyHeader } from './rows/MessageRows';
|
|
49
51
|
import { useChatRow, useChats } from '../../state/chats';
|
|
50
52
|
import { chatHost } from '../../host';
|
|
@@ -530,7 +532,12 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
530
532
|
<ContextMenu.Trigger
|
|
531
533
|
ref={threadRef}
|
|
532
534
|
className={clsx('chat-thread flex flex-col px-4 pt-4', !welcome && 'grow')}
|
|
533
|
-
style={
|
|
535
|
+
style={
|
|
536
|
+
{
|
|
537
|
+
paddingLeft,
|
|
538
|
+
'--chat-wide-inset': `${2 * (showsScrubber ? STRIP_CLEARANCE_PX : paddingLeft) - paddingLeft}px`
|
|
539
|
+
} as CSSProperties
|
|
540
|
+
}
|
|
534
541
|
inert={overlay ? true : undefined}
|
|
535
542
|
onContextMenu={(e) =>
|
|
536
543
|
setTarget(readTimelineTarget(e.target as HTMLElement, threadRef.current, withCurrentText(rows, fullItems())))
|
|
@@ -541,25 +548,19 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
541
548
|
) : empty ? (
|
|
542
549
|
<EmptyThread chatId={chatId} />
|
|
543
550
|
) : (
|
|
544
|
-
<div className="
|
|
551
|
+
<div className="relative w-full shrink-0" style={{ height: virtualizer.getTotalSize() }}>
|
|
545
552
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
546
553
|
const row = rows[virtualRow.index]!;
|
|
547
|
-
// Put turn gaps inside the measured row so the virtualizer includes them.
|
|
548
|
-
const question = row.kind === 'user';
|
|
549
554
|
const previous = virtualRow.index > 0 ? rows[virtualRow.index - 1]! : null;
|
|
550
555
|
const header = replyHeader(row, previous);
|
|
551
556
|
return (
|
|
552
|
-
<
|
|
557
|
+
<RowContainer
|
|
553
558
|
key={row.id}
|
|
554
|
-
|
|
555
|
-
|
|
559
|
+
row={row}
|
|
560
|
+
previous={previous}
|
|
561
|
+
index={virtualRow.index}
|
|
562
|
+
top={virtualRow.start}
|
|
556
563
|
ref={virtualizer.measureElement}
|
|
557
|
-
className={clsx(
|
|
558
|
-
'absolute left-0 top-0 w-full',
|
|
559
|
-
rowRhythm(row, previous),
|
|
560
|
-
question && virtualRow.index > 0 && 'pt-(--chat-turn-gap)'
|
|
561
|
-
)}
|
|
562
|
-
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
563
564
|
>
|
|
564
565
|
{flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && (
|
|
565
566
|
<span
|
|
@@ -568,7 +569,11 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
568
569
|
aria-hidden
|
|
569
570
|
/>
|
|
570
571
|
)}
|
|
571
|
-
{header !== null &&
|
|
572
|
+
{header !== null && (
|
|
573
|
+
<div className="chat-column-content w-full">
|
|
574
|
+
<ReplyHeader chatId={chatId} at={header.at} />
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
572
577
|
<FindRevealContext.Provider value={chatFind.reveal}>
|
|
573
578
|
<MarkableRow row={row} chatId={chatId} bookmark={marks.get(row.id) ?? null}>
|
|
574
579
|
<Row
|
|
@@ -582,7 +587,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
582
587
|
/>
|
|
583
588
|
</MarkableRow>
|
|
584
589
|
</FindRevealContext.Provider>
|
|
585
|
-
</
|
|
590
|
+
</RowContainer>
|
|
586
591
|
);
|
|
587
592
|
})}
|
|
588
593
|
</div>
|
|
@@ -5,8 +5,9 @@ import { visualThemeFragment, type ChatVisual, type VisualAppearance, type Visua
|
|
|
5
5
|
import { Spinner, useMeasuredWidth } from '@adecore/ui';
|
|
6
6
|
import { chatHost } from '../../host';
|
|
7
7
|
import { useChatScope } from '../../scope';
|
|
8
|
-
import {
|
|
8
|
+
import { initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
|
|
9
9
|
import { VisualBridge } from '../visual-bridge';
|
|
10
|
+
import { VisualViewportController } from '../visual-viewport';
|
|
10
11
|
import { useVisualTheme } from './visual-theme';
|
|
11
12
|
|
|
12
13
|
export interface VisualFrameProps {
|
|
@@ -39,6 +40,9 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
|
|
|
39
40
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
40
41
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
41
42
|
const bridgeRef = useRef<VisualBridge | null>(null);
|
|
43
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
44
|
+
const controllerRef = useRef<VisualViewportController | null>(null);
|
|
45
|
+
const [viewportReady, setViewportReady] = useState(false);
|
|
42
46
|
const [measure, width] = useMeasuredWidth();
|
|
43
47
|
const setBox = useCallback(
|
|
44
48
|
(node: HTMLDivElement | null) => {
|
|
@@ -58,7 +62,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
|
|
|
58
62
|
if (opening === null && frameUrl !== null && theme !== null && width > 0) {
|
|
59
63
|
setOpening({ src: frameUrl + visualThemeFragment(theme), theme });
|
|
60
64
|
}
|
|
61
|
-
const height = fill || width === 0 ? undefined : (reported ??
|
|
65
|
+
const height = fill || width === 0 ? undefined : (reported ?? initialVisualContentHeight(visual, width));
|
|
62
66
|
|
|
63
67
|
useLayoutEffect(() => {
|
|
64
68
|
widthRef.current = width;
|
|
@@ -85,6 +89,12 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
|
|
|
85
89
|
setReported(next);
|
|
86
90
|
rememberVisualHeight(visual.id, widthRef.current, next);
|
|
87
91
|
},
|
|
92
|
+
...(!fill
|
|
93
|
+
? {
|
|
94
|
+
onViewportReady: () => setViewportReady(true),
|
|
95
|
+
onScroll: (request) => controllerRef.current?.scroll(request)
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
88
98
|
onAppearance: setDrawn,
|
|
89
99
|
onFailure: () => setFailed(true)
|
|
90
100
|
});
|
|
@@ -99,6 +109,24 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
|
|
|
99
109
|
};
|
|
100
110
|
}, [opening, scope.id, chatId, visual.id, visual.maxHeight, fill]);
|
|
101
111
|
|
|
112
|
+
useLayoutEffect(() => {
|
|
113
|
+
const box = boxRef.current;
|
|
114
|
+
const viewport = viewportRef.current;
|
|
115
|
+
const bridge = bridgeRef.current;
|
|
116
|
+
const scroller = box?.closest<HTMLElement>('.chat-scroll');
|
|
117
|
+
if (fill || !viewportReady || !box || !viewport || !bridge || !scroller) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const controller = new VisualViewportController(box, viewport, scroller, bridge);
|
|
121
|
+
controllerRef.current = controller;
|
|
122
|
+
return () => {
|
|
123
|
+
controller.dispose();
|
|
124
|
+
controllerRef.current = null;
|
|
125
|
+
};
|
|
126
|
+
}, [fill, viewportReady, opening, scope.id, chatId, visual.id, visual.maxHeight]);
|
|
127
|
+
|
|
128
|
+
useLayoutEffect(() => controllerRef.current?.refresh(), [height, width]);
|
|
129
|
+
|
|
102
130
|
useEffect(() => {
|
|
103
131
|
if (theme !== null) {
|
|
104
132
|
bridgeRef.current?.setTheme(theme);
|
|
@@ -107,30 +135,32 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
|
|
|
107
135
|
|
|
108
136
|
return (
|
|
109
137
|
<div ref={setBox} className={clsx(fill ? 'absolute inset-0' : 'relative w-full', className)} style={{ height }}>
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
<div ref={viewportRef} className={fill ? 'absolute inset-0' : 'sticky top-0 w-full'} style={fill ? undefined : { height: '100%' }}>
|
|
139
|
+
{opening !== null && (
|
|
140
|
+
<iframe
|
|
141
|
+
ref={frameRef}
|
|
142
|
+
src={opening.src}
|
|
143
|
+
title={visual.title}
|
|
144
|
+
loading="lazy"
|
|
145
|
+
sandbox="allow-scripts allow-forms"
|
|
146
|
+
className={clsx('block size-full border-0', failed && 'invisible')}
|
|
147
|
+
// A frame whose color scheme differs from its document's paints an opaque ground behind it.
|
|
148
|
+
style={{ colorScheme: drawn ?? 'normal' }}
|
|
149
|
+
onLoad={() => bridgeRef.current?.loaded()}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
{failed ? (
|
|
153
|
+
<p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">
|
|
154
|
+
{t('visuals.failed', { title: visual.title })}
|
|
155
|
+
</p>
|
|
156
|
+
) : (
|
|
157
|
+
drawn === null && (
|
|
158
|
+
<span className="chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint">
|
|
159
|
+
<Spinner size={16} label={t('visuals.loading', { title: visual.title })} />
|
|
160
|
+
</span>
|
|
161
|
+
)
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
134
164
|
</div>
|
|
135
165
|
);
|
|
136
166
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { TimelineRow } from '../../logic/timeline';
|
|
4
|
+
import { rowPosition, rowRhythm } from './row-rhythm';
|
|
5
|
+
|
|
6
|
+
export interface RowContainerProps {
|
|
7
|
+
row: TimelineRow;
|
|
8
|
+
previous: TimelineRow | null;
|
|
9
|
+
index: number;
|
|
10
|
+
top: number;
|
|
11
|
+
ref?: Ref<HTMLDivElement>;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function RowContainer({ row, previous, index, top, ref, children }: RowContainerProps) {
|
|
16
|
+
const wide = row.kind === 'visual' && row.visual.layout === 'wide';
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
data-index={index}
|
|
20
|
+
data-item-id={row.id}
|
|
21
|
+
ref={ref}
|
|
22
|
+
className={clsx(
|
|
23
|
+
'absolute inset-x-0 top-0 mx-auto w-full',
|
|
24
|
+
wide ? 'chat-wide-content' : 'chat-column-content',
|
|
25
|
+
rowRhythm(row, previous),
|
|
26
|
+
// Turn gaps belong inside the measurement used by the virtualizer.
|
|
27
|
+
row.kind === 'user' && index > 0 && 'pt-(--chat-turn-gap)'
|
|
28
|
+
)}
|
|
29
|
+
style={rowPosition(row.kind, top)}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -8,7 +8,7 @@ import { useVisualDialog } from '../../visuals';
|
|
|
8
8
|
import { VisualFrame } from '../VisualFrame';
|
|
9
9
|
|
|
10
10
|
/*
|
|
11
|
-
* A page an agent published, borderless on the thread's ground
|
|
11
|
+
* A page an agent published, borderless on the thread's ground, with its width set by the row. Its few
|
|
12
12
|
* controls float over the page's top corner while the pointer or the focus is on it, and always on a
|
|
13
13
|
* touch screen, which has no hover. A click inside the page never reaches the thread's menu, so they
|
|
14
14
|
* sit here.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
1
2
|
import clsx from 'clsx';
|
|
2
3
|
import { isBlock, type TimelineRow } from '../../logic/timeline';
|
|
3
4
|
|
|
@@ -35,3 +36,8 @@ export function replyHeader(row: TimelineRow, previous: TimelineRow | null): { a
|
|
|
35
36
|
}
|
|
36
37
|
return null;
|
|
37
38
|
}
|
|
39
|
+
|
|
40
|
+
// A transform also shifts a descendant's sticky position after it was resolved against the scrollport.
|
|
41
|
+
export function rowPosition(kind: TimelineRow['kind'], top: number): CSSProperties {
|
|
42
|
+
return kind === 'visual' ? { top } : { transform: `translateY(${top}px)` };
|
|
43
|
+
}
|
|
@@ -4,11 +4,14 @@ import {
|
|
|
4
4
|
visualHostContextMessage,
|
|
5
5
|
visualOpenLinkResult,
|
|
6
6
|
visualResourceReadyMessage,
|
|
7
|
+
visualViewportMessage,
|
|
7
8
|
type ChatVisual,
|
|
8
9
|
type VisualAppearance,
|
|
10
|
+
type VisualScrollRequest,
|
|
11
|
+
type VisualViewport,
|
|
9
12
|
type VisualTheme
|
|
10
13
|
} from '@adecore/agent-contracts/visual';
|
|
11
|
-
import { clampVisualHeight } from './logic/visual-height';
|
|
14
|
+
import { clampVisualHeight, visualContentHeight } from './logic/visual-height';
|
|
12
15
|
|
|
13
16
|
/*
|
|
14
17
|
* How long a frame that finished loading may stay silent before it counts as failed. The sandbox host
|
|
@@ -34,6 +37,8 @@ export interface VisualBridgeOptions {
|
|
|
34
37
|
mayOpenLink(): boolean;
|
|
35
38
|
openLink(url: string): void;
|
|
36
39
|
onHeight(height: number): void;
|
|
40
|
+
onViewportReady?(): void;
|
|
41
|
+
onScroll?(request: VisualScrollRequest): void;
|
|
37
42
|
/* The appearance the page is drawn in, from the first size it reports and after every theme it gets since. */
|
|
38
43
|
onAppearance(appearance: VisualAppearance): void;
|
|
39
44
|
/* The page could not be read, or the frame's address answered with something other than the sandbox host page. */
|
|
@@ -65,6 +70,8 @@ export class VisualBridge {
|
|
|
65
70
|
private sent: VisualTheme | null = null;
|
|
66
71
|
/* Whether the page reported a size since it was sent, which says it runs and is drawn. */
|
|
67
72
|
private live = false;
|
|
73
|
+
private viewportReady = false;
|
|
74
|
+
private viewport: VisualViewport | null = null;
|
|
68
75
|
private disposed = false;
|
|
69
76
|
private cancelSilence: () => void = () => undefined;
|
|
70
77
|
|
|
@@ -98,6 +105,7 @@ export class VisualBridge {
|
|
|
98
105
|
this.waiting = true;
|
|
99
106
|
this.sent = null;
|
|
100
107
|
this.live = false;
|
|
108
|
+
this.viewportReady = false;
|
|
101
109
|
this.cancelSilence();
|
|
102
110
|
this.sendPage();
|
|
103
111
|
return;
|
|
@@ -109,7 +117,22 @@ export class VisualBridge {
|
|
|
109
117
|
this.live = true;
|
|
110
118
|
this.options.onAppearance(this.sent.appearance);
|
|
111
119
|
}
|
|
112
|
-
this.options.onHeight(
|
|
120
|
+
this.options.onHeight(
|
|
121
|
+
this.options.onViewportReady ? visualContentHeight(message.height) : clampVisualHeight(this.options.visual, message.height)
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
case VISUAL_BRIDGE_METHODS.viewportReady:
|
|
125
|
+
if (this.sent === null) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.viewportReady = true;
|
|
129
|
+
this.options.onViewportReady?.();
|
|
130
|
+
this.setViewport(this.viewport);
|
|
131
|
+
return;
|
|
132
|
+
case VISUAL_BRIDGE_METHODS.scrollRequest:
|
|
133
|
+
if (this.sent !== null && this.viewportReady && this.viewport !== null) {
|
|
134
|
+
this.options.onScroll?.(message.request);
|
|
135
|
+
}
|
|
113
136
|
return;
|
|
114
137
|
case VISUAL_BRIDGE_METHODS.openLink:
|
|
115
138
|
if (this.sent === null) {
|
|
@@ -125,6 +148,13 @@ export class VisualBridge {
|
|
|
125
148
|
}
|
|
126
149
|
}
|
|
127
150
|
|
|
151
|
+
setViewport(viewport: VisualViewport | null): void {
|
|
152
|
+
this.viewport = viewport;
|
|
153
|
+
if (!this.disposed && this.sent !== null && this.viewportReady) {
|
|
154
|
+
this.options.frame()?.postMessage(visualViewportMessage(viewport), '*');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
128
158
|
/* The app's theme changed; a page that was sent follows it at once, without loading again. */
|
|
129
159
|
setTheme(theme: VisualTheme): void {
|
|
130
160
|
this.theme = theme;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { VisualScrollRequest } from '@adecore/agent-contracts/visual';
|
|
2
|
+
import { visualViewportGeometry } from './logic/visual-viewport';
|
|
3
|
+
import type { VisualBridge } from './visual-bridge';
|
|
4
|
+
|
|
5
|
+
/** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
|
|
6
|
+
export class VisualViewportController {
|
|
7
|
+
private readonly box: HTMLElement;
|
|
8
|
+
private readonly viewport: HTMLElement;
|
|
9
|
+
private readonly scroller: HTMLElement;
|
|
10
|
+
private readonly bridge: VisualBridge;
|
|
11
|
+
private readonly observer: ResizeObserver;
|
|
12
|
+
private readonly attributes: MutationObserver;
|
|
13
|
+
private readonly view: Window;
|
|
14
|
+
private frame = 0;
|
|
15
|
+
private disposed = false;
|
|
16
|
+
private last = '';
|
|
17
|
+
private pendingDelta = 0;
|
|
18
|
+
private readonly onScroll: () => void;
|
|
19
|
+
|
|
20
|
+
constructor(box: HTMLElement, viewport: HTMLElement, scroller: HTMLElement, bridge: VisualBridge) {
|
|
21
|
+
this.box = box;
|
|
22
|
+
this.viewport = viewport;
|
|
23
|
+
this.scroller = scroller;
|
|
24
|
+
this.bridge = bridge;
|
|
25
|
+
this.view = box.ownerDocument.defaultView!;
|
|
26
|
+
this.onScroll = () => this.refresh();
|
|
27
|
+
this.observer = new ResizeObserver(this.onScroll);
|
|
28
|
+
this.observer.observe(box);
|
|
29
|
+
this.observer.observe(scroller);
|
|
30
|
+
this.attributes = new MutationObserver(this.onScroll);
|
|
31
|
+
const row = box.closest('[data-index]');
|
|
32
|
+
if (row) {
|
|
33
|
+
this.attributes.observe(row, { attributes: true, attributeFilter: ['style'] });
|
|
34
|
+
}
|
|
35
|
+
scroller.addEventListener('scroll', this.onScroll, { passive: true });
|
|
36
|
+
this.refresh();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
refresh(): void {
|
|
40
|
+
if (this.disposed) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const outer = this.scroller.getBoundingClientRect();
|
|
44
|
+
const box = this.box.getBoundingClientRect();
|
|
45
|
+
// A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
|
|
46
|
+
const scale = outer.height / this.scroller.offsetHeight || 1;
|
|
47
|
+
const top = (box.top - outer.top) / scale - this.scroller.clientTop;
|
|
48
|
+
const available = this.scroller.clientHeight;
|
|
49
|
+
const geometry = visualViewportGeometry(this.box.offsetHeight, available, top);
|
|
50
|
+
this.viewport.style.height = `${geometry.height}px`;
|
|
51
|
+
const key = `${geometry.top}:${geometry.height}`;
|
|
52
|
+
if (key !== this.last) {
|
|
53
|
+
this.last = key;
|
|
54
|
+
this.bridge.setViewport(geometry);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
scroll(request: VisualScrollRequest): void {
|
|
59
|
+
const outer = this.scroller.getBoundingClientRect();
|
|
60
|
+
const box = this.box.getBoundingClientRect();
|
|
61
|
+
if (this.disposed || box.bottom <= outer.top || box.top >= outer.bottom) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if ('by' in request) {
|
|
65
|
+
this.pendingDelta += request.by;
|
|
66
|
+
this.schedule();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.pendingDelta = 0;
|
|
70
|
+
const scale = outer.height / this.scroller.offsetHeight || 1;
|
|
71
|
+
const start = this.scroller.scrollTop + (box.top - outer.top) / scale - this.scroller.clientTop;
|
|
72
|
+
const top = 'to' in request ? start + request.to : request.edge === 'start' ? 0 : this.scroller.scrollHeight;
|
|
73
|
+
this.scroller.scrollTo({ top, behavior: 'instant' });
|
|
74
|
+
this.refresh();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispose(): void {
|
|
78
|
+
this.disposed = true;
|
|
79
|
+
this.view.cancelAnimationFrame(this.frame);
|
|
80
|
+
this.observer.disconnect();
|
|
81
|
+
this.attributes.disconnect();
|
|
82
|
+
this.scroller.removeEventListener('scroll', this.onScroll);
|
|
83
|
+
this.bridge.setViewport(null);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private schedule(): void {
|
|
87
|
+
if (this.frame || this.disposed) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.frame = this.view.requestAnimationFrame(() => {
|
|
91
|
+
this.frame = 0;
|
|
92
|
+
if (this.pendingDelta !== 0) {
|
|
93
|
+
this.scroller.scrollBy({ top: this.pendingDelta, behavior: 'instant' });
|
|
94
|
+
this.pendingDelta = 0;
|
|
95
|
+
}
|
|
96
|
+
this.refresh();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/theme.css
CHANGED
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
* thread grows past a screenful; only the rows and the composer inside it are centered.
|
|
97
97
|
*/
|
|
98
98
|
.chat-column .chat-column-content { margin-inline: auto; max-width: 768px; }
|
|
99
|
+
/* Wide pages clear the scrubber even when the reply column needs no extra padding. */
|
|
100
|
+
.chat-wide-content { max-width: calc(100% - 2 * var(--chat-wide-inset, 0px)); }
|
|
99
101
|
/* Wider by the input's inset (`.composer-input .cm-content` padding plus the card's border), so what is typed lines up with the thread. */
|
|
100
102
|
.chat-column .chat-composer-content { margin-inline: auto; max-width: calc(768px + 2 * 21px); }
|
|
101
103
|
|