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[]
{