From e59332514faca77aa573f0a85a1603cd0d5fb958 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 26 Jun 2026 00:35:57 -0700 Subject: [PATCH 1/3] Add tray live dashboard glance Add a compact status summary to the tray flyout that surfaces gateway health, refresh freshness, session and usage metrics, and the current session preview before the existing action rows. Wire a Diagnostics action into the tray menu and keep the summary computation in a testable, render-free builder shared by existing usage/session rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 21 + .../Services/TrayDashboardSummary.cs | 274 ++++++++ .../Services/TrayMenuSnapshot.cs | 5 + .../Services/TrayMenuStateBuilder.cs | 229 +++++- .../OpenClaw.Tray.Tests.csproj | 2 + .../TrayDashboardSummaryBuilderTests.cs | 649 ++++++++++++++++++ 6 files changed, 1163 insertions(+), 17 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs create mode 100644 tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 8199f1838..8cd64e0e3 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,9 +1319,29 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() Settings = _settings, SetupMenuLabel = setupMenuLabel, ShowSetupMenuEntry = !hasSetupManagedLocalWslGateway, + LastUpdated = _appState?.LastCheckTime, + RecentPreview = CaptureActiveSessionPreview(), }; } + /// + /// Resolves the conversation preview for the dashboard glance's active + /// session. Uses the same selector as the summary builder so the title and + /// preview stay in sync. + /// + private SessionPreviewInfo? CaptureActiveSessionPreview() + { + var sessions = _appState?.Sessions; + if (sessions == null || sessions.Length == 0) + return null; + + var session = TrayDashboardSummaryBuilder.SelectActiveSession(sessions); + if (session == null || string.IsNullOrEmpty(session.Key)) + return null; + + return _appState?.GetSessionPreview(session.Key); + } + /// /// Opt-in design preview: when the OPENCLAW_TRAY_PREVIEW_DATA diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs new file mode 100644 index 000000000..f5796bcc3 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -0,0 +1,274 @@ +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, + string? PreviewText); + +/// +/// 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 model and/or current activity — the context % lives in + // its own trailing chip, so don't repeat it here. + var detailParts = new List(2); + if (!string.IsNullOrWhiteSpace(session.Model)) detailParts.Add(session.Model!); + if (usedTokens <= 0 && !string.IsNullOrWhiteSpace(session.CurrentActivity)) + detailParts.Add(session.CurrentActivity!); + var detail = detailParts.Count == 0 ? null : string.Join(" · ", detailParts); + + return new TrayDashboardActiveSession( + Label: label, + Title: title, + Detail: detail, + ContextPercent: pct, + PreviewText: BuildPreviewText(session.Key)); + } + + private string? BuildPreviewText(string activeSessionKey) + { + var preview = _snapshot.RecentPreview; + if (preview?.Items == null || preview.Items.Count == 0) + return null; + + if (!string.IsNullOrEmpty(activeSessionKey) + && !string.IsNullOrEmpty(preview.Key) + && !string.Equals(preview.Key, activeSessionKey, StringComparison.Ordinal)) + { + return null; + } + + var last = preview.Items[^1]; + var text = last.Text?.Replace('\n', ' ').Replace('\r', ' ').Trim(); + if (string.IsNullOrEmpty(text)) + return null; + + var role = string.IsNullOrWhiteSpace(last.Role) ? null : last.Role.Trim(); + var line = role != null ? $"{role}: {text}" : text; + return Truncate(line, 80); + } + + 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"; + } + + private static string Truncate(string value, int max) + { + if (value.Length <= max) return value; + return value[..(max - 1)].TrimEnd() + "…"; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs index 48415c611..5eb2c6145 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,8 @@ 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; } + internal SessionPreviewInfo? RecentPreview { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index 4d3e8ee8b..b16b4e21b 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,188 @@ 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 + }); + } + + if (!string.IsNullOrEmpty(active.PreviewText)) + { + outer.Children.Add(new TextBlock + { + Text = active.PreviewText, + Style = captionStyle, + FontSize = 11, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + MaxLines = 2, + MaxWidth = 280, + 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 +538,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 +862,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 +1245,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 +1292,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 +1429,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 ac9645a8c..4bb61d930 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 000000000..3348dfd22 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -0,0 +1,649 @@ +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, + SessionPreviewInfo? recentPreview = 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, + RecentPreview = recentPreview, + }; + + 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_IncludesPreviewTailWithRole() + { + var preview = new SessionPreviewInfo + { + Key = "main", + Items = + { + new SessionPreviewItemInfo { Role = "user", Text = "first" }, + new SessionPreviewItemInfo { Role = "assistant", Text = "the latest reply" }, + }, + }; + var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; + + var summary = Build(Base(sessions: sessions, recentPreview: preview)); + + Assert.Equal("assistant: the latest reply", summary.ActiveSession!.PreviewText); + } + + [Fact] + public void ActiveSession_PreviewNullWhenNoPreviewItems() + { + var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; + + var summary = Build(Base(sessions: sessions, recentPreview: null)); + + Assert.Null(summary.ActiveSession!.PreviewText); + } + + [Fact] + public void ActiveSession_PreviewCollapsesNewlinesAndTruncates() + { + var longText = new string('x', 200); + var preview = new SessionPreviewInfo + { + Key = "main", + Items = { new SessionPreviewItemInfo { Role = "assistant", Text = "line1\nline2\r" + longText } }, + }; + var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; + + var summary = Build(Base(sessions: sessions, recentPreview: preview)); + + var text = summary.ActiveSession!.PreviewText!; + Assert.DoesNotContain('\n', text); + Assert.DoesNotContain('\r', text); + Assert.True(text.Length <= 80); + Assert.EndsWith("…", text); + } + + // ── 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 Preview_IgnoredWhenKeyDoesNotMatchActiveSession() + { + var preview = new SessionPreviewInfo + { + Key = "other-session", + Items = { new SessionPreviewItemInfo { Role = "assistant", Text = "stale" } }, + }; + var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; + + var summary = Build(Base(sessions: sessions, recentPreview: preview)); + + Assert.Null(summary.ActiveSession!.PreviewText); + } + + [Fact] + public void Preview_UsedWhenKeyEmptyOnEitherSide() + { + var preview = new SessionPreviewInfo + { + Key = "", + Items = { new SessionPreviewItemInfo { Role = "assistant", Text = "reply" } }, + }; + var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; + + var summary = Build(Base(sessions: sessions, recentPreview: preview)); + + Assert.Equal("assistant: reply", summary.ActiveSession!.PreviewText); + } + + [Fact] + public void Preview_WhitespaceRole_OmitsRolePrefix() + { + var preview = new SessionPreviewInfo + { + Key = "main", + Items = { new SessionPreviewItemInfo { Role = " ", Text = "bare text" } }, + }; + var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; + + var summary = Build(Base(sessions: sessions, recentPreview: preview)); + + Assert.Equal("bare text", summary.ActiveSession!.PreviewText); + } + + [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())); + } +} From cd58cea6fadc9e713d921488abe4e70f5694b15f Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 26 Jun 2026 09:08:54 -0700 Subject: [PATCH 2/3] Remove message previews from tray glance Keep the tray dashboard focused on health, freshness, session metadata, and usage while avoiding conversation message text in the top-level flyout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 19 ---- .../Services/TrayDashboardSummary.cs | 34 +------ .../Services/TrayMenuSnapshot.cs | 1 - .../Services/TrayMenuStateBuilder.cs | 15 --- .../TrayDashboardSummaryBuilderTests.cs | 96 ------------------- 5 files changed, 2 insertions(+), 163 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 8cd64e0e3..80cfa4a53 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1320,28 +1320,9 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() SetupMenuLabel = setupMenuLabel, ShowSetupMenuEntry = !hasSetupManagedLocalWslGateway, LastUpdated = _appState?.LastCheckTime, - RecentPreview = CaptureActiveSessionPreview(), }; } - /// - /// Resolves the conversation preview for the dashboard glance's active - /// session. Uses the same selector as the summary builder so the title and - /// preview stay in sync. - /// - private SessionPreviewInfo? CaptureActiveSessionPreview() - { - var sessions = _appState?.Sessions; - if (sessions == null || sessions.Length == 0) - return null; - - var session = TrayDashboardSummaryBuilder.SelectActiveSession(sessions); - if (session == null || string.IsNullOrEmpty(session.Key)) - return null; - - return _appState?.GetSessionPreview(session.Key); - } - /// /// Opt-in design preview: when the OPENCLAW_TRAY_PREVIEW_DATA diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs index f5796bcc3..d9e7d8cd7 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -18,8 +18,7 @@ internal sealed record TrayDashboardActiveSession( string Label, string Title, string? Detail, - int ContextPercent, - string? PreviewText); + int ContextPercent); /// /// Pure, render-free computation for the tray dashboard glance. @@ -224,31 +223,7 @@ static bool IsActive(SessionInfo s) => Label: label, Title: title, Detail: detail, - ContextPercent: pct, - PreviewText: BuildPreviewText(session.Key)); - } - - private string? BuildPreviewText(string activeSessionKey) - { - var preview = _snapshot.RecentPreview; - if (preview?.Items == null || preview.Items.Count == 0) - return null; - - if (!string.IsNullOrEmpty(activeSessionKey) - && !string.IsNullOrEmpty(preview.Key) - && !string.Equals(preview.Key, activeSessionKey, StringComparison.Ordinal)) - { - return null; - } - - var last = preview.Items[^1]; - var text = last.Text?.Replace('\n', ' ').Replace('\r', ' ').Trim(); - if (string.IsNullOrEmpty(text)) - return null; - - var role = string.IsNullOrWhiteSpace(last.Role) ? null : last.Role.Trim(); - var line = role != null ? $"{role}: {text}" : text; - return Truncate(line, 80); + ContextPercent: pct); } private static string FormatTokenCount(long n) @@ -266,9 +241,4 @@ private static string FormatAge(TimeSpan age) return $"{(int)age.TotalDays}d ago"; } - private static string Truncate(string value, int max) - { - if (value.Length <= max) return value; - return value[..(max - 1)].TrimEnd() + "…"; - } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs index 5eb2c6145..5a818bc4c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs @@ -37,5 +37,4 @@ internal sealed record TrayMenuSnapshot // ── Dashboard glance ── internal DateTime? LastUpdated { get; init; } - internal SessionPreviewInfo? RecentPreview { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index b16b4e21b..aa63cf6b5 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -484,21 +484,6 @@ private static UIElement BuildDashboardGlance( }); } - if (!string.IsNullOrEmpty(active.PreviewText)) - { - outer.Children.Add(new TextBlock - { - Text = active.PreviewText, - Style = captionStyle, - FontSize = 11, - Foreground = secondaryText, - TextWrapping = TextWrapping.Wrap, - MaxLines = 2, - MaxWidth = 280, - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }); - } } AutomationProperties.SetName(outer, BuildGlanceAutomationName(summary)); diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 3348dfd22..15cc59644 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -17,7 +17,6 @@ private static TrayMenuSnapshot Base( SessionInfo[]? sessions = null, GatewayUsageInfo? usage = null, DateTime? lastUpdated = null, - SessionPreviewInfo? recentPreview = null, PairingListInfo? nodePairList = null, DevicePairingListInfo? devicePairList = null) => new() { @@ -41,7 +40,6 @@ private static TrayMenuSnapshot Base( SetupMenuLabel = "Reconfigure...", ShowSetupMenuEntry = true, LastUpdated = lastUpdated, - RecentPreview = recentPreview, }; private static TrayDashboardSummary Build(TrayMenuSnapshot snapshot) => @@ -329,55 +327,6 @@ public void ActiveSession_DefaultsContextWindowWhenUnset() Assert.Equal(10, summary.ActiveSession!.ContextPercent); } - [Fact] - public void ActiveSession_IncludesPreviewTailWithRole() - { - var preview = new SessionPreviewInfo - { - Key = "main", - Items = - { - new SessionPreviewItemInfo { Role = "user", Text = "first" }, - new SessionPreviewItemInfo { Role = "assistant", Text = "the latest reply" }, - }, - }; - var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; - - var summary = Build(Base(sessions: sessions, recentPreview: preview)); - - Assert.Equal("assistant: the latest reply", summary.ActiveSession!.PreviewText); - } - - [Fact] - public void ActiveSession_PreviewNullWhenNoPreviewItems() - { - var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; - - var summary = Build(Base(sessions: sessions, recentPreview: null)); - - Assert.Null(summary.ActiveSession!.PreviewText); - } - - [Fact] - public void ActiveSession_PreviewCollapsesNewlinesAndTruncates() - { - var longText = new string('x', 200); - var preview = new SessionPreviewInfo - { - Key = "main", - Items = { new SessionPreviewItemInfo { Role = "assistant", Text = "line1\nline2\r" + longText } }, - }; - var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; - - var summary = Build(Base(sessions: sessions, recentPreview: preview)); - - var text = summary.ActiveSession!.PreviewText!; - Assert.DoesNotContain('\n', text); - Assert.DoesNotContain('\r', text); - Assert.True(text.Length <= 80); - Assert.EndsWith("…", text); - } - // ── Edge cases: severity ordering, usage fallbacks, formatting ── [Fact] @@ -489,51 +438,6 @@ public void ActiveSession_LabelIsMainForIdleMain() Assert.Equal("Main", summary.ActiveSession!.Label); } - [Fact] - public void Preview_IgnoredWhenKeyDoesNotMatchActiveSession() - { - var preview = new SessionPreviewInfo - { - Key = "other-session", - Items = { new SessionPreviewItemInfo { Role = "assistant", Text = "stale" } }, - }; - var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; - - var summary = Build(Base(sessions: sessions, recentPreview: preview)); - - Assert.Null(summary.ActiveSession!.PreviewText); - } - - [Fact] - public void Preview_UsedWhenKeyEmptyOnEitherSide() - { - var preview = new SessionPreviewInfo - { - Key = "", - Items = { new SessionPreviewItemInfo { Role = "assistant", Text = "reply" } }, - }; - var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; - - var summary = Build(Base(sessions: sessions, recentPreview: preview)); - - Assert.Equal("assistant: reply", summary.ActiveSession!.PreviewText); - } - - [Fact] - public void Preview_WhitespaceRole_OmitsRolePrefix() - { - var preview = new SessionPreviewInfo - { - Key = "main", - Items = { new SessionPreviewItemInfo { Role = " ", Text = "bare text" } }, - }; - var sessions = new[] { new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" } }; - - var summary = Build(Base(sessions: sessions, recentPreview: preview)); - - Assert.Equal("bare text", summary.ActiveSession!.PreviewText); - } - [Fact] public void ActiveSession_ContextPercentClampsAtHundredWhenOverBudget() { From 5b31892293303e8d6f2c071dbc959410a925afc1 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Fri, 26 Jun 2026 15:44:56 -0700 Subject: [PATCH 3/3] Avoid activity snippets in tray dashboard glance Keep the top-level tray dashboard limited to stable metadata so command, query, path, or URL activity text stays out of the glance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/TrayDashboardSummary.cs | 9 ++++----- .../TrayDashboardSummaryBuilderTests.cs | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs index d9e7d8cd7..7a5a254d9 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -211,12 +211,11 @@ static bool IsActive(SessionInfo s) => ? (int)Math.Round(Math.Min(100.0, (double)usedTokens / contextTokens * 100.0)) : 0; - // Detail carries model and/or current activity — the context % lives in - // its own trailing chip, so don't repeat it here. - var detailParts = new List(2); + // 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!); - if (usedTokens <= 0 && !string.IsNullOrWhiteSpace(session.CurrentActivity)) - detailParts.Add(session.CurrentActivity!); var detail = detailParts.Count == 0 ? null : string.Join(" · ", detailParts); return new TrayDashboardActiveSession( diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 15cc59644..55ff92744 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -327,6 +327,26 @@ public void ActiveSession_DefaultsContextWindowWhenUnset() 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]