diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs
index a23e765ce..c97fc8fb5 100644
--- a/src/OpenClaw.Chat/ChatModels.cs
+++ b/src/OpenClaw.Chat/ChatModels.cs
@@ -96,6 +96,10 @@ public record ChatThread
{
public required string Id { get; init; }
public required string Title { get; init; }
+ public string? AgentId { get; init; }
+ public bool IsBackground { get; init; }
+ public bool IsVisibleInSessionPicker(string? activeThreadId) =>
+ !IsBackground || string.Equals(Id, activeThreadId, StringComparison.Ordinal);
public ChatThreadStatus Status { get; init; }
public ChatActivity Activity { get; init; }
public string? Cwd { get; init; }
@@ -231,9 +235,9 @@ public record ChatDataSnapshot(
/// and accepts calls keyed by
/// .
///
-public sealed record ChatComposeTarget(string? SessionKey, bool IsReady)
+public sealed record ChatComposeTarget(string? SessionKey, bool IsReady, string? AgentId = null)
{
- public static ChatComposeTarget NotReady { get; } = new(null, false);
+ public static ChatComposeTarget NotReady { get; } = new(null, false, null);
}
public sealed class ChatDataChangedEventArgs(ChatDataSnapshot snapshot) : EventArgs
diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs
index 7bf7305e9..efbb8fe58 100644
--- a/src/OpenClaw.Shared/Models.cs
+++ b/src/OpenClaw.Shared/Models.cs
@@ -251,22 +251,70 @@ private static bool TryGetBool(JsonElement parent, string property) =>
}
}
+public sealed class SessionPresentationInfo
+{
+ public string Title { get; set; } = "";
+ public string TitleSource { get; set; } = "generated";
+ public string? Subtitle { get; set; }
+ public string Family { get; set; } = "custom";
+ public string? AgentId { get; set; }
+ public string? Channel { get; set; }
+ public string? AccountId { get; set; }
+ public string? PeerKind { get; set; }
+ public bool IsMain { get; set; }
+ public bool IsBackground { get; set; }
+}
+
+public sealed class SessionWorktreeInfo
+{
+ public string? Id { get; set; }
+ public string? Branch { get; set; }
+ public string? RepoRoot { get; set; }
+}
+
public class SessionInfo
{
/// Defensive copy so a snapshot handed to a SessionsUpdated subscriber does not
/// alias the live tracked instance (whose fields are mutated in place under _sessionsLock).
- public SessionInfo Clone() => (SessionInfo)MemberwiseClone();
+ public SessionInfo Clone()
+ {
+ var copy = (SessionInfo)MemberwiseClone();
+ if (copy.Presentation is { } p)
+ copy.Presentation = new SessionPresentationInfo
+ {
+ Title = p.Title, TitleSource = p.TitleSource, Subtitle = p.Subtitle,
+ Family = p.Family, AgentId = p.AgentId, Channel = p.Channel,
+ AccountId = p.AccountId, PeerKind = p.PeerKind,
+ IsMain = p.IsMain, IsBackground = p.IsBackground,
+ };
+ if (copy.Worktree is { } w)
+ copy.Worktree = new SessionWorktreeInfo
+ {
+ Id = w.Id, Branch = w.Branch, RepoRoot = w.RepoRoot,
+ };
+ return copy;
+ }
public string Key { get; set; } = "";
public bool IsMain { get; set; }
+ internal bool IsMainResolved { get; set; }
+ public string? Label { get; set; }
public string Status { get; set; } = "unknown";
public string? Model { get; set; }
public string? Channel { get; set; }
public string? DisplayName { get; set; }
+ public string? DerivedTitle { get; set; }
public string? Provider { get; set; }
public string? Subject { get; set; }
public string? Room { get; set; }
public string? Space { get; set; }
+ public string? ChatType { get; set; }
+ public string? OriginLabel { get; set; }
+ public SessionWorktreeInfo? Worktree { get; set; }
+ public string? ExecNode { get; set; }
+ public string? ParentSessionKey { get; set; }
+ public int? SpawnDepth { get; set; }
+ public SessionPresentationInfo? Presentation { get; set; }
public string? SessionId { get; set; }
public string? ThinkingLevel { get; set; }
public string? VerboseLevel { get; set; }
@@ -304,9 +352,7 @@ public string RichDisplayText
{
get
{
- var title = !string.IsNullOrWhiteSpace(DisplayName)
- ? DisplayName!
- : (IsMain ? "Main session" : "Session");
+ var title = SessionPresentationResolver.Resolve(this).Title;
// Fixed-size array avoids List allocation; at most 9 detail slots.
var details = new string?[9];
@@ -349,26 +395,7 @@ public string ContextSummaryShort
/// Gets a shortened, user-friendly version of the session key.
public string ShortKey
{
- get
- {
- if (string.IsNullOrEmpty(Key)) return "unknown";
-
- // Extract meaningful part from session keys like "agent:main:subagent:uuid"
- var parts = Key.Split(':');
- if (parts.Length >= 3)
- {
- // Return something like "subagent" or "cron"
- return parts[^2]; // Second to last part
- }
-
- // For file paths, just return filename
- if (Key.Contains('/') || Key.Contains('\\'))
- {
- return Path.GetFileName(Key);
- }
-
- return Key.Length > 20 ? Key[..17] + "..." : Key;
- }
+ get => SessionPresentationResolver.Resolve(this).Title;
}
}
diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
index 14f62bdbb..96b54382b 100644
--- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs
+++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
@@ -47,13 +47,14 @@ public partial class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatew
private readonly DeviceIdentity _deviceIdentity;
private readonly string _currentGatewayUrl;
private string? _mainSessionKey;
+ private bool _mainSessionKeyIsCanonical;
private bool _hasHandshakeSnapshot;
///
- /// The gateway's canonical main session key as published in the hello-ok
- /// snapshot (preferring the canonical sessionDefaults.mainSessionKey
- /// over the legacy alias mainKey). null until handshake
- /// completes or after a disconnect. Callers should pass this exact value
+ /// The gateway's resolved main session key as published in the hello-ok
+ /// snapshot (preferring canonical sessionDefaults.mainSessionKey,
+ /// with mainKey retained for older gateways). null until
+ /// handshake completes or after a disconnect. Callers should pass this value
/// (or null) to ; never
/// substitute a literal like "main", which can drift from the
/// canonical key the gateway echoes back in chat events.
@@ -186,6 +187,7 @@ protected override void OnDisconnected()
// stale canonical key that the new server doesn't recognize, and
// HasHandshakeSnapshot would lie about the offline state to callers.
Volatile.Write(ref _mainSessionKey, null);
+ Volatile.Write(ref _mainSessionKeyIsCanonical, false);
Volatile.Write(ref _hasHandshakeSnapshot, false);
}
@@ -1824,7 +1826,13 @@ private void HandleResponse(JsonElement root)
// Volatile.Read on the public getters so a reader observing
// HasHandshakeSnapshot==true is guaranteed to see the populated
// MainSessionKey (release/acquire ordering).
- Volatile.Write(ref _mainSessionKey, TryGetHandshakeMainSessionKey(payload));
+ var hasCanonicalMainSessionKey = TryGetCanonicalHandshakeMainSessionKey(
+ payload,
+ out var canonicalMainSessionKey);
+ Volatile.Write(ref _mainSessionKeyIsCanonical, hasCanonicalMainSessionKey);
+ Volatile.Write(
+ ref _mainSessionKey,
+ hasCanonicalMainSessionKey ? canonicalMainSessionKey : TryGetHandshakeMainSessionKey(payload));
Volatile.Write(ref _hasHandshakeSnapshot, true);
_logger.Info($"[HANDSHAKE] deviceId={_operatorDeviceId}, scopes=[{string.Join(", ", _grantedOperatorScopes)}], mainSession={_mainSessionKey ?? "(unset)"}");
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
@@ -2463,13 +2471,8 @@ internal static string ResolveEffectiveSessionKey(
// are keyed by the canonical form. Using the alias here would cause
// the tray's local timeline (keyed by the alias) to diverge from the
// gateway's echo (keyed by canonical), stranding optimistic state.
- if (sessionDefaults.TryGetProperty("mainSessionKey", out var canonical) &&
- canonical.ValueKind == JsonValueKind.String)
- {
- var canonicalValue = canonical.GetString();
- if (!string.IsNullOrWhiteSpace(canonicalValue))
- return canonicalValue;
- }
+ if (TryGetCanonicalHandshakeMainSessionKey(payload, out var canonicalValue))
+ return canonicalValue;
if (sessionDefaults.TryGetProperty("mainKey", out var mainKey) &&
mainKey.ValueKind == JsonValueKind.String)
@@ -2482,6 +2485,21 @@ internal static string ResolveEffectiveSessionKey(
return null;
}
+ private static bool TryGetCanonicalHandshakeMainSessionKey(JsonElement payload, out string? value)
+ {
+ value = null;
+ if (!payload.TryGetProperty("snapshot", out var snapshot)
+ || snapshot.ValueKind != JsonValueKind.Object
+ || !snapshot.TryGetProperty("sessionDefaults", out var sessionDefaults)
+ || sessionDefaults.ValueKind != JsonValueKind.Object
+ || !sessionDefaults.TryGetProperty("mainSessionKey", out var canonical)
+ || canonical.ValueKind != JsonValueKind.String)
+ return false;
+
+ value = canonical.GetString();
+ return !string.IsNullOrWhiteSpace(value);
+ }
+
private static string? TryGetHandshakeDeviceToken(JsonElement payload)
{
return TryGetHandshakeDeviceTokenCore(payload, preferredRole: null);
@@ -3518,13 +3536,10 @@ private void ParseSessions(JsonElement sessions)
session = new SessionInfo { Key = sessionKey };
}
- var endsWithMain = sessionKey.EndsWith(":main");
- session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main");
+ UpdateSessionMainStatus(session, sessionKey, item);
if (item.ValueKind == JsonValueKind.Object)
{
- if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
- session.IsMain = true;
PopulateSessionFromObject(session, item);
}
else if (item.ValueKind == JsonValueKind.String)
@@ -3559,7 +3574,7 @@ private void ParseSessions(JsonElement sessions)
_logger.Warn($"Failed to parse sessions: {ex.Message}");
}
}
-
+
private string? ParseSessionItem(JsonElement item)
{
var sessionKey = "unknown";
@@ -3572,19 +3587,73 @@ private void ParseSessions(JsonElement sessions)
session = new SessionInfo { Key = sessionKey };
}
- session.IsMain = sessionKey == "main" ||
- sessionKey.EndsWith(":main") ||
- sessionKey.Contains(":main:main");
-
- if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
- session.IsMain = true;
-
+ UpdateSessionMainStatus(session, sessionKey, item);
+
PopulateSessionFromObject(session, item);
_sessions[session.Key] = session;
return session.Key;
}
+ private bool IsMainSessionKey(string sessionKey)
+ {
+ var mainSessionKey = MainSessionKey;
+ if (!string.IsNullOrWhiteSpace(mainSessionKey))
+ {
+ if (string.Equals(sessionKey, mainSessionKey, StringComparison.Ordinal))
+ return true;
+ if (Volatile.Read(ref _mainSessionKeyIsCanonical))
+ return false;
+
+ // Legacy hello-ok snapshots exposed only the routing alias. Older
+ // gateways canonicalized that alias with the default main agent.
+ return string.Equals(sessionKey, $"agent:main:{mainSessionKey}", StringComparison.Ordinal);
+ }
+
+ // Compatibility for pre-handshake and older gateways. Once the
+ // handshake resolves a canonical key it is the only authority.
+ return sessionKey.Equals("main", StringComparison.Ordinal)
+ || sessionKey.Equals("agent:main:main", StringComparison.Ordinal);
+ }
+
+ private void UpdateSessionMainStatus(SessionInfo session, string sessionKey, JsonElement item)
+ {
+ if (!string.IsNullOrWhiteSpace(MainSessionKey)
+ && Volatile.Read(ref _mainSessionKeyIsCanonical))
+ {
+ session.IsMain = IsMainSessionKey(sessionKey);
+ session.IsMainResolved = true;
+ return;
+ }
+
+ if (item.ValueKind == JsonValueKind.Object
+ && item.TryGetProperty("presentation", out var presentation)
+ && presentation.ValueKind == JsonValueKind.Object
+ && TryGetRequiredBoolean(presentation, "isMain", out var presentationIsMain))
+ {
+ session.IsMain = presentationIsMain;
+ session.IsMainResolved = true;
+ return;
+ }
+
+ if (item.ValueKind == JsonValueKind.Object
+ && item.TryGetProperty("isMain", out var isMain)
+ && isMain.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ session.IsMain = isMain.GetBoolean();
+ session.IsMainResolved = true;
+ return;
+ }
+
+ // Sparse pre-handshake updates preserve both true and false row values;
+ // only an unclassified session receives the bounded legacy key fallback.
+ if (!session.IsMainResolved)
+ {
+ session.IsMain = IsMainSessionKey(sessionKey);
+ session.IsMainResolved = true;
+ }
+ }
+
private void PopulateSessionFromObject(SessionInfo session, JsonElement item)
{
if (item.TryGetProperty("status", out var status))
@@ -3596,18 +3665,40 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item)
_logger.Info($"[SESSION] {session.Key}: model changed '{session.Model}' → '{newModel}'");
session.Model = newModel;
}
- if (item.TryGetProperty("channel", out var channel))
- session.Channel = channel.GetString();
- if (item.TryGetProperty("displayName", out var displayName))
- session.DisplayName = displayName.GetString();
- if (item.TryGetProperty("provider", out var provider))
- session.Provider = provider.GetString();
- if (item.TryGetProperty("subject", out var subject))
- session.Subject = subject.GetString();
- if (item.TryGetProperty("room", out var room))
- session.Room = room.GetString();
- if (item.TryGetProperty("space", out var space))
- session.Space = space.GetString();
+ if (item.TryGetProperty("label", out _)) session.Label = GetString(item, "label");
+ if (item.TryGetProperty("channel", out _)) session.Channel = GetString(item, "channel");
+ if (item.TryGetProperty("displayName", out _)) session.DisplayName = GetString(item, "displayName");
+ if (item.TryGetProperty("derivedTitle", out _)) session.DerivedTitle = GetString(item, "derivedTitle");
+ if (item.TryGetProperty("modelProvider", out _))
+ session.Provider = GetString(item, "modelProvider");
+ else if (item.TryGetProperty("provider", out _))
+ session.Provider = GetString(item, "provider");
+ if (item.TryGetProperty("subject", out _)) session.Subject = GetString(item, "subject");
+ if (item.TryGetProperty("groupChannel", out _))
+ session.Room = GetString(item, "groupChannel");
+ else if (item.TryGetProperty("room", out _))
+ session.Room = GetString(item, "room");
+ if (item.TryGetProperty("space", out _)) session.Space = GetString(item, "space");
+ if (item.TryGetProperty("chatType", out _)) session.ChatType = GetString(item, "chatType");
+ if (item.TryGetProperty("execNode", out _)) session.ExecNode = GetString(item, "execNode");
+ if (item.TryGetProperty("parentSessionKey", out _))
+ session.ParentSessionKey = GetString(item, "parentSessionKey");
+ if (item.TryGetProperty("spawnDepth", out var spawnDepth))
+ session.SpawnDepth = spawnDepth.ValueKind == JsonValueKind.Number
+ && spawnDepth.TryGetInt32(out var parsedSpawnDepth)
+ ? parsedSpawnDepth
+ : null;
+ if (item.TryGetProperty("origin", out var origin))
+ session.OriginLabel = origin.ValueKind == JsonValueKind.Object
+ ? GetString(origin, "label")
+ : null;
+ if (item.TryGetProperty("worktree", out _)) session.Worktree = ParseSessionWorktree(item);
+ if (item.TryGetProperty("presentation", out _))
+ session.Presentation = ParseSessionPresentation(item);
+ if (session.Presentation is { } presentation)
+ {
+ session.Channel ??= presentation.Channel;
+ }
if (item.TryGetProperty("sessionId", out var sessionId))
session.SessionId = sessionId.GetString();
if (item.TryGetProperty("thinkingLevel", out var thinking))
@@ -3646,6 +3737,56 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item)
}
}
+ private static SessionWorktreeInfo? ParseSessionWorktree(JsonElement item)
+ {
+ if (!item.TryGetProperty("worktree", out var worktree) || worktree.ValueKind != JsonValueKind.Object)
+ return null;
+
+ var id = GetString(worktree, "id");
+ var branch = GetString(worktree, "branch");
+ var repoRoot = GetString(worktree, "repoRoot");
+ return id is null && branch is null && repoRoot is null
+ ? null
+ : new SessionWorktreeInfo { Id = id, Branch = branch, RepoRoot = repoRoot };
+ }
+
+ private static SessionPresentationInfo? ParseSessionPresentation(JsonElement item)
+ {
+ if (!item.TryGetProperty("presentation", out var presentation)
+ || presentation.ValueKind != JsonValueKind.Object)
+ return null;
+
+ var title = GetString(presentation, "title");
+ var family = GetString(presentation, "family");
+ if (title is null
+ || family is null
+ || !TryGetRequiredBoolean(presentation, "isMain", out var isMain)
+ || !TryGetRequiredBoolean(presentation, "isBackground", out var isBackground))
+ return null;
+
+ return new SessionPresentationInfo
+ {
+ Title = title,
+ TitleSource = GetString(presentation, "titleSource") ?? "generated",
+ Subtitle = GetString(presentation, "subtitle"),
+ Family = family,
+ AgentId = GetString(presentation, "agentId"),
+ Channel = GetString(presentation, "channel"),
+ AccountId = GetString(presentation, "accountId"),
+ PeerKind = GetString(presentation, "peerKind"),
+ IsMain = isMain,
+ IsBackground = isBackground,
+ };
+ }
+
+ private static bool TryGetRequiredBoolean(JsonElement parent, string propertyName, out bool value)
+ {
+ value = false;
+ if (!parent.TryGetProperty(propertyName, out var property)) return false;
+ if (property.ValueKind == JsonValueKind.True) value = true;
+ return property.ValueKind is JsonValueKind.True or JsonValueKind.False;
+ }
+
private void ParseNodeList(JsonElement nodesPayload)
{
try
diff --git a/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs b/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs
new file mode 100644
index 000000000..ebb5d81b4
--- /dev/null
+++ b/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs
@@ -0,0 +1,296 @@
+using System.Text.RegularExpressions;
+
+namespace OpenClaw.Shared;
+
+///
+/// Resolves the Gateway's presentation contract and provides a conservative
+/// fallback for older gateways without treating opaque key tails as a schema.
+///
+public static partial class SessionPresentationResolver
+{
+ private static readonly HashSet BackgroundFamilies = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "acp", "cron", "dreaming", "harness", "heartbeat", "hook", "subagent", "system",
+ };
+
+ public static SessionPresentationInfo Resolve(SessionInfo session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ var fallback = ResolveKey(session.Key, session.IsMain, session.Channel, session.Worktree);
+ var gateway = session.Presentation;
+ var label = UsefulTitle(session.Key, session.Label);
+ var gatewayTitle = UsefulTitle(session.Key, gateway?.Title);
+ var displayName = UsefulTitle(session.Key, SafeLegacyDisplayName(session.DisplayName));
+ var derivedTitle = UsefulTitle(session.Key, session.DerivedTitle);
+ var worktreeTitle = UsefulTitle(session.Key, FormatWorktree(session.Worktree));
+ var title = label ?? gatewayTitle ?? displayName ?? derivedTitle ?? worktreeTitle ?? fallback.Title;
+ var titleSource = label is not null ? "label"
+ : gatewayTitle is not null ? NonEmpty(gateway?.TitleSource) ?? "generated"
+ : displayName is not null ? "displayName"
+ : derivedTitle is not null ? "derivedTitle"
+ : worktreeTitle is not null ? "worktree"
+ : fallback.TitleSource;
+ var family = NonEmpty(gateway?.Family) ?? fallback.Family;
+ var agentId = NonEmpty(gateway?.AgentId) ?? fallback.AgentId;
+ var channel = NonEmpty(gateway?.Channel) ?? NonEmpty(session.Channel) ?? fallback.Channel;
+ var accountId = NonEmpty(gateway?.AccountId) ?? fallback.AccountId;
+ var peerKind = NonEmpty(gateway?.PeerKind) ?? fallback.PeerKind;
+ var subtitle = NonEmpty(gateway?.Subtitle)
+ ?? BuildSubtitle(channel, accountId, agentId, session.ExecNode, session.Worktree);
+
+ return new SessionPresentationInfo
+ {
+ Title = title,
+ TitleSource = titleSource,
+ Subtitle = subtitle,
+ Family = family,
+ AgentId = agentId,
+ Channel = channel,
+ AccountId = accountId,
+ PeerKind = peerKind,
+ IsMain = session.IsMain,
+ IsBackground = gateway?.IsBackground ?? BackgroundFamilies.Contains(family),
+ };
+ }
+
+ public static bool IsBackground(SessionInfo session) => Resolve(session).IsBackground;
+
+ public static bool IsVisible(SessionInfo session, bool showBackground) =>
+ showBackground || !IsBackground(session);
+
+ ///
+ /// Produces a bounded context label while masking opaque identifiers.
+ ///
+ public static string FormatContext(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return ReadableTail(value);
+ }
+
+ private static SessionPresentationInfo ResolveKey(
+ string? rawKey,
+ bool isMain,
+ string? rowChannel,
+ SessionWorktreeInfo? worktree)
+ {
+ var key = rawKey?.Trim() ?? string.Empty;
+ if (key.Length == 0)
+ return isMain
+ ? Presentation("Main session", "main", agentId: "main", isMain: true)
+ : Presentation("Session", "unknown");
+ if (key.Equals("global", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Global session", "global", agentId: isMain ? "main" : null, isMain: isMain);
+ if (key.Equals("unknown", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Unknown session", "unknown");
+
+ var (agentId, rest) = ParseAgentWrapper(key);
+ if (isMain)
+ return Presentation("Main session", "main", agentId: agentId, isMain: true);
+
+ // Older gateways did not send the heartbeat base-session marker or a
+ // presentation object. Detect the terminal suffix before parsing any
+ // route so routed heartbeat keys do not become foreground chats.
+ if (rest.StartsWith("tui-", StringComparison.OrdinalIgnoreCase)
+ && rest.EndsWith(":heartbeat", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Heartbeat", "heartbeat", agentId, isBackground: true);
+ if (rest.Equals("subagent", StringComparison.OrdinalIgnoreCase)
+ || rest.StartsWith("subagent:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Subagent", "subagent", agentId, isBackground: true);
+ if (rest.Equals("acp", StringComparison.OrdinalIgnoreCase)
+ || rest.StartsWith("acp:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("ACP session", "acp", agentId, isBackground: true);
+ if (rest.Equals("cron", StringComparison.OrdinalIgnoreCase)
+ || rest.StartsWith("cron:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Scheduled task", "cron", agentId, isBackground: true);
+ if (rest.StartsWith("dashboard:", StringComparison.OrdinalIgnoreCase))
+ return Presentation(FormatWorktree(worktree) ?? "New session", "dashboard", agentId);
+ if (rest.StartsWith("tui-", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Terminal session", "tui", agentId);
+ if (rest.StartsWith("explicit:", StringComparison.OrdinalIgnoreCase))
+ return Presentation(ReadableTail(rest["explicit:".Length..]), "explicit", agentId);
+ if (rest.StartsWith("hook:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Hook run", "hook", agentId, isBackground: true);
+ if (rest.StartsWith("harness:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Harness session", "harness", agentId, isBackground: true);
+ if (rest.StartsWith("voice:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Voice call", "voice", agentId);
+ if (rest.StartsWith("dreaming-narrative-", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Dreaming", "dreaming", agentId, isBackground: true);
+ if (rest.Equals("boot", StringComparison.OrdinalIgnoreCase)
+ || rest.StartsWith("commitments:", StringComparison.OrdinalIgnoreCase)
+ || rest.StartsWith("internal-session-effects:", StringComparison.OrdinalIgnoreCase))
+ return Presentation("Background task", "system", agentId, isBackground: true);
+
+ var threadIndex = rest.LastIndexOf(":thread:", StringComparison.OrdinalIgnoreCase);
+ var routeRest = threadIndex >= 0 ? rest[..threadIndex] : rest;
+ var route = ParseRoute(routeRest, rowChannel);
+ if (threadIndex >= 0)
+ {
+ return Presentation(
+ route.Channel is { Length: > 0 } ? $"{ChannelLabel(route.Channel)} thread" : "Thread",
+ "thread",
+ agentId,
+ route.Channel,
+ route.AccountId,
+ route.PeerKind);
+ }
+ if (route.Family is not null)
+ {
+ var noun = route.Family switch
+ {
+ "direct" => "direct message",
+ "group" => "group",
+ _ => "channel",
+ };
+ return Presentation(
+ route.Channel is { Length: > 0 } ? $"{ChannelLabel(route.Channel)} {noun}" : Capitalize(noun),
+ route.Family,
+ agentId,
+ route.Channel,
+ route.AccountId,
+ route.PeerKind);
+ }
+
+ return Presentation(ReadableTail(rest), "custom", agentId);
+ }
+
+ private static (string? AgentId, string Tail) ParseAgentWrapper(string key)
+ {
+ var first = key.IndexOf(':');
+ var second = first >= 0 ? key.IndexOf(':', first + 1) : -1;
+ if (first <= 0 || second <= first + 1 || !key[..first].Equals("agent", StringComparison.OrdinalIgnoreCase))
+ return (null, key);
+ return (key[(first + 1)..second], key[(second + 1)..]);
+ }
+
+ private static (string? Family, string? Channel, string? AccountId, string? PeerKind) ParseRoute(
+ string rest,
+ string? rowChannel)
+ {
+ var parts = rest.Split(':');
+ if (parts.Length >= 2 && IsDirect(parts[0]))
+ return ("direct", NonEmpty(rowChannel), null, "direct");
+ if (parts.Length < 3)
+ return (null, null, null, null);
+
+ var channel = NonEmpty(parts[0]);
+ if (IsPeerKind(parts[1]))
+ return (NormalizeFamily(parts[1]), channel, null, NormalizePeerKind(parts[1]));
+ if (parts.Length >= 4 && IsPeerKind(parts[2]))
+ return (NormalizeFamily(parts[2]), channel, NonEmpty(parts[1]), NormalizePeerKind(parts[2]));
+ return (null, null, null, null);
+ }
+
+ private static bool IsDirect(string value) =>
+ value.Equals("direct", StringComparison.OrdinalIgnoreCase)
+ || value.Equals("dm", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsPeerKind(string value) =>
+ IsDirect(value)
+ || value.Equals("group", StringComparison.OrdinalIgnoreCase)
+ || value.Equals("channel", StringComparison.OrdinalIgnoreCase)
+ || value.Equals("room", StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeFamily(string value) =>
+ value.Equals("room", StringComparison.OrdinalIgnoreCase) ? "group"
+ : IsDirect(value) ? "direct"
+ : value.ToLowerInvariant();
+
+ private static string NormalizePeerKind(string value) => NormalizeFamily(value);
+
+ private static SessionPresentationInfo Presentation(
+ string title,
+ string family,
+ string? agentId = null,
+ string? channel = null,
+ string? accountId = null,
+ string? peerKind = null,
+ bool isMain = false,
+ bool isBackground = false) => new()
+ {
+ Title = title,
+ TitleSource = "generated",
+ Family = family,
+ AgentId = NonEmpty(agentId),
+ Channel = NonEmpty(channel),
+ AccountId = NonEmpty(accountId),
+ PeerKind = NonEmpty(peerKind),
+ IsMain = isMain,
+ IsBackground = isBackground,
+ };
+
+ private static string? UsefulTitle(string key, string? value)
+ {
+ var normalized = NonEmpty(value);
+ return normalized is not null && !normalized.Equals(key, StringComparison.Ordinal) ? normalized : null;
+ }
+
+ private static string? SafeLegacyDisplayName(string? value)
+ {
+ var normalized = NonEmpty(value);
+ return normalized is null || OpaqueIdRegex().IsMatch(normalized) ? null : normalized;
+ }
+
+ private static string ReadableTail(string value)
+ {
+ var normalizedPath = value.Replace('\\', '/');
+ var leaf = normalizedPath.Contains('/')
+ ? normalizedPath.Split('/').LastOrDefault(part => part.Length > 0) ?? normalizedPath
+ : normalizedPath;
+ var shortened = OpaqueIdRegex().Replace(leaf, match => $"…{match.Value[^4..]}");
+ const int maxLength = 32;
+ if (shortened.Length > maxLength)
+ shortened = $"{shortened[..(maxLength - 1)]}…";
+ return NonEmpty(shortened) ?? "Session";
+ }
+
+ private static string? BuildSubtitle(
+ string? channel,
+ string? accountId,
+ string? agentId,
+ string? execNode,
+ SessionWorktreeInfo? worktree)
+ {
+ var parts = new List(4);
+ var work = FormatWorktree(worktree);
+ if (work is not null) parts.Add(work);
+ if (NonEmpty(channel) is { } channelValue) parts.Add(ChannelLabel(channelValue));
+ if (NonEmpty(accountId) is { } accountValue) parts.Add($"account {ReadableTail(accountValue)}");
+ if (NonEmpty(agentId) is { } agentValue) parts.Add($"agent {ReadableTail(agentValue)}");
+ if (NonEmpty(execNode) is { } nodeValue) parts.Add($"node {ReadableTail(nodeValue)}");
+ return parts.Count > 0 ? string.Join(" · ", parts) : null;
+ }
+
+ private static string? FormatWorktree(SessionWorktreeInfo? worktree)
+ {
+ if (worktree is null) return null;
+ var repo = NonEmpty(worktree.RepoRoot)?.Split('/', '\\').LastOrDefault(part => part.Length > 0);
+ var branch = NonEmpty(worktree.Branch);
+ if (branch?.StartsWith("openclaw/", StringComparison.Ordinal) == true)
+ branch = branch["openclaw/".Length..];
+ return (repo, branch) switch
+ {
+ ({ Length: > 0 }, { Length: > 0 }) => $"{repo} ⎇ {branch}",
+ ({ Length: > 0 }, _) => repo,
+ (_, { Length: > 0 }) => branch,
+ _ => null,
+ };
+ }
+
+ private static string ChannelLabel(string channel) => channel.ToLowerInvariant() switch
+ {
+ "imessage" => "iMessage",
+ "whatsapp" => "WhatsApp",
+ "sms" => "SMS",
+ _ => Capitalize(channel),
+ };
+
+ private static string Capitalize(string value) =>
+ value.Length > 0 ? char.ToUpperInvariant(value[0]) + value[1..] : value;
+
+ private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ [GeneratedRegex(@"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{10,}", RegexOptions.IgnoreCase)]
+ private static partial Regex OpaqueIdRegex();
+}
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 61d41f52b..374a3f742 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -294,6 +294,7 @@ public App()
// The classifier defaults to identity (returns the resource key as-is) for unit-test
// contexts that lack a WinUI runtime; in-app we point it at the real resource lookup.
GatewayHostAccessLocalization.GetString = LocalizationHelper.GetString;
+ SessionTitleFormatter.ConfigureLocalization(LocalizationHelper.GetString);
GatewayHostAccessLocalization.Format = (key, args) => LocalizationHelper.Format(key, args);
InitializeComponent();
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 5f14c5bb7..9a910cff2 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -5847,6 +5847,10 @@ private ChatDataSnapshot BuildSnapshotLocked()
threadList.Add(ToThread(_sessions[i], threadTitles[i]));
var composeKey = _bridge.MainSessionKey;
+ var composeAgentId = _sessions
+ .FirstOrDefault(session => string.Equals(session.Key, composeKey, StringComparison.Ordinal)) is { } mainSession
+ ? SessionPresentationResolver.Resolve(mainSession).AgentId ?? "main"
+ : "main";
var composeReady = _bridge.HasHandshakeSnapshot
&& !string.IsNullOrWhiteSpace(composeKey)
&& _status == ConnectionStatus.Connected
@@ -5875,6 +5879,7 @@ private ChatDataSnapshot BuildSnapshotLocked()
threadList.Add(new ChatThread
{
Id = ck,
+ AgentId = composeAgentId,
Title = _lastChatState?.ThreadTitle ?? "OpenClaw Windows Tray",
Model = _lastChatState?.Model,
ModelProvider = _lastChatState?.ModelProvider,
@@ -5927,7 +5932,7 @@ private ChatDataSnapshot BuildSnapshotLocked()
};
var composeTarget = composeReady
- ? new ChatComposeTarget(composeKey, true)
+ ? new ChatComposeTarget(composeKey, true, composeAgentId)
: ChatComposeTarget.NotReady;
return new ChatDataSnapshot(
@@ -6012,10 +6017,13 @@ private bool TryGetSessionLocked(string threadId, out SessionInfo session)
private static ChatThread ToThread(SessionInfo s, string title)
{
+ var presentation = SessionPresentationResolver.Resolve(s);
return new ChatThread
{
Id = s.Key ?? string.Empty,
Title = title,
+ AgentId = presentation.AgentId,
+ IsBackground = presentation.IsBackground,
Status = SessionVisibilityFilter.ToChatThreadStatus(s),
Activity = string.IsNullOrEmpty(s.CurrentActivity) ? ChatActivity.Idle : ChatActivity.Working,
Workspace = s.Channel,
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index 546c8071a..6014b2c43 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -276,6 +276,7 @@ Element BuildLoadingElement()
composeOnlyThread = new ChatThread
{
Id = composeKey,
+ AgentId = snapshot.ComposeTarget.AgentId,
Title = lastState?.ThreadTitle ?? "OpenClaw Windows Tray",
Model = lastState?.Model,
ModelProvider = lastState?.ModelProvider,
@@ -529,18 +530,14 @@ Element BuildLoadingElement()
OnPermissionResponse: (rid, action) => OnPermission(effectiveThread.Id!, rid, action)));
}
- // Session list for the composer dropdown — grouped by agent, keyed by
- // ID so every session gets its own entry regardless of display name.
- // Exclude cron sessions which are automated/background.
+ // Session list for the composer dropdown — grouped by the Gateway's
+ // agent presentation metadata. Background sessions stay hidden unless
+ // the user explicitly navigated to one, in which case it remains usable.
+ // Exclude ended sessions (completed/failed/killed/timeout) from the picker.
var channelGroups = SessionVisibilityFilter.VisibleChatPickerThreads(snapshot.Threads)
.Where(t => !string.IsNullOrEmpty(t.Title)
- && !t.Id.Contains(":cron:", StringComparison.Ordinal))
- .GroupBy(t =>
- {
- // Parse agent ID from key like "agent:{agentId}:{slot}"
- var parts = (t.Id ?? "").Split(':');
- return parts.Length >= 3 && parts[0] == "agent" ? parts[1] : "other";
- })
+ && t.IsVisibleInSessionPicker(effectiveThread?.Id))
+ .GroupBy(t => string.IsNullOrWhiteSpace(t.AgentId) ? "other" : t.AgentId!)
// "main" first (sort key 0), then alphabetical
.OrderBy(g => g.Key.Equals("main", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
@@ -553,19 +550,14 @@ Element BuildLoadingElement()
// (e.g. fresh install: the gateway has no real sessions yet), inject a
// single-entry "Main" group so the composer's channel combo isn't blank.
//
- // Thread-id format (see OpenClawChatDataProvider): the canonical key
- // is ``agent::`` (e.g. ``agent:main:default``). The
- // first segment is always literal ``agent``; the second is the
- // agent identifier we use to label the channel group; the third is
- // the slot/session-instance within that agent. When the key
- // doesn't match this layout we fall back to a generic "Main"
- // label rather than mis-parsing some other id shape.
+ // Keep routing ids opaque here too. The provider carries the Gateway's
+ // agent identity separately, including for compose-only threads.
+ // Don't inject ended sessions into the synthetic group.
if (effectiveThread is not null
&& SessionVisibilityFilter.IsVisibleInChatPicker(effectiveThread)
&& !ChannelGroupsContain(channelGroups, effectiveThread.Id))
{
- var parts = (effectiveThread.Id ?? "").Split(':');
- var agentId = parts.Length >= 3 && parts[0] == "agent" ? parts[1] : "main";
+ var agentId = string.IsNullOrWhiteSpace(effectiveThread.AgentId) ? "main" : effectiveThread.AgentId!;
var agentLabel = agentId.Length > 0 ? char.ToUpperInvariant(agentId[0]) + agentId[1..] : "Main";
var syntheticGroup = new ChannelGroup(
AgentLabel: agentLabel,
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index b10ba81de..7d32fdfe0 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -426,6 +426,7 @@ Element SessionRow((string Id, string Title, string? Model, string? ModelProvide
b.Padding = new Thickness(8, 6, 8, 6);
b.MinWidth = 240;
b.CornerRadius = controlCornerRadius;
+ ToolTipService.SetToolTip(b, session.Title);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetItemStatus(
b,
isCurrent
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
index b6d5f86d8..04cec19c1 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
@@ -25,6 +25,7 @@
+
-
+
+
internal static class SessionTitleFormatter
{
+ private static Func s_getLocalizedString = key => key;
+
+ internal static void ConfigureLocalization(Func getLocalizedString)
+ {
+ s_getLocalizedString = getLocalizedString ?? throw new ArgumentNullException(nameof(getLocalizedString));
+ }
+
///
- /// Formats a session title and qualifies non-canonical agent sessions with
- /// their agent and slot so identical gateway display names remain distinct.
+ /// Formats a session title from the Gateway presentation contract, with a
+ /// bounded fallback for older gateways.
///
public static string Format(SessionInfo session)
{
ArgumentNullException.ThrowIfNull(session);
- var baseName = !string.IsNullOrWhiteSpace(session.DisplayName)
- ? session.DisplayName!
- : (session.IsMain ? "OpenClaw Windows Tray" : session.ShortKey);
+ var presentation = SessionPresentationResolver.Resolve(session);
+ if (!presentation.TitleSource.Equals("generated", StringComparison.OrdinalIgnoreCase))
+ return presentation.Title;
- // Keys follow agent:{agentId}:{sessionSlot}, for example
- // agent:main:main or agent:assistant:review.
- var parts = (session.Key ?? string.Empty).Split(':');
- if (parts.Length < 3 || !string.Equals(parts[0], "agent", StringComparison.Ordinal))
- return baseName;
-
- var agentId = parts[1];
- var sessionSlot = parts[2];
-
- if (agentId == "main" && sessionSlot == "main")
- return baseName;
+ var family = presentation.Family.ToLowerInvariant();
+ var hasChannel = !string.IsNullOrWhiteSpace(presentation.Channel);
+ var resourceKey = family switch
+ {
+ "main" => "SessionTitle_Main",
+ "global" => "SessionTitle_Global",
+ "unknown" => "SessionTitle_Unknown",
+ "direct" => hasChannel ? "SessionTitle_Direct" : "SessionTitle_DirectGeneric",
+ "group" => hasChannel ? "SessionTitle_Group" : "SessionTitle_GroupGeneric",
+ "channel" => hasChannel ? "SessionTitle_Channel" : "SessionTitle_ChannelGeneric",
+ "thread" => hasChannel ? "SessionTitle_Thread" : "SessionTitle_ThreadGeneric",
+ "cron" => "SessionTitle_Cron",
+ "heartbeat" => "SessionTitle_Heartbeat",
+ "subagent" => "SessionTitle_Subagent",
+ "acp" => "SessionTitle_Acp",
+ "dashboard" => "SessionTitle_Dashboard",
+ "tui" => "SessionTitle_Tui",
+ "hook" => "SessionTitle_Hook",
+ "harness" => "SessionTitle_Harness",
+ "voice" => "SessionTitle_Voice",
+ "dreaming" => "SessionTitle_Dreaming",
+ "system" => "SessionTitle_System",
+ _ => null,
+ };
+ if (resourceKey is null)
+ return presentation.Title;
+
+ return hasChannel && (family is "direct" or "group" or "channel" or "thread")
+ ? LocalizedFormat(resourceKey, presentation.Title, ChannelLabel(presentation.Channel))
+ : Localized(resourceKey, presentation.Title);
+ }
- var qualifier = agentId == sessionSlot
- ? agentId
- : $"{agentId}/{sessionSlot}";
+ public static string? FormatSubtitle(SessionInfo session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
- return $"{baseName} ({qualifier})";
+ var presentation = SessionPresentationResolver.Resolve(session);
+ var parts = new List(5);
+ if (FormatWorktree(session.Worktree) is { } worktree) parts.Add(worktree);
+ if (!string.IsNullOrWhiteSpace(presentation.Channel)) parts.Add(ChannelLabel(presentation.Channel));
+ if (presentation.AccountId is { } accountId && !string.IsNullOrWhiteSpace(accountId))
+ {
+ var display = SessionPresentationResolver.FormatContext(accountId);
+ parts.Add(LocalizedFormat("SessionSubtitle_Account", $"account {display}", display));
+ }
+ if (presentation.AgentId is { } agentId && !string.IsNullOrWhiteSpace(agentId))
+ {
+ var display = SessionPresentationResolver.FormatContext(agentId);
+ parts.Add(LocalizedFormat("SessionSubtitle_Agent", $"agent {display}", display));
+ }
+ if (session.ExecNode is { } execNode && !string.IsNullOrWhiteSpace(execNode))
+ {
+ var display = SessionPresentationResolver.FormatContext(execNode);
+ parts.Add(LocalizedFormat("SessionSubtitle_Node", $"node {display}", display));
+ }
+ return parts.Count > 0 ? string.Join(" · ", parts) : presentation.Subtitle;
}
///
@@ -127,13 +173,51 @@ public static string Format(SessionInfo session, IReadOnlyList sess
private static bool IsCanonicalMain(SessionInfo session)
{
- if (session.IsMain)
- return true;
-
- var parts = (session.Key ?? string.Empty).Split(':');
- return parts.Length >= 3
- && string.Equals(parts[0], "agent", StringComparison.Ordinal)
- && string.Equals(parts[1], "main", StringComparison.Ordinal)
- && string.Equals(parts[2], "main", StringComparison.Ordinal);
+ return SessionPresentationResolver.Resolve(session).IsMain;
+ }
+
+ private static string Localized(string key, string fallback)
+ {
+ var value = s_getLocalizedString(key);
+ return value == key ? fallback : value;
+ }
+
+ private static string LocalizedFormat(string key, string fallback, object value)
+ {
+ var format = s_getLocalizedString(key);
+ if (format == key) return fallback;
+ try
+ {
+ return string.Format(format, value);
+ }
+ catch (FormatException)
+ {
+ return fallback;
+ }
+ }
+
+ private static string ChannelLabel(string? channel) => channel?.ToLowerInvariant() switch
+ {
+ "imessage" => "iMessage",
+ "whatsapp" => "WhatsApp",
+ "sms" => "SMS",
+ { Length: > 0 } value => char.ToUpperInvariant(value[0]) + value[1..],
+ _ => "Channel",
+ };
+
+ private static string? FormatWorktree(SessionWorktreeInfo? worktree)
+ {
+ if (worktree is null) return null;
+ var repo = worktree.RepoRoot?.Replace('\\', '/').Split('/').LastOrDefault(part => part.Length > 0);
+ var branch = worktree.Branch;
+ if (branch?.StartsWith("openclaw/", StringComparison.Ordinal) == true)
+ branch = branch["openclaw/".Length..];
+ return (repo, branch) switch
+ {
+ ({ Length: > 0 }, { Length: > 0 }) => $"{repo} ⎇ {branch}",
+ ({ Length: > 0 }, _) => repo,
+ (_, { Length: > 0 }) => branch,
+ _ => null,
+ };
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs
index ddf24a074..0d772dc65 100644
--- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs
@@ -208,6 +208,13 @@ internal static long SessionUsedTokens(SessionInfo session) =>
if (sessions == null || sessions.Count == 0)
return null;
+ var foregroundSessions = sessions
+ .Where(session => !SessionPresentationResolver.IsBackground(session))
+ .ToArray();
+ if (foregroundSessions.Length == 0)
+ return null;
+ sessions = foregroundSessions;
+
static bool IsActive(SessionInfo s) =>
string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase);
@@ -223,6 +230,14 @@ static bool IsActive(SessionInfo s) =>
var main = sessions.FirstOrDefault(s => s.IsMain);
if (main != null) return main;
+ // Prefer non-ended sessions as the dashboard fallback so a completed/failed
+ // session does not appear as "current" when live sessions remain.
+ var nonEnded = sessions
+ .Where(s => !SessionVisibilityFilter.IsEnded(s))
+ .OrderByDescending(s => s.UpdatedAt ?? s.LastSeen)
+ .FirstOrDefault();
+ if (nonEnded != null) return nonEnded;
+
return sessions.OrderByDescending(s => s.UpdatedAt ?? s.LastSeen).First();
}
@@ -235,9 +250,7 @@ static bool IsActive(SessionInfo s) =>
var isActive = string.Equals(session.Status, "active", StringComparison.OrdinalIgnoreCase);
var label = isActive ? "Active" : (session.IsMain ? "Main" : "Session");
- var title = !string.IsNullOrWhiteSpace(session.DisplayName)
- ? session.DisplayName!
- : (session.IsMain ? "Main session" : (string.IsNullOrEmpty(session.Key) ? "Session" : session.ShortKey));
+ var title = SessionTitleFormatter.Format(session);
var usedTokens = SessionUsedTokens(session);
var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000;
diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
index eb3df3cd0..63511ec2a 100644
--- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
@@ -287,17 +287,20 @@ or OpenClaw.Connection.OverallConnectionState.Degraded
}
// ── Sessions (now below Devices) ──
- if (_snapshot.Sessions.Length > 0)
+ var foregroundSessions = _snapshot.Sessions
+ .Where(session => !SessionPresentationResolver.IsBackground(session))
+ .ToArray();
+ if (foregroundSessions.Length > 0)
{
menu.AddSeparator();
- var sessionCount = _snapshot.Sessions.Length;
- var activeCount = _snapshot.Sessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase));
- var totalTokensAll = _snapshot.Sessions.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens);
+ var sessionCount = foregroundSessions.Length;
+ var activeCount = foregroundSessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase));
+ var totalTokensAll = foregroundSessions.Sum(TrayDashboardSummaryBuilder.SessionUsedTokens);
// Single collapsed entry whose hover flyout reveals the session list.
var sessionsRow = BuildSessionsListRow(sessionCount, activeCount, totalTokensAll, secondaryText);
- var sessionsFlyout = BuildSessionsListFlyoutItems(secondaryText, successBrush, cautionBrush, neutralBrush);
+ var sessionsFlyout = BuildSessionsListFlyoutItems(foregroundSessions, secondaryText, successBrush, cautionBrush, neutralBrush);
menu.AddFlyoutCustomItem(sessionsRow, sessionsFlyout, action: "sessions");
}
@@ -854,7 +857,7 @@ private static UIElement BuildSessionListCard(
var usageText = ChatUsageFormatter.Format(new ChatThread
{
Id = session.Key,
- Title = session.DisplayName ?? session.Key,
+ Title = SessionTitleFormatter.Format(session),
InputTokens = session.InputTokens,
OutputTokens = session.OutputTokens,
TotalTokens = session.TotalTokens,
@@ -884,7 +887,7 @@ private static UIElement BuildSessionListCard(
var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center };
nameRow.Children.Add(new TextBlock
{
- Text = session.DisplayName ?? session.Key,
+ Text = SessionTitleFormatter.Format(session),
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
FontSize = 13,
TextTrimming = TextTrimming.CharacterEllipsis,
@@ -1192,6 +1195,7 @@ private static UIElement BuildCapabilityRow(string cap, List? commands,
// ── Instance helpers (Grupo B) ────────────────────────────────────────
private List BuildSessionsListFlyoutItems(
+ IReadOnlyList sessions,
Microsoft.UI.Xaml.Media.Brush secondaryText,
Microsoft.UI.Xaml.Media.Brush successBrush,
Microsoft.UI.Xaml.Media.Brush cautionBrush,
@@ -1199,16 +1203,16 @@ private List BuildSessionsListFlyoutItems(
{
var items = new List
{
- new() { Text = $"Sessions ({_snapshot.Sessions.Length})", IsHeader = true }
+ new() { Text = $"Sessions ({sessions.Count})", IsHeader = true }
};
- if (_snapshot.Sessions.Length == 0)
+ if (sessions.Count == 0)
{
items.Add(new() { Text = "No active sessions" });
return items;
}
- foreach (var session in _snapshot.Sessions.Take(8))
+ foreach (var session in sessions.Take(8))
{
var card = BuildSessionListCard(session, secondaryText);
items.Add(new() { CustomContent = card });
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 0ac9dc2b0..6faa5ca04 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -4731,6 +4731,34 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
Show ended
+
+ Show background
+
+ Main session
+ Global session
+ Unknown session
+ {0} direct message
+ {0} group
+ {0} channel
+ {0} thread
+ Direct message
+ Group conversation
+ Channel conversation
+ Thread
+ Scheduled task
+ Heartbeat
+ Subagent
+ ACP session
+ New session
+ Terminal session
+ Hook run
+ Harness session
+ Voice call
+ Dreaming
+ Background task
+ account {0}
+ agent {0}
+ node {0}
Gateway disconnected
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 4bcb5c9a7..f34de227f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -4684,6 +4684,34 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
Afficher les sessions terminées
+
+ Afficher les sessions en arrière-plan
+
+ Session principale
+ Session globale
+ Session inconnue
+ Message direct {0}
+ Groupe {0}
+ Canal {0}
+ Fil {0}
+ Message direct
+ Conversation de groupe
+ Conversation de canal
+ Fil
+ Tâche planifiée
+ Battement de cœur
+ Sous-agent
+ Session ACP
+ Nouvelle session
+ Session de terminal
+ Exécution de hook
+ Session de test
+ Appel vocal
+ Rêve
+ Tâche en arrière-plan
+ compte {0}
+ agent logiciel {0}
+ nœud {0}
Passerelle déconnecté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..22681f48d 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -4685,6 +4685,34 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
Beëindigde sessies tonen
+
+ Achtergrondsessies tonen
+
+ Hoofdsessie
+ Globale sessie
+ Onbekende sessie
+ Direct bericht via {0}
+ Groep op {0}
+ Kanaal op {0}
+ Thread op {0}
+ Direct bericht
+ Groepsgesprek
+ Kanaalgesprek
+ Discussie
+ Geplande taak
+ Hartslag
+ Deelagent
+ ACP-sessie
+ Nieuwe sessie
+ Terminalsessie
+ Hook-uitvoering
+ Testsessie
+ Spraakoproep
+ Dromen
+ Achtergrondtaak
+ gebruikersaccount {0}
+ assistent {0}
+ knooppunt {0}
Gateway losgekoppeld
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 88a3253cc..673854276 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -4684,6 +4684,34 @@
显示已结束的会话
+
+ 显示后台会话
+
+ 主会话
+ 全局会话
+ 未知会话
+ {0} 私信
+ {0} 群聊
+ {0} 频道
+ {0} 话题
+ 私信
+ 群聊
+ 频道会话
+ 话题
+ 计划任务
+ 心跳
+ 子代理
+ ACP 会话
+ 新会话
+ 终端会话
+ 钩子任务
+ 测试会话
+ 语音通话
+ 梦境
+ 后台任务
+ 账号 {0}
+ 代理 {0}
+ 节点 {0}
网关已断开
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index aa5cdcaa1..6e097f50e 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -4684,6 +4684,34 @@
顯示已結束的工作階段
+
+ 顯示背景工作階段
+
+ 主要工作階段
+ 全域工作階段
+ 未知工作階段
+ {0} 私訊
+ {0} 群組
+ {0} 頻道
+ {0} 討論串
+ 私訊
+ 群組對話
+ 頻道對話
+ 討論串
+ 排程工作
+ 心跳
+ 子代理
+ ACP 工作階段
+ 新工作階段
+ 終端工作階段
+ 掛鉤工作
+ 測試工作階段
+ 語音通話
+ 夢境
+ 背景工作
+ 帳號 {0}
+ 代理 {0}
+ 節點 {0}
閘道已中斷
diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs
index 9f9f33c7b..3fb021718 100644
--- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs
+++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs
@@ -604,14 +604,14 @@ public void DisplayText_DoesNotShowStatus_WhenActive()
public void ShortKey_ReturnsUnknown_ForEmptyKey()
{
var session = new SessionInfo { Key = "" };
- Assert.Equal("unknown", session.ShortKey);
+ Assert.Equal("Session", session.ShortKey);
}
[Fact]
public void ShortKey_ReturnsSecondToLast_ForColonSeparatedKey()
{
var session = new SessionInfo { Key = "agent:main:subagent:uuid" };
- Assert.Equal("subagent", session.ShortKey);
+ Assert.Equal("Subagent", session.ShortKey);
}
[Fact]
@@ -625,27 +625,14 @@ public void ShortKey_ReturnsFilename_ForPathWithSlashes()
public void ShortKey_ReturnsFilename_ForPathWithBackslashes()
{
var session = new SessionInfo { Key = @"C:\path\to\file.txt" };
- var result = session.ShortKey;
- // ShortKey uses Path.GetFileName which handles backslashes on Windows.
- // On non-Windows, Path.GetFileName may not split on backslash, returning the full key
- // which then gets truncated. Either way, the result must not be empty.
- if (OperatingSystem.IsWindows())
- {
- Assert.Equal("file.txt", result);
- }
- else
- {
- // On Linux Path.GetFileName won't split on '\', so it falls through to truncation
- Assert.NotEmpty(result);
- Assert.DoesNotContain("unknown", result);
- }
+ Assert.Equal("file.txt", session.ShortKey);
}
[Fact]
public void ShortKey_TruncatesLongKeys()
{
var session = new SessionInfo { Key = "this-is-a-very-long-key-that-should-be-truncated" };
- Assert.Equal("this-is-a-very-lo...", session.ShortKey);
+ Assert.Equal("this-is-a-very-long-key-that-sh…", session.ShortKey);
}
[Fact]
@@ -690,6 +677,86 @@ public void SessionInfo_EmptyStatus_DoesNotThrow()
: char.ToUpperInvariant(session.Status[0]) + session.Status[1..];
Assert.Equal("Unknown", status);
}
+
+ [Fact]
+ public void Clone_DeepCopies_Presentation()
+ {
+ var original = new SessionInfo
+ {
+ Key = "agent:main:test",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Original", Family = "custom", AgentId = "main", IsBackground = false,
+ },
+ };
+ var clone = original.Clone();
+
+ // Mutate clone's Presentation
+ clone.Presentation!.Title = "Mutated";
+ clone.Presentation.IsBackground = true;
+
+ // Original must be unchanged
+ Assert.Equal("Original", original.Presentation.Title);
+ Assert.False(original.Presentation.IsBackground);
+ }
+
+ [Fact]
+ public void Clone_DeepCopies_Worktree()
+ {
+ var original = new SessionInfo
+ {
+ Key = "agent:main:dashboard:abc",
+ Worktree = new SessionWorktreeInfo
+ {
+ Id = "wt-1", Branch = "main", RepoRoot = "/home/user/repo",
+ },
+ };
+ var clone = original.Clone();
+
+ // Mutate clone's Worktree
+ clone.Worktree!.Branch = "feature-x";
+ clone.Worktree.RepoRoot = "/other/path";
+
+ // Original must be unchanged
+ Assert.Equal("main", original.Worktree.Branch);
+ Assert.Equal("/home/user/repo", original.Worktree.RepoRoot);
+ }
+
+ [Fact]
+ public void Clone_NullPresentation_DoesNotThrow()
+ {
+ var original = new SessionInfo { Key = "test", Presentation = null, Worktree = null };
+ var clone = original.Clone();
+ Assert.Null(clone.Presentation);
+ Assert.Null(clone.Worktree);
+ }
+
+ [Fact]
+ public void Clone_SnapshotIsolation_MultipleClonesIndependent()
+ {
+ var live = new SessionInfo
+ {
+ Key = "agent:main:explicit:task",
+ Presentation = new SessionPresentationInfo { Title = "V1", Family = "explicit" },
+ Worktree = new SessionWorktreeInfo { Branch = "main" },
+ };
+
+ var snapshot1 = live.Clone();
+ live.Presentation.Title = "V2";
+ live.Worktree.Branch = "develop";
+ var snapshot2 = live.Clone();
+
+ // snapshot1 sees V1, snapshot2 sees V2, both independent
+ Assert.Equal("V1", snapshot1.Presentation!.Title);
+ Assert.Equal("main", snapshot1.Worktree!.Branch);
+ Assert.Equal("V2", snapshot2.Presentation!.Title);
+ Assert.Equal("develop", snapshot2.Worktree!.Branch);
+
+ // Mutating snapshot2 doesn't affect live or snapshot1
+ snapshot2.Presentation.Title = "V3";
+ Assert.Equal("V2", live.Presentation.Title);
+ Assert.Equal("V1", snapshot1.Presentation.Title);
+ }
}
public class GatewayUsageInfoTests
diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
index 2fe242093..cc1516d3a 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
@@ -282,6 +282,12 @@ public void ParseSessionsPayload(string payloadJson)
InvokePrivatePayloadParser("ParseSessions", payloadJson);
}
+ public void SetMainSessionKey(string key, bool isCanonical = true)
+ {
+ SetPrivateField("_mainSessionKeyIsCanonical", isCanonical);
+ SetPrivateField("_mainSessionKey", key);
+ }
+
public ModelsListInfo ParseModelsListPayload(string payloadJson)
{
ModelsListInfo? parsed = null;
@@ -1777,6 +1783,265 @@ public void GetSessionList_SortsMainSessionFirst()
Assert.False(sessions[2].IsMain);
}
+ [Fact]
+ public void ParseSessions_ProjectsGatewayPresentationContract()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.ParseSessionsPayload("""
+ [
+ {
+ "key": "agent:main:telegram:main:direct:491234567890",
+ "label": "Family chat",
+ "displayName": "Telegram:491234567890",
+ "derivedTitle": "Latest plans",
+ "modelProvider": "openai",
+ "model": "gpt-5.4",
+ "channel": "telegram",
+ "groupChannel": "family",
+ "chatType": "direct",
+ "origin": { "label": "Tony" },
+ "worktree": { "id": "wt-1", "branch": "openclaw/session-ux", "repoRoot": "C:\\src\\openclaw" },
+ "execNode": "windows-dev",
+ "parentSessionKey": "agent:main:main",
+ "spawnDepth": 1,
+ "presentation": {
+ "title": "Family chat",
+ "titleSource": "label",
+ "subtitle": "Telegram · account main · agent main",
+ "family": "direct",
+ "agentId": "main",
+ "channel": "telegram",
+ "accountId": "main",
+ "peerKind": "direct",
+ "isMain": false,
+ "isBackground": false
+ }
+ }
+ ]
+ """);
+
+ var session = Assert.Single(helper.GetSessionList());
+ Assert.Equal("Family chat", session.Label);
+ Assert.Equal("Latest plans", session.DerivedTitle);
+ Assert.Equal("openai", session.Provider);
+ Assert.Equal("family", session.Room);
+ Assert.Equal("direct", session.ChatType);
+ Assert.Equal("Tony", session.OriginLabel);
+ Assert.Equal("openclaw/session-ux", session.Worktree?.Branch);
+ Assert.Equal("windows-dev", session.ExecNode);
+ Assert.Equal("agent:main:main", session.ParentSessionKey);
+ Assert.Equal(1, session.SpawnDepth);
+ Assert.False(session.IsMain);
+ Assert.Equal("Family chat", session.Presentation?.Title);
+ Assert.Equal("label", session.Presentation?.TitleSource);
+ Assert.Equal("direct", session.Presentation?.Family);
+ Assert.Equal("main", session.Presentation?.AccountId);
+ }
+
+ [Fact]
+ public void ParseSessions_UsesHandshakeMainSessionKeyInsteadOfKeyShapeGuessing()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.SetMainSessionKey("global");
+ helper.ParseSessionsPayload("""
+ [
+ {
+ "key": "agent:main:main",
+ "displayName": "Named non-main session",
+ "presentation": {
+ "title": "Named non-main session",
+ "titleSource": "displayName",
+ "family": "custom",
+ "isMain": true,
+ "isBackground": false
+ }
+ },
+ {
+ "key": "global",
+ "displayName": "Global main",
+ "presentation": {
+ "title": "Global session",
+ "titleSource": "generated",
+ "family": "global",
+ "isMain": false,
+ "isBackground": false
+ }
+ }
+ ]
+ """);
+
+ var sessions = helper.GetSessionList();
+ Assert.True(Assert.Single(sessions, session => session.Key == "global").IsMain);
+ Assert.False(Assert.Single(sessions, session => session.Key == "agent:main:main").IsMain);
+ }
+
+ [Fact]
+ public void ParseSessions_LegacyHandshakeAliasUsesRowMetadataAndBoundedCanonicalFallback()
+ {
+ var withPresentation = new GatewayClientTestHelper();
+ withPresentation.SetMainSessionKey("main", isCanonical: false);
+ withPresentation.ParseSessionsPayload("""
+ [
+ {
+ "key": "agent:main:main",
+ "presentation": {
+ "title": "Main session",
+ "titleSource": "generated",
+ "family": "main",
+ "isMain": true,
+ "isBackground": false
+ }
+ }
+ ]
+ """);
+ Assert.True(Assert.Single(withPresentation.GetSessionList()).IsMain);
+
+ var withoutPresentation = new GatewayClientTestHelper();
+ withoutPresentation.SetMainSessionKey("main", isCanonical: false);
+ withoutPresentation.ParseSessionsPayload("""
+ [ { "key": "agent:main:main", "status": "active" } ]
+ """);
+ Assert.True(Assert.Single(withoutPresentation.GetSessionList()).IsMain);
+ }
+
+ [Fact]
+ public void ParseSessions_UsesPresentationMainBeforeHandshakeAuthority()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.ParseSessionsPayload("""
+ [
+ {
+ "key": "global",
+ "isMain": false,
+ "presentation": {
+ "title": "Global session",
+ "titleSource": "generated",
+ "family": "global",
+ "isMain": true,
+ "isBackground": false
+ }
+ }
+ ]
+ """);
+
+ Assert.True(Assert.Single(helper.GetSessionList()).IsMain);
+ }
+
+ [Fact]
+ public void ParseSessions_RejectsPartialPresentationObjects()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.ParseSessionsPayload("""
+ [
+ {
+ "key": "agent:main:subagent:child",
+ "presentation": {
+ "title": "Subagent",
+ "family": "subagent"
+ }
+ }
+ ]
+ """);
+
+ var session = Assert.Single(helper.GetSessionList());
+ Assert.Null(session.Presentation);
+ Assert.True(SessionPresentationResolver.IsBackground(session));
+ }
+
+ [Fact]
+ public void ParseSessions_MapPayloadUsesRowMainOnlyBeforeHandshakeAuthority()
+ {
+ var legacy = new GatewayClientTestHelper();
+ legacy.ParseSessionsPayload("""
+ { "session-custom": { "isMain": true, "displayName": "Legacy main" } }
+ """);
+ Assert.True(Assert.Single(legacy.GetSessionList()).IsMain);
+
+ var connected = new GatewayClientTestHelper();
+ connected.SetMainSessionKey("global");
+ connected.ParseSessionsPayload("""
+ {
+ "session-custom": { "isMain": true, "displayName": "Not main" },
+ "global": { "isMain": false, "displayName": "Global main" }
+ }
+ """);
+ Assert.False(Assert.Single(connected.GetSessionList(), session => session.Key == "session-custom").IsMain);
+ Assert.True(Assert.Single(connected.GetSessionList(), session => session.Key == "global").IsMain);
+ }
+
+ [Fact]
+ public void ParseSessions_SparseUpdatesPreserveExplicitFalseMainStatus()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.ParseSessionsPayload("""
+ [
+ {
+ "key": "agent:main:main",
+ "presentation": {
+ "title": "Not main",
+ "titleSource": "label",
+ "family": "custom",
+ "isMain": false,
+ "isBackground": false
+ }
+ },
+ { "key": "main", "isMain": false }
+ ]
+ """);
+ helper.ParseSessionsPayload("""
+ [
+ { "key": "agent:main:main", "status": "active" },
+ { "key": "main", "status": "active" }
+ ]
+ """);
+
+ Assert.All(helper.GetSessionList(), session => Assert.False(session.IsMain));
+ }
+
+ [Fact]
+ public void ParseSessions_SparseUpdatesPreservePresentationMetadata()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.ParseSessionsPayload("""
+ [
+ {
+ "key": "agent:main:subagent:child",
+ "channel": "telegram",
+ "presentation": {
+ "title": "Research",
+ "titleSource": "label",
+ "family": "subagent",
+ "agentId": "main",
+ "isMain": false,
+ "isBackground": true
+ }
+ }
+ ]
+ """);
+ helper.ParseSessionsPayload("""
+ [{ "key": "agent:main:subagent:child", "status": "active" }]
+ """);
+
+ var session = Assert.Single(helper.GetSessionList());
+ Assert.Equal("telegram", session.Channel);
+ Assert.Equal("Research", session.Presentation?.Title);
+ Assert.True(session.Presentation?.IsBackground == true);
+ }
+
+ [Fact]
+ public void ParseSessions_SparseUpdatesPreserveLegacyRowMainStatus()
+ {
+ var helper = new GatewayClientTestHelper();
+ helper.ParseSessionsPayload("""
+ [{ "key": "session-custom", "isMain": true, "displayName": "Legacy main" }]
+ """);
+ helper.ParseSessionsPayload("""
+ [{ "key": "session-custom", "status": "active" }]
+ """);
+
+ Assert.True(Assert.Single(helper.GetSessionList()).IsMain);
+ }
+
[Fact]
public void ParseSessions_EmptyArray_ClearsPreviousSessions()
{
diff --git a/tests/OpenClaw.Shared.Tests/Protocol/GatewayProtocolDriftTests.cs b/tests/OpenClaw.Shared.Tests/Protocol/GatewayProtocolDriftTests.cs
index 9ab9fa42a..9e5cd6a2d 100644
--- a/tests/OpenClaw.Shared.Tests/Protocol/GatewayProtocolDriftTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Protocol/GatewayProtocolDriftTests.cs
@@ -114,7 +114,12 @@ public void SessionsList_responseShape_matches_snapshot()
var pinned = snapshot.Methods.Single(m => m.Method == "sessions.list").ResponseFields.ToHashSet(StringComparer.Ordinal);
- var signatures = new[] { "void PopulateSessionFromObject(", "string? ParseSessionItem(" };
+ var signatures = new[]
+ {
+ "void PopulateSessionFromObject(",
+ "string? ParseSessionItem(",
+ "void UpdateSessionMainStatus(",
+ };
var readByClient = new HashSet(StringComparer.Ordinal);
var missingSignatures = new List();
diff --git a/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json b/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json
index 5988b11b6..85d348524 100644
--- a/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json
+++ b/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json
@@ -55,7 +55,7 @@
},
"clientFoundationCommit": "2cae69ba",
"_clientFoundationCommitNote": "The commit on main that introduced the typed gateway protocol client (OpenClawGatewayClient.Protocol.cs + GatewayProtocolModels.cs) this guard cross-checks.",
- "snapshotUpdated": "2026-06-24",
+ "snapshotUpdated": "2026-07-13",
"scopePrefixes": ["sessions.", "agents.files.", "commands."],
"methods": [
{
@@ -66,8 +66,10 @@
"responseEnvelope": ["sessions"],
"responseEnvelopeSource": "TryGetSessionsPayload(JsonElement payload",
"responseFields": [
- "key", "isMain", "status", "model", "channel", "displayName", "provider",
- "subject", "room", "space", "sessionId", "thinkingLevel", "verboseLevel",
+ "key", "isMain", "status", "model", "label", "channel", "displayName",
+ "derivedTitle", "modelProvider", "provider", "subject", "groupChannel", "room",
+ "space", "chatType", "execNode", "parentSessionKey", "spawnDepth", "origin",
+ "worktree", "presentation", "sessionId", "thinkingLevel", "verboseLevel",
"systemSent", "abortedLastRun", "inputTokens", "outputTokens", "totalTokens",
"contextTokens", "updatedAt", "startedAt"
]
diff --git a/tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs b/tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs
new file mode 100644
index 000000000..3c79c0a17
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs
@@ -0,0 +1,183 @@
+using OpenClaw.Shared;
+
+namespace OpenClaw.Shared.Tests;
+
+public sealed class SessionPresentationResolverTests
+{
+ [Fact]
+ public void Resolve_PrefersGatewayTitleOverLegacyDisplayName()
+ {
+ var presentation = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = "agent:main:telegram:main:direct:491234567890",
+ DisplayName = "Telegram:491234567890",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Family chat",
+ Family = "direct",
+ },
+ });
+
+ Assert.Equal("Family chat", presentation.Title);
+ Assert.DoesNotContain("491234567890", presentation.Title, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_PrefersGatewayPresentationAndProjectsContext()
+ {
+ var session = new SessionInfo
+ {
+ Key = "agent:main:telegram:main:direct:491234567890",
+ DisplayName = "agent:main:telegram:main:direct:491234567890",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Telegram direct message",
+ Subtitle = "Telegram · account main · agent main",
+ Family = "direct",
+ AgentId = "main",
+ Channel = "telegram",
+ AccountId = "main",
+ PeerKind = "direct",
+ },
+ };
+
+ var resolved = SessionPresentationResolver.Resolve(session);
+
+ Assert.Equal("Telegram direct message", resolved.Title);
+ Assert.Equal("Telegram · account main · agent main", resolved.Subtitle);
+ Assert.Equal("direct", resolved.Family);
+ Assert.Equal("main", resolved.AgentId);
+ Assert.Equal("telegram", resolved.Channel);
+ Assert.Equal("main", resolved.AccountId);
+ Assert.DoesNotContain("491234567890", resolved.Title);
+ }
+
+ [Theory]
+ [InlineData("agent:main:main", true, "main", "Main session", false)]
+ [InlineData("agent:main:dashboard:01234567-89ab-cdef-0123-456789abcdef", false, "dashboard", "New session", false)]
+ [InlineData("agent:main:tui-01234567-89ab-cdef-0123-456789abcdef", false, "tui", "Terminal session", false)]
+ [InlineData("agent:main:tui-01234567-89ab-cdef-0123-456789abcdef:heartbeat", false, "heartbeat", "Heartbeat", true)]
+ [InlineData("agent:main:subagent:child", false, "subagent", "Subagent", true)]
+ [InlineData("agent:main:acp:child", false, "acp", "ACP session", true)]
+ [InlineData("agent:main:cron:job:run:run-1", false, "cron", "Scheduled task", true)]
+ [InlineData("agent:main:cron:group:run:run-1", false, "cron", "Scheduled task", true)]
+ [InlineData("agent:main:dreaming-narrative-rem-workspace", false, "dreaming", "Dreaming", true)]
+ [InlineData("agent:main:internal-session-effects:run", false, "system", "Background task", true)]
+ public void Resolve_FallsBackForOlderGateways(
+ string key,
+ bool isMain,
+ string family,
+ string title,
+ bool isBackground)
+ {
+ var resolved = SessionPresentationResolver.Resolve(new SessionInfo { Key = key, IsMain = isMain });
+
+ Assert.Equal(family, resolved.Family);
+ Assert.Equal(title, resolved.Title);
+ Assert.Equal(isBackground, resolved.IsBackground);
+ }
+
+ [Fact]
+ public void Resolve_TreatsOnlyTheAgentWrapperAsStructured()
+ {
+ var resolved = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = "agent:ops:explicit:model-run-01234567-89ab-cdef-0123-456789abcdef",
+ });
+
+ Assert.Equal("model-run-…cdef", resolved.Title);
+ Assert.Equal("ops", resolved.AgentId);
+ Assert.DoesNotContain("01234567-89ab-cdef-0123-456789abcdef", resolved.Title);
+ }
+
+ [Theory]
+ [InlineData("agent:ops:discord:work:group:dev", "group")]
+ [InlineData("agent:ops:discord:work:channel:dev", "channel")]
+ [InlineData("agent:ops:discord:work:room:dev", "group")]
+ public void Resolve_RecognizesAccountQualifiedGroupRoutes(string key, string family)
+ {
+ var resolved = SessionPresentationResolver.Resolve(new SessionInfo { Key = key });
+
+ Assert.Equal(family, resolved.Family);
+ Assert.Equal("discord", resolved.Channel);
+ Assert.Equal("work", resolved.AccountId);
+ }
+
+ [Fact]
+ public void Resolve_RejectsRawKeyDisplayNames()
+ {
+ const string key = "agent:main:telegram:main:direct:491234567890";
+ var resolved = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = key,
+ DisplayName = key,
+ });
+
+ Assert.Equal("Telegram direct message", resolved.Title);
+ Assert.DoesNotContain("491234567890", resolved.Title);
+ }
+
+ [Fact]
+ public void Resolve_DoesNotGuessHeartbeatFromAmbiguousSuffixes()
+ {
+ var explicitSession = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = "agent:ops:explicit:heartbeat",
+ });
+ var routedSession = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = "agent:ops:telegram:group:heartbeat",
+ });
+
+ Assert.Equal("explicit", explicitSession.Family);
+ Assert.False(explicitSession.IsBackground);
+ Assert.Equal("group", routedSession.Family);
+ Assert.False(routedSession.IsBackground);
+
+ var explicitThread = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = "agent:ops:explicit:thread:launch",
+ });
+ Assert.Equal("explicit", explicitThread.Family);
+ }
+
+ [Fact]
+ public void Resolve_RejectsLegacyDisplayNamesContainingOpaqueIds()
+ {
+ var resolved = SessionPresentationResolver.Resolve(new SessionInfo
+ {
+ Key = "agent:main:telegram:main:direct:491234567890",
+ DisplayName = "Telegram:491234567890",
+ });
+
+ Assert.Equal("Telegram direct message", resolved.Title);
+ Assert.DoesNotContain("491234567890", resolved.Title);
+ }
+
+ [Fact]
+ public void Resolve_AssignsCanonicalGlobalSessionToMainAgent()
+ {
+ var resolved = SessionPresentationResolver.Resolve(new SessionInfo { Key = "global", IsMain = true });
+
+ Assert.Equal("main", resolved.AgentId);
+ Assert.True(resolved.IsMain);
+ }
+
+ [Fact]
+ public void IsVisible_HidesBackgroundUnlessUserEnablesIt()
+ {
+ var session = new SessionInfo
+ {
+ Key = "agent:main:subagent:child",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Research",
+ Family = "subagent",
+ IsBackground = true,
+ },
+ };
+
+ Assert.False(SessionPresentationResolver.IsVisible(session, showBackground: false));
+ Assert.True(SessionPresentationResolver.IsVisible(session, showBackground: true));
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
index fd5b4187f..480f1690b 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
@@ -625,6 +625,86 @@ public async Task LoadAsync_DistinguishesDuplicateMultiSegmentSessionTitles()
.Distinct(StringComparer.OrdinalIgnoreCase).Count());
Assert.Equal("agent:main:subagent:uuid-b", snapshot.Threads[0].Id);
Assert.Equal("agent:main:subagent:uuid-a", snapshot.Threads[1].Id);
+ Assert.All(snapshot.Threads, thread => Assert.True(thread.IsBackground));
+ Assert.All(snapshot.Threads, thread => Assert.Equal("main", thread.AgentId));
+ }
+
+ [Fact]
+ public async Task LoadAsync_PreservesRawKeyAsId_EvenWithPresentationTitle()
+ {
+ // Gateway keys must round-trip untouched: the resolver only derives display fields.
+ var rawKey = "agent:main:tui-847241c7-3f9a-4a2b-b123-abcdef123456";
+ var sessions = new[]
+ {
+ new SessionInfo
+ {
+ Key = rawKey,
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Terminal session",
+ Family = "tui",
+ AgentId = "main",
+ },
+ },
+ };
+ var (_, provider, _, _) = CreateProvider(sessions);
+ var snapshot = await provider.LoadAsync();
+
+ Assert.Equal(rawKey, snapshot.Threads[0].Id);
+ Assert.Equal("Terminal session", snapshot.Threads[0].Title);
+ }
+
+ [Fact]
+ public async Task LoadAsync_EndedAndBackgroundFiltering_ComposesCorrectly()
+ {
+ // Verifies the full filtering pipeline: ended sessions get Status=Ended,
+ // background sessions get IsBackground=true, and both properties coexist.
+ var sessions = new[]
+ {
+ new SessionInfo { Key = "agent:main:main", IsMain = true, Status = "active" },
+ new SessionInfo { Key = "agent:main:cron:daily", Status = "completed" },
+ new SessionInfo { Key = "agent:main:explicit:task", Status = "done" },
+ new SessionInfo { Key = "agent:main:hook:pr-check", Status = "active" },
+ };
+ var (_, provider, _, _) = CreateProvider(sessions);
+ var snapshot = await provider.LoadAsync();
+
+ var main = Assert.Single(snapshot.Threads, t => t.Id == "agent:main:main");
+ Assert.Equal(ChatThreadStatus.Running, main.Status);
+ Assert.False(main.IsBackground);
+
+ var cron = Assert.Single(snapshot.Threads, t => t.Id == "agent:main:cron:daily");
+ Assert.Equal(ChatThreadStatus.Ended, cron.Status);
+ Assert.True(cron.IsBackground);
+
+ var task = Assert.Single(snapshot.Threads, t => t.Id == "agent:main:explicit:task");
+ Assert.Equal(ChatThreadStatus.Ended, task.Status);
+ Assert.False(task.IsBackground);
+
+ var hook = Assert.Single(snapshot.Threads, t => t.Id == "agent:main:hook:pr-check");
+ Assert.Equal(ChatThreadStatus.Running, hook.Status);
+ Assert.True(hook.IsBackground);
+ }
+
+ [Fact]
+ public async Task LoadAsync_GatewayPresentationAgentId_OverridesKeyParsing()
+ {
+ // When Presentation.AgentId is set, it takes precedence over key parsing.
+ var sessions = new[]
+ {
+ new SessionInfo
+ {
+ Key = "agent:main:explicit:work",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Work", Family = "explicit", AgentId = "custom-agent",
+ },
+ },
+ };
+ var (_, provider, _, _) = CreateProvider(sessions);
+ var snapshot = await provider.LoadAsync();
+
+ Assert.Equal("custom-agent", snapshot.Threads[0].AgentId);
}
[Fact]
@@ -2879,6 +2959,7 @@ public async Task LoadAsync_HandshakeKnown_ZeroSessions_ExposesReadyComposeTarge
Assert.Empty(snap.Threads);
Assert.True(snap.ComposeTarget.IsReady);
Assert.Equal("agent:main:main", snap.ComposeTarget.SessionKey);
+ Assert.Equal("main", snap.ComposeTarget.AgentId);
Assert.Equal("agent:main:main", snap.DefaultThreadId);
}
@@ -7145,7 +7226,7 @@ public async Task RememberSelectedThread_PrefersSelectionAfterReload()
Assert.Equal("agent:main:review", reloaded.DefaultThreadId);
Assert.Equal("agent:main:review", provider.CachedLastChatState?.DefaultThreadId);
- Assert.Equal("Review (main/review)", provider.CachedLastChatState?.ThreadTitle);
+ Assert.Equal("Review", provider.CachedLastChatState?.ThreadTitle);
Assert.Equal("gpt-5.1", provider.CachedLastChatState?.Model);
Assert.Equal("openai", provider.CachedLastChatState?.ModelProvider);
}
diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs
index 6926c4a54..03a536c5b 100644
--- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs
@@ -708,4 +708,92 @@ public void SelectActiveSession_EmptyReturnsNull()
{
Assert.Null(TrayDashboardSummaryBuilder.SelectActiveSession(Array.Empty()));
}
+
+ [Fact]
+ public void SelectActiveSession_IgnoresBackgroundSessions()
+ {
+ var heartbeat = new SessionInfo
+ {
+ Key = "agent:main:tui-id:heartbeat",
+ Status = "active",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Heartbeat",
+ Family = "heartbeat",
+ IsBackground = true,
+ },
+ };
+ var main = new SessionInfo { Key = "agent:main:main", IsMain = true, Status = "idle" };
+
+ Assert.Same(main, TrayDashboardSummaryBuilder.SelectActiveSession([heartbeat, main]));
+ Assert.Null(TrayDashboardSummaryBuilder.SelectActiveSession([heartbeat]));
+ }
+
+ [Fact]
+ public void SelectActiveSession_PrefersNonEndedOverEndedFallback()
+ {
+ // When no session is "active" or "main", ended sessions should not
+ // appear as the dashboard's current session if non-ended ones exist.
+ var ended = new SessionInfo
+ {
+ Key = "agent:main:explicit:old-task",
+ Status = "completed",
+ UpdatedAt = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc),
+ };
+ var idle = new SessionInfo
+ {
+ Key = "agent:main:explicit:current",
+ Status = "idle",
+ UpdatedAt = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc),
+ };
+
+ // idle is older but non-ended, so it wins over the more recent ended one
+ var result = TrayDashboardSummaryBuilder.SelectActiveSession([ended, idle]);
+ Assert.Same(idle, result);
+ }
+
+ [Fact]
+ public void SelectActiveSession_FallsBackToEndedWhenAllEnded()
+ {
+ // When every session is ended, still return the most recent one
+ // rather than null (the tray should show something if sessions exist).
+ var failed = new SessionInfo
+ {
+ Key = "agent:main:explicit:task-a",
+ Status = "failed",
+ UpdatedAt = new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc),
+ };
+ var done = new SessionInfo
+ {
+ Key = "agent:main:explicit:task-b",
+ Status = "done",
+ UpdatedAt = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc),
+ };
+
+ var result = TrayDashboardSummaryBuilder.SelectActiveSession([failed, done]);
+ Assert.Same(done, result);
+ }
+
+ [Fact]
+ public void SelectActiveSession_AbortedLastRunNotTreatedAsEnded()
+ {
+ // AbortedLastRun sessions must NOT be classified as ended (#1017 invariant)
+ var aborted = new SessionInfo
+ {
+ Key = "agent:main:explicit:retrying",
+ Status = "failed",
+ AbortedLastRun = true,
+ UpdatedAt = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc),
+ };
+ var idle = new SessionInfo
+ {
+ Key = "agent:main:explicit:other",
+ Status = "idle",
+ UpdatedAt = new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc),
+ };
+
+ // aborted is more recent and not ended, so it wins
+ var result = TrayDashboardSummaryBuilder.SelectActiveSession([aborted, idle]);
+ Assert.Same(aborted, result);
+ }
}
diff --git a/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs b/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs
index 970c80713..b776a1e66 100644
--- a/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs
+++ b/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs
@@ -20,19 +20,20 @@ public void Format_DistinguishesForkFromCanonicalSessionWithSameDisplayName()
DisplayName = "OpenClaw Windows Tray",
};
- var canonicalTitle = SessionTitleFormatter.Format(canonical);
- var forkTitle = SessionTitleFormatter.Format(fork);
+ var titles = SessionTitleFormatter.FormatUnique([canonical, fork]);
+ var canonicalTitle = titles[0];
+ var forkTitle = titles[1];
Assert.Equal("OpenClaw Windows Tray", canonicalTitle);
- Assert.Equal("OpenClaw Windows Tray (main/fork)", forkTitle);
+ Assert.Equal("OpenClaw Windows Tray (2)", forkTitle);
Assert.NotEqual(canonicalTitle, forkTitle);
Assert.Equal("agent:main:main", canonical.Key);
Assert.Equal("agent:main:fork", fork.Key);
}
[Theory]
- [InlineData("agent:assistant:assistant", "Research", "Research (assistant)")]
- [InlineData("agent:assistant:review", "Research", "Research (assistant/review)")]
+ [InlineData("agent:assistant:assistant", "Research", "Research")]
+ [InlineData("agent:assistant:review", "Research", "Research")]
[InlineData("legacy-session", "Research", "Research")]
public void Format_UsesStableKeyQualifier(string key, string displayName, string expected)
{
@@ -47,8 +48,8 @@ public void Format_PreservesExistingMissingDisplayNameFallbacks()
var main = new SessionInfo { Key = "agent:main:main", IsMain = true };
var worker = new SessionInfo { Key = "agent:assistant:review" };
- Assert.Equal("OpenClaw Windows Tray", SessionTitleFormatter.Format(main));
- Assert.Equal("assistant (assistant/review)", SessionTitleFormatter.Format(worker));
+ Assert.Equal("Main session", SessionTitleFormatter.Format(main));
+ Assert.Equal("review", SessionTitleFormatter.Format(worker));
}
[Fact]
@@ -62,8 +63,8 @@ public void FormatUnique_DistinguishesMultiSegmentKeysWithSamePrefix()
var titles = SessionTitleFormatter.FormatUnique(sessions);
- Assert.Equal("Research (main/subagent) (2)", titles[0]);
- Assert.Equal("Research (main/subagent)", titles[1]);
+ Assert.Equal("Research (2)", titles[0]);
+ Assert.Equal("Research", titles[1]);
Assert.Equal(2, titles.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
@@ -118,6 +119,112 @@ public void FormatUnique_AssignmentRemainsStableWhenCallerFiltersSubset()
Assert.Equal("Research (2)", whatsAppTitle);
}
+ [Theory]
+ [InlineData("agent:main:telegram:main:direct:491234567890", "Telegram direct message")]
+ [InlineData("agent:main:tui-01234567-89ab-cdef-0123-456789abcdef", "Terminal session")]
+ [InlineData("agent:main:tui-01234567-89ab-cdef-0123-456789abcdef:heartbeat", "Heartbeat")]
+ [InlineData("agent:main:subagent:01234567-89ab-cdef-0123-456789abcdef", "Subagent")]
+ public void Format_DoesNotExposeOpaqueSessionKeys(string key, string expected)
+ {
+ Assert.Equal(expected, SessionTitleFormatter.Format(new SessionInfo
+ {
+ Key = key,
+ DisplayName = key,
+ }));
+ }
+
+ [Fact]
+ public void Format_PreservesExplicitGatewayTitlesAndLocalizesOnlyGeneratedFamilies()
+ {
+ var explicitTitle = new SessionInfo
+ {
+ Key = "agent:main:telegram:main:direct:491234567890",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Family chat",
+ TitleSource = "label",
+ Family = "direct",
+ Channel = "telegram",
+ },
+ };
+
+ Assert.Equal("Family chat", SessionTitleFormatter.Format(explicitTitle));
+ }
+
+ [Fact]
+ public void Format_HandlesCaseInsensitiveGeneratedRouteFamilies()
+ {
+ var session = new SessionInfo
+ {
+ Key = "route",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Telegram direct message",
+ TitleSource = "generated",
+ Family = "Direct",
+ Channel = "telegram",
+ },
+ };
+
+ Assert.Equal("Telegram direct message", SessionTitleFormatter.Format(session));
+ }
+
+ [Fact]
+ public void FormatSubtitle_PreservesGatewaySubtitleWithoutStructuredParts()
+ {
+ var session = new SessionInfo
+ {
+ Key = "custom",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Custom",
+ Family = "custom",
+ Subtitle = "Remote workspace",
+ },
+ };
+
+ Assert.Equal("Remote workspace", SessionTitleFormatter.FormatSubtitle(session));
+ }
+
+ [Fact]
+ public void FormatSubtitle_MasksPhoneLikeAccountIds()
+ {
+ var session = new SessionInfo
+ {
+ Key = "custom",
+ Presentation = new SessionPresentationInfo
+ {
+ Title = "Telegram direct message",
+ Family = "direct",
+ AccountId = "491234567890",
+ },
+ };
+
+ var subtitle = SessionTitleFormatter.FormatSubtitle(session);
+
+ Assert.Equal("account …7890", subtitle);
+ Assert.DoesNotContain("491234567890", subtitle, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SessionLocalizationResources_CoverGeneratedTitlesAndSubtitleLabels()
+ {
+ var root = TestRepositoryPaths.GetRepositoryRoot();
+ foreach (var locale in new[] { "en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw" })
+ {
+ var resources = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Strings",
+ locale,
+ "Resources.resw"));
+ Assert.Contains("SessionTitle_Heartbeat", resources, StringComparison.Ordinal);
+ Assert.Contains("SessionTitle_Direct", resources, StringComparison.Ordinal);
+ Assert.Contains("SessionSubtitle_Account", resources, StringComparison.Ordinal);
+ }
+ }
+
[Fact]
public void SessionsPage_UsesSharedSessionTitleFormatter()
{
@@ -136,6 +243,8 @@ public void SessionsPage_UsesSharedSessionTitleFormatter()
Assert.Contains("ToViewModel(item.Session, item.Title)", source, StringComparison.Ordinal);
Assert.Contains("DisplayName = displayName", source, StringComparison.Ordinal);
Assert.Contains("Key = s.Key", source, StringComparison.Ordinal);
+ Assert.Contains("SessionsForCurrentBackgroundScope()", source, StringComparison.Ordinal);
+ Assert.Contains("SessionPresentationResolver.IsVisible(session, _showBackgroundSessions)", source, StringComparison.Ordinal);
var xaml = File.ReadAllText(Path.Combine(
TestRepositoryPaths.GetRepositoryRoot(),
@@ -144,5 +253,37 @@ public void SessionsPage_UsesSharedSessionTitleFormatter()
"Pages",
"SessionsPage.xaml"));
Assert.Contains("Tag=\"{Binding Key}\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("SessionsPageShowBackground", xaml, StringComparison.Ordinal);
+
+ var chatRoot = File.ReadAllText(Path.Combine(
+ TestRepositoryPaths.GetRepositoryRoot(),
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Chat",
+ "OpenClawChatRoot.cs"));
+ Assert.Contains("t.IsVisibleInSessionPicker(effectiveThread?.Id)", chatRoot, StringComparison.Ordinal);
+ Assert.Contains("t.AgentId", chatRoot, StringComparison.Ordinal);
+
+ var composer = File.ReadAllText(Path.Combine(
+ TestRepositoryPaths.GetRepositoryRoot(),
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Chat",
+ "OpenClawComposer.cs"));
+ Assert.Contains("ToolTipService.SetToolTip(b, session.Title)", composer, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ChatThreadVisibility_HidesBackgroundButRetainsExplicitSelection()
+ {
+ var thread = new OpenClaw.Chat.ChatThread
+ {
+ Id = "agent:main:subagent:child",
+ Title = "Research",
+ IsBackground = true,
+ };
+
+ Assert.False(thread.IsVisibleInSessionPicker("agent:main:main"));
+ Assert.True(thread.IsVisibleInSessionPicker(thread.Id));
}
}
diff --git a/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs b/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs
index 374241237..e343c4f14 100644
--- a/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs
+++ b/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs
@@ -14,7 +14,7 @@ namespace OpenClaw.Tray.UITests;
public sealed class SessionTitleBehaviorProofTests
{
private const string RawTitle = "OpenClaw Windows Tray";
- private const string ForkTitle = "OpenClaw Windows Tray (main/fork)";
+ private const string ForkTitle = "OpenClaw Windows Tray (2)";
private static readonly TimeSpan UiTimeout = TimeSpan.FromSeconds(15);
private readonly AccessibilityAppFixture _app;