Skip to content

Commit be07589

Browse files
karkarlCopilot
andcommitted
fix(chat): validate queued tail navigation
Capture immutable tail requests, revalidate their index and row identity against the reconciled item count, and coalesce dispatcher work without allowing stale generations to execute. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 96f0e9e commit be07589

5 files changed

Lines changed: 211 additions & 24 deletions

File tree

src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ void SetEntryHovered(string entryId, bool isHovered)
160160
.BindVerticalScrollController(
161161
annotatedScrollBarRef,
162162
rows.Count - 1,
163+
rows.Count,
163164
initialTailRequestKey,
164165
displayedTailKey)
165166
.Grid(column: 0)

src/OpenClaw.Tray.WinUI/Chat/ReactorItemsViewScrollController.cs

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ file sealed record ItemsViewVerticalScrollControllerElement(
1414
Element Child,
1515
ElementRef<WinUIAnnotatedScrollBar> ScrollBarRef,
1616
int InitialTailIndex,
17+
int ItemCount,
1718
string InitialTailRequestKey,
1819
string? DisplayedTailKey) : Element
1920
{
@@ -41,6 +42,7 @@ public UIElement Mount(MountContext context, ItemsViewVerticalScrollControllerEl
4142
Positioners.Add(itemsView, positioner);
4243
positioner.Request(
4344
element.InitialTailIndex,
45+
element.ItemCount,
4446
element.InitialTailRequestKey,
4547
element.DisplayedTailKey);
4648
return itemsView;
@@ -59,11 +61,13 @@ public UIElement Update(
5961
&& Positioners.TryGetValue(itemsView, out var positioner))
6062
positioner.Request(
6163
newElement.InitialTailIndex,
64+
newElement.ItemCount,
6265
newElement.InitialTailRequestKey,
6366
newElement.DisplayedTailKey);
6467
else if (Positioners.TryGetValue(itemsView, out var existingPositioner))
6568
existingPositioner.UpdateTail(
6669
newElement.InitialTailIndex,
70+
newElement.ItemCount,
6771
newElement.DisplayedTailKey);
6872
return itemsView;
6973
}
@@ -87,13 +91,14 @@ public V1UnmountDisposition Unmount(UnmountContext context, ItemsViewVerticalScr
8791
private string? _requestKey;
8892
private string? _displayedTailKey;
8993
private int _tailIndex;
94+
private int _itemCount;
9095
private int _version;
9196
private bool _valid;
9297
private bool _awaitingLayout;
9398
private WinUIScrollView? _awaitingScrollView;
9499
private WinUIScrollView? _scrollView;
95100
private bool _following;
96-
private bool _tailRequestQueued;
101+
private readonly TailNavigationQueue _tailNavigationQueue = new();
97102
private bool _disposed;
98103

99104
public InitialTailPositioner(WinUIItemsView itemsView)
@@ -103,33 +108,42 @@ public InitialTailPositioner(WinUIItemsView itemsView)
103108
itemsView.Unloaded += OnUnloaded;
104109
}
105110

106-
public void Request(int tailIndex, string requestKey, string? displayedTailKey)
111+
public void Request(int tailIndex, int itemCount, string requestKey, string? displayedTailKey)
107112
{
108113
if (_disposed || string.Equals(_requestKey, requestKey, StringComparison.Ordinal))
109114
return;
110115

111116
_requestKey = requestKey;
112117
_version++;
113118
DetachLayout();
114-
_valid = tailIndex >= 0;
115-
if (!_valid)
116-
return;
117-
118119
_tailIndex = tailIndex;
120+
_itemCount = itemCount;
119121
_displayedTailKey = displayedTailKey;
120122
_following = true;
123+
_valid = TailNavigationPolicy.TryCapture(tailIndex, displayedTailKey, itemCount, out _);
124+
if (!_valid)
125+
return;
126+
121127
if (itemsView.IsLoaded)
122128
AwaitLayout();
123129
}
124130

125-
public void UpdateTail(int tailIndex, string? displayedTailKey)
131+
public void UpdateTail(int tailIndex, int itemCount, string? displayedTailKey)
126132
{
127133
var changed = !string.Equals(_displayedTailKey, displayedTailKey, StringComparison.Ordinal);
128134
_tailIndex = tailIndex;
135+
_itemCount = itemCount;
129136
_displayedTailKey = displayedTailKey;
130-
if (changed && _following && tailIndex >= 0 && itemsView.IsLoaded && displayedTailKey is not null)
137+
_valid = TailNavigationPolicy.TryCapture(tailIndex, displayedTailKey, itemCount, out var request);
138+
if (!_valid)
139+
{
140+
_tailNavigationQueue.Clear();
141+
return;
142+
}
143+
144+
if (changed && _following && itemsView.IsLoaded)
131145
{
132-
QueueTailRequest(_version);
146+
QueueTailRequest(_version, request);
133147
}
134148
}
135149

@@ -174,7 +188,9 @@ private void OnLayoutUpdated(object? sender, object args)
174188
}
175189

176190
var version = _version;
177-
var index = _tailIndex;
191+
if (!TailNavigationPolicy.TryCapture(_tailIndex, _displayedTailKey, _itemCount, out var request))
192+
return;
193+
178194
itemsView.DispatcherQueue.TryEnqueue(() =>
179195
{
180196
if (_disposed || !_valid || !itemsView.IsLoaded || version != _version
@@ -186,7 +202,8 @@ private void OnLayoutUpdated(object? sender, object args)
186202
}
187203

188204
AttachScrollView();
189-
StartTailRequest(index);
205+
if (!StartTailRequest(request) && !_disposed && _valid)
206+
AwaitLayout();
190207
});
191208
}
192209

@@ -210,38 +227,51 @@ private void OnViewChanged(WinUIScrollView sender, object args)
210227
_following = IsNearBottom(sender);
211228
}
212229

213-
private void QueueTailRequest(int version)
230+
private void QueueTailRequest(int version, TailNavigationRequest request)
214231
{
215-
if (_tailRequestQueued)
232+
if (!_tailNavigationQueue.Enqueue(version, request))
216233
return;
217234

218-
_tailRequestQueued = true;
219235
if (!itemsView.DispatcherQueue.TryEnqueue(() =>
220236
{
221-
_tailRequestQueued = false;
222-
if (_disposed || !_valid || !itemsView.IsLoaded || version != _version || !_following)
237+
if (!_tailNavigationQueue.TryDequeue(_version, out var queuedRequest)
238+
|| _disposed
239+
|| !_valid
240+
|| !itemsView.IsLoaded
241+
|| !_following)
223242
{
224243
return;
225244
}
226245

227-
StartTailRequest(_tailIndex);
246+
StartTailRequest(queuedRequest);
228247
}))
229248
{
230-
_tailRequestQueued = false;
249+
_tailNavigationQueue.SchedulingFailed();
231250
_following = false;
232251
}
233252
}
234253

235-
private void StartTailRequest(int index)
254+
private bool StartTailRequest(TailNavigationRequest request)
236255
{
237256
if (itemsView.ScrollView is not { IsLoaded: true })
238-
return;
257+
return false;
258+
259+
if (!TailNavigationPolicy.CanExecute(
260+
request,
261+
_tailIndex,
262+
_displayedTailKey,
263+
_itemCount))
264+
{
265+
return false;
266+
}
267+
239268
_following = true;
240-
itemsView.StartBringItemIntoView(index, new BringIntoViewOptions
269+
itemsView.StartBringItemIntoView(request.Index, new BringIntoViewOptions
241270
{
242271
AnimationDesired = false,
243272
VerticalAlignmentRatio = 1.0,
244273
});
274+
return true;
245275
}
246276

247277
private static bool IsNearBottom(WinUIScrollView scrollView) =>
@@ -250,6 +280,7 @@ private static bool IsNearBottom(WinUIScrollView scrollView) =>
250280
private void OnUnloaded(object sender, RoutedEventArgs args)
251281
{
252282
_version++;
283+
_tailNavigationQueue.Clear();
253284
DetachLayout();
254285
DetachScrollView();
255286
}
@@ -287,6 +318,7 @@ public void Dispose()
287318

288319
_disposed = true;
289320
_version++;
321+
_tailNavigationQueue.Clear();
290322
DetachLayout();
291323
DetachScrollView();
292324
itemsView.Loaded -= OnLoaded;
@@ -300,12 +332,14 @@ public static Element BindVerticalScrollController<T>(
300332
this ItemsViewElement<T> itemsView,
301333
ElementRef<WinUIAnnotatedScrollBar> scrollBarRef,
302334
int initialTailIndex,
335+
int itemCount,
303336
string initialTailRequestKey,
304337
string? displayedTailKey) =>
305338
new ItemsViewVerticalScrollControllerElement(
306339
itemsView,
307340
scrollBarRef,
308341
initialTailIndex,
342+
itemCount,
309343
initialTailRequestKey,
310344
displayedTailKey);
311345
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
namespace OpenClawTray.Chat;
2+
3+
internal readonly record struct TailNavigationRequest(int Index, string DisplayedTailKey);
4+
5+
internal static class TailNavigationPolicy
6+
{
7+
public static bool TryCapture(
8+
int tailIndex,
9+
string? displayedTailKey,
10+
int itemCount,
11+
out TailNavigationRequest request)
12+
{
13+
if (tailIndex < 0 || tailIndex >= itemCount || string.IsNullOrEmpty(displayedTailKey))
14+
{
15+
request = default;
16+
return false;
17+
}
18+
19+
request = new TailNavigationRequest(tailIndex, displayedTailKey);
20+
return true;
21+
}
22+
23+
public static bool CanExecute(
24+
TailNavigationRequest request,
25+
int currentTailIndex,
26+
string? currentDisplayedTailKey,
27+
int itemCount) =>
28+
request.Index >= 0
29+
&& request.Index < itemCount
30+
&& request.Index == currentTailIndex
31+
&& string.Equals(
32+
request.DisplayedTailKey,
33+
currentDisplayedTailKey,
34+
StringComparison.Ordinal);
35+
}
36+
37+
internal sealed class TailNavigationQueue
38+
{
39+
private (int Version, TailNavigationRequest Request)? _pending;
40+
41+
public bool Enqueue(int version, TailNavigationRequest request)
42+
{
43+
_pending = (version, request);
44+
if (IsScheduled)
45+
return false;
46+
47+
IsScheduled = true;
48+
return true;
49+
}
50+
51+
public bool TryDequeue(int currentVersion, out TailNavigationRequest request)
52+
{
53+
IsScheduled = false;
54+
var pending = _pending;
55+
_pending = null;
56+
if (pending is not { } value || value.Version != currentVersion)
57+
{
58+
request = default;
59+
return false;
60+
}
61+
62+
request = value.Request;
63+
return true;
64+
}
65+
66+
public void Clear() => _pending = null;
67+
68+
public void SchedulingFailed()
69+
{
70+
IsScheduled = false;
71+
_pending = null;
72+
}
73+
74+
public bool IsScheduled { get; private set; }
75+
}

0 commit comments

Comments
 (0)