diff --git a/src/OpenClaw.Tray.WinUI/Automations/AutomationsRoot.cs b/src/OpenClaw.Tray.WinUI/Automations/AutomationsRoot.cs
index afb60df43c..f9aa2ae40b 100644
--- a/src/OpenClaw.Tray.WinUI/Automations/AutomationsRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Automations/AutomationsRoot.cs
@@ -13,6 +13,7 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using OpenClaw.Shared.Cron;
+using OpenClawTray.Helpers;
using OpenClawTray.Presentation;
using static Microsoft.UI.Reactor.Factories;
using static OpenClawTray.Automations.AutomationsVisuals;
@@ -314,11 +315,12 @@ void RegisterRow(FrameworkElement row, string id)
IsOpen = true,
CloseButtonText = t["Cancel"],
DefaultButton = ContentDialogButton.None,
- OnOpened = () => { if (confirmDialogRef.Current is { } native) FocusConfirmationCancel(native); },
OnClosed = result => { confirmDialogRef.Current = null; pending.Result.TrySetResult(result == ContentDialogResult.Primary); setConfirm(null); },
}).Set(native =>
{
confirmDialogRef.Current = native;
+ // Cancel must get initial focus deterministically, not race WinUI's own initial focus.
+ DialogInitialFocus.FocusCloseButton(native);
ConfigureDialogWidth(native, dialogWidth);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(native, "AutomationsConfirmDialog");
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(native, pending.Title);
@@ -384,19 +386,6 @@ private static Element RenderConfirmation(ConfirmRequest request, double width)
.Padding(12).CornerRadius(6).Background(Theme.SubtleFill))
.MaxWidth(420);
- private static void FocusConfirmationCancel(ContentDialog sender) =>
- sender.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
- () => FindCloseButton(sender, sender.CloseButtonText)?.Focus(FocusState.Programmatic));
-
- private static Button? FindCloseButton(DependencyObject parent, string label)
- {
- if (parent is Button button && (button.Name == "CloseButton" || string.Equals(button.Content as string, label, StringComparison.Ordinal)))
- return button;
- for (var i = 0; i < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent); i++)
- if (FindCloseButton(Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, i), label) is { } close)
- return close;
- return null;
- }
private static Element RenderHeader(AutomationsHost host, AutomationsSnapshot state, AutomationsActions actions, AutomationsLook look, PageWidthTier tier)
{
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/DialogInitialFocus.cs b/src/OpenClaw.Tray.WinUI/Helpers/DialogInitialFocus.cs
new file mode 100644
index 0000000000..be72a15d9f
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Helpers/DialogInitialFocus.cs
@@ -0,0 +1,108 @@
+// DialogInitialFocus: makes a ContentDialog's initial keyboard focus land on its Close (Cancel) button.
+//
+// When a ContentDialog opens, WinUI runs its own initial-focus transaction (with DefaultButton None
+// it picks the first focusable element, for example selectable body text). A focus call merely
+// queued from Opened can run before or after that transaction, so the outcome depends on timing.
+// Redirecting the first GettingFocus into the dialog makes Cancel win every time. When a dialog
+// opens right after another closes, Opened and even the first focus can arrive before the Cancel
+// button is realized or focusable; the fallback then retries after each layout pass until Cancel takes
+// focus. The fallback also covers a dialog that opened before this was attached. Once Cancel has focus,
+// later focus moves are untouched.
+//
+// Usage (Reactor ContentDialog element):
+// ContentDialog(...) with { DefaultButton = ContentDialogButton.None, CloseButtonText = "Cancel" }
+// .Set(native => DialogInitialFocus.FocusCloseButton(native));
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+
+namespace OpenClawTray.Helpers;
+
+internal static class DialogInitialFocus
+{
+ private const string CloseButtonPart = "CloseButton";
+
+ private static readonly DependencyProperty PendingProperty = DependencyProperty.RegisterAttached(
+ "Pending", typeof(object), typeof(DialogInitialFocus), new PropertyMetadata(null));
+
+ ///
+ /// Attaches once per native dialog and gives its first focus to Cancel. Callers mount a fresh keyed
+ /// dialog per confirmation (as AutomationsRoot does), so one open per native dialog is covered.
+ ///
+ public static void FocusCloseButton(ContentDialog dialog)
+ {
+ if (dialog.GetValue(PendingProperty) is not null)
+ return;
+ dialog.SetValue(PendingProperty, true);
+ dialog.GettingFocus += RedirectInitialFocus;
+ dialog.Opened += FocusOnOpened;
+ // Real user input ends the initial-focus duty, so a late fallback never takes focus back after
+ // the user starts interacting. Handled events count too (a TextBox handles its own keys).
+ dialog.AddHandler(UIElement.KeyDownEvent, new KeyEventHandler(EndOnUserInput), handledEventsToo: true);
+ dialog.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(EndOnUserInput), handledEventsToo: true);
+ // A side-mounted dialog can open before the modifier that attaches this runs.
+ if (dialog.IsLoaded)
+ QueueFocus(dialog);
+ }
+
+ internal static Button? FindCloseButton(DependencyObject parent)
+ {
+ if (parent is Button { Name: CloseButtonPart } button)
+ return button;
+ for (var index = 0; index < VisualTreeHelper.GetChildrenCount(parent); index++)
+ if (FindCloseButton(VisualTreeHelper.GetChild(parent, index)) is { } found)
+ return found;
+ return null;
+ }
+
+ private static bool IsPending(ContentDialog dialog) => dialog.GetValue(PendingProperty) is true;
+
+ private static void EndOnUserInput(object sender, RoutedEventArgs args) =>
+ ((ContentDialog)sender).SetValue(PendingProperty, false);
+
+ private static void RedirectInitialFocus(UIElement sender, GettingFocusEventArgs args)
+ {
+ var dialog = (ContentDialog)sender;
+ // Only the initial transaction (focus entering from outside); never redirect moves within the dialog.
+ if (!IsPending(dialog) || IsWithin(dialog, args.OldFocusedElement))
+ return;
+ if (FindCloseButton(dialog) is not { } cancel || !args.TrySetNewFocusedElement(cancel))
+ QueueFocus(dialog);
+ else
+ dialog.SetValue(PendingProperty, false);
+ }
+
+ private static bool IsWithin(DependencyObject ancestor, DependencyObject? element)
+ {
+ for (var current = element; current is not null; current = VisualTreeHelper.GetParent(current))
+ if (ReferenceEquals(current, ancestor))
+ return true;
+ return false;
+ }
+
+ private static void FocusOnOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) => QueueFocus(sender);
+
+ // Runs after WinUI's own initial-focus transaction; a no-op once Cancel has received focus.
+ private static void QueueFocus(ContentDialog dialog) =>
+ dialog.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => FocusWhenRealized(dialog));
+
+ private static void FocusWhenRealized(ContentDialog dialog)
+ {
+ if (!IsPending(dialog) || !dialog.IsLoaded)
+ return;
+ if (FindCloseButton(dialog) is { } cancel && cancel.Focus(FocusState.Programmatic))
+ {
+ dialog.SetValue(PendingProperty, false);
+ return;
+ }
+ // Cancel is not realized or not focusable yet: try again after the next layout pass.
+ void OnLayoutUpdated(object? sender, object args)
+ {
+ dialog.LayoutUpdated -= OnLayoutUpdated;
+ QueueFocus(dialog);
+ }
+ dialog.LayoutUpdated += OnLayoutUpdated;
+ }
+}
diff --git a/tests/OpenClaw.Tray.UITests/AutomationsUiTests.cs b/tests/OpenClaw.Tray.UITests/AutomationsUiTests.cs
index 400b7345eb..4820453eb7 100644
--- a/tests/OpenClaw.Tray.UITests/AutomationsUiTests.cs
+++ b/tests/OpenClaw.Tray.UITests/AutomationsUiTests.cs
@@ -680,10 +680,12 @@ await WaitUiAsync(run, () => FindById(run, "AutomationsConfirmDialog") is not nu
Assert.Equal($"It's removed from Fixture Gateway ({run.Profile.RunId[..8]}) along with its schedule and run history. This can't be undone.",
removeText.DocumentRange.GetText(-1).TrimEnd('\r', '\n'));
var removeDialog = FindById(run, "AutomationsConfirmDialog")!;
- var cancel = removeDialog.FindFirst(TreeScope.Descendants, new AndCondition(
- new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
- new PropertyCondition(AutomationElement.NameProperty, "Cancel")));
- await WaitUiAsync(run, () => cancel.Current.HasKeyboardFocus, "Cancel receives destructive initial focus");
+ // Re-acquire Cancel on every poll: a dialog re-render can replace the button's peer.
+ await WaitUiAsync(run, () => FindById(run, "AutomationsConfirmDialog")?.FindFirst(TreeScope.Descendants,
+ new AndCondition(
+ new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
+ new PropertyCondition(AutomationElement.NameProperty, "Cancel"))) is { Current.HasKeyboardFocus: true },
+ "Cancel receives destructive initial focus");
Assert.Equal(ControlType.Window, removeDialog.Current.ControlType);
Assert.True(((WindowPattern)removeDialog.GetCurrentPattern(WindowPattern.Pattern)).Current.IsModal);
await ScanStateAsync("remove confirmation");
@@ -1564,8 +1566,11 @@ private static AutomationElement PageScrollPeer(GatewayFixtureRun run, bool dump
var marker = window.FindFirst(TreeScope.Descendants,
new PropertyCondition(AutomationElement.AutomationIdProperty, "CronPageMarker"));
if (marker is null) return null;
- Assert.True(NativeUiOwnership.IsOwnedDescendant(window, marker, run.AppProcessId),
+ // Polled while Automations re-renders: a marker peer that is detached or replaced proves nothing yet.
+ var markerEvidence = NativeUiOwnership.Classify(window, marker, run.AppProcessId);
+ Assert.True(markerEvidence != NativeUiOwnership.Evidence.Foreign,
"Automations page marker must belong to the retained native window.");
+ if (markerEvidence != NativeUiOwnership.Evidence.Owned) return null;
var markerIdentity = marker.GetRuntimeId();
bool IsMarker(AutomationElement? element) => element is not null
&& NativeUiOwnership.IsOwnedDescendant(window, element, run.AppProcessId)
diff --git a/tests/OpenClaw.Tray.UITests/DialogInitialFocusTests.cs b/tests/OpenClaw.Tray.UITests/DialogInitialFocusTests.cs
new file mode 100644
index 0000000000..12fd8017d2
--- /dev/null
+++ b/tests/OpenClaw.Tray.UITests/DialogInitialFocusTests.cs
@@ -0,0 +1,181 @@
+// Proves DialogInitialFocus gives a destructive ContentDialog's initial focus to Cancel even when the
+// dialog content holds a focusable element: the first focus inside the dialog after Cancel exists is
+// Cancel, and nothing else takes focus after it. Opened can fire before the template is realized, so
+// focus is observed through the dialog's own GettingFocus, not sampled at Opened. A later focus move is
+// never stolen back. Like AutomationsRoot, each confirmation mounts a fresh keyed dialog.
+// Run with the DialogInitialFocusTests filter on an isolated Windows desktop.
+using Microsoft.UI.Reactor;
+using Microsoft.UI.Reactor.Core;
+using Microsoft.UI.Reactor.Hosting;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using OpenClawTray.Helpers;
+using static Microsoft.UI.Reactor.Factories;
+
+namespace OpenClaw.Tray.UITests;
+
+[Collection(UICollection.Name)]
+public sealed class DialogInitialFocusTests(UIThreadFixture ui)
+{
+ private static readonly TimeSpan DialogTransitionTimeout = TimeSpan.FromSeconds(10);
+ // Each run opens this many confirmations, each right after the previous one closes.
+ private const int Confirmations = 11;
+
+ [Fact]
+ public async Task DestructiveDialog_CancelOwnsInitialFocusForEachConfirmationWithoutStealingLaterFocus()
+ {
+ await ui.ResetContainerAsync();
+ ReactorHostControl? host = null;
+ Microsoft.UI.Xaml.Controls.ContentDialog? native = null;
+ Element? current = null;
+ var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ // Each focus change routed through the dialog, read synchronously in GettingFocus after the helper's
+ // handler (so the final target after any redirect), with whether Cancel existed at that moment.
+ var focusLog = new List<(DependencyObject Element, bool CancelRealized)>();
+ var recorded = new HashSet(ReferenceEqualityComparer.Instance);
+ void RecordFocus(UIElement sender, GettingFocusEventArgs args)
+ {
+ if (!args.Cancel && args.NewFocusedElement is { } element)
+ focusLog.Add((element, DialogInitialFocus.FindCloseButton(sender) is not null));
+ }
+ void AssertCancelKeptFocus(Microsoft.UI.Xaml.Controls.ContentDialog dialog, string key)
+ {
+ var cancel = DialogInitialFocus.FindCloseButton(dialog)!;
+ Assert.Same(cancel, FocusManager.GetFocusedElement(dialog.XamlRoot));
+ var inside = focusLog.Where(entry => IsWithin(dialog, entry.Element)).ToArray();
+ // Before Cancel exists nothing can redirect to it; once it exists, the first focus must be Cancel.
+ var firstRealized = inside.FirstOrDefault(entry => entry.CancelRealized).Element;
+ Assert.True(ReferenceEquals(firstRealized, cancel),
+ $"{key}: first focus after Cancel was realized went to {firstRealized?.GetType().Name ?? "nothing"}.");
+ // WinUI can raise GettingFocus again for the same target; only a different element is a move.
+ var afterCancel = inside.SkipWhile(entry => !ReferenceEquals(entry.Element, cancel))
+ .Where(entry => !ReferenceEquals(entry.Element, cancel)).ToArray();
+ Assert.True(afterCancel.Length == 0,
+ $"{key}: focus moved inside the dialog after Cancel: {string.Join(", ", afterCancel.Select(entry => entry.Element.GetType().Name))}");
+ }
+ Element Dialog(string key) => (ContentDialog("", TextBox("selectable body", _ => { }).AutomationName("Body"), "Remove") with
+ {
+ IsOpen = true,
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.None,
+ OnClosed = _ => closed.TrySetResult(),
+ OnOpened = () => opened.TrySetResult(),
+ }).Set(dialog =>
+ {
+ native = dialog;
+ DialogInitialFocus.FocusCloseButton(dialog);
+ // After the helper's handler, so the recorder sees the final target. OnMountAdd would receive
+ // Reactor's placeholder, not this native dialog.
+ AttachRecorder(dialog);
+ }).WithKey(key);
+ void AttachRecorder(Microsoft.UI.Xaml.Controls.ContentDialog dialog)
+ {
+ if (recorded.Add(dialog))
+ dialog.GettingFocus += RecordFocus;
+ }
+ void Render(Element next)
+ {
+ host!.Content = current is null
+ ? host.Reconciler.Mount(next, static () => { })
+ : host.Reconciler.Reconcile(current, next, (UIElement)host.Content!, static () => { });
+ current = next;
+ }
+ async Task ConfirmAndAssertCancelAsync(string key)
+ {
+ opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ focusLog.Clear();
+ await ui.RunOnUIAsync(() => Render(Dialog(key)));
+ await opened.Task.WaitAsync(DialogTransitionTimeout);
+ return await ui.RunOnUIAsync(async () =>
+ {
+ var dialog = native!;
+ // Opened can precede the template and the first focus. Wait until Cancel holds focus, drain
+ // queued focus work, then require that Cancel was the first focus once it existed and kept it.
+ await TestSupport.WaitForRenderedConditionAsync(() =>
+ DialogInitialFocus.FindCloseButton(dialog) is { } realized && dialog.XamlRoot is { } root &&
+ ReferenceEquals(FocusManager.GetFocusedElement(root), realized),
+ $"{key}: Cancel receives initial focus in the realized dialog",
+ () => $"focused={FocusManager.GetFocusedElement(dialog.XamlRoot)?.GetType().Name ?? "none"}");
+ await ui.YieldToRenderAsync();
+ AssertCancelKeptFocus(dialog, key);
+ return dialog;
+ });
+ }
+ async Task CloseAndRemoveAsync()
+ {
+ await ui.RunOnUIAsync(() => native!.Hide());
+ await closed.Task.WaitAsync(DialogTransitionTimeout);
+ await ui.RunOnUIAsync(() => Render(Empty()));
+ }
+ try
+ {
+ await ui.RunOnUIAsync(() =>
+ {
+ host = new ReactorHostControl { Width = 560, Height = 400 };
+ ui.Container.Children.Add(host);
+ });
+ var first = await ConfirmAndAssertCancelAsync("confirmation-0");
+ // Once Cancel has focus the initial-focus duty is over: a later move (here to the body, which
+ // also runs layout for any pending retry) must not be taken back.
+ TextBox? body = null;
+ await ui.RunOnUIAsync(() =>
+ {
+ body = FindDescendant(first);
+ Assert.NotNull(body);
+ Assert.True(body.Focus(FocusState.Programmatic));
+ });
+ await ui.YieldToRenderAsync();
+ await ui.RunOnUIAsync(() => Assert.Same(body, FocusManager.GetFocusedElement(first.XamlRoot)));
+
+ var previous = first;
+ for (var index = 1; index < Confirmations; index++)
+ {
+ if (index > 1)
+ await ui.RunOnUIAsync(() => AssertCancelKeptFocus(previous, $"confirmation-{index - 1} before close"));
+ await CloseAndRemoveAsync();
+ var next = await ConfirmAndAssertCancelAsync($"confirmation-{index}");
+ Assert.NotSame(previous, next);
+ previous = next;
+ }
+ // The last confirmation stays open through the test's own async steps; Cancel must still hold focus.
+ await ui.RunOnUIAsync(() => AssertCancelKeptFocus(previous, $"confirmation-{Confirmations - 1} before close"));
+ await CloseAndRemoveAsync();
+ }
+ finally
+ {
+ await ui.RunOnUIAsync(() =>
+ {
+ foreach (var dialog in recorded)
+ dialog.GettingFocus -= RecordFocus;
+ if (current is not null && native is { IsLoaded: true })
+ native.Hide();
+ if (host is null) return;
+ ui.Container.Children.Remove(host);
+ host.Content = null;
+ host.Dispose();
+ });
+ }
+ }
+
+ private static bool IsWithin(DependencyObject ancestor, DependencyObject element)
+ {
+ for (DependencyObject? current = element; current is not null; current = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(current))
+ if (ReferenceEquals(current, ancestor))
+ return true;
+ return false;
+ }
+
+ private static T? FindDescendant(DependencyObject parent) where T : DependencyObject
+ {
+ for (var index = 0; index < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent); index++)
+ {
+ var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, index);
+ if (child is T match) return match;
+ if (FindDescendant(child) is { } nested) return nested;
+ }
+ return null;
+ }
+}
diff --git a/tests/OpenClaw.Tray.UITests/GatewayFixtureSystemsUiTests.cs b/tests/OpenClaw.Tray.UITests/GatewayFixtureSystemsUiTests.cs
index 3763f5032d..ad330c6196 100644
--- a/tests/OpenClaw.Tray.UITests/GatewayFixtureSystemsUiTests.cs
+++ b/tests/OpenClaw.Tray.UITests/GatewayFixtureSystemsUiTests.cs
@@ -410,11 +410,25 @@ await WaitUiAsync(run, () =>
{
var element = parent.FindFirst(TreeScope.Descendants,
new PropertyCondition(AutomationElement.AutomationIdProperty, id));
- if (element is not null)
- Assert.True(NativeUiOwnership.IsOwnedDescendant(window, element, run.AppProcessId),
- $"Systems observation '{id}' must belong to the retained native window and process.");
+ if (element is null)
+ return null;
+ // Polls run while Systems re-renders (refresh, error and live readings). A peer that is
+ // detached or replaced is not observable yet: reject the whole observation so a found but
+ // unproven element can never satisfy an absence check. Foreign evidence still fails.
+ var evidence = NativeUiOwnership.Classify(window, element, run.AppProcessId);
+ Assert.True(evidence != NativeUiOwnership.Evidence.Foreign,
+ $"Systems observation '{id}' must belong to the retained native window and process.");
+ if (evidence != NativeUiOwnership.Evidence.Owned)
+ throw new ElementNotAvailableException($"Systems observation '{id}' ownership is not observable yet.");
return element;
}
+ bool OwnedNow(AutomationElement element, string what)
+ {
+ var evidence = NativeUiOwnership.Classify(window, element, run.AppProcessId);
+ Assert.True(evidence != NativeUiOwnership.Evidence.Foreign,
+ $"{what} must belong to the captured native window and process.");
+ return evidence == NativeUiOwnership.Evidence.Owned;
+ }
AutomationElement? Find(string id) => FindUnder(window, id);
var hwnd = new IntPtr(window.Current.NativeWindowHandle);
var scale = SystemsWindowDpi(hwnd) / 96d;
@@ -433,15 +447,30 @@ await WaitUiAsync(run, () =>
}, "normal Systems window");
Assert.NotNull(paneToggle);
var records = new List