From 0247641613e2b6b28f57bcc8939d69238a2c5bdf Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 26 Jun 2026 16:35:40 -0700 Subject: [PATCH] Redesign Hub title bar notifications and status Move notifications from the footer rail into a title-bar bell flyout and replace the compact connection status dots with a status pill plus detail flyout. Keep the status presentation aligned with the Connection page by deriving node state from shared presenter logic and the node-mode/capability settings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/FluentIconCatalog.cs | 1 + .../Pages/NotificationsPage.xaml.cs | 83 +---- .../Services/ConnectionStatusPresenter.cs | 74 +++++ .../Strings/en-us/Resources.resw | 78 ++++- .../Strings/fr-fr/Resources.resw | 78 ++++- .../Strings/nl-nl/Resources.resw | 78 ++++- .../Strings/zh-cn/Resources.resw | 78 ++++- .../Strings/zh-tw/Resources.resw | 78 ++++- .../ViewModels/NotificationItemViewModel.cs | 90 ++++++ .../Windows/HubWindow.xaml | 257 +++++++++++++-- .../Windows/HubWindow.xaml.cs | 305 +++++++++++++++--- .../ConnectionStatusPresenterTests.cs | 138 ++++++++ .../FluentIconCatalogTests.cs | 2 +- .../LocalizationValidationTests.cs | 9 + .../OpenClaw.Tray.Tests.csproj | 1 + 15 files changed, 1135 insertions(+), 215 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs create mode 100644 src/OpenClaw.Tray.WinUI/ViewModels/NotificationItemViewModel.cs create mode 100644 tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs 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 @@ +