Skip to content

Commit 5caf085

Browse files
shanselmanTurboTheTurtleCopilot
authored
Disambiguate duplicate session titles (#959)
Share session-title formatting between native Chat and the Sessions page, adding deterministic disambiguation when display names collide while preserving original session keys for navigation and actions. This is the #955 app/test change set without the optional workflow proof-summary plumbing. Validation: focused local OpenClawChatDataProvider/SessionTitleFormatter test run passed (234 tests); GitHub Build and Test, CodeQL, E2E, win-x64/win-arm64 builds all passed. Maintainer live-tested against a real profile containing duplicate session titles. Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c8cd986 commit 5caf085

9 files changed

Lines changed: 688 additions & 63 deletions

File tree

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

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ internal void RememberSelectedThread(string? threadId)
328328
state = new LastChatState
329329
{
330330
DefaultThreadId = threadId,
331-
ThreadTitle = BuildSessionTitle(session),
331+
ThreadTitle = SessionTitleFormatter.Format(session, _sessions),
332332
Model = session.Model,
333333
ModelProvider = session.Provider,
334334
AvailableModels = _availableModels,
@@ -5608,8 +5608,9 @@ private ChatDataSnapshot BuildSnapshotLocked()
56085608
// a usable composer even before the first session materializes server-
56095609
// side (e.g. fresh install with zero sessions).
56105610
var threadList = new List<ChatThread>(_sessions.Length + 1);
5611+
var threadTitles = SessionTitleFormatter.FormatUnique(_sessions);
56115612
for (int i = 0; i < _sessions.Length; i++)
5612-
threadList.Add(ToThread(_sessions[i]));
5613+
threadList.Add(ToThread(_sessions[i], threadTitles[i]));
56135614

56145615
var composeKey = _bridge.MainSessionKey;
56155616
var composeReady = _bridge.HasHandshakeSnapshot
@@ -5752,7 +5753,7 @@ private void RememberLastSessionStateLocked()
57525753
_lastChatState = new LastChatState
57535754
{
57545755
DefaultThreadId = session.Key,
5755-
ThreadTitle = BuildSessionTitle(session),
5756+
ThreadTitle = SessionTitleFormatter.Format(session, _sessions),
57565757
Model = session.Model,
57575758
ModelProvider = session.Provider,
57585759
AvailableModels = _availableModels,
@@ -5775,10 +5776,8 @@ private bool TryGetSessionLocked(string threadId, out SessionInfo session)
57755776
return false;
57765777
}
57775778

5778-
private static ChatThread ToThread(SessionInfo s)
5779+
private static ChatThread ToThread(SessionInfo s, string title)
57795780
{
5780-
var title = BuildSessionTitle(s);
5781-
57825781
return new ChatThread
57835782
{
57845783
Id = s.Key ?? string.Empty,
@@ -5798,40 +5797,6 @@ private static ChatThread ToThread(SessionInfo s)
57985797
};
57995798
}
58005799

5801-
/// <summary>
5802-
/// Builds a human-readable title from the session key and display name.
5803-
/// Keys follow the pattern agent:{agentId}:{sessionSlot} (e.g. agent:main:main, agent:assistant:main).
5804-
/// When a DisplayName is set, we append the agent/slot as a qualifier to disambiguate
5805-
/// sessions that share the same DisplayName.
5806-
/// </summary>
5807-
private static string BuildSessionTitle(SessionInfo s)
5808-
{
5809-
var baseName = !string.IsNullOrWhiteSpace(s.DisplayName)
5810-
? s.DisplayName!
5811-
: (s.IsMain ? "OpenClaw Windows Tray" : s.ShortKey);
5812-
5813-
// Parse agent:agentId:sessionSlot from the key
5814-
var parts = (s.Key ?? "").Split(':');
5815-
if (parts.Length >= 3 && parts[0] == "agent")
5816-
{
5817-
var agentId = parts[1]; // e.g. "main", "assistant"
5818-
var sessionSlot = parts[2]; // e.g. "main", "assistant", "cron"
5819-
5820-
// For the canonical main session (agent:main:main), just show the base name
5821-
if (agentId == "main" && sessionSlot == "main")
5822-
return baseName;
5823-
5824-
// Otherwise, qualify with agent/slot to distinguish
5825-
var qualifier = agentId == sessionSlot
5826-
? agentId // e.g. "assistant" when both match
5827-
: $"{agentId}/{sessionSlot}"; // e.g. "assistant/main"
5828-
5829-
return $"{baseName} ({qualifier})";
5830-
}
5831-
5832-
return baseName;
5833-
}
5834-
58355800
private static DateTimeOffset ToOffset(DateTime dt)
58365801
{
58375802
// SessionInfo.StartedAt/UpdatedAt arrive as DateTimeKind.Local or

src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,9 @@ private void ShowFunctionalSurface()
334334

335335
private sealed class AccessibilityChatDataProvider : IChatDataProvider
336336
{
337-
private const string ThreadId = "accessibility-main";
337+
private const string DefaultThreadId = "accessibility-main";
338+
private const string MainThreadId = "agent:main:main";
339+
private const string ForkThreadId = "agent:main:fork";
338340
private static readonly ChatDataSnapshot Snapshot = CreateSnapshot();
339341

340342
public string DisplayName => "Accessibility test chat";
@@ -389,38 +391,58 @@ public Task RespondToPermissionAsync(
389391

390392
private static ChatDataSnapshot CreateSnapshot()
391393
{
392-
var timeline = ChatTimelineState.Initial() with
394+
static ChatTimelineState CreateTimeline(string id) => ChatTimelineState.Initial() with
393395
{
394396
Entries = ChatTimelineState.Initial().Entries
395397
.Add(new ChatTimelineItem(
396-
"accessibility-user",
398+
$"accessibility-user-{id}",
397399
ChatTimelineItemKind.User,
398400
"Verify the native chat surface."))
399401
.Add(new ChatTimelineItem(
400-
"accessibility-assistant",
402+
$"accessibility-assistant-{id}",
401403
ChatTimelineItemKind.Assistant,
402404
"The timeline and composer are ready.")),
403405
NextId = 3,
404406
HistoryLoaded = true,
405407
};
406408

407409
return new ChatDataSnapshot(
408-
[new ChatThread
409-
{
410-
Id = ThreadId,
411-
Title = "Accessibility session",
412-
Status = ChatThreadStatus.Running,
413-
Activity = ChatActivity.Idle,
414-
Model = "test-model",
415-
}],
410+
[
411+
new ChatThread
412+
{
413+
Id = DefaultThreadId,
414+
Title = "Accessibility session",
415+
Status = ChatThreadStatus.Running,
416+
Activity = ChatActivity.Idle,
417+
Model = "test-model",
418+
},
419+
new ChatThread
420+
{
421+
Id = MainThreadId,
422+
Title = $"Route target: {MainThreadId}",
423+
Status = ChatThreadStatus.Running,
424+
Activity = ChatActivity.Idle,
425+
Model = "test-model",
426+
},
427+
new ChatThread
428+
{
429+
Id = ForkThreadId,
430+
Title = $"Route target: {ForkThreadId}",
431+
Status = ChatThreadStatus.Running,
432+
Activity = ChatActivity.Idle,
433+
Model = "test-model",
434+
},
435+
],
416436
new Dictionary<string, ChatTimelineState>
417437
{
418-
[ThreadId] = timeline,
438+
[DefaultThreadId] = CreateTimeline(DefaultThreadId),
439+
[MainThreadId] = CreateTimeline(MainThreadId),
440+
[ForkThreadId] = CreateTimeline(ForkThreadId),
419441
},
420-
ThreadId,
442+
DefaultThreadId,
421443
"Connected (accessibility test)",
422444
["test-model"],
423-
new ChatComposeTarget(ThreadId, IsReady: true));
445+
new ChatComposeTarget(DefaultThreadId, IsReady: true));
424446
}
425447
}
426448

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ public void Initialize()
5555

5656
var client = CurrentApp.GatewayClient;
5757

58+
// The real-process accessibility suite has no gateway. Give it an
59+
// isolated, deterministic duplicate-name scenario so UI Automation can
60+
// prove both the rendered titles and the row-to-chat key hand-off.
61+
// Requiring the test data directory as well as the explicit flag keeps
62+
// this path unreachable from a normal app launch.
63+
if (Environment.GetEnvironmentVariable("OPENCLAW_ACCESSIBILITY_TEST_SESSIONS") == "1"
64+
&& Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 })
65+
{
66+
UpdateSessions(
67+
[
68+
new SessionInfo
69+
{
70+
Key = "agent:main:main",
71+
IsMain = true,
72+
Status = "active",
73+
DisplayName = "OpenClaw Windows Tray",
74+
UpdatedAt = DateTime.UtcNow,
75+
},
76+
new SessionInfo
77+
{
78+
Key = "agent:main:fork",
79+
Status = "active",
80+
DisplayName = "OpenClaw Windows Tray",
81+
UpdatedAt = DateTime.UtcNow.AddSeconds(-1),
82+
},
83+
]);
84+
return;
85+
}
86+
5887
// Rebind when the client instance changes so a cached page never holds
5988
// a stale command-result subscription.
6089
if (_subscribedClient != client)
@@ -161,18 +190,23 @@ private void ApplyFilter()
161190
return;
162191
}
163192

164-
IEnumerable<SessionInfo> filtered = _allSessions ?? Array.Empty<SessionInfo>();
165-
filtered = SessionVisibilityFilter.VisibleSessions(filtered, ShowCompletedSessions);
193+
var activeSessions = SessionVisibilityFilter.VisibleSessions(
194+
_allSessions ?? Array.Empty<SessionInfo>(),
195+
ShowCompletedSessions)
196+
.ToList();
197+
var activeTitles = SessionTitleFormatter.FormatUnique(activeSessions);
198+
IEnumerable<(SessionInfo Session, string Title)> filtered = activeSessions
199+
.Select((session, index) => (Session: session, Title: activeTitles[index]));
166200

167201
if (_activeChannel != "all")
168202
{
169-
filtered = filtered.Where(s =>
170-
string.Equals(s.Channel, _activeChannel, StringComparison.OrdinalIgnoreCase));
203+
filtered = filtered.Where(item =>
204+
string.Equals(item.Session.Channel, _activeChannel, StringComparison.OrdinalIgnoreCase));
171205
}
172206

173207
var viewModels = filtered
174-
.OrderByDescending(s => s.UpdatedAt ?? s.LastSeen)
175-
.Select(s => ToViewModel(s))
208+
.OrderByDescending(item => item.Session.UpdatedAt ?? item.Session.LastSeen)
209+
.Select(item => ToViewModel(item.Session, item.Title))
176210
.ToList();
177211

178212
if (viewModels.Count == 0)
@@ -232,7 +266,7 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)
232266
}
233267
}
234268

235-
private SessionViewModel ToViewModel(SessionInfo s)
269+
private SessionViewModel ToViewModel(SessionInfo s, string displayName)
236270
{
237271
var parts = new List<string>(3);
238272
if (!string.IsNullOrWhiteSpace(s.Provider)) parts.Add(s.Provider!);
@@ -259,7 +293,7 @@ private SessionViewModel ToViewModel(SessionInfo s)
259293
return new SessionViewModel
260294
{
261295
Key = s.Key,
262-
DisplayName = !string.IsNullOrWhiteSpace(s.DisplayName) ? s.DisplayName! : s.Key,
296+
DisplayName = displayName,
263297
AgeText = s.AgeText,
264298
DetailLine = parts.Count > 0 ? string.Join(" · ", parts) : "",
265299
StatusBrush = ResolveStatusBrush(s),
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using OpenClaw.Shared;
2+
3+
namespace OpenClawTray.Services;
4+
5+
/// <summary>
6+
/// Builds stable, human-readable titles for gateway sessions.
7+
/// </summary>
8+
internal static class SessionTitleFormatter
9+
{
10+
/// <summary>
11+
/// Formats a session title and qualifies non-canonical agent sessions with
12+
/// their agent and slot so identical gateway display names remain distinct.
13+
/// </summary>
14+
public static string Format(SessionInfo session)
15+
{
16+
ArgumentNullException.ThrowIfNull(session);
17+
18+
var baseName = !string.IsNullOrWhiteSpace(session.DisplayName)
19+
? session.DisplayName!
20+
: (session.IsMain ? "OpenClaw Windows Tray" : session.ShortKey);
21+
22+
// Keys follow agent:{agentId}:{sessionSlot}, for example
23+
// agent:main:main or agent:assistant:review.
24+
var parts = (session.Key ?? string.Empty).Split(':');
25+
if (parts.Length < 3 || !string.Equals(parts[0], "agent", StringComparison.Ordinal))
26+
return baseName;
27+
28+
var agentId = parts[1];
29+
var sessionSlot = parts[2];
30+
31+
if (agentId == "main" && sessionSlot == "main")
32+
return baseName;
33+
34+
var qualifier = agentId == sessionSlot
35+
? agentId
36+
: $"{agentId}/{sessionSlot}";
37+
38+
return $"{baseName} ({qualifier})";
39+
}
40+
41+
/// <summary>
42+
/// Formats a collection of sessions and adds stable numeric suffixes only
43+
/// when the normal key qualifier still leaves duplicate visible titles.
44+
/// The returned titles are aligned with the input collection.
45+
/// </summary>
46+
public static IReadOnlyList<string> FormatUnique(IReadOnlyList<SessionInfo> sessions)
47+
{
48+
ArgumentNullException.ThrowIfNull(sessions);
49+
50+
if (sessions.Count == 0)
51+
return Array.Empty<string>();
52+
53+
var baseTitles = new string[sessions.Count];
54+
for (var i = 0; i < sessions.Count; i++)
55+
baseTitles[i] = Format(sessions[i]);
56+
57+
// Reserve every natural title before adding counters so a generated
58+
// title such as "Research (2)" cannot collide with another row whose
59+
// actual display name is already "Research (2)".
60+
var reservedTitles = new HashSet<string>(baseTitles, StringComparer.OrdinalIgnoreCase);
61+
var assignedTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
62+
var results = new string[sessions.Count];
63+
64+
foreach (var group in Enumerable.Range(0, sessions.Count)
65+
.GroupBy(index => baseTitles[index], StringComparer.OrdinalIgnoreCase))
66+
{
67+
var orderedIndices = group
68+
.OrderByDescending(index => IsCanonicalMain(sessions[index]))
69+
.ThenBy(index => sessions[index].Key ?? string.Empty, StringComparer.Ordinal)
70+
.ThenBy(index => index)
71+
.ToArray();
72+
73+
var primaryTitle = baseTitles[orderedIndices[0]];
74+
results[orderedIndices[0]] = primaryTitle;
75+
assignedTitles.Add(primaryTitle);
76+
77+
var suffix = 2;
78+
for (var i = 1; i < orderedIndices.Length; i++)
79+
{
80+
string candidate;
81+
do
82+
{
83+
candidate = $"{primaryTitle} ({suffix})";
84+
suffix++;
85+
}
86+
while (reservedTitles.Contains(candidate) || !assignedTitles.Add(candidate));
87+
88+
results[orderedIndices[i]] = candidate;
89+
}
90+
}
91+
92+
return results;
93+
}
94+
95+
/// <summary>
96+
/// Formats one session in the context of its active peers.
97+
/// </summary>
98+
public static string Format(SessionInfo session, IReadOnlyList<SessionInfo> sessions)
99+
{
100+
ArgumentNullException.ThrowIfNull(session);
101+
ArgumentNullException.ThrowIfNull(sessions);
102+
103+
var index = -1;
104+
for (var i = 0; i < sessions.Count; i++)
105+
{
106+
if (ReferenceEquals(session, sessions[i]))
107+
{
108+
index = i;
109+
break;
110+
}
111+
}
112+
113+
if (index < 0)
114+
{
115+
for (var i = 0; i < sessions.Count; i++)
116+
{
117+
if (string.Equals(session.Key, sessions[i].Key, StringComparison.Ordinal))
118+
{
119+
index = i;
120+
break;
121+
}
122+
}
123+
}
124+
125+
return index >= 0 ? FormatUnique(sessions)[index] : Format(session);
126+
}
127+
128+
private static bool IsCanonicalMain(SessionInfo session)
129+
{
130+
if (session.IsMain)
131+
return true;
132+
133+
var parts = (session.Key ?? string.Empty).Split(':');
134+
return parts.Length >= 3
135+
&& string.Equals(parts[0], "agent", StringComparison.Ordinal)
136+
&& string.Equals(parts[1], "main", StringComparison.Ordinal)
137+
&& string.Equals(parts[2], "main", StringComparison.Ordinal);
138+
}
139+
}

tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\DiagnosticsBundleBuilder.cs" Link="Services\DiagnosticsBundleBuilder.cs" />
5454
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\SettingsManager.cs" Link="Services\SettingsManager.cs" />
5555
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\SessionVisibilityFilter.cs" Link="Services\SessionVisibilityFilter.cs" />
56+
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\SessionTitleFormatter.cs" Link="Services\SessionTitleFormatter.cs" />
5657
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\OnboardingChatBootstrapper.cs" Link="Services\OnboardingChatBootstrapper.cs" />
5758
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\StartupSetupState.cs" Link="Services\StartupSetupState.cs" />
5859
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\SetupExistingGatewayClassifier.cs" Link="Services\SetupExistingGatewayClassifier.cs" />

0 commit comments

Comments
 (0)