Skip to content

Commit 96f0e9e

Browse files
karkarlCopilot
andcommitted
fix(chat): use stable bottom anchoring
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 07bcd90 commit 96f0e9e

3 files changed

Lines changed: 47 additions & 132 deletions

File tree

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

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,6 @@ public sealed record ReactorChatIdentity(
3838
string? Avatar = null,
3939
string? Emoji = null);
4040

41-
internal sealed record ReactorStreamingTailState(
42-
string EntryId,
43-
int TextLength,
44-
bool IsStreaming);
45-
4641
/// <summary>
4742
/// Reactor-owned production timeline. Reactor's keyed ItemsView handles row
4843
/// reconciliation, container realization, scrolling, and virtualization.
@@ -105,7 +100,7 @@ async Task ToggleSpeechAsync(ChatTimelineItem entry)
105100
var rows = BuildRows(props);
106101
var initialTailRequestKey =
107102
$"{props.Timeline.SessionId ?? "none"}|{props.Timeline.TimelineGeneration}|{props.HistoryRevision}|{props.Timeline.ScrollToBottomToken}";
108-
var streamingTailState = GetStreamingTailState(props.Timeline.Entries);
103+
var displayedTailKey = rows.Count > 0 ? rows[^1].Key : null;
109104
void SetEntryHovered(string entryId, bool isHovered)
110105
{
111106
if (isHovered)
@@ -166,7 +161,7 @@ void SetEntryHovered(string entryId, bool isHovered)
166161
annotatedScrollBarRef,
167162
rows.Count - 1,
168163
initialTailRequestKey,
169-
streamingTailState)
164+
displayedTailKey)
170165
.Grid(column: 0)
171166
.AutomationName("Chat messages")
172167
.HAlign(HorizontalAlignment.Stretch)
@@ -186,15 +181,6 @@ public static string RowKey(OpenClawChatTimelineProps props, ChatTimelineItem en
186181
public static string SyntheticRowKey(OpenClawChatTimelineProps props, string id, ChatTimelineItemKind kind) =>
187182
$"thread:{props.SessionId ?? "none"}|generation:{props.TimelineGeneration}|kind:{kind}|synthetic:{id}";
188183

189-
private static ReactorStreamingTailState? GetStreamingTailState(
190-
IReadOnlyList<ChatTimelineItem> entries)
191-
{
192-
if (entries.LastOrDefault() is not { Kind: ChatTimelineItemKind.Assistant } entry)
193-
return null;
194-
195-
return new ReactorStreamingTailState(entry.Id, entry.Text.Length, entry.IsStreaming);
196-
}
197-
198184
private static IReadOnlyList<ReactorTimelineRow> BuildRows(ReactorChatTimelineProps props)
199185
{
200186
if (props.Mode == ReactorChatTimelineMode.Loading)

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

Lines changed: 27 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using System.Runtime.CompilerServices;
77
using WinUIAnnotatedScrollBar = Microsoft.UI.Xaml.Controls.AnnotatedScrollBar;
88
using WinUIItemsView = Microsoft.UI.Xaml.Controls.ItemsView;
9-
using WinUIScrollingInteractionState = Microsoft.UI.Xaml.Controls.ScrollingInteractionState;
109
using WinUIScrollView = Microsoft.UI.Xaml.Controls.ScrollView;
1110

1211
namespace OpenClawTray.Chat;
@@ -16,7 +15,7 @@ file sealed record ItemsViewVerticalScrollControllerElement(
1615
ElementRef<WinUIAnnotatedScrollBar> ScrollBarRef,
1716
int InitialTailIndex,
1817
string InitialTailRequestKey,
19-
ReactorStreamingTailState? StreamingTailState) : Element
18+
string? DisplayedTailKey) : Element
2019
{
2120
static ItemsViewVerticalScrollControllerElement() =>
2221
ControlRegistry.RegisterDecorator<ItemsViewVerticalScrollControllerElement>(
@@ -40,7 +39,10 @@ public UIElement Mount(MountContext context, ItemsViewVerticalScrollControllerEl
4039
((WinUIItemsView)value).VerticalScrollController = scrollBar?.ScrollController);
4140
var positioner = new InitialTailPositioner(itemsView);
4241
Positioners.Add(itemsView, positioner);
43-
positioner.Request(element.InitialTailIndex, element.InitialTailRequestKey);
42+
positioner.Request(
43+
element.InitialTailIndex,
44+
element.InitialTailRequestKey,
45+
element.DisplayedTailKey);
4446
return itemsView;
4547
}
4648

@@ -55,12 +57,14 @@ public UIElement Update(
5557
throw new InvalidOperationException("ItemsView scroll controller binding requires an ItemsView child.");
5658
if (!string.Equals(oldElement.InitialTailRequestKey, newElement.InitialTailRequestKey, StringComparison.Ordinal)
5759
&& Positioners.TryGetValue(itemsView, out var positioner))
58-
positioner.Request(newElement.InitialTailIndex, newElement.InitialTailRequestKey);
60+
positioner.Request(
61+
newElement.InitialTailIndex,
62+
newElement.InitialTailRequestKey,
63+
newElement.DisplayedTailKey);
5964
else if (Positioners.TryGetValue(itemsView, out var existingPositioner))
60-
existingPositioner.UpdateTailIndex(
65+
existingPositioner.UpdateTail(
6166
newElement.InitialTailIndex,
62-
oldElement.StreamingTailState,
63-
newElement.StreamingTailState);
67+
newElement.DisplayedTailKey);
6468
return itemsView;
6569
}
6670

@@ -78,22 +82,18 @@ public V1UnmountDisposition Unmount(UnmountContext context, ItemsViewVerticalScr
7882
file sealed class InitialTailPositioner : IDisposable
7983
{
8084
private const double FollowThreshold = 60;
81-
private const int StreamingTailFollowIntervalMs = 125;
8285

8386
private readonly WinUIItemsView itemsView;
8487
private string? _requestKey;
88+
private string? _displayedTailKey;
8589
private int _tailIndex;
8690
private int _version;
8791
private bool _valid;
8892
private bool _awaitingLayout;
8993
private WinUIScrollView? _awaitingScrollView;
9094
private WinUIScrollView? _scrollView;
91-
private DispatcherTimer? _streamingTailFollowTimer;
92-
private ReactorStreamingTailState? _pendingStreamingTail;
9395
private bool _following;
9496
private bool _tailRequestQueued;
95-
private bool _streamingTailFollowPending;
96-
private bool _userInteractionObserved;
9797
private bool _disposed;
9898

9999
public InitialTailPositioner(WinUIItemsView itemsView)
@@ -103,41 +103,34 @@ public InitialTailPositioner(WinUIItemsView itemsView)
103103
itemsView.Unloaded += OnUnloaded;
104104
}
105105

106-
public void Request(int tailIndex, string requestKey)
106+
public void Request(int tailIndex, string requestKey, string? displayedTailKey)
107107
{
108108
if (_disposed || string.Equals(_requestKey, requestKey, StringComparison.Ordinal))
109109
return;
110110

111111
_requestKey = requestKey;
112112
_version++;
113113
DetachLayout();
114-
StopStreamingTailFollow();
115114
_valid = tailIndex >= 0;
116115
if (!_valid)
117116
return;
118117

119118
_tailIndex = tailIndex;
119+
_displayedTailKey = displayedTailKey;
120120
_following = true;
121121
if (itemsView.IsLoaded)
122122
AwaitLayout();
123123
}
124124

125-
public void UpdateTailIndex(
126-
int tailIndex,
127-
ReactorStreamingTailState? previousStreamingTail,
128-
ReactorStreamingTailState? currentStreamingTail)
125+
public void UpdateTail(int tailIndex, string? displayedTailKey)
129126
{
130-
var changed = _tailIndex != tailIndex;
127+
var changed = !string.Equals(_displayedTailKey, displayedTailKey, StringComparison.Ordinal);
131128
_tailIndex = tailIndex;
132-
if (changed && _following && tailIndex >= 0 && itemsView.IsLoaded)
129+
_displayedTailKey = displayedTailKey;
130+
if (changed && _following && tailIndex >= 0 && itemsView.IsLoaded && displayedTailKey is not null)
133131
{
134-
StopStreamingTailFollow();
135132
QueueTailRequest(_version);
136-
return;
137133
}
138-
139-
if (_following && IsStreamingTailUpdate(previousStreamingTail, currentStreamingTail))
140-
RequestStreamingTailFollow(currentStreamingTail!);
141134
}
142135

143136
private void OnLoaded(object sender, RoutedEventArgs args)
@@ -206,24 +199,15 @@ private void AttachScrollView()
206199
DetachScrollView();
207200
_scrollView = nextScrollView;
208201
if (_scrollView is not null)
202+
{
203+
_scrollView.VerticalAnchorRatio = 1.0;
209204
_scrollView.ViewChanged += OnViewChanged;
205+
}
210206
}
211207

212208
private void OnViewChanged(WinUIScrollView sender, object args)
213209
{
214-
if (sender.State == WinUIScrollingInteractionState.Interaction)
215-
{
216-
_following = false;
217-
_userInteractionObserved = true;
218-
StopStreamingTailFollow();
219-
return;
220-
}
221-
222-
if (_userInteractionObserved)
223-
{
224-
_userInteractionObserved = false;
225-
_following = IsNearBottom(sender);
226-
}
210+
_following = IsNearBottom(sender);
227211
}
228212

229213
private void QueueTailRequest(int version)
@@ -252,7 +236,6 @@ private void StartTailRequest(int index)
252236
{
253237
if (itemsView.ScrollView is not { IsLoaded: true })
254238
return;
255-
256239
_following = true;
257240
itemsView.StartBringItemIntoView(index, new BringIntoViewOptions
258241
{
@@ -261,67 +244,13 @@ private void StartTailRequest(int index)
261244
});
262245
}
263246

264-
private void RequestStreamingTailFollow(ReactorStreamingTailState streamingTail)
265-
{
266-
_pendingStreamingTail = streamingTail;
267-
_streamingTailFollowPending = true;
268-
_streamingTailFollowTimer ??= CreateStreamingTailFollowTimer();
269-
if (!_streamingTailFollowTimer.IsEnabled)
270-
_streamingTailFollowTimer.Start();
271-
}
272-
273-
private DispatcherTimer CreateStreamingTailFollowTimer()
274-
{
275-
var timer = new DispatcherTimer
276-
{
277-
Interval = TimeSpan.FromMilliseconds(StreamingTailFollowIntervalMs),
278-
};
279-
timer.Tick += OnStreamingTailFollowTick;
280-
return timer;
281-
}
282-
283-
private void OnStreamingTailFollowTick(object? sender, object args)
284-
{
285-
_streamingTailFollowTimer?.Stop();
286-
if (_disposed
287-
|| !_valid
288-
|| !_following
289-
|| !_streamingTailFollowPending
290-
|| !itemsView.IsLoaded
291-
|| _scrollView is not { IsLoaded: true, State: not WinUIScrollingInteractionState.Interaction })
292-
{
293-
StopStreamingTailFollow();
294-
return;
295-
}
296-
297-
_streamingTailFollowPending = false;
298-
var streamingTail = _pendingStreamingTail;
299-
_pendingStreamingTail = null;
300-
StartTailRequest(_tailIndex);
301-
if (streamingTail is { IsStreaming: true } && _following)
302-
return;
303-
304-
StopStreamingTailFollow();
305-
}
306-
307247
private static bool IsNearBottom(WinUIScrollView scrollView) =>
308248
scrollView.ScrollableHeight - scrollView.VerticalOffset <= FollowThreshold;
309249

310-
private static bool IsStreamingTailUpdate(
311-
ReactorStreamingTailState? previous,
312-
ReactorStreamingTailState? current) =>
313-
previous is not null
314-
&& current is not null
315-
&& string.Equals(previous.EntryId, current.EntryId, StringComparison.Ordinal)
316-
&& ((current.IsStreaming
317-
&& (!previous.IsStreaming || previous.TextLength != current.TextLength))
318-
|| (previous.IsStreaming && !current.IsStreaming));
319-
320250
private void OnUnloaded(object sender, RoutedEventArgs args)
321251
{
322252
_version++;
323253
DetachLayout();
324-
StopStreamingTailFollow();
325254
DetachScrollView();
326255
}
327256

@@ -343,18 +272,14 @@ private void DetachLayout()
343272
private void DetachScrollView()
344273
{
345274
if (_scrollView is not null)
275+
{
276+
_scrollView.VerticalAnchorRatio = double.NaN;
346277
_scrollView.ViewChanged -= OnViewChanged;
278+
}
347279

348280
_scrollView = null;
349281
}
350282

351-
private void StopStreamingTailFollow()
352-
{
353-
_streamingTailFollowTimer?.Stop();
354-
_pendingStreamingTail = null;
355-
_streamingTailFollowPending = false;
356-
}
357-
358283
public void Dispose()
359284
{
360285
if (_disposed)
@@ -363,12 +288,6 @@ public void Dispose()
363288
_disposed = true;
364289
_version++;
365290
DetachLayout();
366-
StopStreamingTailFollow();
367-
if (_streamingTailFollowTimer is not null)
368-
{
369-
_streamingTailFollowTimer.Tick -= OnStreamingTailFollowTick;
370-
_streamingTailFollowTimer = null;
371-
}
372291
DetachScrollView();
373292
itemsView.Loaded -= OnLoaded;
374293
itemsView.Unloaded -= OnUnloaded;
@@ -382,11 +301,11 @@ public static Element BindVerticalScrollController<T>(
382301
ElementRef<WinUIAnnotatedScrollBar> scrollBarRef,
383302
int initialTailIndex,
384303
string initialTailRequestKey,
385-
ReactorStreamingTailState? streamingTailState) =>
304+
string? displayedTailKey) =>
386305
new ItemsViewVerticalScrollControllerElement(
387306
itemsView,
388307
scrollBarRef,
389308
initialTailIndex,
390309
initialTailRequestKey,
391-
streamingTailState);
310+
displayedTailKey);
392311
}

tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public void ReactorTimeline_UsesNonSelectableItemsViewContainersAndAnnotatedScro
3030
Assert.Contains("annotatedScrollBarRef,", timeline);
3131
Assert.Contains("rows.Count - 1", timeline);
3232
Assert.Contains("initialTailRequestKey", timeline);
33+
Assert.Contains("var displayedTailKey = rows.Count > 0 ? rows[^1].Key : null", timeline);
3334
Assert.DoesNotContain("ItemsRepeater(", timeline);
3435
Assert.DoesNotContain("ScrollView(", timeline);
3536
}
@@ -50,7 +51,7 @@ public void ReactorTimeline_UsesReactiveAnnotatedScrollBarControllerBinding()
5051
}
5152

5253
[Fact]
53-
public void ReactorTimeline_ThrottlesStreamingAssistantTailWithoutPersistentAnchoring()
54+
public void ReactorTimeline_UsesStableBottomAnchoringAndDiscreteTailRequests()
5455
{
5556
var binding = File.ReadAllText(Path.Combine(
5657
TestRepositoryPaths.GetRepositoryRoot(),
@@ -64,23 +65,32 @@ public void ReactorTimeline_ThrottlesStreamingAssistantTailWithoutPersistentAnch
6465
Assert.Contains("itemsView.DispatcherQueue.TryEnqueue", binding);
6566
Assert.Contains("itemsView.StartBringItemIntoView(", binding);
6667
Assert.Contains("VerticalAlignmentRatio = 1.0", binding);
67-
Assert.Contains("var changed = _tailIndex != tailIndex", binding);
68-
Assert.Contains("IsStreamingTailUpdate(previousStreamingTail, currentStreamingTail)", binding);
69-
Assert.Contains("RequestStreamingTailFollow(currentStreamingTail!)", binding);
70-
Assert.Contains("StreamingTailFollowIntervalMs = 125", binding);
71-
Assert.Contains("OnStreamingTailFollowTick", binding);
72-
Assert.Contains("StopStreamingTailFollow()", binding);
68+
Assert.Contains("!string.Equals(_displayedTailKey, displayedTailKey, StringComparison.Ordinal)", binding);
69+
Assert.Contains("_following = IsNearBottom(sender)", binding);
70+
Assert.Contains("displayedTailKey is not null", binding);
71+
Assert.Contains("_scrollView.VerticalAnchorRatio = 1.0", binding);
72+
Assert.Contains("_scrollView.VerticalAnchorRatio = double.NaN", binding);
7373
Assert.Contains("if (_tailRequestQueued)", binding);
7474
Assert.Contains("_valid = tailIndex >= 0", binding);
7575
Assert.Contains("itemsView.Unloaded += OnUnloaded", binding);
7676
Assert.Contains("itemsView.Loaded -= OnLoaded", binding);
7777
Assert.Contains("itemsView.LayoutUpdated -= OnLayoutUpdated", binding);
7878
Assert.DoesNotContain("ChangeView", binding);
7979
Assert.DoesNotContain("UpdateLayout", binding);
80-
Assert.DoesNotContain("VerticalAnchorRatio =", binding);
8180
Assert.DoesNotContain("TailSettle", binding);
8281
Assert.DoesNotContain("ScrollTo(", binding);
8382
Assert.DoesNotContain("ScrollCompleted", binding);
83+
Assert.DoesNotContain("DispatcherTimer", binding);
84+
Assert.DoesNotContain("TextLength != current.TextLength", binding);
85+
Assert.DoesNotContain("ReactorStreamingTailState", binding);
86+
Assert.DoesNotContain("QueueBottomAnchoringUpdate", binding);
87+
Assert.DoesNotContain("ApplyBottomAnchoring", binding);
88+
89+
var viewChangedStart = binding.IndexOf("private void OnViewChanged", StringComparison.Ordinal);
90+
var tailRequestStart = binding.IndexOf("private void QueueTailRequest", viewChangedStart, StringComparison.Ordinal);
91+
var viewChanged = binding[viewChangedStart..tailRequestStart];
92+
Assert.DoesNotContain("VerticalAnchorRatio", viewChanged);
93+
Assert.DoesNotContain("StartBringItemIntoView", viewChanged);
8494
}
8595

8696
[Fact]

0 commit comments

Comments
 (0)