Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
Peter Steinberger 91d4e8ed8e feat(apple): session search, archived browsing, and sheet management actions (#101053)
The shared chat session sheet (iOS chat + macOS webchat) gains server-backed
search (sessions.list search param, 250ms debounce, cancellation-safe local
fallback when offline), an Active/Archived scope, swipe/context actions for
pin/rename/archive with optimistic updates and rollback, pin indicators, and
restore-on-open for archived rows. Archiving the open session switches back
to the main session so the composer never points at a send-rejecting session.

OpenClawChatTransport consolidates the two list requirements into canonical
listSessions(limit:search:archived:) with forwarding sugars (unreleased
internal API; all in-repo conformers updated: iOS gateway transport, demo and
fixture transports, previews, macOS webchat, test fakes). macOS webchat also
implements patchSession, giving the sheet's controls a live transport there.

OpenClawChatSessionEntry becomes var-based and gains pinnedAt/archivedAt;
the two full-init rebuild blocks in ChatViewModel collapse to copy-mutation
(the pattern that silently dropped newly added fields), preserving main's
model-identity/thinking-metadata semantics. A shared session list organizer
mirrors gateway ordering (pinnedAt desc, updatedAt desc, key) for cached and
offline lists, and pinned sessions survive the 24h recency cutoff in the
session picker.

Refs #100712
2026-07-07 00:28:38 +01:00

260 lines
8.1 KiB
Swift

import Foundation
public struct OpenClawChatThinkingLevelOption: Codable, Identifiable, Sendable, Hashable {
public let id: String
public let label: String
public init(id: String, label: String) {
self.id = id
self.label = label
}
}
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
public var id: String {
self.selectionID
}
public let modelID: String
public let name: String
public let provider: String
public let contextWindow: Int?
public let reasoning: Bool?
public init(
modelID: String,
name: String,
provider: String,
contextWindow: Int?,
reasoning: Bool? = nil)
{
self.modelID = modelID
self.name = name
self.provider = provider
self.contextWindow = contextWindow
self.reasoning = reasoning
}
/// Provider-qualified model ref used for picker identity and selection tags.
public var selectionID: String {
let trimmedProvider = self.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedProvider.isEmpty else { return self.modelID }
let providerPrefix = "\(trimmedProvider)/"
if self.modelID.hasPrefix(providerPrefix) {
return self.modelID
}
return "\(trimmedProvider)/\(self.modelID)"
}
public var displayLabel: String {
self.selectionID
}
}
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
public let modelProvider: String?
public let model: String?
public let contextTokens: Int?
public let thinkingLevels: [OpenClawChatThinkingLevelOption]?
public let thinkingOptions: [String]?
public let thinkingDefault: String?
public let mainSessionKey: String?
public init(
modelProvider: String? = nil,
model: String?,
contextTokens: Int?,
thinkingLevels: [OpenClawChatThinkingLevelOption]? = nil,
thinkingOptions: [String]? = nil,
thinkingDefault: String? = nil,
mainSessionKey: String? = nil)
{
self.modelProvider = modelProvider
self.model = model
self.contextTokens = contextTokens
self.thinkingLevels = thinkingLevels
self.thinkingOptions = thinkingOptions
self.thinkingDefault = thinkingDefault
self.mainSessionKey = mainSessionKey
}
}
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String {
self.key
}
public var key: String
public var kind: String?
public var displayName: String?
public var label: String?
public var category: String?
public var pinned: Bool?
public var pinnedAt: Double?
public var archived: Bool?
public var archivedAt: Double?
public var unread: Bool?
public var surface: String?
public var subject: String?
public var room: String?
public var space: String?
public var updatedAt: Double?
public var lastReadAt: Double?
public var lastActivityAt: Double?
public var sessionId: String?
public var systemSent: Bool?
public var abortedLastRun: Bool?
public var thinkingLevel: String?
public var verboseLevel: String?
public var inputTokens: Int?
public var outputTokens: Int?
public var totalTokens: Int?
public var totalTokensFresh: Bool?
public var modelProvider: String?
public var model: String?
public var contextTokens: Int?
public var thinkingLevels: [OpenClawChatThinkingLevelOption]?
public var thinkingOptions: [String]?
public var thinkingDefault: String?
public init(
key: String,
kind: String?,
displayName: String?,
surface: String?,
subject: String?,
room: String?,
space: String?,
updatedAt: Double?,
sessionId: String?,
systemSent: Bool?,
abortedLastRun: Bool?,
thinkingLevel: String?,
verboseLevel: String?,
inputTokens: Int?,
outputTokens: Int?,
totalTokens: Int?,
totalTokensFresh: Bool? = nil,
modelProvider: String?,
model: String?,
contextTokens: Int?,
thinkingLevels: [OpenClawChatThinkingLevelOption]? = nil,
thinkingOptions: [String]? = nil,
thinkingDefault: String? = nil,
label: String? = nil,
category: String? = nil,
pinned: Bool? = nil,
pinnedAt: Double? = nil,
archived: Bool? = nil,
archivedAt: Double? = nil,
unread: Bool? = nil,
lastReadAt: Double? = nil,
lastActivityAt: Double? = nil)
{
self.key = key
self.kind = kind
self.displayName = displayName
self.label = label
self.category = category
self.pinned = pinned
self.pinnedAt = pinnedAt
self.archived = archived
self.archivedAt = archivedAt
self.unread = unread
self.surface = surface
self.subject = subject
self.room = room
self.space = space
self.updatedAt = updatedAt
self.lastReadAt = lastReadAt
self.lastActivityAt = lastActivityAt
self.sessionId = sessionId
self.systemSent = systemSent
self.abortedLastRun = abortedLastRun
self.thinkingLevel = thinkingLevel
self.verboseLevel = verboseLevel
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.totalTokens = totalTokens
self.totalTokensFresh = totalTokensFresh
self.modelProvider = modelProvider
self.model = model
self.contextTokens = contextTokens
self.thinkingLevels = thinkingLevels
self.thinkingOptions = thinkingOptions
self.thinkingDefault = thinkingDefault
}
public var isPinned: Bool {
self.pinned == true
}
public var isArchived: Bool {
self.archived == true
}
}
/// Client-side session list policy shared by every session list surface.
/// Ordering mirrors the gateway (`pinnedAt` desc, `updatedAt` desc, key) so
/// cached/offline lists render in the same order as server responses.
public enum OpenClawChatSessionListOrganizer {
public static func organize(_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
sessions.sorted { lhs, rhs in
let lhsPinnedAt = lhs.pinnedAt ?? (lhs.isPinned ? .greatestFiniteMagnitude : 0)
let rhsPinnedAt = rhs.pinnedAt ?? (rhs.isPinned ? .greatestFiniteMagnitude : 0)
if lhsPinnedAt != rhsPinnedAt {
return lhsPinnedAt > rhsPinnedAt
}
let lhsUpdatedAt = lhs.updatedAt ?? 0
let rhsUpdatedAt = rhs.updatedAt ?? 0
if lhsUpdatedAt != rhsUpdatedAt {
return lhsUpdatedAt > rhsUpdatedAt
}
return lhs.key < rhs.key
}
}
/// Local fallback for the server-side `sessions.list` search when the
/// gateway is unreachable and only cached entries are available.
public static func filter(
_ sessions: [OpenClawChatSessionEntry],
search: String) -> [OpenClawChatSessionEntry]
{
let query = search.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !query.isEmpty else { return sessions }
return sessions.filter { session in
for field in [session.displayName, session.label, session.subject, session.sessionId, session.key] {
if let field, field.lowercased().contains(query) {
return true
}
}
return false
}
}
}
public struct OpenClawChatSessionsListResponse: Codable, Sendable {
public let ts: Double?
public let path: String?
public let count: Int?
public let defaults: OpenClawChatSessionsDefaults?
public let sessions: [OpenClawChatSessionEntry]
public init(
ts: Double?,
path: String?,
count: Int?,
defaults: OpenClawChatSessionsDefaults?,
sessions: [OpenClawChatSessionEntry])
{
self.ts = ts
self.path = path
self.count = count
self.defaults = defaults
self.sessions = sessions
}
}