From d1703c7c48cb014667b242356551360b78acab9c Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 13 Jul 2026 14:41:46 -0700 Subject: [PATCH 01/10] fix: render sessions with friendly presentation metadata --- src/OpenClaw.Chat/ChatModels.cs | 8 +- src/OpenClaw.Shared/Models.cs | 55 ++-- src/OpenClaw.Shared/OpenClawGatewayClient.cs | 117 +++++-- .../Sessions/SessionPresentationResolver.cs | 286 ++++++++++++++++++ .../Chat/OpenClawChatDataProvider.cs | 10 +- .../Chat/OpenClawChatRoot.cs | 28 +- .../Pages/SessionsPage.xaml | 10 +- .../Pages/SessionsPage.xaml.cs | 38 +-- .../Services/SessionTitleFormatter.cs | 127 ++++++-- .../Services/TrayDashboardSummary.cs | 11 +- .../Services/TrayMenuStateBuilder.cs | 24 +- .../Strings/en-us/Resources.resw | 28 ++ .../Strings/fr-fr/Resources.resw | 28 ++ .../Strings/nl-nl/Resources.resw | 28 ++ .../Strings/zh-cn/Resources.resw | 28 ++ .../Strings/zh-tw/Resources.resw | 28 ++ tests/OpenClaw.Shared.Tests/ModelsTests.cs | 21 +- .../OpenClawGatewayClientTests.cs | 115 +++++++ .../SessionPresentationResolverTests.cs | 176 +++++++++++ .../OpenClawChatDataProviderTests.cs | 2 + .../TrayDashboardSummaryBuilderTests.cs | 20 ++ .../SessionTitleFormatterTests.cs | 139 ++++++++- .../SessionTitleBehaviorProofTests.cs | 2 +- 23 files changed, 1177 insertions(+), 152 deletions(-) create mode 100644 src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs create mode 100644 tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index da526cd1d..4682333dd 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -95,6 +95,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; } @@ -230,9 +234,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 6174b48cb..345bd2f04 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -251,6 +251,27 @@ 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 @@ -259,14 +280,23 @@ public class SessionInfo public string Key { get; set; } = ""; public bool IsMain { 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 +334,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 +377,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 ed6674a66..5bd99ee19 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -3483,8 +3483,7 @@ private void ParseSessions(JsonElement sessions) session = new SessionInfo { Key = sessionKey }; } - var endsWithMain = sessionKey.EndsWith(":main"); - session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main"); + session.IsMain = IsMainSessionKey(sessionKey); if (item.ValueKind == JsonValueKind.Object) { @@ -3524,7 +3523,7 @@ private void ParseSessions(JsonElement sessions) _logger.Warn($"Failed to parse sessions: {ex.Message}"); } } - + private string? ParseSessionItem(JsonElement item) { var sessionKey = "unknown"; @@ -3537,19 +3536,31 @@ 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 = IsMainSessionKey(sessionKey); + + if (string.IsNullOrWhiteSpace(MainSessionKey) + && item.TryGetProperty("isMain", out var isMain) + && isMain.ValueKind == JsonValueKind.True) session.IsMain = true; - + PopulateSessionFromObject(session, item); _sessions[session.Key] = session; return session.Key; } + private bool IsMainSessionKey(string sessionKey) + { + var mainSessionKey = MainSessionKey; + if (!string.IsNullOrWhiteSpace(mainSessionKey)) + return string.Equals(sessionKey, 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 PopulateSessionFromObject(SessionInfo session, JsonElement item) { if (item.TryGetProperty("status", out var status)) @@ -3561,18 +3572,32 @@ 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(); + session.Label = GetString(item, "label"); + session.Channel = GetString(item, "channel"); + session.DisplayName = GetString(item, "displayName"); + session.DerivedTitle = GetString(item, "derivedTitle"); + session.Provider = GetString(item, "modelProvider") ?? GetString(item, "provider"); + session.Subject = GetString(item, "subject"); + session.Room = GetString(item, "groupChannel") ?? GetString(item, "room"); + session.Space = GetString(item, "space"); + session.ChatType = GetString(item, "chatType"); + session.ExecNode = GetString(item, "execNode"); + session.ParentSessionKey = GetString(item, "parentSessionKey"); + session.SpawnDepth = item.TryGetProperty("spawnDepth", out var spawnDepth) + && spawnDepth.ValueKind == JsonValueKind.Number + && spawnDepth.TryGetInt32(out var parsedSpawnDepth) + ? parsedSpawnDepth + : null; + session.OriginLabel = item.TryGetProperty("origin", out var origin) + && origin.ValueKind == JsonValueKind.Object + ? GetString(origin, "label") + : null; + session.Worktree = ParseSessionWorktree(item); + 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)) @@ -3611,6 +3636,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..18195e979 --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs @@ -0,0 +1,286 @@ +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); + + private static SessionPresentationInfo ResolveKey( + string? rawKey, + bool isMain, + string? rowChannel, + SessionWorktreeInfo? worktree) + { + var key = rawKey?.Trim() ?? string.Empty; + if (key.Length == 0) + return Presentation("Session", "unknown", isMain: isMain); + 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); + + 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); + } + + 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); + + return Presentation(ReadableTail(rest), "custom", agentId); + } + + private static (string? AgentId, string Rest) 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/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index b67e43ce0..14c9870de 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 + : null; 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 = ChatThreadStatus.Running, 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 686be483f..cc040eb06 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,13 @@ 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. var channelGroups = 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,17 +549,11 @@ 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. if (effectiveThread is not null && !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/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml index c59b955bd..70dd8b328 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml @@ -25,6 +25,7 @@ + -