diff --git a/docs/MISSION_CONTROL.md b/docs/MISSION_CONTROL.md index d3629853d..2978ec568 100644 --- a/docs/MISSION_CONTROL.md +++ b/docs/MISSION_CONTROL.md @@ -469,7 +469,7 @@ Recent native chat behavior implemented during this phase: - Queued-message UI tracks local send state (`Queued`, `Sending`, `Failed`) until the gateway confirms or rejects the turn. - Timeline virtualization keeps long chat histories responsive while preserving stable render identity for visible entries. -- Completed sessions are hidden by default in the native chat session list, with active/in-progress sessions surfaced first. +- The Sessions page hides only successful completed runs by default; reusable sessions remain available in the native chat picker, with working sessions surfaced first. - TTS notification speech preserves full assistant text while UI preview truncation remains visual-only. - Slash-command suggestions use the gateway command catalog grouped into Mac-compatible command buckets. - Local MCP chat automation exposes `app.chat.snapshot`, `app.chat.send`, and `app.chat.reset` for current-thread inspection, send, and reset flows. diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 7bf7305e9..aa82a1fc4 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -272,6 +272,11 @@ public class SessionInfo public string? VerboseLevel { get; set; } public bool SystemSent { get; set; } public bool AbortedLastRun { get; set; } + /// + /// Gateway-provided liveness for the session's current run. Null means an + /// older Gateway did not provide the field, so callers may use legacy status. + /// + public bool? HasActiveRun { get; set; } public long InputTokens { get; set; } public long OutputTokens { get; set; } public long TotalTokens { get; set; } diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 14f62bdbb..597a0ece3 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -3588,7 +3588,19 @@ private void ParseSessions(JsonElement sessions) private void PopulateSessionFromObject(SessionInfo session, JsonElement item) { if (item.TryGetProperty("status", out var status)) - session.Status = status.GetString() ?? "active"; + session.Status = status.ValueKind == JsonValueKind.String + ? status.GetString() ?? "unknown" + : "unknown"; + else + session.Status = "unknown"; + if (item.TryGetProperty("hasActiveRun", out var hasActiveRun)) + { + session.HasActiveRun = hasActiveRun.ValueKind is JsonValueKind.True or JsonValueKind.False + ? hasActiveRun.GetBoolean() + : null; + } + else + session.HasActiveRun = null; if (item.TryGetProperty("model", out var model)) { var newModel = model.GetString(); diff --git a/src/OpenClaw.Shared/Sessions/SessionRunState.cs b/src/OpenClaw.Shared/Sessions/SessionRunState.cs new file mode 100644 index 000000000..c98fc97db --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionRunState.cs @@ -0,0 +1,86 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Sessions; + +/// +/// Canonical interpretation of the Gateway's latest-run outcome and live-run flag. +/// Keep this separate from presentation: the Gateway records outcomes per run, while a +/// session remains available for a later turn after that run finishes. +/// +public enum SessionRunStatus +{ + Unknown, + Running, + Completed, + Failed, + Stopped, + TimedOut, +} + +/// The intentionally small set of session states shown by the Windows app. +public enum SessionDisplayState +{ + Working, + Ready, + NeedsAttention, +} + +public static class SessionRunState +{ + public static SessionRunStatus ResolveStatus(string? status) => status?.Trim().ToLowerInvariant() switch + { + "active" or "running" => SessionRunStatus.Running, + "done" or "completed" => SessionRunStatus.Completed, + "error" or "failed" or "failure" => SessionRunStatus.Failed, + "killed" or "aborted" or "cancelled" or "canceled" => SessionRunStatus.Stopped, + "timeout" or "timed_out" => SessionRunStatus.TimedOut, + _ => SessionRunStatus.Unknown, + }; + + /// + /// A terminal outcome wins over a stale live-run flag. For older Gateways which + /// do not send hasActiveRun, preserve the legacy running fallback. + /// + public static bool IsWorking(SessionInfo session) + { + var status = ResolveStatus(session.Status); + if (status is SessionRunStatus.Completed or SessionRunStatus.Failed + or SessionRunStatus.Stopped or SessionRunStatus.TimedOut) + { + return false; + } + + return session.HasActiveRun ?? status == SessionRunStatus.Running; + } + + public static SessionDisplayState GetDisplayState(SessionInfo session) + { + if (IsWorking(session)) + return SessionDisplayState.Working; + + return ResolveStatus(session.Status) is SessionRunStatus.Failed or SessionRunStatus.TimedOut + ? SessionDisplayState.NeedsAttention + : SessionDisplayState.Ready; + } + + /// Stable priority for compact lists: live work, attention, then ready sessions. + public static int GetDisplaySortOrder(SessionInfo session) => GetDisplayState(session) switch + { + SessionDisplayState.Working => 0, + SessionDisplayState.NeedsAttention => 1, + _ => 2, + }; + + /// Successful completed runs may be hidden from the compact session list. + public static bool IsCompleted(SessionInfo session) => + !IsWorking(session) + && !session.AbortedLastRun + && ResolveStatus(session.Status) == SessionRunStatus.Completed; + + /// + /// A stopped run is useful context in a row or transcript, but is not a + /// fourth primary session status: the session is still ready for another turn. + /// + public static bool HasStoppedLastRun(SessionInfo session) => + session.AbortedLastRun || ResolveStatus(session.Status) == SessionRunStatus.Stopped; +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 53f20b671..7792a44d2 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Xaml.Media; using OpenClaw.Connection; using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; using OpenClawTray.Helpers; using OpenClawTray.Services; using System; @@ -758,9 +759,7 @@ private void ApplyOperatorCard(ConnectionPagePlan plan) // Status sub-row (mirrors PermissionsPage NodeStatusDot pattern): // colored dot + descriptive label that reflects the live state. var sessions = _appState?.Sessions; - int activeSessions = sessions?.Count(s => - string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase) || - string.Equals(s.Status, "running", StringComparison.OrdinalIgnoreCase)) ?? 0; + int activeSessions = sessions?.Count(SessionRunState.IsWorking) ?? 0; var (statusGlyph, statusBrushKey, statusText) = plan.OperatorCard switch { diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml index b6d5f86d8..eb95e6af7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml @@ -34,7 +34,7 @@ + (), ShowCompletedSessions) .ToList(); - var activeTitles = SessionTitleFormatter.FormatUnique(activeSessions); - IEnumerable<(SessionInfo Session, string Title)> filtered = activeSessions + var activeTitles = SessionTitleFormatter.FormatUnique(visibleSessions); + IEnumerable<(SessionInfo Session, string Title)> filtered = visibleSessions .Select((session, index) => (Session: session, Title: activeTitles[index])); if (_activeChannel != "all") @@ -206,7 +206,8 @@ private void ApplyFilter() } var viewModels = filtered - .OrderByDescending(item => item.Session.UpdatedAt ?? item.Session.LastSeen) + .OrderBy(item => SessionRunState.GetDisplaySortOrder(item.Session)) + .ThenByDescending(item => item.Session.UpdatedAt ?? item.Session.LastSeen) .Select(item => ToViewModel(item.Session, item.Title)) .ToList(); @@ -269,10 +270,12 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e) private SessionViewModel ToViewModel(SessionInfo s, string displayName) { - var parts = new List(3); + var parts = new List(4); if (!string.IsNullOrWhiteSpace(s.Provider)) parts.Add(s.Provider!); if (!string.IsNullOrWhiteSpace(s.Model)) parts.Add(s.Model!); if (!string.IsNullOrWhiteSpace(s.Channel)) parts.Add(s.Channel!); + if (SessionRunState.HasStoppedLastRun(s)) + parts.Add(LocalizationHelper.GetString("SessionsPage_LastRunStopped")); var hasTokens = s.InputTokens > 0 || s.OutputTokens > 0; var tokensText = hasTokens @@ -298,7 +301,8 @@ private SessionViewModel ToViewModel(SessionInfo s, string displayName) AgeText = s.AgeText, DetailLine = parts.Count > 0 ? string.Join(" · ", parts) : "", StatusBrush = ResolveStatusBrush(s), - StatusTooltip = ResolveStatusTooltip(s), + StatusText = ResolveStatusText(s), + StatusTooltip = ResolveStatusText(s), TokensText = tokensText, ContextPercent = contextPercent, HasTokenData = hasTokens || contextPercent > 0, @@ -310,23 +314,22 @@ private SessionViewModel ToViewModel(SessionInfo s, string displayName) private static Brush ResolveStatusBrush(SessionInfo s) { - var status = s.Status?.Trim().ToLowerInvariant(); - if (status is "error" or "failed" or "failure") - return s_criticalBrush.Value; - if (s.AbortedLastRun) - return s_cautionBrush.Value; - if (status is "active" or "running") - return s_successBrush.Value; - return s_neutralBrush.Value; + return SessionRunState.GetDisplayState(s) switch + { + SessionDisplayState.Working => s_successBrush.Value, + SessionDisplayState.NeedsAttention => s_criticalBrush.Value, + _ => s_neutralBrush.Value, + }; } - private static string ResolveStatusTooltip(SessionInfo s) + private static string ResolveStatusText(SessionInfo s) { - var status = s.Status?.Trim().ToLowerInvariant(); - if (status is "error" or "failed" or "failure") return "Error"; - if (s.AbortedLastRun) return "Aborted last run"; - if (status is "active" or "running") return "Running"; - return "Idle"; + return SessionRunState.GetDisplayState(s) switch + { + SessionDisplayState.Working => LocalizationHelper.GetString("SessionsPage_Status_Working"), + SessionDisplayState.NeedsAttention => LocalizationHelper.GetString("SessionsPage_Status_NeedsAttention"), + _ => LocalizationHelper.GetString("SessionsPage_Status_Ready"), + }; } private static readonly Lazy s_successBrush = @@ -923,7 +926,8 @@ public class SessionViewModel public string AgeText { get; set; } = ""; public string DetailLine { get; set; } = ""; public Brush StatusBrush { get; set; } = new SolidColorBrush(Colors.Gray); - public string StatusTooltip { get; set; } = "Idle"; + public string StatusText { get; set; } = "Ready"; + public string StatusTooltip { get; set; } = "Ready"; public string TokensText { get; set; } = ""; public double ContextPercent { get; set; } public bool HasTokenData { get; set; } diff --git a/src/OpenClaw.Tray.WinUI/Services/SessionVisibilityFilter.cs b/src/OpenClaw.Tray.WinUI/Services/SessionVisibilityFilter.cs index 29104a206..0d0a3b68a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SessionVisibilityFilter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SessionVisibilityFilter.cs @@ -1,31 +1,21 @@ using OpenClaw.Chat; using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; namespace OpenClawTray.Services; public static class SessionVisibilityFilter { - public static IEnumerable VisibleSessions(IEnumerable sessions, bool showEnded) - => showEnded ? sessions : sessions.Where(IsVisibleWhenEndedHidden); + public static IEnumerable VisibleSessions(IEnumerable sessions, bool showCompleted) + => showCompleted ? sessions : sessions.Where(IsVisibleWhenCompletedHidden); - public static bool IsVisibleWhenEndedHidden(SessionInfo session) - => !IsEnded(session); + public static bool IsVisibleWhenCompletedHidden(SessionInfo session) + => !IsCompleted(session); - public static bool IsEnded(SessionInfo session) - { - if (session.AbortedLastRun) - return false; - - var status = session.Status?.Trim(); - return string.Equals(status, "done", StringComparison.OrdinalIgnoreCase) - || string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase) - || string.Equals(status, "failed", StringComparison.OrdinalIgnoreCase) - || string.Equals(status, "killed", StringComparison.OrdinalIgnoreCase) - || string.Equals(status, "timeout", StringComparison.OrdinalIgnoreCase); - } + public static bool IsCompleted(SessionInfo session) => SessionRunState.IsCompleted(session); public static ChatThreadStatus ToChatThreadStatus(SessionInfo session) - => IsEnded(session) ? ChatThreadStatus.Ended : ChatThreadStatus.Running; + => SessionRunState.IsWorking(session) ? ChatThreadStatus.Running : ChatThreadStatus.Created; public static IEnumerable VisibleChatPickerThreads(IEnumerable threads) => threads.Where(IsVisibleInChatPicker); diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs index ddf24a074..eb365dc07 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; using System; using System.Collections.Generic; using System.Globalization; @@ -151,10 +152,9 @@ private bool HasRelevantMcpStartupError() => var sessionCount = _snapshot.Sessions.Length; if (sessionCount > 0) { - var active = _snapshot.Sessions.Count( - s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); + var active = _snapshot.Sessions.Count(SessionRunState.IsWorking); parts.Add(active > 0 - ? $"{sessionCount} {(sessionCount == 1 ? "session" : "sessions")} ({active} active)" + ? $"{sessionCount} {(sessionCount == 1 ? "session" : "sessions")} ({active} working)" : $"{sessionCount} {(sessionCount == 1 ? "session" : "sessions")}"); } @@ -208,14 +208,11 @@ internal static long SessionUsedTokens(SessionInfo session) => 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)); + var activeMain = sessions.FirstOrDefault(s => s.IsMain && SessionRunState.IsWorking(s)); if (activeMain != null) return activeMain; var activeRecent = sessions - .Where(IsActive) + .Where(SessionRunState.IsWorking) .OrderByDescending(s => s.UpdatedAt ?? s.LastSeen) .FirstOrDefault(); if (activeRecent != null) return activeRecent; @@ -232,8 +229,12 @@ static bool IsActive(SessionInfo s) => if (session == null) return null; - var isActive = string.Equals(session.Status, "active", StringComparison.OrdinalIgnoreCase); - var label = isActive ? "Active" : (session.IsMain ? "Main" : "Session"); + var label = SessionRunState.GetDisplayState(session) switch + { + SessionDisplayState.Working => "Working", + SessionDisplayState.NeedsAttention => "Needs attention", + _ => "Ready", + }; var title = !string.IsNullOrWhiteSpace(session.DisplayName) ? session.DisplayName! diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index eb3df3cd0..4af655246 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Xaml.Controls.Primitives; using OpenClaw.Chat; using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; using OpenClawTray.Chat; using OpenClawTray.Helpers; using OpenClawTray.Windows; @@ -292,7 +293,7 @@ or OpenClaw.Connection.OverallConnectionState.Degraded menu.AddSeparator(); var sessionCount = _snapshot.Sessions.Length; - var activeCount = _snapshot.Sessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); + var activeCount = _snapshot.Sessions.Count(SessionRunState.IsWorking); var totalTokensAll = _snapshot.Sessions.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens); // Single collapsed entry whose hover flyout reveals the session list. @@ -631,9 +632,9 @@ private static string FormatRelative(DateTime utc) // ── Sessions: collapsed entry + flyout list ───────────────────────── - private static UIElement BuildSessionsListRow(int total, int active, long totalTokens, Microsoft.UI.Xaml.Media.Brush secondaryText) + private static UIElement BuildSessionsListRow(int total, int working, long totalTokens, Microsoft.UI.Xaml.Media.Brush secondaryText) { - // Card row: [icon] Sessions (N active · X tokens) + // Card row: [icon] Sessions (N working · X tokens) var resources = Application.Current.Resources; var captionStyle = (Style)resources["CaptionTextBlockStyle"]; @@ -659,7 +660,7 @@ private static UIElement BuildSessionsListRow(int total, int active, long totalT var summary = new TextBlock { - Text = $"{active} active · {FormatTokenCount(totalTokens)} tokens", + Text = $"{working} working · {FormatTokenCount(totalTokens)} tokens", Style = captionStyle, FontSize = 11, Foreground = secondaryText, @@ -1204,7 +1205,7 @@ private List BuildSessionsListFlyoutItems( if (_snapshot.Sessions.Length == 0) { - items.Add(new() { Text = "No active sessions" }); + items.Add(new() { Text = "No sessions" }); return items; } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 0ac9dc2b0..65f8f37d2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4729,7 +4729,7 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo All - Show ended + Show completed Gateway disconnected @@ -4744,7 +4744,7 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo Loading sessions... - No active sessions + No sessions Reset @@ -6942,4 +6942,16 @@ Make sure the gateway is running. splitter + + Working + + + Ready + + + Needs attention + + + Last run stopped + diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 4bcb5c9a7..64c0cea10 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4697,7 +4697,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Chargement des sessions… - Aucune session active + Aucune session Réinitialiser @@ -6902,4 +6902,16 @@ Le binaire wxc-exec est introuvable. {1} S'il s'agit d'une build développeur, c séparateur + + En cours + + + Prête + + + À vérifier + + + Dernière exécution arrêtée + diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 7d82896c7..32bee1c9f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4683,7 +4683,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alle - Beëindigde sessies tonen + Voltooide sessies weergeven Gateway losgekoppeld @@ -4698,7 +4698,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Sessies worden geladen… - Geen actieve sessies + Geen sessies Resetten @@ -6903,4 +6903,16 @@ Het binaire bestand wxc-exec is niet gevonden. {1} Als dit een ontwikkelaarsbuil scheidingslijn + + Bezig + + + Gereed + + + Heeft aandacht nodig + + + Laatste uitvoering gestopt + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 88a3253cc..95002f25c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4682,7 +4682,7 @@ 全部 - 显示已结束的会话 + 显示已完成的会话 网关已断开 @@ -4697,7 +4697,7 @@ 正在加载会话… - 无活动会话 + 没有会话 重置 @@ -6902,4 +6902,16 @@ 分隔条 + + 工作中 + + + 就绪 + + + 需要注意 + + + 上次运行已停止 + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index aa5cdcaa1..e0a3d2b5e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4682,7 +4682,7 @@ 全部 - 顯示已結束的工作階段 + 顯示已完成的工作階段 閘道已中斷 @@ -4697,7 +4697,7 @@ 正在載入工作階段… - 沒有作用中的工作階段 + 沒有工作階段 重設 @@ -6902,4 +6902,16 @@ 分隔線 + + 處理中 + + + 就緒 + + + 需要注意 + + + 上次執行已停止 + diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 2fe242093..9e29b1613 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -1796,6 +1796,26 @@ public void ParseSessions_EmptyArray_ClearsPreviousSessions() Assert.Empty(helper.GetSessionList()); } + [Fact] + public void ParseSessions_PreservesGatewayRunLivenessAndDoesNotInventActiveStatus() + { + var helper = new GatewayClientTestHelper(); + + helper.ParseSessionsPayload(""" + [ + { "key": "agent:main:working", "status": "running", "hasActiveRun": true }, + { "key": "agent:main:idle", "status": "running", "hasActiveRun": false }, + { "key": "agent:main:unknown" } + ] + """); + + var sessions = helper.GetSessionList().ToDictionary(session => session.Key); + Assert.True(sessions["agent:main:working"].HasActiveRun == true); + Assert.True(sessions["agent:main:idle"].HasActiveRun == false); + Assert.Null(sessions["agent:main:unknown"].HasActiveRun); + Assert.Equal("unknown", sessions["agent:main:unknown"].Status); + } + [Fact] public void ParseUsageStatusPayload_PopulatesProviderSummary() { diff --git a/tests/OpenClaw.Shared.Tests/SessionRunStateTests.cs b/tests/OpenClaw.Shared.Tests/SessionRunStateTests.cs new file mode 100644 index 000000000..5f821d1a6 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SessionRunStateTests.cs @@ -0,0 +1,61 @@ +using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; + +namespace OpenClaw.Shared.Tests; + +public sealed class SessionRunStateTests +{ + [Theory] + [InlineData("running", null, true)] + [InlineData("running", false, false)] + [InlineData("running", true, true)] + [InlineData("done", true, false)] + [InlineData("failed", true, false)] + [InlineData("killed", true, false)] + [InlineData("timeout", true, false)] + [InlineData("unknown", true, true)] + public void IsWorking_UsesTerminalOutcomeBeforeGatewayLiveness( + string status, + bool? hasActiveRun, + bool expected) + { + var session = new SessionInfo { Status = status, HasActiveRun = hasActiveRun }; + + Assert.Equal(expected, SessionRunState.IsWorking(session)); + } + + [Theory] + [InlineData("running", true, SessionDisplayState.Working)] + [InlineData("running", false, SessionDisplayState.Ready)] + [InlineData("done", false, SessionDisplayState.Ready)] + [InlineData("killed", false, SessionDisplayState.Ready)] + [InlineData("failed", false, SessionDisplayState.NeedsAttention)] + [InlineData("timeout", false, SessionDisplayState.NeedsAttention)] + public void GetDisplayState_UsesOnlyThreeUserFacingStates( + string status, + bool hasActiveRun, + SessionDisplayState expected) + { + var session = new SessionInfo { Status = status, HasActiveRun = hasActiveRun }; + + Assert.Equal(expected, SessionRunState.GetDisplayState(session)); + } + + [Fact] + public void IsCompleted_RequiresACompletedRunThatIsNotWorking() + { + Assert.True(SessionRunState.IsCompleted(new SessionInfo { Status = "done", HasActiveRun = false })); + Assert.False(SessionRunState.IsCompleted(new SessionInfo { Status = "failed", HasActiveRun = false })); + Assert.False(SessionRunState.IsCompleted(new SessionInfo { Status = "running", HasActiveRun = false })); + Assert.False(SessionRunState.IsCompleted(new SessionInfo { Status = "done", AbortedLastRun = true })); + } + + [Fact] + public void HasStoppedLastRun_SeparatesRunContextFromTheReadyState() + { + var session = new SessionInfo { Status = "killed", HasActiveRun = false }; + + Assert.Equal(SessionDisplayState.Ready, SessionRunState.GetDisplayState(session)); + Assert.True(SessionRunState.HasStoppedLastRun(session)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs index cac295b7c..32e3b8db1 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs @@ -79,6 +79,14 @@ public void NodeCapabilityPills_ExposeStateThroughReadableTextPeer() Assert.DoesNotContain("AutomationProperties.SetName(pill", pageSource); } + [Fact] + public void SessionCount_UsesCanonicalRunLiveness() + { + var pageSource = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs"); + + Assert.Contains("sessions?.Count(SessionRunState.IsWorking)", pageSource); + } + [Fact] public void PairingRequiredDisconnectGuard_RunsInsideTransitionSemaphore() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index fd5b4187f..46b38dfb8 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -576,7 +576,7 @@ public async Task LoadAsync_ReturnsSeededSessionsAsThreads() } [Fact] - public async Task LoadAsync_MapsOnlyEndedSessionsToEndedThreads() + public async Task LoadAsync_MapsRunLivenessWithoutEndingReusableThreads() { var sessions = new[] { @@ -596,16 +596,16 @@ public async Task LoadAsync_MapsOnlyEndedSessionsToEndedThreads() var snapshot = await provider.LoadAsync(); Assert.Equal( - ChatThreadStatus.Ended, + ChatThreadStatus.Created, Assert.Single(snapshot.Threads, thread => thread.Id == "done").Status); Assert.Equal( - ChatThreadStatus.Ended, + ChatThreadStatus.Created, Assert.Single(snapshot.Threads, thread => thread.Id == "killed").Status); Assert.Equal( - ChatThreadStatus.Running, + ChatThreadStatus.Created, Assert.Single(snapshot.Threads, thread => thread.Id == "aborted").Status); Assert.Equal( - ChatThreadStatus.Running, + ChatThreadStatus.Created, Assert.Single(snapshot.Threads, thread => thread.Id == "unknown").Status); } diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 6926c4a54..bbe0d1266 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -333,17 +333,17 @@ public void MetricsLine_SingularNodeLabel() } [Fact] - public void MetricsLine_ShowsSessionsAndActiveCount() + public void MetricsLine_ShowsSessionsAndWorkingCount() { var sessions = new[] { - new SessionInfo { Key = "a", Status = "active" }, - new SessionInfo { Key = "b", Status = "idle" }, + new SessionInfo { Key = "a", Status = "running", HasActiveRun = true }, + new SessionInfo { Key = "b", Status = "running", HasActiveRun = false }, }; var summary = Build(Base(sessions: sessions)); - Assert.Contains("2 sessions (1 active)", summary.MetricsLine); + Assert.Contains("2 sessions (1 working)", summary.MetricsLine); } [Fact] @@ -546,7 +546,7 @@ public void Endpoint_KeepsExplicitNonDefaultPort() } [Fact] - public void ActiveSession_PrefersActiveSubOverIdleMain() + public void ActiveSession_PrefersWorkingSubOverReadyMain() { var sessions = new[] { @@ -557,11 +557,11 @@ public void ActiveSession_PrefersActiveSubOverIdleMain() var summary = Build(Base(sessions: sessions)); Assert.Equal("Worker", summary.ActiveSession!.Title); - Assert.Equal("Active", summary.ActiveSession.Label); + Assert.Equal("Working", summary.ActiveSession.Label); } [Fact] - public void ActiveSession_PrefersActiveMainOverActiveSub() + public void ActiveSession_PrefersWorkingMainOverWorkingSub() { var sessions = new[] { @@ -580,11 +580,11 @@ public void ActiveSession_PrefersActiveMainOverActiveSub() var summary = Build(Base(sessions: sessions)); Assert.Equal("Main", summary.ActiveSession!.Title); - Assert.Equal("Active", summary.ActiveSession.Label); + Assert.Equal("Working", summary.ActiveSession.Label); } [Fact] - public void ActiveSession_LabelIsMainForIdleMain() + public void ActiveSession_LabelIsReadyForIdleMain() { var sessions = new[] { @@ -593,7 +593,7 @@ public void ActiveSession_LabelIsMainForIdleMain() var summary = Build(Base(sessions: sessions)); - Assert.Equal("Main", summary.ActiveSession!.Label); + Assert.Equal("Ready", summary.ActiveSession!.Label); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs b/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs index 970c80713..74a6f5ef4 100644 --- a/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs +++ b/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs @@ -128,7 +128,7 @@ public void SessionsPage_UsesSharedSessionTitleFormatter() "Pages", "SessionsPage.xaml.cs")); - var formatIndex = source.IndexOf("SessionTitleFormatter.FormatUnique(activeSessions)", StringComparison.Ordinal); + var formatIndex = source.IndexOf("SessionTitleFormatter.FormatUnique(visibleSessions)", StringComparison.Ordinal); var channelFilterIndex = source.IndexOf("if (_activeChannel != \"all\")", StringComparison.Ordinal); Assert.True(formatIndex >= 0); @@ -145,4 +145,27 @@ public void SessionsPage_UsesSharedSessionTitleFormatter() "SessionsPage.xaml")); Assert.Contains("Tag=\"{Binding Key}\"", xaml, StringComparison.Ordinal); } + + [Fact] + public void SessionsPage_UsesCanonicalCompactSessionPresentation() + { + var source = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Pages", + "SessionsPage.xaml.cs")); + + Assert.Contains("SessionRunState.GetDisplaySortOrder(item.Session)", source, StringComparison.Ordinal); + Assert.Contains("StatusText = ResolveStatusText(s)", source, StringComparison.Ordinal); + Assert.Contains("SessionRunState.HasStoppedLastRun(s)", source, StringComparison.Ordinal); + + var xaml = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Pages", + "SessionsPage.xaml")); + Assert.Contains("Text=\"{Binding StatusText}\"", xaml, StringComparison.Ordinal); + } } diff --git a/tests/OpenClaw.Tray.Tests/SessionVisibilityFilterTests.cs b/tests/OpenClaw.Tray.Tests/SessionVisibilityFilterTests.cs index 28a49dc36..c6cdc235c 100644 --- a/tests/OpenClaw.Tray.Tests/SessionVisibilityFilterTests.cs +++ b/tests/OpenClaw.Tray.Tests/SessionVisibilityFilterTests.cs @@ -10,30 +10,30 @@ public class SessionVisibilityFilterTests [InlineData("done")] [InlineData("DONE")] [InlineData(" completed ")] - [InlineData("failed")] - [InlineData("killed")] - [InlineData("timeout")] - public void IsEnded_RecognizesTerminalStatuses(string status) + public void IsCompleted_RecognizesSuccessfulCompletedStatuses(string status) { var session = new SessionInfo { Status = status }; - Assert.True(SessionVisibilityFilter.IsEnded(session)); - Assert.False(SessionVisibilityFilter.IsVisibleWhenEndedHidden(session)); + Assert.True(SessionVisibilityFilter.IsCompleted(session)); + Assert.False(SessionVisibilityFilter.IsVisibleWhenCompletedHidden(session)); } [Theory] + [InlineData("failed")] + [InlineData("killed")] + [InlineData("timeout")] [InlineData("running")] [InlineData("unknown")] - public void IsEnded_LeavesActiveAndUnknownStatusesVisible(string status) + public void IsCompleted_LeavesWorkingAndNonSuccessOutcomesVisible(string status) { var session = new SessionInfo { Status = status }; - Assert.False(SessionVisibilityFilter.IsEnded(session)); - Assert.True(SessionVisibilityFilter.IsVisibleWhenEndedHidden(session)); + Assert.False(SessionVisibilityFilter.IsCompleted(session)); + Assert.True(SessionVisibilityFilter.IsVisibleWhenCompletedHidden(session)); } [Fact] - public void IsEnded_KeepsAbortedDoneSessionsVisible() + public void IsCompleted_LeavesAbortedDoneSessionsVisibleAsStoppedWork() { var session = new SessionInfo { @@ -41,12 +41,12 @@ public void IsEnded_KeepsAbortedDoneSessionsVisible() AbortedLastRun = true, }; - Assert.False(SessionVisibilityFilter.IsEnded(session)); - Assert.True(SessionVisibilityFilter.IsVisibleWhenEndedHidden(session)); + Assert.False(SessionVisibilityFilter.IsCompleted(session)); + Assert.True(SessionVisibilityFilter.IsVisibleWhenCompletedHidden(session)); } [Fact] - public void VisibleSessions_HidesEndedSessionsByDefault() + public void VisibleSessions_HidesOnlySuccessfulCompletedSessionsByDefault() { var sessions = new[] { @@ -58,15 +58,15 @@ public void VisibleSessions_HidesEndedSessionsByDefault() new SessionInfo { Key = "running", Status = "running" }, }; - var visible = SessionVisibilityFilter.VisibleSessions(sessions, showEnded: false) + var visible = SessionVisibilityFilter.VisibleSessions(sessions, showCompleted: false) .Select(s => s.Key) .ToArray(); - Assert.Equal(new[] { "aborted-done", "running" }, visible); + Assert.Equal(new[] { "failed", "killed", "timeout", "aborted-done", "running" }, visible); } [Fact] - public void VisibleSessions_ShowEndedPreservesAllSessions() + public void VisibleSessions_ShowCompletedPreservesAllSessions() { var sessions = new[] { @@ -74,7 +74,7 @@ public void VisibleSessions_ShowEndedPreservesAllSessions() new SessionInfo { Key = "failed", Status = "failed" }, }; - var visible = SessionVisibilityFilter.VisibleSessions(sessions, showEnded: true) + var visible = SessionVisibilityFilter.VisibleSessions(sessions, showCompleted: true) .Select(s => s.Key) .ToArray(); @@ -82,29 +82,28 @@ public void VisibleSessions_ShowEndedPreservesAllSessions() } [Theory] - [InlineData("done", false, ChatThreadStatus.Ended)] - [InlineData("completed", false, ChatThreadStatus.Ended)] - [InlineData("failed", false, ChatThreadStatus.Ended)] - [InlineData("killed", false, ChatThreadStatus.Ended)] - [InlineData("timeout", false, ChatThreadStatus.Ended)] - [InlineData("done", true, ChatThreadStatus.Running)] - [InlineData("unknown", false, ChatThreadStatus.Running)] - public void ToChatThreadStatus_ReusesEndedSemantics( + [InlineData("running", true, ChatThreadStatus.Running)] + [InlineData("running", false, ChatThreadStatus.Created)] + [InlineData("done", false, ChatThreadStatus.Created)] + [InlineData("failed", false, ChatThreadStatus.Created)] + [InlineData("killed", false, ChatThreadStatus.Created)] + [InlineData("timeout", false, ChatThreadStatus.Created)] + public void ToChatThreadStatus_UsesCanonicalRunLiveness( string status, - bool abortedLastRun, + bool hasActiveRun, ChatThreadStatus expected) { var session = new SessionInfo { Status = status, - AbortedLastRun = abortedLastRun, + HasActiveRun = hasActiveRun, }; Assert.Equal(expected, SessionVisibilityFilter.ToChatThreadStatus(session)); } [Fact] - public void VisibleChatPickerThreads_HidesEndedThreads() + public void VisibleChatPickerThreads_HidesOnlyExplicitlyEndedThreads() { var threads = new[] {