diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index 7bf2f04a8..2728062d1 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -49,6 +49,7 @@ public static class FluentIconCatalog
public const string Settings = "\uE713"; // Settings
public const string Setup = "\uE825"; // Bank — Reconfigure / Setup wizard launcher
public const string About = "\uE946"; // Info
+ public const string Notifications = "\uE7E7"; // Ringer — title-bar notifications bell
public const string Exit = "\uE711"; // Cancel (X) — used for "Close" menu item
public const string Add = "\uE710"; // Add — "+ Add gateway" header button
public const string Back = "\uE72B"; // Back — leading chevron on Back hyperlink
diff --git a/src/OpenClaw.Tray.WinUI/Pages/NotificationsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/NotificationsPage.xaml.cs
index 293d4b321..8e752df7b 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/NotificationsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/NotificationsPage.xaml.cs
@@ -1,11 +1,8 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using OpenClawTray.Helpers;
using OpenClawTray.Services;
-using System;
+using OpenClawTray.ViewModels;
using System.Collections.ObjectModel;
-using System.Globalization;
-using System.Linq;
namespace OpenClawTray.Pages;
@@ -98,82 +95,4 @@ private void OnNotificationActionClick(object sender, RoutedEventArgs e)
((IAppCommands)CurrentApp).Navigate(item.ActionRoute);
_notificationService?.Dismiss(item.Id);
}
-
- private sealed record NotificationItemViewModel(
- string Id,
- string SeverityGlyph,
- string Title,
- string Message,
- string Metadata,
- string? ActionLabel,
- string? ActionRoute,
- Visibility ActionVisibility,
- string OccurrenceText,
- Visibility OccurrenceVisibility,
- string DismissAutomationName)
- {
- public static NotificationItemViewModel From(AppNotification notification)
- {
- var metadata = new[]
- {
- LocalizeMetadataValue(notification.Source),
- LocalizeMetadataValue(notification.Category),
- notification.CreatedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)
- }
- .Where(value => !string.IsNullOrWhiteSpace(value));
-
- var occurrenceText = LocalizationHelper.Format(
- "AppNotification_RepeatedBadgeFormat",
- notification.OccurrenceCount);
-
- return new(
- notification.Id,
- ToSeverityGlyph(notification.Severity),
- notification.Title,
- notification.Message,
- string.Join(" - ", metadata),
- notification.ActionLabel,
- notification.ActionRoute,
- !string.IsNullOrWhiteSpace(notification.ActionLabel) &&
- !string.IsNullOrWhiteSpace(notification.ActionRoute)
- ? Visibility.Visible
- : Visibility.Collapsed,
- occurrenceText,
- notification.OccurrenceCount > 1 ? Visibility.Visible : Visibility.Collapsed,
- LocalizationHelper.Format("NotificationsPage_DismissAutomationNameFormat", notification.Title));
- }
-
- private static string ToSeverityGlyph(AppNotificationSeverity severity) => severity switch
- {
- AppNotificationSeverity.Success => "\uE930",
- AppNotificationSeverity.Warning => "\uE7BA",
- AppNotificationSeverity.Error => "\uE783",
- _ => "\uE946"
- };
-
- private static string? LocalizeMetadataValue(string? value) => value switch
- {
- null or "" => null,
- "authentication" => LocalizationHelper.GetString("NotificationsPage_MetadataAuthentication"),
- "bindings" => LocalizationHelper.GetString("NotificationsPage_MetadataBindings"),
- "channels" => LocalizationHelper.GetString("NotificationsPage_MetadataChannels"),
- "config" => LocalizationHelper.GetString("NotificationsPage_MetadataConfig"),
- "connection" => LocalizationHelper.GetString("NotificationsPage_MetadataConnection"),
- "cron" => LocalizationHelper.GetString("NotificationsPage_MetadataCron"),
- "exec-approval" => LocalizationHelper.GetString("NotificationsPage_MetadataSourceExecApproval"),
- "gateway" => LocalizationHelper.GetString("NotificationsPage_MetadataGateway"),
- "jobs" => LocalizationHelper.GetString("NotificationsPage_MetadataJobs"),
- "load" => LocalizationHelper.GetString("NotificationsPage_MetadataLoad"),
- "lifecycle" => LocalizationHelper.GetString("NotificationsPage_MetadataLifecycle"),
- "local-gateway" => LocalizationHelper.GetString("NotificationsPage_MetadataLocalGateway"),
- "node.invoke" => LocalizationHelper.GetString("NotificationsPage_MetadataCategoryNodeInvoke"),
- "node" => LocalizationHelper.GetString("NotificationsPage_MetadataNode"),
- "pairing" => LocalizationHelper.GetString("NotificationsPage_MetadataPairing"),
- "sandbox" => LocalizationHelper.GetString("NotificationsPage_MetadataSandbox"),
- "settings" => LocalizationHelper.GetString("NotificationsPage_MetadataSettings"),
- "status" => LocalizationHelper.GetString("NotificationsPage_MetadataStatus"),
- "system.run" => LocalizationHelper.GetString("NotificationsPage_MetadataSystemRun"),
- _ => value
- };
- }
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs
new file mode 100644
index 000000000..2bea86d1d
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs
@@ -0,0 +1,74 @@
+using OpenClaw.Connection;
+
+namespace OpenClawTray.Services;
+
+internal enum ConnectionStatusAccent
+{
+ Neutral,
+ Success,
+ Caution,
+ Critical,
+}
+
+internal static class ConnectionStatusPresenter
+{
+ public static (string LabelKey, ConnectionStatusAccent Accent) Pill(OverallConnectionState overall) => overall switch
+ {
+ OverallConnectionState.Connected or OverallConnectionState.Ready =>
+ ("StatusDisplay_Connected", ConnectionStatusAccent.Success),
+ OverallConnectionState.Connecting =>
+ ("StatusDisplay_Connecting", ConnectionStatusAccent.Caution),
+ OverallConnectionState.Degraded =>
+ ("HubWindow_Pill_Degraded", ConnectionStatusAccent.Caution),
+ OverallConnectionState.PairingRequired =>
+ ("HubWindow_Pill_PairingRequired", ConnectionStatusAccent.Caution),
+ OverallConnectionState.Error =>
+ ("StatusDisplay_Error", ConnectionStatusAccent.Critical),
+ _ => ("StatusDisplay_Disconnected", ConnectionStatusAccent.Neutral),
+ };
+
+ public static ConnectionStatusAccent RoleAccent(RoleConnectionState state) => state switch
+ {
+ RoleConnectionState.Connected => ConnectionStatusAccent.Success,
+ RoleConnectionState.Connecting => ConnectionStatusAccent.Caution,
+ RoleConnectionState.PairingRequired => ConnectionStatusAccent.Caution,
+ RoleConnectionState.Error or
+ RoleConnectionState.PairingRejected or
+ RoleConnectionState.RateLimited => ConnectionStatusAccent.Critical,
+ _ => ConnectionStatusAccent.Neutral,
+ };
+
+ public static string RoleStateLabelKey(RoleConnectionState state) => state switch
+ {
+ RoleConnectionState.Connected => "StatusDisplay_Connected",
+ RoleConnectionState.Connecting => "StatusDisplay_Connecting",
+ RoleConnectionState.PairingRequired => "HubWindow_Role_PairingRequired",
+ RoleConnectionState.PairingRejected => "HubWindow_Role_PairingRejected",
+ RoleConnectionState.RateLimited => "HubWindow_Role_RateLimited",
+ RoleConnectionState.Error => "StatusDisplay_Error",
+ RoleConnectionState.Disabled => "HubWindow_Role_Disabled",
+ _ => "HubWindow_Role_Off",
+ };
+
+ public static (string LabelKey, ConnectionStatusAccent Accent) NodeRow(
+ GatewayConnectionSnapshot snapshot, bool nodeModeEnabled, int enabledCapabilityCount)
+ {
+ if (!nodeModeEnabled || snapshot.OperatorState != RoleConnectionState.Connected)
+ return ("HubWindow_Role_Disabled", ConnectionStatusAccent.Neutral);
+
+ return snapshot.NodeState switch
+ {
+ RoleConnectionState.PairingRequired => (RoleStateLabelKey(snapshot.NodeState), ConnectionStatusAccent.Caution),
+ RoleConnectionState.PairingRejected or
+ RoleConnectionState.RateLimited or
+ RoleConnectionState.Error => (RoleStateLabelKey(snapshot.NodeState), ConnectionStatusAccent.Critical),
+ _ when enabledCapabilityCount == 0 => ("HubWindow_Role_PermissionsIncomplete", ConnectionStatusAccent.Caution),
+ _ => (RoleStateLabelKey(snapshot.NodeState), RoleAccent(snapshot.NodeState)),
+ };
+ }
+
+ public static bool NodeNeedsApproval(GatewayConnectionSnapshot snapshot, bool nodeModeEnabled) =>
+ nodeModeEnabled
+ && snapshot.OperatorState == RoleConnectionState.Connected
+ && snapshot.NodeState == RoleConnectionState.PairingRequired;
+}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 6ce7d3a6d..d04a980fc 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2421,9 +2421,6 @@ On your gateway host (Mac/Linux), run:
Permissions
-
- Notifications
-
Diagnostics
@@ -5029,12 +5026,6 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
Disconnected
-
- Op
-
-
- Node
-
Advanced
@@ -5065,9 +5056,6 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
Preview Voice
-
- Disconnected
-
Commands the agent runs directly on the gateway aren't. If the gateway is on this PC they may still be able to reach your Windows files, clipboard, and network — check your gateway's configuration.
@@ -6706,4 +6694,70 @@ Make sure the gateway is running.
Auth
+
+ Connection
+
+
+ Open Connection
+
+
+ Gateway
+
+
+ Reconnect
+
+
+ Operator
+
+
+ Node
+
+
+ Approve…
+
+
+ Connection status
+
+
+ Notifications
+
+
+ Ready
+
+
+ Pairing required
+
+
+ Degraded
+
+
+ Off
+
+
+ Pairing required
+
+
+ Pairing rejected
+
+
+ Rate limited
+
+
+ Disabled
+
+
+ Clear all
+
+
+ No notifications
+
+
+ Open notifications
+
+
+ {0} active
+
+
+ Permissions incomplete
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 45705128b..d2f8eb8aa 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2376,9 +2376,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Débogage
-
- Alertes
-
Informations
@@ -4981,12 +4978,6 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
Déconnecté
-
- Op.
-
-
- Nœud
-
Avancé
@@ -5017,9 +5008,6 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
Aperçu de la voix
-
- Déconnecté
-
Les commandes que l'agent exécute directement sur la passerelle ne le sont pas. Si la passerelle se trouve sur ce PC, elles peuvent toujours accéder à vos fichiers Windows, au presse-papiers et au réseau — vérifiez la configuration de votre passerelle.
@@ -6658,4 +6646,70 @@ Assurez-vous que la passerelle est en cours d'exécution.
Authentification
+
+ Connection
+
+
+ Open Connection
+
+
+ Gateway
+
+
+ Reconnect
+
+
+ Operator
+
+
+ Node
+
+
+ Approve…
+
+
+ Connection status
+
+
+ Notifications
+
+
+ Ready
+
+
+ Pairing required
+
+
+ Degraded
+
+
+ Off
+
+
+ Pairing required
+
+
+ Pairing rejected
+
+
+ Rate limited
+
+
+ Disabled
+
+
+ Clear all
+
+
+ No notifications
+
+
+ Open notifications
+
+
+ {0} active
+
+
+ Permissions incomplete
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 5a2b26bc8..bc29dcebf 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2377,9 +2377,6 @@ Voer op uw gateway-host (Mac/Linux) uit:
Foutopsporing
-
- Meldingen
-
Informatie
@@ -4982,12 +4979,6 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
Niet verbonden
-
- Op.
-
-
- Knooppunt
-
Geavanceerd
@@ -5018,9 +5009,6 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
Stemvoorbeeld
-
- Niet verbonden
-
Opdrachten die de agent rechtstreeks op de gateway uitvoert, vallen hier niet onder. Als de gateway op deze pc staat, hebben ze mogelijk nog steeds toegang tot uw Windows-bestanden, klembord en netwerk — controleer de configuratie van uw gateway.
@@ -6659,4 +6647,70 @@ Controleer of de gateway actief is.
Verificatie
+
+ Connection
+
+
+ Open Connection
+
+
+ Gateway
+
+
+ Reconnect
+
+
+ Operator
+
+
+ Node
+
+
+ Approve…
+
+
+ Connection status
+
+
+ Notifications
+
+
+ Ready
+
+
+ Pairing required
+
+
+ Degraded
+
+
+ Off
+
+
+ Pairing required
+
+
+ Pairing rejected
+
+
+ Rate limited
+
+
+ Disabled
+
+
+ Clear all
+
+
+ No notifications
+
+
+ Open notifications
+
+
+ {0} active
+
+
+ Permissions incomplete
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 698ce6c04..cc31b01fd 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2376,9 +2376,6 @@
调试
-
- 通知
-
信息
@@ -4981,12 +4978,6 @@
已断开连接
-
- 操作
-
-
- 节点
-
高级
@@ -5017,9 +5008,6 @@
语音预览
-
- 已断开连接
-
代理直接在网关上运行的命令不受此限制。如果网关在此电脑上,它们仍可能访问您的 Windows 文件、剪贴板和网络——请检查网关的配置。
@@ -6659,4 +6647,70 @@
身份验证
+
+ Connection
+
+
+ Open Connection
+
+
+ Gateway
+
+
+ Reconnect
+
+
+ Operator
+
+
+ Node
+
+
+ Approve…
+
+
+ Connection status
+
+
+ Notifications
+
+
+ Ready
+
+
+ Pairing required
+
+
+ Degraded
+
+
+ Off
+
+
+ Pairing required
+
+
+ Pairing rejected
+
+
+ Rate limited
+
+
+ Disabled
+
+
+ Clear all
+
+
+ No notifications
+
+
+ Open notifications
+
+
+ {0} active
+
+
+ Permissions incomplete
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 43dbc909e..aff06cf80 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2376,9 +2376,6 @@
偵錯
-
- 通知
-
資訊
@@ -4981,12 +4978,6 @@
已中斷連線
-
- 操作
-
-
- 節點
-
進階
@@ -5017,9 +5008,6 @@
語音預覽
-
- 已中斷連線
-
代理直接在閘道上執行的命令不受此限制。如果閘道在此電腦上,它們仍可能存取您的 Windows 檔案、剪貼簿和網路——請檢查閘道的設定。
@@ -6659,4 +6647,70 @@
驗證
+
+ Connection
+
+
+ Open Connection
+
+
+ Gateway
+
+
+ Reconnect
+
+
+ Operator
+
+
+ Node
+
+
+ Approve…
+
+
+ Connection status
+
+
+ Notifications
+
+
+ Ready
+
+
+ Pairing required
+
+
+ Degraded
+
+
+ Off
+
+
+ Pairing required
+
+
+ Pairing rejected
+
+
+ Rate limited
+
+
+ Disabled
+
+
+ Clear all
+
+
+ No notifications
+
+
+ Open notifications
+
+
+ {0} active
+
+
+ Permissions incomplete
+
diff --git a/src/OpenClaw.Tray.WinUI/ViewModels/NotificationItemViewModel.cs b/src/OpenClaw.Tray.WinUI/ViewModels/NotificationItemViewModel.cs
new file mode 100644
index 000000000..1bdf78def
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/ViewModels/NotificationItemViewModel.cs
@@ -0,0 +1,90 @@
+using Microsoft.UI.Xaml;
+using OpenClawTray.Helpers;
+using OpenClawTray.Services;
+using System.Globalization;
+using System.Linq;
+
+namespace OpenClawTray.ViewModels;
+
+///
+/// Presentation model for a single app notification card. Shared by the
+/// full NotificationsPage and the title-bar notifications bell flyout
+/// so severity glyphs and metadata localization stay defined in one place.
+///
+internal sealed record NotificationItemViewModel(
+ string Id,
+ string SeverityGlyph,
+ string Title,
+ string Message,
+ string Metadata,
+ string? ActionLabel,
+ string? ActionRoute,
+ Visibility ActionVisibility,
+ string OccurrenceText,
+ Visibility OccurrenceVisibility,
+ string DismissAutomationName)
+{
+ public static NotificationItemViewModel From(AppNotification notification)
+ {
+ var metadata = new[]
+ {
+ LocalizeMetadataValue(notification.Source),
+ LocalizeMetadataValue(notification.Category),
+ notification.CreatedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)
+ }
+ .Where(value => !string.IsNullOrWhiteSpace(value));
+
+ var occurrenceText = LocalizationHelper.Format(
+ "AppNotification_RepeatedBadgeFormat",
+ notification.OccurrenceCount);
+
+ return new(
+ notification.Id,
+ ToSeverityGlyph(notification.Severity),
+ notification.Title,
+ notification.Message,
+ string.Join(" - ", metadata),
+ notification.ActionLabel,
+ notification.ActionRoute,
+ !string.IsNullOrWhiteSpace(notification.ActionLabel) &&
+ !string.IsNullOrWhiteSpace(notification.ActionRoute)
+ ? Visibility.Visible
+ : Visibility.Collapsed,
+ occurrenceText,
+ notification.OccurrenceCount > 1 ? Visibility.Visible : Visibility.Collapsed,
+ LocalizationHelper.Format("NotificationsPage_DismissAutomationNameFormat", notification.Title));
+ }
+
+ private static string ToSeverityGlyph(AppNotificationSeverity severity) => severity switch
+ {
+ AppNotificationSeverity.Success => "\uE930",
+ AppNotificationSeverity.Warning => "\uE7BA",
+ AppNotificationSeverity.Error => "\uE783",
+ _ => "\uE946"
+ };
+
+ private static string? LocalizeMetadataValue(string? value) => value switch
+ {
+ null or "" => null,
+ "authentication" => LocalizationHelper.GetString("NotificationsPage_MetadataAuthentication"),
+ "bindings" => LocalizationHelper.GetString("NotificationsPage_MetadataBindings"),
+ "channels" => LocalizationHelper.GetString("NotificationsPage_MetadataChannels"),
+ "config" => LocalizationHelper.GetString("NotificationsPage_MetadataConfig"),
+ "connection" => LocalizationHelper.GetString("NotificationsPage_MetadataConnection"),
+ "cron" => LocalizationHelper.GetString("NotificationsPage_MetadataCron"),
+ "exec-approval" => LocalizationHelper.GetString("NotificationsPage_MetadataSourceExecApproval"),
+ "gateway" => LocalizationHelper.GetString("NotificationsPage_MetadataGateway"),
+ "jobs" => LocalizationHelper.GetString("NotificationsPage_MetadataJobs"),
+ "load" => LocalizationHelper.GetString("NotificationsPage_MetadataLoad"),
+ "lifecycle" => LocalizationHelper.GetString("NotificationsPage_MetadataLifecycle"),
+ "local-gateway" => LocalizationHelper.GetString("NotificationsPage_MetadataLocalGateway"),
+ "node.invoke" => LocalizationHelper.GetString("NotificationsPage_MetadataCategoryNodeInvoke"),
+ "node" => LocalizationHelper.GetString("NotificationsPage_MetadataNode"),
+ "pairing" => LocalizationHelper.GetString("NotificationsPage_MetadataPairing"),
+ "sandbox" => LocalizationHelper.GetString("NotificationsPage_MetadataSandbox"),
+ "settings" => LocalizationHelper.GetString("NotificationsPage_MetadataSettings"),
+ "status" => LocalizationHelper.GetString("NotificationsPage_MetadataStatus"),
+ "system.run" => LocalizationHelper.GetString("NotificationsPage_MetadataSystemRun"),
+ _ => value
+ };
+}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
index 7a97be51a..df9df996c 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
@@ -90,26 +90,240 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -269,9 +483,6 @@
-
-
-
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 55fb66d54..889a2784c 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -3,12 +3,16 @@
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using OpenClaw.Connection;
using OpenClaw.Shared;
using OpenClawTray.Helpers;
using OpenClawTray.Pages;
using OpenClawTray.Services;
+using OpenClawTray.ViewModels;
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using WinUIEx;
@@ -38,6 +42,9 @@ private static TaskCompletionSource CreateCompletedContentReady()
private bool _suppressAppNotificationClosed;
private bool _appNotificationActionShowsMore;
+ private readonly ObservableCollection _bellItems = new();
+ private bool _bellListBound;
+
// Legacy compatibility alias
public string SelectedAgentId => _currentAgentId;
public Action? OpenDashboardAction { get; set; }
@@ -108,6 +115,9 @@ public HubWindow()
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
RootGrid.SizeChanged += OnRootGridSizeChanged;
+
+ ToolTipService.SetToolTip(StatusPillButton, LocalizationHelper.GetString("HubWindow_StatusPill_Tooltip"));
+ ToolTipService.SetToolTip(NotificationsBellButton, LocalizationHelper.GetString("HubWindow_Bell_Tooltip"));
}
///
@@ -150,12 +160,22 @@ private void OnAppNotificationChanged(object? sender, AppNotificationChangedEven
private void RenderAppNotification(AppNotificationSnapshot snapshot)
{
_lastAppNotificationSnapshot = snapshot;
+
+ UpdateNotificationsBell(snapshot);
+
+ var bannerActive = snapshot.ActiveNotifications
+ .Where(n => IsBannerSeverity(n.Severity))
+ .ToList();
+ var bannerSnapshot = bannerActive.Count == snapshot.ActiveNotifications.Count
+ ? snapshot
+ : snapshot with { ActiveNotifications = bannerActive };
+
var displayedNotificationWasRemoved = _currentAppNotification is not null
&& AppNotificationInfoBar.IsOpen
- && !snapshot.ActiveNotifications.Any(notification =>
+ && !bannerSnapshot.ActiveNotifications.Any(notification =>
string.Equals(notification.Id, _currentAppNotification.Id, StringComparison.Ordinal));
_currentAppNotification = _appNotificationBannerState.SelectVisibleNotification(
- snapshot,
+ bannerSnapshot,
revealHiddenIfNeeded: displayedNotificationWasRemoved);
if (_currentAppNotification is null)
{
@@ -169,11 +189,6 @@ private void RenderAppNotification(AppNotificationSnapshot snapshot)
AppNotificationInfoBar.Title = string.Empty;
AppNotificationInfoBar.Message = string.Empty;
- // Single-line compact banner: bold headline + regular detail on one
- // line (e.g. "Gateway connection failed — Transport error"). The bold
- // run gives the headline scannability without adding a second row.
- // Full detail/troubleshooting still lives on the surface the action
- // routes to (e.g. the Connection page).
AppNotificationMessageText.Inlines.Clear();
AppNotificationMessageText.Inlines.Add(new Run
{
@@ -247,6 +262,197 @@ private void UpdateAppNotificationActionEnabledState()
_ => InfoBarSeverity.Informational
};
+ private static bool IsBannerSeverity(AppNotificationSeverity severity) =>
+ severity is AppNotificationSeverity.Error or AppNotificationSeverity.Warning;
+
+ private void UpdateNotificationsBell(AppNotificationSnapshot snapshot)
+ {
+ var count = snapshot.ActiveNotifications.Count;
+
+ if (NotificationsBadge is not null)
+ {
+ NotificationsBadge.Value = count;
+ NotificationsBadge.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ SyncBellItems(snapshot.ActiveNotifications.Select(NotificationItemViewModel.From).ToList());
+
+ SyncBellFlyoutEmptyState();
+ }
+
+ private void SyncBellItems(IReadOnlyList desiredItems)
+ {
+ var desiredIds = desiredItems
+ .Select(item => item.Id)
+ .ToHashSet(StringComparer.Ordinal);
+
+ for (var i = _bellItems.Count - 1; i >= 0; i--)
+ {
+ if (!desiredIds.Contains(_bellItems[i].Id))
+ _bellItems.RemoveAt(i);
+ }
+
+ for (var i = 0; i < desiredItems.Count; i++)
+ {
+ var item = desiredItems[i];
+ if (i < _bellItems.Count && string.Equals(_bellItems[i].Id, item.Id, StringComparison.Ordinal))
+ {
+ if (!_bellItems[i].Equals(item))
+ _bellItems[i] = item;
+ continue;
+ }
+
+ var existingIndex = -1;
+ for (var j = i + 1; j < _bellItems.Count; j++)
+ {
+ if (string.Equals(_bellItems[j].Id, item.Id, StringComparison.Ordinal))
+ {
+ existingIndex = j;
+ break;
+ }
+ }
+
+ if (existingIndex >= 0)
+ {
+ _bellItems.Move(existingIndex, i);
+ if (!_bellItems[i].Equals(item))
+ _bellItems[i] = item;
+ }
+ else
+ {
+ _bellItems.Insert(i, item);
+ }
+ }
+ }
+
+ private void SyncBellFlyoutEmptyState()
+ {
+ var hasItems = _bellItems.Count > 0;
+
+ if (BellNotificationsList is not null)
+ BellNotificationsList.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
+ if (BellEmptyState is not null)
+ BellEmptyState.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible;
+ if (BellClearAllButton is not null)
+ BellClearAllButton.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
+ if (BellActiveCountText is not null)
+ BellActiveCountText.Text = hasItems
+ ? LocalizationHelper.Format("NotificationsFlyout_ActiveCountFormat", _bellItems.Count)
+ : string.Empty;
+ }
+
+ private void OnNotificationsFlyoutOpening(object sender, object e)
+ {
+ if (BellNotificationsList is not null && !_bellListBound)
+ {
+ BellNotificationsList.ItemsSource = _bellItems;
+ _bellListBound = true;
+ }
+ SyncBellFlyoutEmptyState();
+ }
+
+ private void OnBellClearAllClick(object sender, RoutedEventArgs e)
+ => _appNotificationService?.ClearAll();
+
+ private void OnBellDismissNotificationClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { Tag: string notificationId })
+ _appNotificationService?.Dismiss(notificationId);
+ }
+
+ private void OnBellOpenPageClick(object sender, RoutedEventArgs e)
+ {
+ NotificationsFlyout.Hide();
+ NavigateTo("notifications");
+ }
+
+ private void OnStatusFlyoutOpening(object sender, object e)
+ {
+ var snapshot = CurrentApp.ConnectionManager?.CurrentSnapshot;
+ var settings = CurrentApp.Settings;
+ var nodeEnabled = settings?.EnableNodeMode == true;
+ var enabledCapabilities = CountEnabledCapabilities(settings);
+ var op = snapshot?.OperatorState ?? RoleConnectionState.Idle;
+
+ GatewayRowDot.Fill = AccentBrush(ConnectionStatusPresenter.RoleAccent(op));
+ GatewayRowDetail.Text = BuildGatewayDetail(snapshot);
+ GatewayRowAction.Visibility =
+ op is RoleConnectionState.Connected or RoleConnectionState.Connecting
+ ? Visibility.Collapsed
+ : Visibility.Visible;
+
+ OperatorRowDot.Fill = AccentBrush(ConnectionStatusPresenter.RoleAccent(op));
+ OperatorRowDetail.Text = LocalizationHelper.GetString(
+ ConnectionStatusPresenter.RoleStateLabelKey(op));
+
+ if (snapshot is not null)
+ {
+ var (nodeKey, nodeAccent) = ConnectionStatusPresenter.NodeRow(snapshot, nodeEnabled, enabledCapabilities);
+ NodeRowDot.Fill = AccentBrush(nodeAccent);
+ NodeRowDetail.Text = LocalizationHelper.GetString(nodeKey);
+ NodeRowAction.Visibility = ConnectionStatusPresenter.NodeNeedsApproval(snapshot, nodeEnabled)
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+ else
+ {
+ NodeRowDot.Fill = AccentBrush(ConnectionStatusAccent.Neutral);
+ NodeRowDetail.Text = LocalizationHelper.GetString("HubWindow_Role_Disabled");
+ NodeRowAction.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private string BuildGatewayDetail(GatewayConnectionSnapshot? snapshot)
+ {
+ var parts = new List();
+ if (!string.IsNullOrWhiteSpace(snapshot?.GatewayName))
+ parts.Add(snapshot!.GatewayName!);
+ if (!string.IsNullOrWhiteSpace(snapshot?.GatewayUrl))
+ parts.Add(snapshot!.GatewayUrl!);
+ if (LastGatewaySelf is { ServerVersion: { Length: > 0 } ver })
+ parts.Add($"v{ver}");
+ return parts.Count > 0
+ ? string.Join(" · ", parts)
+ : LocalizationHelper.GetString("StatusDisplay_Disconnected");
+ }
+
+ private static int CountEnabledCapabilities(SettingsManager? settings)
+ {
+ if (settings is null) return 0;
+
+ var count = 0;
+ if (settings.NodeBrowserProxyEnabled) count++;
+ if (settings.NodeCameraEnabled) count++;
+ if (settings.NodeCanvasEnabled) count++;
+ if (settings.NodeScreenEnabled) count++;
+ if (settings.NodeLocationEnabled) count++;
+ if (settings.NodeTtsEnabled) count++;
+ if (settings.NodeSttEnabled) count++;
+ return count;
+ }
+
+ private void OnStatusFlyoutOpenConnectionClick(object sender, RoutedEventArgs e)
+ {
+ StatusFlyout.Hide();
+ NavigateTo("connection");
+ }
+
+ private void OnStatusFlyoutReconnectClick(object sender, RoutedEventArgs e)
+ {
+ StatusFlyout.Hide();
+ if (ReconnectAction is not null)
+ ReconnectAction.Invoke();
+ else
+ ConnectAction?.Invoke();
+ }
+
+ private void OnStatusFlyoutNodeActionClick(object sender, RoutedEventArgs e)
+ {
+ StatusFlyout.Hide();
+ NavigateTo("connection");
+ }
+
+
private void OnAppNotificationInfoBarClosed(InfoBar sender, InfoBarClosedEventArgs args)
{
if (_suppressAppNotificationClosed)
@@ -366,11 +572,6 @@ private void OnNavContentHostSizeChanged(object sender, SizeChangedEventArgs e)
NavContentClip.Rect = new global::Windows.Foundation.Rect(0, 0, e.NewSize.Width, e.NewSize.Height);
}
- private void OnTitleBarStatusTapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e)
- {
- NavigateTo("connection");
- }
-
private void OnNavPaneToggleButtonClick(object sender, RoutedEventArgs e)
{
NavView.IsPaneOpen = !NavView.IsPaneOpen;
@@ -498,6 +699,14 @@ private void SyncNavSelection(string? tag)
_syncingNavSelection = true;
try { NavView.SelectedItem = item; }
finally { _syncingNavSelection = false; }
+ return;
+ }
+
+ if (item == null && NavView.SelectedItem != null)
+ {
+ _syncingNavSelection = true;
+ try { NavView.SelectedItem = null; }
+ finally { _syncingNavSelection = false; }
}
}
@@ -535,52 +744,50 @@ public async Task WaitForCurrentContentReadyAsync()
private void UpdateTitleBarStatus(ConnectionStatus status)
{
- var color = status switch
- {
- ConnectionStatus.Connected => Microsoft.UI.Colors.LimeGreen,
- ConnectionStatus.Connecting => Microsoft.UI.Colors.Orange,
- ConnectionStatus.Error => Microsoft.UI.Colors.Red,
- _ => Microsoft.UI.Colors.Gray
- };
-
- TitleStatusDot.Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush(color);
-
- // Build status text with version when connected
- if (status == ConnectionStatus.Connected && LastGatewaySelf is { ServerVersion: { Length: > 0 } ver })
- TitleStatusText.Text = $"v{ver}";
- else
- TitleStatusText.Text = LocalizationHelper.GetConnectionStatusText(status);
-
- // Update role indicator dots
var snapshot = CurrentApp.ConnectionManager?.CurrentSnapshot;
- if (snapshot != null)
+ var (text, accent) = ComputePillState(status, snapshot);
+ StatusPillText.Text = text;
+ StatusPillDot.Fill = AccentBrush(accent);
+ }
+
+ private static (string Text, ConnectionStatusAccent Accent) ComputePillState(
+ ConnectionStatus status, GatewayConnectionSnapshot? snapshot)
+ {
+ if (snapshot is not null)
{
- TitleOpDot.Fill = RoleDotBrush(snapshot.OperatorState);
- TitleNodeDot.Fill = RoleDotBrush(snapshot.NodeState);
+ var (labelKey, accent) = ConnectionStatusPresenter.Pill(snapshot.OverallState);
+ return (LocalizationHelper.GetString(labelKey), accent);
}
- else
+
+ return status switch
{
- var gray = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray);
- TitleOpDot.Fill = gray;
- TitleNodeDot.Fill = gray;
- }
+ ConnectionStatus.Connected => (LocalizationHelper.GetString("StatusDisplay_Connected"), ConnectionStatusAccent.Success),
+ ConnectionStatus.Connecting => (LocalizationHelper.GetString("StatusDisplay_Connecting"), ConnectionStatusAccent.Caution),
+ ConnectionStatus.Error => (LocalizationHelper.GetString("StatusDisplay_Error"), ConnectionStatusAccent.Critical),
+ _ => (LocalizationHelper.GetString("StatusDisplay_Disconnected"), ConnectionStatusAccent.Neutral),
+ };
}
- private static Microsoft.UI.Xaml.Media.SolidColorBrush RoleDotBrush(OpenClaw.Connection.RoleConnectionState state) => state switch
+ private static string AccentBrushKey(ConnectionStatusAccent accent) => accent switch
{
- OpenClaw.Connection.RoleConnectionState.Connected =>
- new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.LimeGreen),
- OpenClaw.Connection.RoleConnectionState.Connecting =>
- new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Orange),
- OpenClaw.Connection.RoleConnectionState.PairingRequired =>
- new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Orange),
- OpenClaw.Connection.RoleConnectionState.Error or
- OpenClaw.Connection.RoleConnectionState.PairingRejected or
- OpenClaw.Connection.RoleConnectionState.RateLimited =>
- new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Red),
- _ => new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray),
+ ConnectionStatusAccent.Success => "SystemFillColorSuccessBrush",
+ ConnectionStatusAccent.Caution => "SystemFillColorCautionBrush",
+ ConnectionStatusAccent.Critical => "SystemFillColorCriticalBrush",
+ _ => "SystemFillColorNeutralBrush",
};
+ private static Brush AccentBrush(ConnectionStatusAccent accent)
+ {
+ var resources = Application.Current.Resources;
+ if (resources.TryGetValue(AccentBrushKey(accent), out var brush) && brush is Brush typed)
+ return typed;
+
+ if (resources.TryGetValue("SystemFillColorNeutralBrush", out var neutral) && neutral is Brush fallback)
+ return fallback;
+
+ return new SolidColorBrush(Microsoft.UI.Colors.Transparent);
+ }
+
private void ScheduleGatewayNavVisibilityForStatus(ConnectionStatus status, bool debounceDisconnected)
{
switch (GatewayNavVisibilityDebouncePolicy.GetDecision(status, debounceDisconnected))
diff --git a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs
new file mode 100644
index 000000000..17ed08c2e
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs
@@ -0,0 +1,138 @@
+using OpenClaw.Connection;
+using OpenClawTray.Services;
+using System.Xml.Linq;
+using Xunit;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class ConnectionStatusPresenterTests
+{
+ [Theory]
+ [InlineData(OverallConnectionState.Connected, "StatusDisplay_Connected", (int)ConnectionStatusAccent.Success)]
+ [InlineData(OverallConnectionState.Ready, "StatusDisplay_Connected", (int)ConnectionStatusAccent.Success)]
+ [InlineData(OverallConnectionState.Connecting, "StatusDisplay_Connecting", (int)ConnectionStatusAccent.Caution)]
+ [InlineData(OverallConnectionState.Degraded, "HubWindow_Pill_Degraded", (int)ConnectionStatusAccent.Caution)]
+ [InlineData(OverallConnectionState.PairingRequired, "HubWindow_Pill_PairingRequired", (int)ConnectionStatusAccent.Caution)]
+ [InlineData(OverallConnectionState.Error, "StatusDisplay_Error", (int)ConnectionStatusAccent.Critical)]
+ [InlineData(OverallConnectionState.Idle, "StatusDisplay_Disconnected", (int)ConnectionStatusAccent.Neutral)]
+ [InlineData(OverallConnectionState.Disconnecting, "StatusDisplay_Disconnected", (int)ConnectionStatusAccent.Neutral)]
+ public void Pill_MapsOverallState(OverallConnectionState overall, string expectedKey, int expectedAccent)
+ {
+ var (labelKey, accent) = ConnectionStatusPresenter.Pill(overall);
+ Assert.Equal(expectedKey, labelKey);
+ Assert.Equal(expectedAccent, (int)accent);
+ }
+
+ [Fact]
+ public void Pill_ReadyAndConnected_BothReadConnected()
+ {
+ Assert.Equal(ConnectionStatusPresenter.Pill(OverallConnectionState.Connected),
+ ConnectionStatusPresenter.Pill(OverallConnectionState.Ready));
+ }
+
+ [Fact]
+ public void NodeRow_NodeModeDisabled_ReadsDisabled_EvenWhenTransportConnected()
+ {
+ var snap = new GatewayConnectionSnapshot
+ {
+ OperatorState = RoleConnectionState.Connected,
+ NodeState = RoleConnectionState.Connected,
+ };
+
+ var (labelKey, accent) = ConnectionStatusPresenter.NodeRow(snap, nodeModeEnabled: false, enabledCapabilityCount: 7);
+
+ Assert.Equal("HubWindow_Role_Disabled", labelKey);
+ Assert.Equal(ConnectionStatusAccent.Neutral, accent);
+ }
+
+ [Fact]
+ public void NodeRow_OperatorNotConnected_ReadsDisabled()
+ {
+ var snap = new GatewayConnectionSnapshot
+ {
+ OperatorState = RoleConnectionState.Connecting,
+ NodeState = RoleConnectionState.Connected,
+ };
+
+ var (labelKey, accent) = ConnectionStatusPresenter.NodeRow(snap, nodeModeEnabled: true, enabledCapabilityCount: 7);
+
+ Assert.Equal("HubWindow_Role_Disabled", labelKey);
+ Assert.Equal(ConnectionStatusAccent.Neutral, accent);
+ }
+
+ [Fact]
+ public void NodeRow_NodeModeEnabledAndConnected_ReadsConnected()
+ {
+ var snap = new GatewayConnectionSnapshot
+ {
+ OperatorState = RoleConnectionState.Connected,
+ NodeState = RoleConnectionState.Connected,
+ };
+
+ var (labelKey, accent) = ConnectionStatusPresenter.NodeRow(snap, nodeModeEnabled: true, enabledCapabilityCount: 1);
+
+ Assert.Equal("StatusDisplay_Connected", labelKey);
+ Assert.Equal(ConnectionStatusAccent.Success, accent);
+ }
+
+ [Fact]
+ public void NodeRow_NodeModeEnabledConnectedAndNoCapabilities_ReadsPermissionsIncomplete()
+ {
+ var snap = new GatewayConnectionSnapshot
+ {
+ OperatorState = RoleConnectionState.Connected,
+ NodeState = RoleConnectionState.Connected,
+ };
+
+ var (labelKey, accent) = ConnectionStatusPresenter.NodeRow(snap, nodeModeEnabled: true, enabledCapabilityCount: 0);
+
+ Assert.Equal("HubWindow_Role_PermissionsIncomplete", labelKey);
+ Assert.Equal(ConnectionStatusAccent.Caution, accent);
+ }
+
+ [Fact]
+ public void NodeNeedsApproval_OnlyWhenEnabledOperatorConnectedAndPairing()
+ {
+ var pairing = new GatewayConnectionSnapshot
+ {
+ OperatorState = RoleConnectionState.Connected,
+ NodeState = RoleConnectionState.PairingRequired,
+ };
+
+ Assert.True(ConnectionStatusPresenter.NodeNeedsApproval(pairing, nodeModeEnabled: true));
+ Assert.False(ConnectionStatusPresenter.NodeNeedsApproval(pairing, nodeModeEnabled: false));
+ }
+
+ [Fact]
+ public void Presenter_ReturnedResourceKeys_ExistInEnUsResources()
+ {
+ var keys = new HashSet(StringComparer.Ordinal);
+
+ foreach (var overall in Enum.GetValues())
+ keys.Add(ConnectionStatusPresenter.Pill(overall).LabelKey);
+
+ foreach (var state in Enum.GetValues())
+ keys.Add(ConnectionStatusPresenter.RoleStateLabelKey(state));
+
+ var connected = new GatewayConnectionSnapshot
+ {
+ OperatorState = RoleConnectionState.Connected,
+ NodeState = RoleConnectionState.Connected,
+ };
+ keys.Add(ConnectionStatusPresenter.NodeRow(connected, nodeModeEnabled: false, enabledCapabilityCount: 0).LabelKey);
+ keys.Add(ConnectionStatusPresenter.NodeRow(connected, nodeModeEnabled: true, enabledCapabilityCount: 0).LabelKey);
+ keys.Add(ConnectionStatusPresenter.NodeRow(connected, nodeModeEnabled: true, enabledCapabilityCount: 1).LabelKey);
+
+ var reswPath = Path.Combine(
+ TestRepositoryPaths.GetRepositoryRoot(),
+ "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw");
+ var resourceKeys = XDocument.Load(reswPath)
+ .Descendants("data")
+ .Select(e => (string?)e.Attribute("name"))
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .ToHashSet(StringComparer.Ordinal);
+
+ var missing = keys.Where(key => !resourceKeys.Contains(key)).OrderBy(key => key).ToArray();
+ Assert.Empty(missing);
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
index 2c668aefd..96e4c36cb 100644
--- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
+++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
@@ -20,7 +20,7 @@ public sealed class FluentIconCatalogTests
"Sessions", "Approvals", "Devices", "Hostname", "Permissions",
"Browser", "Camera", "Canvas", "Screen", "Location", "Voice", "Speech", "System", "Terminal", "Operator",
"Dashboard", "OpenInBrowser", "Chat", "CanvasAct", "VoiceAct", "Settings",
- "Setup", "About", "Exit",
+ "Setup", "About", "Notifications", "Exit",
"Add", "Back", "Sync", "Lock", "Plug", "MoreOverflow",
"People", "Money", "ServerEnvironment", "CapabilityOff", "Channels",
"ChevronR", "Check",
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index 6e6d71f92..c2eb80c89 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -559,6 +559,15 @@ private static bool IsInvariantOrDeferred(string key, string value) =>
|| key.StartsWith("ChannelsPage_", StringComparison.Ordinal)
|| key.StartsWith("DiagnosticsPage_", StringComparison.Ordinal)
|| key.StartsWith("SettingsRow_", StringComparison.Ordinal)
+ // Title-bar status pill + notifications bell flyout strings. Seeded
+ // English-only across all five .resw files using the deferred-translation
+ // pattern; translations land in a follow-up.
+ || key.StartsWith("HubWindow_StatusFlyout_", StringComparison.Ordinal)
+ || key.StartsWith("HubWindow_StatusPill_", StringComparison.Ordinal)
+ || key.StartsWith("HubWindow_Pill_", StringComparison.Ordinal)
+ || key.StartsWith("HubWindow_Role_", StringComparison.Ordinal)
+ || key.StartsWith("HubWindow_Bell_", StringComparison.Ordinal)
+ || key.StartsWith("NotificationsFlyout_", StringComparison.Ordinal)
// V2 onboarding redesign strings (V2_*) are intentionally English-only at first
// ship. They live in V2Strings.DefaultEnUs and the cutover seeded them into all
// five .resw files with English values. Translations land in a follow-up.
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 4bb61d930..47a3b212b 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -65,6 +65,7 @@
+