From cc4de243c62dae2373efbc30f14fd8fb697ef954 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 10 Jul 2026 02:28:31 +0300 Subject: [PATCH] fix(chat): sticky user headers float mid-list in the virtualized timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tanstack rows sit in a wrapper offset with transform: translateY(), and a transformed ancestor becomes the sticky containing block — turn headers stuck to the wrapper's overscan-dependent top edge instead of the scroll container, floating over the previous turn. Offset the wrapper with padding-top instead: identical geometry, sticky computes against the scroll container again, and the padding only changes when the virtual window shifts, not per scroll frame. --- packages/ui/src/components/chat/MessageList.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 746c3c05..58d2caae 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1193,12 +1193,17 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, if (engine === 'tanstack') { const virtualItems = tanstackVirtualizer.getVirtualItems(); const startOffset = virtualItems[0]?.start ?? 0; - // Rendered rows stay in normal flow inside a single translated wrapper - // (not per-row absolute positioning) so per-turn sticky user headers - // keep working against the scroll container. + // Rendered rows stay in normal flow inside a single offset wrapper (not + // per-row absolute positioning) so per-turn sticky user headers keep + // working against the scroll container. The offset MUST be padding, not + // transform: a transformed ancestor becomes the sticky containing block, + // so headers would stick to the wrapper's (arbitrary, overscan-dependent) + // top edge mid-list and float over the previous turn. Padding only + // changes when the virtual window shifts — not per scroll frame — so the + // layout cost is negligible. return (
-
+
{virtualItems.map((item) => { const entry = renderEntries[item.index]; if (!entry) return null;