diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index 4496d3cc9..3b9ecb372 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -84,13 +84,7 @@ public class OpenClawChatTimeline : Component const int FollowToBottomSettleTickMs = 16; const int FollowToBottomMaxSettleTicks = 24; const int FollowToBottomSettleStableTicks = 2; - // While the settle timer is chasing the true bottom, an offset that lands below the bottom is - // either post-jump virtualization re-estimation (a BOUNDED band — keep chasing) or a genuine - // user scroll-up to read earlier history (MANY viewports away — abandon and don't fight). The - // abandon gap is the larger of an absolute floor and a viewport-relative band so it scales - // with window size while never dipping below the floor on short viewports. - const double FollowToBottomMinAbandonGap = 900; - const double FollowToBottomAbandonViewportFactor = 1.5; + const double ScrollTargetEpsilon = 0.5; // Follow-to-bottom during in-place streaming growth is handled by WinUI ScrollViewer scroll // anchoring (sv.VerticalAnchorRatio = 1.0), NOT by a reactive post-layout re-pin. With the // bottom row pinned as an anchor, the ScrollViewer keeps it glued to the viewport bottom @@ -111,6 +105,15 @@ public class OpenClawChatTimeline : Component private static readonly Dictionary s_sessionOffsets = new(); private const int MaxSessionOffsets = 50; + private sealed class ProgrammaticScrollTransition( + long generation, + bool followOnCompletion) + { + public long Generation { get; } = generation; + public bool FollowOnCompletion { get; } = followOnCompletion; + public double? PlatformTargetOffset { get; set; } + } + // SECURITY (chat-rubber-duck HIGH 1 / MEDIUM 3): chat-bubble Markdown is // rendered as sanitized inert text that: // 1. Renders images as inert ``[Image: ]`` text (no Uri fetch) — @@ -526,6 +529,9 @@ public override Element Render() var sessionOffsetsRef = UseRef>(new()); var prevScrollToBottomTokenRef = UseRef(0); var scrollSettleTimerRef = UseRef(null); + var scrollGenerationRef = UseRef(0L); + var programmaticScrollRef = UseRef(null); + var userScrollActiveRef = UseRef(false); // A reactive follow (SizeChanged) enqueues a pin on the dispatcher. Without a guard, // the many SizeChanged notifications fired across an ItemsRepeater realization pass pile // up dozens of enqueued pins; each re-pins to the bottom and CANCELS a user scroll issued @@ -689,16 +695,97 @@ void UpdateScrollMetrics(Microsoft.UI.Xaml.Controls.ScrollViewer sv) lastVerticalOffsetRef.Current = sv.VerticalOffset; lastScrollableHeightRef.Current = sv.ScrollableHeight; - isFollowingRef.Current = sv.ScrollableHeight - sv.VerticalOffset <= FollowThreshold; StoreSessionOffset(prevSessionIdRef.Current, sv.VerticalOffset); } + long StartProgrammaticScrollOperation() + { + programmaticScrollRef.Current = null; + userScrollActiveRef.Current = false; + return ++scrollGenerationRef.Current; + } + + void CancelAutoFollowForUser(Microsoft.UI.Xaml.Controls.ScrollViewer sv) + { + ++scrollGenerationRef.Current; + programmaticScrollRef.Current = null; + userScrollActiveRef.Current = true; + pendingRestoreOffsetRef.Current = null; + isFollowingRef.Current = false; + scrollPinPendingRef.Current = false; + scrollSettleTimerRef.Current?.Stop(); + scrollSettleTimerRef.Current = null; + sv.VerticalAnchorRatio = double.NaN; + } + + void CompleteUserScroll(Microsoft.UI.Xaml.Controls.ScrollViewer sv) + { + if (!userScrollActiveRef.Current) + return; + + userScrollActiveRef.Current = false; + var reachedBottom = sv.ScrollableHeight - sv.VerticalOffset <= ScrollTargetEpsilon; + isFollowingRef.Current = reachedBottom; + sv.VerticalAnchorRatio = reachedBottom ? 1.0 : double.NaN; + } + + void NotifyDiscreteUserIntent(Microsoft.UI.Xaml.Controls.ScrollViewer sv) + { + var originOffset = sv.VerticalOffset; + if (!TimelineScrollIntent.NotifyUserIntent(sv)) + return; + + var intentGeneration = scrollGenerationRef.Current; + sv.DispatcherQueue.TryEnqueue( + Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, + () => + { + if (scrollGenerationRef.Current == intentGeneration + && userScrollActiveRef.Current + && Math.Abs(sv.VerticalOffset - originOffset) <= ScrollTargetEpsilon) + { + CompleteUserScroll(sv); + } + }); + } + + void ChangeViewProgrammatically( + Microsoft.UI.Xaml.Controls.ScrollViewer sv, + long generation, + double targetOffset, + bool disableAnimation, + bool followOnCompletion) + { + if (scrollGenerationRef.Current != generation) + return; + + var platformTarget = ClampOffset(targetOffset, sv.ScrollableHeight); + if (Math.Abs(sv.VerticalOffset - platformTarget) <= ScrollTargetEpsilon) + { + programmaticScrollRef.Current = null; + isFollowingRef.Current = followOnCompletion; + return; + } + + var transition = new ProgrammaticScrollTransition( + generation, + followOnCompletion); + programmaticScrollRef.Current = transition; + var accepted = sv.ChangeView(null, targetOffset, null, disableAnimation); + if (!accepted && ReferenceEquals(programmaticScrollRef.Current, transition)) + { + programmaticScrollRef.Current = null; + isFollowingRef.Current = followOnCompletion; + } + } + void QueueScrollToBottom( Microsoft.UI.Xaml.Controls.ScrollViewer sv, string? sessionId, bool disableAnimation, bool respectUserScrollPosition = false) { + var generation = StartProgrammaticScrollOperation(); isFollowingRef.Current = true; // Bottom scroll anchoring (VerticalAnchorRatio = 1.0) keeps whatever row currently @@ -716,17 +803,22 @@ void QueueScrollToBottom( var anchoringRestored = false; void RestoreAnchoring() { - if (anchoringRestored) + if (anchoringRestored || scrollGenerationRef.Current != generation) return; anchoringRestored = true; - sv.VerticalAnchorRatio = 1.0; + sv.VerticalAnchorRatio = isFollowingRef.Current ? 1.0 : double.NaN; } void PinToBottom(bool passDisableAnimation) { sv.UpdateLayout(); var bottom = sv.ScrollableHeight; - sv.ChangeView(null, bottom, null, passDisableAnimation); + ChangeViewProgrammatically( + sv, + generation, + bottom, + passDisableAnimation, + followOnCompletion: true); lastVerticalOffsetRef.Current = bottom; lastScrollableHeightRef.Current = sv.ScrollableHeight; isFollowingRef.Current = true; @@ -741,22 +833,6 @@ void PinToBottom(bool passDisableAnimation) scrollSettleTimerRef.Current?.Stop(); scrollSettleTimerRef.Current = null; - // A scroll-to-bottom is a FOLLOW intent, but it can be triggered by a layout growth - // (thinking indicator / new row) that fires while the user has ALREADY scrolled far up - // to read earlier history. Re-pinning then would yank them back down (the reported - // "fighting the scrollbar" bug). Distinguish the two by how far the live offset sits - // from the bottom: content-growth follow stays within a bounded band of the bottom, - // while a reader has scrolled MANY viewports away. The gap is measured on a FRESH - // layout (below), after any in-flight user ChangeView has been applied, so the decision - // never races a scroll the user just issued. - bool UserScrolledAway() - { - var abandonGap = Math.Max( - FollowToBottomMinAbandonGap, - sv.ViewportHeight * FollowToBottomAbandonViewportFactor); - return sv.ScrollableHeight - sv.VerticalOffset > abandonGap; - } - // Coalesce reactive follows: mark a pin in flight so the SizeChanged storm does not // pile up dozens of enqueued pins. Held across the enqueued callback's SYNCHRONOUS // layout/pin work (during which our own UpdateLayout/ChangeView can re-enter @@ -766,6 +842,9 @@ bool UserScrolledAway() if (!sv.DispatcherQueue.TryEnqueue(() => { + if (scrollGenerationRef.Current != generation) + return; + // Stop any timer that a concurrently-enqueued QueueScrollToBottom may have created // and left in the ref, so we never leak an orphaned timer that keeps pinning. scrollSettleTimerRef.Current?.Stop(); @@ -777,7 +856,7 @@ bool UserScrolledAway() // switch, token bump, user sent a message) pass respectUserScrollPosition = false // and always pin, since they ARE the user asking to jump to the newest row. sv.UpdateLayout(); - if ((respectUserScrollPosition && UserScrolledAway()) || suppressAutoFollowRef.Current) + if ((respectUserScrollPosition && !isFollowingRef.Current) || suppressAutoFollowRef.Current) { // Abandoning the follow: we are NOT at the bottom, so DISABLE anchoring rather // than restore it to 1.0 — pinning the bottom row here would let post-remount @@ -813,11 +892,9 @@ bool UserScrolledAway() ticks++; - // Honor user-scroll intent detected by ViewChanged between ticks. - // When the user scrolls, their ChangeView fires ViewChanged which calls - // UpdateScrollMetrics → sets isFollowingRef=false (gap > FollowThreshold). - // Check BEFORE UpdateLayout so we never call PinToBottom after a user scroll. - if (!isFollowingRef.Current) + // Native input invalidates this generation before a user transition can + // compete with the next programmatic pin. + if (scrollGenerationRef.Current != generation || !isFollowingRef.Current) { timer.Stop(); scrollSettleTimerRef.Current = null; @@ -837,11 +914,7 @@ bool UserScrolledAway() return; } - // Yield to a real user scroll away from the bottom (bounded-band vs. many- - // viewports discriminator described on UserScrolledAway above). The settle - // timer ALWAYS yields — even for an explicit scroll-to-bottom, once the initial - // jump has landed we must not keep fighting a user who then drags up to read. - if (UserScrolledAway() || suppressAutoFollowRef.Current) + if (suppressAutoFollowRef.Current) { // Same abandon rule as the first pin: disable anchoring (do not restore // 1.0) so the held reading position is not dragged by extent re-estimation. @@ -890,6 +963,7 @@ bool UserScrolledAway() void QueuePreservePrependOffset(Microsoft.UI.Xaml.Controls.ScrollViewer sv, string? sessionId, double oldOffset, double oldScrollableHeight) { + var generation = StartProgrammaticScrollOperation(); // Content is inserted ABOVE the current viewport ("load earlier history"). In a stock // WinUI ItemsRepeater, bottom scroll anchoring (VerticalAnchorRatio = 1.0) would // natively preserve the on-screen position by shifting VerticalOffset down by the @@ -914,13 +988,21 @@ void QueuePreservePrependOffset(Microsoft.UI.Xaml.Controls.ScrollViewer sv, stri void RestoreOffset() { + if (scrollGenerationRef.Current != generation) + return; + // Re-seat by the ACTUAL inserted height measured after layout (ScrollableHeight // delta), not a stale precomputed value, so estimated-extent wobble during row // realization can't leave the reader clamped to the bottom. sv.UpdateLayout(); var delta = sv.ScrollableHeight - oldScrollableHeight; var target = ClampOffset(oldOffset + Math.Max(0, delta), sv.ScrollableHeight); - sv.ChangeView(null, target, null, disableAnimation: true); + ChangeViewProgrammatically( + sv, + generation, + target, + disableAnimation: true, + followOnCompletion: sv.ScrollableHeight - target <= FollowThreshold); lastVerticalOffsetRef.Current = target; lastScrollableHeightRef.Current = sv.ScrollableHeight; isFollowingRef.Current = sv.ScrollableHeight - target <= FollowThreshold; @@ -2824,8 +2906,14 @@ bool BurstIsNestable(System.Collections.Generic.List b) { pendingRestoreOffsetRef.Current = null; var target = ClampOffset(pendingOffset, sv.ScrollableHeight); + var generation = StartProgrammaticScrollOperation(); isFollowingRef.Current = sv.ScrollableHeight - target <= FollowThreshold; - sv.ChangeView(null, target, null, disableAnimation: true); + ChangeViewProgrammatically( + sv, + generation, + target, + disableAnimation: true, + followOnCompletion: isFollowingRef.Current); lastVerticalOffsetRef.Current = target; lastScrollableHeightRef.Current = sv.ScrollableHeight; suppressAutoFollowRef.Current = false; @@ -2839,10 +2927,10 @@ bool BurstIsNestable(System.Collections.Generic.List b) // appears below the anchor (the thinking indicator, an appended // row) is not followed by anchoring alone. Re-pin on the sticky // follow intent only; QueueScrollToBottom itself re-checks the - // live offset on a fresh layout and BAILS if the user has since - // scrolled away (see UserScrolledAway there), so this cannot yank - // a scrolled-up reader back down even though SizeChanged fires on - // the same realization pass as their scroll. + // sticky user-intent state on a fresh layout and bails if the user + // has since scrolled away, so this cannot yank a scrolled-up reader + // back down even though SizeChanged fires on the same realization + // pass as their scroll. if (!suppressAutoFollowRef.Current && isFollowingRef.Current && scrollSettleTimerRef.Current is null @@ -2872,7 +2960,75 @@ bool BurstIsNestable(System.Collections.Generic.List b) // pre-paint as the ItemsRepeater extent grows during streaming (see the // constants note above). This replaces the old reactive ViewChanged re-pin. sv.VerticalAnchorRatio = 1.0; - sv.ViewChanged += (_, _) => + TimelineScrollIntent.Register(sv, () => CancelAutoFollowForUser(sv)); + sv.AddHandler( + UIElement.PointerWheelChangedEvent, + new Microsoft.UI.Xaml.Input.PointerEventHandler((_, _) => NotifyDiscreteUserIntent(sv)), + handledEventsToo: true); + sv.AddHandler( + UIElement.PointerPressedEvent, + new Microsoft.UI.Xaml.Input.PointerEventHandler((_, args) => + { + if (IsScrollBarInteraction(args.OriginalSource as DependencyObject)) + TimelineScrollIntent.NotifyUserIntent(sv); + }), + handledEventsToo: true); + sv.AddHandler( + UIElement.PointerReleasedEvent, + new Microsoft.UI.Xaml.Input.PointerEventHandler((_, args) => + { + if (IsScrollBarInteraction(args.OriginalSource as DependencyObject)) + CompleteUserScroll(sv); + }), + handledEventsToo: true); + sv.DirectManipulationStarted += (_, _) => TimelineScrollIntent.NotifyUserIntent(sv); + sv.DirectManipulationCompleted += (_, _) => CompleteUserScroll(sv); + sv.KeyDown += (_, args) => + { + if (args.Key is global::Windows.System.VirtualKey.Up + or global::Windows.System.VirtualKey.Down + or global::Windows.System.VirtualKey.PageUp + or global::Windows.System.VirtualKey.PageDown + or global::Windows.System.VirtualKey.Home + or global::Windows.System.VirtualKey.End) + { + NotifyDiscreteUserIntent(sv); + } + }; + sv.ViewChanging += (_, args) => + { + if (userScrollActiveRef.Current) + return; + + var ownedTransition = programmaticScrollRef.Current; + if (ownedTransition is not null + && ownedTransition.Generation == scrollGenerationRef.Current) + { + if (ownedTransition.PlatformTargetOffset is null) + { + // ChangeView raises its first ViewChanging synchronously on the UI + // thread, before external input can interleave. Capture WinUI's + // clamped target because a virtualizing extent may not yet make the + // full requested offset reachable. + ownedTransition.PlatformTargetOffset = args.FinalView.VerticalOffset; + return; + } + + if (Math.Abs( + args.FinalView.VerticalOffset + - ownedTransition.PlatformTargetOffset.Value) <= ScrollTargetEpsilon) + { + return; + } + + // A later transition with a different target is not the ChangeView this + // generation owns. Drop ownership without classifying it as user input: + // WinUI anchoring and virtualization also produce unowned transitions. + if (ReferenceEquals(programmaticScrollRef.Current, ownedTransition)) + programmaticScrollRef.Current = null; + } + }; + sv.ViewChanged += (_, args) => { // Follow-to-bottom during in-place streaming growth is handled by scroll // anchoring (VerticalAnchorRatio = 1.0), so there is NO reactive re-pin @@ -2881,55 +3037,17 @@ bool BurstIsNestable(System.Collections.Generic.List b) // and drive the load-earlier trigger. UpdateScrollMetrics(sv); - // A genuine user scroll far away from the bottom is authoritative: cancel - // any in-flight follow so it can't drag the reader back down. Without this, - // an initial-load / append settle timer (which keeps re-pinning while the - // ItemsRepeater extent estimate is still climbing) or a coalesced reactive - // pin can supersede the user's own ChangeView a frame later, and the view - // snaps back to the bottom (the reported "fighting the scrollbar" bug). - // Disable bottom anchoring too, so extent re-estimation below the viewport - // doesn't nudge the held reading position. - var abandonGap = Math.Max( - FollowToBottomMinAbandonGap, - sv.ViewportHeight * FollowToBottomAbandonViewportFactor); - if (sv.ScrollableHeight - sv.VerticalOffset > abandonGap) - { - isFollowingRef.Current = false; - scrollPinPendingRef.Current = false; - if (scrollSettleTimerRef.Current is not null) - { - scrollSettleTimerRef.Current.Stop(); - scrollSettleTimerRef.Current = null; - } - sv.VerticalAnchorRatio = double.NaN; - } - else if (!isFollowingRef.Current - && scrollSettleTimerRef.Current is not null) + var ownedTransition = programmaticScrollRef.Current; + if (ownedTransition is not null + && ownedTransition.Generation == scrollGenerationRef.Current + && !args.IsIntermediate) { - // Moderate user scroll above FollowThreshold (but below the large - // abandonGap) during an active settle timer: the user's reading intent - // is authoritative. After our own PinToBottom the gap is ~0 (offset - // equals ScrollableHeight), so isFollowingRef stays true across the - // pin's ViewChanged. If isFollowingRef is false here, the VIEW moved - // because the USER scrolled between ticks — cancel the timer so - // subsequent ticks cannot re-pin their position back to the bottom. - // This closes the 61–900px band where the old design tolerated noise - // at the cost of fighting a deliberate scroll. - scrollPinPendingRef.Current = false; - scrollSettleTimerRef.Current.Stop(); - scrollSettleTimerRef.Current = null; - sv.VerticalAnchorRatio = double.NaN; + isFollowingRef.Current = ownedTransition.FollowOnCompletion; + programmaticScrollRef.Current = null; } - else if (isFollowingRef.Current - && scrollSettleTimerRef.Current is null - && !scrollPinPendingRef.Current) + else if (userScrollActiveRef.Current && !args.IsIntermediate) { - // The user (or a settled pin) returned to the bottom band: re-arm bottom - // anchoring immediately so in-place streaming growth follows again, - // without waiting for the next render. Skipped while an explicit pin is - // in flight — that path deliberately drives to the true bottom with - // anchoring off and restores 1.0 itself once the extent settles. - sv.VerticalAnchorRatio = 1.0; + CompleteUserScroll(sv); } if (sv.ScrollableHeight > 0 @@ -2951,7 +3069,7 @@ bool BurstIsNestable(System.Collections.Generic.List b) // re-estimation — and the full remount FunctionalUI performs on every streamed // revision — nudge their offset (observed ~40px drift per revision). Disabling it // holds the reading position steady; it is re-enabled the moment they return to the - // bottom band (isFollowing flips true on the next render). Explicit pins + // exact bottom (isFollowing flips true when the user transition completes). Explicit pins // (QueueScrollToBottom) and prepend (QueuePreservePrependOffset) own the ratio for // their own async windows, so defer to them while one is in flight. if (scrollSettleTimerRef.Current is null && !scrollPinPendingRef.Current) @@ -3020,4 +3138,17 @@ bool BurstIsNestable(System.Collections.Generic.List b) }).Background(chatPageBg).Grid(row: 0, column: 0) ); } + + private static bool IsScrollBarInteraction(DependencyObject? source) + { + while (source is not null) + { + if (source is Microsoft.UI.Xaml.Controls.Primitives.ScrollBar) + return true; + + source = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(source); + } + + return false; + } } diff --git a/src/OpenClaw.Tray.WinUI/Chat/TimelineScrollIntent.cs b/src/OpenClaw.Tray.WinUI/Chat/TimelineScrollIntent.cs new file mode 100644 index 000000000..55d9b1ce3 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/TimelineScrollIntent.cs @@ -0,0 +1,25 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.UI.Xaml.Controls; + +namespace OpenClawTray.Chat; + +internal static class TimelineScrollIntent +{ + private static readonly ConditionalWeakTable s_handlers = new(); + + public static void Register(ScrollViewer scrollViewer, Action handler) + { + s_handlers.Remove(scrollViewer); + s_handlers.Add(scrollViewer, handler); + } + + public static bool NotifyUserIntent(ScrollViewer scrollViewer) + { + if (!s_handlers.TryGetValue(scrollViewer, out var handler)) + return false; + + handler(); + return true; + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs index 37d0f38f7..222bd6a73 100644 --- a/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs @@ -34,10 +34,15 @@ public void ProductionTimeline_StabilizesFollowToBottomForVirtualizedRows() Assert.Contains("FollowToBottomMaxSettleTicks", timeline); Assert.Contains("FollowToBottomSettleStableTicks", timeline); Assert.Contains("scrollSettleTimerRef", timeline); - // 3. Reactive follows are coalesced and a genuine user scroll-away is authoritative, so - // the follow machinery never clobbers the user's own scroll ("fighting the scrollbar"). + // 3. Reactive follows are coalesced, native user intent invalidates the active generation, + // and ViewChanging verifies that programmatic ownership stays scoped to its target. Assert.Contains("scrollPinPendingRef", timeline); - Assert.Contains("UserScrolledAway", timeline); + Assert.Contains("programmaticScrollRef", timeline); + Assert.Contains("sv.ViewChanging +=", timeline); + Assert.Contains("CancelAutoFollowForUser", timeline); + Assert.Contains("TimelineScrollIntent.NotifyUserIntent", timeline); + Assert.Contains("UIElement.PointerWheelChangedEvent", timeline); + Assert.Contains("handledEventsToo: true", timeline); Assert.Contains("sv.UpdateLayout();", timeline); } diff --git a/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs b/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs index a19fa091d..69d66816f 100644 --- a/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs @@ -299,6 +299,7 @@ await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout(); Assert.True(scrollViewer.ScrollableHeight > 0, "timeline should overflow and be scrollable"); // ~30% from the top => a large gap from the bottom, unambiguously "scrolled up". + Assert.True(TimelineScrollIntent.NotifyUserIntent(scrollViewer)); scrollViewer.ChangeView(null, scrollViewer.ScrollableHeight * 0.3, null, disableAnimation: true); _ui.Container.UpdateLayout(); }); @@ -352,15 +353,276 @@ await _ui.RunOnUIAsync(() => await _ui.RunOnUIAsync(() => host!.Dispose()); } - // Regression proof for the moderate-scroll band: a user who scrolls JUST above - // FollowThreshold (gap = 61–900px, well below the large abandonGap) during an ACTIVE settle - // timer must not be re-pinned to the bottom by subsequent timer ticks or streaming revisions. - // This is the fix for the "61–900px re-pin" band identified in review: the ViewChanged - // handler now cancels the settle timer immediately when isFollowing flips false, regardless - // of whether the gap also exceeds the large abandon threshold. The test uses a bottom-follow - // starting state with a ScrollToBottomToken (which starts a settle timer), then simulates a - // user scroll to a gap of ~FollowThreshold+100px (well below abandonGap), and asserts the - // view is NOT re-pinned across multiple streaming revisions. + [Fact] + public async Task IdlePopulatedTimeline_UserScrollDeltasRemainMonotonicAndReachBothEnds() + { + await _ui.ResetContainerAsync(); + + const int rows = 120; + const double wheelDelta = 48; + FunctionalHostControl? host = null; + ScrollViewer? scrollViewer = null; + + var props = BuildProps(rows, scrollToBottomToken: 0, sessionId: "ui-proof-idle-user-scroll"); + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + _ui.TestWindow.AppWindow.MoveAndResize(new RectInt32(-32000, -32000, 960, 720)); + _ui.Container.Width = 900; + _ui.Container.Height = 640; + + host = new FunctionalHostControl { Width = 860, Height = 560, SuppressAutoDispose = true }; + _ui.Container.Children.Add(host); + host.Mount(_ => Component(props)); + }); + + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + var userRequests = 0; + var viewChangingEvents = 0; + var viewChangedEvents = 0; + var userDirection = 0; + var upwardOffsets = new List(); + var downwardOffsets = new List(); + await _ui.RunOnUIAsync(() => + { + scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + Assert.True(scrollViewer.ScrollableHeight > wheelDelta * 20, + $"precondition: idle timeline must be long enough for repeated wheel deltas; scrollable={scrollViewer.ScrollableHeight:0.0}"); + Assert.True(scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset <= 4, + $"precondition: idle timeline should start at bottom; offset={scrollViewer.VerticalOffset:0.0}, scrollable={scrollViewer.ScrollableHeight:0.0}"); + Assert.Equal(1.0, scrollViewer.VerticalAnchorRatio); + + scrollViewer.ViewChanging += (_, _) => viewChangingEvents++; + scrollViewer.ViewChanged += (_, _) => + { + viewChangedEvents++; + if (userDirection < 0) + upwardOffsets.Add(scrollViewer.VerticalOffset); + else if (userDirection > 0) + downwardOffsets.Add(scrollViewer.VerticalOffset); + }; + }); + + async Task ApplyUserDeltaAsync(double delta) + { + var before = 0.0; + var target = 0.0; + await _ui.RunOnUIAsync(() => + { + before = scrollViewer!.VerticalOffset; + target = Math.Clamp(before + delta, 0, scrollViewer.ScrollableHeight); + userDirection = Math.Sign(delta); + userRequests++; + Assert.True( + TimelineScrollIntent.NotifyUserIntent(scrollViewer), + "external ChangeView must be marked as user intent, not a timeline-owned transition"); + Assert.True( + scrollViewer.ChangeView(null, target, null, disableAnimation: false), + $"user ChangeView request should be accepted; before={before:0.0}, target={target:0.0}"); + _ui.Container.UpdateLayout(); + }); + await _ui.YieldToRenderAsync(); + await Task.Delay(20); + await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout()); + + var after = 0.0; + await _ui.RunOnUIAsync(() => after = scrollViewer!.VerticalOffset); + if (delta < 0) + { + Assert.True( + after <= before + 0.5, + $"upward user delta must not reverse toward the bottom; before={before:0.0}, target={target:0.0}, after={after:0.0}"); + } + else + { + Assert.True( + after >= before - 0.5, + $"downward user delta must not reverse toward the top; before={before:0.0}, target={target:0.0}, after={after:0.0}"); + } + + return after; + } + + async Task<(double Offset, double ScrollableHeight)> ReadScrollPositionAsync() + { + var position = (Offset: 0.0, ScrollableHeight: 0.0); + await _ui.RunOnUIAsync(() => + { + position = (scrollViewer!.VerticalOffset, scrollViewer.ScrollableHeight); + }); + return position; + } + + var crossedFollowThreshold = false; + for (var step = 0; step < 2000; step++) + { + var before = await ReadScrollPositionAsync(); + if (before.Offset <= 0.5) + break; + + var after = await ApplyUserDeltaAsync(-wheelDelta); + var current = await ReadScrollPositionAsync(); + crossedFollowThreshold |= current.ScrollableHeight - after > FollowThreshold; + } + + await _ui.RunOnUIAsync(() => + { + userDirection = 0; + Assert.True(crossedFollowThreshold, + "precondition: repeated upward user deltas must cross the follow threshold"); + Assert.InRange(scrollViewer!.VerticalOffset, 0, 0.5); + Assert.True(upwardOffsets.Count > 20, + $"upward proof must observe many real ViewChanged offsets; count={upwardOffsets.Count}"); + for (var i = 1; i < upwardOffsets.Count; i++) + { + Assert.True( + upwardOffsets[i] <= upwardOffsets[i - 1] + 0.5, + "upward user movement must be monotonic across every WinUI ViewChanged event; " + + $"index={i}, previous={upwardOffsets[i - 1]:0.0}, current={upwardOffsets[i]:0.0}"); + } + }); + + for (var step = 0; step < 2000; step++) + { + var position = await ReadScrollPositionAsync(); + var gap = position.ScrollableHeight - position.Offset; + if (gap <= 0.5) + break; + + await ApplyUserDeltaAsync(wheelDelta); + } + + await _ui.RunOnUIAsync(() => + { + userDirection = 0; + var gap = scrollViewer!.ScrollableHeight - scrollViewer.VerticalOffset; + Assert.InRange(gap, 0, 0.5); + Assert.True(downwardOffsets.Count > 20, + $"downward proof must observe many real ViewChanged offsets; count={downwardOffsets.Count}"); + for (var i = 1; i < downwardOffsets.Count; i++) + { + Assert.True( + downwardOffsets[i] >= downwardOffsets[i - 1] - 0.5, + "downward user movement must be monotonic across every WinUI ViewChanged event; " + + $"index={i}, previous={downwardOffsets[i - 1]:0.0}, current={downwardOffsets[i]:0.0}"); + } + Assert.True(userRequests > 40, + $"proof must issue many small user deltas, not one endpoint jump; requests={userRequests}"); + Assert.True(viewChangingEvents > 0, + "external user ChangeView requests must produce real WinUI ViewChanging transitions"); + Assert.True(viewChangedEvents > 0, + "external user ChangeView requests must produce real WinUI ViewChanged transitions"); + Console.WriteLine( + "CHAT_TIMELINE_IDLE_USER_SCROLL_PROOF " + + $"requests={userRequests} viewChanging={viewChangingEvents} viewChanged={viewChangedEvents} " + + $"topReached=true bottomReached=true finalOffset={scrollViewer.VerticalOffset:0.0} " + + $"scrollable={scrollViewer.ScrollableHeight:0.0}"); + }); + + await _ui.RunOnUIAsync(() => host!.Dispose()); + } + + [Fact] + public async Task ResizeWhileFollowingAndDetached_PreservesScrollIntent() + { + await _ui.ResetContainerAsync(); + + const int rows = 120; + FunctionalHostControl? host = null; + var props = BuildProps(rows, scrollToBottomToken: 0, sessionId: "ui-proof-resize-scroll"); + + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + _ui.TestWindow.AppWindow.MoveAndResize(new RectInt32(-32000, -32000, 960, 720)); + _ui.Container.Width = 900; + _ui.Container.Height = 640; + + host = new FunctionalHostControl { Width = 860, Height = 560, SuppressAutoDispose = true }; + _ui.Container.Children.Add(host); + host.Mount(_ => Component(props)); + }); + + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + Assert.True(scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset <= 4, + $"precondition: following view must start at bottom; gap={scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset:0.0}"); + host!.Height = 440; + _ui.Container.UpdateLayout(); + }); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + Assert.True(scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset <= 4, + $"shrinking a following viewport must preserve bottom follow; gap={scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset:0.0}"); + Assert.True(TimelineScrollIntent.NotifyUserIntent(scrollViewer)); + Assert.True(scrollViewer.ChangeView( + null, + scrollViewer.ScrollableHeight * 0.35, + null, + disableAnimation: true)); + _ui.Container.UpdateLayout(); + }); + await DrainRenderQueueAsync(); + + var detachedOffset = 0.0; + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + detachedOffset = scrollViewer.VerticalOffset; + Assert.True(scrollViewer.ScrollableHeight - detachedOffset > FollowThreshold, + "precondition: resize proof must detach from bottom follow"); + Assert.True(double.IsNaN(scrollViewer.VerticalAnchorRatio), + "user-detached view must disable bottom anchoring before resize"); + host!.Height = 600; + _ui.Container.UpdateLayout(); + }); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + var drift = Math.Abs(scrollViewer.VerticalOffset - detachedOffset); + Assert.True(scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset > FollowThreshold, + "growing a detached viewport must not re-enter follow mode"); + Assert.True(drift <= 4, + $"growing a detached viewport must preserve reading position; drift={drift:0.0}"); + }); + + props = props with { ScrollToBottomToken = 1 }; + await _ui.RunOnUIAsync(() => + host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + var gap = scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset; + Assert.True(gap <= 4, + $"explicit follow after resize must reach bottom; gap={gap:0.0}"); + Console.WriteLine( + "CHAT_TIMELINE_RESIZE_SCROLL_PROOF " + + $"detachedOffset={detachedOffset:0.0} finalOffset={scrollViewer.VerticalOffset:0.0} " + + $"scrollable={scrollViewer.ScrollableHeight:0.0} gap={gap:0.0}"); + }); + + await _ui.RunOnUIAsync(() => host!.Dispose()); + } + + // A user who scrolls just above FollowThreshold during an active settle must not be re-pinned + // by later timer ticks or streaming revisions. The explicit user-intent signal cancels the + // owned programmatic generation immediately, independent of the user's distance from bottom. [Fact] public async Task ModerateScrollUpDuringSettleTimer_IsNotRepinned() { @@ -424,19 +686,19 @@ await _ui.RunOnUIAsync(() => Assert.True(timerWasActive, "precondition: settle timer must be active (anchoring disabled) before user scroll"); - // User scrolls up a MODERATE amount: above FollowThreshold (60px) but well below the - // abandonGap (900px/1.5 viewports). This is the critical band. + // User scrolls up a moderate amount, just above FollowThreshold. var userOffset = 0.0; await _ui.RunOnUIAsync(() => { var scrollViewer = FindLogical(host!).Single(); _ui.Container.UpdateLayout(); var targetOffset = Math.Max(0, scrollViewer.ScrollableHeight - 400); + Assert.True(TimelineScrollIntent.NotifyUserIntent(scrollViewer)); scrollViewer.ChangeView(null, targetOffset, null, disableAnimation: true); _ui.Container.UpdateLayout(); }); - // Let ViewChanged fire and propagate (the fix: moderate-cancel stops the timer). + // Let the user-owned ViewChanged transition propagate after canceling the settle. await DrainRenderQueueAsync(); await _ui.RunOnUIAsync(() => @@ -447,13 +709,11 @@ await _ui.RunOnUIAsync(() => var gap = scrollViewer.ScrollableHeight - userOffset; Assert.True( gap > FollowThreshold && gap < 840, - "precondition: user should be in the moderate band (above FollowThreshold, below abandonGap); " + + "precondition: user should be in the moderate band above FollowThreshold; " + $"gap={gap:0.0}, threshold={FollowThreshold}"); }); - // Stream multiple revisions. If the fix works, the timer was cancelled by ViewChanged's - // moderate-cancel clause and subsequent streaming won't re-pin. Without the fix, the - // timer would keep calling PinToBottom every 16ms, overriding the user's scroll. + // Stream multiple revisions. The invalidated generation must not resume pinning. props = props with { ShowThinkingIndicator = true }; for (var revision = 1; revision <= 4; revision++) { @@ -548,6 +808,7 @@ await _ui.RunOnUIAsync(() => var scrollViewer = FindLogical(host!).Single(); _ui.Container.UpdateLayout(); Assert.True(scrollViewer.ScrollableHeight > 0, "timeline should overflow and be scrollable"); + Assert.True(TimelineScrollIntent.NotifyUserIntent(scrollViewer)); scrollViewer.ChangeView(null, scrollViewer.ScrollableHeight * 0.4, null, disableAnimation: true); _ui.Container.UpdateLayout(); });