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;