Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 33 additions & 52 deletions src/OpenClaw.Tray.WinUI/Chat/ReactorItemsViewScrollController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ public V1UnmountDisposition Unmount(UnmountContext context, ItemsViewVerticalScr
private int _itemCount;
private int _version;
private bool _valid;
private bool _awaitingLayout;
private WinUIScrollView? _awaitingScrollView;
private WinUIScrollView? _scrollView;
private bool _following;
Expand All @@ -115,7 +114,7 @@ public void Request(int tailIndex, int itemCount, string requestKey, string? dis

_requestKey = requestKey;
_version++;
DetachLayout();
StopWaitingForScrollView();
_tailIndex = tailIndex;
_itemCount = itemCount;
_displayedTailKey = displayedTailKey;
Expand All @@ -124,8 +123,7 @@ public void Request(int tailIndex, int itemCount, string requestKey, string? dis
if (!_valid)
return;

if (itemsView.IsLoaded)
AwaitLayout();
TryPositionInitialTail();
}

public void UpdateTail(int tailIndex, int itemCount, string? displayedTailKey)
Expand All @@ -149,64 +147,54 @@ public void UpdateTail(int tailIndex, int itemCount, string? displayedTailKey)

private void OnLoaded(object sender, RoutedEventArgs args)
{
if (_valid)
AwaitLayout();
TryPositionInitialTail();
}

private void AwaitLayout()
private void TryPositionInitialTail()
{
if (_disposed || !_valid || !itemsView.IsLoaded || _awaitingLayout)
if (_disposed || !_valid || !itemsView.IsLoaded
|| itemsView.ScrollView is not { } scrollView)
return;

if (itemsView.ScrollView is { IsLoaded: false } scrollView)
if (!scrollView.IsLoaded)
{
_awaitingScrollView = scrollView;
scrollView.Loaded += OnScrollViewLoaded;
return;
}

_awaitingLayout = true;
itemsView.LayoutUpdated += OnLayoutUpdated;
}

private void OnScrollViewLoaded(object sender, RoutedEventArgs args)
{
if (sender is WinUIScrollView scrollView)
scrollView.Loaded -= OnScrollViewLoaded;

_awaitingScrollView = null;
AwaitLayout();
}

private void OnLayoutUpdated(object? sender, object args)
{
DetachLayout();
if (itemsView.ScrollView is not { IsLoaded: true })
{
AwaitLayout();
if (!ReferenceEquals(_awaitingScrollView, scrollView))
{
StopWaitingForScrollView();
_awaitingScrollView = scrollView;
scrollView.Loaded += OnScrollViewLoaded;
}
return;
}

StopWaitingForScrollView();
var version = _version;
if (!TailNavigationPolicy.TryCapture(_tailIndex, _displayedTailKey, _itemCount, out var request))
return;

// Leave Reactor reconciliation before native navigation. The bring request handles layout itself.
itemsView.DispatcherQueue.TryEnqueue(() =>
{
if (_disposed || !_valid || !itemsView.IsLoaded || version != _version
|| itemsView.ScrollView is not { IsLoaded: true })
if (_disposed || !_valid || version != _version)
return;

if (!itemsView.IsLoaded || itemsView.ScrollView is not { IsLoaded: true })
{
if (!_disposed && _valid)
AwaitLayout();
TryPositionInitialTail();
return;
}

AttachScrollView();
if (!StartTailRequest(request) && !_disposed && _valid)
AwaitLayout();
StartTailRequest(request);
});
}

private void OnScrollViewLoaded(object sender, RoutedEventArgs args)
{
StopWaitingForScrollView();
TryPositionInitialTail();
}

private void AttachScrollView()
{
var nextScrollView = itemsView.ScrollView;
Expand Down Expand Up @@ -251,18 +239,18 @@ private void QueueTailRequest(int version, TailNavigationRequest request)
}
}

private bool StartTailRequest(TailNavigationRequest request)
private void StartTailRequest(TailNavigationRequest request)
{
if (itemsView.ScrollView is not { IsLoaded: true })
return false;
return;

if (!TailNavigationPolicy.CanExecute(
request,
_tailIndex,
_displayedTailKey,
_itemCount))
{
return false;
return;
}

_following = true;
Expand All @@ -271,7 +259,6 @@ private bool StartTailRequest(TailNavigationRequest request)
AnimationDesired = false,
VerticalAlignmentRatio = 1.0,
});
return true;
}

private static bool IsNearBottom(WinUIScrollView scrollView) =>
Expand All @@ -281,23 +268,17 @@ private void OnUnloaded(object sender, RoutedEventArgs args)
{
_version++;
_tailNavigationQueue.Clear();
DetachLayout();
StopWaitingForScrollView();
DetachScrollView();
}

private void DetachLayout()
private void StopWaitingForScrollView()
{
if (_awaitingScrollView is { } scrollView)
{
scrollView.Loaded -= OnScrollViewLoaded;
_awaitingScrollView = null;
}

if (_awaitingLayout)
{
itemsView.LayoutUpdated -= OnLayoutUpdated;
_awaitingLayout = false;
}
}

private void DetachScrollView()
Expand All @@ -319,7 +300,7 @@ public void Dispose()
_disposed = true;
_version++;
_tailNavigationQueue.Clear();
DetachLayout();
StopWaitingForScrollView();
DetachScrollView();
itemsView.Loaded -= OnLoaded;
itemsView.Unloaded -= OnUnloaded;
Expand Down
41 changes: 39 additions & 2 deletions tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public void ReactorTimeline_UsesStableBottomAnchoringAndDiscreteTailRequests()
"ReactorItemsViewScrollController.cs"));

Assert.Contains("itemsView.Loaded += OnLoaded", binding);
Assert.Contains("itemsView.LayoutUpdated += OnLayoutUpdated", binding);
Assert.DoesNotContain("LayoutUpdated", binding);
Assert.Contains("itemsView.DispatcherQueue.TryEnqueue", binding);
Assert.Contains("itemsView.StartBringItemIntoView(", binding);
Assert.Contains("VerticalAlignmentRatio = 1.0", binding);
Expand All @@ -79,7 +79,7 @@ public void ReactorTimeline_UsesStableBottomAnchoringAndDiscreteTailRequests()
Assert.Contains("TailNavigationPolicy.CanExecute(", binding);
Assert.Contains("itemsView.Unloaded += OnUnloaded", binding);
Assert.Contains("itemsView.Loaded -= OnLoaded", binding);
Assert.Contains("itemsView.LayoutUpdated -= OnLayoutUpdated", binding);
Assert.Contains("scrollView.Loaded -= OnScrollViewLoaded", binding);
Assert.DoesNotContain("ChangeView", binding);
Assert.DoesNotContain("UpdateLayout", binding);
Assert.DoesNotContain("TailSettle", binding);
Expand All @@ -98,6 +98,43 @@ public void ReactorTimeline_UsesStableBottomAnchoringAndDiscreteTailRequests()
Assert.DoesNotContain("StartBringItemIntoView", viewChanged);
}

[Fact]
public void ReactorTimeline_InitialTailWaitsForLoadWithoutLayoutSubscription()
{
var binding = File.ReadAllText(Path.Combine(
TestRepositoryPaths.GetRepositoryRoot(),
"src",
"OpenClaw.Tray.WinUI",
"Chat",
"ReactorItemsViewScrollController.cs"));

var requestStart = binding.IndexOf("public void Request(", StringComparison.Ordinal);
var updateStart = binding.IndexOf("public void UpdateTail(", requestStart, StringComparison.Ordinal);
Assert.Contains("TryPositionInitialTail();", binding[requestStart..updateStart]);

var loadStart = binding.IndexOf("private void OnLoaded(", StringComparison.Ordinal);
var attachStart = binding.IndexOf("private void AttachScrollView(", loadStart, StringComparison.Ordinal);
var initialPositioning = binding[loadStart..attachStart];
Assert.Contains("_disposed || !_valid || !itemsView.IsLoaded", initialPositioning);
Assert.Contains("if (!scrollView.IsLoaded)", initialPositioning);
Assert.Contains("!ReferenceEquals(_awaitingScrollView, scrollView)", initialPositioning);
Assert.Contains("scrollView.Loaded += OnScrollViewLoaded", initialPositioning);
Assert.Contains("StopWaitingForScrollView();", initialPositioning);
Assert.Contains("AttachScrollView();", initialPositioning);
Assert.Contains("TailNavigationPolicy.TryCapture(", initialPositioning);
Assert.Contains("StartTailRequest(request);", initialPositioning);
Assert.Contains("itemsView.DispatcherQueue.TryEnqueue", initialPositioning);
Assert.Contains("var version = _version;", initialPositioning);
Assert.Contains("_disposed || !_valid || version != _version", initialPositioning);
Assert.DoesNotContain("AwaitLayout", binding);

var unloadStart = binding.IndexOf("private void OnUnloaded(", StringComparison.Ordinal);
var stopWaitingStart = binding.IndexOf("private void StopWaitingForScrollView(", unloadStart, StringComparison.Ordinal);
Assert.Contains("StopWaitingForScrollView();", binding[unloadStart..stopWaitingStart]);
var disposeStart = binding.IndexOf("public void Dispose()", StringComparison.Ordinal);
Assert.Contains("StopWaitingForScrollView();", binding[disposeStart..]);
}

[Fact]
public void TailNavigationPolicy_RejectsQueuedRequestAfterValidTailBecomesEmpty()
{
Expand Down
Loading