diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 8199f183..80cfa4a5 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -987,6 +987,7 @@ private void OnTrayMenuItemClicked(object? sender, string action) case "connection": ShowHub("connection"); break; case "permissions": ShowHub("permissions"); break; case "dashboard": OpenDashboard(); break; + case "diagnostics": ShowHub("debug"); break; case "canvas": ShowCanvasWindow(); break; case "openchat": ShowHub("chat"); break; case "voice": ShowHub("voice"); break; // was: ShowVoiceOverlay() @@ -1318,6 +1319,7 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() Settings = _settings, SetupMenuLabel = setupMenuLabel, ShowSetupMenuEntry = !hasSetupManagedLocalWslGateway, + LastUpdated = _appState?.LastCheckTime, }; } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs new file mode 100644 index 00000000..7a5a254d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -0,0 +1,243 @@ +using OpenClaw.Shared; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace OpenClawTray.Services; + +internal enum TrayHealthSeverity +{ + Neutral, + Ok, + Caution, + Critical, +} + +internal sealed record TrayDashboardActiveSession( + string Label, + string Title, + string? Detail, + int ContextPercent); + +/// +/// Pure, render-free computation for the tray dashboard glance. +/// +internal sealed record TrayDashboardSummary +{ + internal required TrayHealthSeverity Severity { get; init; } + internal required string Headline { get; init; } + internal required string? Endpoint { get; init; } + internal required string? Heartbeat { get; init; } + internal required string? MetricsLine { get; init; } + internal required TrayDashboardActiveSession? ActiveSession { get; init; } + + internal bool HasActiveSession => ActiveSession is not null; +} + +/// +/// Builds tray dashboard summary text from a menu snapshot. +/// +internal sealed class TrayDashboardSummaryBuilder +{ + private readonly TrayMenuSnapshot _snapshot; + private readonly DateTime _nowUtc; + + internal TrayDashboardSummaryBuilder(TrayMenuSnapshot snapshot, DateTime? nowUtc = null) + { + _snapshot = snapshot; + _nowUtc = nowUtc ?? DateTime.UtcNow; + } + + internal TrayDashboardSummary Build() + { + var isConnected = _snapshot.CurrentStatus == ConnectionStatus.Connected; + + var (severity, headline) = ClassifyHealth(); + + string? endpoint = null; + if (!string.IsNullOrEmpty(_snapshot.GatewayUrl) + && Uri.TryCreate(_snapshot.GatewayUrl, UriKind.Absolute, out var uri)) + { + endpoint = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}"; + } + + return new TrayDashboardSummary + { + Severity = severity, + Headline = headline, + Endpoint = endpoint, + Heartbeat = isConnected ? BuildHeartbeat() : null, + MetricsLine = BuildMetricsLine(isConnected), + ActiveSession = BuildActiveSession(), + }; + } + + private (TrayHealthSeverity, string) ClassifyHealth() + { + if (!string.IsNullOrEmpty(_snapshot.AuthFailureMessage)) + return (TrayHealthSeverity.Critical, "Authentication failed"); + + var pending = (_snapshot.NodePairList?.Pending.Count ?? 0) + + (_snapshot.DevicePairList?.Pending.Count ?? 0); + if (pending > 0) + return (TrayHealthSeverity.Caution, $"Pairing approval pending ({pending})"); + + return _snapshot.CurrentStatus switch + { + ConnectionStatus.Connected => (TrayHealthSeverity.Ok, "Connected"), + ConnectionStatus.Connecting => (TrayHealthSeverity.Caution, "Connecting…"), + ConnectionStatus.Error => (TrayHealthSeverity.Critical, "Connection error"), + _ => (TrayHealthSeverity.Neutral, "Disconnected"), + }; + } + + private string? BuildHeartbeat() + { + if (_snapshot.LastUpdated is not { } updated) + return null; + + var updatedUtc = updated.Kind == DateTimeKind.Utc ? updated : updated.ToUniversalTime(); + var age = _nowUtc - updatedUtc; + if (age < TimeSpan.Zero) age = TimeSpan.Zero; + return $"Updated {FormatAge(age)}"; + } + + private string? BuildMetricsLine(bool isConnected) + { + var parts = new List(3); + + var nodesTotal = _snapshot.Nodes.Length; + if (nodesTotal > 0) + { + var online = _snapshot.Nodes.Count(n => n.IsOnline); + parts.Add($"{online}/{nodesTotal} {(nodesTotal == 1 ? "node" : "nodes")}"); + } + + var sessionCount = _snapshot.Sessions.Length; + if (sessionCount > 0) + { + var active = _snapshot.Sessions.Count( + s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); + parts.Add(active > 0 + ? $"{sessionCount} {(sessionCount == 1 ? "session" : "sessions")} ({active} active)" + : $"{sessionCount} {(sessionCount == 1 ? "session" : "sessions")}"); + } + + if (isConnected) + { + var usage = BuildUsageGlance(); + if (usage != null) parts.Add(usage); + } + + return parts.Count == 0 ? null : string.Join(" · ", parts); + } + + private string? BuildUsageGlance() + { + var cost = FirstPositiveCost( + _snapshot.Usage?.CostUsd, + _snapshot.UsageCost?.Totals.TotalCost); + + var totalTokens = FirstPositiveTokens( + _snapshot.Usage?.TotalTokens, + _snapshot.UsageCost?.Totals.TotalTokens, + _snapshot.Sessions.Sum(SessionUsedTokens)); + + if (cost <= 0 && totalTokens <= 0) + return null; + + if (cost >= 0.005) + return "$" + cost.ToString("F2", CultureInfo.InvariantCulture); + return $"{FormatTokenCount(totalTokens)} tokens"; + } + + internal static double FirstPositiveCost(params double?[] candidates) + { + foreach (var c in candidates) + if (c is > 0) return c.Value; + return 0.0; + } + + internal static long FirstPositiveTokens(params long?[] candidates) + { + foreach (var c in candidates) + if (c is > 0) return c.Value; + return 0L; + } + + internal static long SessionUsedTokens(SessionInfo session) => + session.TotalTokens > 0 ? session.TotalTokens : session.InputTokens + session.OutputTokens; + + internal static SessionInfo? SelectActiveSession(IReadOnlyList sessions) + { + if (sessions == null || sessions.Count == 0) + return null; + + static bool IsActive(SessionInfo s) => + string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase); + + var activeMain = sessions.FirstOrDefault(s => s.IsMain && IsActive(s)); + if (activeMain != null) return activeMain; + + var activeRecent = sessions + .Where(IsActive) + .OrderByDescending(s => s.UpdatedAt ?? s.LastSeen) + .FirstOrDefault(); + if (activeRecent != null) return activeRecent; + + var main = sessions.FirstOrDefault(s => s.IsMain); + if (main != null) return main; + + return sessions.OrderByDescending(s => s.UpdatedAt ?? s.LastSeen).First(); + } + + private TrayDashboardActiveSession? BuildActiveSession() + { + var session = SelectActiveSession(_snapshot.Sessions); + if (session == null) + return null; + + var isActive = string.Equals(session.Status, "active", StringComparison.OrdinalIgnoreCase); + var label = isActive ? "Active" : (session.IsMain ? "Main" : "Session"); + + var title = !string.IsNullOrWhiteSpace(session.DisplayName) + ? session.DisplayName! + : (session.IsMain ? "Main session" : (string.IsNullOrEmpty(session.Key) ? "Session" : session.ShortKey)); + + var usedTokens = SessionUsedTokens(session); + var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; + var pct = usedTokens > 0 + ? (int)Math.Round(Math.Min(100.0, (double)usedTokens / contextTokens * 100.0)) + : 0; + + // Detail carries only stable metadata; CurrentActivity can include + // command/query/path/URL snippets and should stay out of the top-level + // tray glance. + var detailParts = new List(1); + if (!string.IsNullOrWhiteSpace(session.Model)) detailParts.Add(session.Model!); + var detail = detailParts.Count == 0 ? null : string.Join(" · ", detailParts); + + return new TrayDashboardActiveSession( + Label: label, + Title: title, + Detail: detail, + ContextPercent: pct); + } + + 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(CultureInfo.InvariantCulture); + } + + private static string FormatAge(TimeSpan age) + { + if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago"; + 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"; + } + +} diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs index 48415c61..5a818bc4 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared; using OpenClawTray.Services; +using System; namespace OpenClawTray.Services; @@ -33,4 +34,7 @@ internal sealed record TrayMenuSnapshot internal required SettingsManager? Settings { get; init; } internal required string SetupMenuLabel { get; init; } internal required bool ShowSetupMenuEntry { get; init; } + + // ── Dashboard glance ── + internal DateTime? LastUpdated { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index 4d3e8ee8..aa63cf6b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using OpenClaw.Chat; @@ -116,6 +117,13 @@ internal void Build(TrayMenuWindow menu) menu.AddCustomElement(brandGrid); + // ── Dashboard glance (live status header) ── + var dashboard = new TrayDashboardSummaryBuilder(_snapshot).Build(); + var glance = BuildDashboardGlance( + dashboard, successBrush, cautionBrush, neutralBrush, criticalBrush, + secondaryText, captionStyle); + menu.AddCustomElement(glance); + // ── Pairing approval pending (high-priority action above Gateway) ── var nodePendingCount = _snapshot.NodePairList?.Pending.Count ?? 0; var devicePendingCount = _snapshot.DevicePairList?.Pending.Count ?? 0; @@ -272,7 +280,7 @@ internal void Build(TrayMenuWindow menu) 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); + var totalTokensAll = _snapshot.Sessions.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens); // Single collapsed entry whose hover flyout reveals the session list. var sessionsRow = BuildSessionsListRow(sessionCount, activeCount, totalTokensAll, secondaryText); @@ -301,6 +309,7 @@ internal void Build(TrayMenuWindow menu) 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("Diagnostics", FluentIconCatalog.Build(FluentIconCatalog.Bug), "diagnostics"); // Voice overlay disabled — inline chat voice mode is used instead. // menu.AddMenuItem("Voice", FluentIconCatalog.Build(FluentIconCatalog.VoiceAct), "voice"); @@ -331,6 +340,173 @@ private static string FormatTokenCount(long n) return n.ToString(); } + private static UIElement BuildDashboardGlance( + TrayDashboardSummary summary, + Microsoft.UI.Xaml.Media.Brush successBrush, + Microsoft.UI.Xaml.Media.Brush cautionBrush, + Microsoft.UI.Xaml.Media.Brush neutralBrush, + Microsoft.UI.Xaml.Media.Brush criticalBrush, + Microsoft.UI.Xaml.Media.Brush secondaryText, + Style captionStyle) + { + var dotBrush = summary.Severity switch + { + TrayHealthSeverity.Ok => successBrush, + TrayHealthSeverity.Caution => cautionBrush, + TrayHealthSeverity.Critical => criticalBrush, + _ => neutralBrush, + }; + + var outer = new StackPanel + { + Padding = new Thickness(12, 4, 12, 8), + Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + // 3-column grid so the headline trims within 320px instead of pushing + // the heartbeat off-screen (a horizontal StackPanel measures unbounded). + var line1 = new Grid { ColumnSpacing = 6, HorizontalAlignment = HorizontalAlignment.Stretch }; + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + line1.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var dot = new Microsoft.UI.Xaml.Shapes.Ellipse + { + Width = 8, Height = 8, + VerticalAlignment = VerticalAlignment.Center, + Fill = dotBrush + }; + Grid.SetColumn(dot, 0); + line1.Children.Add(dot); + + var headlineText = summary.Endpoint != null + ? $"{summary.Headline} · {summary.Endpoint}" + : summary.Headline; + var headline = new TextBlock + { + Text = headlineText, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 13, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(headline, 1); + line1.Children.Add(headline); + + if (summary.Heartbeat != null) + { + var hb = new TextBlock + { + Text = summary.Heartbeat, + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(hb, 2); + line1.Children.Add(hb); + } + outer.Children.Add(line1); + + if (!string.IsNullOrEmpty(summary.MetricsLine)) + { + outer.Children.Add(new TextBlock + { + Text = summary.MetricsLine, + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }); + } + + if (summary.ActiveSession is { } active) + { + var sessionLine = new Grid { ColumnSpacing = 6, Margin = new Thickness(0, 2, 0, 0), HorizontalAlignment = HorizontalAlignment.Stretch }; + sessionLine.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + sessionLine.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + sessionLine.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var labelText = new TextBlock + { + Text = active.Label, + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(labelText, 0); + sessionLine.Children.Add(labelText); + + var titleText = new TextBlock + { + Text = active.Title, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(titleText, 1); + sessionLine.Children.Add(titleText); + + if (active.ContextPercent > 0) + { + var ctx = new TextBlock + { + Text = $"{active.ContextPercent}% ctx", + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, + IsTextSelectionEnabled = false + }; + Grid.SetColumn(ctx, 2); + sessionLine.Children.Add(ctx); + } + outer.Children.Add(sessionLine); + + if (!string.IsNullOrEmpty(active.Detail)) + { + outer.Children.Add(new TextBlock + { + Text = active.Detail, + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false + }); + } + + } + + AutomationProperties.SetName(outer, BuildGlanceAutomationName(summary)); + AutomationProperties.SetAccessibilityView(outer, AccessibilityView.Content); + return outer; + } + + private static string BuildGlanceAutomationName(TrayDashboardSummary summary) + { + var parts = new List { summary.Headline }; + if (summary.Endpoint != null) parts.Add(summary.Endpoint); + if (summary.Heartbeat != null) parts.Add(summary.Heartbeat); + if (!string.IsNullOrEmpty(summary.MetricsLine)) parts.Add(summary.MetricsLine!); + if (summary.ActiveSession is { } a) + { + var sess = a.Label == "Session" ? $"Session {a.Title}" : $"{a.Label} session {a.Title}"; + if (!string.IsNullOrEmpty(a.Detail)) sess += $", {a.Detail}"; + if (a.ContextPercent > 0) sess += $", {a.ContextPercent}% context"; + parts.Add(sess); + } + return string.Join(". ", parts) + "."; + } + /// /// Mini progress bar built from Borders inside a Grid (two Star columns: /// pct and 100-pct). Avoids the default WinUI ProgressBar template which @@ -347,8 +523,8 @@ private static FrameworkElement BuildMiniBar(double percent) : "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. + // Subtle hairline stroke 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 @@ -671,9 +847,7 @@ private static UIElement BuildSessionListCard( TotalTokens = session.TotalTokens, ContextTokens = session.ContextTokens, }) ?? ""; - var usedTokens = session.TotalTokens > 0 - ? session.TotalTokens - : session.InputTokens + session.OutputTokens; + var usedTokens = TrayDashboardSummaryBuilder.SessionUsedTokens(session); var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; var pct = usedTokens > 0 ? Math.Min(100.0, (double)usedTokens / contextTokens * 100.0) : 0.0; @@ -1056,11 +1230,15 @@ private UIElement BuildUsageRow(Microsoft.UI.Xaml.Media.Brush secondaryText) 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; + // Use the shared positive-fallback helpers so this row can't contradict + // the dashboard glance (a present-but-zero Usage must not hide UsageCost). + var totalTokens = TrayDashboardSummaryBuilder.FirstPositiveTokens( + _snapshot.Usage?.TotalTokens, + _snapshot.UsageCost?.Totals.TotalTokens, + _snapshot.Sessions.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens)); + var cost = TrayDashboardSummaryBuilder.FirstPositiveCost( + _snapshot.Usage?.CostUsd, + _snapshot.UsageCost?.Totals.TotalCost); string summaryText; if (cost <= 0 && totalTokens <= 0) { @@ -1099,15 +1277,17 @@ private List BuildUsageFlyoutItems(Microsoft.UI.Xaml.Media.B new() { Text = "Usage", IsHeader = true } }; - var totalTokens = _snapshot.Usage?.TotalTokens - ?? _snapshot.Sessions.Sum(s => s.InputTokens + s.OutputTokens); + var totalTokens = TrayDashboardSummaryBuilder.FirstPositiveTokens( + _snapshot.Usage?.TotalTokens, + _snapshot.UsageCost?.Totals.TotalTokens, + _snapshot.Sessions.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens)); 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 cost = TrayDashboardSummaryBuilder.FirstPositiveCost( + _snapshot.Usage?.CostUsd, + _snapshot.UsageCost?.Totals.TotalCost); var requests = _snapshot.Usage?.RequestCount ?? 0; // Totals card @@ -1234,7 +1414,7 @@ private List BuildUsageFlyoutItems(Microsoft.UI.Xaml.Media.B 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) }) + .Select(g => new { Model = g.Key, Tokens = g.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens) }) .Where(x => x.Tokens > 0) .OrderByDescending(x => x.Tokens) .Take(3) diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index ac9645a8..4bb61d93 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -71,6 +71,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs new file mode 100644 index 00000000..55ff9274 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -0,0 +1,573 @@ +using OpenClaw.Shared; +using OpenClawTray.Services; +using System; +using Xunit; + +namespace OpenClaw.Tray.Tests.Services; + +public sealed class TrayDashboardSummaryBuilderTests +{ + private static readonly DateTime FixedNowUtc = new(2024, 1, 15, 10, 30, 45, DateTimeKind.Utc); + + private static TrayMenuSnapshot Base( + ConnectionStatus status = ConnectionStatus.Connected, + string? gatewayUrl = "http://localhost:7070", + string? authFailure = null, + GatewayNodeInfo[]? nodes = null, + SessionInfo[]? sessions = null, + GatewayUsageInfo? usage = null, + DateTime? lastUpdated = null, + PairingListInfo? nodePairList = null, + DevicePairingListInfo? devicePairList = null) => new() + { + CurrentStatus = status, + AuthFailureMessage = authFailure, + GatewayUrl = gatewayUrl, + GatewaySelf = null, + Presence = null, + EnableNodeMode = false, + NodeIsPaired = false, + NodeIsPendingApproval = false, + NodeIsConnected = false, + NodePairList = nodePairList, + DevicePairList = devicePairList, + Nodes = nodes ?? Array.Empty(), + Sessions = sessions ?? Array.Empty(), + Usage = usage, + UsageStatus = null, + UsageCost = null, + Settings = null, + SetupMenuLabel = "Reconfigure...", + ShowSetupMenuEntry = true, + LastUpdated = lastUpdated, + }; + + private static TrayDashboardSummary Build(TrayMenuSnapshot snapshot) => + new TrayDashboardSummaryBuilder(snapshot, FixedNowUtc).Build(); + + // ── Health classification ── + + [Fact] + public void Connected_IsOkSeverityWithEndpoint() + { + var summary = Build(Base(ConnectionStatus.Connected)); + + Assert.Equal(TrayHealthSeverity.Ok, summary.Severity); + Assert.Equal("Connected", summary.Headline); + Assert.Equal("localhost:7070", summary.Endpoint); + } + + [Fact] + public void Disconnected_IsNeutralSeverity() + { + var summary = Build(Base(ConnectionStatus.Disconnected)); + + Assert.Equal(TrayHealthSeverity.Neutral, summary.Severity); + Assert.Equal("Disconnected", summary.Headline); + } + + [Fact] + public void Connecting_IsCautionSeverity() + { + var summary = Build(Base(ConnectionStatus.Connecting)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Connecting…", summary.Headline); + } + + [Fact] + public void Error_IsCriticalSeverity() + { + var summary = Build(Base(ConnectionStatus.Error)); + + Assert.Equal(TrayHealthSeverity.Critical, summary.Severity); + Assert.Equal("Connection error", summary.Headline); + } + + [Fact] + public void AuthFailure_OverridesConnectedWithCriticalSeverity() + { + var summary = Build(Base(ConnectionStatus.Connected, authFailure: "token expired")); + + Assert.Equal(TrayHealthSeverity.Critical, summary.Severity); + Assert.Equal("Authentication failed", summary.Headline); + } + + [Fact] + public void PendingPairing_TakesPriorityOverConnectedStatus() + { + var nodePairList = new PairingListInfo(); + nodePairList.Pending.Add(new PairingRequest()); + nodePairList.Pending.Add(new PairingRequest()); + + var summary = Build(Base(ConnectionStatus.Connected, nodePairList: nodePairList)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Pairing approval pending (2)", summary.Headline); + } + + [Fact] + public void NullGatewayUrl_ProducesNullEndpoint() + { + var summary = Build(Base(gatewayUrl: null)); + + Assert.Null(summary.Endpoint); + } + + // ── Heartbeat ── + + [Fact] + public void Heartbeat_NullWhenNoLastUpdated() + { + var summary = Build(Base(lastUpdated: null)); + + Assert.Null(summary.Heartbeat); + } + + [Fact] + public void Heartbeat_FormatsRelativeAge() + { + var summary = Build(Base(lastUpdated: FixedNowUtc.AddSeconds(-12))); + + Assert.Equal("Updated 12s ago", summary.Heartbeat); + } + + [Fact] + public void Heartbeat_FormatsMinutes() + { + var summary = Build(Base(lastUpdated: FixedNowUtc.AddMinutes(-5))); + + Assert.Equal("Updated 5m ago", summary.Heartbeat); + } + + [Fact] + public void Heartbeat_ConvertsLocalTimestampToUtc() + { + var localUpdated = FixedNowUtc.AddMinutes(-7).ToLocalTime(); + + var summary = Build(Base(lastUpdated: localUpdated)); + + Assert.Equal("Updated 7m ago", summary.Heartbeat); + } + + [Fact] + public void Heartbeat_FutureTimestampClampsToZero() + { + var summary = Build(Base(lastUpdated: FixedNowUtc.AddSeconds(30))); + + Assert.Equal("Updated 0s ago", summary.Heartbeat); + } + + // ── Metrics line ── + + [Fact] + public void MetricsLine_NullWhenNothingToShow() + { + var summary = Build(Base(ConnectionStatus.Disconnected)); + + Assert.Null(summary.MetricsLine); + } + + [Fact] + public void MetricsLine_SummarizesNodesOnlineOverTotal() + { + var nodes = new[] + { + new GatewayNodeInfo { IsOnline = true }, + new GatewayNodeInfo { IsOnline = false }, + new GatewayNodeInfo { IsOnline = true }, + }; + + var summary = Build(Base(nodes: nodes)); + + Assert.NotNull(summary.MetricsLine); + Assert.Contains("2/3 nodes", summary.MetricsLine); + } + + [Fact] + public void MetricsLine_SingularNodeLabel() + { + var nodes = new[] { new GatewayNodeInfo { IsOnline = true } }; + + var summary = Build(Base(nodes: nodes)); + + Assert.Contains("1/1 node", summary.MetricsLine); + } + + [Fact] + public void MetricsLine_ShowsSessionsAndActiveCount() + { + var sessions = new[] + { + new SessionInfo { Key = "a", Status = "active" }, + new SessionInfo { Key = "b", Status = "idle" }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Contains("2 sessions (1 active)", summary.MetricsLine); + } + + [Fact] + public void MetricsLine_ShowsCostWhenConnected() + { + var usage = new GatewayUsageInfo { CostUsd = 1.25, TotalTokens = 5000 }; + + var summary = Build(Base(ConnectionStatus.Connected, usage: usage)); + + Assert.Contains("$1.25", summary.MetricsLine); + } + + [Fact] + public void MetricsLine_OmitsUsageWhenDisconnected() + { + var usage = new GatewayUsageInfo { CostUsd = 1.25, TotalTokens = 5000 }; + var nodes = new[] { new GatewayNodeInfo { IsOnline = true } }; + + var summary = Build(Base(ConnectionStatus.Disconnected, nodes: nodes, usage: usage)); + + Assert.DoesNotContain("$", summary.MetricsLine); + } + + [Fact] + public void MetricsLine_FallsBackToSessionTokensWhenNoUsage() + { + var sessions = new[] + { + new SessionInfo { Key = "a", Status = "active", InputTokens = 1500, OutputTokens = 500 }, + }; + + var summary = Build(Base(ConnectionStatus.Connected, sessions: sessions)); + + Assert.Contains("2.0K tokens", summary.MetricsLine); + } + + [Fact] + public void MetricsLine_SubCentCost_FallsBackToTokens() + { + var usage = new GatewayUsageInfo { CostUsd = 0.004, TotalTokens = 2500 }; + + var summary = Build(Base(ConnectionStatus.Connected, usage: usage)); + + Assert.Contains("2.5K tokens", summary.MetricsLine); + Assert.DoesNotContain("$0.00", summary.MetricsLine); + } + + // ── Active session ── + + [Fact] + public void ActiveSession_NullWhenNoSessions() + { + var summary = Build(Base()); + + Assert.Null(summary.ActiveSession); + Assert.False(summary.HasActiveSession); + } + + [Fact] + public void ActiveSession_PrefersMainSession() + { + var sessions = new[] + { + new SessionInfo { Key = "sub", IsMain = false, DisplayName = "Sub" }, + new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main work" }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.NotNull(summary.ActiveSession); + Assert.Equal("Main work", summary.ActiveSession!.Title); + } + + [Fact] + public void ActiveSession_FallsBackToMostRecentlyUpdated() + { + var sessions = new[] + { + new SessionInfo { Key = "old", IsMain = false, DisplayName = "Older", UpdatedAt = FixedNowUtc.AddMinutes(-30) }, + new SessionInfo { Key = "new", IsMain = false, DisplayName = "Newer", UpdatedAt = FixedNowUtc.AddMinutes(-1) }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal("Newer", summary.ActiveSession!.Title); + } + + [Fact] + public void ActiveSession_ComputesContextPercent() + { + var sessions = new[] + { + new SessionInfo + { + Key = "main", IsMain = true, DisplayName = "Main", + Model = "claude-opus", InputTokens = 90_000, OutputTokens = 10_000, + ContextTokens = 200_000, + }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal(50, summary.ActiveSession!.ContextPercent); + Assert.NotNull(summary.ActiveSession.Detail); + Assert.Contains("claude-opus", summary.ActiveSession.Detail); + Assert.DoesNotContain("ctx", summary.ActiveSession.Detail); + } + + [Fact] + public void ActiveSession_DefaultsContextWindowWhenUnset() + { + var sessions = new[] + { + new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main", InputTokens = 20_000, OutputTokens = 0 }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal(10, summary.ActiveSession!.ContextPercent); + } + + [Fact] + public void ActiveSession_DoesNotExposeCurrentActivityInTopLevelGlance() + { + var sessions = new[] + { + new SessionInfo + { + Key = "main", + IsMain = true, + DisplayName = "Main", + CurrentActivity = "🔧 curl https://internal.example.test/secrets" + }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.NotNull(summary.ActiveSession); + Assert.Null(summary.ActiveSession!.Detail); + } + + // ── Edge cases: severity ordering, usage fallbacks, formatting ── + + [Fact] + public void AuthFailure_OutranksPendingPairing() + { + var nodePairList = new PairingListInfo(); + nodePairList.Pending.Add(new PairingRequest()); + + var summary = Build(Base( + ConnectionStatus.Connected, authFailure: "token expired", nodePairList: nodePairList)); + + Assert.Equal(TrayHealthSeverity.Critical, summary.Severity); + Assert.Equal("Authentication failed", summary.Headline); + } + + [Fact] + public void Usage_NonNullZeroCost_FallsBackToUsageCostTotals() + { + var usage = new GatewayUsageInfo { CostUsd = 0, TotalTokens = 0 }; + var usageCost = new GatewayCostUsageInfo + { + Totals = new GatewayCostUsageTotalsInfo { TotalCost = 2.50, TotalTokens = 1234 }, + }; + var snapshot = Base(ConnectionStatus.Connected, usage: usage) with { UsageCost = usageCost }; + + var summary = Build(snapshot); + + Assert.Contains("$2.50", summary.MetricsLine); + } + + [Fact] + public void Usage_NonNullZeroTokens_FallsBackToSessionTotals() + { + var usage = new GatewayUsageInfo { CostUsd = 0, TotalTokens = 0 }; + var sessions = new[] + { + new SessionInfo { Key = "a", Status = "active", TotalTokens = 3000 }, + }; + + var summary = Build(Base(ConnectionStatus.Connected, usage: usage, sessions: sessions)); + + Assert.Contains("3.0K tokens", summary.MetricsLine); + } + + [Fact] + public void Endpoint_SuppressesDefaultPort() + { + var summary = Build(Base(gatewayUrl: "https://gateway.example.com")); + + Assert.Equal("gateway.example.com", summary.Endpoint); + } + + [Fact] + public void Endpoint_KeepsExplicitNonDefaultPort() + { + var summary = Build(Base(gatewayUrl: "http://localhost:7070")); + + Assert.Equal("localhost:7070", summary.Endpoint); + } + + [Fact] + public void ActiveSession_PrefersActiveSubOverIdleMain() + { + var sessions = new[] + { + new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main", Status = "idle" }, + new SessionInfo { Key = "sub", IsMain = false, DisplayName = "Worker", Status = "active" }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal("Worker", summary.ActiveSession!.Title); + Assert.Equal("Active", summary.ActiveSession.Label); + } + + [Fact] + public void ActiveSession_PrefersActiveMainOverActiveSub() + { + var sessions = new[] + { + new SessionInfo + { + Key = "main", IsMain = true, DisplayName = "Main", + Status = "active", UpdatedAt = FixedNowUtc.AddMinutes(-30) + }, + new SessionInfo + { + Key = "sub", IsMain = false, DisplayName = "Worker", + Status = "active", UpdatedAt = FixedNowUtc.AddMinutes(-1) + }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal("Main", summary.ActiveSession!.Title); + Assert.Equal("Active", summary.ActiveSession.Label); + } + + [Fact] + public void ActiveSession_LabelIsMainForIdleMain() + { + var sessions = new[] + { + new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main", Status = "idle" }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal("Main", summary.ActiveSession!.Label); + } + + [Fact] + public void ActiveSession_ContextPercentClampsAtHundredWhenOverBudget() + { + var sessions = new[] + { + new SessionInfo + { + Key = "main", IsMain = true, DisplayName = "Main", + InputTokens = 250_000, OutputTokens = 50_000, ContextTokens = 200_000, + }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal(100, summary.ActiveSession!.ContextPercent); + } + + [Fact] + public void ActiveSession_ContextPercentRoundsToNearest() + { + var sessions = new[] + { + new SessionInfo + { + Key = "main", IsMain = true, DisplayName = "Main", + // 179800 / 200000 = 89.9% -> rounds to 90 + InputTokens = 179_800, OutputTokens = 0, ContextTokens = 200_000, + }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal(90, summary.ActiveSession!.ContextPercent); + } + + [Fact] + public void Heartbeat_HiddenWhenDisconnected() + { + var summary = Build(Base(ConnectionStatus.Disconnected, lastUpdated: FixedNowUtc.AddSeconds(-5))); + + Assert.Null(summary.Heartbeat); + } + + [Fact] + public void Heartbeat_FormatsHoursAndDays() + { + var hours = Build(Base(lastUpdated: FixedNowUtc.AddHours(-3))); + Assert.Equal("Updated 3h ago", hours.Heartbeat); + + var days = Build(Base(lastUpdated: FixedNowUtc.AddDays(-2))); + Assert.Equal("Updated 2d ago", days.Heartbeat); + } + + [Fact] + public void MetricsLine_FormatsMillionsTokenCount() + { + var usage = new GatewayUsageInfo { CostUsd = 0, TotalTokens = 2_500_000 }; + + var summary = Build(Base(ConnectionStatus.Connected, usage: usage)); + + Assert.Contains("2.5M tokens", summary.MetricsLine); + } + + [Fact] + public void ActiveSession_ContextPercentUsesTotalTokensWhenInputOutputZero() + { + var sessions = new[] + { + new SessionInfo + { + Key = "main", IsMain = true, DisplayName = "Main", + TotalTokens = 100_000, InputTokens = 0, OutputTokens = 0, ContextTokens = 200_000, + }, + }; + + var summary = Build(Base(sessions: sessions)); + + Assert.Equal(50, summary.ActiveSession!.ContextPercent); + } + + [Fact] + public void SessionUsedTokens_PrefersTotalTokensWhenPositive() + { + var withTotal = new SessionInfo { TotalTokens = 5000, InputTokens = 1, OutputTokens = 1 }; + var withoutTotal = new SessionInfo { TotalTokens = 0, InputTokens = 1200, OutputTokens = 300 }; + + Assert.Equal(5000, TrayDashboardSummaryBuilder.SessionUsedTokens(withTotal)); + Assert.Equal(1500, TrayDashboardSummaryBuilder.SessionUsedTokens(withoutTotal)); + } + + [Fact] + public void MetricsLine_MixedSessionTokenSources_SumsBothPerSession() + { + // One session reports TotalTokens only, the other input/output only. + // The token fallback must count both (3000 + 1500 = 4500 = "4.5K"). + var usage = new GatewayUsageInfo { CostUsd = 0, TotalTokens = 0 }; + var sessions = new[] + { + new SessionInfo { Key = "a", Status = "active", TotalTokens = 3000 }, + new SessionInfo { Key = "b", Status = "idle", InputTokens = 1000, OutputTokens = 500 }, + }; + + var summary = Build(Base(ConnectionStatus.Connected, usage: usage, sessions: sessions)); + + Assert.Contains("4.5K tokens", summary.MetricsLine); + } + + [Fact] + public void SelectActiveSession_EmptyReturnsNull() + { + Assert.Null(TrayDashboardSummaryBuilder.SelectActiveSession(Array.Empty())); + } +}