From 2e36a178dc2712b53aed80a33677a2a25a7735fc Mon Sep 17 00:00:00 2001 From: arturict Date: Sun, 12 Jul 2026 11:22:20 +0200 Subject: [PATCH 1/3] fix(chat): keep session picker stable during renders --- src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs | 3 + .../Chat/OpenClawComposer.cs | 119 ++++++++++-------- .../Chat/SessionPickerSnapshot.cs | 37 ++++++ .../OpenClaw.Tray.Tests.csproj | 2 + .../SessionPickerSnapshotTests.cs | 47 +++++++ 5 files changed, 159 insertions(+), 49 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs create mode 100644 src/OpenClaw.Tray.WinUI/Chat/SessionPickerSnapshot.cs create mode 100644 tests/OpenClaw.Tray.Tests/SessionPickerSnapshotTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs b/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs new file mode 100644 index 000000000..004156514 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs @@ -0,0 +1,3 @@ +namespace OpenClawTray.Chat; + +public record ChannelGroup(string AgentLabel, (string Id, string Title)[] Sessions); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 75d64696a..98b8e4100 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -38,8 +38,6 @@ namespace OpenClawTray.Chat; /// banner that InputBar used to render are preserved here above the /// composer. /// -public record ChannelGroup(string AgentLabel, (string Id, string Title)[] Sessions); - public record OpenClawComposerProps( string ConnectionState, bool TurnActive, @@ -249,64 +247,87 @@ public override Element Render() // Build grouped session ComboBox directly (bypassing the FunctionalUI // ComboBox helper which only supports flat string[] items). var groups = Props.AvailableChannels; + var channelComboRef = UseRef(null); + var channelGroupsRef = UseRef(null); + var channelChangedRef = UseRef(Props.OnChannelChanged); + var channelComboUpdatingRef = UseRef(false); + channelChangedRef.Current = Props.OnChannelChanged; var channelCombo = Border() .Set(border => { - var cb = new ComboBox + var cb = channelComboRef.Current; + if (cb is null) { - MinWidth = 0, - Width = double.NaN, - Height = 28, - FontSize = 11, - Padding = new Thickness(8, 0, 4, 0), - CornerRadius = composerCornerRadius, - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Center, - }; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName( - cb, - LocalizationHelper.GetString("Chat_Composer_Accessibility_Session")); + cb = new ComboBox + { + MinWidth = 0, + Width = double.NaN, + Height = 28, + FontSize = 11, + Padding = new Thickness(8, 0, 4, 0), + CornerRadius = composerCornerRadius, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName( + cb, + LocalizationHelper.GetString("Chat_Composer_Accessibility_Session")); + cb.SelectionChanged += (_, _) => + { + if (!channelComboUpdatingRef.Current && + cb.SelectedItem is ComboBoxItem { Tag: string id }) + channelChangedRef.Current(id); + }; + channelComboRef.Current = cb; + } - ComboBoxItem? selectedItem = null; - foreach (var group in groups) + var groupsChanged = channelGroupsRef.Current?.Matches(groups) != true; + channelComboUpdatingRef.Current = true; + try { - if (groups.Length > 1) - { - cb.Items.Add(new ComboBoxItem - { - Content = group.AgentLabel, - IsEnabled = false, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 10, - Padding = new Thickness(4, 2, 4, 2), - IsHitTestVisible = false, - }); - } - foreach (var session in group.Sessions) + if (groupsChanged) { - var item = new ComboBoxItem + cb.Items.Clear(); + foreach (var group in groups) { - Content = session.Title, - Tag = session.Id, - Padding = groups.Length > 1 - ? new Thickness(16, 4, 4, 4) - : new Thickness(8, 4, 4, 4), - }; - cb.Items.Add(item); - if (session.Id == (Props.ChannelId ?? "")) - selectedItem = item; + if (groups.Length > 1) + { + cb.Items.Add(new ComboBoxItem + { + Content = group.AgentLabel, + IsEnabled = false, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 10, + Padding = new Thickness(4, 2, 4, 2), + IsHitTestVisible = false, + }); + } + foreach (var session in group.Sessions) + { + cb.Items.Add(new ComboBoxItem + { + Content = session.Title, + Tag = session.Id, + Padding = groups.Length > 1 + ? new Thickness(16, 4, 4, 4) + : new Thickness(8, 4, 4, 4), + }); + } + } + channelGroupsRef.Current = SessionPickerSnapshot.Capture(groups); } - } - if (selectedItem != null) - cb.SelectedItem = selectedItem; - - var onChanged = Props.OnChannelChanged; - cb.SelectionChanged += (_, _) => + var selectedId = Props.ChannelId ?? ""; + var selectedItem = cb.Items + .OfType() + .FirstOrDefault(item => item.Tag is string id && id == selectedId); + if (!ReferenceEquals(cb.SelectedItem, selectedItem)) + cb.SelectedItem = selectedItem; + } + finally { - if (cb.SelectedItem is ComboBoxItem { Tag: string id }) - onChanged(id); - }; + channelComboUpdatingRef.Current = false; + } border.Child = cb; }); diff --git a/src/OpenClaw.Tray.WinUI/Chat/SessionPickerSnapshot.cs b/src/OpenClaw.Tray.WinUI/Chat/SessionPickerSnapshot.cs new file mode 100644 index 000000000..f280c7cf0 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/SessionPickerSnapshot.cs @@ -0,0 +1,37 @@ +namespace OpenClawTray.Chat; + +internal sealed class SessionPickerSnapshot +{ + private readonly ChannelGroup[] _groups; + + private SessionPickerSnapshot(ChannelGroup[] groups) + { + _groups = groups; + } + + public static SessionPickerSnapshot Capture(ChannelGroup[] groups) => + new(groups.Select(group => + new ChannelGroup(group.AgentLabel, group.Sessions.ToArray())).ToArray()); + + public bool Matches(ChannelGroup[] groups) + { + if (_groups.Length != groups.Length) + return false; + + for (var groupIndex = 0; groupIndex < groups.Length; groupIndex++) + { + var previous = _groups[groupIndex]; + var current = groups[groupIndex]; + if (previous.AgentLabel != current.AgentLabel || previous.Sessions.Length != current.Sessions.Length) + return false; + + for (var sessionIndex = 0; sessionIndex < current.Sessions.Length; sessionIndex++) + { + if (previous.Sessions[sessionIndex] != current.Sessions[sessionIndex]) + return false; + } + } + + return true; + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 0f30ba629..7d5d10c0c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -35,6 +35,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/SessionPickerSnapshotTests.cs b/tests/OpenClaw.Tray.Tests/SessionPickerSnapshotTests.cs new file mode 100644 index 000000000..e664e7f29 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SessionPickerSnapshotTests.cs @@ -0,0 +1,47 @@ +using OpenClawTray.Chat; + +namespace OpenClaw.Tray.Tests; + +public sealed class SessionPickerSnapshotTests +{ + [Fact] + public void Composer_ReusesTheNativePickerAcrossRenders() + { + var composer = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Chat", + "OpenClawComposer.cs")); + + Assert.Contains("var channelComboRef = UseRef(null);", composer); + Assert.Contains("channelGroupsRef.Current?.Matches(groups) != true", composer); + Assert.Contains("channelComboRef.Current = cb;", composer); + } + + [Fact] + public void Matches_EquivalentGroupsFromANewRender() + { + var snapshot = SessionPickerSnapshot.Capture(Groups()); + + Assert.True(snapshot.Matches(Groups())); + } + + [Fact] + public void Matches_DetectsContentAndOrderChanges() + { + var snapshot = SessionPickerSnapshot.Capture(Groups()); + + Assert.False(snapshot.Matches([ + new ChannelGroup("Main", [("two", "Second"), ("one", "First")]), + ])); + Assert.False(snapshot.Matches([ + new ChannelGroup("Main", [("one", "Renamed"), ("two", "Second")]), + ])); + } + + private static ChannelGroup[] Groups() => + [ + new ChannelGroup("Main", [("one", "First"), ("two", "Second")]), + ]; +} From e0da0de62b08795eede70f5b1ff47255273fc9ba Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Mon, 13 Jul 2026 13:24:23 -0700 Subject: [PATCH 2/3] fix(chat): stabilize native session picker host Keep the session picker ComboBox attached as a declarative native child across FunctionalUI renders, and harden native control lifecycle handling for mount, ownership, and event routing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Chat/OpenClawComposer.cs | 60 +++-- src/OpenClawTray.FunctionalUI/FunctionalUI.cs | 206 ++++++++++++++- .../FunctionalUiNativeElementTests.cs | 249 ++++++++++++++++++ 3 files changed, 478 insertions(+), 37 deletions(-) create mode 100644 tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 98b8e4100..2983802fa 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -252,34 +252,40 @@ public override Element Render() var channelChangedRef = UseRef(Props.OnChannelChanged); var channelComboUpdatingRef = UseRef(false); channelChangedRef.Current = Props.OnChannelChanged; - var channelCombo = Border() - .Set(border => + var channelCombo = Border(Native(() => + { + var cb = channelComboRef.Current; + if (cb is null) { - var cb = channelComboRef.Current; - if (cb is null) + cb = new ComboBox { - cb = new ComboBox - { - MinWidth = 0, - Width = double.NaN, - Height = 28, - FontSize = 11, - Padding = new Thickness(8, 0, 4, 0), - CornerRadius = composerCornerRadius, - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Center, - }; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName( - cb, - LocalizationHelper.GetString("Chat_Composer_Accessibility_Session")); - cb.SelectionChanged += (_, _) => - { - if (!channelComboUpdatingRef.Current && - cb.SelectedItem is ComboBoxItem { Tag: string id }) - channelChangedRef.Current(id); - }; - channelComboRef.Current = cb; - } + MinWidth = 0, + Width = double.NaN, + Height = 28, + FontSize = 11, + Padding = new Thickness(8, 0, 4, 0), + CornerRadius = composerCornerRadius, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName( + cb, + LocalizationHelper.GetString("Chat_Composer_Accessibility_Session")); + cb.SelectionChanged += (_, _) => + { + if (!channelComboUpdatingRef.Current && + cb.SelectedItem is ComboBoxItem { Tag: string id }) + channelChangedRef.Current(id); + }; + channelComboRef.Current = cb; + } + + return cb; + })) + .Set(_ => + { + var cb = channelComboRef.Current; + if (cb is null) return; var groupsChanged = channelGroupsRef.Current?.Matches(groups) != true; channelComboUpdatingRef.Current = true; @@ -328,8 +334,6 @@ public override Element Render() { channelComboUpdatingRef.Current = false; } - - border.Child = cb; }); // ── Model picker (provider-rich) ───────────────────────────────── diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs index 5b83efad7..68fc30d30 100644 --- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs +++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs @@ -200,6 +200,11 @@ public sealed record SliderElement(double Value, double Minimum, double Maximum, public sealed record ColorPickerElement(Color Value, Action? OnChanged) : Element; public sealed record ComboBoxElement(string[] Items, int SelectedIndex, Action? OnSelectionChanged) : Element; public sealed record ImageElement(string Source) : Element; +/// +/// Wraps a caller-owned native control. The factory must return a stable instance +/// across renders when preserving focus, popup, or selection state matters. +/// +public sealed record NativeElement(Func GetControl) : Element; public sealed record BorderElement(Element? Child) : Element; public sealed record StackElement(Orientation Orientation, double Spacing, IReadOnlyList Children) : Element; public sealed record VirtualStackElement(Orientation Orientation, double Spacing, IReadOnlyList Children) : Element; @@ -592,6 +597,7 @@ public static ColorPickerElement ColorPicker(Color value, Action? onChang public static ComboBoxElement ComboBox(string[] items, int selectedIndex = -1, Action? onSelectionChanged = null) => new(items, selectedIndex, onSelectionChanged); public static ImageElement Image(string source) => new(source); + public static NativeElement Native(Func getControl) => new(getControl); public static BorderElement Border(Element? child = null) => new(child); public static FlexRowElement FlexRow(params Element?[] children) => new(children); public static StackElement VStack(params Element?[] children) => new(Orientation.Vertical, 0, children); @@ -918,6 +924,9 @@ internal sealed class UiRenderer(Action requestRender) private readonly Dictionary _contentFlyouts = new(); private readonly HashSet _mountedPaths = new(); private readonly HashSet _visitedControlPaths = new(); + private readonly HashSet _visitedNativePaths = new(); + private readonly Dictionary _nativeControls = new(); + private readonly Dictionary _nativeEventElements = new(); private readonly HashSet _visitedComponentKeys = new(); private readonly HashSet _visitedContentFlyoutPaths = new(); private readonly HashSet _visitedVirtualStackPaths = new(); @@ -929,6 +938,7 @@ internal sealed class UiRenderer(Action requestRender) public UIElement Render(Element element, string path, List effects) { _visitedControlPaths.Clear(); + _visitedNativePaths.Clear(); _visitedComponentKeys.Clear(); _visitedContentFlyoutPaths.Clear(); _visitedVirtualStackPaths.Clear(); @@ -945,11 +955,16 @@ public void Dispose() foreach (var control in _controls.Values) DetachChildren(control); + foreach (var control in _nativeControls.Values.OfType()) + DetachNativeEventHandlers(control); _components.Clear(); _controls.Clear(); + _nativeControls.Clear(); + _nativeEventElements.Clear(); _contentFlyouts.Clear(); _mountedPaths.Clear(); + _visitedNativePaths.Clear(); _visitedVirtualStackPaths.Clear(); _virtualStackOwnedPathPrefixes.Clear(); } @@ -976,6 +991,7 @@ private UIElement RenderElementCore(Element element, string path, List e ColorPickerElement e => ConfigureColorPicker(GetOrCreate(path), e), ComboBoxElement e => ConfigureComboBox(GetOrCreate(path), e), ImageElement e => ConfigureImage(GetOrCreate(path), e), + NativeElement e => ConfigureNativeElement(e, path), BorderElement e => ConfigureBorder(GetOrCreate(path), e, path, effects), StackElement e => ConfigureStack(GetOrCreate(path), e, path, effects), VirtualStackElement e => ConfigureVirtualStack(GetOrCreate(path), e, path, effects), @@ -992,6 +1008,25 @@ private UIElement RenderElementCore(Element element, string path, List e return control; } + private UIElement ConfigureNativeElement(NativeElement element, string path) + { + _visitedNativePaths.Add(path); + RemoveRendererControlPath(path); + + var control = element.GetControl(); + if (_nativeControls.TryGetValue(path, out var previous) && !ReferenceEquals(previous, control)) + RemoveNativeControlPath(path); + _nativeControls[path] = control; + + if (control is FrameworkElement frameworkElement) + { + ApplyNativeModifiers(frameworkElement, element); + ApplySetters(frameworkElement, element); + } + + return control; + } + private static string ResolveElementPath(string path, Element element) => string.IsNullOrEmpty(element.Key) ? path : path + "#" + element.Key; @@ -1021,22 +1056,42 @@ private UIElement RenderNavigationHost(INavigationHostElement element, string pa private T GetOrCreate(string path) where T : UIElement, new() { _visitedControlPaths.Add(path); + RemoveNativeControlPath(path); if (_controls.TryGetValue(path, out var existing) && existing is T typed) return typed; if (existing is not null) - { - _mountedPaths.Remove(path); - DetachChildren(existing); - RemoveFromParent(existing); - } + RemoveRendererControlPath(path); var control = new T(); _controls[path] = control; return control; } + private void RemoveRendererControlPath(string path) + { + if (!_controls.TryGetValue(path, out var control)) + return; + + _mountedPaths.Remove(path); + DetachChildren(control); + RemoveFromParent(control); + _controls.Remove(path); + } + + private void RemoveNativeControlPath(string path) + { + if (!_nativeControls.TryGetValue(path, out var control)) + return; + + _mountedPaths.Remove(path); + if (control is FrameworkElement frameworkElement) + DetachNativeEventHandlers(frameworkElement); + RemoveFromParent(control); + _nativeControls.Remove(path); + } + private void QueueMount(UIElement control, Element element, string path, List effects) { if (element.Modifiers.OnMount is null || control is not FrameworkElement fe || !_mountedPaths.Add(path)) @@ -1649,15 +1704,20 @@ private void PruneUnvisitedPaths() _contentFlyouts.Remove(path); } + foreach (var (path, _) in _nativeControls.ToArray()) + { + if (_visitedNativePaths.Contains(path) || IsOwnedByVirtualStack(path)) + continue; + + RemoveNativeControlPath(path); + } + foreach (var (path, control) in _controls.ToArray()) { if (_visitedControlPaths.Contains(path) || IsOwnedByVirtualStack(path)) continue; - _mountedPaths.Remove(path); - DetachChildren(control); - RemoveFromParent(control); - _controls.Remove(path); + RemoveRendererControlPath(path); } } @@ -1997,6 +2057,134 @@ private static void ApplyModifiers(FrameworkElement control, Element element) } } + private void ApplyNativeModifiers(FrameworkElement control, NativeElement element) + { + // Native controls are caller-owned, so only apply explicit values and + // never clear unset properties that may have been configured directly. + var m = element.Modifiers; + + if (m.Margin is { } margin) control.Margin = margin; + if (m.Width is { } width) control.Width = width; + if (m.Height is { } height) control.Height = height; + if (m.MinWidth is { } minWidth) control.MinWidth = minWidth; + if (m.MaxWidth is { } maxWidth) control.MaxWidth = maxWidth; + if (m.MinHeight is { } minHeight) control.MinHeight = minHeight; + if (m.MaxHeight is { } maxHeight) control.MaxHeight = maxHeight; + if (m.HorizontalAlignment is { } hAlign) control.HorizontalAlignment = hAlign; + if (m.VerticalAlignment is { } vAlign) control.VerticalAlignment = vAlign; + if (m.Opacity is { } opacity) control.Opacity = opacity; + if (m.AutomationName is { } automationName) AutomationProperties.SetName(control, automationName); + if (m.LiveRegion is { } liveRegion) AutomationProperties.SetLiveSetting(control, liveRegion); + ApplyResourceOverrides(control, m.ResourceOverrides); + + if (control is Control disabledControl && m.Disabled is { } disabled) + disabledControl.IsEnabled = !disabled; + if (control is TextBox textBox && m.ReadOnly is { } readOnly) + textBox.IsReadOnly = readOnly; + if (control is ScrollViewer scrollViewer && m.HorizontalScrollMode is { } horizontalScrollMode) + scrollViewer.HorizontalScrollMode = horizontalScrollMode; + + ApplyNativeEventModifiers(control, element); + + switch (control) + { + case TextBlock tb: + if (m.FontSize is { } textSize) tb.FontSize = textSize; + if (m.FontWeight is { } textWeight) tb.FontWeight = textWeight; + if (m.FontFamily is { } textFamily) tb.FontFamily = textFamily; + if (m.TextWrapping is { } wrapping) tb.TextWrapping = wrapping; + if (m.Padding is { } textPadding) tb.Padding = textPadding; + if (m.ForegroundResourceKey is { } textFgResource) tb.Foreground = ThemeResources.ResolveBrush(textFgResource); + else if (m.Foreground is { } textFg) tb.Foreground = textFg; + break; + case Control c: + if (m.Padding is { } controlPadding) c.Padding = controlPadding; + if (m.FontSize is { } controlSize) c.FontSize = controlSize; + if (m.FontWeight is { } controlWeight) c.FontWeight = controlWeight; + if (m.FontFamily is { } controlFamily) c.FontFamily = controlFamily; + if (m.ForegroundResourceKey is { } controlFgResource) c.Foreground = ThemeResources.ResolveBrush(controlFgResource); + else if (m.Foreground is { } controlFg) c.Foreground = controlFg; + if (m.BorderBrushResourceKey is { } controlBorderResource) c.BorderBrush = ThemeResources.ResolveBrush(controlBorderResource); + else if (m.BorderBrush is { } controlBorder) c.BorderBrush = controlBorder; + if (m.BorderThickness is { } controlThickness) c.BorderThickness = controlThickness; + break; + case Border b: + if (m.Padding is { } borderPadding) b.Padding = borderPadding; + if (m.BackgroundResourceKey is { } backgroundResourceKey) + b.Background = ThemeResources.ResolveBrush(backgroundResourceKey); + else if (m.Background is { } bg) + b.Background = bg; + if (m.BorderBrushResourceKey is { } borderResourceKey) + b.BorderBrush = ThemeResources.ResolveBrush(borderResourceKey); + else if (m.BorderBrush is { } borderBrush) + b.BorderBrush = borderBrush; + if (m.BorderThickness is { } borderThickness) + b.BorderThickness = borderThickness; + if (m.CornerRadius is { } radius) + b.CornerRadius = radius; + break; + } + } + + private void ApplyNativeEventModifiers(FrameworkElement control, NativeElement element) + { + var m = element.Modifiers; + var hasEvents = m.GotFocus is not null + || m.KeyDown is not null + || m.PointerEntered is not null + || m.PointerExited is not null; + + if (!hasEvents && !_nativeEventElements.ContainsKey(control)) + return; + + DetachNativeEventHandlers(control); + if (!hasEvents) + return; + + _nativeEventElements[control] = element; + if (m.GotFocus is not null) control.GotFocus += NativeElementGotFocus; + if (m.KeyDown is not null) control.KeyDown += NativeElementKeyDown; + if (m.PointerEntered is not null) control.PointerEntered += NativeElementPointerEntered; + if (m.PointerExited is not null) control.PointerExited += NativeElementPointerExited; + } + + private void DetachNativeEventHandlers(FrameworkElement control) + { + control.GotFocus -= NativeElementGotFocus; + control.KeyDown -= NativeElementKeyDown; + control.PointerEntered -= NativeElementPointerEntered; + control.PointerExited -= NativeElementPointerExited; + _nativeEventElements.Remove(control); + } + + private void NativeElementGotFocus(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement frameworkElement && + _nativeEventElements.TryGetValue(frameworkElement, out var element)) + element.Modifiers.GotFocus?.Invoke(sender, e); + } + + private void NativeElementKeyDown(object sender, KeyRoutedEventArgs e) + { + if (sender is FrameworkElement frameworkElement && + _nativeEventElements.TryGetValue(frameworkElement, out var element)) + element.Modifiers.KeyDown?.Invoke(sender, e); + } + + private void NativeElementPointerEntered(object sender, PointerRoutedEventArgs e) + { + if (sender is FrameworkElement frameworkElement && + _nativeEventElements.TryGetValue(frameworkElement, out var element)) + element.Modifiers.PointerEntered?.Invoke(sender, e); + } + + private void NativeElementPointerExited(object sender, PointerRoutedEventArgs e) + { + if (sender is FrameworkElement frameworkElement && + _nativeEventElements.TryGetValue(frameworkElement, out var element)) + element.Modifiers.PointerExited?.Invoke(sender, e); + } + private static void ApplyResourceOverrides(FrameworkElement control, ResourceOverrides? overrides) { if (overrides is null) diff --git a/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs b/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs new file mode 100644 index 000000000..05e055ca2 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs @@ -0,0 +1,249 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Hosting; +using static OpenClawTray.FunctionalUI.Factories; + +namespace OpenClaw.Tray.UITests; + +[Collection(UICollection.Name)] +public sealed class FunctionalUiNativeElementTests +{ + private readonly UIThreadFixture _ui; + + public FunctionalUiNativeElementTests(UIThreadFixture ui) => _ui = ui; + + [Fact] + public async Task BorderNativeChild_DoesNotUnloadWrappedControlAcrossRenders() + { + await _ui.ResetContainerAsync(); + + FunctionalHostControl? host = null; + ComboBox? combo = null; + var unloadedCount = 0; + + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + combo = new ComboBox + { + Width = 160, + Height = 32, + }; + combo.Items.Add(new ComboBoxItem { Content = "One" }); + combo.Items.Add(new ComboBoxItem { Content = "Two" }); + combo.Unloaded += (_, _) => unloadedCount++; + + host = new FunctionalHostControl + { + SuppressAutoDispose = true, + }; + _ui.Container.Children.Add(host); + host.Mount(_ => Border(Native(() => combo!))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var border = Assert.IsType(host!.Content); + Assert.Same(combo, border.Child); + Assert.Equal(0, unloadedCount); + }); + + await _ui.RunOnUIAsync(() => + { + host!.Mount(_ => Border(Native(() => combo!))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var border = Assert.IsType(host!.Content); + Assert.Same(combo, border.Child); + Assert.Equal(0, unloadedCount); + Assert.Equal(160, combo!.Width); + Assert.Equal(32, combo.Height); + host.Dispose(); + }); + } + + [Fact] + public async Task NativeElement_AppliesExplicitModifiersWithoutClearingPreconfiguredState() + { + await _ui.ResetContainerAsync(); + + FunctionalHostControl? host = null; + ComboBox? combo = null; + + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + combo = new ComboBox + { + Width = 160, + Height = 32, + Padding = new Thickness(8, 0, 4, 0), + Tag = "caller-tag", + }; + + host = new FunctionalHostControl + { + SuppressAutoDispose = true, + }; + _ui.Container.Children.Add(host); + host.Mount(_ => Border(Native(() => combo!) + .AutomationName("Session picker") + .Disabled() + .OnGotFocus((_, _) => { }))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Equal("Session picker", AutomationProperties.GetName(combo)); + Assert.False(combo!.IsEnabled); + Assert.Equal(160, combo.Width); + Assert.Equal(32, combo.Height); + Assert.Equal(new Thickness(8, 0, 4, 0), combo.Padding); + Assert.Equal("caller-tag", combo.Tag); + }); + + await _ui.RunOnUIAsync(() => + { + host!.Mount(_ => Border(Native(() => combo!) + .Disabled(false))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.True(combo!.IsEnabled); + Assert.Equal("Session picker", AutomationProperties.GetName(combo)); + Assert.Equal(160, combo.Width); + Assert.Equal(32, combo.Height); + Assert.Equal(new Thickness(8, 0, 4, 0), combo.Padding); + Assert.Equal("caller-tag", combo.Tag); + host!.Dispose(); + }); + } + + [Fact] + public async Task NativeElement_OnMountRunsAcrossSamePathOwnershipChanges() + { + await _ui.ResetContainerAsync(); + + FunctionalHostControl? host = null; + TextBlock? nativeText = null; + var showNative = true; + var nativeMountCount = 0; + var placeholderMountCount = 0; + + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + nativeText = new TextBlock { Text = "native" }; + host = new FunctionalHostControl + { + SuppressAutoDispose = true, + }; + _ui.Container.Children.Add(host); + host.Mount(_ => showNative + ? Border(Native(() => nativeText!).OnMount(_ => nativeMountCount++)) + : Border(TextBlock("placeholder").OnMount(_ => placeholderMountCount++))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Equal(1, nativeMountCount); + Assert.Equal(0, placeholderMountCount); + showNative = false; + host!.Mount(_ => showNative + ? Border(Native(() => nativeText!).OnMount(_ => nativeMountCount++)) + : Border(TextBlock("placeholder").OnMount(_ => placeholderMountCount++))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Equal(1, nativeMountCount); + Assert.Equal(1, placeholderMountCount); + }); + + await _ui.RunOnUIAsync(() => + { + showNative = true; + host!.Mount(_ => showNative + ? Border(Native(() => nativeText!).OnMount(_ => nativeMountCount++)) + : Border(TextBlock("placeholder").OnMount(_ => placeholderMountCount++))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Equal(2, nativeMountCount); + Assert.Equal(1, placeholderMountCount); + var border = Assert.IsType(host!.Content); + Assert.Same(nativeText, border.Child); + host.Dispose(); + }); + } + + [Fact] + public async Task NativeElement_OnMountRunsWhenStablePathGetsNewNativeInstance() + { + await _ui.ResetContainerAsync(); + + FunctionalHostControl? host = null; + var firstNative = new TextBlock { Text = "first" }; + var secondNative = new TextBlock { Text = "second" }; + var currentNative = firstNative; + var mountCount = 0; + + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + host = new FunctionalHostControl + { + SuppressAutoDispose = true, + }; + _ui.Container.Children.Add(host); + host.Mount(_ => Border(Native(() => currentNative).OnMount(_ => mountCount++))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Equal(1, mountCount); + currentNative = secondNative; + host!.Mount(_ => Border(Native(() => currentNative).OnMount(_ => mountCount++))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Equal(2, mountCount); + var border = Assert.IsType(host!.Content); + Assert.Same(secondNative, border.Child); + host.Dispose(); + }); + } + + private async Task DrainRenderQueueAsync() + { + await _ui.RunOnUIAsync(() => { }); + await Task.Delay(50); + await _ui.RunOnUIAsync(() => _ui.Container.UpdateLayout()); + await _ui.RunOnUIAsync(() => { }); + } +} From 9c1b926847921c124dcc1c42c9d4426948a26bcf Mon Sep 17 00:00:00 2001 From: arturict Date: Mon, 13 Jul 2026 22:53:24 +0200 Subject: [PATCH 3/3] fix(ui): clean up native controls across render paths --- src/OpenClawTray.FunctionalUI/FunctionalUI.cs | 23 +++++ .../FunctionalUiNativeElementTests.cs | 94 ++++++++++++++++++- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs index 68fc30d30..d2e4c86d2 100644 --- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs +++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs @@ -1014,6 +1014,13 @@ private UIElement ConfigureNativeElement(NativeElement element, string path) RemoveRendererControlPath(path); var control = element.GetControl(); + foreach (var (existingPath, existingControl) in _nativeControls + .Where(pair => !string.Equals(pair.Key, path, StringComparison.Ordinal) + && ReferenceEquals(pair.Value, control)) + .ToArray()) + { + RemoveNativeControlPath(existingPath); + } if (_nativeControls.TryGetValue(path, out var previous) && !ReferenceEquals(previous, control)) RemoveNativeControlPath(path); _nativeControls[path] = control; @@ -1496,6 +1503,14 @@ private void PruneUnvisitedCachedSubtree(string prefix) _contentFlyouts.Remove(path); } + foreach (var (path, _) in _nativeControls + .Where(pair => IsPathAtOrBelow(pair.Key, prefix) && !_visitedNativePaths.Contains(pair.Key)) + .OrderByDescending(pair => pair.Key.Length) + .ToArray()) + { + RemoveNativeControlPath(path); + } + foreach (var (path, cachedControl) in _controls .Where(pair => IsPathAtOrBelow(pair.Key, prefix) && !_visitedControlPaths.Contains(pair.Key)) .OrderByDescending(pair => pair.Key.Length) @@ -1526,6 +1541,14 @@ private void RemoveCachedSubtree(string prefix) _contentFlyouts.Remove(path); } + foreach (var (path, _) in _nativeControls + .Where(pair => IsPathAtOrBelow(pair.Key, prefix)) + .OrderByDescending(pair => pair.Key.Length) + .ToArray()) + { + RemoveNativeControlPath(path); + } + foreach (var (path, cachedControl) in _controls .Where(pair => IsPathAtOrBelow(pair.Key, prefix)) .OrderByDescending(pair => pair.Key.Length) diff --git a/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs b/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs index 05e055ca2..4d40d60d4 100644 --- a/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs +++ b/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; using OpenClawTray.FunctionalUI; using OpenClawTray.FunctionalUI.Hosting; using static OpenClawTray.FunctionalUI.Factories; @@ -132,6 +133,86 @@ await _ui.RunOnUIAsync(() => }); } + [Fact] + public async Task NativeElement_RemainsAttachedWhenItsRenderPathChanges() + { + await _ui.ResetContainerAsync(); + + FunctionalHostControl? host = null; + TextBlock? nativeText = null; + var showPrefix = false; + + await _ui.RunOnUIAsync(() => + { + nativeText = new TextBlock { Text = "native" }; + host = new FunctionalHostControl { SuppressAutoDispose = true }; + _ui.Container.Children.Add(host); + host.Mount(_ => showPrefix + ? VStack(0, TextBlock("prefix"), Native(() => nativeText!)) + : VStack(0, Native(() => nativeText!))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + showPrefix = true; + host!.Mount(_ => VStack(0, TextBlock("prefix"), Native(() => nativeText!))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var wrapper = Assert.IsType(host!.Content); + var panel = Assert.IsType(wrapper.Child); + Assert.Equal(2, panel.Children.Count); + Assert.Same(nativeText, panel.Children[1]); + host.Dispose(); + }); + } + + [Fact] + public async Task VirtualStack_RecycleDetachesNativeElement() + { + await _ui.ResetContainerAsync(); + + FunctionalHostControl? host = null; + TextBlock? nativeText = null; + + await _ui.RunOnUIAsync(() => + { + nativeText = new TextBlock { Text = "native" }; + host = new FunctionalHostControl + { + Width = 400, + Height = 300, + SuppressAutoDispose = true, + }; + _ui.Container.Children.Add(host); + host.Mount(_ => VirtualVStack(0, Native(() => nativeText!))); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + var wrapper = Assert.IsType(host!.Content); + var repeater = Assert.IsType(wrapper.Child); + var container = Assert.IsType(repeater.TryGetElement(0)); + Assert.Same(nativeText, container.Child); + host.Mount(_ => VirtualVStack(0)); + }); + + await DrainRenderQueueAsync(); + + await _ui.RunOnUIAsync(() => + { + Assert.Null(VisualTreeHelper.GetParent(nativeText)); + host!.Dispose(); + }); + } + [Fact] public async Task NativeElement_OnMountRunsAcrossSamePathOwnershipChanges() { @@ -203,20 +284,23 @@ public async Task NativeElement_OnMountRunsWhenStablePathGetsNewNativeInstance() await _ui.ResetContainerAsync(); FunctionalHostControl? host = null; - var firstNative = new TextBlock { Text = "first" }; - var secondNative = new TextBlock { Text = "second" }; - var currentNative = firstNative; + TextBlock? firstNative = null; + TextBlock? secondNative = null; + TextBlock? currentNative = null; var mountCount = 0; await _ui.RunOnUIAsync(() => { TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + firstNative = new TextBlock { Text = "first" }; + secondNative = new TextBlock { Text = "second" }; + currentNative = firstNative; host = new FunctionalHostControl { SuppressAutoDispose = true, }; _ui.Container.Children.Add(host); - host.Mount(_ => Border(Native(() => currentNative).OnMount(_ => mountCount++))); + host.Mount(_ => Border(Native(() => currentNative!).OnMount(_ => mountCount++))); }); await DrainRenderQueueAsync(); @@ -225,7 +309,7 @@ await _ui.RunOnUIAsync(() => { Assert.Equal(1, mountCount); currentNative = secondNative; - host!.Mount(_ => Border(Native(() => currentNative).OnMount(_ => mountCount++))); + host!.Mount(_ => Border(Native(() => currentNative!).OnMount(_ => mountCount++))); }); await DrainRenderQueueAsync();