diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index ffcc47817..2e749f966 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -75,8 +75,33 @@ public record OpenClawChatTimelineProps( public class OpenClawChatTimeline : Component { const double FollowThreshold = 60; - const int FollowToBottomMaxStabilizationPasses = 4; - const double FollowToBottomExtentEpsilon = 0.5; + // Bounded settle used by QueueScrollToBottom to catch LATE virtualization extent + // corrections: after a discrete scroll-to-bottom, a row can realize below the fold a few + // frames later, growing the extent with NO ViewChanged/SizeChanged to drive a re-pin. A + // short self-terminating timer keeps chasing the true bottom until the extent is stable + // for a couple of ticks (or the hard cap elapses), then restores bottom anchoring. Re-pins + // are ChangeView-only (they never grow the extent) so this converges and cannot storm. + 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; + // 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 + // BEFORE each frame is painted as the ItemsRepeater's extent estimate climbs during + // realization — so there is no intermediate short frame (no jitter) and no programmatic + // ChangeView fighting the user's own scrolling. Discrete events (new entry, session switch, + // initial load, ScrollToBottom token) use QueueScrollToBottom, which briefly turns anchoring + // OFF while it drives ChangeView to the true bottom then restores 1.0 — otherwise anchoring + // would re-pin the stale bottom row mid-growth and the view would land one row short. + // Anchoring alone covers the in-place growth case that fires no reliable SizeChanged. See + // issue #996 for the upstream (Reactor) port context. /// /// Static scroll-offset store shared across all timeline instances so that @@ -478,6 +503,14 @@ public override Element Render() var suppressAutoFollowRef = UseRef(false); var sessionOffsetsRef = UseRef>(new()); var prevScrollToBottomTokenRef = UseRef(0); + var scrollSettleTimerRef = UseRef(null); + // 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 + // in between (their ChangeView never gets a frame to apply), so the view can never leave + // the bottom — the "fighting the scrollbar" bug. This flag coalesces reactive follows to a + // single in-flight pin/settle at a time. Explicit scroll-to-bottom requests bypass it. + var scrollPinPendingRef = UseRef(false); var hasMoreHistoryRef = UseRef(Props.HasMoreHistory); var loadMoreHistoryRef = UseRef(Props.OnLoadMoreHistory); var loadMoreRequestedForCountRef = UseRef(-1); @@ -638,46 +671,245 @@ void UpdateScrollMetrics(Microsoft.UI.Xaml.Controls.ScrollViewer sv) StoreSessionOffset(prevSessionIdRef.Current, sv.VerticalOffset); } - void QueueScrollToBottom(Microsoft.UI.Xaml.Controls.ScrollViewer sv, string? sessionId, bool disableAnimation) + void QueueScrollToBottom( + Microsoft.UI.Xaml.Controls.ScrollViewer sv, + string? sessionId, + bool disableAnimation, + bool respectUserScrollPosition = false) { isFollowingRef.Current = true; - void QueuePass(int pass, double previousScrollableHeight, bool passDisableAnimation) + // Bottom scroll anchoring (VerticalAnchorRatio = 1.0) keeps whatever row currently + // sits at the viewport bottom pinned there. During a DISCRETE scroll-to-bottom the + // extent is still growing — rows below the current anchor keep realizing — so if + // anchoring stays on the platform re-pins the STALE anchor row after each ChangeView + // and the view settles ~one row short of the true bottom and never converges (this is + // the LargeNative gap=240 and ThinkingAndStreaming 30%-stick regression; see PR #1014 + // / issue #996). Turn anchoring OFF while we drive the view to the real bottom, then + // restore 1.0 once the extent settles so subsequent IN-PLACE streaming growth of the + // newest row keeps following. This also stops anchoring and the SizeChanged-driven + // QueueScrollToBottom from fighting each other mid-stream (the residual streaming + // jitter noted on #996). + sv.VerticalAnchorRatio = double.NaN; + var anchoringRestored = false; + void RestoreAnchoring() { - sv.DispatcherQueue.TryEnqueue(() => + if (anchoringRestored) + return; + anchoringRestored = true; + sv.VerticalAnchorRatio = 1.0; + } + + void PinToBottom(bool passDisableAnimation) + { + sv.UpdateLayout(); + var bottom = sv.ScrollableHeight; + sv.ChangeView(null, bottom, null, passDisableAnimation); + lastVerticalOffsetRef.Current = bottom; + lastScrollableHeightRef.Current = sv.ScrollableHeight; + isFollowingRef.Current = true; + StoreSessionOffset(sessionId, bottom); + } + + // Only one settle timer runs at a time: a later discrete scroll-to-bottom (e.g. a + // token bump mid-stream) restarts the settle window instead of spawning parallel + // timers that would fight over ChangeView. Null the ref too (not just Stop) so a + // subsequently rejected enqueue can't leave a stopped-but-non-null timer that makes the + // follow gates believe a settle is still in flight and suppress follow forever. + 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 + // SizeChanged) and released only in a terminal path: after the settle timer is started + // and owns the chase, on the abandon bail, or below if the enqueue is rejected. + scrollPinPendingRef.Current = true; + + if (!sv.DispatcherQueue.TryEnqueue(() => + { + // 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(); + scrollSettleTimerRef.Current = null; + + // Flush any pending user ChangeView, then bail before pinning if this is a REACTIVE + // follow (layout growth) and the user has scrolled away — do NOT clobber their + // reading position with a bottom pin. Explicit scroll-to-bottom requests (session + // 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) { + // 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 + // extent re-estimation drift the reader's held position. The coalescing guard is + // released as this pin is now resolved (no timer will run). + isFollowingRef.Current = false; + sv.VerticalAnchorRatio = double.NaN; + scrollPinPendingRef.Current = false; + return; + } + + // First pin immediately for responsiveness. + PinToBottom(disableAnimation); + + var ticks = 0; + var stableTicks = 0; + var timer = new Microsoft.UI.Xaml.DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(FollowToBottomSettleTickMs) + }; + scrollSettleTimerRef.Current = timer; + timer.Tick += (_, _) => + { + // Bail out if the ScrollViewer was swapped/detached (unmount) so we never + // keep pinning a dead visual or leave anchoring disabled. + if (scrollViewRef.Current != sv || sv.XamlRoot is null) + { + timer.Stop(); + scrollSettleTimerRef.Current = null; + RestoreAnchoring(); + return; + } + + 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) + { + timer.Stop(); + scrollSettleTimerRef.Current = null; + sv.VerticalAnchorRatio = double.NaN; + return; + } + sv.UpdateLayout(); - var bottom = sv.ScrollableHeight; - sv.ChangeView(null, bottom, null, passDisableAnimation); - lastVerticalOffsetRef.Current = bottom; - lastScrollableHeightRef.Current = sv.ScrollableHeight; - isFollowingRef.Current = true; - StoreSessionOffset(sessionId, bottom); - - var extentStable = Math.Abs(bottom - previousScrollableHeight) <= FollowToBottomExtentEpsilon; + + // Re-check after layout: UpdateLayout can flush pending ViewChanged events + // (e.g. a user ChangeView that was queued but not yet dispatched). + if (!isFollowingRef.Current) + { + timer.Stop(); + scrollSettleTimerRef.Current = null; + sv.VerticalAnchorRatio = double.NaN; + 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) + { + // 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. + isFollowingRef.Current = false; + timer.Stop(); + scrollSettleTimerRef.Current = null; + sv.VerticalAnchorRatio = double.NaN; + return; + } + + PinToBottom(passDisableAnimation: true); + + // Converge on being AT the bottom for a couple of ticks, then hand off to + // scroll anchoring (VerticalAnchorRatio = 1.0, restored below). We deliberately + // do NOT require the extent to be stable: WinUI's ItemsRepeater keeps re- + // estimating row heights as rows realize, so the extent wobbles for many frames + // even once we are visually pinned. Waiting for extent stability made this timer + // run its full hard cap (~384ms) re-pinning every tick, which clobbered a user + // scroll issued during that window (the offset never dropped because our own + // ChangeView superseded theirs every 16ms). Once we are at the bottom, restored + // anchoring keeps the bottom row glued as the extent estimate settles, so the + // timer's job is done — terminate quickly and stop fighting user input. var atBottom = sv.ScrollableHeight - sv.VerticalOffset <= FollowThreshold; - if (pass < FollowToBottomMaxStabilizationPasses && (!extentStable || !atBottom)) - QueuePass(pass + 1, sv.ScrollableHeight, passDisableAnimation: true); - }); - } + stableTicks = atBottom ? stableTicks + 1 : 0; - QueuePass(0, double.NaN, disableAnimation); + if (stableTicks >= FollowToBottomSettleStableTicks || ticks >= FollowToBottomMaxSettleTicks) + { + timer.Stop(); + scrollSettleTimerRef.Current = null; + RestoreAnchoring(); + } + }; + timer.Start(); + // The settle timer now owns the chase; release the coalescing guard. Further + // SizeChanged notifications are gated by scrollSettleTimerRef being non-null while + // it runs, and by scrollPinPendingRef only during the synchronous window above. + scrollPinPendingRef.Current = false; + })) + { + // Dispatcher rejected the enqueue (e.g. teardown): never leave anchoring disabled + // or the coalescing guard stuck on. + scrollPinPendingRef.Current = false; + RestoreAnchoring(); + } } void QueuePreservePrependOffset(Microsoft.UI.Xaml.Controls.ScrollViewer sv, string? sessionId, double oldOffset, double oldScrollableHeight) { + // 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 + // inserted height. Our FunctionalUI reconciler, however, FULL-REMOUNTS every Entry on + // this render (the #996 limitation): the element the platform had chosen as the + // anchor is destroyed, so leaving anchoring at 1.0 just re-pins to the NEW bottom and + // yanks a scrolled-up reader down to the newest row (observed: offset 2528 -> 8531, + // gap 0). So for the prepend pass we DISABLE anchoring and manually re-seat the offset + // by the inserted height instead. Exact pixel preservation isn't achievable under the + // full remount, but this keeps the reader in the middle band — not reset to the top, + // not dragged to the bottom (see the KNOWN LIMITATION note on the prepend proof test). suppressAutoFollowRef.Current = true; - sv.DispatcherQueue.TryEnqueue(() => + sv.VerticalAnchorRatio = double.NaN; + + // A prior scroll-to-bottom settle timer would keep pinning to the bottom and defeat + // the preserved reading position — cancel it for this prepend. Clear the coalescing + // guard too so the anchoring gate/SizeChanged follow path isn't left believing a pin + // is still in flight. + scrollSettleTimerRef.Current?.Stop(); + scrollSettleTimerRef.Current = null; + scrollPinPendingRef.Current = false; + + void RestoreOffset() { + // 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 + delta, sv.ScrollableHeight); + var target = ClampOffset(oldOffset + Math.Max(0, delta), sv.ScrollableHeight); sv.ChangeView(null, target, null, disableAnimation: true); lastVerticalOffsetRef.Current = target; lastScrollableHeightRef.Current = sv.ScrollableHeight; isFollowingRef.Current = sv.ScrollableHeight - target <= FollowThreshold; StoreSessionOffset(sessionId, target); - sv.DispatcherQueue.TryEnqueue(() => suppressAutoFollowRef.Current = false); - }); + suppressAutoFollowRef.Current = false; + } + + if (!sv.DispatcherQueue.TryEnqueue(RestoreOffset)) + { + RestoreOffset(); + } } // Load more button — outside the repeated items @@ -2529,9 +2761,23 @@ bool BurstIsNestable(System.Collections.Generic.List b) return; } - if (!suppressAutoFollowRef.Current && isFollowingRef.Current) + // Follow-to-bottom during layout-driven growth (streaming into + // the newest row, thinking indicator, tool expand) needs a re- + // pin: WinUI scroll anchoring (VerticalAnchorRatio = 1.0) keeps + // an EXISTING bottom row glued as it grows, but NEW content that + // 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. + if (!suppressAutoFollowRef.Current + && isFollowingRef.Current + && scrollSettleTimerRef.Current is null + && !scrollPinPendingRef.Current) { - QueueScrollToBottom(sv, prevSessionIdRef.Current, disableAnimation: true); + QueueScrollToBottom(sv, prevSessionIdRef.Current, disableAnimation: true, respectUserScrollPosition: true); } else if (suppressAutoFollowRef.Current) { @@ -2551,10 +2797,70 @@ bool BurstIsNestable(System.Collections.Generic.List b) if (scrollViewRef.Current != sv) { scrollViewRef.Current = sv; + // Option B: WinUI scroll anchoring pins the bottom row to the viewport bottom + // 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 += (_, _) => { + // Follow-to-bottom during in-place streaming growth is handled by scroll + // anchoring (VerticalAnchorRatio = 1.0), so there is NO reactive re-pin + // here. The old re-pin produced an intermediate short frame (jitter) and + // fought the user's own scrolling. We just refresh follow/offset metrics + // 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) + { + // 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; + } + else if (isFollowingRef.Current + && scrollSettleTimerRef.Current is null + && !scrollPinPendingRef.Current) + { + // 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; + } + if (sv.ScrollableHeight > 0 && sv.VerticalOffset <= FollowThreshold && hasMoreHistoryRef.Current @@ -2569,6 +2875,17 @@ bool BurstIsNestable(System.Collections.Generic.List b) if (entryCount != previousEntryCount) loadMoreRequestedForCountRef.Current = -1; + // Keep bottom scroll anchoring active only while actually following. When the user + // has scrolled up to read history, anchoring the viewport-bottom row lets extent + // 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 + // (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) + sv.VerticalAnchorRatio = isFollowingRef.Current ? 1.0 : double.NaN; + if (sessionChanged && !isFirstMount) { StoreSessionOffset(previousSessionId, lastVerticalOffsetRef.Current); diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs index f9dbc23b3..37d0f38f7 100644 --- a/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs @@ -24,11 +24,21 @@ public void ProductionTimeline_StabilizesFollowToBottomForVirtualizedRows() { var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatTimeline.cs"); - Assert.Contains("FollowToBottomMaxStabilizationPasses", timeline); - Assert.Contains("FollowToBottomExtentEpsilon", timeline); - Assert.Contains("void QueuePass(", timeline); + // Follow-to-bottom for the virtualized ItemsRepeater is delivered by three cooperating + // mechanisms; guard that all three stay present so a refactor can't silently drop back to + // the old scroll-fighting behavior (PR #1014 / issue #996): + // 1. WinUI scroll anchoring pins the bottom row pre-paint as the extent grows in place. + Assert.Contains("sv.VerticalAnchorRatio = 1.0", timeline); + // 2. A self-terminating settle timer chases LATE extent corrections, converging on being + // at the bottom rather than requiring an (never-settling) stable extent estimate. + 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"). + Assert.Contains("scrollPinPendingRef", timeline); + Assert.Contains("UserScrolledAway", timeline); Assert.Contains("sv.UpdateLayout();", timeline); - Assert.Contains("QueuePass(pass + 1", timeline); } [Fact] diff --git a/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs b/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs index bbc86a933..6753039ba 100644 --- a/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs @@ -20,6 +20,11 @@ public sealed class ChatTimelineVirtualizationProofTests { private const int InitialRows = 240; + // Mirrors OpenClawChatTimeline.FollowThreshold (private): the gap-from-bottom (px) within + // which the timeline treats itself as "following" the newest row. Used by the scroll + // stability assertions to prove the view is / is not in follow mode. + private const double FollowThreshold = 60; + private readonly UIThreadFixture _ui; public ChatTimelineVirtualizationProofTests(UIThreadFixture ui) => _ui = ui; @@ -162,6 +167,478 @@ await _ui.RunOnUIAsync(() => await _ui.RunOnUIAsync(() => host!.Dispose()); } + // Regression proof for the "scrollbar doesn't reach the bottom while the agent is + // thinking" bug. The thinking indicator and streamed assistant text grow the last row IN + // PLACE: no new Props.Entries, no ScrollToBottomToken bump, so the timeline's token/append + // follow branches never fire and there is no reliable SizeChanged to drive + // QueueScrollToBottom. Root cause: as ItemsRepeater re-realizes the grown row its extent + // estimate climbs a few px per frame, leaving the offset short of the new bottom with + // nothing to re-pin it. Fix (Option B): sv.VerticalAnchorRatio = 1.0 keeps the bottom-most + // realized row glued to the viewport bottom BEFORE each frame is painted as the extent + // grows, so the view stays pinned without a reactive post-layout ChangeView. + [Fact] + public async Task ThinkingAndStreamingGrowth_KeepsViewPinnedToBottom() + { + await _ui.ResetContainerAsync(); + + const int rows = 60; + FunctionalHostControl? host = null; + + // 1. Mount a scrollable timeline. First mount is an initial load -> scrolls to bottom. + var props = BuildProps(rows, scrollToBottomToken: 0, sessionId: "ui-proof-thinking-stream"); + 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 > 0, "timeline should overflow and be scrollable"); + Assert.True( + scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset <= 4, + $"precondition: view should start pinned to bottom; offset={scrollViewer.VerticalOffset:0.0}, height={scrollViewer.ScrollableHeight:0.0}"); + }); + + // 2. Agent starts thinking: a synthetic indicator row appears. No new Props.Entry, + // no ScrollToBottomToken bump. The view must stay pinned to the bottom. + props = props with { ShowThinkingIndicator = true }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var gap = scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset; + Console.WriteLine( + "CHAT_TIMELINE_PIN_PROOF stage=thinking-indicator " + + $"gap={gap:0.0} offset={scrollViewer.VerticalOffset:0.0} scrollable={scrollViewer.ScrollableHeight:0.0}"); + Assert.True( + gap <= 4, + $"thinking indicator must not break bottom-follow; gap={gap:0.0}, height={scrollViewer.ScrollableHeight:0.0}"); + }); + + // 3. Assistant streams: the last entry's text grows over several re-renders while the + // thinking indicator is still present. No token bump, no entry-count change, so the + // timeline's token/append follow branches never fire — follow relies on the frame-spaced + // landing correction. Regression: ItemsRepeater virtualization briefly reports + // ScrollableHeight a few px past the realized reachable offset after an in-place row + // growth, leaving the scrollbar short of the bottom while the agent is thinking/streaming. + for (var revision = 1; revision <= 4; revision++) + { + var streamed = BuildStreamingEntries(rows, revision); + props = props with { Entries = streamed }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var gap = scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset; + Console.WriteLine( + "CHAT_TIMELINE_PIN_PROOF stage=streaming " + + $"revision={revision} gap={gap:0.0} offset={scrollViewer.VerticalOffset:0.0} " + + $"scrollable={scrollViewer.ScrollableHeight:0.0}"); + Assert.True( + gap <= 4, + $"streaming revision {revision} must keep view pinned to the bottom; " + + $"gap={gap:0.0}, " + + $"offset={scrollViewer.VerticalOffset:0.0}, scrollable={scrollViewer.ScrollableHeight:0.0}"); + }); + } + + await _ui.RunOnUIAsync(() => host!.Dispose()); + } + + // Companion to ThinkingAndStreamingGrowth: when the USER has deliberately scrolled up to + // read earlier messages, streaming growth of the newest (off-screen, below the viewport) + // row must NOT yank the viewport to the bottom. VerticalAnchorRatio = 1.0 anchors the + // element at the viewport's bottom edge, so content growing further below only extends the + // scrollable range while the visible rows stay put — the user keeps their reading position. + // This is the "don't fight the scrollbar" half of the fix. + [Fact] + public async Task UserScrolledUpDuringStreaming_KeepsReadingPositionStable() + { + await _ui.ResetContainerAsync(); + + const int rows = 60; + FunctionalHostControl? host = null; + + var props = BuildProps(rows, scrollToBottomToken: 0, sessionId: "ui-proof-scrollup"); + 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(); + + // User scrolls up well above the follow threshold to read earlier messages. + var userOffset = 0.0; + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _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". + scrollViewer.ChangeView(null, scrollViewer.ScrollableHeight * 0.3, null, disableAnimation: true); + _ui.Container.UpdateLayout(); + }); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + userOffset = scrollViewer.VerticalOffset; + Assert.True( + scrollViewer.ScrollableHeight - userOffset > FollowThreshold, + "precondition: user should be scrolled above the follow threshold; " + + $"gap={scrollViewer.ScrollableHeight - userOffset:0.0}, threshold={FollowThreshold}"); + }); + + // Assistant streams into the newest (off-screen) row while the user stays scrolled up. + props = props with { ShowThinkingIndicator = true }; + for (var revision = 1; revision <= 4; revision++) + { + var streamed = BuildStreamingEntries(rows, revision); + props = props with { Entries = streamed }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var gap = scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset; + var drift = Math.Abs(scrollViewer.VerticalOffset - userOffset); + Console.WriteLine( + "CHAT_TIMELINE_SCROLLUP_STABLE " + + $"revision={revision} userOffset={userOffset:0.0} offset={scrollViewer.VerticalOffset:0.0} " + + $"drift={drift:0.0} gap={gap:0.0} scrollable={scrollViewer.ScrollableHeight:0.0}"); + + // The view must NOT be dragged into follow mode... + Assert.True( + gap > FollowThreshold, + $"streaming must not drag a scrolled-up user to the bottom; revision {revision}, " + + $"gap={gap:0.0}, threshold={FollowThreshold}"); + // ...and the user's reading position must stay put (allow minor extent-estimate wobble). + Assert.True( + drift <= 24, + $"user reading position must stay stable while streaming; revision {revision}, " + + $"drift={drift:0.0}, userOffset={userOffset:0.0}, offset={scrollViewer.VerticalOffset:0.0}"); + }); + } + + 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 ModerateScrollUpDuringSettleTimer_IsNotRepinned() + { + await _ui.ResetContainerAsync(); + + const int rows = 60; + FunctionalHostControl? host = null; + + var props = BuildProps(rows, scrollToBottomToken: 0, sessionId: "ui-proof-moderate-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(); + + // Verify we start pinned to the bottom. + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + Assert.True( + scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset <= 4, + $"precondition: view should start pinned to bottom; offset={scrollViewer.VerticalOffset:0.0}, " + + $"scrollable={scrollViewer.ScrollableHeight:0.0}"); + }); + + // Trigger a scroll-to-bottom token bump, which starts a settle timer. + // Use a fresh token bump so QueueScrollToBottom fires and creates the DispatcherTimer. + props = props with { ScrollToBottomToken = 1 }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + + // Yield enough for the enqueued QueueScrollToBottom to fire (starts timer) but not + // enough for the timer to fully converge and stop (needs ~2 stable ticks = ~32ms+). + // A single UpdateLayout + YieldToRender puts us right after the initial PinToBottom. + await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout()); + await _ui.YieldToRenderAsync(); + + // Bounded wait: confirm the settle timer is ACTIVE before proceeding. Poll up to 200ms. + var timerWasActive = false; + for (var i = 0; i < 10 && !timerWasActive; i++) + { + await Task.Delay(20); + await _ui.RunOnUIAsync(() => + { + // The settle timer sets scrollSettleTimerRef. We can observe it indirectly: + // while the timer is active, the ScrollViewer has VerticalAnchorRatio = NaN + // (QueueScrollToBottom disables anchoring during settling). + var scrollViewer = FindLogical(host!).Single(); + timerWasActive = double.IsNaN(scrollViewer.VerticalAnchorRatio); + }); + } + 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. + var userOffset = 0.0; + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var targetOffset = Math.Max(0, scrollViewer.ScrollableHeight - 400); + scrollViewer.ChangeView(null, targetOffset, null, disableAnimation: true); + _ui.Container.UpdateLayout(); + }); + + // Let ViewChanged fire and propagate (the fix: moderate-cancel stops the timer). + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + userOffset = scrollViewer.VerticalOffset; + var gap = scrollViewer.ScrollableHeight - userOffset; + Assert.True( + gap > FollowThreshold && gap < 840, + "precondition: user should be in the moderate band (above FollowThreshold, below abandonGap); " + + $"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. + props = props with { ShowThinkingIndicator = true }; + for (var revision = 1; revision <= 4; revision++) + { + var streamed = BuildStreamingEntries(rows, revision); + props = props with { Entries = streamed }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var gap = scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset; + var drift = Math.Abs(scrollViewer.VerticalOffset - userOffset); + + // The view must NOT be re-pinned to the bottom. + Assert.True( + gap > FollowThreshold, + $"moderate scroll must not be re-pinned to the bottom during streaming; revision {revision}, " + + $"gap={gap:0.0}, threshold={FollowThreshold}"); + // Reading position must remain bounded-stable (FunctionalUI re-estimation + // causes up to ~80px drift per revision, independent of scroll anchoring). + Assert.True( + drift <= 80, + $"moderate scroll position must stay bounded-stable while streaming; revision {revision}, " + + $"drift={drift:0.0}, userOffset={userOffset:0.0}, offset={scrollViewer.VerticalOffset:0.0}"); + }); + } + + await _ui.RunOnUIAsync(() => host!.Dispose()); + } + + // Load-earlier / prepend restore. This scenario isn't reproducible on a fresh live session + // (which has no earlier history to load), so it is proven here instead of in the recording. + // When earlier messages are prepended this test guards the invariants a temporary scroll fix + // CAN hold under the current full-remount FunctionalUI reconciler: + // (1) the scrolled-up reader is NOT yanked to the newest row and NOT reset to the very top + // (the offset stays in the "reading earlier history" band), and + // (2) bottom-follow still works afterward. Under the dynamic-anchoring model, + // VerticalAnchorRatio is 1.0 only while the view is in the bottom band and NaN while the + // reader is scrolled up (so post-remount extent estimates can't drift their position); + // the end-to-end guarantee is that a ScrollToBottomToken bump re-follows to the newest + // row. A regression that left follow permanently broken would fail that final assertion. + // + // KNOWN LIMITATION (tracked in issue #996): exact pixel-for-pixel reading-position + // preservation (offset shifting by the full inserted height) is NOT achievable here because + // the prepend re-mounts the whole Entries collection, resetting the ItemsRepeater's realized + // rows. That reset defeats BOTH native WinUI scroll anchoring (the anchor element is a new + // instance post-reset) AND a manual ChangeView(oldOffset + insertedHeight) (jumping into + // un-realized territory re-estimates row heights and clamps/oscillates the target back). Real + // preservation needs the incremental keyed reconciliation the Reactor port (#996) provides; + // this test therefore asserts the bounded reading position, not the exact offset. + // + // NOTE: the prepend path keys off the entries-prepended predicate (new first id, unchanged + // last id, previous first id still present, count grew) — NOT Props.HasMoreHistory — so this + // test drives it with HasMoreHistory=false on purpose. Rendering the HasMoreHistory "load + // earlier" Button pulls in theme-brush resource refs the headless UI-test host doesn't + // realize, which aborts the timeline subtree; keeping it false isolates the offset/anchoring + // invariant we actually care about here. + [Fact] + public async Task PrependHistory_PreservesOffset_AndRestoresBottomAnchoring() + { + await _ui.ResetContainerAsync(); + + const int rows = 60; + const string sessionId = "ui-proof-prepend"; + FunctionalHostControl? host = null; + + var entries = BuildEntries(rows, textRevision: 0); + var props = BuildPropsFrom(sessionId, entries, hasMoreHistory: false, scrollToBottomToken: 0); + 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(); + + // User scrolls up to a mid position to read earlier messages, then history is prepended. + var oldOffset = 0.0; + var oldScrollable = 0.0; + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + Assert.True(scrollViewer.ScrollableHeight > 0, "timeline should overflow and be scrollable"); + scrollViewer.ChangeView(null, scrollViewer.ScrollableHeight * 0.4, null, disableAnimation: true); + _ui.Container.UpdateLayout(); + }); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + oldOffset = scrollViewer.VerticalOffset; + oldScrollable = scrollViewer.ScrollableHeight; + }); + + // Prepend 20 earlier messages: NEW ids at the FRONT, same LAST id, previous first id + // still present, count grows — exactly OpenClawChatTimeline's prependedHistory predicate, + // which drives QueuePreservePrependOffset (offset preservation + anchoring toggle). + var prepended = PrependHistory(entries, historyRows: 20); + props = props with { Entries = prepended }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var delta = scrollViewer.ScrollableHeight - oldScrollable; + var offset = scrollViewer.VerticalOffset; + var gapFromBottom = scrollViewer.ScrollableHeight - offset; + Console.WriteLine( + "CHAT_TIMELINE_PREPEND_RESTORE " + + $"oldOffset={oldOffset:0.0} oldScrollable={oldScrollable:0.0} " + + $"newScrollable={scrollViewer.ScrollableHeight:0.0} delta={delta:0.0} " + + $"offset={offset:0.0} gapFromBottom={gapFromBottom:0.0} " + + $"anchorRatio={scrollViewer.VerticalAnchorRatio}"); + + // Prepending earlier history grows the scrollable extent. + Assert.True(delta > 0, $"prepending earlier history should grow the scrollable extent; delta={delta:0.0}"); + + // Bounded reading position (see the KNOWN LIMITATION note above): the scrolled-up + // reader must NOT be reset to the very top and must NOT be yanked down into follow at + // the newest row. Exact pixel preservation isn't achievable under the full-remount + // reconciler (#996), but these two regressions MUST NOT happen. + Assert.True( + offset > FollowThreshold, + $"prepend must not reset the reader to the top; offset={offset:0.0}"); + Assert.True( + gapFromBottom > FollowThreshold, + "prepend must not yank a scrolled-up reader down to the newest row; " + + $"gapFromBottom={gapFromBottom:0.0}, threshold={FollowThreshold}"); + + // Follow is now DYNAMICALLY armed: bottom anchoring (VerticalAnchorRatio = 1.0) is + // enabled only while the view sits in the bottom band, and disabled (NaN) while the + // user is scrolled up so extent re-estimation can't nudge their held reading position. + // Since this reader stays scrolled up after the prepend, anchoring being NaN here is + // CORRECT, not a stuck-disabled regression — the old design kept a single static 1.0, + // but that let post-reset extent estimates drift the reader. Anchoring must be one of + // the two well-defined states (NaN while up, or exactly 1.0 at the bottom), never a + // stale partial ratio. The real guarantee — that bottom-follow still WORKS after a + // prepend — is proven end-to-end below via the ScrollToBottomToken bump. + Assert.True( + double.IsNaN(scrollViewer.VerticalAnchorRatio) || scrollViewer.VerticalAnchorRatio == 1.0, + "prepend must leave anchoring in a well-defined state (NaN while scrolled up, or " + + $"1.0 at the bottom); VerticalAnchorRatio was {scrollViewer.VerticalAnchorRatio}"); + }); + + // End-to-end: with anchoring restored, a scroll-to-bottom token must follow again. + props = props with { ScrollToBottomToken = 1 }; + await _ui.RunOnUIAsync(() => host!.Mount(_ => Component(props))); + await DrainRenderQueueAsync(); + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var scrollViewer = FindLogical(host!).Single(); + _ui.Container.UpdateLayout(); + var gap = scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset; + Console.WriteLine($"CHAT_TIMELINE_PREPEND_RESTORE followAfterPrepend gap={gap:0.0}"); + Assert.True( + gap <= 4, + $"after prepend + restore, scroll-to-bottom must follow to the newest row; gap={gap:0.0}"); + }); + + await _ui.RunOnUIAsync(() => host!.Dispose()); + } + [Fact] public async Task RealizedComponentRow_ReplacesRootAndPrunesRemovedEffects() { @@ -213,19 +690,45 @@ await _ui.RunOnUIAsync(() => await _ui.RunOnUIAsync(() => host!.Dispose()); } + // Bounded, deterministic render-queue drain. ItemsRepeater realizes rows and the + // ScrollViewer settles its extent estimate — and re-fires its initial scroll-to-bottom + // follow — across several dispatcher ticks. Each pass forces layout, then drains the + // render queue deterministically via a LOW-priority dispatcher callback (WinUI runs + // layout/render/realization at higher priority, so a Low continuation only resumes once + // that pass's queued work has run), then yields REAL wall-clock time so timer-based scroll + // retries can fire. The per-pass delay must stay generous: the previous proven-green drain + // gave a single contiguous Task.Delay(50) of free dispatcher time, and shrinking that (to a + // 5ms floor) left the virtualized scroll-to-bottom short of convergence (offset stuck ~30% + // from the bottom). Three 40ms passes give strictly MORE free dispatcher time than the old + // 50ms while keeping the drain deterministic. The pass count is fixed, so this can never + // spin or block indefinitely even if layout never fully quiesces. private async Task DrainRenderQueueAsync() { - await _ui.RunOnUIAsync(() => { }); - await Task.Delay(50); - await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout()); - await _ui.RunOnUIAsync(() => { }); + const int maxDrainPasses = 3; + for (var pass = 0; pass < maxDrainPasses; pass++) + { + await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout()); + await _ui.YieldToRenderAsync(); + await Task.Delay(40); + } } - private static OpenClawChatTimelineProps BuildProps(int rows, int scrollToBottomToken, int textRevision = 0) => + private static OpenClawChatTimelineProps BuildProps(int rows, int scrollToBottomToken, int textRevision = 0, string sessionId = "ui-proof-large-chat") => + BuildPropsFrom( + sessionId, + BuildEntries(rows, textRevision), + hasMoreHistory: false, + scrollToBottomToken); + + private static OpenClawChatTimelineProps BuildPropsFrom( + string sessionId, + IReadOnlyList entries, + bool hasMoreHistory, + int scrollToBottomToken) => new( - SessionId: "ui-proof-large-chat", - Entries: BuildEntries(rows, textRevision), - HasMoreHistory: false, + SessionId: sessionId, + Entries: entries, + HasMoreHistory: hasMoreHistory, OnLoadMoreHistory: null, UserSenderLabel: "UI proof user", AssistantSenderLabel: "UI proof assistant", @@ -233,6 +736,27 @@ private static OpenClawChatTimelineProps BuildProps(int rows, int scrollToBottom ShowToolCalls: true, ScrollToBottomToken: scrollToBottomToken); + // Insert `historyRows` NEW entries at the FRONT (ids that sort before the existing ones), + // keeping the existing entries — and crucially the same LAST entry id — in place. This + // matches OpenClawChatTimeline's prependedHistory predicate (new first id, unchanged last + // id, previous first id still present, count grew), so mounting it drives the load-earlier + // offset-preservation path. + private static IReadOnlyList PrependHistory(IReadOnlyList existing, int historyRows) + { + var combined = new List(historyRows + existing.Count); + for (var i = 1; i <= historyRows; i++) + { + var isUser = i % 2 == 1; + combined.Add(new ChatTimelineItem( + Id: $"hist-{i:000}", + Kind: isUser ? ChatTimelineItemKind.User : ChatTimelineItemKind.Assistant, + Text: FormatVariableHeightText(isUser ? "History user" : "History assistant", i, textRevision: 0))); + } + + combined.AddRange(existing); + return combined; + } + private static IReadOnlyList BuildEntries(int rows, int textRevision) { var entries = new List(rows); @@ -248,6 +772,21 @@ private static IReadOnlyList BuildEntries(int rows, int textRe return entries; } + // Same entry ids as BuildEntries (so counts/keys are stable, mirroring a streaming + // update), but the final assistant row's text grows with each revision to emulate the + // assistant reply streaming in while the thinking indicator is still shown. + private static IReadOnlyList BuildStreamingEntries(int rows, int streamRevision) + { + var entries = new List(BuildEntries(rows, textRevision: 0)); + var lastIndex = entries.Count - 1; + var last = entries[lastIndex]; + var streamedBody = string.Join( + Environment.NewLine, + Enumerable.Range(0, streamRevision * 3).Select(line => $"streamed line {line} of assistant reply")); + entries[lastIndex] = last with { Text = last.Text + Environment.NewLine + streamedBody }; + return entries; + } + private static string FormatVariableHeightText(string role, int row, int textRevision) { var detailLines = row % 4; diff --git a/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs b/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs index 63970bc88..a3f45e19e 100644 --- a/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs +++ b/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs @@ -150,6 +150,25 @@ public Task RunOnUIAsync(Func> work) /// Run an async void lambda on the UI thread, awaiting its completion. public Task RunOnUIAsync(Func work) => RunOnUIAsync(async () => { await work().ConfigureAwait(true); return 0; }); + /// + /// Await a single LOW-priority dispatcher callback. WinUI schedules layout/render and the + /// realization callbacks they queue at higher priority, so by the time a Low-priority + /// continuation runs, all currently-queued render/layout/realization work has already run. + /// This is a deterministic drain primitive — a bounded alternative to sleeping a fixed + /// interval — used by tests that need the render queue to settle. If the dispatcher is + /// shutting down and rejects the enqueue, the task completes immediately so callers can + /// never block indefinitely. + /// + public Task YieldToRenderAsync() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!Dispatcher.TryEnqueue(DispatcherQueuePriority.Low, () => tcs.TrySetResult())) + { + tcs.TrySetResult(); + } + return tcs.Task; + } + /// Clear the container so each test starts from an empty surface. public Task ResetContainerAsync() => RunOnUIAsync(() => Container.Children.Clear());