Line data Source code
1 : import * as React from 'react';
2 :
3 : import { useListRef } from 'react-window';
4 :
5 : import { observeSize } from '../../ui/foundations/size_observer.js';
6 : import { useScrollbarWidth } from '../../utils/scrollbar.js';
7 :
8 : interface FeedLayoutEntry {
9 : scriptId: number;
10 : estimatedHeight: number;
11 : }
12 :
13 : /// The space above the first script
14 : export const FEED_TOP_PADDING = 32;
15 : /// The space between two scripts, the add buttons of the scripts sit in it
16 : export const FEED_GAP = 32;
17 : /// The space below the last script, it holds the add button below the last script
18 : export const FEED_END_PADDING = 32;
19 : /// The height of a script that is not measured yet, at least its header
20 1 : const MIN_ENTRY_HEIGHT = 40;
21 :
22 : /// The heights of the rows of the feed, one row per script.
23 : /// A row is the card of the script and the space that follows it, the first row also holds the space above the feed.
24 : /// The rows measure the cards only, the spaces are added here.
25 1 : export class FeedRowHeightCache {
26 1 : private readonly measuredHeights = new Map<number, number>();
27 : /// The mean amount that the measured scripts exceed their estimates by.
28 : /// The estimate only counts the editor lines, the results of a script make its card taller.
29 : /// react-window sizes the list by extrapolating the rows it has laid out, the measured rows around the viewport.
30 : /// With the unmeasured rows at their bare estimate, the list grows far taller than its rows,
31 : /// and a scroll into that excess shows an empty feed.
32 1 : private estimateCorrection = 0;
33 :
34 1 : constructor(
35 1 : private entries: FeedLayoutEntry[],
36 1 : private readonly onChange: (index: number, previousHeight: number, height: number) => void = () => {},
37 1 : ) {}
38 :
39 1 : updateEntries(entries: FeedLayoutEntry[]) {
40 1 : if (entries !== this.entries) {
41 1 : this.entries = entries;
42 1 : this.updateEstimateCorrection();
43 1 : }
44 1 : }
45 :
46 1 : private updateEstimateCorrection() {
47 1 : let excess = 0;
48 1 : let measured = 0;
49 1 : for (const entry of this.entries) {
50 1 : const height = this.measuredHeights.get(entry.scriptId);
51 1 : if (height == null) continue;
52 1 : excess += height - entry.estimatedHeight;
53 1 : ++measured;
54 1 : }
55 1 : this.estimateCorrection = measured === 0 ? 0 : excess / measured;
56 1 : }
57 :
58 1 : private getEntryHeight(entry: FeedLayoutEntry) {
59 1 : return this.measuredHeights.get(entry.scriptId)
60 1 : ?? Math.max(MIN_ENTRY_HEIGHT, Math.round(entry.estimatedHeight + this.estimateCorrection));
61 1 : }
62 :
63 : /// The space of a row above its card
64 1 : private getLeadingSpace(index: number) {
65 1 : return index === 0 ? FEED_TOP_PADDING : 0;
66 1 : }
67 :
68 : /// The space of a row below its card
69 1 : private getTrailingSpace(index: number) {
70 1 : return index === this.entries.length - 1 ? FEED_END_PADDING : FEED_GAP;
71 1 : }
72 :
73 1 : getAverageRowHeight() {
74 1 : if (this.entries.length === 0) return MIN_ENTRY_HEIGHT;
75 1 : let total = FEED_TOP_PADDING + FEED_END_PADDING + (this.entries.length - 1) * FEED_GAP;
76 1 : for (const entry of this.entries) total += this.getEntryHeight(entry);
77 1 : return total / this.entries.length;
78 1 : }
79 :
80 1 : getRowHeight(index: number) {
81 1 : const entry = this.entries[index];
82 1 : if (entry == null) return undefined;
83 1 : return this.getLeadingSpace(index) + this.getEntryHeight(entry) + this.getTrailingSpace(index);
84 1 : }
85 :
86 : /// The offset of a row from the top of the list, the sum of the rows above it
87 1 : getRowOffset(index: number) {
88 1 : let offset = 0;
89 1 : for (let i = 0; i < index; ++i) {
90 1 : offset += this.getRowHeight(i) ?? this.getAverageRowHeight();
91 : }
92 1 : return offset;
93 1 : }
94 :
95 : /// The top and the bottom of the card of a script in the list, without the spaces around it
96 1 : getEntrySpan(index: number): [number, number] {
97 1 : const top = this.getRowOffset(index) + this.getLeadingSpace(index);
98 1 : const entry = this.entries[index];
99 1 : return [top, top + (entry != null ? this.getEntryHeight(entry) : 0)];
100 1 : }
101 :
102 : /// Store the measured height of the card of a row
103 1 : setRowHeight = (index: number, height: number) => {
104 1 : const entry = this.entries[index];
105 1 : if (entry == null) return;
106 1 : if (this.measuredHeights.get(entry.scriptId) === height) return;
107 1 : const previousHeight = this.getEntryHeight(entry);
108 1 : this.measuredHeights.set(entry.scriptId, height);
109 1 : this.updateEstimateCorrection();
110 1 : this.onChange(index, previousHeight, height);
111 1 : };
112 :
113 0 : observeRowElements() {
114 0 : return () => {};
115 0 : }
116 1 : }
117 :
118 : /// The space kept around a revealed script
119 1 : const REVEAL_MARGIN = 16;
120 :
121 : /// The scroll offset that brings a span of the list into view, null if it is in view already.
122 : /// A span taller than the viewport shows its start, the agent's script reads from the top.
123 1 : export function revealScrollOffset(spanTop: number, spanBottom: number, scrollTop: number, viewportHeight: number): number | null {
124 1 : const top = Math.max(0, spanTop - REVEAL_MARGIN);
125 1 : const bottom = spanBottom + REVEAL_MARGIN;
126 1 : if (top >= scrollTop && bottom <= scrollTop + viewportHeight) return null;
127 1 : const target = bottom - top > viewportHeight || top < scrollTop ? top : bottom - viewportHeight;
128 1 : return Math.abs(target - scrollTop) < 1 ? null : target;
129 1 : }
130 :
131 1 : export function useSessionFeedLayout(entries: FeedLayoutEntry[]) {
132 1 : const listContainerRef = React.useRef<HTMLDivElement>(null);
133 1 : const listRef = useListRef(null);
134 1 : const [heightsVersion, setHeightsVersion] = React.useState(0);
135 1 : const rowHeightsRef = React.useRef<FeedRowHeightCache | null>(null);
136 1 : if (rowHeightsRef.current == null) {
137 1 : rowHeightsRef.current = new FeedRowHeightCache(entries, () => {
138 1 : setHeightsVersion(version => version + 1);
139 1 : });
140 : }
141 1 : rowHeightsRef.current.updateEntries(entries);
142 1 : const rowHeightCache = rowHeightsRef.current;
143 1 : const rowHeights = React.useMemo(() => ({
144 1 : getAverageRowHeight: () => rowHeightCache.getAverageRowHeight(),
145 1 : getRowHeight: (index: number) => rowHeightCache.getRowHeight(index),
146 1 : setRowHeight: rowHeightCache.setRowHeight,
147 1 : observeRowElements: () => () => {},
148 1 : }), [rowHeightCache, heightsVersion]);
149 1 : const getEntrySpan = React.useCallback((index: number) => rowHeightCache.getEntrySpan(index), [rowHeightCache]);
150 1 : const listContainerSize = observeSize(listContainerRef);
151 1 : const listWidth = listContainerSize?.width ?? 0;
152 1 : const listHeight = listContainerSize?.height ?? 0;
153 1 : const listScrollbarInset = useScrollbarWidth();
154 :
155 1 : return {
156 1 : listContainerRef,
157 1 : listRef,
158 1 : rowHeights,
159 1 : rowHeightsVersion: heightsVersion,
160 1 : getEntrySpan,
161 1 : listWidth,
162 1 : listHeight,
163 1 : listScrollbarInset,
164 1 : };
165 1 : }
|