diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift index a812058f9e1e..c511bae3da89 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift @@ -496,14 +496,11 @@ public enum ChatSessionSidebarModel { // active row selectable instead of showing an empty selection. entries.append(self.placeholder(key: currentSessionKey)) } - entries.sort { (($0.updatedAt ?? $0.lastActivityAt) ?? 0) > (($1.updatedAt ?? $1.lastActivityAt) ?? 0) } - - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !needle.isEmpty else { return entries } - return entries.filter { entry in - self.displayName(for: entry).lowercased().contains(needle) || - entry.key.lowercased().contains(needle) - } + // Gateway, cached lists, iOS, and macOS must share the same pin + // chronology, stable key ties, and searchable session fields. + return OpenClawChatSessionListOrganizer.filter( + OpenClawChatSessionListOrganizer.organize(entries), + search: query) } private static func placeholder(key: String) -> OpenClawChatSessionEntry { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift index 2861d03a88cc..2da94a64ef54 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift @@ -8,8 +8,12 @@ struct ChatSessionSidebarModelTests { private func entry( key: String, displayName: String? = nil, + label: String? = nil, + subject: String? = nil, + sessionId: String? = nil, updatedAt: Double? = nil, pinned: Bool? = nil, + pinnedAt: Double? = nil, archived: Bool? = nil, unread: Bool? = nil, lastReadAt: Double? = nil, @@ -31,11 +35,11 @@ struct ChatSessionSidebarModelTests { kind: nil, displayName: displayName, surface: nil, - subject: nil, + subject: subject, room: nil, space: nil, updatedAt: updatedAt, - sessionId: nil, + sessionId: sessionId, systemSent: nil, abortedLastRun: nil, thinkingLevel: nil, @@ -46,8 +50,10 @@ struct ChatSessionSidebarModelTests { modelProvider: nil, model: nil, contextTokens: nil, + label: label, category: category, pinned: pinned, + pinnedAt: pinnedAt, archived: archived, unread: unread, agentStatus: agentStatus, @@ -80,6 +86,49 @@ struct ChatSessionSidebarModelTests { #expect(sections[1].title == "Recent") } + @Test func `pinned sidebar sessions follow gateway pin chronology`() { + let sections = ChatSessionSidebarModel.sections( + sessions: [ + self.entry(key: "pinned-old", updatedAt: 300, pinned: true, pinnedAt: 100), + self.entry(key: "pinned-new", updatedAt: 100, pinned: true, pinnedAt: 300), + self.entry(key: "recent", updatedAt: 400), + ], + currentSessionKey: "recent", + query: "") + + #expect(sections.map(\.id) == ["pinned", "recent"]) + #expect(sections[0].nodes.map(\.session.key) == ["pinned-new", "pinned-old"]) + #expect(sections[1].nodes.map(\.session.key) == ["recent"]) + } + + @Test func `sidebar sessions preserve deterministic gateway key ties`() { + let sections = ChatSessionSidebarModel.sections( + sessions: [ + self.entry(key: "z-tie", updatedAt: 100), + self.entry(key: "a-tie", updatedAt: 100), + self.entry(key: "m-tie", updatedAt: 100), + ], + currentSessionKey: "a-tie", + query: "") + + #expect(sections.flatMap(\.nodes).map(\.session.key) == ["a-tie", "m-tie", "z-tie"]) + } + + @Test func `gateway groups preserve canonical session order`() { + let sections = ChatSessionSidebarModel.sections( + sessions: [ + self.entry(key: "project-z", updatedAt: 100, category: "Projects"), + self.entry(key: "project-a", updatedAt: 100, category: "Projects"), + self.entry(key: "recent", updatedAt: 50), + ], + currentSessionKey: "recent", + groups: [OpenClawChatSessionGroup(name: "Projects", position: 0)], + query: "") + + #expect(sections.map(\.id) == ["group:Projects", "recent"]) + #expect(sections[0].nodes.map(\.session.key) == ["project-a", "project-z"]) + } + @Test func `single unpinned section carries no title`() { let sections = ChatSessionSidebarModel.sections( sessions: [self.entry(key: "a", updatedAt: 100)], @@ -258,6 +307,52 @@ struct ChatSessionSidebarModelTests { #expect(sections.flatMap(\.nodes).map(\.session.key) == ["agent:main:research"]) } + @Test(arguments: ["holiday", "KYOTO", "session-123", " HoLiDaY "]) + func `sidebar search matches every canonical gateway session field`(_ query: String) { + let matching = self.entry( + key: "agent:main:roadmap", + displayName: "Planning", + label: "Summer holiday", + subject: "Kyoto itinerary", + sessionId: "session-123", + updatedAt: 200) + let other = self.entry( + key: "agent:main:other", + displayName: "Unrelated", + updatedAt: 100) + let sections = ChatSessionSidebarModel.sections( + sessions: [other, matching], + currentSessionKey: other.key, + query: query) + + #expect(sections.flatMap(\.nodes).map(\.session.key) == [matching.key]) + } + + @Test(arguments: ["holiday", "KYOTO", "session-123"]) + func `canonical search never reveals hidden or archived sidebar sessions`(_ query: String) { + let archived = self.entry( + key: "agent:main:archived", + label: "Summer holiday", + subject: "Kyoto itinerary", + sessionId: "session-123", + updatedAt: 300, + archived: true) + let onboarding = self.entry( + key: "agent:main:onboarding", + label: "Summer holiday", + subject: "Kyoto itinerary", + sessionId: "session-123", + updatedAt: 200) + let active = self.entry(key: "agent:main:active", updatedAt: 100) + + let sections = ChatSessionSidebarModel.sections( + sessions: [archived, onboarding, active], + currentSessionKey: active.key, + query: query) + + #expect(sections.isEmpty) + } + @Test func `session keys render as human names`() { #expect(ChatSessionSidebarModel.displayName(forKey: "agent:main:main") == "main") #expect(ChatSessionSidebarModel.displayName(forKey: "agent:ops:standup") == "standup (ops)")