Files
openclaw/apps/ios/Sources/Design/CommandCenterTab.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

999 lines
37 KiB
Swift

import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
static let recentSessionsFetchLimit = 200
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
var ownsNavigationStack: Bool = true
var usesNativeNavigationChrome: Bool = false
var headerTitle: String = "OpenClaw"
var headerLeadingAction: OpenClawSidebarHeaderAction?
var showsHeaderMark: Bool = true
var openChat: () -> Void
var openSettings: () -> Void
var openSessions: (() -> Void)?
enum WorkRoute {
case chat(String?)
case settings
}
struct WorkItem: Identifiable {
let id: String
let icon: String
let title: String
let detail: String
let state: String
let trailing: String
let color: Color
let progress: Double?
let route: WorkRoute
let isUnread: Bool
let isPinned: Bool
}
var body: some View {
Group {
if self.ownsNavigationStack {
NavigationStack {
self.content
}
} else {
self.content
}
}
.task(id: self.recentSessionsRefreshID) {
await self.refreshRecentSessionsIfNeeded()
}
}
private var content: some View {
GeometryReader { geometry in
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 14) {
if !self.usesNativeNavigationChrome {
self.header
}
self.gatewayCard
if Self.usesSplitSectionsLayout(
horizontalSizeClass: self.horizontalSizeClass,
containerWidth: geometry.size.width)
{
HStack(alignment: .top, spacing: 12) {
self.defaultChatSessionSection
.frame(maxWidth: .infinity, alignment: .topLeading)
self.recentSessions
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
} else {
self.defaultChatSessionSection
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.recentSessions
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
.padding(.top, 18)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
}
.navigationTitle(self.headerTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar(self.usesNativeNavigationChrome ? .visible : .hidden, for: .navigationBar)
.toolbar {
if self.usesNativeNavigationChrome {
ToolbarItem(placement: .topBarTrailing) {
Button(action: self.openSettings) {
Image(systemName: "antenna.radiowaves.left.and.right")
}
.accessibilityLabel("Gateway settings")
}
}
}
}
static func usesSplitSectionsLayout(
horizontalSizeClass: UserInterfaceSizeClass?,
containerWidth: CGFloat) -> Bool
{
guard horizontalSizeClass == .regular else { return false }
return containerWidth >= 1000
}
static func shouldShowHeaderMark(
hasLeadingAction: Bool,
showsHeaderMark: Bool) -> Bool
{
!hasLeadingAction && showsHeaderMark
}
private var header: some View {
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: self.gatewaySubtitle,
titleFont: OpenClawType.title3SemiBold,
subtitleFont: OpenClawType.caption,
subtitleLineLimit: 1)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} else if Self.shouldShowHeaderMark(
hasLeadingAction: headerLeadingAction != nil,
showsHeaderMark: self.showsHeaderMark)
{
OpenClawProMark(size: 28, shadowRadius: 5)
}
} accessory: {
Button(action: self.openSettings) {
Image(systemName: "gearshape.fill")
.font(OpenClawType.subheadSemiBold)
.frame(width: OpenClawProMetric.compactControlSize, height: OpenClawProMetric.compactControlSize)
}
.openClawGlassButton()
.accessibilityLabel("Gateway settings")
.accessibilityHint("Opens gateway settings")
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var commandAmbientOverlay: some View {
Group {
if self.colorScheme == .light {
LinearGradient(
colors: [
Color.white.opacity(0.05),
Color.clear,
],
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
}
private var gatewayCard: some View {
CommandPanel(isProminent: true, padding: 12) {
VStack(alignment: .leading, spacing: 10) {
self.cardHeader(title: "Gateway")
HStack(spacing: 0) {
self.gatewayFact(
icon: "network",
title: "Connection",
value: self.gatewayConnectionText,
color: self.gatewayStatusColor)
Divider().frame(height: 38)
self.gatewayFact(
icon: "server.rack",
title: "Address",
value: self.gatewayAddressText,
color: OpenClawBrand.accentForeground)
Divider().frame(height: 38)
self.gatewayFact(
icon: "person.2.fill",
title: "Agents",
value: self.gatewayAgentCountText,
color: OpenClawBrand.accentHotForeground)
}
.padding(.vertical, 7)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func gatewayFact(icon: String, title: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: icon)
.font(OpenClawType.caption2Bold)
.foregroundStyle(color)
Text(title)
.font(OpenClawType.caption2Medium)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(value)
.font(OpenClawType.captionSemiBold)
.foregroundStyle(title == "Connection" ? color : .primary)
.lineLimit(1)
.minimumScaleFactor(0.72)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
}
private var defaultChatSessionSection: some View {
CommandPanel(padding: 12) {
VStack(spacing: 10) {
self.cardHeader(title: "Agent session")
Button {
self.openDefaultChatSession()
} label: {
CommandSessionRow(item: self.defaultChatWorkItem)
}
.buttonStyle(.plain)
}
}
}
private var recentSessions: some View {
CommandPanel(padding: 12) {
VStack(spacing: 10) {
self.cardHeader(title: "Recent sessions")
if self.recentSessionPreviewSessions.isEmpty {
CommandEmptyStateRow(
icon: self.gatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
title: self.gatewayConnected ? "No recent sessions" : "Gateway offline",
detail: self
.gatewayConnected ? "Start a chat and it will appear here." : "Connect to the gateway.")
} else {
VStack(spacing: 8) {
ForEach(self.recentSessionPreviewSessions) { session in
let item = Self.sessionWorkItem(
for: session,
currentSessionKey: self.appModel.chatSessionKey)
Button {
self.open(session)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
.commandSessionActions(
session: session,
categories: self.sessionCategories,
isEnabled: self.sessionControlsAvailable,
onRename: { self.patchSession(session, label: .some($0)) },
onMoveToGroup: { self.patchSession(session, category: .some($0)) },
onTogglePinned: { self.patchSession(session, pinned: session.pinned != true) },
onToggleUnread: { self.patchSession(session, unread: session.unread != true) },
onFork: { self.forkSession(session) },
onToggleArchived: { self.archiveSession(session) },
onDelete: { self.deleteSession(session) })
}
if self.hasMoreRecentSessions {
if let openSessions {
Button(action: openSessions) {
CommandViewMoreRow()
}
.buttonStyle(.plain)
} else {
NavigationLink {
CommandSessionsScreen(
usesNativeNavigationChrome: self.usesNativeNavigationChrome,
openChat: self.openChat)
} label: {
CommandViewMoreRow()
}
.buttonStyle(.plain)
}
}
}
}
}
}
}
private func cardHeader(title: String) -> some View {
HStack(spacing: 8) {
Text(title)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
}
}
private var gatewayConnected: Bool {
self.gatewayDisplayState == .connected
}
private var gatewayDisplayState: GatewayDisplayState {
GatewayStatusBuilder.build(appModel: self.appModel)
}
private var gatewayConnectionText: String {
switch self.gatewayDisplayState {
case .connected:
"Online"
case .connecting:
"Connecting"
case .error:
"Attention"
case .disconnected:
"Offline"
}
}
private var gatewayStatusColor: Color {
switch self.gatewayDisplayState {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private var gatewayAddressText: String {
self.normalized(self.appModel.gatewayRemoteAddress)
?? self.normalized(self.appModel.gatewayServerName)
?? "Unknown"
}
private var gatewayAgentCountText: String {
guard self.gatewayConnected else { return "" }
return "\(self.appModel.gatewayAgents.count)"
}
private var defaultChatWorkItem: WorkItem {
let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey
return WorkItem(
id: "default-chat",
icon: isOpen ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: self.appModel.activeAgentName,
detail: self.defaultChatActivityText,
state: isOpen ? "open" : "default",
trailing: "chat",
color: isOpen ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(nil),
isUnread: self.defaultChatSessionEntry?.unread == true,
isPinned: self.defaultChatSessionEntry?.pinned == true)
}
private var defaultChatActivityText: String {
let activityAt = self.defaultChatSessionEntry?.lastActivityAt ?? self.defaultChatSessionEntry?.updatedAt
guard let activityAt, activityAt > 0 else {
return "No recent activity"
}
return Self.relativeTimeText(forMilliseconds: activityAt)
}
private var recentSessionPreviewSessions: [OpenClawChatSessionEntry] {
CommandSessionGrouping.previewSelection(
self.recentChatSessions,
currentKey: self.appModel.chatSessionKey)
}
private var hasMoreRecentSessions: Bool {
self.recentChatSessions.count > self.recentSessionPreviewSessions.count
}
private var sessionCategories: [String] {
CommandSessionGrouping.categories(from: self.recentChatSessions)
}
private var sessionControlsAvailable: Bool {
!self.appModel.isLocalChatFixtureEnabled && self.appModel.isOperatorGatewayConnected
}
private var recentSessionsRefreshID: String {
[
self.sessionListMode,
self.appModel.chatSessionKey,
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var sessionListAvailable: Bool {
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionListMode: String {
self.appModel.chatViewModelIdentityID
}
private func open(_ route: WorkRoute, unread: Bool = false) {
switch route {
case let .chat(sessionKey):
self.appModel.openChat(sessionKey: sessionKey, unread: unread)
self.openChat()
case .settings:
self.openSettings()
}
}
private func open(_ session: OpenClawChatSessionEntry) {
self.open(.chat(session.key), unread: session.unread == true)
}
private func openDefaultChatSession() {
self.open(.chat(nil), unread: self.defaultChatSessionEntry?.unread == true)
}
private func patchSession(
_ session: OpenClawChatSessionEntry,
label: String?? = nil,
category: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
unread: Bool? = nil)
{
self.performSessionMutation { transport in
try await transport.patchSession(
key: session.key,
label: label,
category: category,
pinned: pinned,
archived: archived,
unread: unread)
}
}
private func deleteSession(_ session: OpenClawChatSessionEntry) {
self.performSessionMutation(resetActiveSessionKey: session.key) { transport in
try await transport.deleteSession(key: session.key)
}
}
private func archiveSession(_ session: OpenClawChatSessionEntry) {
self.performSessionMutation(resetActiveSessionKey: session.key) { transport in
try await transport.patchSession(
key: session.key,
label: nil,
category: nil,
pinned: nil,
archived: true,
unread: nil)
}
}
private func forkSession(_ session: OpenClawChatSessionEntry) {
Task {
do {
let key = try await self.appModel.makeChatTransport().forkSession(parentKey: session.key)
await self.refreshRecentSessionsIfNeeded()
self.open(.chat(key))
} catch {}
}
}
private func performSessionMutation(
resetActiveSessionKey: String? = nil,
_ operation: @escaping (any OpenClawChatTransport) async throws -> Void)
{
Task {
do {
try await operation(self.appModel.makeChatTransport())
if resetActiveSessionKey == self.appModel.chatSessionKey {
self.appModel.focusChatSession(nil)
}
await self.refreshRecentSessionsIfNeeded()
} catch {}
}
}
private func refreshRecentSessionsIfNeeded() async {
guard self.scenePhase == .active else { return }
guard self.sessionListAvailable else {
await self.applyCachedSessions()
return
}
do {
let transport = self.appModel.makeChatTransport()
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
self.applySessions(response.sessions)
self.appModel.reconcileChatSessionReadState(response.sessions)
await self.appModel.storeCachedChatSessions(response.sessions)
} catch {
await self.applyCachedSessions()
}
}
private func applyCachedSessions() async {
let sessions = await self.appModel.loadCachedChatSessions()
self.applySessions(sessions)
}
private func applySessions(_ sessions: [OpenClawChatSessionEntry]) {
self.defaultChatSessionEntry = sessions.first {
$0.key == self.appModel.defaultChatSessionKey
}
self.recentChatSessions = Self.sessionChoices(
sessions,
defaultSessionKey: self.appModel.defaultChatSessionKey)
}
private static func sessionChoices(
_ sessions: [OpenClawChatSessionEntry],
defaultSessionKey: String) -> [OpenClawChatSessionEntry]
{
sessions.filter {
Self.isRecentChatSession($0.key, defaultSessionKey: defaultSessionKey)
}
}
static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
let isCurrent = session.key == currentSessionKey
return WorkItem(
id: "chat-session-\(session.key)",
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: Self.sessionTitle(session),
detail: Self.sessionDetail(session),
state: isCurrent ? "open" : "recent",
trailing: "chat",
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(session.key),
isUnread: session.unread == true,
isPinned: session.pinned == true)
}
fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
let label = session.label?.trimmingCharacters(in: .whitespacesAndNewlines)
if let label, !label.isEmpty {
return label
}
if let title = redactedSessionTitle(for: session.key) {
return title
}
let displayName = session.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
if let displayName, !displayName.isEmpty {
return Self.redactedSessionTitle(for: displayName) ?? displayName
}
let subject = session.subject?.trimmingCharacters(in: .whitespacesAndNewlines)
if let subject, !subject.isEmpty {
return Self.redactedSessionTitle(for: subject) ?? subject
}
return session.key
}
fileprivate static func redactedSessionTitle(for key: String) -> String? {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercased = trimmed.lowercased()
guard !trimmed.isEmpty else { return nil }
if lowercased.contains(":ios-") {
return "iOS chat"
}
if lowercased.hasPrefix("telegram:") {
return "Telegram chat"
}
if lowercased.hasPrefix("user:+") {
return "Direct chat"
}
if lowercased.hasPrefix("cron:") {
return Self.humanizedSessionKey(String(trimmed.dropFirst("cron:".count)))
}
return nil
}
fileprivate static func humanizedSessionKey(_ key: String) -> String? {
let words = key
.replacingOccurrences(of: "_", with: "-")
.split(separator: "-")
.map(String.init)
.filter { !$0.isEmpty }
guard !words.isEmpty else { return nil }
return words
.map { word in
switch word.lowercased() {
case "ai", "api", "ios", "qmd", "url":
word.uppercased()
default:
word.prefix(1).uppercased() + String(word.dropFirst())
}
}
.joined(separator: " ")
}
fileprivate static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
let activityAt = session.lastActivityAt ?? session.updatedAt
if let activityAt, activityAt > 0 {
return self.relativeTimeText(forMilliseconds: activityAt)
}
return session.key
}
fileprivate static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
let date = Date(timeIntervalSince1970: milliseconds / 1000)
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: .now)
}
fileprivate nonisolated static func isHiddenInternalSession(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
}
nonisolated static func isRecentChatSession(_ key: String, defaultSessionKey: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if trimmed == defaultSessionKey { return false }
let normalized = trimmed.lowercased()
let defaultBase = self.sessionBaseKey(defaultSessionKey)
if !normalized.contains(":"),
self.isDirectSessionBase(normalized, defaultBase: defaultBase)
{
return false
}
if self.isHiddenInternalSession(trimmed) { return false }
return !self.isAgentDeviceSession(trimmed, defaultSessionKey: defaultSessionKey)
}
private nonisolated static func isAgentDeviceSession(_ key: String, defaultSessionKey: String) -> Bool {
let parts = key
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return false }
guard parts.count == 3 || parts[3].lowercased() == "thread" else { return false }
let base = String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let defaultKey = self.sessionBaseKey(defaultSessionKey)
return self.isDirectSessionBase(base, defaultBase: defaultKey)
}
private nonisolated static func isDirectSessionBase(_ base: String, defaultBase: String) -> Bool {
base == defaultBase || base == "main" || base == "global" || base.hasPrefix("node-")
}
private nonisolated static func sessionBaseKey(_ key: String) -> String {
let parts = key
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count >= 3, parts[0].lowercased() == "agent" else {
return key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
return String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private var gatewaySubtitle: String {
if let server = normalized(appModel.gatewayServerName) {
return "\(self.appModel.activeAgentName) on \(server)"
}
if let address = normalized(appModel.gatewayRemoteAddress) {
return "\(self.appModel.activeAgentName) via \(address)"
}
return self.appModel.gatewayDisplayStatusText
}
private func normalized(_ value: String?) -> String? {
Self.normalized(value)
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
struct CommandSessionsScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
@State private var showArchived = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let usesNativeNavigationChrome: Bool
let openChat: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
usesNativeNavigationChrome: Bool = false,
openChat: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.usesNativeNavigationChrome = usesNativeNavigationChrome
self.openChat = openChat
}
var body: some View {
ZStack {
CommandControlBackground()
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if !self.usesNativeNavigationChrome {
self.header
}
self.sessionsPanel
}
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.inline)
.toolbar(self.usesNativeNavigationChrome ? .visible : .hidden, for: .navigationBar)
.task(id: self.refreshID) {
await self.refreshSessions()
}
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(OpenClawType.title2)
Text(self.headerDetail)
.font(OpenClawType.captionMedium)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var sessionsPanel: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
HStack(spacing: 8) {
Text(self.showArchived ? "Archived sessions" : "Recent sessions")
.font(OpenClawType.subheadBold)
Spacer(minLength: 8)
if self.isLoading {
ProgressView()
.controlSize(.small)
}
if self.sessionControlsAvailable {
Toggle(isOn: self.$showArchived) {
Text("Show Archived")
.font(OpenClawType.captionMedium)
}
.toggleStyle(.switch)
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 3)
if let loadErrorText {
CommandEmptyStateRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: loadErrorText)
.padding(.horizontal, 10)
.padding(.bottom, 10)
} else if self.visibleSessions.isEmpty {
CommandEmptyStateRow(
icon: self.appModel
.isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
title: self.emptyTitle,
detail: self.appModel
.isCommandSessionListAvailable ? self.emptyDetail :
"Connect to the gateway.")
.padding(.horizontal, 10)
.padding(.bottom, 10)
} else {
VStack(alignment: .leading, spacing: 10) {
ForEach(self.sessionSections) { section in
VStack(alignment: .leading, spacing: 6) {
if section.showsHeader {
Text(section.title)
.font(OpenClawType.captionSemiBold)
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
ForEach(section.entries) { session in
self.sessionRow(session)
}
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var headerDetail: String {
if self.isLoading, self.sessions.isEmpty {
return self.showArchived ? "Loading archived sessions" : "Loading recent sessions"
}
let count = self.visibleSessions.count
if count == 0 {
return self.emptyTitle
}
return "\(count) \(count == 1 ? "session" : "sessions")"
}
private var visibleSessions: [OpenClawChatSessionEntry] {
self.sessions
.filter { CommandCenterTab.isRecentChatSession(
$0.key,
defaultSessionKey: self.appModel.defaultChatSessionKey) }
// Gate on the entry's own archived flag so a stale pre-toggle list can
// never render active sessions with archived-only actions mid-refresh.
.filter { self.showArchived ? $0.archived == true : $0.archived != true }
}
private var sessionSections: [CommandSessionSection] {
CommandSessionGrouping.sections(from: self.visibleSessions)
}
private var sessionCategories: [String] {
CommandSessionGrouping.categories(from: self.sessions)
}
private var sessionControlsAvailable: Bool {
!self.appModel.isLocalChatFixtureEnabled && self.appModel.isOperatorGatewayConnected
}
private var emptyTitle: String {
guard self.appModel.isCommandSessionListAvailable else { return "Gateway offline" }
return self.showArchived ? "No archived sessions" : "No recent sessions"
}
private var emptyDetail: String {
self.showArchived ? "Archived sessions will appear here." : "Start a chat and it will appear here."
}
private var refreshID: String {
"\(self.appModel.commandSessionListMode):\(self.showArchived)"
}
private func sessionRow(_ session: OpenClawChatSessionEntry) -> some View {
let item = CommandCenterTab.sessionWorkItem(
for: session,
currentSessionKey: self.appModel.chatSessionKey)
return Button {
self.open(session)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
.commandSessionActions(
session: session,
categories: self.sessionCategories,
isArchived: session.archived == true,
isEnabled: self.sessionControlsAvailable,
onRename: { self.patchSession(session, label: .some($0)) },
onMoveToGroup: { self.patchSession(session, category: .some($0)) },
onTogglePinned: { self.patchSession(session, pinned: session.pinned != true) },
onToggleUnread: { self.patchSession(session, unread: session.unread != true) },
onFork: { self.forkSession(session) },
onToggleArchived: { self.toggleArchived(session) },
onDelete: { self.deleteSession(session) })
}
private func open(_ session: OpenClawChatSessionEntry) {
self.openSessionKey(session.key, unread: session.unread == true)
}
private func openSessionKey(_ key: String, unread: Bool = false) {
self.appModel.openChat(sessionKey: key, unread: unread)
self.dismiss()
self.openChat()
}
private func patchSession(
_ session: OpenClawChatSessionEntry,
label: String?? = nil,
category: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
unread: Bool? = nil)
{
self.performMutation { transport in
try await transport.patchSession(
key: session.key,
label: label,
category: category,
pinned: pinned,
archived: archived,
unread: unread)
}
}
private func deleteSession(_ session: OpenClawChatSessionEntry) {
self.performMutation(resetActiveSessionKey: session.key) { transport in
try await transport.deleteSession(key: session.key)
}
}
private func toggleArchived(_ session: OpenClawChatSessionEntry) {
let archivesSession = !self.showArchived && session.archived != true
self.performMutation(resetActiveSessionKey: archivesSession ? session.key : nil) { transport in
try await transport.patchSession(
key: session.key,
label: nil,
category: nil,
pinned: nil,
archived: archivesSession,
unread: nil)
}
}
private func forkSession(_ session: OpenClawChatSessionEntry) {
Task {
do {
let key = try await self.appModel.makeChatTransport().forkSession(parentKey: session.key)
await self.refreshSessions()
self.openSessionKey(key)
} catch {
self.loadErrorText = error.localizedDescription
}
}
}
private func performMutation(
resetActiveSessionKey: String? = nil,
_ operation: @escaping (any OpenClawChatTransport) async throws -> Void)
{
Task {
do {
try await operation(self.appModel.makeChatTransport())
if resetActiveSessionKey == self.appModel.chatSessionKey {
self.appModel.focusChatSession(nil)
}
await self.refreshSessions()
} catch {
self.loadErrorText = error.localizedDescription
}
}
}
private func refreshSessions() async {
let requestsArchived = self.showArchived
guard self.appModel.isCommandSessionListAvailable else {
self.sessions = requestsArchived ? [] : await self.appModel.loadCachedChatSessions()
self.loadErrorText = nil
return
}
self.isLoading = true
self.loadErrorText = nil
defer { self.isLoading = false }
do {
let transport = self.appModel.makeChatTransport()
let response = try await transport.listSessions(
limit: CommandCenterTab.recentSessionsFetchLimit,
archived: requestsArchived)
guard requestsArchived == self.showArchived else { return }
self.sessions = response.sessions
if !requestsArchived {
self.appModel.reconcileChatSessionReadState(response.sessions)
await self.appModel.storeCachedChatSessions(response.sessions)
}
} catch {
guard requestsArchived == self.showArchived else { return }
self.sessions = requestsArchived ? [] : await self.appModel.loadCachedChatSessions()
self.loadErrorText = self.sessions.isEmpty ? "Try again after the gateway reconnects." : nil
}
}
}
extension NodeAppModel {
fileprivate var isCommandSessionListAvailable: Bool {
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
}
fileprivate var commandSessionListMode: String {
self.chatViewModelIdentityID
}
}