diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index 4eea1e3d1..c2b2da852 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -465,7 +465,7 @@ public override Element Render() var scrollViewRef = UseRef(null); var isFollowingRef = UseRef(true); - var contentRef = UseRef(null); + var contentRef = UseRef(null); var prevEntryCountRef = UseRef(0); var prevSessionIdRef = UseRef(null); var prevFirstEntryIdRef = UseRef(null); @@ -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(); + else if (contentRef.Current is StackPanel stackPanel) + stackPanel.Children.Clear(); } // Hover state — set of entry ids currently under the pointer. Used to @@ -2482,12 +2485,12 @@ bool BurstIsNestable(System.Collections.Generic.List 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; diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs index 9cd01035a..00d8c44a2 100644 --- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs +++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs @@ -202,6 +202,7 @@ public sealed record ComboBoxElement(string[] Items, int SelectedIndex, Action Children) : Element; +public sealed record VirtualStackElement(Orientation Orientation, double Spacing, IReadOnlyList Children) : Element; public sealed record FlexRowElement(IReadOnlyList Children) : Element { public double ColumnGap { get; init; } @@ -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); @@ -955,6 +958,7 @@ private UIElement RenderElement(Element element, string path, List effec ImageElement e => ConfigureImage(GetOrCreate(path), e), BorderElement e => ConfigureBorder(GetOrCreate(path), e, path, effects), StackElement e => ConfigureStack(GetOrCreate(path), e, path, effects), + VirtualStackElement e => ConfigureVirtualStack(GetOrCreate(path), e, path), FlexRowElement e => ConfigureFlexRow(GetOrCreate(path), e, path, effects), GridElement e => ConfigureGrid(GetOrCreate(path), e, path, effects), ScrollViewElement e => ConfigureScrollView(GetOrCreate(path), e, path, effects), @@ -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(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() + .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(); + 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 effects) { var panel = GetOrCreate(path + ".panel"); diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs new file mode 100644 index 000000000..a306b129d --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatTimelineVirtualizationContractTests.cs @@ -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", 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())); +} diff --git a/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs b/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs new file mode 100644 index 000000000..138675047 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/ChatTimelineVirtualizationProofTests.cs @@ -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(props)); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var repeater = FindLogical(host!).Single(); + var layout = Assert.IsType(repeater.Layout); + Assert.Equal(Orientation.Vertical, layout.Orientation); + Assert.Equal(2, layout.Spacing); + Assert.Equal(InitialRows, CountItems(repeater.ItemsSource)); + + var scrollViewer = FindLogical(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(props)); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var repeater = FindLogical(host!).Single(); + Assert.Equal(appendedRows, CountItems(repeater.ItemsSource)); + + var scrollViewer = FindLogical(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(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 BuildEntries(int rows) + { + var entries = new List(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().Count() + : 0; +} diff --git a/tests/OpenClaw.Tray.UITests/TestApp.cs b/tests/OpenClaw.Tray.UITests/TestApp.cs index a07a31fdb..844996a61 100644 --- a/tests/OpenClaw.Tray.UITests/TestApp.cs +++ b/tests/OpenClaw.Tray.UITests/TestApp.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; +using Microsoft.UI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; namespace OpenClaw.Tray.UITests; @@ -20,6 +23,27 @@ namespace OpenClaw.Tray.UITests; /// internal sealed class TestApp : Application { + private static readonly (string Key, Windows.UI.Color Color)[] FluentBrushFallbacks = + [ + ("SolidBackgroundFillColorBaseBrush", Colors.White), + ("LayerFillColorDefaultBrush", Colors.White), + ("CardBackgroundFillColorDefaultBrush", Colors.White), + ("CardStrokeColorDefaultBrush", ColorHelper.FromArgb(0x33, 0x00, 0x00, 0x00)), + ("ControlFillColorTertiaryBrush", ColorHelper.FromArgb(0x0F, 0x00, 0x00, 0x00)), + ("ControlStrokeColorDefaultBrush", ColorHelper.FromArgb(0x33, 0x00, 0x00, 0x00)), + ("SubtleFillColorSecondaryBrush", ColorHelper.FromArgb(0x0F, 0x00, 0x00, 0x00)), + ("SubtleFillColorTertiaryBrush", ColorHelper.FromArgb(0x14, 0x00, 0x00, 0x00)), + ("AccentFillColorDefaultBrush", ColorHelper.FromArgb(0xFF, 0x00, 0x66, 0xCC)), + ("AccentFillColorSecondaryBrush", ColorHelper.FromArgb(0xCC, 0x00, 0x66, 0xCC)), + ("TextFillColorPrimaryBrush", Colors.Black), + ("TextFillColorSecondaryBrush", ColorHelper.FromArgb(0xE3, 0x00, 0x00, 0x00)), + ("TextFillColorTertiaryBrush", ColorHelper.FromArgb(0x99, 0x00, 0x00, 0x00)), + ("TextOnAccentFillColorPrimaryBrush", Colors.White), + ("SystemFillColorSuccessBrush", ColorHelper.FromArgb(0xFF, 0x0F, 0x7B, 0x0F)), + ("SystemFillColorCautionBrush", ColorHelper.FromArgb(0xFF, 0x9D, 0x5D, 0x00)), + ("SystemFillColorCriticalBrush", ColorHelper.FromArgb(0xFF, 0xC4, 0x2B, 0x1C)), + ]; + /// /// Merge XamlControlsResources + the production App.xaml's custom keys /// (LobsterAccentBrush, AccentButtonStyle) so renderers that look them up @@ -28,9 +52,14 @@ internal sealed class TestApp : Application /// public void MergeStandardResources() { + if (!TryGetResources(out var resources)) + { + return; + } + try { - Resources.MergedDictionaries.Add(new Microsoft.UI.Xaml.Controls.XamlControlsResources()); + resources.MergedDictionaries.Add(new Microsoft.UI.Xaml.Controls.XamlControlsResources()); } // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. catch @@ -39,22 +68,65 @@ public void MergeStandardResources() // going — the renderers degrade gracefully without theme styles. } - TryAddResource("LobsterAccentBrush", + TryAddResource(resources, "LobsterAccentBrush", ""); - TryAddResource("AccentButtonStyle", + TryAddResource(resources, "AccentButtonStyle", ""); + + EnsureFluentBrushFallbacks(resources); + } + + internal static void EnsureFluentBrushFallbacks(ResourceDictionary resources) + { + foreach (var (key, color) in FluentBrushFallbacks) + { + TryAddBrushResource(resources, key, color); + } + } + + private bool TryGetResources(out ResourceDictionary resources) + { + for (var attempt = 0; attempt < 5; attempt++) + { + try + { + resources = Resources; + return true; + } + // slopwatch-ignore: SW003 Test fixture resource setup is best-effort and must not hide the test outcome. + catch + { + Thread.Sleep(10); + } + } + + resources = null!; + return false; + } + + private static void TryAddResource(ResourceDictionary resources, string key, string xaml) + { + try + { + resources[key] = XamlReader.Load(xaml); + } + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + catch + { + // best-effort; missing key just means renderers fall back. + } } - private void TryAddResource(string key, string xaml) + private static void TryAddBrushResource(ResourceDictionary resources, string key, Windows.UI.Color color) { try { - Resources[key] = XamlReader.Load(xaml); + resources[key] = new SolidColorBrush(color); } // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. catch