Skip to content

Commit cdcb74f

Browse files
authored
Simplify session status presentation (#1057)
* feat: simplify session status presentation * test: preserve session presentation priority * test: pin active session run field * test: align chat threads with session run state * test: seed session status UI fixture * test: add background session to UI fixture * fix: retain session run state * fix: update session fixture for flattened fields * fix: preserve localized resource nodes * fix: align chat activity with run liveness
1 parent 2eab151 commit cdcb74f

24 files changed

Lines changed: 466 additions & 130 deletions

‎docs/MISSION_CONTROL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ Recent native chat behavior implemented during this phase:
469469

470470
- Queued-message UI tracks local send state (`Queued`, `Sending`, `Failed`) until the gateway confirms or rejects the turn.
471471
- Timeline virtualization keeps long chat histories responsive while preserving stable render identity for visible entries.
472-
- Completed sessions are hidden by default in the native chat session list, with active/in-progress sessions surfaced first.
472+
- The Sessions page hides only successful completed runs by default; reusable sessions remain available in the native chat picker, with working sessions surfaced first.
473473
- TTS notification speech preserves full assistant text while UI preview truncation remains visual-only.
474474
- Slash-command suggestions use the gateway command catalog grouped into Mac-compatible command buckets.
475475
- Local MCP chat automation exposes `app.chat.snapshot`, `app.chat.send`, and `app.chat.reset` for current-thread inspection, send, and reset flows.

‎src/OpenClaw.Shared/Models.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ public SessionInfo Clone()
302302
public string? VerboseLevel { get; set; }
303303
public bool SystemSent { get; set; }
304304
public bool AbortedLastRun { get; set; }
305+
/// <summary>
306+
/// Gateway-provided liveness for the session's current run. Null means an
307+
/// older Gateway did not provide the field, so callers may use legacy status.
308+
/// </summary>
309+
public bool? HasActiveRun { get; set; }
305310
public long InputTokens { get; set; }
306311
public long OutputTokens { get; set; }
307312
public long TotalTokens { get; set; }

‎src/OpenClaw.Shared/OpenClawGatewayClient.cs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3813,7 +3813,15 @@ private void UpdateSessionMainStatus(SessionInfo session, string sessionKey, Jso
38133813
private void PopulateSessionFromObject(SessionInfo session, JsonElement item)
38143814
{
38153815
if (item.TryGetProperty("status", out var status))
3816-
session.Status = status.GetString() ?? "active";
3816+
session.Status = status.ValueKind == JsonValueKind.String
3817+
? status.GetString() ?? "unknown"
3818+
: "unknown";
3819+
if (item.TryGetProperty("hasActiveRun", out var hasActiveRun))
3820+
{
3821+
session.HasActiveRun = hasActiveRun.ValueKind is JsonValueKind.True or JsonValueKind.False
3822+
? hasActiveRun.GetBoolean()
3823+
: null;
3824+
}
38173825
if (item.TryGetProperty("model", out var model))
38183826
{
38193827
var newModel = model.GetString();
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using OpenClaw.Shared;
2+
3+
namespace OpenClaw.Shared.Sessions;
4+
5+
/// <summary>
6+
/// Canonical interpretation of the Gateway's latest-run outcome and live-run flag.
7+
/// Keep this separate from presentation: the Gateway records outcomes per run, while a
8+
/// session remains available for a later turn after that run finishes.
9+
/// </summary>
10+
public enum SessionRunStatus
11+
{
12+
Unknown,
13+
Running,
14+
Completed,
15+
Failed,
16+
Stopped,
17+
TimedOut,
18+
}
19+
20+
/// <summary>The intentionally small set of session states shown by the Windows app.</summary>
21+
public enum SessionDisplayState
22+
{
23+
Working,
24+
Ready,
25+
NeedsAttention,
26+
}
27+
28+
public static class SessionRunState
29+
{
30+
public static SessionRunStatus ResolveStatus(string? status) => status?.Trim().ToLowerInvariant() switch
31+
{
32+
"active" or "running" => SessionRunStatus.Running,
33+
"done" or "completed" => SessionRunStatus.Completed,
34+
"error" or "failed" or "failure" => SessionRunStatus.Failed,
35+
"killed" or "aborted" or "cancelled" or "canceled" => SessionRunStatus.Stopped,
36+
"timeout" or "timed_out" => SessionRunStatus.TimedOut,
37+
_ => SessionRunStatus.Unknown,
38+
};
39+
40+
/// <summary>
41+
/// The Gateway's current-run flag is authoritative when present. Older Gateways
42+
/// omit it, so only those payloads fall back to the latest recorded outcome.
43+
/// </summary>
44+
public static bool IsWorking(SessionInfo session)
45+
{
46+
if (session.HasActiveRun is { } hasActiveRun)
47+
return hasActiveRun;
48+
49+
return ResolveStatus(session.Status) == SessionRunStatus.Running;
50+
}
51+
52+
public static SessionDisplayState GetDisplayState(SessionInfo session)
53+
{
54+
if (IsWorking(session))
55+
return SessionDisplayState.Working;
56+
57+
return ResolveStatus(session.Status) is SessionRunStatus.Failed or SessionRunStatus.TimedOut
58+
? SessionDisplayState.NeedsAttention
59+
: SessionDisplayState.Ready;
60+
}
61+
62+
/// <summary>Stable priority for compact lists: live work, attention, then ready sessions.</summary>
63+
public static int GetDisplaySortOrder(SessionInfo session) => GetDisplayState(session) switch
64+
{
65+
SessionDisplayState.Working => 0,
66+
SessionDisplayState.NeedsAttention => 1,
67+
_ => 2,
68+
};
69+
70+
/// <summary>Successful completed runs may be hidden from the compact session list.</summary>
71+
public static bool IsCompleted(SessionInfo session) =>
72+
!IsWorking(session)
73+
&& !session.AbortedLastRun
74+
&& ResolveStatus(session.Status) == SessionRunStatus.Completed;
75+
76+
/// <summary>
77+
/// A stopped run is useful context in a row or transcript, but is not a
78+
/// fourth primary session status: the session is still ready for another turn.
79+
/// </summary>
80+
public static bool HasStoppedLastRun(SessionInfo session) =>
81+
session.AbortedLastRun || ResolveStatus(session.Status) == SessionRunStatus.Stopped;
82+
}

‎src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6261,7 +6261,7 @@ private static ChatThread ToThread(SessionInfo s, string title)
62616261
AgentId = display.AgentId,
62626262
IsBackground = display.IsBackground,
62636263
Status = SessionVisibilityFilter.ToChatThreadStatus(s),
6264-
Activity = string.IsNullOrEmpty(s.CurrentActivity) ? ChatActivity.Idle : ChatActivity.Working,
6264+
Activity = SessionVisibilityFilter.ToChatThreadActivity(s),
62656265
Workspace = s.Channel,
62666266
Model = s.Model,
62676267
ModelProvider = s.Provider,

‎src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.UI.Xaml.Media;
77
using OpenClaw.Connection;
88
using OpenClaw.Shared;
9+
using OpenClaw.Shared.Sessions;
910
using OpenClawTray.Helpers;
1011
using OpenClawTray.Services;
1112
using System;
@@ -730,9 +731,7 @@ private void ApplyOperatorCard(ConnectionPagePlan plan)
730731
// Status sub-row (mirrors PermissionsPage NodeStatusDot pattern):
731732
// colored dot + descriptive label that reflects the live state.
732733
var sessions = _appState?.Sessions;
733-
int activeSessions = sessions?.Count(s =>
734-
string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase) ||
735-
string.Equals(s.Status, "running", StringComparison.OrdinalIgnoreCase)) ?? 0;
734+
int activeSessions = sessions?.Count(SessionRunState.IsWorking) ?? 0;
736735

737736
var (statusGlyph, statusBrushKey, statusText) = plan.OperatorCard switch
738737
{

‎src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
</SelectorBar>
3636

3737
<ToggleSwitch x:Uid="SessionsPage_ShowCompleted" x:Name="ShowCompletedToggle" Grid.Column="1"
38-
Header="Show ended"
38+
Header="Show completed"
3939
MinWidth="0"
4040
OnContent="" OffContent=""
4141
Toggled="OnShowCompletedToggled"
@@ -97,7 +97,7 @@
9797
HorizontalAlignment="Center"
9898
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
9999
<TextBlock x:Uid="SessionsPage_NoActiveSessions"
100-
Text="No active sessions"
100+
Text="No sessions"
101101
Style="{StaticResource BodyStrongTextBlockStyle}"
102102
HorizontalAlignment="Center"/>
103103
<TextBlock x:Uid="SessionsPage_EmptyHint" Text="Sessions appear here when a channel is connected and traffic is flowing."
@@ -142,6 +142,10 @@
142142
VerticalAlignment="Center"
143143
Fill="{Binding StatusBrush}"
144144
ToolTipService.ToolTip="{Binding StatusTooltip}"/>
145+
<TextBlock Text="{Binding StatusText}"
146+
Style="{StaticResource CaptionTextBlockStyle}"
147+
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
148+
VerticalAlignment="Center"/>
145149
<TextBlock Text="{Binding DisplayName}"
146150
Style="{StaticResource BodyStrongTextBlockStyle}"
147151
TextTrimming="CharacterEllipsis"

‎src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs‎

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,56 @@ public void Initialize()
7373
Key = "agent:main:main",
7474
IsMain = true,
7575
Status = "active",
76+
HasActiveRun = true,
7677
DisplayName = "OpenClaw Windows Tray",
7778
UpdatedAt = DateTime.UtcNow,
7879
},
7980
new SessionInfo
8081
{
8182
Key = "agent:main:fork",
82-
Status = "active",
83+
Status = "running",
84+
HasActiveRun = false,
8385
DisplayName = "OpenClaw Windows Tray",
8486
UpdatedAt = DateTime.UtcNow.AddSeconds(-1),
8587
},
88+
new SessionInfo
89+
{
90+
Key = "agent:main:deploy-migration",
91+
Status = "failed",
92+
DisplayName = "Deploy migration",
93+
UpdatedAt = DateTime.UtcNow.AddMinutes(-2),
94+
},
95+
new SessionInfo
96+
{
97+
Key = "agent:main:research-labels",
98+
Status = "timeout",
99+
DisplayName = "Research status labels",
100+
UpdatedAt = DateTime.UtcNow.AddMinutes(-4),
101+
},
102+
new SessionInfo
103+
{
104+
Key = "agent:main:release-notes",
105+
Status = "killed",
106+
DisplayName = "Draft release notes",
107+
UpdatedAt = DateTime.UtcNow.AddMinutes(-6),
108+
},
109+
new SessionInfo
110+
{
111+
Key = "agent:main:cron:nightly-cleanup",
112+
Status = "active",
113+
HasActiveRun = true,
114+
DisplayName = "Nightly cleanup",
115+
Classification = "cron",
116+
IsBackground = true,
117+
UpdatedAt = DateTime.UtcNow.AddMinutes(-7),
118+
},
119+
new SessionInfo
120+
{
121+
Key = "agent:main:completed-cleanup",
122+
Status = "done",
123+
DisplayName = "Completed cleanup",
124+
UpdatedAt = DateTime.UtcNow.AddMinutes(-8),
125+
},
86126
]);
87127
return;
88128
}
@@ -186,12 +226,12 @@ private void ApplyFilter()
186226
return;
187227
}
188228

189-
var activeSessions = SessionVisibilityFilter.VisibleSessions(
229+
var visibleSessions = SessionVisibilityFilter.VisibleSessions(
190230
SessionsForCurrentBackgroundScope(),
191231
ShowCompletedSessions)
192232
.ToList();
193-
var activeTitles = SessionTitleFormatter.FormatUnique(activeSessions);
194-
IEnumerable<(SessionInfo Session, string Title)> filtered = activeSessions
233+
var activeTitles = SessionTitleFormatter.FormatUnique(visibleSessions);
234+
IEnumerable<(SessionInfo Session, string Title)> filtered = visibleSessions
195235
.Select((session, index) => (Session: session, Title: activeTitles[index]));
196236

197237
if (_activeChannel != "all")
@@ -201,7 +241,8 @@ private void ApplyFilter()
201241
}
202242

203243
var viewModels = filtered
204-
.OrderByDescending(item => item.Session.UpdatedAt ?? item.Session.LastSeen)
244+
.OrderBy(item => SessionRunState.GetDisplaySortOrder(item.Session))
245+
.ThenByDescending(item => item.Session.UpdatedAt ?? item.Session.LastSeen)
205246
.Select(item => ToViewModel(item.Session, item.Title))
206247
.ToList();
207248

@@ -272,12 +313,14 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)
272313
private SessionViewModel ToViewModel(SessionInfo s, string displayName)
273314
{
274315
var subtitle = SessionTitleFormatter.FormatSubtitle(s);
275-
var parts = new List<string>(4);
316+
var parts = new List<string>(5);
276317
if (!string.IsNullOrWhiteSpace(subtitle)) parts.Add(subtitle!);
277318
if (!string.IsNullOrWhiteSpace(s.Provider)) parts.Add(s.Provider!);
278319
if (!string.IsNullOrWhiteSpace(s.Model)) parts.Add(s.Model!);
279320
if (string.IsNullOrWhiteSpace(subtitle) && !string.IsNullOrWhiteSpace(s.Channel))
280321
parts.Add(s.Channel!);
322+
if (SessionRunState.HasStoppedLastRun(s))
323+
parts.Add(LocalizationHelper.GetString("SessionsPage_LastRunStopped"));
281324

282325
var hasTokens = s.InputTokens > 0 || s.OutputTokens > 0;
283326
var tokensText = hasTokens
@@ -303,7 +346,8 @@ private SessionViewModel ToViewModel(SessionInfo s, string displayName)
303346
AgeText = s.AgeText,
304347
DetailLine = parts.Count > 0 ? string.Join(" · ", parts) : "",
305348
StatusBrush = ResolveStatusBrush(s),
306-
StatusTooltip = ResolveStatusTooltip(s),
349+
StatusText = ResolveStatusText(s),
350+
StatusTooltip = ResolveStatusText(s),
307351
TokensText = tokensText,
308352
ContextPercent = contextPercent,
309353
HasTokenData = hasTokens || contextPercent > 0,
@@ -315,23 +359,22 @@ private SessionViewModel ToViewModel(SessionInfo s, string displayName)
315359

316360
private static Brush ResolveStatusBrush(SessionInfo s)
317361
{
318-
var status = s.Status?.Trim().ToLowerInvariant();
319-
if (status is "error" or "failed" or "failure")
320-
return s_criticalBrush.Value;
321-
if (s.AbortedLastRun)
322-
return s_cautionBrush.Value;
323-
if (status is "active" or "running")
324-
return s_successBrush.Value;
325-
return s_neutralBrush.Value;
362+
return SessionRunState.GetDisplayState(s) switch
363+
{
364+
SessionDisplayState.Working => s_successBrush.Value,
365+
SessionDisplayState.NeedsAttention => s_criticalBrush.Value,
366+
_ => s_neutralBrush.Value,
367+
};
326368
}
327369

328-
private static string ResolveStatusTooltip(SessionInfo s)
370+
private static string ResolveStatusText(SessionInfo s)
329371
{
330-
var status = s.Status?.Trim().ToLowerInvariant();
331-
if (status is "error" or "failed" or "failure") return "Error";
332-
if (s.AbortedLastRun) return "Aborted last run";
333-
if (status is "active" or "running") return "Running";
334-
return "Idle";
372+
return SessionRunState.GetDisplayState(s) switch
373+
{
374+
SessionDisplayState.Working => LocalizationHelper.GetString("SessionsPage_Status_Working"),
375+
SessionDisplayState.NeedsAttention => LocalizationHelper.GetString("SessionsPage_Status_NeedsAttention"),
376+
_ => LocalizationHelper.GetString("SessionsPage_Status_Ready"),
377+
};
335378
}
336379

337380
private static readonly Lazy<Brush> s_successBrush =
@@ -701,7 +744,8 @@ public class SessionViewModel
701744
public string AgeText { get; set; } = "";
702745
public string DetailLine { get; set; } = "";
703746
public Brush StatusBrush { get; set; } = new SolidColorBrush(Colors.Gray);
704-
public string StatusTooltip { get; set; } = "Idle";
747+
public string StatusText { get; set; } = "Ready";
748+
public string StatusTooltip { get; set; } = "Ready";
705749
public string TokensText { get; set; } = "";
706750
public double ContextPercent { get; set; }
707751
public bool HasTokenData { get; set; }

‎src/OpenClaw.Tray.WinUI/Services/SessionVisibilityFilter.cs‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
11
using OpenClaw.Chat;
22
using OpenClaw.Shared;
3+
using OpenClaw.Shared.Sessions;
34

45
namespace OpenClawTray.Services;
56

67
public static class SessionVisibilityFilter
78
{
8-
public static IEnumerable<SessionInfo> VisibleSessions(IEnumerable<SessionInfo> sessions, bool showEnded)
9-
=> showEnded ? sessions : sessions.Where(IsVisibleWhenEndedHidden);
9+
public static IEnumerable<SessionInfo> VisibleSessions(IEnumerable<SessionInfo> sessions, bool showCompleted)
10+
=> showCompleted ? sessions : sessions.Where(IsVisibleWhenCompletedHidden);
1011

11-
public static bool IsVisibleWhenEndedHidden(SessionInfo session)
12-
=> !IsEnded(session);
12+
public static bool IsVisibleWhenCompletedHidden(SessionInfo session)
13+
=> !IsCompleted(session);
1314

14-
public static bool IsEnded(SessionInfo session)
15-
{
16-
if (session.AbortedLastRun)
17-
return false;
18-
19-
var status = session.Status?.Trim();
20-
return string.Equals(status, "done", StringComparison.OrdinalIgnoreCase)
21-
|| string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase)
22-
|| string.Equals(status, "failed", StringComparison.OrdinalIgnoreCase)
23-
|| string.Equals(status, "killed", StringComparison.OrdinalIgnoreCase)
24-
|| string.Equals(status, "timeout", StringComparison.OrdinalIgnoreCase);
25-
}
15+
public static bool IsCompleted(SessionInfo session) => SessionRunState.IsCompleted(session);
2616

2717
public static ChatThreadStatus ToChatThreadStatus(SessionInfo session)
28-
=> IsEnded(session) ? ChatThreadStatus.Ended : ChatThreadStatus.Running;
18+
=> SessionRunState.IsWorking(session) ? ChatThreadStatus.Running : ChatThreadStatus.Created;
19+
20+
public static ChatActivity ToChatThreadActivity(SessionInfo session)
21+
=> SessionRunState.IsWorking(session) ? ChatActivity.Working : ChatActivity.Idle;
2922

3023
public static IEnumerable<ChatThread> VisibleChatPickerThreads(
3124
IEnumerable<ChatThread> threads,

0 commit comments

Comments
 (0)