mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 03:45:19 +08:00
Gateway (additive, no protocol version bump): SessionEntry gains lastReadAt/markedUnreadAt/lastActivityAt; session rows expose a derived unread flag (explicit mark, or last read before latest activity; never-read sessions stay read so upgrades do not light up). lastActivityAt is stamped in the canonical post-run store update - user, channel, and cron runs count as activity; heartbeat, internal-event, and preserved-state runs do not. sessions.patch gains unread; sessions.create gains fork (transcript fork from parentSessionKey under the parent lifecycle lock, refusing active, concurrently-changed, and oversized parents, cross-agent aware). Web sidebar: Pinned/custom-group/Ungrouped sections, unread dots, kebab and right-click context menu (pin, mark unread/read, rename, fork, move to group, archive, delete guarded for agent main sessions and active runs), mark-read on view with loop-safe re-acknowledgement and failure retry; sessions page gets unread + fork actions and shared custom-group helpers. iOS Command Center: grouped sections, unread/pin indicators, Show Archived gated on per-entry state, full context menu with rename/new-group alerts and delete confirmation, current-session preview guarantee, read-episode re-acknowledgement; new patch/delete/fork transport calls; Swift protocol models regenerated. Android SessionsScreen: grouped headers, unread/pin indicators, Archived filter gated on per-entry state, long-press menu with the full control set, agent-scoped forks, explicit label/category clears from session events, main-session fallback when archiving/deleting the open chat, read-episode re-acknowledgement with failure retry. Closes #100739
112 lines
4.1 KiB
Swift
112 lines
4.1 KiB
Swift
import Foundation
|
|
import OpenClawChatUI
|
|
|
|
struct CommandSessionSection: Identifiable {
|
|
enum ID: Hashable {
|
|
case pinned
|
|
case category(String)
|
|
case ungrouped
|
|
}
|
|
|
|
let id: ID
|
|
let title: String
|
|
let entries: [OpenClawChatSessionEntry]
|
|
let showsHeader: Bool
|
|
}
|
|
|
|
enum CommandSessionGrouping {
|
|
static func sections(from entries: [OpenClawChatSessionEntry]) -> [CommandSessionSection] {
|
|
let pinned = self.sortedByActivity(entries.filter { $0.pinned == true })
|
|
let unpinned = entries.filter { $0.pinned != true }
|
|
let categoryNames = Set(unpinned.compactMap { self.normalizedCategory($0.category) })
|
|
.sorted(by: self.categoryComesBefore)
|
|
var sections: [CommandSessionSection] = []
|
|
|
|
if !pinned.isEmpty {
|
|
sections.append(CommandSessionSection(
|
|
id: .pinned,
|
|
title: "Pinned",
|
|
entries: pinned,
|
|
showsHeader: true))
|
|
}
|
|
|
|
for category in categoryNames {
|
|
let categoryEntries = unpinned.filter { self.normalizedCategory($0.category) == category }
|
|
sections.append(CommandSessionSection(
|
|
id: .category(category),
|
|
title: category,
|
|
entries: self.sortedByActivity(categoryEntries),
|
|
showsHeader: true))
|
|
}
|
|
|
|
let ungrouped = self.sortedByActivity(unpinned.filter { self.normalizedCategory($0.category) == nil })
|
|
if !ungrouped.isEmpty {
|
|
sections.append(CommandSessionSection(
|
|
id: .ungrouped,
|
|
title: "Ungrouped",
|
|
entries: ungrouped,
|
|
showsHeader: !categoryNames.isEmpty))
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
static func previewOrder(_ entries: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
|
|
entries.sorted { lhs, rhs in
|
|
if (lhs.pinned == true) != (rhs.pinned == true) {
|
|
return lhs.pinned == true
|
|
}
|
|
let left = self.activityTimestamp(lhs)
|
|
let right = self.activityTimestamp(rhs)
|
|
return left == right ? lhs.key < rhs.key : left > right
|
|
}
|
|
}
|
|
|
|
/// Capped preview that always keeps the open chat visible: when the current
|
|
/// session falls outside the cap it leads the list (pre-existing Command
|
|
/// Center contract), otherwise natural pinned/activity order wins.
|
|
static func previewSelection(
|
|
_ entries: [OpenClawChatSessionEntry],
|
|
currentKey: String,
|
|
limit: Int = 3) -> [OpenClawChatSessionEntry]
|
|
{
|
|
let ordered = self.previewOrder(entries)
|
|
let capped = Array(ordered.prefix(limit))
|
|
guard !currentKey.isEmpty,
|
|
!capped.contains(where: { $0.key == currentKey }),
|
|
let current = ordered.first(where: { $0.key == currentKey })
|
|
else { return capped }
|
|
return [current] + capped.prefix(max(0, limit - 1))
|
|
}
|
|
|
|
static func categories(from entries: [OpenClawChatSessionEntry]) -> [String] {
|
|
Set(entries.compactMap { self.normalizedCategory($0.category) })
|
|
.sorted(by: self.categoryComesBefore)
|
|
}
|
|
|
|
static func activityTimestamp(_ entry: OpenClawChatSessionEntry) -> Double {
|
|
entry.lastActivityAt ?? entry.updatedAt ?? 0
|
|
}
|
|
|
|
private static func sortedByActivity(
|
|
_ entries: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry]
|
|
{
|
|
entries.sorted { lhs, rhs in
|
|
let left = self.activityTimestamp(lhs)
|
|
let right = self.activityTimestamp(rhs)
|
|
return left == right ? lhs.key < rhs.key : left > right
|
|
}
|
|
}
|
|
|
|
private static func normalizedCategory(_ category: String?) -> String? {
|
|
guard let category else { return nil }
|
|
let trimmed = category.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func categoryComesBefore(_ lhs: String, _ rhs: String) -> Bool {
|
|
let order = lhs.localizedCaseInsensitiveCompare(rhs)
|
|
return order == .orderedSame ? lhs < rhs : order == .orderedAscending
|
|
}
|
|
}
|