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()));
+ }
+}