From 229b697e3414b504d5d8ad7987f87500a40475d3 Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Tue, 12 May 2026 16:04:04 -0700 Subject: [PATCH 1/2] feat(nodes): rich node cards with rename, forget, and tray cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tray-app changes covering four areas of how the operator manages paired nodes: 1. Tray flyout — the "Devices" section now shows only currently-connected nodes. Stale paired-but-offline entries no longer accumulate in the right-click menu; they remain accessible on the full Nodes page. 2. Gateway model + parser — `GatewayNodeInfo` now carries the rest of the `NodeListNode` schema the gateway already sends: `Version`, `CoreVersion`, `UiVersion`, `ClientId`, `ClientMode`, `DeviceFamily`, `ModelIdentifier`, `RemoteIp`, `PathEnv`, `ConnectedAt`, `ApprovedAt`, `LastSeenReason`, `IsPaired`, `HasExplicitDisplayName`, plus the `DisabledCommands` list. `ParseNodeList` now reads the production `*Ms` timestamp wire names (`lastSeenAtMs`, `connectedAtMs`, `approvedAtMs`); the legacy non-Ms fallbacks remain for mocks/tests. `LastSeen` no longer falls back to `connectedAtMs` so the same value doesn't appear twice in the UI. 3. New gateway client methods: - `NodeRenameAsync` — awaits the gateway response via `SendWizardRequestAsync` and returns a `NodeRenameResult` with success/error so the UI can surface the actual server message. - `NodePairRemoveAsync` — same pattern, returns a `NodeForgetResult`. Using `SendWizardRequestAsync` (rather than the fire-and-forget `TrySendTrackedRequestAsync`) means scope rejections, missing nodeIds, and other application-level failures are reported back to the caller instead of being silently swallowed. The `node.pair.resolved` broadcast handler now refreshes both the pair-list and the node list so removed nodes disappear immediately. 4. NodesPage — full rewrite of the per-node card. Each node renders as an `Expander` (online auto-expanded, offline collapsed) with a body that shows the identity row, version line, hardware, network, timestamps, capability tags (now actually populated), commands list with disabled annotations, permissions grid, and an optional collapsed PATH dump. Action footer at the bottom — separator + a right-aligned [Rename] [Forget] pair — follows the Win11 Settings pattern (Manage / Remove on Email account cards). Both actions open `ContentDialog`s using the deferral pattern so failures stay inline instead of silently closing. Implementation notes worth highlighting: - Click lambdas wrap their async work in try/catch so an unhandled exception in a dialog flow can never become an `async void` crash. - Rename TextBox pre-fills with the explicit display name only — never with the parser's fallback id — so pressing Enter on an unnamed node doesn't persist the id as the new display name. - Layout uses `Grid` with star/auto columns where `TextWrapping` and `TextTrimming` actually matter (header, identity row, label rows); long ids/names ellipsize with a tooltip, long values wrap. - `ModelFormatting.FormatAge` was made `public` and grew a clock-skew guard plus a >30d absolute-date branch; both the header `DetailText` and the body timestamps now share it, so a single timestamp can't show as "1d ago" in one place and "36h ago" in another. - 25 new resource keys per locale (en-us, fr-fr, nl-nl, zh-cn, zh-tw); "Version", "Hardware", and "PATH" are registered as invariant. Tests: - `OpenClaw.Shared.Tests` adds 7 new parser tests covering the full `NodeListNode` schema, legacy wire-name compatibility, minimal payload defaults, and rename/forget input validation paths. - `OpenClaw.Tray.Tests` mock implementation of `IOperatorGatewayClient` updated for the new methods. Localization validation tests pass with the three invariant exceptions registered. Validation: `dotnet build` clean; `OpenClaw.Shared.Tests` 1547 passed, `OpenClaw.Tray.Tests` 965 passed. Known follow-up (out of scope for this PR, deserves its own change): - Action buttons are not yet gated on `operator.pairing` scope. Bootstrap-token sessions will see enabled buttons that fail with the gateway's "missing scope" message inline — same as the existing pair approve/reject buttons. A wider scope-aware UI pass should expose granted scopes on `IOperatorGatewayClient` and gate everywhere. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/IOperatorGatewayClient.cs | 2 + src/OpenClaw.Shared/Models.cs | 64 +- src/OpenClaw.Shared/OpenClawGatewayClient.cs | 133 +++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 13 +- .../Pages/NodesPage.xaml.cs | 739 +++++++++++++++--- .../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 ++ .../OpenClawGatewayClientTests.cs | 196 +++++ .../LocalizationValidationTests.cs | 8 + .../OnboardingChatBootstrapperTests.cs | 4 + 13 files changed, 1417 insertions(+), 132 deletions(-) diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 08ea6a7c1..328309196 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -87,6 +87,8 @@ public interface IOperatorGatewayClient Task RequestNodePairListAsync(); Task NodePairApproveAsync(string requestId); Task NodePairRejectAsync(string requestId); + Task NodePairRemoveAsync(string nodeId); + Task NodeRenameAsync(string nodeId, string displayName); Task RequestDevicePairListAsync(); Task DevicePairApproveAsync(string requestId); Task DevicePairRejectAsync(string requestId); diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index d38d0fb08..4606da22a 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -491,6 +491,32 @@ public class GatewayNodeInfo public List DisabledCommands { get; set; } = new(); public Dictionary Permissions { get; set; } = new(StringComparer.OrdinalIgnoreCase); + // Identity / hardware (from gateway NodeListNode schema) + public string? Version { get; set; } + public string? CoreVersion { get; set; } + public string? UiVersion { get; set; } + public string? ClientId { get; set; } + public string? ClientMode { get; set; } + public string? DeviceFamily { get; set; } + public string? ModelIdentifier { get; set; } + public string? RemoteIp { get; set; } + public string? PathEnv { get; set; } + + // Timestamps and state + public DateTime? ConnectedAt { get; set; } + public DateTime? ApprovedAt { get; set; } + public string? LastSeenReason { get; set; } + + // True when the node is in the gateway's paired set (regardless of current + // connection state). Distinct from IsOnline — a paired node can be offline. + public bool IsPaired { get; set; } + + // True when the gateway provided an explicit displayName/name/label. + // False when the parser had to fall back to shortId or nodeId. UI surfaces + // (e.g. the rename dialog) use this to distinguish "the user gave this + // node a name" from "we showed the id because there was nothing better". + public bool HasExplicitDisplayName { get; set; } + public string ShortId => NodeId.Length <= 12 ? NodeId : NodeId[..12] + "…"; public string DisplayText @@ -522,6 +548,26 @@ public string DetailText private static string FormatAge(DateTime timestampUtc) => ModelFormatting.FormatAge(timestampUtc); } +/// +/// Result of a node.rename request to the gateway. +/// +/// True when the gateway accepted the rename and persisted it. +/// Node id the gateway returned; same as the requested id on success. +/// Updated display name as persisted by the gateway. +/// Gateway-supplied or transport-derived error description; null on success. +public sealed record NodeRenameResult( + bool Success, + string? NodeId = null, + string? DisplayName = null, + string? ErrorMessage = null); + +/// +/// Result of a node.pair.remove request to the gateway. +/// +/// True when the gateway accepted the removal. +/// Gateway-supplied or transport-derived error description; null on success. +public sealed record NodeForgetResult(bool Success, string? ErrorMessage = null); + public enum GatewayDiagnosticSeverity { Info, @@ -1490,18 +1536,28 @@ private static string BuildLocalTunnelUrl(int localPort) => } /// Shared display-formatting helpers used by model classes. -internal static class ModelFormatting +public static class ModelFormatting { /// - /// Formats a UTC timestamp as a human-readable age string (e.g. "just now", "5m ago", "2h ago", "3d ago"). + /// Formats a UTC timestamp as a human-readable age string. + /// Examples: "just now", "5m ago", "12h ago", "3d ago", "2026-03-12". + /// Public so all UI surfaces share one canonical formatter — divergent + /// thresholds between callers can otherwise show the same timestamp as + /// "1d ago" in one place and "36h ago" in another for the same node. /// - internal static string FormatAge(DateTime timestampUtc) + public static string FormatAge(DateTime timestampUtc) { var delta = DateTime.UtcNow - timestampUtc; + // Clock skew between gateway host and local machine can produce + // timestamps slightly in the future. Treat those as "just now". + if (delta < TimeSpan.Zero) return "just now"; if (delta.TotalSeconds < 60) return "just now"; if (delta.TotalMinutes < 60) return $"{(int)delta.TotalMinutes}m ago"; if (delta.TotalHours < 48) return $"{(int)delta.TotalHours}h ago"; - return $"{(int)delta.TotalDays}d ago"; + if (delta.TotalDays < 30) return $"{(int)delta.TotalDays}d ago"; + // For very old timestamps the relative form loses meaning; show + // an absolute local date instead. + return timestampUtc.ToLocalTime().ToString("yyyy-MM-dd"); } /// diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 08f70e59e..3db6c37a7 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -571,6 +571,79 @@ public Task NodePairRejectAsync(string requestId) return TrySendTrackedRequestAsync("node.pair.reject", new { requestId }); } + /// + /// Removes a paired node from the gateway and waits for the gateway's + /// application-level response. Returns Success=true only when the + /// gateway confirms the removal — Success=false on transport failure, + /// missing scope, unknown nodeId, or any server-side rejection. The + /// gateway also broadcasts node.pair.resolved with + /// decision="removed" after success, which the broadcast handler + /// turns into a node.list + node.pair.list refresh. + /// + public async Task NodePairRemoveAsync(string nodeId) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return new NodeForgetResult(false, "nodeId required"); + if (!IsConnected) + return new NodeForgetResult(false, "Gateway connection is not open"); + + try + { + // SendWizardRequestAsync awaits the matching ack frame and + // throws InvalidOperationException when the gateway responds + // with ok=false, so callers see a real failure result on + // rejection (missing scope, unknown nodeId) rather than a + // false success the moment the WS frame is sent. + await SendWizardRequestAsync("node.pair.remove", new { nodeId }); + return new NodeForgetResult(true); + } + catch (Exception ex) + { + _logger.Warn($"node.pair.remove failed: {ex.Message}"); + return new NodeForgetResult(false, ex.Message); + } + } + + /// + /// Renames the display name of a paired node. Awaits the gateway's + /// response, so callers can rely on + /// before refreshing UI state. The gateway does not broadcast a rename, + /// so callers should follow a successful rename with + /// to pick up the new value. + /// + public async Task NodeRenameAsync(string nodeId, string displayName) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return new NodeRenameResult(false, ErrorMessage: "nodeId required"); + var trimmed = displayName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(trimmed)) + return new NodeRenameResult(false, ErrorMessage: "displayName required"); + if (!IsConnected) + return new NodeRenameResult(false, ErrorMessage: "Gateway connection is not open"); + + try + { + var response = await SendWizardRequestAsync( + "node.rename", + new { nodeId, displayName = trimmed }); + + var returnedNodeId = response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("nodeId", out var idEl) + ? idEl.GetString() + : nodeId; + var returnedDisplayName = response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("displayName", out var nameEl) + ? nameEl.GetString() ?? trimmed + : trimmed; + return new NodeRenameResult(true, returnedNodeId, returnedDisplayName); + } + catch (Exception ex) + { + _logger.Warn($"node.rename failed: {ex.Message}"); + return new NodeRenameResult(false, ErrorMessage: ex.Message); + } + } + public async Task RequestDevicePairListAsync() { if (_devicePairListUnsupported) return; @@ -1815,8 +1888,13 @@ private void HandleEvent(JsonElement root) break; case "node.pair.requested": case "node.pair.resolved": - // Refresh node pair list when pairing state changes + // Refresh node pair list when pairing state changes. Also + // refresh node.list because resolved decisions (in particular + // "removed") drop the node from the gateway's known set, so + // any UI mirroring node.list would otherwise show stale data + // until the next poll. _ = RequestNodePairListAsync(); + _ = RequestNodesAsync(); break; case "device.pair.requested": case "device.pair.resolved": @@ -2395,40 +2473,73 @@ private void ParseNodeList(JsonElement nodesPayload) "unknown"); var connected = GetOptionalBool(nodeElement, "connected"); var online = GetOptionalBool(nodeElement, "online"); + var paired = GetOptionalBool(nodeElement, "paired"); var capabilities = GetStringArray(nodeElement, "caps"); if (capabilities.Length == 0) capabilities = GetStringArray(nodeElement, "capabilities"); var commands = GetStringArray(nodeElement, "declaredCommands"); if (commands.Length == 0) commands = GetStringArray(nodeElement, "commands"); + var disabledCommands = GetStringArray(nodeElement, "disabledCommands"); var permissions = GetBoolDictionary(nodeElement, "permissions"); + var clientMode = GetString(nodeElement, "clientMode"); + + // Distinguish "user gave this node a name" from "we fell back + // to the id". The rename dialog uses this so it can prefill + // empty when the node has no explicit name (rather than + // pre-seeding the textbox with the id, which would otherwise + // get persisted as the new display name on Enter). + var explicitName = FirstNonEmpty( + GetString(nodeElement, "displayName"), + GetString(nodeElement, "name"), + GetString(nodeElement, "label")); + buffer[count++] = new GatewayNodeInfo { NodeId = nodeId!, - DisplayName = FirstNonEmpty( - GetString(nodeElement, "displayName"), - GetString(nodeElement, "name"), - GetString(nodeElement, "label"), - GetString(nodeElement, "shortId"), - nodeId)!, + DisplayName = !string.IsNullOrWhiteSpace(explicitName) + ? explicitName! + : FirstNonEmpty(GetString(nodeElement, "shortId"), nodeId)!, + HasExplicitDisplayName = !string.IsNullOrWhiteSpace(explicitName), Mode = FirstNonEmpty( GetString(nodeElement, "mode"), - GetString(nodeElement, "clientMode"), + clientMode, "node")!, Status = status!, Platform = FirstNonEmpty( GetString(nodeElement, "platform"), GetString(nodeElement, "os")), - LastSeen = ParseUnixTimestampMs(nodeElement, "lastSeenAt") ?? + // Gateway NodeListNode wire schema uses *Ms suffix; older + // fallbacks kept for compatibility with mocks/tests. + // ConnectedAt is parsed independently below — do NOT fall + // back to it here, otherwise the UI shows the same value + // twice as both "Connected Xm ago" and "Seen Xm ago". + LastSeen = ParseUnixTimestampMs(nodeElement, "lastSeenAtMs") ?? + ParseUnixTimestampMs(nodeElement, "lastSeenAt") ?? ParseUnixTimestampMs(nodeElement, "lastSeen") ?? - ParseUnixTimestampMs(nodeElement, "updatedAt") ?? - ParseUnixTimestampMs(nodeElement, "connectedAt"), + ParseUnixTimestampMs(nodeElement, "updatedAt"), + ConnectedAt = ParseUnixTimestampMs(nodeElement, "connectedAtMs") ?? + ParseUnixTimestampMs(nodeElement, "connectedAt"), + ApprovedAt = ParseUnixTimestampMs(nodeElement, "approvedAtMs") ?? + ParseUnixTimestampMs(nodeElement, "approvedAt"), + LastSeenReason = GetString(nodeElement, "lastSeenReason"), Capabilities = capabilities.ToList(), Commands = commands.ToList(), + DisabledCommands = disabledCommands.ToList(), Permissions = permissions, CapabilityCount = capabilities.Length, CommandCount = commands.Length, + Version = GetString(nodeElement, "version"), + CoreVersion = GetString(nodeElement, "coreVersion"), + UiVersion = GetString(nodeElement, "uiVersion"), + ClientId = GetString(nodeElement, "clientId"), + ClientMode = clientMode, + DeviceFamily = GetString(nodeElement, "deviceFamily"), + ModelIdentifier = GetString(nodeElement, "modelIdentifier"), + RemoteIp = GetString(nodeElement, "remoteIp"), + PathEnv = GetString(nodeElement, "pathEnv"), + IsPaired = paired ?? false, IsOnline = online ?? connected ?? status is "ok" or "online" or "connected" or "ready" or "active" }; } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index ccc0cfcdf..43f4ceffb 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1383,18 +1383,21 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) } // ── Connected Devices with inline permission toggles ── - if (_lastNodes.Length > 0) + // Only show currently-connected nodes; offline/stale paired nodes + // remain visible on the full Nodes page where they can be renamed + // or forgotten. + var connectedNodes = _lastNodes.Where(n => n.IsOnline).ToArray(); + if (connectedNodes.Length > 0) { menu.AddSeparator(); - var onlineCount = _lastNodes.Count(n => n.IsOnline); - var totalCaps = _lastNodes.Sum(n => n.CapabilityCount); - var deviceSummaryRight = $"{onlineCount} online · {totalCaps} caps"; + var totalCaps = connectedNodes.Sum(n => n.CapabilityCount); + var deviceSummaryRight = $"{connectedNodes.Length} online · {totalCaps} caps"; menu.AddCustomElement(BuildSectionHeader("Devices", deviceSummaryRight)); var currentHost = Environment.MachineName; - foreach (var node in _lastNodes.Take(5)) + foreach (var node in connectedNodes.Take(5)) { var card = BuildDeviceCard(node); var flyoutItems = BuildDeviceFlyoutItems(node); diff --git a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs index 032cb7a9c..b52fce135 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs @@ -4,9 +4,12 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Shapes; using OpenClaw.Shared; +using OpenClawTray.Helpers; using OpenClawTray.Windows; using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer; using WinColor = global::Windows.UI.Color; @@ -15,6 +18,7 @@ namespace OpenClawTray.Pages; public sealed partial class NodesPage : Page { private HubWindow? _hub; + private readonly HashSet _nodesWithDialogOpen = new(StringComparer.Ordinal); public NodesPage() { @@ -48,24 +52,14 @@ public void UpdateNodes(GatewayNodeInfo[] nodes) } EmptyState.Visibility = Visibility.Collapsed; - var vms = new List(); - foreach (var n in nodes) - { - vms.Add(new NodeViewModel - { - Name = string.IsNullOrWhiteSpace(n.DisplayName) ? n.ShortId : n.DisplayName, - DeviceId = n.NodeId, - Platform = n.Platform ?? "unknown", - IsOnline = n.IsOnline, - Capabilities = Array.Empty(), - Commands = Array.Empty(), - }); - } - RenderNodes(vms); + // Render straight from the gateway model: Capabilities, Commands + // and Permissions are already populated by ParseNodeList and the + // card layout consumes them as-is. + RenderNodes(nodes); }); } - private void RenderNodes(List nodes) + private void RenderNodes(IReadOnlyList nodes) { NodesList.Children.Clear(); if (nodes.Count == 0) @@ -76,125 +70,656 @@ private void RenderNodes(List nodes) EmptyState.Visibility = Visibility.Collapsed; - foreach (var vm in nodes) + foreach (var node in nodes) { - var card = new Border + NodesList.Children.Add(BuildNodeCard(node)); + } + } + + private Border BuildNodeCard(GatewayNodeInfo node) + { + var card = new Border + { + Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], + BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + }; + + var expander = new Expander + { + IsExpanded = node.IsOnline, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + Header = BuildCardHeader(node), + Content = BuildCardDetails(node), + }; + + card.Child = expander; + return card; + } + + private static Grid BuildCardHeader(GatewayNodeInfo node) + { + // Header is purely informational: dot · name · platform badge · + // status caption. Actions live in the body footer (Win11 Settings + // pattern: see Settings → Accounts → Email & accounts → Manage / + // Remove buttons at the bottom). + // + // Use a Grid (not a horizontal StackPanel) so a long display name + // ellipsizes at the available width instead of pushing the platform + // badge and caption off the right edge. + var header = new Grid + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + ColumnSpacing = 10, + }; + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // dot + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // name (ellipsizes) + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // platform badge + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // status caption + + var dot = new Ellipse + { + Width = 10, + Height = 10, + VerticalAlignment = VerticalAlignment.Center, + Fill = node.IsOnline ? new SolidColorBrush(Colors.LimeGreen) : new SolidColorBrush(Colors.Gray), + }; + Grid.SetColumn(dot, 0); + header.Children.Add(dot); + + var nameLabel = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName; + var nameText = new TextBlock + { + Text = nameLabel, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + }; + ToolTipService.SetToolTip(nameText, nameLabel); + Grid.SetColumn(nameText, 1); + header.Children.Add(nameText); + + var platform = node.Platform ?? "unknown"; + var platformBadge = new Border + { + Background = new SolidColorBrush(GetPlatformColor(platform)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 2, 8, 2), + VerticalAlignment = VerticalAlignment.Center, + }; + platformBadge.Child = new TextBlock + { + Text = platform, + FontSize = 11, + Foreground = new SolidColorBrush(Colors.White), + }; + Grid.SetColumn(platformBadge, 2); + header.Children.Add(platformBadge); + + var detailText = new TextBlock + { + Text = node.DetailText, + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(detailText, 3); + header.Children.Add(detailText); + + return header; + } + + private StackPanel BuildCardDetails(GatewayNodeInfo node) + { + var stack = new StackPanel { Spacing = 10 }; + + // Short identity row + stack.Children.Add(BuildIdentityRow(node)); + + // Optional one-line fact rows. Each helper returns null when the + // backing data is empty so we don't render empty labels. + AppendIfNotNull(stack, BuildVersionRow(node)); + AppendIfNotNull(stack, BuildHardwareRow(node)); + AppendIfNotNull(stack, BuildNetworkRow(node)); + AppendIfNotNull(stack, BuildTimestampsRow(node)); + AppendIfNotNull(stack, BuildCapabilitiesSection(node)); + AppendIfNotNull(stack, BuildCommandsSection(node)); + AppendIfNotNull(stack, BuildPermissionsSection(node)); + AppendIfNotNull(stack, BuildPathEnvSection(node)); + + // Actions footer — separator line then right-aligned Rename/Forget. + // This mirrors how Win11 Settings places "Manage" / "Remove" actions + // at the bottom of an account or device card: they're visually + // separated from the informational content so destructive actions + // are deliberate, not accidental. + stack.Children.Add(BuildActionFooter(node)); + + return stack; + } + + private static void AppendIfNotNull(StackPanel stack, UIElement? element) + { + if (element != null) stack.Children.Add(element); + } + + private StackPanel BuildActionFooter(GatewayNodeInfo node) + { + var clientAvailable = _hub?.GatewayClient != null; + + var footer = new StackPanel + { + Spacing = 8, + Margin = new Thickness(0, 8, 0, 0), + }; + + // Subtle 1px divider above the actions. Uses the same stroke colour + // the card itself uses for its border so the line feels native. + footer.Children.Add(new Border + { + Height = 1, + Background = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], + HorizontalAlignment = HorizontalAlignment.Stretch, + }); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Right, + }; + + var renameBtn = new Button + { + Content = LocalizationHelper.GetString("NodesPage_Action_Rename"), + IsEnabled = clientAvailable, + MinWidth = 96, + }; + renameBtn.Click += async (s, e) => + { + try { await OnRenameClickedAsync(node); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Rename click failed: {ex}"); } + }; + actions.Children.Add(renameBtn); + + var forgetBtn = new Button + { + Content = LocalizationHelper.GetString("NodesPage_Action_Forget"), + IsEnabled = clientAvailable, + MinWidth = 96, + // Critical-coloured text marks destructive intent without + // turning the whole button red — destructive primary buttons + // are reserved for the confirmation dialog. + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + }; + forgetBtn.Click += async (s, e) => + { + try { await OnForgetClickedAsync(node); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Forget click failed: {ex}"); } + }; + actions.Children.Add(forgetBtn); + + footer.Children.Add(actions); + return footer; + } + + private Grid BuildIdentityRow(GatewayNodeInfo node) + { + // Use a Grid so a long node id (full GUIDs are 36+ chars) ellipsizes + // instead of pushing the copy button off-screen or out of the card. + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var idText = new TextBlock + { + Text = node.NodeId, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas, monospace"), + // Body text style so the id sits at the same visual weight as + // the rest of the card body (Version / Hardware / Network rows). + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.NoWrap, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 0, 8, 0), + }; + ToolTipService.SetToolTip(idText, node.NodeId); + Grid.SetColumn(idText, 0); + grid.Children.Add(idText); + + var copyBtn = new Button + { + Content = "📋", + Padding = new Thickness(6, 2, 6, 2), + Tag = node.NodeId, + VerticalAlignment = VerticalAlignment.Center, + }; + copyBtn.Click += OnCopyDeviceId; + Grid.SetColumn(copyBtn, 1); + grid.Children.Add(copyBtn); + return grid; + } + + private static FrameworkElement? BuildVersionRow(GatewayNodeInfo node) + { + var parts = new List(3); + if (!string.IsNullOrWhiteSpace(node.Version)) parts.Add(node.Version!); + if (!string.IsNullOrWhiteSpace(node.CoreVersion)) parts.Add($"core {node.CoreVersion}"); + if (!string.IsNullOrWhiteSpace(node.UiVersion)) parts.Add($"ui {node.UiVersion}"); + if (parts.Count == 0) return null; + return MakeLabeledRow( + LocalizationHelper.GetString("NodesPage_Label_Version"), + string.Join(" · ", parts)); + } + + private static FrameworkElement? BuildHardwareRow(GatewayNodeInfo node) + { + var parts = new List(2); + if (!string.IsNullOrWhiteSpace(node.DeviceFamily)) parts.Add(node.DeviceFamily!); + if (!string.IsNullOrWhiteSpace(node.ModelIdentifier)) parts.Add(node.ModelIdentifier!); + if (parts.Count == 0) return null; + return MakeLabeledRow( + LocalizationHelper.GetString("NodesPage_Label_Hardware"), + string.Join(" · ", parts)); + } + + private static FrameworkElement? BuildNetworkRow(GatewayNodeInfo node) + { + var parts = new List(3); + if (!string.IsNullOrWhiteSpace(node.RemoteIp)) parts.Add(node.RemoteIp!); + if (!string.IsNullOrWhiteSpace(node.ClientId)) parts.Add(node.ClientId!); + if (!string.IsNullOrWhiteSpace(node.ClientMode)) parts.Add(node.ClientMode!); + if (parts.Count == 0) return null; + return MakeLabeledRow( + LocalizationHelper.GetString("NodesPage_Label_Network"), + string.Join(" · ", parts)); + } + + private static FrameworkElement? BuildTimestampsRow(GatewayNodeInfo node) + { + var parts = new List(3); + if (node.ApprovedAt.HasValue) + parts.Add($"{LocalizationHelper.GetString("NodesPage_Label_Approved")} {FormatAge(node.ApprovedAt.Value)}"); + if (node.ConnectedAt.HasValue) + parts.Add($"{LocalizationHelper.GetString("NodesPage_Label_Connected")} {FormatAge(node.ConnectedAt.Value)}"); + if (node.LastSeen.HasValue) + { + var label = $"{LocalizationHelper.GetString("NodesPage_Label_LastSeen")} {FormatAge(node.LastSeen.Value)}"; + if (!string.IsNullOrWhiteSpace(node.LastSeenReason)) + label += $" ({node.LastSeenReason})"; + parts.Add(label); + } + if (parts.Count == 0) return null; + return MakeSingleLine(string.Join(" · ", parts)); + } + + private static StackPanel? BuildCapabilitiesSection(GatewayNodeInfo node) + { + if (node.Capabilities is not { Count: > 0 } caps) return null; + var section = new StackPanel { Spacing = 4 }; + section.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("NodesPage_Label_Capabilities"), + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + var wrap = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; + foreach (var cap in caps) + { + var badge = new Border { Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(16), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 3, 8, 3), }; + badge.Child = new TextBlock + { + Text = cap, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + }; + wrap.Children.Add(badge); + } + section.Children.Add(wrap); + return section; + } - var stack = new StackPanel { Spacing = 8 }; - - // Header: name + online dot - var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - header.Children.Add(new Ellipse + private static Expander? BuildCommandsSection(GatewayNodeInfo node) + { + if (node.Commands is not { Count: > 0 } cmds) return null; + var disabled = new HashSet(node.DisabledCommands ?? new List(), StringComparer.OrdinalIgnoreCase); + var expander = new Expander + { + Header = string.Format(LocalizationHelper.GetString("NodesPage_Commands_Header"), cmds.Count), + IsExpanded = false, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + }; + var stack = new StackPanel { Spacing = 2 }; + foreach (var cmd in cmds) + { + var isDisabled = disabled.Contains(cmd); + stack.Children.Add(new TextBlock { - Width = 10, Height = 10, - VerticalAlignment = VerticalAlignment.Center, - Fill = vm.IsOnline ? new SolidColorBrush(Colors.LimeGreen) : new SolidColorBrush(Colors.Gray), + Text = isDisabled + ? $" • {cmd}{LocalizationHelper.GetString("NodesPage_Command_DisabledSuffix")}" + : $" • {cmd}", + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources[ + isDisabled ? "TextFillColorTertiaryBrush" : "TextFillColorSecondaryBrush"], }); - header.Children.Add(new TextBlock + } + expander.Content = stack; + return expander; + } + + private static StackPanel? BuildPermissionsSection(GatewayNodeInfo node) + { + if (node.Permissions is not { Count: > 0 } perms) return null; + var section = new StackPanel { Spacing = 4 }; + section.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("NodesPage_Label_Permissions"), + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + foreach (var kv in perms) + { + section.Children.Add(new TextBlock { - Text = vm.Name, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - VerticalAlignment = VerticalAlignment.Center, + Text = $" {kv.Key}: {(kv.Value ? "✅" : "❌")}", + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], }); - stack.Children.Add(header); + } + return section; + } - // Platform badge - var platformBadge = new Border + private static Expander? BuildPathEnvSection(GatewayNodeInfo node) + { + if (string.IsNullOrWhiteSpace(node.PathEnv)) return null; + // PATH can contain usernames, network shares, build tool locations. + // Keep it collapsed by default so it doesn't reveal those at a glance. + var expander = new Expander + { + Header = LocalizationHelper.GetString("NodesPage_Label_PathEnv"), + IsExpanded = false, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + }; + expander.Content = new TextBlock + { + Text = node.PathEnv, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas, monospace"), + // PATH lines are very dense; staying at Caption keeps long + // entries readable without dominating the card. + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + return expander; + } + + private static Grid MakeLabeledRow(string label, string value) + { + // Use a Grid with star value column so long values wrap properly. A + // horizontal StackPanel gives children unbounded width, which makes + // TextWrapping=Wrap a no-op and lets long network/hardware strings + // overflow the card. + var grid = new Grid { ColumnSpacing = 6 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var labelText = new TextBlock + { + Text = label, + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + VerticalAlignment = VerticalAlignment.Top, + }; + Grid.SetColumn(labelText, 0); + grid.Children.Add(labelText); + + var valueText = new TextBlock + { + Text = value, + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + Grid.SetColumn(valueText, 1); + grid.Children.Add(valueText); + return grid; + } + + private static TextBlock MakeSingleLine(string value) + { + // No wrapping container needed: a TextBlock placed directly in a + // vertical StackPanel gets the full width and wraps cleanly. + return new TextBlock + { + Text = value, + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorTertiaryBrush"], + TextWrapping = TextWrapping.Wrap, + }; + } + + private static string FormatAge(DateTime utc) + { + // Single canonical age formatter shared with GatewayNodeInfo.DetailText + // (so a node never shows "1d ago" in one place and "36h ago" in + // another for the same timestamp). + return ModelFormatting.FormatAge(utc); + } + + private async Task OnRenameClickedAsync(GatewayNodeInfo node) + { + if (_hub?.GatewayClient is not { } client) return; + if (!_nodesWithDialogOpen.Add(node.NodeId)) return; + try + { + var input = new TextBox { - Background = new SolidColorBrush(GetPlatformColor(vm.Platform)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(8, 2, 8, 2), - HorizontalAlignment = HorizontalAlignment.Left, + // Pre-fill ONLY when the gateway gave us an explicit display + // name. If we fell back to the id (HasExplicitDisplayName= + // false), seeding the textbox with the id would cause Enter + // to persist that id as the new display name. + Text = node.HasExplicitDisplayName ? node.DisplayName : string.Empty, + MaxLength = 64, + AcceptsReturn = false, + SelectionStart = 0, + PlaceholderText = LocalizationHelper.GetString("NodesPage_Rename_Placeholder"), }; - platformBadge.Child = new TextBlock + input.SelectAll(); + + var errorBlock = new TextBlock { - Text = vm.Platform, - FontSize = 11, - Foreground = new SolidColorBrush(Colors.White), + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + FontSize = 12, + Visibility = Visibility.Collapsed, }; - stack.Children.Add(platformBadge); - // Device ID with copy button - var idRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - var shortId = vm.DeviceId.Length > 16 ? vm.DeviceId[..16] + "…" : vm.DeviceId; - idRow.Children.Add(new TextBlock + var content = new StackPanel { Spacing = 8 }; + content.Children.Add(new TextBlock { - Text = shortId, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, + Text = string.Format( + LocalizationHelper.GetString("NodesPage_Rename_Body"), + string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName), + TextWrapping = TextWrapping.Wrap, }); - var copyBtn = new Button + content.Children.Add(input); + content.Children.Add(errorBlock); + + var dialog = new ContentDialog { - Content = "📋", - Padding = new Thickness(4, 2, 4, 2), - Tag = vm.DeviceId, - FontSize = 11, + Title = LocalizationHelper.GetString("NodesPage_Rename_Title"), + Content = content, + PrimaryButtonText = LocalizationHelper.GetString("NodesPage_Rename_Primary"), + CloseButtonText = LocalizationHelper.GetString("NodesPage_Common_Cancel"), + // Rename is non-destructive — Enter should confirm. (Forget + // is destructive and intentionally keeps Close as default.) + DefaultButton = ContentDialogButton.Primary, + XamlRoot = this.XamlRoot, }; - copyBtn.Click += OnCopyDeviceId; - idRow.Children.Add(copyBtn); - stack.Children.Add(idRow); - // Capabilities as tags - if (vm.Capabilities.Length > 0) + dialog.PrimaryButtonClick += async (s, args) => { - stack.Children.Add(new TextBlock - { - Text = "Capabilities", - FontSize = 12, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - Margin = new Thickness(0, 4, 0, 0), - }); - var capWrap = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; - foreach (var cap in vm.Capabilities) + var deferral = args.GetDeferral(); + try { - var badge = new Border + var newName = input.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(newName)) + { + errorBlock.Text = LocalizationHelper.GetString("NodesPage_Rename_Error_Empty"); + errorBlock.Visibility = Visibility.Visible; + args.Cancel = true; + return; + } + // Don't short-circuit on "name unchanged" — our local + // node.DisplayName might be stale (another operator may + // have renamed in the interim). Always send to the + // gateway and let it decide. + + s.IsPrimaryButtonEnabled = false; + input.IsEnabled = false; + errorBlock.Visibility = Visibility.Collapsed; + + var result = await client.NodeRenameAsync(node.NodeId, newName); + if (!result.Success) { - Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], - BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 2, 6, 2), - }; - badge.Child = new TextBlock { Text = cap, FontSize = 11 }; - capWrap.Children.Add(badge); + errorBlock.Text = result.ErrorMessage ?? LocalizationHelper.GetString("NodesPage_Rename_Error_Generic"); + errorBlock.Visibility = Visibility.Visible; + s.IsPrimaryButtonEnabled = true; + input.IsEnabled = true; + args.Cancel = true; + return; + } + + // Gateway does not broadcast rename; trigger an explicit + // list refresh so this card reflects the new display name + // (and any other state that changed in the meantime). + _ = client.RequestNodesAsync(); } - stack.Children.Add(capWrap); - } + finally + { + deferral.Complete(); + } + }; + + await dialog.ShowAsync(); + } + finally + { + _nodesWithDialogOpen.Remove(node.NodeId); + } + } - // Commands (collapsed by default in an Expander) - if (vm.Commands.Length > 0) + private async Task OnForgetClickedAsync(GatewayNodeInfo node) + { + if (_hub?.GatewayClient is not { } client) return; + if (!_nodesWithDialogOpen.Add(node.NodeId)) return; + try + { + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(new TextBlock { - var expander = new Expander - { - Header = $"Commands ({vm.Commands.Length})", - IsExpanded = false, - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch, - }; - var cmdStack = new StackPanel { Spacing = 2 }; - foreach (var cmd in vm.Commands) + Text = LocalizationHelper.GetString("NodesPage_Forget_Body"), + TextWrapping = TextWrapping.Wrap, + }); + // Surface the identity prominently so the user is forgetting the + // node they think they're forgetting. + var identity = new StackPanel { Spacing = 2, Margin = new Thickness(0, 4, 0, 4) }; + identity.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + var subtitle = new List(2); + if (!string.IsNullOrWhiteSpace(node.Platform)) subtitle.Add(node.Platform!); + subtitle.Add(node.ShortId); + identity.Children.Add(new TextBlock + { + Text = string.Join(" · ", subtitle), + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + FontSize = 12, + }); + body.Children.Add(identity); + body.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("NodesPage_Forget_Warning"), + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + + var errorBlock = new TextBlock + { + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + FontSize = 12, + Visibility = Visibility.Collapsed, + TextWrapping = TextWrapping.Wrap, + }; + body.Children.Add(errorBlock); + + var dialog = new ContentDialog + { + Title = LocalizationHelper.GetString("NodesPage_Forget_Title"), + Content = body, + PrimaryButtonText = LocalizationHelper.GetString("NodesPage_Forget_Primary"), + CloseButtonText = LocalizationHelper.GetString("NodesPage_Common_Cancel"), + // Cancel is the default so pressing Enter does NOT confirm a + // destructive action. + DefaultButton = ContentDialogButton.Close, + XamlRoot = this.XamlRoot, + }; + dialog.PrimaryButtonStyle = (Style?)Application.Current.Resources["AccentButtonStyle"]; + + // Use the deferral pattern so we can keep the dialog open and + // surface an inline error when NodePairRemoveAsync reports + // failure (disconnected, missing scope, unknown nodeId). On + // success the dialog closes and the gateway's + // node.pair.resolved broadcast triggers a node.list refresh. + dialog.PrimaryButtonClick += async (s, args) => + { + var deferral = args.GetDeferral(); + try { - cmdStack.Children.Add(new TextBlock + s.IsPrimaryButtonEnabled = false; + errorBlock.Visibility = Visibility.Collapsed; + + var result = await client.NodePairRemoveAsync(node.NodeId); + if (!result.Success) { - Text = $" • {cmd}", - FontSize = 12, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - }); + // Surface the actual gateway error message when we + // have one (e.g. "missing scope: operator.pairing"), + // falling back to a generic message for unrecognised + // failures so non-English locales never see a raw + // English string from the server. + errorBlock.Text = result.ErrorMessage + ?? LocalizationHelper.GetString("NodesPage_Forget_Error_Generic"); + errorBlock.Visibility = Visibility.Visible; + s.IsPrimaryButtonEnabled = true; + args.Cancel = true; + } + // On success: dialog closes; gateway's node.pair.resolved + // broadcast triggers a node.list refresh. } - expander.Content = cmdStack; - stack.Children.Add(expander); - } + finally + { + deferral.Complete(); + } + }; - card.Child = stack; - NodesList.Children.Add(card); + await dialog.ShowAsync(); + } + finally + { + _nodesWithDialogOpen.Remove(node.NodeId); } } @@ -223,16 +748,6 @@ private void OnCopyDeviceId(object sender, RoutedEventArgs e) _ => WinColor.FromArgb(255, 128, 128, 128), }; - public class NodeViewModel - { - public string Name { get; set; } = ""; - public string DeviceId { get; set; } = ""; - public string Platform { get; set; } = ""; - public bool IsOnline { get; set; } - public string[] Capabilities { get; set; } = Array.Empty(); - public string[] Commands { get; set; } = Array.Empty(); - } - public void UpdatePairingRequests(PairingListInfo data) { PairingList.Children.Clear(); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index b56819566..6923d1c17 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1925,6 +1925,84 @@ On your gateway host (Mac/Linux), run: 🌐 Connected Clients + + Rename + + + Forget + + + More options + + + Version + + + Hardware + + + Network + + + Approved + + + Connected + + + Seen + + + Capabilities + + + Permissions + + + PATH + + + Commands ({0}) + + + (disabled) + + + Rename node + + + Pick a new display name for {0}. + + + Display name + + + Rename + + + Display name cannot be empty. + + + Rename failed. + + + Forget node? + + + This will remove the gateway's record of the node below. The node will need to re-pair before it can reconnect. + + + Active operator and chat sessions targeting this node will lose access immediately. + + + Forget node + + + Could not forget node — gateway not reachable or permission denied. + + + Cancel + 🔐 Permissions diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 7aea33289..8d52d0270 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1926,6 +1926,84 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : 🌐 Clients connectés + + Renommer + + + Oublier + + + Plus d'options + + + Version + + + Hardware + + + Réseau + + + Approuvé + + + Connecté + + + Vu + + + Capacités + + + Autorisations + + + PATH + + + Commandes ({0}) + + + (désactivée) + + + Renommer le nœud + + + Choisissez un nouveau nom d'affichage pour {0}. + + + Nom d'affichage + + + Renommer + + + Le nom d'affichage ne peut pas être vide. + + + Échec du renommage. + + + Oublier le nœud ? + + + Cela supprimera l'enregistrement du nœud ci-dessous dans la passerelle. Le nœud devra se réappairer pour pouvoir se reconnecter. + + + Les sessions opérateur et de chat actives qui ciblent ce nœud perdront l'accès immédiatement. + + + Oublier le nœud + + + Impossible d'oublier le nœud — passerelle inaccessible ou autorisation refusée. + + + Annuler + 🔐 Autorisations diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 91ce6bf82..335dd0331 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1927,6 +1927,84 @@ Voer op uw gateway-host (Mac/Linux) uit: 🌐 Verbonden clients + + Hernoemen + + + Vergeten + + + Meer opties + + + Version + + + Hardware + + + Netwerk + + + Goedgekeurd + + + Verbonden + + + Gezien + + + Mogelijkheden + + + Machtigingen + + + PATH + + + Opdrachten ({0}) + + + (uitgeschakeld) + + + Knooppunt hernoemen + + + Kies een nieuwe weergavenaam voor {0}. + + + Weergavenaam + + + Hernoemen + + + Weergavenaam mag niet leeg zijn. + + + Hernoemen mislukt. + + + Knooppunt vergeten? + + + Dit verwijdert de registratie van het onderstaande knooppunt bij de gateway. Het knooppunt moet opnieuw worden gekoppeld voordat het opnieuw verbinding kan maken. + + + Actieve operator- en chatsessies die op dit knooppunt zijn gericht, verliezen onmiddellijk toegang. + + + Knooppunt vergeten + + + Knooppunt vergeten mislukt — gateway niet bereikbaar of toestemming geweigerd. + + + Annuleren + 🔐 Machtigingen diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 5d624c0e4..db06908e9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1926,6 +1926,84 @@ 🌐 已连接的客户端 + + 重命名 + + + 忘记 + + + 更多选项 + + + Version + + + Hardware + + + 网络 + + + 已批准 + + + 已连接 + + + 上次见到 + + + 功能 + + + 权限 + + + PATH + + + 命令 ({0}) + + + (已禁用) + + + 重命名节点 + + + 为 {0} 选择新的显示名称。 + + + 显示名称 + + + 重命名 + + + 显示名称不能为空。 + + + 重命名失败。 + + + 忘记节点? + + + 这将从网关中删除下面节点的记录。节点需要重新配对才能再次连接。 + + + 针对该节点的活动操作员和聊天会话将立即失去访问权限。 + + + 忘记节点 + + + 无法忘记节点 — 网关不可达或权限被拒绝。 + + + 取消 + 🔐 权限 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index c761108b9..a57708652 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1926,6 +1926,84 @@ 🌐 已連線的用戶端 + + 重新命名 + + + 忘記 + + + 更多選項 + + + Version + + + Hardware + + + 網路 + + + 已核准 + + + 已連線 + + + 上次看到 + + + 功能 + + + 權限 + + + PATH + + + 命令 ({0}) + + + (已停用) + + + 重新命名節點 + + + 為 {0} 選擇新的顯示名稱。 + + + 顯示名稱 + + + 重新命名 + + + 顯示名稱不能為空。 + + + 重新命名失敗。 + + + 忘記節點? + + + 這將從閘道中刪除下面節點的記錄。節點需要重新配對才能再次連線。 + + + 針對該節點的作用中操作員和聊天工作階段將立即失去存取權。 + + + 忘記節點 + + + 無法忘記節點 — 閘道無法連線或權限遭拒。 + + + 取消 + 🔐 權限 diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index d971cc59b..35f49d49e 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -1288,6 +1288,202 @@ public void ParseNodeListPayload_SkipsItemsWithNoNodeId() Assert.Equal("valid-node", nodes[0].NodeId); } + [Fact] + public void ParseNodeListPayload_PopulatesAllNodeListNodeFields() + { + // Mirrors the full NodeListNode schema from openclaw/openclaw + // src/shared/node-list-types.ts so we don't lose data the gateway + // already sends. Uses the production *Ms timestamp names. + var helper = new GatewayClientTestHelper(); + var nodes = helper.ParseNodeListPayload(""" + { + "nodes": [ + { + "nodeId": "node-rich", + "displayName": "Rich Node", + "platform": "windows", + "mode": "node", + "status": "connected", + "version": "v2026.5.7", + "coreVersion": "1.2.3", + "uiVersion": "4.5.6", + "clientId": "client-abc", + "clientMode": "operator-node", + "remoteIp": "192.168.1.42", + "deviceFamily": "desktop", + "modelIdentifier": "Surface-Pro-X", + "pathEnv": "C:\\Windows;C:\\tools", + "caps": ["camera", "screen"], + "commands": ["system.run"], + "disabledCommands": ["camera.recordVideo"], + "permissions": { "screen.record": true, "camera.snap": false }, + "paired": true, + "connected": true, + "connectedAtMs": 1739760000000, + "lastSeenAtMs": 1739760123456, + "lastSeenReason": "heartbeat", + "approvedAtMs": 1739700000000 + } + ] + } + """); + + Assert.Single(nodes); + var n = nodes[0]; + Assert.Equal("node-rich", n.NodeId); + Assert.Equal("Rich Node", n.DisplayName); + Assert.Equal("v2026.5.7", n.Version); + Assert.Equal("1.2.3", n.CoreVersion); + Assert.Equal("4.5.6", n.UiVersion); + Assert.Equal("client-abc", n.ClientId); + Assert.Equal("operator-node", n.ClientMode); + Assert.True(n.HasExplicitDisplayName); + Assert.Equal("192.168.1.42", n.RemoteIp); + Assert.Equal("desktop", n.DeviceFamily); + Assert.Equal("Surface-Pro-X", n.ModelIdentifier); + Assert.Equal("C:\\Windows;C:\\tools", n.PathEnv); + Assert.Equal(["camera", "screen"], n.Capabilities); + Assert.Equal(["system.run"], n.Commands); + Assert.Equal(["camera.recordVideo"], n.DisabledCommands); + Assert.True(n.IsPaired); + Assert.True(n.IsOnline); + Assert.True(n.Permissions["screen.record"]); + Assert.False(n.Permissions["camera.snap"]); + Assert.Equal("heartbeat", n.LastSeenReason); + + // Timestamps come from *Ms wire names + Assert.NotNull(n.ConnectedAt); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739760000000).UtcDateTime, + n.ConnectedAt!.Value); + Assert.NotNull(n.ApprovedAt); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739700000000).UtcDateTime, + n.ApprovedAt!.Value); + Assert.NotNull(n.LastSeen); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739760123456).UtcDateTime, + n.LastSeen!.Value); + } + + [Fact] + public void ParseNodeListPayload_AcceptsLegacyLastSeenAtWireName() + { + // Older mocks / non-gateway producers may emit lastSeenAt (no Ms suffix). + // The parser keeps that path as a fallback so existing fixtures keep + // working after we add the *Ms primary names. + var helper = new GatewayClientTestHelper(); + var nodes = helper.ParseNodeListPayload(""" + { + "nodes": [ + { + "nodeId": "legacy", + "status": "connected", + "lastSeenAt": 1739760000000 + } + ] + } + """); + + Assert.Single(nodes); + Assert.NotNull(nodes[0].LastSeen); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739760000000).UtcDateTime, + nodes[0].LastSeen!.Value); + } + + [Fact] + public void ParseNodeListPayload_DefaultsForMinimalPayload() + { + // A node entry with only nodeId must still parse without throwing + // and the new optional fields must default to null / false / empty. + var helper = new GatewayClientTestHelper(); + var nodes = helper.ParseNodeListPayload(""" + { + "nodes": [ { "nodeId": "bare" } ] + } + """); + + Assert.Single(nodes); + var n = nodes[0]; + Assert.Null(n.Version); + Assert.Null(n.CoreVersion); + Assert.Null(n.UiVersion); + Assert.Null(n.ClientId); + Assert.Null(n.ClientMode); + Assert.Null(n.DeviceFamily); + Assert.Null(n.ModelIdentifier); + Assert.Null(n.RemoteIp); + Assert.Null(n.PathEnv); + Assert.Null(n.ConnectedAt); + Assert.Null(n.ApprovedAt); + Assert.Null(n.LastSeen); + Assert.Null(n.LastSeenReason); + Assert.False(n.IsPaired); + Assert.False(n.HasExplicitDisplayName); + Assert.Empty(n.DisabledCommands); + } + + [Fact] + public async Task NodeRenameAsync_RejectsEmptyNodeId_WithoutHittingTransport() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodeRenameAsync("", "New Name"); + + Assert.False(result.Success); + Assert.Equal("nodeId required", result.ErrorMessage); + } + + [Fact] + public async Task NodeRenameAsync_RejectsEmptyDisplayName_WithoutHittingTransport() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodeRenameAsync("node-1", " "); + + Assert.False(result.Success); + Assert.Equal("displayName required", result.ErrorMessage); + } + + [Fact] + public async Task NodeRenameAsync_ReturnsErrorWhenNotConnected() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodeRenameAsync("node-1", "Pretty Name"); + + Assert.False(result.Success); + Assert.Equal("Gateway connection is not open", result.ErrorMessage); + } + + [Fact] + public async Task NodePairRemoveAsync_ReturnsFailureForEmptyNodeId() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodePairRemoveAsync(""); + + Assert.False(result.Success); + Assert.Equal("nodeId required", result.ErrorMessage); + } + + [Fact] + public async Task NodePairRemoveAsync_ReturnsFailureWhenNotConnected() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodePairRemoveAsync("node-1"); + + Assert.False(result.Success); + Assert.Equal("Gateway connection is not open", result.ErrorMessage); + } + [Fact] public void Constructor_InitializesWithProvidedValues() { diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 824a99f44..ebf2dd11e 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -36,6 +36,14 @@ public class LocalizationValidationTests // Sample IDs / brand identifiers — same across locales. "VoiceSettingsPage_ElevenLabsVoiceIdBox.PlaceholderText", "VoiceSettingsPage_ElevenLabsModelBox.PlaceholderText", + // NodesPage detail labels — "Version", "Hardware", and "PATH" are + // technical loanwords that read the same in every supported locale + // in this app's audience. Translating them adds no clarity and + // mixing scripts in a single detail row reads worse than keeping + // the English label. + "NodesPage_Label_Version", + "NodesPage_Label_Hardware", + "NodesPage_Label_PathEnv", }; private static readonly string[] RequiredRuntimeOnboardingKeys = diff --git a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs index 8237757c2..8e09af30b 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs @@ -181,6 +181,10 @@ public void SetPreferStructuredCategories(bool value) { } public Task RequestNodePairListAsync() => Task.CompletedTask; public Task NodePairApproveAsync(string requestId) => Task.FromResult(false); public Task NodePairRejectAsync(string requestId) => Task.FromResult(false); + public Task NodePairRemoveAsync(string nodeId) => + Task.FromResult(new NodeForgetResult(false, "stub")); + public Task NodeRenameAsync(string nodeId, string displayName) => + Task.FromResult(new NodeRenameResult(false, ErrorMessage: "stub")); public Task RequestDevicePairListAsync() => Task.CompletedTask; public Task DevicePairApproveAsync(string requestId) => Task.FromResult(false); public Task DevicePairRejectAsync(string requestId) => Task.FromResult(false); From 562cf7bf8f92bc4a2678918772d2fb1ea504d21b Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Tue, 12 May 2026 20:26:00 -0700 Subject: [PATCH 2/2] fix(nodes): address PR #324 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues raised in the PR review: 1. Page-wide ContentDialog reentrancy. WinUI 3 only allows a single ContentDialog per XamlRoot, so the per-node `_nodesWithDialogOpen` HashSet did not protect against Rename-on-A then Forget-on-B fast paths — the second `ShowAsync` would throw and be swallowed by the click-handler catch. Replaced with a page-wide `_dialogOpen` bool, matching the convention `SandboxPage._confirmDialogOpen` already uses. 2. Rename TextBox selection. `input.SelectAll()` ran before the box was attached to the visual tree, so the pre-filled name was never actually selected. Moved focus+select-all into the `Loaded` event so it fires after the dialog inserts the TextBox. 3. Raw `ex.Message` leaking into the UI. NodeRenameAsync and NodePairRemoveAsync used to return whatever the catch block saw, including internal transport/timeout exception text. Split the catch by type: `InvalidOperationException` (the gateway's ok=false ack) is still surfaced verbatim because the messages are actionable (e.g. "missing scope: operator.pairing"); every other exception now returns a null ErrorMessage so the dialog falls back to its localized generic string instead of showing internal text. Also removed the AccentButtonStyle on the Forget confirmation primary button. The destructive primary should not be styled as a call-to- action; Cancel remains the default focus. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/OpenClawGatewayClient.cs | 26 ++++++++++++++-- .../Pages/NodesPage.xaml.cs | 30 ++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 3db6c37a7..c786be0fd 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -597,10 +597,21 @@ public async Task NodePairRemoveAsync(string nodeId) await SendWizardRequestAsync("node.pair.remove", new { nodeId }); return new NodeForgetResult(true); } + catch (InvalidOperationException ex) + { + // Gateway business error (e.g. "missing scope: operator.pairing", + // "unknown nodeId"). Surface this verbatim so the user sees an + // actionable message. + _logger.Warn($"node.pair.remove rejected: {ex.Message}"); + return new NodeForgetResult(false, ex.Message); + } catch (Exception ex) { + // Transport / timeout / unexpected exception. Don't leak raw + // exception text into the UI — return null so the caller uses + // its localized fallback string. _logger.Warn($"node.pair.remove failed: {ex.Message}"); - return new NodeForgetResult(false, ex.Message); + return new NodeForgetResult(false, ErrorMessage: null); } } @@ -637,10 +648,21 @@ public async Task NodeRenameAsync(string nodeId, string displa : trimmed; return new NodeRenameResult(true, returnedNodeId, returnedDisplayName); } + catch (InvalidOperationException ex) + { + // Gateway business error (e.g. "missing scope: operator.pairing", + // "unknown nodeId"). Surface this verbatim so the user sees an + // actionable message. + _logger.Warn($"node.rename rejected: {ex.Message}"); + return new NodeRenameResult(false, ErrorMessage: ex.Message); + } catch (Exception ex) { + // Transport / timeout / unexpected exception. Don't leak raw + // exception text into the UI — return null so the caller uses + // its localized fallback string. _logger.Warn($"node.rename failed: {ex.Message}"); - return new NodeRenameResult(false, ErrorMessage: ex.Message); + return new NodeRenameResult(false, ErrorMessage: null); } } diff --git a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs index b52fce135..750826f75 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs @@ -18,7 +18,12 @@ namespace OpenClawTray.Pages; public sealed partial class NodesPage : Page { private HubWindow? _hub; - private readonly HashSet _nodesWithDialogOpen = new(StringComparer.Ordinal); + // Page-wide guard for ContentDialog reentrancy. WinUI 3 only permits one + // ContentDialog per XamlRoot at a time, so a per-node guard is not enough + // (Rename on node A and Forget on node B in quick succession would + // otherwise throw inside the second ShowAsync). Match the convention used + // by SandboxPage's _confirmDialogOpen field. + private bool _dialogOpen; public NodesPage() { @@ -520,7 +525,8 @@ private static string FormatAge(DateTime utc) private async Task OnRenameClickedAsync(GatewayNodeInfo node) { if (_hub?.GatewayClient is not { } client) return; - if (!_nodesWithDialogOpen.Add(node.NodeId)) return; + if (_dialogOpen) return; + _dialogOpen = true; try { var input = new TextBox @@ -535,7 +541,13 @@ private async Task OnRenameClickedAsync(GatewayNodeInfo node) SelectionStart = 0, PlaceholderText = LocalizationHelper.GetString("NodesPage_Rename_Placeholder"), }; - input.SelectAll(); + // Focus + select-all only after the TextBox is actually attached + // to the visual tree; calling these before Loaded is a no-op. + input.Loaded += (_, _) => + { + input.Focus(FocusState.Programmatic); + input.SelectAll(); + }; var errorBlock = new TextBlock { @@ -615,14 +627,15 @@ private async Task OnRenameClickedAsync(GatewayNodeInfo node) } finally { - _nodesWithDialogOpen.Remove(node.NodeId); + _dialogOpen = false; } } private async Task OnForgetClickedAsync(GatewayNodeInfo node) { if (_hub?.GatewayClient is not { } client) return; - if (!_nodesWithDialogOpen.Add(node.NodeId)) return; + if (_dialogOpen) return; + _dialogOpen = true; try { var body = new StackPanel { Spacing = 8 }; @@ -673,11 +686,12 @@ private async Task OnForgetClickedAsync(GatewayNodeInfo node) PrimaryButtonText = LocalizationHelper.GetString("NodesPage_Forget_Primary"), CloseButtonText = LocalizationHelper.GetString("NodesPage_Common_Cancel"), // Cancel is the default so pressing Enter does NOT confirm a - // destructive action. + // destructive action. Leaving the primary button at its + // default style (no accent fill) keeps the destructive label + // visually muted — Cancel remains the recommended action. DefaultButton = ContentDialogButton.Close, XamlRoot = this.XamlRoot, }; - dialog.PrimaryButtonStyle = (Style?)Application.Current.Resources["AccentButtonStyle"]; // Use the deferral pattern so we can keep the dialog open and // surface an inline error when NodePairRemoveAsync reports @@ -719,7 +733,7 @@ private async Task OnForgetClickedAsync(GatewayNodeInfo node) } finally { - _nodesWithDialogOpen.Remove(node.NodeId); + _dialogOpen = false; } }