From 98c7ea5d0d5a479e74cae333eabf5cbfaa2d789b Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Wed, 8 Apr 2026 16:34:46 +0200 Subject: [PATCH 1/3] chore: gitignore internal fork workflow files --- .gitignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitignore b/.gitignore index 6b3d49e7..b42ef02c 100644 --- a/.gitignore +++ b/.gitignore @@ -344,3 +344,24 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd Output/ + +# Internal fork workflow docs — never reach upstream PRs +CLAUDE.md +AGENTS.md +STYLE.md +docs/PR_WORKFLOW.md +docs/CONTRIBUTING.md +PLAN_*.md + +# Internal scratch and analysis files +diff.txt +merge-analysis.txt +*.diff +pr*.txt + +# Development diary — private, never reaches upstream +DIARY.md + +# Internal tooling state +.claude/ +.serena/ From 565fba8feffa655aa5229fab99b0227ccdfb742f Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Fri, 15 May 2026 20:07:23 +0200 Subject: [PATCH 2/3] refactor(tray): extract TrayMenuStateBuilder from App.xaml.cs (~1,300 lines) Move all Build* tray menu helpers into TrayMenuStateBuilder, backed by an immutable TrayMenuSnapshot captured under lock before each rebuild. App.xaml.cs delegates entirely via CaptureTrayMenuSnapshot() + builder.Build(). Also adds a self-marshal guard to UpdateTrayIcon() so gateway/node callbacks are safe to call from any thread without a separate OnUiThread wrapper. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 1346 +---------------- .../Services/TrayMenuSnapshot.cs | 35 + .../Services/TrayMenuStateBuilder.cs | 1337 ++++++++++++++++ 3 files changed, 1412 insertions(+), 1306 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 82ebea15..a6497faa 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1,4 +1,4 @@ -using Microsoft.Toolkit.Uwp.Notifications; +using Microsoft.Toolkit.Uwp.Notifications; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; @@ -1211,9 +1211,6 @@ private async Task ConfirmSessionActionAsync(string title, string body, st return result == ContentDialogResult.Primary; } - private static string TruncateMenuText(string text, int maxLength = 96) => - MenuDisplayHelper.TruncateText(text, maxLength); - private void AddRecentActivity( string line, string category = "general", @@ -1242,13 +1239,22 @@ private List GetRecentActivity(int maxItems) private void BuildTrayMenuPopup(TrayMenuWindow menu) { + // Preview data must be applied before snapshot capture so the injected + // values are visible to the builder without coupling it to App state. + ApplyTrayMenuPreviewDataIfRequested(); + var snapshot = CaptureTrayMenuSnapshot(); + var callbacks = new TrayMenuCallbacks( + DispatchAction: action => OnTrayMenuItemClicked(null, action), + SaveAndReconnect: () => { _settings?.Save(); _ = _connectionManager?.ReconnectAsync(); }); + var builder = new TrayMenuStateBuilder(snapshot, _permToggleActions, callbacks); + // Render the whole menu inside a single update batch so layout // measures only once instead of once-per-row. Pair with EndUpdate // in finally so an exception mid-build doesn't wedge layout. menu.BeginUpdate(); try { - BuildTrayMenuPopupCore(menu); + builder.Build(menu); } finally { @@ -1256,300 +1262,38 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) } } - private void BuildTrayMenuPopupCore(TrayMenuWindow menu) + private TrayMenuSnapshot CaptureTrayMenuSnapshot() { - // Stale closures from the previous build hold references to old - // ToggleAction delegates; recreate the lookup each rebuild. - _permToggleActions.Clear(); - - ApplyTrayMenuPreviewDataIfRequested(); - - var isConnected = _currentStatus == ConnectionStatus.Connected; - var statusText = LocalizationHelper.GetConnectionStatusText(_currentStatus); - - // Cache theme brushes once per build so cells don't each do a - // resource lookup. The previous implementation looked up - // SystemFill/Text brushes per row, which contributed to the - // visible right-click hitch. - var resources = Application.Current.Resources; - var successBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorSuccessBrush"]; - var cautionBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCautionBrush"]; - var neutralBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorNeutralBrush"]; - var criticalBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCriticalBrush"]; - var secondaryText = (Microsoft.UI.Xaml.Media.Brush)resources["TextFillColorSecondaryBrush"]; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - var controlSecondaryFill = (Microsoft.UI.Xaml.Media.Brush)resources["ControlFillColorSecondaryBrush"]; - - // ── Brand Header with Disconnect/Connect on the right ── - var brandGrid = new Grid - { - Padding = new Thickness(14, 10, 14, 8), - ColumnSpacing = 8 - }; - brandGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - brandGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - brandGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var brandRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - VerticalAlignment = VerticalAlignment.Center, - Children = - { - new Microsoft.UI.Xaml.Controls.Image - { - Source = new BitmapImage(new Uri("ms-appx:///Assets/Square44x44Logo.targetsize-48_altform-unplated.png")), - Width = 28, - Height = 28, - VerticalAlignment = VerticalAlignment.Center - }, - new TextBlock - { - Text = "OpenClaw", - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 18, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - } - } - }; - Grid.SetColumn(brandRow, 0); - brandGrid.Children.Add(brandRow); - - var brandBtn = new Button - { - Content = isConnected ? "Disconnect" : "Connect", - VerticalAlignment = VerticalAlignment.Center, - Padding = new Thickness(12, 4, 12, 4), - MinHeight = 0, - MinWidth = 0, - FontSize = 12 - }; - AutomationProperties.SetName(brandBtn, isConnected ? "Disconnect from gateway" : "Connect to gateway"); - ToolTipService.SetToolTip(brandBtn, isConnected ? "Disconnect from gateway" : "Connect to gateway"); - brandBtn.Click += (s, ev) => - { - _trayMenuWindow?.HideCascade(); - OnTrayMenuItemClicked(this, isConnected ? "disconnect" : "reconnect"); - }; - Grid.SetColumn(brandBtn, 2); - brandGrid.Children.Add(brandBtn); - - menu.AddCustomElement(brandGrid); - - // ── Pairing approval pending (high-priority action above Gateway) ── - var nodePendingCount = _lastNodePairList?.Pending.Count ?? 0; - var devicePendingCount = _lastDevicePairList?.Pending.Count ?? 0; - if (nodePendingCount + devicePendingCount > 0) - { - var total = nodePendingCount + devicePendingCount; - menu.AddMenuItem( - $"Pairing approval pending ({total})", - FluentIconCatalog.Build(FluentIconCatalog.Approvals), - "hub"); - } - - // ── Gateway Section ── - // (device-card format) - var gwOuter = new StackPanel - { - Padding = new Thickness(12, 8, 12, 8), - Spacing = 2, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - - // ── Line 1: dot + "Gateway" + Local chip ── - var gwLine1 = new Grid { ColumnSpacing = 6 }; - gwLine1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - gwLine1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - gwLine1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var gwNameRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 6, - VerticalAlignment = VerticalAlignment.Center - }; - gwNameRow.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = isConnected ? successBrush - : _currentStatus == ConnectionStatus.Connecting ? cautionBrush - : neutralBrush - }); - gwNameRow.Children.Add(new TextBlock - { - Text = "Gateway", - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 13, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - Grid.SetColumn(gwNameRow, 0); - gwLine1.Children.Add(gwNameRow); - - // Right-side: optional chip on the header line (Disconnect lives in brand header) - string? chipText = null; - var gwUrl = _settings?.GetEffectiveGatewayUrl(); - Uri? gwUri = null; - if (!string.IsNullOrEmpty(gwUrl)) Uri.TryCreate(gwUrl, UriKind.Absolute, out gwUri); - if (isConnected) - { - if (gwUri != null && (gwUri.Host == "localhost" || gwUri.Host == "127.0.0.1" || gwUri.Host == "::1")) - chipText = "Local"; - else if (_lastGatewaySelf != null && !string.IsNullOrEmpty(_lastGatewaySelf.ServerVersion)) - chipText = $"v{_lastGatewaySelf.ServerVersion}"; - } - if (chipText != null) - { - var chip = new Border - { - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 1, 6, 1), - Background = controlSecondaryFill, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Right, - Child = new TextBlock - { - Text = chipText, - FontSize = 10, - Foreground = secondaryText, - IsTextSelectionEnabled = false - } - }; - Grid.SetColumn(chip, 2); - gwLine1.Children.Add(chip); - } - gwOuter.Children.Add(gwLine1); - - // ── Line 2: secondary details ── - var gwLine2Parts = new List(); - if (gwUri != null) gwLine2Parts.Add($"{gwUri.Host}:{gwUri.Port}"); - gwLine2Parts.Add(statusText.ToLowerInvariant()); - if (isConnected && _lastPresence != null && _lastPresence.Length > 0) - gwLine2Parts.Add($"{_lastPresence.Length} client{(_lastPresence.Length != 1 ? "s" : "")}"); - if (_settings?.EnableNodeMode == true && _nodeService != null) - { - if (_nodeService.IsPaired) gwLine2Parts.Add("node paired"); - else if (_nodeService.IsPendingApproval) gwLine2Parts.Add("node pairing pending"); - else if (_nodeService.IsConnected) gwLine2Parts.Add("node connected"); - } - gwOuter.Children.Add(new TextBlock - { - Text = string.Join(" · ", gwLine2Parts), - Style = captionStyle, - Foreground = secondaryText, - FontSize = 11, - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }); - - // Auth failure inline (line 3, critical brush) ── preserved from prior layout - if (!string.IsNullOrEmpty(_authFailureMessage)) - { - gwOuter.Children.Add(new TextBlock - { - Text = _authFailureMessage, - Style = captionStyle, - Foreground = criticalBrush, - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 240, - IsTextSelectionEnabled = false - }); - } - - // Tap the gateway block to open connection settings (button has its own click handler) - gwOuter.Padding = new Thickness(14, 6, 14, 8); - - AutomationProperties.SetName(gwOuter, - $"Gateway {statusText}. Activate to open connection settings."); - - // Gateway hover flyout — richer connection details - var gwFlyoutItems = BuildGatewayFlyoutItems( - isConnected, statusText, gwUri, _lastPresence, _lastGatewaySelf, - _lastNodePairList, _lastDevicePairList, _authFailureMessage, - captionStyle, secondaryText, successBrush, neutralBrush, criticalBrush); - menu.AddFlyoutCustomItem(gwOuter, gwFlyoutItems, action: "connection"); - - // ── Connected Devices (moved above Sessions) ── - // Devices flow directly after the Gateway block without a divider - // or section header — they share the gateway visual format. - var connectedNodes = _lastNodes.Where(n => n.IsOnline).ToArray(); - if (connectedNodes.Length > 0) - { - foreach (var node in connectedNodes.Take(5)) - { - var card = BuildDeviceCard(node, successBrush, neutralBrush, secondaryText); - var flyoutItems = BuildDeviceFlyoutItems(node); - menu.AddFlyoutCustomItem(card, flyoutItems, action: "nodes"); - } - } - - // ── Sessions (now below Devices) ── - if (_lastSessions.Length > 0) - { - menu.AddSeparator(); - - var sessionCount = _lastSessions.Length; - var activeCount = _lastSessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); - var totalTokensAll = _lastSessions.Sum(s => s.InputTokens + s.OutputTokens); - var sessionSummaryRight = $"{activeCount} active · {FormatTokenCount(totalTokensAll)} tokens"; - - // Single collapsed entry whose hover flyout reveals the session list. - var sessionsRow = BuildSessionsListRow(sessionCount, activeCount, totalTokensAll, secondaryText); - var sessionsFlyout = BuildSessionsListFlyoutItems(secondaryText, successBrush, cautionBrush, neutralBrush); - menu.AddFlyoutCustomItem(sessionsRow, sessionsFlyout, action: "sessions"); - } - - // ── Usage (no divider — flows directly under Sessions) ── - { - var usageRow = BuildUsageRow(secondaryText); - var usageFlyout = BuildUsageFlyoutItems(secondaryText); - menu.AddFlyoutCustomItem(usageRow, usageFlyout, action: "usage"); - } - - // ── Actions ── - menu.AddSeparator(); - if (_settings != null) - { - menu.AddFlyoutMenuItem( - "Permissions", - FluentIconCatalog.Build(FluentIconCatalog.Permissions), - BuildPermissionsFlyoutItems(_settings), - action: "permissions"); - } - menu.AddMenuItem("Dashboard", FluentIconCatalog.Build(FluentIconCatalog.Dashboard), "dashboard"); - menu.AddMenuItem("Chat", FluentIconCatalog.Build(FluentIconCatalog.Chat), "openchat"); - menu.AddMenuItem("Canvas", FluentIconCatalog.Build(FluentIconCatalog.CanvasAct), "canvas"); - menu.AddMenuItem("Voice", FluentIconCatalog.Build(FluentIconCatalog.VoiceAct), "voice"); - menu.AddMenuItem( - LocalizationHelper.GetString("Menu_QuickSend"), - FluentIconCatalog.Build(FluentIconCatalog.QuickSend), - "quicksend"); - - // Setup Guide / Reconfigure entry — label flips based on whether prior - // configuration exists; routes to the existing "setup" action handler. var setupMenuLabel = _settings != null && new OpenClawTray.Onboarding.Services.OnboardingExistingConfigGuard(_settings, IdentityDataPath) .HasExistingConfiguration() ? LocalizationHelper.GetString("Menu_Reconfigure") : LocalizationHelper.GetString("Menu_SetupGuide"); - menu.AddMenuItem(setupMenuLabel, FluentIconCatalog.Build(FluentIconCatalog.Setup), "setup"); - // ── Footer ── - menu.AddSeparator(); - menu.AddMenuItemWithHint( - "Companion Settings...", - FluentIconCatalog.Build(FluentIconCatalog.Settings), - "companion", - "Ctrl+Alt+;"); - menu.AddMenuItem("About", FluentIconCatalog.Build(FluentIconCatalog.About), "about"); - menu.AddMenuItem("Close", FluentIconCatalog.Build(FluentIconCatalog.Exit), "exit"); + return new TrayMenuSnapshot + { + CurrentStatus = _currentStatus, + AuthFailureMessage = _authFailureMessage, + GatewayUrl = _settings?.GetEffectiveGatewayUrl(), + GatewaySelf = _lastGatewaySelf, + Presence = _lastPresence, + EnableNodeMode = _settings?.EnableNodeMode == true && _nodeService != null, + NodeIsPaired = _nodeService?.IsPaired ?? false, + NodeIsPendingApproval = _nodeService?.IsPendingApproval ?? false, + NodeIsConnected = _nodeService?.IsConnected ?? false, + NodePairList = _lastNodePairList, + DevicePairList = _lastDevicePairList, + Nodes = _lastNodes, + Sessions = _lastSessions, + Usage = _lastUsage, + UsageStatus = _lastUsageStatus, + UsageCost = _lastUsageCost, + Settings = _settings, + SetupMenuLabel = setupMenuLabel, + }; } + /// /// Opt-in design preview: when the OPENCLAW_TRAY_PREVIEW_DATA /// environment variable is set to 1, populate the session/usage @@ -1636,1025 +1380,9 @@ private void ApplyTrayMenuPreviewDataIfRequested() }; } - /// - /// Flyout items for the local-node Permissions row: one check-toggle per - /// capability flag in . Toggling saves the - /// setting and reconnects so the gateway picks up the new capability set. - /// - private List BuildPermissionsFlyoutItems(SettingsManager settings) - { - var items = new List - { - new() { Text = "Permissions", IsHeader = true }, - }; - - AddPermToggle(items, "Windows node", FluentIconCatalog.System, - "Run OpenClaw as a local node on this PC", - () => settings.EnableNodeMode, v => settings.EnableNodeMode = v); - AddPermToggle(items, "Browser control", FluentIconCatalog.Browser, - "Let agents drive web browsers via proxy", - () => settings.NodeBrowserProxyEnabled, v => settings.NodeBrowserProxyEnabled = v); - AddPermToggle(items, "Camera", FluentIconCatalog.Camera, - "Allow webcam capture during sessions", - () => settings.NodeCameraEnabled, v => settings.NodeCameraEnabled = v); - AddPermToggle(items, "Canvas", FluentIconCatalog.Canvas, - "Render generated HTML canvases in chat", - () => settings.NodeCanvasEnabled, v => settings.NodeCanvasEnabled = v); - AddPermToggle(items, "Screen capture", FluentIconCatalog.Screen, - "Share what's on your screen with the agent", - () => settings.NodeScreenEnabled, v => settings.NodeScreenEnabled = v); - AddPermToggle(items, "Location", FluentIconCatalog.Location, - "Share this device's location", - () => settings.NodeLocationEnabled, v => settings.NodeLocationEnabled = v); - AddPermToggle(items, "Voice (TTS)", FluentIconCatalog.Voice, - "Read responses out loud", - () => settings.NodeTtsEnabled, v => settings.NodeTtsEnabled = v); - AddPermToggle(items, "Speech-to-text (STT)", FluentIconCatalog.Speech, - "Dictate input by speaking", - () => settings.NodeSttEnabled, v => settings.NodeSttEnabled = v); - - return items; - } - - private void AddPermToggle(List items, string label, string iconGlyph, string description, Func get, Action set) - { - var on = get(); - var actionId = $"perm-toggle|{label}"; - items.Add(new TrayMenuFlyoutItem - { - Text = label, - Icon = iconGlyph, - Description = description, - Action = actionId, - IsToggle = true, - IsOn = on, - }); - _permToggleActions[actionId] = () => - { - set(!get()); - _settings?.Save(); - _ = _connectionManager?.ReconnectAsync(); - }; - } private readonly Dictionary _permToggleActions = new(StringComparer.Ordinal); - - private static string FormatTokenCount(long n) - { - if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M"; - if (n >= 1_000) return $"{n / 1_000.0:F1}K"; - return n.ToString(); - } - - /// - /// Mini progress bar built from Borders inside a Grid (two Star columns: - /// pct and 100-pct). Avoids the default WinUI ProgressBar template which - /// renders 0-height inside dynamic-width flyout layouts. - /// - private static FrameworkElement BuildMiniBar(double percent) - { - var p = Math.Min(100.0, Math.Max(0.0, percent)); - var resources = Application.Current.Resources; - // Two-tier color: green by default, red only once usage nearly maxed - // (≥ 95%). No amber middle band — keeps the signal binary and clear. - string accentKey = p >= 95 ? "SystemFillColorCriticalBrush" - : "SystemFillColorSuccessBrush"; - var accent = (Microsoft.UI.Xaml.Media.Brush)resources[accentKey]; - var track = (Microsoft.UI.Xaml.Media.Brush)resources["ControlAltFillColorTertiaryBrush"]; - // Subtle hairline stroke — macOS-style — gives the bar a defined edge - // even when the fill is at 0% or matches the surrounding chrome. - var stroke = (Microsoft.UI.Xaml.Media.Brush)resources["ControlStrokeColorDefaultBrush"]; - - // Outer wrapper carries the rounded corners + track color and clips - // the inner accent fill. This guarantees both ends render a clean - // pill cap regardless of percent or flyout width. - var frame = new Microsoft.UI.Xaml.Controls.Border - { - Height = 6, - CornerRadius = new CornerRadius(3), - Background = track, - BorderBrush = stroke, - BorderThickness = new Thickness(1), - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2), - MinWidth = 60, - }; - - var fillGrid = new Grid(); - // 1e-6 guard so a 0% bar still renders the empty slot; a 0/0 star pair - // would collapse and break the wrapper height. - fillGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(Math.Max(0.0001, p), GridUnitType.Star) }); - fillGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(Math.Max(0.0001, 100.0 - p), GridUnitType.Star) }); - - var filled = new Microsoft.UI.Xaml.Controls.Border - { - Background = accent, - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Stretch, - Opacity = p <= 0 ? 0 : 1, - }; - Grid.SetColumn(filled, 0); - fillGrid.Children.Add(filled); - - frame.Child = fillGrid; - return frame; - } - - // ── Rich card builder helpers for tray menu ── - - private static readonly FrozenDictionary CapabilityIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["screen"] = FluentIconCatalog.Screen, - ["camera"] = FluentIconCatalog.Camera, - ["browser"] = FluentIconCatalog.Browser, - ["clipboard"] = "\uE77F", // PasteAsText - ["tts"] = FluentIconCatalog.Voice, - ["stt"] = FluentIconCatalog.Speech, - ["location"] = FluentIconCatalog.Location, - ["canvas"] = FluentIconCatalog.Canvas, - ["system"] = FluentIconCatalog.System, - ["device"] = FluentIconCatalog.Devices, - ["app"] = "\uECAA", // AppIconDefault - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - - private static Grid BuildSectionHeader(string title, string summary) - { - var grid = new Grid - { - Padding = new Thickness(12, 8, 12, 4), - HorizontalAlignment = HorizontalAlignment.Stretch - }; - grid.Children.Add(new TextBlock - { - Text = title, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - Opacity = 0.7, - VerticalAlignment = VerticalAlignment.Center - }); - grid.Children.Add(new TextBlock - { - Text = summary, - HorizontalAlignment = HorizontalAlignment.Right, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - FontSize = 11, - VerticalAlignment = VerticalAlignment.Center - }); - return grid; - } - - private static string FormatRelative(DateTime utc) - { - var age = DateTime.UtcNow - utc; - if (age.TotalSeconds < 60) return "just now"; - if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago"; - if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago"; - return $"{(int)age.TotalDays}d ago"; - } - - // ── Sessions: collapsed entry + flyout list ───────────────────────── - - private UIElement BuildSessionsListRow(int total, int active, long totalTokens, Microsoft.UI.Xaml.Media.Brush secondaryText) - { - // Card row: [icon] Sessions (N active · X tokens) - var resources = Application.Current.Resources; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - - var grid = new Grid - { - Padding = new Thickness(12, 8, 12, 8), - HorizontalAlignment = HorizontalAlignment.Stretch, - ColumnSpacing = 8 - }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var title = new TextBlock - { - Text = "Sessions", - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 13, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(title, 0); - grid.Children.Add(title); - - var summary = new TextBlock - { - Text = $"{active} active · {FormatTokenCount(totalTokens)} tokens", - Style = captionStyle, - FontSize = 11, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(summary, 1); - grid.Children.Add(summary); - - return grid; - } - - private static List BuildGatewayFlyoutItems( - bool isConnected, - string statusText, - Uri? gwUri, - PresenceEntry[]? presence, - GatewaySelfInfo? self, - PairingListInfo? nodePair, - DevicePairingListInfo? devicePair, - string? authFailure, - Style captionStyle, - Microsoft.UI.Xaml.Media.Brush secondaryText, - Microsoft.UI.Xaml.Media.Brush successBrush, - Microsoft.UI.Xaml.Media.Brush neutralBrush, - Microsoft.UI.Xaml.Media.Brush criticalBrush) - { - var items = new List - { - new() { Text = "Gateway", IsHeader = true } - }; - - // Status card: ● Online/Offline · localhost:7070 - var statusCard = new StackPanel - { - Padding = new Thickness(12, 2, 12, 6), - Spacing = 2, - MinWidth = 280 - }; - var statusLine = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 6, - VerticalAlignment = VerticalAlignment.Center - }; - statusLine.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = isConnected ? successBrush : neutralBrush - }); - var statusParts = new List { statusText }; - if (gwUri != null) statusParts.Add($"{gwUri.Host}:{gwUri.Port}"); - statusLine.Children.Add(new TextBlock - { - Text = string.Join(" · ", statusParts), - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - statusCard.Children.Add(statusLine); - - if (gwUri != null) - { - statusCard.Children.Add(new TextBlock - { - Text = gwUri.ToString(), - Style = captionStyle, - FontSize = 11, - Foreground = secondaryText, - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }); - } - items.Add(new() { CustomContent = statusCard }); - - if (!string.IsNullOrEmpty(authFailure)) - { - var authRow = new StackPanel { Padding = new Thickness(12, 2, 12, 4) }; - authRow.Children.Add(new TextBlock - { - Text = authFailure, - Style = captionStyle, FontSize = 11, - Foreground = criticalBrush, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 260, - IsTextSelectionEnabled = false - }); - items.Add(new() { CustomContent = authRow }); - } - - // Server details - if (self != null && self.HasAnyDetails) - { - items.Add(new() { Text = "Server", IsHeader = true }); - if (!string.IsNullOrEmpty(self.ServerVersion)) - items.Add(BuildKvRow("Version", $"v{self.ServerVersion}", secondaryText, captionStyle)); - if (!string.IsNullOrEmpty(self.AuthMode)) - items.Add(BuildKvRow("Auth", self.AuthMode!, secondaryText, captionStyle)); - if (self.Protocol.HasValue) - items.Add(BuildKvRow("Protocol", $"v{self.Protocol}", secondaryText, captionStyle)); - if (self.UptimeMs.HasValue) - items.Add(BuildKvRow("Uptime", FormatUptime(self.UptimeMs.Value), secondaryText, captionStyle)); - if (!string.IsNullOrEmpty(self.ConnectionId)) - items.Add(BuildKvRow("Conn ID", self.ConnectionId!, secondaryText, captionStyle)); - } - - // Presence - if (isConnected && presence != null && presence.Length > 0) - { - items.Add(new() { Text = $"Clients ({presence.Length})", IsHeader = true }); - foreach (var p in presence.Take(6)) - { - var name = !string.IsNullOrEmpty(p.Host) ? p.Host! : (p.Platform ?? "client"); - var detailParts = new List(); - if (!string.IsNullOrEmpty(p.Platform)) detailParts.Add(p.Platform!); - if (!string.IsNullOrEmpty(p.Version)) detailParts.Add($"v{p.Version}"); - if (!string.IsNullOrEmpty(p.Mode)) detailParts.Add(p.Mode!); - items.Add(BuildKvRow(name!, string.Join(" · ", detailParts), secondaryText, captionStyle)); - } - } - - // Pending pairings (if any) — quick summary line - var nodePending = nodePair?.Pending.Count ?? 0; - var devicePending = devicePair?.Pending.Count ?? 0; - if (nodePending + devicePending > 0) - { - items.Add(new() { Text = "Pending approval", IsHeader = true }); - if (nodePending > 0) - items.Add(BuildKvRow("Nodes", nodePending.ToString(), secondaryText, captionStyle)); - if (devicePending > 0) - items.Add(BuildKvRow("Devices", devicePending.ToString(), secondaryText, captionStyle)); - } - - return items; - } - - private static TrayMenuFlyoutItem BuildKvRow(string key, string value, Microsoft.UI.Xaml.Media.Brush secondaryText, Style captionStyle) - { - var grid = new Grid - { - Padding = new Thickness(12, 2, 12, 2), - ColumnSpacing = 12, - MinWidth = 260 - }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var k = new TextBlock - { - Text = key, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - Foreground = secondaryText, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(k, 0); - grid.Children.Add(k); - - var v = new TextBlock - { - Text = value, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - TextAlignment = TextAlignment.Right, - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(v, 1); - grid.Children.Add(v); - - return new TrayMenuFlyoutItem { CustomContent = grid }; - } - - private static string FormatUptime(long ms) - { - var ts = TimeSpan.FromMilliseconds(ms); - if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d {ts.Hours}h"; - if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h {ts.Minutes}m"; - if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m"; - return $"{(int)ts.TotalSeconds}s"; - } - - private List BuildSessionsListFlyoutItems( - Microsoft.UI.Xaml.Media.Brush secondaryText, - Microsoft.UI.Xaml.Media.Brush successBrush, - Microsoft.UI.Xaml.Media.Brush cautionBrush, - Microsoft.UI.Xaml.Media.Brush neutralBrush) - { - var items = new List - { - new() { Text = $"Sessions ({_lastSessions.Length})", IsHeader = true } - }; - - if (_lastSessions.Length == 0) - { - items.Add(new() { Text = "No active sessions" }); - return items; - } - - foreach (var session in _lastSessions.Take(8)) - { - var card = BuildSessionListCard(session, secondaryText); - items.Add(new() { CustomContent = card }); - } - - return items; - } - - private static UIElement BuildSessionListCard( - SessionInfo session, - Microsoft.UI.Xaml.Media.Brush secondaryText) - { - // 2-row card: - // Row 0: {name} {age} - // Row 1: {model} [████░░░░] {used}/{ctx} ({pct}%) - var usedTokens = session.InputTokens + session.OutputTokens; - var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; - var pct = usedTokens > 0 ? Math.Min(100.0, (double)usedTokens / contextTokens * 100.0) : 0.0; - - var resources = Application.Current.Resources; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - - var outer = new StackPanel - { - Padding = new Thickness(12, 6, 12, 8), - Spacing = 4, - HorizontalAlignment = HorizontalAlignment.Stretch, - MinWidth = 260 - }; - - // Row 0: name + age - var line1 = new Grid { ColumnSpacing = 6 }; - line1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center }; - nameRow.Children.Add(new TextBlock - { - Text = session.DisplayName ?? session.Key, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 13, - TextTrimming = TextTrimming.CharacterEllipsis, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - Grid.SetColumn(nameRow, 0); - line1.Children.Add(nameRow); - - if (session.UpdatedAt.HasValue) - { - var age = new TextBlock - { - Text = FormatRelative(session.UpdatedAt.Value), - Style = captionStyle, FontSize = 11, Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(age, 1); - line1.Children.Add(age); - } - outer.Children.Add(line1); - - // Row 1: model + ratio (text only — bar gets its own row below for clarity) - var line2 = new Grid { ColumnSpacing = 8 }; - line2.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - line2.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var modelText = !string.IsNullOrEmpty(session.Model) ? session.Model! : "unknown"; - var model = new TextBlock - { - Text = modelText, - Style = captionStyle, FontSize = 11, Foreground = secondaryText, - TextTrimming = TextTrimming.CharacterEllipsis, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(model, 0); - line2.Children.Add(model); - - var ratio = new TextBlock - { - Text = $"{FormatTokenCount(usedTokens)}/{FormatTokenCount(contextTokens)} ({(int)pct}%)", - Style = captionStyle, FontSize = 11, Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(ratio, 1); - line2.Children.Add(ratio); - - outer.Children.Add(line2); - - // Row 2: dedicated full-width progress bar so it never gets squeezed - // between model name and ratio text. - var bar = BuildMiniBar(pct); - bar.HorizontalAlignment = HorizontalAlignment.Stretch; - outer.Children.Add(bar); - - return outer; - } - - // ── Usage: collapsed entry + flyout body ──────────────────────────── - - private UIElement BuildUsageRow(Microsoft.UI.Xaml.Media.Brush secondaryText) - { - var resources = Application.Current.Resources; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - - var grid = new Grid - { - Padding = new Thickness(12, 8, 12, 8), - HorizontalAlignment = HorizontalAlignment.Stretch, - ColumnSpacing = 8 - }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var title = new TextBlock - { - Text = "Usage", - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 13, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(title, 0); - grid.Children.Add(title); - - // Right-side summary: $X.XX · Y tokens (always include both when any data present) - var totalTokens = _lastUsage?.TotalTokens - ?? _lastSessions.Sum(s => s.InputTokens + s.OutputTokens); - var cost = _lastUsage?.CostUsd - ?? _lastUsageCost?.Totals.TotalCost - ?? 0.0; - string summaryText; - if (cost <= 0 && totalTokens <= 0) - { - summaryText = "no data"; - } - else - { - // Always show both, formatted as "$X.XX · Y tokens" even when one is 0. - var costStr = "$" + cost.ToString("F2", CultureInfo.InvariantCulture); - var tokStr = $"{FormatTokenCount(totalTokens)} tokens"; - summaryText = $"{costStr} · {tokStr}"; - } - - var summary = new TextBlock - { - Text = summaryText, - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(summary, 1); - grid.Children.Add(summary); - - return grid; - } - - private List BuildUsageFlyoutItems(Microsoft.UI.Xaml.Media.Brush secondaryText) - { - var resources = Application.Current.Resources; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - var subhead = (Style)resources["BodyStrongTextBlockStyle"]; - - var items = new List - { - new() { Text = "Usage", IsHeader = true } - }; - - var totalTokens = _lastUsage?.TotalTokens - ?? _lastSessions.Sum(s => s.InputTokens + s.OutputTokens); - var inputTokens = _lastUsage?.InputTokens - ?? _lastSessions.Sum(s => s.InputTokens); - var outputTokens = _lastUsage?.OutputTokens - ?? _lastSessions.Sum(s => s.OutputTokens); - var cost = _lastUsage?.CostUsd - ?? _lastUsageCost?.Totals.TotalCost - ?? 0.0; - var requests = _lastUsage?.RequestCount ?? 0; - - // Totals card - if (totalTokens > 0 || cost > 0) - { - var totalsCard = new StackPanel - { - Padding = new Thickness(12, 6, 12, 8), - Spacing = 2, - MinWidth = 260 - }; - if (cost > 0) - { - totalsCard.Children.Add(new TextBlock - { - Text = "$" + cost.ToString("F2", CultureInfo.InvariantCulture), - FontSize = 20, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - IsTextSelectionEnabled = false - }); - } - var detail = new List(); - if (totalTokens > 0) detail.Add($"{FormatTokenCount(totalTokens)} tokens"); - if (inputTokens > 0 || outputTokens > 0) - detail.Add($"in {FormatTokenCount(inputTokens)} · out {FormatTokenCount(outputTokens)}"); - if (requests > 0) detail.Add($"{requests} requests"); - if (detail.Count > 0) - { - totalsCard.Children.Add(new TextBlock - { - Text = string.Join(" · ", detail), - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - IsTextSelectionEnabled = false - }); - } - items.Add(new() { CustomContent = totalsCard }); - } - else - { - items.Add(new() { Text = "No usage data yet" }); - } - - // Providers section - var providers = _lastUsageStatus?.Providers; - if (providers != null && providers.Count > 0) - { - items.Add(new() { Text = "Providers", IsHeader = true }); - foreach (var prov in providers) - { - var provCard = new StackPanel - { - Padding = new Thickness(12, 4, 12, 6), - Spacing = 3, - MinWidth = 260 - }; - var header = !string.IsNullOrEmpty(prov.DisplayName) ? prov.DisplayName : prov.Provider; - if (!string.IsNullOrEmpty(prov.Plan)) header += $" · {prov.Plan}"; - provCard.Children.Add(new TextBlock - { - Text = header, - FontSize = 12, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - IsTextSelectionEnabled = false - }); - - if (!string.IsNullOrEmpty(prov.Error)) - { - provCard.Children.Add(new TextBlock - { - Text = prov.Error!, - Style = captionStyle, FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCriticalBrush"], - TextWrapping = TextWrapping.Wrap, - IsTextSelectionEnabled = false - }); - } - - foreach (var win in prov.Windows) - { - // Window block: label + % on one row, full-width bar below. - var winBlock = new StackPanel { Spacing = 2 }; - - var winHeader = new Grid { ColumnSpacing = 8 }; - winHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - winHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var label = new TextBlock - { - Text = win.Label, - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(label, 0); - winHeader.Children.Add(label); - - var pctLbl = new TextBlock - { - Text = $"{(int)win.UsedPercent}%", - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(pctLbl, 1); - winHeader.Children.Add(pctLbl); - - winBlock.Children.Add(winHeader); - - var bar = BuildMiniBar(Math.Min(100.0, Math.Max(0.0, win.UsedPercent))); - bar.HorizontalAlignment = HorizontalAlignment.Stretch; - winBlock.Children.Add(bar); - - provCard.Children.Add(winBlock); - } - - items.Add(new() { CustomContent = provCard }); - } - } - - // By Model section — aggregate from sessions - var byModel = _lastSessions - .Where(s => !string.IsNullOrEmpty(s.Model)) - .GroupBy(s => s.Model!, StringComparer.OrdinalIgnoreCase) - .Select(g => new { Model = g.Key, Tokens = g.Sum(s => s.InputTokens + s.OutputTokens) }) - .Where(x => x.Tokens > 0) - .OrderByDescending(x => x.Tokens) - .Take(3) - .ToList(); - if (byModel.Count > 0) - { - items.Add(new() { Text = "By Model", IsHeader = true }); - foreach (var m in byModel) - { - var row = new Grid - { - Padding = new Thickness(12, 2, 12, 2), - ColumnSpacing = 8, - MinWidth = 260 - }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - var name = new TextBlock - { - Text = m.Model, - Style = captionStyle, FontSize = 11, - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(name, 0); - row.Children.Add(name); - var amt = new TextBlock - { - Text = $"{FormatTokenCount(m.Tokens)} tokens", - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - IsTextSelectionEnabled = false - }; - Grid.SetColumn(amt, 1); - row.Children.Add(amt); - items.Add(new() { CustomContent = row }); - } - } - - return items; - } - - private static UIElement BuildDeviceCard( - GatewayNodeInfo node, - Microsoft.UI.Xaml.Media.Brush successBrush, - Microsoft.UI.Xaml.Media.Brush neutralBrush, - Microsoft.UI.Xaml.Media.Brush secondaryText) - { - // VarB: verbose two-line device card. - // Line 1: ● {DisplayName} [os-pill] › - // Line 2: Online · {Role} · Windows {OsVersion} · app {Version} - var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; - - var resources = Application.Current.Resources; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - var controlSecondaryFill = (Microsoft.UI.Xaml.Media.Brush)resources["ControlFillColorSecondaryBrush"]; - - // Build line-2 tokens: drop empties, render only if at least one survives. - var line2Tokens = new List - { - node.IsOnline ? "Online" : "Offline" - }; - if (!string.IsNullOrWhiteSpace(node.Mode)) line2Tokens.Add(node.Mode!); - // No dedicated OsVersion field on GatewayNodeInfo; surface platform/family - // when available as the OS hint. Falls under the "drop unknown tokens" rule. - if (!string.IsNullOrWhiteSpace(node.DeviceFamily)) line2Tokens.Add(node.DeviceFamily!); - if (!string.IsNullOrWhiteSpace(node.Version)) line2Tokens.Add($"app {node.Version}"); - - var outer = new StackPanel - { - Padding = new Thickness(12, 8, 12, 8), - Spacing = 2, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - - // ── Line 1 ── - var line1 = new Grid { ColumnSpacing = 6 }; - line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // dot + name stack - line1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // spacer - line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // os chip - - var nameRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 6, - VerticalAlignment = VerticalAlignment.Center - }; - nameRow.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = node.IsOnline ? successBrush : neutralBrush - }); - nameRow.Children.Add(new TextBlock - { - Text = nodeName, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 13, - TextTrimming = TextTrimming.CharacterEllipsis, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - Grid.SetColumn(nameRow, 0); - line1.Children.Add(nameRow); - - if (!string.IsNullOrWhiteSpace(node.Platform)) - { - var osChip = new Border - { - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 1, 6, 1), - Background = controlSecondaryFill, - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = node.Platform!.ToLowerInvariant(), - FontSize = 10, - Foreground = secondaryText, - IsTextSelectionEnabled = false - } - }; - Grid.SetColumn(osChip, 2); - line1.Children.Add(osChip); - } - - // Inner chevron removed — AddFlyoutCustomItem already appends the - // official Fluent chevron, so drawing another here looked like a - // duplicate ":›" glyph in narrow flyouts. - outer.Children.Add(line1); - - // ── Line 2 (verbose details) ── - // Always render when at least one non-name token exists; otherwise the - // card collapses to single-line (just line 1). - if (line2Tokens.Count > 0) - { - outer.Children.Add(new TextBlock - { - Text = string.Join(" · ", line2Tokens), - Style = captionStyle, - FontSize = 11, - Foreground = secondaryText, - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }); - } - - return outer; - } - - private static List BuildDeviceFlyoutItems(GatewayNodeInfo node) - { - var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; - var items = new List - { - new() { Text = nodeName, IsHeader = true }, - }; - - // Status card: ● Online · windows · node - // Last seen 4m ago - var resources = Application.Current.Resources; - var captionStyle = (Style)resources["CaptionTextBlockStyle"]; - var secondaryText = (Microsoft.UI.Xaml.Media.Brush)resources["TextFillColorSecondaryBrush"]; - var successBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorSuccessBrush"]; - var neutralBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorNeutralBrush"]; - - var statusCard = new StackPanel - { - Padding = new Thickness(12, 2, 12, 6), - Spacing = 2, - MinWidth = 260 - }; - var statusLine = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 6, - VerticalAlignment = VerticalAlignment.Center - }; - statusLine.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = node.IsOnline ? successBrush : neutralBrush - }); - var statusParts = new List { node.IsOnline ? "Online" : "Offline" }; - if (!string.IsNullOrEmpty(node.Platform)) statusParts.Add(node.Platform); - if (!string.IsNullOrEmpty(node.Mode)) statusParts.Add(node.Mode); - statusLine.Children.Add(new TextBlock - { - Text = string.Join(" · ", statusParts), - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - statusCard.Children.Add(statusLine); - - if (node.LastSeen.HasValue) - { - var age = DateTime.UtcNow - node.LastSeen.Value; - var seenText = age.TotalMinutes < 1 ? "just now" - : age.TotalHours < 1 ? $"{(int)age.TotalMinutes}m ago" - : age.TotalDays < 1 ? $"{(int)age.TotalHours}h ago" - : $"{(int)age.TotalDays}d ago"; - statusCard.Children.Add(new TextBlock - { - Text = $"Last seen {seenText}", - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - IsTextSelectionEnabled = false - }); - } - items.Add(new() { CustomContent = statusCard }); - - // Capabilities + Commands - if (node.Capabilities.Count > 0 || node.Commands.Count > 0) - { - items.Add(new() { Text = $"Capabilities ({node.CapabilityCount}) · Commands ({node.CommandCount})", IsHeader = true }); - - var cmdGroups = node.Commands - .GroupBy(c => c.Contains('.') ? c[..c.IndexOf('.')] : c, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.Select(c => c.Contains('.') ? c[(c.IndexOf('.') + 1)..] : c).ToList(), StringComparer.OrdinalIgnoreCase); - - var shownGroups = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var cap in node.Capabilities) - { - cmdGroups.TryGetValue(cap, out var cmds); - items.Add(new() { CustomContent = BuildCapabilityRow(cap, cmds, secondaryText, captionStyle) }); - shownGroups.Add(cap); - } - - // Command groups without a matching capability entry - foreach (var group in cmdGroups.Where(g => !shownGroups.Contains(g.Key)).OrderBy(g => g.Key)) - { - items.Add(new() { CustomContent = BuildCapabilityRow(group.Key, group.Value, secondaryText, captionStyle) }); - } - } - - return items; - } - - private static UIElement BuildCapabilityRow(string cap, List? commands, Microsoft.UI.Xaml.Media.Brush secondaryText, Style captionStyle) - { - var grid = new Grid - { - Padding = new Thickness(12, 4, 12, 4), - ColumnSpacing = 10, - MinWidth = 260 - }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - var glyph = CapabilityIcons.TryGetValue(cap, out var pua) ? pua : "\uE7C3"; // Page (fallback) - var icon = FluentIconCatalog.Build(glyph); - icon.HorizontalAlignment = HorizontalAlignment.Center; - icon.VerticalAlignment = VerticalAlignment.Top; - icon.Margin = new Thickness(0, 2, 0, 0); - Grid.SetColumn(icon, 0); - grid.Children.Add(icon); - - var stack = new StackPanel { Spacing = 1, VerticalAlignment = VerticalAlignment.Center }; - stack.Children.Add(new TextBlock - { - Text = cap, - FontSize = 13, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - IsTextSelectionEnabled = false - }); - if (commands != null && commands.Count > 0) - { - stack.Children.Add(new TextBlock - { - Text = string.Join(", ", commands), - Style = captionStyle, FontSize = 11, - Foreground = secondaryText, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 240, - IsTextSelectionEnabled = false - }); - } - Grid.SetColumn(stack, 1); - grid.Children.Add(stack); - - return grid; - } - - private static Border BuildBadge(string text) - { - return new Border - { - Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"], - CornerRadius = new CornerRadius(3), - Padding = new Thickness(5, 1, 5, 1), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock - { - Text = text, - FontSize = 10, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - IsTextSelectionEnabled = false - } - }; - } - #region Gateway Client private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) @@ -3956,6 +2684,12 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) private void UpdateTrayIcon() { + if (_dispatcherQueue != null && !_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(UpdateTrayIcon); + return; + } + if (_trayIcon == null) return; // Tray icon is pinned to the app icon so it visually matches the agent diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs new file mode 100644 index 00000000..d62eb875 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs @@ -0,0 +1,35 @@ +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Services; + +internal sealed record TrayMenuSnapshot +{ + // ── Conexión ── + internal required ConnectionStatus CurrentStatus { get; init; } + internal required string? AuthFailureMessage { get; init; } + internal required string? GatewayUrl { get; init; } + internal required GatewaySelfInfo? GatewaySelf { get; init; } + internal required PresenceEntry[]? Presence { get; init; } + + // ── Node ── + internal required bool EnableNodeMode { get; init; } + internal required bool NodeIsPaired { get; init; } + internal required bool NodeIsPendingApproval { get; init; } + internal required bool NodeIsConnected { get; init; } + internal required PairingListInfo? NodePairList { get; init; } + internal required DevicePairingListInfo? DevicePairList { get; init; } + internal required GatewayNodeInfo[] Nodes { get; init; } + + // ── Sesiones ── + internal required SessionInfo[] Sessions { get; init; } + + // ── Usage ── + internal required GatewayUsageInfo? Usage { get; init; } + internal required GatewayUsageStatusInfo? UsageStatus { get; init; } + internal required GatewayCostUsageInfo? UsageCost { get; init; } + + // ── Permisos y setup ── + internal required SettingsManager? Settings { get; init; } + internal required string SetupMenuLabel { get; init; } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs new file mode 100644 index 00000000..4ee7f12b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -0,0 +1,1337 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using OpenClawTray.Windows; +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace OpenClawTray.Services; + +internal sealed record TrayMenuCallbacks( + Action DispatchAction, + Action SaveAndReconnect); + +internal sealed class TrayMenuStateBuilder +{ + private readonly TrayMenuSnapshot _snapshot; + private readonly Dictionary _permToggleActions; + private readonly TrayMenuCallbacks _callbacks; + + internal TrayMenuStateBuilder( + TrayMenuSnapshot snapshot, + Dictionary permToggleActions, + TrayMenuCallbacks callbacks) + { + _snapshot = snapshot; + _permToggleActions = permToggleActions; + _callbacks = callbacks; + } + + internal void Build(TrayMenuWindow menu) + { + // Stale closures from the previous build hold references to old + // ToggleAction delegates; recreate the lookup each rebuild. + _permToggleActions.Clear(); + + var isConnected = _snapshot.CurrentStatus == ConnectionStatus.Connected; + var statusText = LocalizationHelper.GetConnectionStatusText(_snapshot.CurrentStatus); + + // Cache theme brushes once per build so cells don't each do a + // resource lookup. The previous implementation looked up + // SystemFill/Text brushes per row, which contributed to the + // visible right-click hitch. + var resources = Application.Current.Resources; + var successBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorSuccessBrush"]; + var cautionBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCautionBrush"]; + var neutralBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorNeutralBrush"]; + var criticalBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCriticalBrush"]; + var secondaryText = (Microsoft.UI.Xaml.Media.Brush)resources["TextFillColorSecondaryBrush"]; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + var controlSecondaryFill = (Microsoft.UI.Xaml.Media.Brush)resources["ControlFillColorSecondaryBrush"]; + + // ── Brand Header with Disconnect/Connect on the right ── + var brandGrid = new Grid + { + Padding = new Thickness(14, 10, 14, 8), + ColumnSpacing = 8 + }; + brandGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + brandGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + brandGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var brandRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + new Microsoft.UI.Xaml.Controls.Image + { + Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri("ms-appx:///Assets/Square44x44Logo.targetsize-48_altform-unplated.png")), + Width = 28, + Height = 28, + VerticalAlignment = VerticalAlignment.Center + }, + new TextBlock + { + Text = "OpenClaw", + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 18, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + } + } + }; + Grid.SetColumn(brandRow, 0); + brandGrid.Children.Add(brandRow); + + var brandBtn = new Button + { + Content = isConnected ? "Disconnect" : "Connect", + VerticalAlignment = VerticalAlignment.Center, + Padding = new Thickness(12, 4, 12, 4), + MinHeight = 0, + MinWidth = 0, + FontSize = 12 + }; + AutomationProperties.SetName(brandBtn, isConnected ? "Disconnect from gateway" : "Connect to gateway"); + ToolTipService.SetToolTip(brandBtn, isConnected ? "Disconnect from gateway" : "Connect to gateway"); + brandBtn.Click += (s, ev) => + { + menu.HideCascade(); + _callbacks.DispatchAction(isConnected ? "disconnect" : "reconnect"); + }; + Grid.SetColumn(brandBtn, 2); + brandGrid.Children.Add(brandBtn); + + menu.AddCustomElement(brandGrid); + + // ── Pairing approval pending (high-priority action above Gateway) ── + var nodePendingCount = _snapshot.NodePairList?.Pending.Count ?? 0; + var devicePendingCount = _snapshot.DevicePairList?.Pending.Count ?? 0; + if (nodePendingCount + devicePendingCount > 0) + { + var total = nodePendingCount + devicePendingCount; + menu.AddMenuItem( + $"Pairing approval pending ({total})", + FluentIconCatalog.Build(FluentIconCatalog.Approvals), + "hub"); + } + + // ── Gateway Section ── + // (device-card format) + var gwOuter = new StackPanel + { + Padding = new Thickness(12, 8, 12, 8), + Spacing = 2, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + // ── Line 1: dot + "Gateway" + Local chip ── + var gwLine1 = new Grid { ColumnSpacing = 6 }; + gwLine1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + gwLine1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + gwLine1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var gwNameRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center + }; + gwNameRow.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse + { + Width = 8, Height = 8, + VerticalAlignment = VerticalAlignment.Center, + Fill = isConnected ? successBrush + : _snapshot.CurrentStatus == ConnectionStatus.Connecting ? cautionBrush + : neutralBrush + }); + gwNameRow.Children.Add(new TextBlock + { + Text = "Gateway", + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 13, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }); + Grid.SetColumn(gwNameRow, 0); + gwLine1.Children.Add(gwNameRow); + + // Right-side: optional chip on the header line + string? chipText = null; + Uri? gwUri = null; + if (!string.IsNullOrEmpty(_snapshot.GatewayUrl)) + Uri.TryCreate(_snapshot.GatewayUrl, UriKind.Absolute, out gwUri); + if (isConnected) + { + if (gwUri != null && (gwUri.Host == "localhost" || gwUri.Host == "127.0.0.1" || gwUri.Host == "::1")) + chipText = "Local"; + else if (_snapshot.GatewaySelf != null && !string.IsNullOrEmpty(_snapshot.GatewaySelf.ServerVersion)) + chipText = $"v{_snapshot.GatewaySelf.ServerVersion}"; + } + if (chipText != null) + { + var chip = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 1, 6, 1), + Background = controlSecondaryFill, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Right, + Child = new TextBlock + { + Text = chipText, + FontSize = 10, + Foreground = secondaryText, + IsTextSelectionEnabled = false + } + }; + Grid.SetColumn(chip, 2); + gwLine1.Children.Add(chip); + } + gwOuter.Children.Add(gwLine1); + + // ── Line 2: secondary details ── + var gwLine2Parts = new List(); + if (gwUri != null) gwLine2Parts.Add($"{gwUri.Host}:{gwUri.Port}"); + gwLine2Parts.Add(statusText.ToLowerInvariant()); + if (isConnected && _snapshot.Presence != null && _snapshot.Presence.Length > 0) + gwLine2Parts.Add($"{_snapshot.Presence.Length} client{(_snapshot.Presence.Length != 1 ? "s" : "")}"); + if (_snapshot.EnableNodeMode) + { + if (_snapshot.NodeIsPaired) gwLine2Parts.Add("node paired"); + else if (_snapshot.NodeIsPendingApproval) gwLine2Parts.Add("node pairing pending"); + else if (_snapshot.NodeIsConnected) gwLine2Parts.Add("node connected"); + } + gwOuter.Children.Add(new TextBlock + { + Text = string.Join(" · ", gwLine2Parts), + Style = captionStyle, + Foreground = secondaryText, + FontSize = 11, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }); + + // Auth failure inline (line 3, critical brush) + if (!string.IsNullOrEmpty(_snapshot.AuthFailureMessage)) + { + gwOuter.Children.Add(new TextBlock + { + Text = _snapshot.AuthFailureMessage, + Style = captionStyle, + Foreground = criticalBrush, + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 240, + IsTextSelectionEnabled = false + }); + } + + gwOuter.Padding = new Thickness(14, 6, 14, 8); + + AutomationProperties.SetName(gwOuter, + $"Gateway {statusText}. Activate to open connection settings."); + + // Gateway hover flyout — richer connection details + var gwFlyoutItems = BuildGatewayFlyoutItems( + isConnected, statusText, gwUri, _snapshot.Presence, _snapshot.GatewaySelf, + _snapshot.NodePairList, _snapshot.DevicePairList, _snapshot.AuthFailureMessage, + captionStyle, secondaryText, successBrush, neutralBrush, criticalBrush); + menu.AddFlyoutCustomItem(gwOuter, gwFlyoutItems, action: "connection"); + + // ── Connected Devices (moved above Sessions) ── + // Devices flow directly after the Gateway block without a divider + // or section header — they share the gateway visual format. + var connectedNodes = _snapshot.Nodes.Where(n => n.IsOnline).ToArray(); + if (connectedNodes.Length > 0) + { + foreach (var node in connectedNodes.Take(5)) + { + var card = BuildDeviceCard(node, successBrush, neutralBrush, secondaryText); + var flyoutItems = BuildDeviceFlyoutItems(node); + menu.AddFlyoutCustomItem(card, flyoutItems, action: "nodes"); + } + } + + // ── Sessions (now below Devices) ── + if (_snapshot.Sessions.Length > 0) + { + menu.AddSeparator(); + + var sessionCount = _snapshot.Sessions.Length; + var activeCount = _snapshot.Sessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); + var totalTokensAll = _snapshot.Sessions.Sum(s => s.InputTokens + s.OutputTokens); + + // Single collapsed entry whose hover flyout reveals the session list. + var sessionsRow = BuildSessionsListRow(sessionCount, activeCount, totalTokensAll, secondaryText); + var sessionsFlyout = BuildSessionsListFlyoutItems(secondaryText, successBrush, cautionBrush, neutralBrush); + menu.AddFlyoutCustomItem(sessionsRow, sessionsFlyout, action: "sessions"); + } + + // ── Usage (no divider — flows directly under Sessions) ── + { + var usageRow = BuildUsageRow(secondaryText); + var usageFlyout = BuildUsageFlyoutItems(secondaryText); + menu.AddFlyoutCustomItem(usageRow, usageFlyout, action: "usage"); + } + + // ── Actions ── + menu.AddSeparator(); + if (_snapshot.Settings != null) + { + menu.AddFlyoutMenuItem( + "Permissions", + FluentIconCatalog.Build(FluentIconCatalog.Permissions), + BuildPermissionsFlyoutItems(_snapshot.Settings), + action: "permissions"); + } + menu.AddMenuItem("Dashboard", FluentIconCatalog.Build(FluentIconCatalog.Dashboard), "dashboard"); + menu.AddMenuItem("Chat", FluentIconCatalog.Build(FluentIconCatalog.Chat), "openchat"); + menu.AddMenuItem("Canvas", FluentIconCatalog.Build(FluentIconCatalog.CanvasAct), "canvas"); + menu.AddMenuItem("Voice", FluentIconCatalog.Build(FluentIconCatalog.VoiceAct), "voice"); + menu.AddMenuItem( + LocalizationHelper.GetString("Menu_QuickSend"), + FluentIconCatalog.Build(FluentIconCatalog.QuickSend), + "quicksend"); + + // Setup Guide / Reconfigure entry — label flips based on whether prior + // configuration exists; routes to the existing "setup" action handler. + menu.AddMenuItem(_snapshot.SetupMenuLabel, FluentIconCatalog.Build(FluentIconCatalog.Setup), "setup"); + + // ── Footer ── + menu.AddSeparator(); + menu.AddMenuItemWithHint( + "Companion Settings...", + FluentIconCatalog.Build(FluentIconCatalog.Settings), + "companion", + "Ctrl+Alt+;"); + menu.AddMenuItem("About", FluentIconCatalog.Build(FluentIconCatalog.About), "about"); + menu.AddMenuItem("Close", FluentIconCatalog.Build(FluentIconCatalog.Exit), "exit"); + } + + // ── Static helpers (Grupo A) ───────────────────────────────────────── + + private static string FormatTokenCount(long n) + { + if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M"; + if (n >= 1_000) return $"{n / 1_000.0:F1}K"; + return n.ToString(); + } + + /// + /// Mini progress bar built from Borders inside a Grid (two Star columns: + /// pct and 100-pct). Avoids the default WinUI ProgressBar template which + /// renders 0-height inside dynamic-width flyout layouts. + /// + private static FrameworkElement BuildMiniBar(double percent) + { + var p = Math.Min(100.0, Math.Max(0.0, percent)); + var resources = Application.Current.Resources; + // Two-tier color: green by default, red only once usage nearly maxed + // (≥ 95%). No amber middle band — keeps the signal binary and clear. + string accentKey = p >= 95 ? "SystemFillColorCriticalBrush" + : "SystemFillColorSuccessBrush"; + var accent = (Microsoft.UI.Xaml.Media.Brush)resources[accentKey]; + var track = (Microsoft.UI.Xaml.Media.Brush)resources["ControlAltFillColorTertiaryBrush"]; + // Subtle hairline stroke — macOS-style — gives the bar a defined edge + // even when the fill is at 0% or matches the surrounding chrome. + var stroke = (Microsoft.UI.Xaml.Media.Brush)resources["ControlStrokeColorDefaultBrush"]; + + // Outer wrapper carries the rounded corners + track color and clips + // the inner accent fill. This guarantees both ends render a clean + // pill cap regardless of percent or flyout width. + var frame = new Microsoft.UI.Xaml.Controls.Border + { + Height = 6, + CornerRadius = new CornerRadius(3), + Background = track, + BorderBrush = stroke, + BorderThickness = new Thickness(1), + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2), + MinWidth = 60, + }; + + var fillGrid = new Grid(); + // 1e-6 guard so a 0% bar still renders the empty slot; a 0/0 star pair + // would collapse and break the wrapper height. + fillGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(Math.Max(0.0001, p), GridUnitType.Star) }); + fillGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(Math.Max(0.0001, 100.0 - p), GridUnitType.Star) }); + + var filled = new Microsoft.UI.Xaml.Controls.Border + { + Background = accent, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + Opacity = p <= 0 ? 0 : 1, + }; + Grid.SetColumn(filled, 0); + fillGrid.Children.Add(filled); + + frame.Child = fillGrid; + return frame; + } + + // ── Rich card builder helpers for tray menu ── + + private static readonly FrozenDictionary CapabilityIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["screen"] = FluentIconCatalog.Screen, + ["camera"] = FluentIconCatalog.Camera, + ["browser"] = FluentIconCatalog.Browser, + ["clipboard"] = "", // PasteAsText + ["tts"] = FluentIconCatalog.Voice, + ["stt"] = FluentIconCatalog.Speech, + ["location"] = FluentIconCatalog.Location, + ["canvas"] = FluentIconCatalog.Canvas, + ["system"] = FluentIconCatalog.System, + ["device"] = FluentIconCatalog.Devices, + ["app"] = "", // AppIconDefault + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + private static Grid BuildSectionHeader(string title, string summary) + { + var grid = new Grid + { + Padding = new Thickness(12, 8, 12, 4), + HorizontalAlignment = HorizontalAlignment.Stretch + }; + grid.Children.Add(new TextBlock + { + Text = title, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + Opacity = 0.7, + VerticalAlignment = VerticalAlignment.Center + }); + grid.Children.Add(new TextBlock + { + Text = summary, + HorizontalAlignment = HorizontalAlignment.Right, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + FontSize = 11, + VerticalAlignment = VerticalAlignment.Center + }); + return grid; + } + + private static string FormatRelative(DateTime utc) + { + var age = DateTime.UtcNow - utc; + if (age.TotalSeconds < 60) return "just now"; + if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago"; + if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago"; + return $"{(int)age.TotalDays}d ago"; + } + + // ── Sessions: collapsed entry + flyout list ───────────────────────── + + private static UIElement BuildSessionsListRow(int total, int active, long totalTokens, Microsoft.UI.Xaml.Media.Brush secondaryText) + { + // Card row: [icon] Sessions (N active · X tokens) + var resources = Application.Current.Resources; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + + var grid = new Grid + { + Padding = new Thickness(12, 8, 12, 8), + HorizontalAlignment = HorizontalAlignment.Stretch, + ColumnSpacing = 8 + }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var title = new TextBlock + { + Text = "Sessions", + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 13, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(title, 0); + grid.Children.Add(title); + + var summary = new TextBlock + { + Text = $"{active} active · {FormatTokenCount(totalTokens)} tokens", + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(summary, 1); + grid.Children.Add(summary); + + return grid; + } + + private static List BuildGatewayFlyoutItems( + bool isConnected, + string statusText, + Uri? gwUri, + PresenceEntry[]? presence, + GatewaySelfInfo? self, + PairingListInfo? nodePair, + DevicePairingListInfo? devicePair, + string? authFailure, + Style captionStyle, + Microsoft.UI.Xaml.Media.Brush secondaryText, + Microsoft.UI.Xaml.Media.Brush successBrush, + Microsoft.UI.Xaml.Media.Brush neutralBrush, + Microsoft.UI.Xaml.Media.Brush criticalBrush) + { + var items = new List + { + new() { Text = "Gateway", IsHeader = true } + }; + + // Status card: ● Online/Offline · localhost:7070 + var statusCard = new StackPanel + { + Padding = new Thickness(12, 2, 12, 6), + Spacing = 2, + MinWidth = 280 + }; + var statusLine = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center + }; + statusLine.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse + { + Width = 8, Height = 8, + VerticalAlignment = VerticalAlignment.Center, + Fill = isConnected ? successBrush : neutralBrush + }); + var statusParts = new List { statusText }; + if (gwUri != null) statusParts.Add($"{gwUri.Host}:{gwUri.Port}"); + statusLine.Children.Add(new TextBlock + { + Text = string.Join(" · ", statusParts), + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }); + statusCard.Children.Add(statusLine); + + if (gwUri != null) + { + statusCard.Children.Add(new TextBlock + { + Text = gwUri.ToString(), + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }); + } + items.Add(new() { CustomContent = statusCard }); + + if (!string.IsNullOrEmpty(authFailure)) + { + var authRow = new StackPanel { Padding = new Thickness(12, 2, 12, 4) }; + authRow.Children.Add(new TextBlock + { + Text = authFailure, + Style = captionStyle, FontSize = 11, + Foreground = criticalBrush, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 260, + IsTextSelectionEnabled = false + }); + items.Add(new() { CustomContent = authRow }); + } + + // Server details + if (self != null && self.HasAnyDetails) + { + items.Add(new() { Text = "Server", IsHeader = true }); + if (!string.IsNullOrEmpty(self.ServerVersion)) + items.Add(BuildKvRow("Version", $"v{self.ServerVersion}", secondaryText, captionStyle)); + if (!string.IsNullOrEmpty(self.AuthMode)) + items.Add(BuildKvRow("Auth", self.AuthMode!, secondaryText, captionStyle)); + if (self.Protocol.HasValue) + items.Add(BuildKvRow("Protocol", $"v{self.Protocol}", secondaryText, captionStyle)); + if (self.UptimeMs.HasValue) + items.Add(BuildKvRow("Uptime", FormatUptime(self.UptimeMs.Value), secondaryText, captionStyle)); + if (!string.IsNullOrEmpty(self.ConnectionId)) + items.Add(BuildKvRow("Conn ID", self.ConnectionId!, secondaryText, captionStyle)); + } + + // Presence + if (isConnected && presence != null && presence.Length > 0) + { + items.Add(new() { Text = $"Clients ({presence.Length})", IsHeader = true }); + foreach (var p in presence.Take(6)) + { + var name = !string.IsNullOrEmpty(p.Host) ? p.Host! : (p.Platform ?? "client"); + var detailParts = new List(); + if (!string.IsNullOrEmpty(p.Platform)) detailParts.Add(p.Platform!); + if (!string.IsNullOrEmpty(p.Version)) detailParts.Add($"v{p.Version}"); + if (!string.IsNullOrEmpty(p.Mode)) detailParts.Add(p.Mode!); + items.Add(BuildKvRow(name!, string.Join(" · ", detailParts), secondaryText, captionStyle)); + } + } + + // Pending pairings (if any) — quick summary line + var nodePending = nodePair?.Pending.Count ?? 0; + var devicePending = devicePair?.Pending.Count ?? 0; + if (nodePending + devicePending > 0) + { + items.Add(new() { Text = "Pending approval", IsHeader = true }); + if (nodePending > 0) + items.Add(BuildKvRow("Nodes", nodePending.ToString(), secondaryText, captionStyle)); + if (devicePending > 0) + items.Add(BuildKvRow("Devices", devicePending.ToString(), secondaryText, captionStyle)); + } + + return items; + } + + private static TrayMenuFlyoutItem BuildKvRow(string key, string value, Microsoft.UI.Xaml.Media.Brush secondaryText, Style captionStyle) + { + var grid = new Grid + { + Padding = new Thickness(12, 2, 12, 2), + ColumnSpacing = 12, + MinWidth = 260 + }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var k = new TextBlock + { + Text = key, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Foreground = secondaryText, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(k, 0); + grid.Children.Add(k); + + var v = new TextBlock + { + Text = value, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Right, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(v, 1); + grid.Children.Add(v); + + return new TrayMenuFlyoutItem { CustomContent = grid }; + } + + private static string FormatUptime(long ms) + { + var ts = TimeSpan.FromMilliseconds(ms); + if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d {ts.Hours}h"; + if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h {ts.Minutes}m"; + if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m"; + return $"{(int)ts.TotalSeconds}s"; + } + + private static UIElement BuildSessionListCard( + SessionInfo session, + Microsoft.UI.Xaml.Media.Brush secondaryText) + { + // 2-row card: + // Row 0: {name} {age} + // Row 1: {model} [████░░░░] {used}/{ctx} ({pct}%) + var usedTokens = session.InputTokens + session.OutputTokens; + var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; + var pct = usedTokens > 0 ? Math.Min(100.0, (double)usedTokens / contextTokens * 100.0) : 0.0; + + var resources = Application.Current.Resources; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + + var outer = new StackPanel + { + Padding = new Thickness(12, 6, 12, 8), + Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Stretch, + MinWidth = 260 + }; + + // Row 0: name + age + var line1 = new Grid { ColumnSpacing = 6 }; + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center }; + nameRow.Children.Add(new TextBlock + { + Text = session.DisplayName ?? session.Key, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 13, + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }); + Grid.SetColumn(nameRow, 0); + line1.Children.Add(nameRow); + + if (session.UpdatedAt.HasValue) + { + var age = new TextBlock + { + Text = FormatRelative(session.UpdatedAt.Value), + Style = captionStyle, FontSize = 11, Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(age, 1); + line1.Children.Add(age); + } + outer.Children.Add(line1); + + // Row 1: model + ratio (text only — bar gets its own row below for clarity) + var line2 = new Grid { ColumnSpacing = 8 }; + line2.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + line2.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var modelText = !string.IsNullOrEmpty(session.Model) ? session.Model! : "unknown"; + var model = new TextBlock + { + Text = modelText, + Style = captionStyle, FontSize = 11, Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(model, 0); + line2.Children.Add(model); + + var ratio = new TextBlock + { + Text = $"{FormatTokenCount(usedTokens)}/{FormatTokenCount(contextTokens)} ({(int)pct}%)", + Style = captionStyle, FontSize = 11, Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(ratio, 1); + line2.Children.Add(ratio); + + outer.Children.Add(line2); + + // Row 2: dedicated full-width progress bar so it never gets squeezed + // between model name and ratio text. + var bar = BuildMiniBar(pct); + bar.HorizontalAlignment = HorizontalAlignment.Stretch; + outer.Children.Add(bar); + + return outer; + } + + private static UIElement BuildDeviceCard( + GatewayNodeInfo node, + Microsoft.UI.Xaml.Media.Brush successBrush, + Microsoft.UI.Xaml.Media.Brush neutralBrush, + Microsoft.UI.Xaml.Media.Brush secondaryText) + { + // VarB: verbose two-line device card. + // Line 1: ● {DisplayName} [os-pill] › + // Line 2: Online · {Role} · Windows {OsVersion} · app {Version} + var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; + + var resources = Application.Current.Resources; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + var controlSecondaryFill = (Microsoft.UI.Xaml.Media.Brush)resources["ControlFillColorSecondaryBrush"]; + + // Build line-2 tokens: drop empties, render only if at least one survives. + var line2Tokens = new List + { + node.IsOnline ? "Online" : "Offline" + }; + if (!string.IsNullOrWhiteSpace(node.Mode)) line2Tokens.Add(node.Mode!); + // No dedicated OsVersion field on GatewayNodeInfo; surface platform/family + // when available as the OS hint. Falls under the "drop unknown tokens" rule. + if (!string.IsNullOrWhiteSpace(node.DeviceFamily)) line2Tokens.Add(node.DeviceFamily!); + if (!string.IsNullOrWhiteSpace(node.Version)) line2Tokens.Add($"app {node.Version}"); + + var outer = new StackPanel + { + Padding = new Thickness(12, 8, 12, 8), + Spacing = 2, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + // ── Line 1 ── + var line1 = new Grid { ColumnSpacing = 6 }; + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // dot + name stack + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // spacer + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // os chip + + var nameRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center + }; + nameRow.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse + { + Width = 8, Height = 8, + VerticalAlignment = VerticalAlignment.Center, + Fill = node.IsOnline ? successBrush : neutralBrush + }); + nameRow.Children.Add(new TextBlock + { + Text = nodeName, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 13, + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }); + Grid.SetColumn(nameRow, 0); + line1.Children.Add(nameRow); + + if (!string.IsNullOrWhiteSpace(node.Platform)) + { + var osChip = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 1, 6, 1), + Background = controlSecondaryFill, + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = node.Platform!.ToLowerInvariant(), + FontSize = 10, + Foreground = secondaryText, + IsTextSelectionEnabled = false + } + }; + Grid.SetColumn(osChip, 2); + line1.Children.Add(osChip); + } + + // Inner chevron removed — AddFlyoutCustomItem already appends the + // official Fluent chevron, so drawing another here looked like a + // duplicate ":›" glyph in narrow flyouts. + outer.Children.Add(line1); + + // ── Line 2 (verbose details) ── + // Always render when at least one non-name token exists; otherwise the + // card collapses to single-line (just line 1). + if (line2Tokens.Count > 0) + { + outer.Children.Add(new TextBlock + { + Text = string.Join(" · ", line2Tokens), + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }); + } + + return outer; + } + + private static List BuildDeviceFlyoutItems(GatewayNodeInfo node) + { + var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; + var items = new List + { + new() { Text = nodeName, IsHeader = true }, + }; + + // Status card: ● Online · windows · node + // Last seen 4m ago + var resources = Application.Current.Resources; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + var secondaryText = (Microsoft.UI.Xaml.Media.Brush)resources["TextFillColorSecondaryBrush"]; + var successBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorSuccessBrush"]; + var neutralBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorNeutralBrush"]; + + var statusCard = new StackPanel + { + Padding = new Thickness(12, 2, 12, 6), + Spacing = 2, + MinWidth = 260 + }; + var statusLine = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center + }; + statusLine.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse + { + Width = 8, Height = 8, + VerticalAlignment = VerticalAlignment.Center, + Fill = node.IsOnline ? successBrush : neutralBrush + }); + var statusParts = new List { node.IsOnline ? "Online" : "Offline" }; + if (!string.IsNullOrEmpty(node.Platform)) statusParts.Add(node.Platform); + if (!string.IsNullOrEmpty(node.Mode)) statusParts.Add(node.Mode); + statusLine.Children.Add(new TextBlock + { + Text = string.Join(" · ", statusParts), + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }); + statusCard.Children.Add(statusLine); + + if (node.LastSeen.HasValue) + { + var age = DateTime.UtcNow - node.LastSeen.Value; + var seenText = age.TotalMinutes < 1 ? "just now" + : age.TotalHours < 1 ? $"{(int)age.TotalMinutes}m ago" + : age.TotalDays < 1 ? $"{(int)age.TotalHours}h ago" + : $"{(int)age.TotalDays}d ago"; + statusCard.Children.Add(new TextBlock + { + Text = $"Last seen {seenText}", + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + IsTextSelectionEnabled = false + }); + } + items.Add(new() { CustomContent = statusCard }); + + // Capabilities + Commands + if (node.Capabilities.Count > 0 || node.Commands.Count > 0) + { + items.Add(new() { Text = $"Capabilities ({node.CapabilityCount}) · Commands ({node.CommandCount})", IsHeader = true }); + + var cmdGroups = node.Commands + .GroupBy(c => c.Contains('.') ? c[..c.IndexOf('.')] : c, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Select(c => c.Contains('.') ? c[(c.IndexOf('.') + 1)..] : c).ToList(), StringComparer.OrdinalIgnoreCase); + + var shownGroups = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cap in node.Capabilities) + { + cmdGroups.TryGetValue(cap, out var cmds); + items.Add(new() { CustomContent = BuildCapabilityRow(cap, cmds, secondaryText, captionStyle) }); + shownGroups.Add(cap); + } + + // Command groups without a matching capability entry + foreach (var group in cmdGroups.Where(g => !shownGroups.Contains(g.Key)).OrderBy(g => g.Key)) + { + items.Add(new() { CustomContent = BuildCapabilityRow(group.Key, group.Value, secondaryText, captionStyle) }); + } + } + + return items; + } + + private static UIElement BuildCapabilityRow(string cap, List? commands, Microsoft.UI.Xaml.Media.Brush secondaryText, Style captionStyle) + { + var grid = new Grid + { + Padding = new Thickness(12, 4, 12, 4), + ColumnSpacing = 10, + MinWidth = 260 + }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var glyph = CapabilityIcons.TryGetValue(cap, out var pua) ? pua : ""; // Page (fallback) + var icon = FluentIconCatalog.Build(glyph); + icon.HorizontalAlignment = HorizontalAlignment.Center; + icon.VerticalAlignment = VerticalAlignment.Top; + icon.Margin = new Thickness(0, 2, 0, 0); + Grid.SetColumn(icon, 0); + grid.Children.Add(icon); + + var stack = new StackPanel { Spacing = 1, VerticalAlignment = VerticalAlignment.Center }; + stack.Children.Add(new TextBlock + { + Text = cap, + FontSize = 13, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + IsTextSelectionEnabled = false + }); + if (commands != null && commands.Count > 0) + { + stack.Children.Add(new TextBlock + { + Text = string.Join(", ", commands), + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 240, + IsTextSelectionEnabled = false + }); + } + Grid.SetColumn(stack, 1); + grid.Children.Add(stack); + + return grid; + } + + // ── Instance helpers (Grupo B) ──────────────────────────────────────── + + private List BuildSessionsListFlyoutItems( + Microsoft.UI.Xaml.Media.Brush secondaryText, + Microsoft.UI.Xaml.Media.Brush successBrush, + Microsoft.UI.Xaml.Media.Brush cautionBrush, + Microsoft.UI.Xaml.Media.Brush neutralBrush) + { + var items = new List + { + new() { Text = $"Sessions ({_snapshot.Sessions.Length})", IsHeader = true } + }; + + if (_snapshot.Sessions.Length == 0) + { + items.Add(new() { Text = "No active sessions" }); + return items; + } + + foreach (var session in _snapshot.Sessions.Take(8)) + { + var card = BuildSessionListCard(session, secondaryText); + items.Add(new() { CustomContent = card }); + } + + return items; + } + + private UIElement BuildUsageRow(Microsoft.UI.Xaml.Media.Brush secondaryText) + { + var resources = Application.Current.Resources; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + + var grid = new Grid + { + Padding = new Thickness(12, 8, 12, 8), + HorizontalAlignment = HorizontalAlignment.Stretch, + ColumnSpacing = 8 + }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var title = new TextBlock + { + Text = "Usage", + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 13, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(title, 0); + grid.Children.Add(title); + + // Right-side summary: $X.XX · Y tokens (always include both when any data present) + var totalTokens = _snapshot.Usage?.TotalTokens + ?? _snapshot.Sessions.Sum(s => s.InputTokens + s.OutputTokens); + var cost = _snapshot.Usage?.CostUsd + ?? _snapshot.UsageCost?.Totals.TotalCost + ?? 0.0; + string summaryText; + if (cost <= 0 && totalTokens <= 0) + { + summaryText = "no data"; + } + else + { + // Always show both, formatted as "$X.XX · Y tokens" even when one is 0. + var costStr = "$" + cost.ToString("F2", CultureInfo.InvariantCulture); + var tokStr = $"{FormatTokenCount(totalTokens)} tokens"; + summaryText = $"{costStr} · {tokStr}"; + } + + var summary = new TextBlock + { + Text = summaryText, + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(summary, 1); + grid.Children.Add(summary); + + return grid; + } + + private List BuildUsageFlyoutItems(Microsoft.UI.Xaml.Media.Brush secondaryText) + { + var resources = Application.Current.Resources; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + var subhead = (Style)resources["BodyStrongTextBlockStyle"]; + + var items = new List + { + new() { Text = "Usage", IsHeader = true } + }; + + var totalTokens = _snapshot.Usage?.TotalTokens + ?? _snapshot.Sessions.Sum(s => s.InputTokens + s.OutputTokens); + var inputTokens = _snapshot.Usage?.InputTokens + ?? _snapshot.Sessions.Sum(s => s.InputTokens); + var outputTokens = _snapshot.Usage?.OutputTokens + ?? _snapshot.Sessions.Sum(s => s.OutputTokens); + var cost = _snapshot.Usage?.CostUsd + ?? _snapshot.UsageCost?.Totals.TotalCost + ?? 0.0; + var requests = _snapshot.Usage?.RequestCount ?? 0; + + // Totals card + if (totalTokens > 0 || cost > 0) + { + var totalsCard = new StackPanel + { + Padding = new Thickness(12, 6, 12, 8), + Spacing = 2, + MinWidth = 260 + }; + if (cost > 0) + { + totalsCard.Children.Add(new TextBlock + { + Text = "$" + cost.ToString("F2", CultureInfo.InvariantCulture), + FontSize = 20, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + IsTextSelectionEnabled = false + }); + } + var detail = new List(); + if (totalTokens > 0) detail.Add($"{FormatTokenCount(totalTokens)} tokens"); + if (inputTokens > 0 || outputTokens > 0) + detail.Add($"in {FormatTokenCount(inputTokens)} · out {FormatTokenCount(outputTokens)}"); + if (requests > 0) detail.Add($"{requests} requests"); + if (detail.Count > 0) + { + totalsCard.Children.Add(new TextBlock + { + Text = string.Join(" · ", detail), + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + IsTextSelectionEnabled = false + }); + } + items.Add(new() { CustomContent = totalsCard }); + } + else + { + items.Add(new() { Text = "No usage data yet" }); + } + + // Providers section + var providers = _snapshot.UsageStatus?.Providers; + if (providers != null && providers.Count > 0) + { + items.Add(new() { Text = "Providers", IsHeader = true }); + foreach (var prov in providers) + { + var provCard = new StackPanel + { + Padding = new Thickness(12, 4, 12, 6), + Spacing = 3, + MinWidth = 260 + }; + var header = !string.IsNullOrEmpty(prov.DisplayName) ? prov.DisplayName : prov.Provider; + if (!string.IsNullOrEmpty(prov.Plan)) header += $" · {prov.Plan}"; + provCard.Children.Add(new TextBlock + { + Text = header, + FontSize = 12, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + IsTextSelectionEnabled = false + }); + + if (!string.IsNullOrEmpty(prov.Error)) + { + provCard.Children.Add(new TextBlock + { + Text = prov.Error!, + Style = captionStyle, FontSize = 11, + Foreground = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCriticalBrush"], + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = false + }); + } + + foreach (var win in prov.Windows) + { + // Window block: label + % on one row, full-width bar below. + var winBlock = new StackPanel { Spacing = 2 }; + + var winHeader = new Grid { ColumnSpacing = 8 }; + winHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + winHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var label = new TextBlock + { + Text = win.Label, + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(label, 0); + winHeader.Children.Add(label); + + var pctLbl = new TextBlock + { + Text = $"{(int)win.UsedPercent}%", + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(pctLbl, 1); + winHeader.Children.Add(pctLbl); + + winBlock.Children.Add(winHeader); + + var bar = BuildMiniBar(Math.Min(100.0, Math.Max(0.0, win.UsedPercent))); + bar.HorizontalAlignment = HorizontalAlignment.Stretch; + winBlock.Children.Add(bar); + + provCard.Children.Add(winBlock); + } + + items.Add(new() { CustomContent = provCard }); + } + } + + // By Model section — aggregate from sessions + var byModel = _snapshot.Sessions + .Where(s => !string.IsNullOrEmpty(s.Model)) + .GroupBy(s => s.Model!, StringComparer.OrdinalIgnoreCase) + .Select(g => new { Model = g.Key, Tokens = g.Sum(s => s.InputTokens + s.OutputTokens) }) + .Where(x => x.Tokens > 0) + .OrderByDescending(x => x.Tokens) + .Take(3) + .ToList(); + if (byModel.Count > 0) + { + items.Add(new() { Text = "By Model", IsHeader = true }); + foreach (var m in byModel) + { + var row = new Grid + { + Padding = new Thickness(12, 2, 12, 2), + ColumnSpacing = 8, + MinWidth = 260 + }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var name = new TextBlock + { + Text = m.Model, + Style = captionStyle, FontSize = 11, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(name, 0); + row.Children.Add(name); + var amt = new TextBlock + { + Text = $"{FormatTokenCount(m.Tokens)} tokens", + Style = captionStyle, FontSize = 11, + Foreground = secondaryText, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(amt, 1); + row.Children.Add(amt); + items.Add(new() { CustomContent = row }); + } + } + + return items; + } + + /// + /// Flyout items for the local-node Permissions row: one check-toggle per + /// capability flag in . Toggling saves the + /// setting and reconnects so the gateway picks up the new capability set. + /// + private List BuildPermissionsFlyoutItems(SettingsManager settings) + { + var items = new List + { + new() { Text = "Permissions", IsHeader = true }, + }; + + AddPermToggle(items, "Windows node", FluentIconCatalog.System, + "Run OpenClaw as a local node on this PC", + () => settings.EnableNodeMode, v => settings.EnableNodeMode = v); + AddPermToggle(items, "Browser control", FluentIconCatalog.Browser, + "Let agents drive web browsers via proxy", + () => settings.NodeBrowserProxyEnabled, v => settings.NodeBrowserProxyEnabled = v); + AddPermToggle(items, "Camera", FluentIconCatalog.Camera, + "Allow webcam capture during sessions", + () => settings.NodeCameraEnabled, v => settings.NodeCameraEnabled = v); + AddPermToggle(items, "Canvas", FluentIconCatalog.Canvas, + "Render generated HTML canvases in chat", + () => settings.NodeCanvasEnabled, v => settings.NodeCanvasEnabled = v); + AddPermToggle(items, "Screen capture", FluentIconCatalog.Screen, + "Share what's on your screen with the agent", + () => settings.NodeScreenEnabled, v => settings.NodeScreenEnabled = v); + AddPermToggle(items, "Location", FluentIconCatalog.Location, + "Share this device's location", + () => settings.NodeLocationEnabled, v => settings.NodeLocationEnabled = v); + AddPermToggle(items, "Voice (TTS)", FluentIconCatalog.Voice, + "Read responses out loud", + () => settings.NodeTtsEnabled, v => settings.NodeTtsEnabled = v); + AddPermToggle(items, "Speech-to-text (STT)", FluentIconCatalog.Speech, + "Dictate input by speaking", + () => settings.NodeSttEnabled, v => settings.NodeSttEnabled = v); + + return items; + } + + private void AddPermToggle(List items, string label, string iconGlyph, string description, Func get, Action set) + { + var on = get(); + var actionId = $"perm-toggle|{label}"; + items.Add(new TrayMenuFlyoutItem + { + Text = label, + Icon = iconGlyph, + Description = description, + Action = actionId, + IsToggle = true, + IsOn = on, + }); + _permToggleActions[actionId] = () => + { + set(!get()); + _callbacks.SaveAndReconnect(); + }; + } + + private static Border BuildBadge(string text) + { + return new Border + { + Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"], + CornerRadius = new CornerRadius(3), + Padding = new Thickness(5, 1, 5, 1), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = text, + FontSize = 10, + Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + IsTextSelectionEnabled = false + } + }; + } +} From b0dc7450b33a7d6cda9556a1228f9f414501d9ab Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Fri, 15 May 2026 20:53:58 +0200 Subject: [PATCH 3/3] test: update source-inspection tests to read from TrayMenuStateBuilder Four TrayMenuPopupCompositionTests read App.xaml.cs and checked for method names and section markers that now live in TrayMenuStateBuilder.cs after the builder extraction. Point them at the new file; RoutesAboutAction splits across both (menu emission in builder, case handler in App). Co-Authored-By: Claude Sonnet 4.6 --- .../FluentIconCatalogTests.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs index e224b0ae..3d8aed6c 100644 --- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs +++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs @@ -127,6 +127,14 @@ private static string ReadAppXaml() return File.ReadAllText(path); } + private static string ReadStateBuilder() + { + var path = Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "TrayMenuStateBuilder.cs"); + return File.ReadAllText(path); + } + private static string ReadHubWindowXaml() { var path = Path.Combine( @@ -152,7 +160,7 @@ public void BuildTrayMenuPopup_NoHardcodedColors() [Fact] public void BuildTrayMenuPopup_UsesThemeBrushes() { - var src = ReadAppXaml(); + var src = ReadStateBuilder(); Assert.Contains("SystemFillColorSuccessBrush", src); Assert.Contains("SystemFillColorCautionBrush", src); Assert.Contains("SystemFillColorNeutralBrush", src); @@ -162,7 +170,7 @@ public void BuildTrayMenuPopup_UsesThemeBrushes() [Fact] public void BuildTrayMenuPopup_SectionOrder_GatewayThenDevicesThenSessions() { - var src = ReadAppXaml(); + var src = ReadStateBuilder(); var gateway = src.IndexOf("// ── Gateway Section ──", StringComparison.Ordinal); var devices = src.IndexOf("// ── Connected Devices (moved above Sessions) ──", StringComparison.Ordinal); var sessions = src.IndexOf("// ── Sessions (now below Devices) ──", StringComparison.Ordinal); @@ -179,7 +187,7 @@ public void BuildTrayMenuPopup_SectionOrder_GatewayThenDevicesThenSessions() [Fact] public void BuildTrayMenuPopup_EmitsPermissionsSubmenuForLocalDevice() { - var src = ReadAppXaml(); + var src = ReadStateBuilder(); Assert.Contains("BuildPermissionsFlyoutItems", src); Assert.Contains("FluentIconCatalog.Permissions", src); } @@ -187,9 +195,8 @@ public void BuildTrayMenuPopup_EmitsPermissionsSubmenuForLocalDevice() [Fact] public void BuildTrayMenuPopup_RoutesAboutAction() { - var src = ReadAppXaml(); - Assert.Contains("\"About\", FluentIconCatalog.Build(FluentIconCatalog.About), \"about\"", src); - Assert.Contains("case \"about\":", src); + Assert.Contains("\"About\", FluentIconCatalog.Build(FluentIconCatalog.About), \"about\"", ReadStateBuilder()); + Assert.Contains("case \"about\":", ReadAppXaml()); } [Fact]