From d4ecf57a53099a0ad2854ddc71b2b50ccadbe4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Apr 2026 19:16:38 +0200 Subject: [PATCH 1/4] fix(ui): break follow on upward scroll intent Disable auto-follow as soon as the user expresses an explicit upward scroll intent, so streaming renders stop snapping the message list back to bottom before the upward scroll can take effect. --- packages/ui/src/components/virtual-follow-list.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 070e0b92..56c6018e 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -190,6 +190,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS if (direction) { lastUserScrollIntentDirection = direction + if (direction === "up" && autoScroll() && activeHoldTargetKey() === null) { + // Streaming renders can re-pin before the scroll event is observed. + // Break follow immediately on an explicit upward user intent. + setAutoScroll(false) + lastObservedPinnedAtBottom = false + suppressAutoScrollOnce = true + } } } From 0a602662a94cbc87c47646b7ab52a946d46b3822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Apr 2026 19:29:07 +0200 Subject: [PATCH 2/4] fix(ui): confirm upward scroll before breaking follow Convert the streaming scroll fix into a two-step flow: upward intent suppresses the next auto-pin immediately, but follow mode only turns off once the viewport actually moves away from bottom, avoiding false negatives when the list cannot scroll yet. --- .../ui/src/components/virtual-follow-list.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 56c6018e..19661a5a 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -173,6 +173,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let detachScrollIntentListeners: (() => void) | undefined let lastResetKey: string | number | undefined let suppressAutoScrollOnce = false + let pendingUpwardBreakUntil = 0 let pendingInitialScroll = true let lastObservedScrollOffset = 0 let lastObservedPinnedAtBottom = false @@ -192,9 +193,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { lastUserScrollIntentDirection = direction if (direction === "up" && autoScroll() && activeHoldTargetKey() === null) { // Streaming renders can re-pin before the scroll event is observed. - // Break follow immediately on an explicit upward user intent. - setAutoScroll(false) - lastObservedPinnedAtBottom = false + // Suppress the next automatic bottom pin, but only break follow once a + // real upward movement away from bottom is confirmed. + pendingUpwardBreakUntil = now + USER_SCROLL_INTENT_WINDOW_MS suppressAutoScrollOnce = true } } @@ -204,6 +205,14 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return performance.now() <= userScrollIntentUntil } + function hasPendingUpwardBreak() { + return performance.now() <= pendingUpwardBreakUntil + } + + function clearPendingUpwardBreak() { + pendingUpwardBreakUntil = 0 + } + function clearAutoPinHold(options?: { resumeBottom?: boolean }) { if (activeHoldTargetKey() === null) return setActiveHoldTargetKey(null) @@ -276,8 +285,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // scrollbar). If follow mode stays enabled, the next render notification // snaps the list straight back to bottom. A real upward viewport move away // from bottom should always break follow unless a hold target is active. - if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) { + if (wasPinnedAtBottom && scrolledUp && (autoScroll() || hasPendingUpwardBreak()) && !atBottom && activeHoldTargetKey() === null) { setAutoScroll(false) + clearPendingUpwardBreak() lastObservedPinnedAtBottom = false return } @@ -292,6 +302,10 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } } + if (atBottom && !hasUserScrollIntent()) { + clearPendingUpwardBreak() + } + lastObservedPinnedAtBottom = autoScroll() && atBottom } @@ -401,6 +415,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { notifyContentRendered: () => { updateAutoPinHold() if (activeHoldTargetKey() !== null) return + if (hasPendingUpwardBreak()) return if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { scrollToBottom(true) } @@ -418,6 +433,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { itemElements.clear() setActiveHoldTargetKey(null) setDidTriggerHoldForCurrentTarget(false) + clearPendingUpwardBreak() lastObservedScrollOffset = 0 lastObservedPinnedAtBottom = false })) @@ -433,7 +449,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Handle autoScroll (Follow) on items change createEffect(on(() => props.items().length, (len, prevLen) => { - if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { + if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce && !hasPendingUpwardBreak()) { requestAnimationFrame(() => scrollToBottom(true)) } suppressAutoScrollOnce = false @@ -441,7 +457,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Handle followToken change createEffect(on(() => props.followToken?.(), () => { - if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { + if (autoScroll() && !effectiveSuspendAutoPinToBottom() && !hasPendingUpwardBreak()) { scrollToBottom(true) } }, { defer: true })) From d43fae34f167d0a15693ab07775a7b898c4a224b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Apr 2026 20:25:10 +0200 Subject: [PATCH 3/4] fix(ui): clear pending upward break on resume --- packages/ui/src/components/virtual-follow-list.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 19661a5a..d42757b4 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -312,6 +312,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) { const handle = virtuaHandle() if (!handle) return + clearPendingUpwardBreak() if (options?.suppressAutoAnchor ?? !immediate) { suppressAutoScrollOnce = true } @@ -420,7 +421,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollToBottom(true) } }, - setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)), + setAutoScroll: (enabled) => { + const nextEnabled = Boolean(enabled) + if (nextEnabled) { + clearPendingUpwardBreak() + } + setAutoScroll(nextEnabled) + }, getAutoScroll: () => autoScroll(), getScrollElement: () => scrollElement(), getShellElement: () => shellElement(), From 95309bf97d6349fca79d4206ab14d631749e0593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Apr 2026 20:29:26 +0200 Subject: [PATCH 4/4] fix(ui): clear pending upward break at bottom --- packages/ui/src/components/virtual-follow-list.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index d42757b4..05a7d205 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -295,8 +295,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Sync autoScroll state based on scroll position if it was a user scroll if (hasUserScrollIntent()) { clearAutoPinHold() - if (atBottom && !autoScroll()) { - setAutoScroll(true) + if (atBottom) { + clearPendingUpwardBreak() + if (!autoScroll()) { + setAutoScroll(true) + } } else if (!atBottom && autoScroll()) { setAutoScroll(false) }