@@ -68,11 +68,12 @@ const rows = computed<Row[]>(() => {
6868 return items ;
6969});
7070
71- // Row heights — must match the CSS values (.log-section-label padding
72- // + font + border = ~24px, .log-commit-row = 72px). The estimate is
73- // per-index so section labels don't inherit the commit-row height
74- // (visible regression: legends "unpushed" / "pushed" rendered at 72px).
75- const COMMIT_ROW_H = 72 ;
71+ // Estimated row heights used as initial guess before DOM measurement.
72+ // The virtualizer measures actual rendered heights via measureElement, so
73+ // these values only affect the initial scroll-height estimate and the
74+ // position of items before they are painted for the first time.
75+ // Keeping them close to reality avoids visible layout jumps on first render.
76+ const COMMIT_ROW_H = 56 ; // ~2 text lines + meta row + padding, no refs
7677const SECTION_ROW_H = 24 ;
7778
7879const virtualizer = useVirtualizer ({
@@ -82,7 +83,11 @@ const virtualizer = useVirtualizer({
8283 const row = rows .value [index ];
8384 return row && row .type !== " commit" ? SECTION_ROW_H : COMMIT_ROW_H ;
8485 },
85- overscan: 5 ,
86+ // Measure each item's actual DOM height after paint so rows with
87+ // ref badges, AI reason lines, or wrapped messages display at their
88+ // real height instead of the fixed 72px estimate.
89+ measureElement : (el ) => (el as HTMLElement ).offsetHeight ,
90+ overscan: 8 ,
8691});
8792
8893// NOTE: the watcher that syncs `rows.value.length` into the virtualizer
@@ -111,12 +116,20 @@ function sectionLabelText(row: Row): string {
111116}
112117
113118function vrStyle(vr : { start: number ; size: number }) {
119+ // height: vr.size keeps items from overlapping during the estimate phase.
120+ // measureElement is attached to the INNER content elements (commit-item /
121+ // log-section-label), not this wrapper — so offsetHeight reads the true
122+ // content height, not the explicitly-set wrapper height. On the next render
123+ // vr.size is corrected to the measured value and the wrapper matches the
124+ // content exactly. overflow: visible lets content show even if it briefly
125+ // exceeds the estimate before the first measurement lands.
114126 return {
115127 position: " absolute" as const ,
116128 top: 0 ,
117129 left: 0 ,
118130 width: " 100%" ,
119131 height: vr .size + " px" ,
132+ overflow: " visible" as const ,
120133 transform: " translateY(" + vr .start + " px)" ,
121134 };
122135}
@@ -445,9 +458,18 @@ function authorColor(name: string): string {
445458
446459 <div ref =" scrollContainerRef" class =" log-list" v-else-if =" displayedEntries.length > 0" @scroll.passive =" onLogScroll" >
447460 <div :style =" { height: virtualizer.getTotalSize() + 'px', position: 'relative', width: '100%' }" >
448- <div v-for =" vr in virtualizer.getVirtualItems()" :key =" '' + vr.key" :style =" vrStyle(vr)" >
461+ <div
462+ v-for =" vr in virtualizer.getVirtualItems()"
463+ :key =" '' + vr.key"
464+ :style =" vrStyle(vr)"
465+ >
449466 <template v-if =" isSectionRow (rows [vr .index ])" >
450- <div class =" log-section-label" :class =" sectionLabelClass(rows[vr.index])" >
467+ <div
468+ class =" log-section-label"
469+ :class =" sectionLabelClass(rows[vr.index])"
470+ :data-index =" vr.index"
471+ :ref =" (el) => { if (el) virtualizer.measureElement(el as Element); }"
472+ >
451473 <svg width =" 12" height =" 12" viewBox =" 0 0 16 16" fill =" none" aria-hidden =" true" >
452474 <path d =" M8 13V3M5 6l3-3 3 3" stroke =" currentColor" stroke-width =" 1.5" stroke-linecap =" round" stroke-linejoin =" round" />
453475 </svg >
@@ -457,6 +479,8 @@ function authorColor(name: string): string {
457479 <template v-else >
458480 <div
459481 class =" commit-item"
482+ :data-index =" vr.index"
483+ :ref =" (el) => { if (el) virtualizer.measureElement(el as Element); }"
460484 :class =" {
461485 'commit-item--selected': selectedHash === c(vr.index).hashFull,
462486 'commit-item--unpushed': isUnpushed(c(vr.index)),
0 commit comments