LCOV - code coverage report
Current view: top level - dashql-app/src/session/feed - session_feed_layout.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 97.7 % 129 126
Test Date: 2026-11-09 20:22:13

            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              :         /// Called after a height changed, with how far the row at the top of the viewport moved
      37            1 :         private readonly onChange: (scrollShift: number) => void = () => {},
      38            1 :         private readonly getScrollOffset: () => number = () => 0,
      39            1 :     ) {}
      40              : 
      41            1 :     updateEntries(entries: FeedLayoutEntry[]) {
      42            1 :         if (entries !== this.entries) {
      43            1 :             this.entries = entries;
      44            1 :             this.updateEstimateCorrection();
      45            1 :         }
      46            1 :     }
      47              : 
      48            1 :     private updateEstimateCorrection() {
      49            1 :         let excess = 0;
      50            1 :         let measured = 0;
      51            1 :         for (const entry of this.entries) {
      52            1 :             const height = this.measuredHeights.get(entry.scriptId);
      53            1 :             if (height == null) continue;
      54            1 :             excess += height - entry.estimatedHeight;
      55            1 :             ++measured;
      56            1 :         }
      57            1 :         this.estimateCorrection = measured === 0 ? 0 : excess / measured;
      58            1 :     }
      59              : 
      60            1 :     private getEntryHeight(entry: FeedLayoutEntry) {
      61            1 :         return this.measuredHeights.get(entry.scriptId)
      62            1 :             ?? Math.max(MIN_ENTRY_HEIGHT, Math.round(entry.estimatedHeight + this.estimateCorrection));
      63            1 :     }
      64              : 
      65              :     /// The space of a row above its card
      66            1 :     private getLeadingSpace(index: number) {
      67            1 :         return index === 0 ? FEED_TOP_PADDING : 0;
      68            1 :     }
      69              : 
      70              :     /// The space of a row below its card
      71            1 :     private getTrailingSpace(index: number) {
      72            1 :         return index === this.entries.length - 1 ? FEED_END_PADDING : FEED_GAP;
      73            1 :     }
      74              : 
      75            1 :     getAverageRowHeight() {
      76            1 :         if (this.entries.length === 0) return MIN_ENTRY_HEIGHT;
      77            1 :         let total = FEED_TOP_PADDING + FEED_END_PADDING + (this.entries.length - 1) * FEED_GAP;
      78            1 :         for (const entry of this.entries) total += this.getEntryHeight(entry);
      79            1 :         return total / this.entries.length;
      80            1 :     }
      81              : 
      82            1 :     getRowHeight(index: number) {
      83            1 :         const entry = this.entries[index];
      84            1 :         if (entry == null) return undefined;
      85            1 :         return this.getLeadingSpace(index) + this.getEntryHeight(entry) + this.getTrailingSpace(index);
      86            1 :     }
      87              : 
      88              :     /// The offset of a row from the top of the list, the sum of the rows above it
      89            1 :     getRowOffset(index: number) {
      90            1 :         let offset = 0;
      91            1 :         for (let i = 0; i < index; ++i) {
      92            1 :             offset += this.getRowHeight(i) ?? this.getAverageRowHeight();
      93              :         }
      94            1 :         return offset;
      95            1 :     }
      96              : 
      97              :     /// The top and the bottom of the card of a script in the list, without the spaces around it
      98            1 :     getEntrySpan(index: number): [number, number] {
      99            1 :         const top = this.getRowOffset(index) + this.getLeadingSpace(index);
     100            1 :         const entry = this.entries[index];
     101            1 :         return [top, top + (entry != null ? this.getEntryHeight(entry) : 0)];
     102            1 :     }
     103              : 
     104              :     /// The index of the row at a scroll offset
     105            1 :     private getRowAtOffset(scrollOffset: number) {
     106            1 :         let bottom = 0;
     107            1 :         for (let i = 0; i < this.entries.length; ++i) {
     108            1 :             bottom += this.getRowHeight(i) ?? 0;
     109            1 :             if (bottom > scrollOffset) return i;
     110            1 :         }
     111            1 :         return Math.max(0, this.entries.length - 1);
     112            1 :     }
     113              : 
     114              :     /// Store the measured height of the card of a row.
     115              :     /// Measuring a row also changes the estimates of the unmeasured rows, the shift covers all rows above the viewport.
     116            1 :     setRowHeight = (index: number, height: number) => {
     117            1 :         const entry = this.entries[index];
     118            1 :         if (entry == null) return;
     119            1 :         if (this.measuredHeights.get(entry.scriptId) === height) return;
     120            1 :         const anchor = this.getRowAtOffset(this.getScrollOffset());
     121            1 :         const anchorOffset = this.getRowOffset(anchor);
     122            1 :         this.measuredHeights.set(entry.scriptId, height);
     123            1 :         this.updateEstimateCorrection();
     124            1 :         this.onChange(this.getRowOffset(anchor) - anchorOffset);
     125            1 :     };
     126              : 
     127            0 :     observeRowElements() {
     128            0 :         return () => {};
     129            0 :     }
     130            1 : }
     131              : 
     132              : /// The space kept around a revealed script
     133            1 : const REVEAL_MARGIN = 16;
     134              : 
     135              : /// The scroll offset that brings a span of the list into view, null if it is in view already.
     136              : /// A span taller than the viewport shows its start, the agent's script reads from the top.
     137            1 : export function revealScrollOffset(spanTop: number, spanBottom: number, scrollTop: number, viewportHeight: number): number | null {
     138            1 :     const top = Math.max(0, spanTop - REVEAL_MARGIN);
     139            1 :     const bottom = spanBottom + REVEAL_MARGIN;
     140            1 :     if (top >= scrollTop && bottom <= scrollTop + viewportHeight) return null;
     141            1 :     const target = bottom - top > viewportHeight || top < scrollTop ? top : bottom - viewportHeight;
     142            1 :     return Math.abs(target - scrollTop) < 1 ? null : target;
     143            1 : }
     144              : 
     145            1 : export function useSessionFeedLayout(entries: FeedLayoutEntry[]) {
     146            1 :     const listContainerRef = React.useRef<HTMLDivElement>(null);
     147            1 :     const listRef = useListRef(null);
     148            1 :     const [heightsVersion, setHeightsVersion] = React.useState(0);
     149            1 :     const rowHeightsRef = React.useRef<FeedRowHeightCache | null>(null);
     150            1 :     const pendingScrollShift = React.useRef(0);
     151            1 :     if (rowHeightsRef.current == null) {
     152            1 :         rowHeightsRef.current = new FeedRowHeightCache(entries, scrollShift => {
     153            1 :             pendingScrollShift.current += scrollShift;
     154            1 :             setHeightsVersion(version => version + 1);
     155            1 :         }, () => listRef.current?.element?.scrollTop ?? 0);
     156              :     }
     157            1 :     rowHeightsRef.current.updateEntries(entries);
     158            1 :     const rowHeightCache = rowHeightsRef.current;
     159            1 :     const rowHeights = React.useMemo(() => ({
     160            1 :         getAverageRowHeight: () => rowHeightCache.getAverageRowHeight(),
     161            1 :         getRowHeight: (index: number) => rowHeightCache.getRowHeight(index),
     162            1 :         setRowHeight: rowHeightCache.setRowHeight,
     163            1 :         observeRowElements: () => () => {},
     164            1 :     }), [rowHeightCache, heightsVersion]);
     165              :     // A row above the viewport changed its height: the rows in view stay in place.
     166              :     // The browser can't anchor the scroll position itself, the list positions its rows with transforms.
     167            1 :     React.useLayoutEffect(() => {
     168            1 :         const scrollShift = pendingScrollShift.current;
     169            1 :         pendingScrollShift.current = 0;
     170            1 :         const element = listRef.current?.element;
     171            1 :         if (scrollShift !== 0 && element != null) element.scrollTop += scrollShift;
     172            1 :     }, [heightsVersion, listRef]);
     173            1 :     const getEntrySpan = React.useCallback((index: number) => rowHeightCache.getEntrySpan(index), [rowHeightCache]);
     174            1 :     const listContainerSize = observeSize(listContainerRef);
     175            1 :     const listWidth = listContainerSize?.width ?? 0;
     176            1 :     const listHeight = listContainerSize?.height ?? 0;
     177            1 :     const listScrollbarInset = useScrollbarWidth();
     178              : 
     179            1 :     return {
     180            1 :         listContainerRef,
     181            1 :         listRef,
     182            1 :         rowHeights,
     183            1 :         rowHeightsVersion: heightsVersion,
     184            1 :         getEntrySpan,
     185            1 :         listWidth,
     186            1 :         listHeight,
     187            1 :         listScrollbarInset,
     188            1 :     };
     189            1 : }
        

Generated by: LCOV version 2.0-1