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..2983802fa 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,10 +247,17 @@ 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 channelCombo = Border()
- .Set(border =>
+ var channelComboRef = UseRef(null);
+ var channelGroupsRef = UseRef(null);
+ var channelChangedRef = UseRef(Props.OnChannelChanged);
+ var channelComboUpdatingRef = UseRef(false);
+ channelChangedRef.Current = Props.OnChannelChanged;
+ var channelCombo = Border(Native(() =>
+ {
+ var cb = channelComboRef.Current;
+ if (cb is null)
{
- var cb = new ComboBox
+ cb = new ComboBox
{
MinWidth = 0,
Width = double.NaN,
@@ -266,49 +271,69 @@ public override Element Render()
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)
+ return cb;
+ }))
+ .Set(_ =>
+ {
+ var cb = channelComboRef.Current;
+ if (cb is null) return;
+
+ 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);
- };
-
- border.Child = cb;
+ channelComboUpdatingRef.Current = false;
+ }
});
// ── Model picker (provider-rich) ─────────────────────────────────
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/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
index 5b83efad7..d2e4c86d2 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,32 @@ 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();
+ 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;
+
+ 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 +1063,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))
@@ -1441,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)
@@ -1471,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)
@@ -1649,15 +1727,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 +2080,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.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")]),
+ ];
+}
diff --git a/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs b/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs
new file mode 100644
index 000000000..4d40d60d4
--- /dev/null
+++ b/tests/OpenClaw.Tray.UITests/FunctionalUiNativeElementTests.cs
@@ -0,0 +1,333 @@
+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;
+
+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_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()
+ {
+ 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;
+ 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++)));
+ });
+
+ 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(() => { });
+ }
+}