LCOV - code coverage report
Current view: top level - dashql-app/src/session/feed - session_feed_layout.ts Coverage Total Hit
Test: DashQL 915828b Lines: 97.3 % 111 108
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1