Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/MISSION_CONTROL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,11 @@ public class SessionInfo
public string? VerboseLevel { get; set; }
public bool SystemSent { get; set; }
public bool AbortedLastRun { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool? HasActiveRun { get; set; }
public long InputTokens { get; set; }
public long OutputTokens { get; set; }
public long TotalTokens { get; set; }
Expand Down
14 changes: 13 additions & 1 deletion src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
86 changes: 86 additions & 0 deletions src/OpenClaw.Shared/Sessions/SessionRunState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using OpenClaw.Shared;

namespace OpenClaw.Shared.Sessions;

/// <summary>
/// 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.
/// </summary>
public enum SessionRunStatus
{
Unknown,
Running,
Completed,
Failed,
Stopped,
TimedOut,
}

/// <summary>The intentionally small set of session states shown by the Windows app.</summary>
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,
};

/// <summary>
/// A terminal outcome wins over a stale live-run flag. For older Gateways which
/// do not send <c>hasActiveRun</c>, preserve the legacy <c>running</c> fallback.
/// </summary>
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;
}

/// <summary>Stable priority for compact lists: live work, attention, then ready sessions.</summary>
public static int GetDisplaySortOrder(SessionInfo session) => GetDisplayState(session) switch
{
SessionDisplayState.Working => 0,
SessionDisplayState.NeedsAttention => 1,
_ => 2,
};

/// <summary>Successful completed runs may be hidden from the compact session list.</summary>
public static bool IsCompleted(SessionInfo session) =>
!IsWorking(session)
&& !session.AbortedLastRun
&& ResolveStatus(session.Status) == SessionRunStatus.Completed;

/// <summary>
/// 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.
/// </summary>
public static bool HasStoppedLastRun(SessionInfo session) =>
session.AbortedLastRun || ResolveStatus(session.Status) == SessionRunStatus.Stopped;
}
5 changes: 2 additions & 3 deletions src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down
8 changes: 6 additions & 2 deletions src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
</SelectorBar>

<ToggleSwitch x:Uid="SessionsPage_ShowCompleted" x:Name="ShowCompletedToggle" Grid.Column="1"
Header="Show ended"
Header="Show completed"
MinWidth="0"
OnContent="" OffContent=""
Toggled="OnShowCompletedToggled"
Expand Down Expand Up @@ -89,7 +89,7 @@
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
<TextBlock x:Uid="SessionsPage_NoActiveSessions"
Text="No active sessions"
Text="No sessions"
Style="{StaticResource BodyStrongTextBlockStyle}"
HorizontalAlignment="Center"/>
<TextBlock x:Uid="SessionsPage_EmptyHint" Text="Sessions appear here when a channel is connected and traffic is flowing."
Expand Down Expand Up @@ -134,6 +134,10 @@
VerticalAlignment="Center"
Fill="{Binding StatusBrush}"
ToolTipService.ToolTip="{Binding StatusTooltip}"/>
<TextBlock Text="{Binding StatusText}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding DisplayName}"
Style="{StaticResource BodyStrongTextBlockStyle}"
TextTrimming="CharacterEllipsis"
Expand Down
46 changes: 25 additions & 21 deletions src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,12 @@ private void ApplyFilter()
return;
}

var activeSessions = SessionVisibilityFilter.VisibleSessions(
var visibleSessions = SessionVisibilityFilter.VisibleSessions(
_allSessions ?? Array.Empty<SessionInfo>(),
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")
Expand 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();

Expand Down Expand Up @@ -269,10 +270,12 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)

private SessionViewModel ToViewModel(SessionInfo s, string displayName)
{
var parts = new List<string>(3);
var parts = new List<string>(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
Expand All @@ -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,
Expand All @@ -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<Brush> s_successBrush =
Expand Down Expand Up @@ -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; }
Expand Down
24 changes: 7 additions & 17 deletions src/OpenClaw.Tray.WinUI/Services/SessionVisibilityFilter.cs
Original file line number Diff line number Diff line change
@@ -1,31 +1,21 @@
using OpenClaw.Chat;
using OpenClaw.Shared;
using OpenClaw.Shared.Sessions;

namespace OpenClawTray.Services;

public static class SessionVisibilityFilter
{
public static IEnumerable<SessionInfo> VisibleSessions(IEnumerable<SessionInfo> sessions, bool showEnded)
=> showEnded ? sessions : sessions.Where(IsVisibleWhenEndedHidden);
public static IEnumerable<SessionInfo> VisibleSessions(IEnumerable<SessionInfo> 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<ChatThread> VisibleChatPickerThreads(IEnumerable<ChatThread> threads)
=> threads.Where(IsVisibleInChatPicker);
Expand Down
21 changes: 11 additions & 10 deletions src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using OpenClaw.Shared;
using OpenClaw.Shared.Sessions;
using System;
using System.Collections.Generic;
using System.Globalization;
Expand Down Expand Up @@ -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")}");
}

Expand Down Expand Up @@ -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;
Expand All @@ -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!
Expand Down
Loading