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
15 changes: 9 additions & 6 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ public override Element Render()

var scrollViewRef = UseRef<Microsoft.UI.Xaml.Controls.ScrollViewer?>(null);
var isFollowingRef = UseRef(true);
var contentRef = UseRef<Microsoft.UI.Xaml.Controls.StackPanel?>(null);
var contentRef = UseRef<FrameworkElement?>(null);
var prevEntryCountRef = UseRef(0);
var prevSessionIdRef = UseRef<string?>(null);
var prevFirstEntryIdRef = UseRef<string?>(null);
Expand Down Expand Up @@ -508,7 +508,10 @@ public override Element Render()
if (prevShowToolCallsRef.Current != showToolCalls)
{
prevShowToolCallsRef.Current = showToolCalls;
contentRef.Current?.Children.Clear();
if (contentRef.Current is ItemsRepeater repeater)
repeater.ItemsSource = Array.Empty<object>();
else if (contentRef.Current is StackPanel stackPanel)
stackPanel.Children.Clear();
}

// Hover state — set of entry ids currently under the pointer. Used to
Expand Down Expand Up @@ -2482,12 +2485,12 @@ bool BurstIsNestable(System.Collections.Generic.List<ChatTimelineItem> b)
Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto],
loadMoreButton.Grid(row: 0, column: 0),
Border(Empty()).Height(20).Grid(row: 1, column: 0),
VStack(2, timelineRows).Set(sp =>
VirtualVStack(2, timelineRows).Set(host =>
{
if (contentRef.Current != sp)
if (contentRef.Current != host)
{
contentRef.Current = (Microsoft.UI.Xaml.Controls.StackPanel)sp;
sp.SizeChanged += (_, _) =>
contentRef.Current = host;
host.SizeChanged += (_, _) =>
{
if (scrollViewRef.Current is not { } sv) return;

Expand Down
54 changes: 54 additions & 0 deletions src/OpenClawTray.FunctionalUI/FunctionalUI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ public sealed record ComboBoxElement(string[] Items, int SelectedIndex, Action<i
public sealed record ImageElement(string Source) : Element;
public sealed record BorderElement(Element? Child) : Element;
public sealed record StackElement(Orientation Orientation, double Spacing, IReadOnlyList<Element?> Children) : Element;
public sealed record VirtualStackElement(Orientation Orientation, double Spacing, IReadOnlyList<Element?> Children) : Element;
public sealed record FlexRowElement(IReadOnlyList<Element?> Children) : Element
{
public double ColumnGap { get; init; }
Expand Down Expand Up @@ -597,6 +598,8 @@ public static ComboBoxElement ComboBox(string[] items, int selectedIndex = -1, A
public static StackElement VStack(double spacing, params Element?[] children) => new(Orientation.Vertical, spacing, children);
public static StackElement HStack(params Element?[] children) => new(Orientation.Horizontal, 0, children);
public static StackElement HStack(double spacing, params Element?[] children) => new(Orientation.Horizontal, spacing, children);
public static VirtualStackElement VirtualVStack(double spacing, params Element?[] children) => new(Orientation.Vertical, spacing, children);
public static VirtualStackElement VirtualHStack(double spacing, params Element?[] children) => new(Orientation.Horizontal, spacing, children);
public static GridElement Grid(string[] columns, string[] rows, params Element?[] children) => new(columns, rows, children);
public static GridElement Grid(GridSize[] columns, GridSize[] rows, params Element?[] children) =>
new(columns.Select(c => c.ToString()).ToArray(), rows.Select(r => r.ToString()).ToArray(), children);
Expand Down Expand Up @@ -955,6 +958,7 @@ private UIElement RenderElement(Element element, string path, List<Action> effec
ImageElement e => ConfigureImage(GetOrCreate<Image>(path), e),
BorderElement e => ConfigureBorder(GetOrCreate<Border>(path), e, path, effects),
StackElement e => ConfigureStack(GetOrCreate<Border>(path), e, path, effects),
VirtualStackElement e => ConfigureVirtualStack(GetOrCreate<Border>(path), e, path),
FlexRowElement e => ConfigureFlexRow(GetOrCreate<Border>(path), e, path, effects),
GridElement e => ConfigureGrid(GetOrCreate<Border>(path), e, path, effects),
ScrollViewElement e => ConfigureScrollView(GetOrCreate<ScrollViewer>(path), e, path, effects),
Expand Down Expand Up @@ -1266,6 +1270,56 @@ private Border ConfigureStack(Border wrapper, StackElement element, string path,
return wrapper;
}

private Border ConfigureVirtualStack(Border wrapper, VirtualStackElement element, string path)
{
var repeater = GetOrCreate<ItemsRepeater>(path + ".repeater");
if (repeater.Layout is not StackLayout layout)
{
layout = new StackLayout();
repeater.Layout = layout;
}
layout.Orientation = element.Orientation;
layout.Spacing = element.Spacing;

repeater.ItemsSource = element.Children
.Select((child, index) => child is null ? null : new VirtualStackItem(index, child))
.Where(item => item is not null)
.Cast<VirtualStackItem>()
.ToArray();
repeater.ItemTemplate = new VirtualStackItemTemplate(this, path);
repeater.HorizontalAlignment = HorizontalAlignment.Stretch;
repeater.VerticalAlignment = VerticalAlignment.Stretch;

SetChild(wrapper, repeater);
ApplyModifiers(wrapper, element);
ApplySetters(repeater, element);
return wrapper;
}

private sealed record VirtualStackItem(int Index, Element Element);

private sealed class VirtualStackItemTemplate(UiRenderer renderer, string path) : IElementFactory
{
public UIElement GetElement(ElementFactoryGetArgs args)
{
if (args.Data is not VirtualStackItem item)
return new Border();

var effects = new List<Action>();
var control = renderer.RenderElement(item.Element, ChildPath(path, item.Index, item.Element), effects);
foreach (var effect in effects)
effect();
return control;
}

public void RecycleElement(ElementFactoryRecycleArgs args)
{
// The renderer cache owns element identity by path. ItemsRepeater
// detaches recycled rows; re-realization asks for the same path and
// gets a refreshed control without rebuilding the whole list.
}
}

private Border ConfigureFlexRow(Border wrapper, FlexRowElement element, string path, List<Action> effects)
{
var panel = GetOrCreate<StackPanel>(path + ".panel");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace OpenClaw.Tray.Tests;

public sealed class ChatTimelineVirtualizationContractTests
{
[Fact]
public void ProductionTimeline_UsesVirtualizedItemsRepeaterRows()
{
var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatTimeline.cs");
var functionalUi = Read("src", "OpenClawTray.FunctionalUI", "FunctionalUI.cs");

Assert.Contains("VirtualVStack(2, timelineRows)", timeline);
Assert.DoesNotContain(" VStack(2, timelineRows)", timeline);
Assert.Contains("UseRef<FrameworkElement?>", timeline);
Assert.Contains("ItemsRepeater repeater", timeline);

Assert.Contains("VirtualStackElement", functionalUi);
Assert.Contains("ItemsRepeater", functionalUi);
Assert.Contains("StackLayout", functionalUi);
Assert.Contains("IElementFactory", functionalUi);
}

private static string Read(params string[] parts)
=> File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(parts).ToArray()));
}
152 changes: 152 additions & 0 deletions tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using OpenClaw.Chat;
using OpenClawTray.Chat;
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Hosting;
using Windows.Graphics;
using static OpenClawTray.FunctionalUI.Factories;
using static OpenClaw.Tray.UITests.TestSupport;

namespace OpenClaw.Tray.UITests;

[Collection(UICollection.Name)]
public sealed class ChatTimelineVirtualizationProofTests
{
private const int InitialRows = 240;

private readonly UIThreadFixture _ui;

public ChatTimelineVirtualizationProofTests(UIThreadFixture ui) => _ui = ui;

[Fact]
public async Task LargeNativeChatTimeline_VirtualizesRowsAndFollowsNewMessages()
{
await _ui.ResetContainerAsync();

var props = BuildProps(InitialRows, scrollToBottomToken: 0);
FunctionalHostControl? host = null;

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<OpenClawChatTimeline, OpenClawChatTimelineProps>(props));
});

await DrainRenderQueueAsync();

await _ui.RunOnUIAsync(() =>
{
var repeater = FindLogical<ItemsRepeater>(host!).Single();
var layout = Assert.IsType<StackLayout>(repeater.Layout);
Assert.Equal(Orientation.Vertical, layout.Orientation);
Assert.Equal(2, layout.Spacing);
Assert.Equal(InitialRows, CountItems(repeater.ItemsSource));

var scrollViewer = FindLogical<ScrollViewer>(host!).Single();
_ui.Container.UpdateLayout();
Assert.True(scrollViewer.ScrollableHeight > 0, "large chat timeline should overflow and become scrollable");

scrollViewer.ChangeView(null, scrollViewer.ScrollableHeight, null, disableAnimation: true);
_ui.Container.UpdateLayout();
Assert.True(scrollViewer.VerticalOffset > 0, "large chat timeline should scroll away from the top");
});

await DrainRenderQueueAsync();

var appendedRows = InitialRows + 1;
props = BuildProps(appendedRows, scrollToBottomToken: 1);
await _ui.RunOnUIAsync(() =>
{
host!.Mount(_ => Component<OpenClawChatTimeline, OpenClawChatTimelineProps>(props));
});

await DrainRenderQueueAsync();

await _ui.RunOnUIAsync(() =>
{
var repeater = FindLogical<ItemsRepeater>(host!).Single();
Assert.Equal(appendedRows, CountItems(repeater.ItemsSource));

var scrollViewer = FindLogical<ScrollViewer>(host!).Single();
_ui.Container.UpdateLayout();
Assert.True(
scrollViewer.ScrollableHeight - scrollViewer.VerticalOffset <= 4,
$"chat follow should stay near the newest row; offset={scrollViewer.VerticalOffset:0.0}, height={scrollViewer.ScrollableHeight:0.0}");

var visibleText = FindDescendants<TextBlock>(host!)
.Select(t => t.Text)
.Where(t => !string.IsNullOrWhiteSpace(t))
.ToArray();
Assert.Contains("User proof row 241", visibleText);

Console.WriteLine(
"CHAT_TIMELINE_VIRTUALIZATION_PROOF " +
$"rows={appendedRows} " +
$"itemsRepeater={repeater.GetType().Name} " +
$"layout={((StackLayout)repeater.Layout).Orientation} " +
$"scrollableHeight={scrollViewer.ScrollableHeight:0.0} " +
$"verticalOffset={scrollViewer.VerticalOffset:0.0} " +
"newestRowVisible=true");
});

await _ui.RunOnUIAsync(() => host!.Dispose());
}

private async Task DrainRenderQueueAsync()
{
await _ui.RunOnUIAsync(() => { });
await Task.Delay(50);
await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout());
await _ui.RunOnUIAsync(() => { });
}

private static OpenClawChatTimelineProps BuildProps(int rows, int scrollToBottomToken) =>
new(
SessionId: "ui-proof-large-chat",
Entries: BuildEntries(rows),
HasMoreHistory: false,
OnLoadMoreHistory: null,
UserSenderLabel: "UI proof user",
AssistantSenderLabel: "UI proof assistant",
DefaultModel: "proof-model",
ShowToolCalls: true,
ScrollToBottomToken: scrollToBottomToken);

private static IReadOnlyList<ChatTimelineItem> BuildEntries(int rows)
{
var entries = new List<ChatTimelineItem>(rows);
for (var i = 1; i <= rows; i++)
{
var isUser = i % 2 == 1;
entries.Add(new ChatTimelineItem(
Id: $"proof-{i:000}",
Kind: isUser ? ChatTimelineItemKind.User : ChatTimelineItemKind.Assistant,
Text: isUser
? $"User proof row {i}"
: $"Assistant proof row {i}"));
}

return entries;
}

private static int CountItems(object? itemsSource) =>
itemsSource is System.Collections.IEnumerable enumerable
? enumerable.Cast<object>().Count()
: 0;
}
Loading
Loading