// Match the titlebar fade inset so draft promotion preserves the first row's position. export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; export interface TimelineListMeasurementState { readonly data: readonly unknown[]; readonly scroll: number; readonly scrollLength: number; readonly positionAtIndex: (index: number) => number | undefined; readonly sizeAtIndex: (index: number) => number | undefined; } export interface AnchoredTurnMetrics { readonly anchorTop: number; readonly lastBottom: number; readonly turnHeight: number; readonly usableViewportHeight: number; readonly visibleUsableBottom: number; readonly overflowsUsableViewport: boolean; readonly targetScrollToRevealEnd: number; readonly scrollDeltaToRevealEnd: number; } export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { const top = state.positionAtIndex(index); const height = state.sizeAtIndex(index); if ( typeof top !== "number" || typeof height !== "number" || !Number.isFinite(top) || !Number.isFinite(height) ) { return null; } return top + Math.max(1, height); } /** * Whether the timeline's real rows extend past the viewport left above the * composer. The list's own content length includes the composer inset * spacer, so this measures from the last row instead. Unknown row geometry * or an unmeasured viewport counts as fitting. */ export function timelineContentOverflowsViewport( state: TimelineListMeasurementState | undefined, input: { readonly composerInset: number; readonly anchorOffset: number }, ): boolean { if (!state || !state.data || state.data.length === 0) { return false; } const scrollLength = state.scrollLength; if (typeof scrollLength !== "number" || !Number.isFinite(scrollLength) || scrollLength <= 0) { return false; } const lastBottom = getRowBottom(state, state.data.length - 1); if (lastBottom === null) { return false; } const visibleScrollLength = Math.max(0, scrollLength - input.composerInset - input.anchorOffset); return lastBottom > visibleScrollLength; } export function getAnchoredTurnMetrics({ state, anchorIndex, composerOverlayHeight, anchorOffset, }: { readonly state: TimelineListMeasurementState; readonly anchorIndex: number; readonly composerOverlayHeight: number; readonly anchorOffset: number; }): AnchoredTurnMetrics | null { if (state.data.length === 0) { return null; } const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1)); const anchorTop = state.positionAtIndex(boundedAnchorIndex); const lastBottom = getRowBottom(state, state.data.length - 1); if (typeof anchorTop !== "number" || !Number.isFinite(anchorTop) || lastBottom === null) { return null; } const usableViewportHeight = Math.max( 0, state.scrollLength - composerOverlayHeight - anchorOffset, ); const turnHeight = Math.max(0, lastBottom - anchorTop); const visibleUsableBottom = state.scroll + usableViewportHeight; const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight); const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll); return { anchorTop, lastBottom, turnHeight, usableViewportHeight, visibleUsableBottom, overflowsUsableViewport: turnHeight > usableViewportHeight, targetScrollToRevealEnd, scrollDeltaToRevealEnd, }; }