@adecore/agents-react 0.18.3 → 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.
@@ -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.
@@ -0,0 +1,5 @@
1
+ export interface VisualViewportGeometry {
2
+ height: number;
3
+ top: number;
4
+ }
5
+ export declare function visualViewportGeometry(contentHeight: number, availableHeight: number, top: number): VisualViewportGeometry;
@@ -0,0 +1,4 @@
1
+ export function visualViewportGeometry(contentHeight, availableHeight, top) {
2
+ const height = Math.max(1, Math.min(contentHeight, availableHeight));
3
+ return { height, top: Math.max(0, Math.min(-top, contentHeight - height)) };
4
+ }
@@ -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, rowRhythm } from './rows/row-rhythm.js';
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: { paddingLeft }, 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: "chat-column-content relative w-full shrink-0", style: { height: virtualizer.getTotalSize() }, children: virtualizer.getVirtualItems().map((virtualRow) => {
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("div", { "data-index": virtualRow.index, "data-item-id": row.id, ref: virtualizer.measureElement, className: clsx('absolute left-0 top-0 w-full', rowRhythm(row, previous), question && virtualRow.index > 0 && 'pt-(--chat-turn-gap)'), style: { transform: `translateY(${virtualRow.start}px)` }, children: [flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && (_jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce)), header !== null && _jsx(ReplyHeader, { chatId: chatId, at: header.at }), _jsx(FindRevealContext.Provider, { value: chatFind.reveal, children: _jsx(MarkableRow, { row: row, chatId: chatId, bookmark: marks.get(row.id) ?? null, children: _jsx(Row, { row: row, chatId: chatId, toggleGroup: groups.toggle, toggleTurn: turns.toggle, toggleSubagent: subagents.toggle, openSubagent: openSubagent, openConversation: openConversation }) }) })] }, row.id));
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 { initialVisualHeight, rememberVisualHeight } from '../logic/visual-height.js';
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 ?? initialVisualHeight(visual, width));
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 (_jsxs("div", { ref: setBox, className: clsx(fill ? 'absolute inset-0' : 'relative w-full', className), style: { height }, children: [opening !== null && (_jsx("iframe", { ref: frameRef, src: opening.src, title: visual.title, loading: "lazy", sandbox: "allow-scripts allow-forms", className: clsx('block size-full border-0', failed && 'invisible'),
93
- // A frame whose color scheme differs from its document's paints an opaque ground behind it.
94
- style: { colorScheme: drawn ?? 'normal' }, onLoad: () => bridgeRef.current?.loaded() })), failed ? (_jsx("p", { className: "absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted", children: t('visuals.failed', { title: visual.title }) })) : (drawn === null && (_jsx("span", { className: "chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint", children: _jsx(Spinner, { size: 16, label: t('visuals.loading', { title: visual.title }) }) })))] }));
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 and as wide as the reply. Its few
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.18.3",
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.18.3",
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.18.3",
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
+ }
@@ -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, rowRhythm } from './rows/row-rhythm';
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={{ paddingLeft }}
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="chat-column-content relative w-full shrink-0" style={{ height: virtualizer.getTotalSize() }}>
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
- <div
557
+ <RowContainer
553
558
  key={row.id}
554
- data-index={virtualRow.index}
555
- data-item-id={row.id}
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 && <ReplyHeader chatId={chatId} at={header.at} />}
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
- </div>
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 { initialVisualHeight, rememberVisualHeight } from '../logic/visual-height';
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 ?? initialVisualHeight(visual, width));
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
- {opening !== null && (
111
- <iframe
112
- ref={frameRef}
113
- src={opening.src}
114
- title={visual.title}
115
- loading="lazy"
116
- sandbox="allow-scripts allow-forms"
117
- className={clsx('block size-full border-0', failed && 'invisible')}
118
- // A frame whose color scheme differs from its document's paints an opaque ground behind it.
119
- style={{ colorScheme: drawn ?? 'normal' }}
120
- onLoad={() => bridgeRef.current?.loaded()}
121
- />
122
- )}
123
- {failed ? (
124
- <p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">
125
- {t('visuals.failed', { title: visual.title })}
126
- </p>
127
- ) : (
128
- drawn === null && (
129
- <span className="chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint">
130
- <Spinner size={16} label={t('visuals.loading', { title: visual.title })} />
131
- </span>
132
- )
133
- )}
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 and as wide as the reply. Its few
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(clampVisualHeight(this.options.visual, message.height));
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