Files
openclaw-openclaw/apps/ios/Sources/Design/CommandSessionGrouping.swift
Peter Steinberger 88f1ec38d4 feat(sessions): grouping, unread state, and full session controls on web, iOS, and Android (#100814)
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
2026-07-06 14:30:55 +01:00

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
}
}