mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* feat(ios): snooze sessions from the session menu Add shared calendar presets and identity-bound snooze/wake patches while preserving existing Swift transport call sites. iOS gains Active, Snoozed, and Archived scopes, wake labels, and deadline-driven updates for Sessions, the sidebar, and Overview. Snoozing keeps ongoing work and the open conversation intact. * test(ios): capture session snooze proof states Extend the synthetic Gateway with awake and snoozed sessions. Capture Active and Snoozed lists, snooze presets, and Wake through the real iOS session menu, using All Sessions navigation and a row selector that excludes the retained sidebar. * fix(ios): show the weekday for the next-week snooze preset * chore(ios): record snooze strings in the native i18n source inventory * fix(ios): format snooze wake labels through the localized catalog Apple i18n verification rejects runtime-interpolated localized strings because they bypass the generated catalog, so the "Wake session" menu item and the "tomorrow" wake description now use the repository's String(format: String(localized: "… %@")) pattern, and the native string inventory records the format sources. * refactor(ios): drop the superseded mutation lease initializer * fix(ios): keep snoozed sessions browsable offline Separate cached browsing scopes from connected mutation controls. Keep Archived connected-only and reset an archived selection to Active on disconnect. Cover scope availability and the offline cached roster, including snoozed rows and wake labels.
1256 lines
49 KiB
Swift
1256 lines
49 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
|
|
var headerTitle: String = "OpenClaw"
|
|
var headerSidebarAction: OpenClawSidebarHeaderAction?
|
|
var dashboardModel: RootSidebarModel
|
|
var openChat: () -> Void
|
|
var openSettings: () -> Void
|
|
var openSessions: () -> Void
|
|
var openApprovals: () -> Void
|
|
var openAutomations: () -> Void
|
|
var openUsage: () -> Void
|
|
|
|
struct WorkItem {
|
|
let icon: String
|
|
let title: String
|
|
let detail: String
|
|
let state: String
|
|
let trailing: String
|
|
let color: Color
|
|
let isUnread: Bool
|
|
let isPinned: Bool
|
|
var sessionColor: String?
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
OpenClawProBackground()
|
|
self.commandAmbientOverlay
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
self.header
|
|
self.gatewayCard
|
|
self.threadTiles
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
self.attentionCard
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
self.usageSummaryCard
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
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(.hidden, for: .navigationBar)
|
|
}
|
|
|
|
private var threadTiles: some View {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
|
|
self.threadTile(
|
|
title: String(localized: "Sessions"),
|
|
value: self.overviewCountText(self.overviewSessions.count))
|
|
self.threadTile(
|
|
title: String(localized: "Live"),
|
|
value: self.overviewCountText(self.overviewLiveCount))
|
|
self.threadTile(
|
|
title: String(localized: "Unread"),
|
|
value: self.overviewCountText(self.overviewUnreadCount))
|
|
self.threadTile(
|
|
title: String(localized: "Tokens"),
|
|
value: self.overviewTokenText)
|
|
}
|
|
}
|
|
|
|
private func threadTile(title: String, value: String) -> some View {
|
|
Button {
|
|
self.openSessions()
|
|
} label: {
|
|
ProCard(padding: 10) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(verbatim: value)
|
|
.font(OpenClawType.headline)
|
|
.foregroundStyle(OpenClawBrand.accent)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
Text(verbatim: title)
|
|
.font(OpenClawType.caption2Medium)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityHint(String(localized: "Opens Sessions"))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var attentionCard: some View {
|
|
let approvalCount = self.appModel.pendingExecApprovalCount
|
|
let cronCount = self.dashboardModel.failedCronJobCount + self.dashboardModel.overdueCronJobCount
|
|
if approvalCount > 0 || cronCount > 0 {
|
|
ProCard(tint: OpenClawBrand.warn, padding: 12) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
self.cardHeader(title: String(localized: "Attention"))
|
|
if approvalCount > 0 {
|
|
self.dashboardActionRow(
|
|
title: String(localized: "Pending approvals"),
|
|
value: approvalCount.formatted(),
|
|
systemImage: "checkmark.shield",
|
|
action: self.openApprovals)
|
|
}
|
|
if cronCount > 0 {
|
|
self.dashboardActionRow(
|
|
title: String(localized: "Automation issues"),
|
|
value: cronCount.formatted(),
|
|
systemImage: "clock.badge.exclamationmark",
|
|
action: self.openAutomations)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var usageSummaryCard: some View {
|
|
if let usage = self.dashboardModel.usage {
|
|
Button(action: self.openUsage) {
|
|
ProCard(padding: 12) {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(String(localized: "31-day usage"))
|
|
.font(OpenClawType.subheadSemiBold)
|
|
.foregroundStyle(.primary)
|
|
Text(self.usageCostText(usage.totalCost))
|
|
.font(OpenClawType.title3SemiBold)
|
|
.foregroundStyle(OpenClawBrand.accent)
|
|
}
|
|
Spacer(minLength: 8)
|
|
self.usageTrend(usage.daily ?? [])
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityHint(String(localized: "Opens Usage"))
|
|
}
|
|
}
|
|
|
|
private func dashboardActionRow(
|
|
title: String,
|
|
value: String,
|
|
systemImage: String,
|
|
action: @escaping () -> Void) -> some View
|
|
{
|
|
Button(action: action) {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: systemImage)
|
|
.font(OpenClawType.subheadSemiBold)
|
|
.foregroundStyle(OpenClawBrand.warn)
|
|
Text(verbatim: title)
|
|
.font(OpenClawType.subheadSemiBold)
|
|
Spacer(minLength: 8)
|
|
Text(verbatim: value)
|
|
.font(OpenClawType.subheadSemiBold)
|
|
.foregroundStyle(OpenClawBrand.warn)
|
|
Image(systemName: "chevron.right")
|
|
.font(OpenClawType.captionSemiBold)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(minHeight: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func usageTrend(_ daily: [CostUsageDailyEntryLite]) -> some View {
|
|
let values = daily.suffix(14).map { max(0, $0.totalCost ?? 0) }
|
|
let maximum = values.max() ?? 0
|
|
return HStack(alignment: .bottom, spacing: 2) {
|
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
|
Capsule()
|
|
.fill(OpenClawBrand.accent.opacity(0.75))
|
|
.frame(width: 3, height: maximum > 0 ? max(3, 28 * value / maximum) : 3)
|
|
}
|
|
}
|
|
.frame(height: 30, alignment: .bottom)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
private func usageCostText(_ value: Double?) -> String {
|
|
guard let value else { return "—" }
|
|
return value.formatted(.currency(code: "USD"))
|
|
}
|
|
|
|
private var overviewSessions: [OpenClawChatSessionEntry] {
|
|
Self.visibleOverviewSessions(self.dashboardModel.sessions, now: self.dashboardModel.now)
|
|
}
|
|
|
|
static func visibleOverviewSessions(
|
|
_ sessions: [OpenClawChatSessionEntry],
|
|
now: Date = .now) -> [OpenClawChatSessionEntry]
|
|
{
|
|
sessions.filter {
|
|
SessionStatusScope.active.includes($0, at: now) &&
|
|
!ChatSessionSidebarModel.isHiddenInternalSession($0.key)
|
|
}
|
|
}
|
|
|
|
private var overviewLiveCount: Int {
|
|
self.overviewSessions.count { session in
|
|
session.hasActiveRun == true || session.hasActiveSubagentRun == true ||
|
|
session.status?.lowercased() == "running"
|
|
}
|
|
}
|
|
|
|
private var overviewUnreadCount: Int {
|
|
self.overviewSessions.count { $0.unread == true }
|
|
}
|
|
|
|
private func overviewCountText(_ count: Int) -> String {
|
|
"\(count.formatted())\(self.dashboardModel.isSessionRosterComplete ? "" : "+")"
|
|
}
|
|
|
|
private var overviewTokenText: String {
|
|
let summary = RootSidebarModel.tokenUsageSummary(
|
|
for: self.overviewSessions,
|
|
rosterIsComplete: self.dashboardModel.isSessionRosterComplete)
|
|
guard let total = summary.total else { return "n/a" }
|
|
return "\(summary.isPartial ? "~" : "")\(total.formatted(.number.notation(.compactName)))"
|
|
}
|
|
|
|
static func usesSplitSectionsLayout(
|
|
horizontalSizeClass: UserInterfaceSizeClass?,
|
|
containerWidth: CGFloat) -> Bool
|
|
{
|
|
guard horizontalSizeClass == .regular else { return false }
|
|
return containerWidth >= 1000
|
|
}
|
|
|
|
private var header: some View {
|
|
OpenClawAdaptiveHeaderRow(
|
|
title: .localized(self.headerTitle),
|
|
subtitle: .localized(self.gatewaySubtitle),
|
|
titleFont: OpenClawType.title3SemiBold,
|
|
subtitleFont: OpenClawType.caption,
|
|
subtitleLineLimit: 1)
|
|
{
|
|
if let headerSidebarAction {
|
|
OpenClawSidebarControlButton(action: headerSidebarAction)
|
|
}
|
|
} accessory: {
|
|
HStack(spacing: 10) {
|
|
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)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var commandAmbientOverlay: some View {
|
|
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 {
|
|
ProCard(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 {
|
|
ProCard(padding: 12) {
|
|
VStack(spacing: 10) {
|
|
self.cardHeader(title: "Agent session")
|
|
|
|
Button {
|
|
self.openSessionKey(nil)
|
|
} label: {
|
|
CommandSessionRow(item: self.defaultChatWorkItem)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var recentSessions: some View {
|
|
ProCard(padding: 12) {
|
|
VStack(spacing: 10) {
|
|
self.cardHeader(title: "Recent sessions")
|
|
|
|
if let sessionErrorText = self.dashboardModel.sessionErrorText {
|
|
Text(verbatim: sessionErrorText)
|
|
.font(OpenClawType.captionMedium)
|
|
.foregroundStyle(OpenClawBrand.warn)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
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.openSessionKey(session.key)
|
|
} label: {
|
|
CommandSessionRow(item: item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.commandSessionActions(
|
|
session: session,
|
|
mainSessionKey: self.appModel.defaultChatSessionKey,
|
|
categories: self.sessionCategories,
|
|
isEnabled: self.sessionControlsAvailable,
|
|
canArchive: ChatSessionSidebarModel.canArchiveSession(
|
|
session,
|
|
mainSessionKey: self.appModel.defaultChatSessionKey),
|
|
actions: .gateway(
|
|
session: session,
|
|
performMutation: self.performSessionMutation,
|
|
fork: { self.forkSession(session) }))
|
|
}
|
|
|
|
if self.hasMoreRecentSessions {
|
|
Button(action: self.openSessions) {
|
|
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:
|
|
String(localized: "Online")
|
|
case .connecting:
|
|
String(localized: "Connecting")
|
|
case .error:
|
|
String(localized: "Attention")
|
|
case .disconnected:
|
|
String(localized: "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)
|
|
?? String(localized: "Unknown")
|
|
}
|
|
|
|
private var gatewayAgentCountText: String {
|
|
guard self.gatewayConnected else { return "—" }
|
|
return self.appModel.gatewayAgents.count.formatted()
|
|
}
|
|
|
|
private var defaultChatWorkItem: WorkItem {
|
|
let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey
|
|
return WorkItem(
|
|
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,
|
|
isUnread: self.effectiveDefaultChatSessionEntry?.unread == true,
|
|
isPinned: self.effectiveDefaultChatSessionEntry?.pinned == true,
|
|
sessionColor: self.effectiveDefaultChatSessionEntry?.color)
|
|
}
|
|
|
|
private var defaultChatActivityText: String {
|
|
let activityAt = self.effectiveDefaultChatSessionEntry?.lastActivityAt ??
|
|
self.effectiveDefaultChatSessionEntry?.updatedAt
|
|
guard let activityAt, activityAt > 0 else {
|
|
return String(localized: "No recent activity")
|
|
}
|
|
return Self.relativeTimeText(forMilliseconds: activityAt)
|
|
}
|
|
|
|
private var recentSessionPreviewSessions: [OpenClawChatSessionEntry] {
|
|
CommandSessionGrouping.previewSelection(
|
|
self.effectiveRecentChatSessions,
|
|
currentKey: self.appModel.chatSessionKey)
|
|
}
|
|
|
|
private var hasMoreRecentSessions: Bool {
|
|
self.effectiveRecentChatSessions.count > self.recentSessionPreviewSessions.count
|
|
}
|
|
|
|
private var sessionCategories: [String] {
|
|
CommandSessionGrouping.categories(
|
|
from: self.effectiveRecentChatSessions,
|
|
knownGroups: SessionGroupStore.load())
|
|
}
|
|
|
|
private var effectiveDefaultChatSessionEntry: OpenClawChatSessionEntry? {
|
|
let sessions = self.dashboardModel.sessions
|
|
let mainKey = ChatSessionSidebarModel.selectedSessionKey(
|
|
sessions: sessions,
|
|
currentSessionKey: "main",
|
|
mainSessionKey: self.appModel.defaultChatSessionKey,
|
|
activeAgentID: self.appModel.chatAgentId,
|
|
sessionRoutingContract: self.appModel.chatSessionRoutingContract)
|
|
return sessions.first { $0.key == mainKey }
|
|
}
|
|
|
|
private var effectiveRecentChatSessions: [OpenClawChatSessionEntry] {
|
|
self.dashboardModel.sessions.filter {
|
|
SessionStatusScope.active.includes($0, at: self.dashboardModel.now) &&
|
|
Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey)
|
|
}
|
|
}
|
|
|
|
private var sessionControlsAvailable: Bool {
|
|
!self.appModel.isLocalChatFixtureEnabled && self.appModel.isOperatorGatewayConnected
|
|
}
|
|
|
|
private func openSessionKey(_ key: String?) {
|
|
self.appModel.openChat(sessionKey: key)
|
|
self.openChat()
|
|
}
|
|
|
|
private func forkSession(_ session: OpenClawChatSessionEntry) {
|
|
Task {
|
|
do {
|
|
let key = try await self.appModel.makeChatTransport().forkSession(
|
|
parentKey: session.key,
|
|
fromLastCompleted: session.hasActiveRun == true)
|
|
await self.dashboardModel.refreshSessions(appModel: self.appModel)
|
|
self.openSessionKey(key)
|
|
} catch {
|
|
self.dashboardModel.reportSessionError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.dashboardModel.refreshSessions(appModel: self.appModel)
|
|
} catch {
|
|
self.dashboardModel.reportSessionError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func sessionWorkItem(
|
|
for session: OpenClawChatSessionEntry,
|
|
currentSessionKey: String,
|
|
now: Date = .now) -> WorkItem
|
|
{
|
|
let isCurrent = session.key == currentSessionKey
|
|
return WorkItem(
|
|
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
|
title: Self.sessionTitle(session),
|
|
detail: Self.sessionDetail(session, now: now),
|
|
state: isCurrent ? "open" : "recent",
|
|
trailing: "chat",
|
|
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
|
|
isUnread: session.unread == true,
|
|
isPinned: session.pinned == true,
|
|
sessionColor: session.color)
|
|
}
|
|
|
|
static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
|
|
if let label = self.normalized(session.label) {
|
|
return label
|
|
}
|
|
if let displayName = self.normalized(session.displayName) {
|
|
return Self.redactedSessionTitle(for: displayName) ?? displayName
|
|
}
|
|
if let autoLabel = self.normalized(session.autoLabel) {
|
|
return autoLabel
|
|
}
|
|
if let subject = self.normalized(session.subject) {
|
|
return Self.redactedSessionTitle(for: subject) ?? subject
|
|
}
|
|
// Generic key placeholders only after real topic names are absent.
|
|
return self.redactedSessionTitle(for: session.key) ?? 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 String(localized: "iOS chat")
|
|
}
|
|
if lowercased.hasPrefix("telegram:") {
|
|
return String(localized: "Telegram chat")
|
|
}
|
|
if lowercased.hasPrefix("user:+") {
|
|
return String(localized: "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: "-")
|
|
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: " ")
|
|
}
|
|
|
|
static func sessionDetail(_ session: OpenClawChatSessionEntry, now: Date = .now) -> String {
|
|
if session.archived != true, session.isSnoozed(at: now), let milliseconds = session.snoozedUntil {
|
|
let wake = OpenClawChatSessionSnooze.wakeDescription(
|
|
Date(timeIntervalSince1970: milliseconds / 1000), now: now)
|
|
return String(format: String(localized: "Wakes %@"), wake)
|
|
}
|
|
let activityAt = session.lastActivityAt ?? session.updatedAt
|
|
if let activityAt, activityAt > 0 {
|
|
return self.relativeTimeText(forMilliseconds: activityAt, relativeTo: now)
|
|
}
|
|
return session.key
|
|
}
|
|
|
|
static func relativeTimeText(
|
|
forMilliseconds milliseconds: Double,
|
|
relativeTo now: Date = .now) -> String
|
|
{
|
|
let date = Date(timeIntervalSince1970: milliseconds / 1000)
|
|
guard now.timeIntervalSince(date) >= 60 else {
|
|
return String(localized: "just now")
|
|
}
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.dateTimeStyle = .numeric
|
|
formatter.unitsStyle = .short
|
|
return formatter.localizedString(for: date, relativeTo: now)
|
|
}
|
|
|
|
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 ChatSessionSidebarModel.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 = Self.normalized(appModel.gatewayServerName) {
|
|
return String(
|
|
format: String(localized: "%@ on %@"),
|
|
self.appModel.activeAgentName,
|
|
server)
|
|
}
|
|
if let address = Self.normalized(appModel.gatewayRemoteAddress) {
|
|
return String(
|
|
format: String(localized: "%@ via %@"),
|
|
self.appModel.activeAgentName,
|
|
address)
|
|
}
|
|
return self.appModel.gatewayDisplayStatusText
|
|
}
|
|
|
|
private static func normalized(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
enum SessionStatusScope: String, CaseIterable {
|
|
case active
|
|
case snoozed
|
|
case archived
|
|
|
|
static func available(isConnected: Bool) -> [Self] {
|
|
isConnected ? self.allCases : [.active, .snoozed]
|
|
}
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .active: String(localized: "Active")
|
|
case .snoozed: String(localized: "Snoozed")
|
|
case .archived: String(localized: "Archived")
|
|
}
|
|
}
|
|
|
|
func includes(_ session: OpenClawChatSessionEntry, at now: Date) -> Bool {
|
|
switch self {
|
|
case .active: session.archived != true && !session.isSnoozed(at: now)
|
|
case .snoozed: session.archived != true && session.isSnoozed(at: now)
|
|
case .archived: session.archived == true
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CommandSessionsScreen: View {
|
|
@Environment(NodeAppModel.self) private var appModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
private enum GroupEditor: Equatable {
|
|
case rename(String)
|
|
case create
|
|
}
|
|
|
|
/// Group mutations need the full session store, not a recency window.
|
|
private static let groupMemberFetchLimit = 10000
|
|
|
|
@State private var sessions: [OpenClawChatSessionEntry] = []
|
|
@State private var isLoading = false
|
|
@State private var loadErrorText: String?
|
|
@State private var statusScope: SessionStatusScope = .active
|
|
@State private var now: Date = .now
|
|
@State private var knownGroups = SessionGroupStore.load()
|
|
@State private var groupEditor: GroupEditor?
|
|
@State private var groupDraftText = ""
|
|
@State private var groupPendingDelete: String?
|
|
let headerSidebarAction: OpenClawSidebarHeaderAction?
|
|
let openChat: () -> Void
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
OpenClawProBackground()
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
self.header
|
|
self.sessionsPanel
|
|
}
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 18)
|
|
}
|
|
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
|
}
|
|
.navigationTitle("Sessions")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.task(id: self.refreshID) {
|
|
await self.refreshSessions()
|
|
}
|
|
.onChange(of: self.availableStatusScopes, initial: true) { _, scopes in
|
|
if !scopes.contains(self.statusScope) {
|
|
self.statusScope = .active
|
|
}
|
|
}
|
|
.task(id: self.sessions) {
|
|
self.now = .now
|
|
while let wake = OpenClawChatSessionSnooze.nextWake(in: self.sessions, now: self.now) {
|
|
do {
|
|
try await Task.sleep(for: .seconds(max(0, wake.timeIntervalSinceNow) + 0.001))
|
|
try Task.checkCancellation()
|
|
} catch {
|
|
return
|
|
}
|
|
self.now = .now
|
|
}
|
|
}
|
|
.alert(self.groupEditorTitle, isPresented: self.groupEditorBinding) {
|
|
TextField("Group name", text: self.$groupDraftText)
|
|
.font(OpenClawType.body)
|
|
Button {
|
|
self.commitGroupEditor()
|
|
} label: {
|
|
Text(self.groupEditor == .create
|
|
? LocalizedStringKey("Create")
|
|
: LocalizedStringKey("Save"))
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
Button(role: .cancel) {
|
|
self.groupEditor = nil
|
|
} label: {
|
|
Text("Cancel")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
}
|
|
.alert(
|
|
"Delete Group?",
|
|
isPresented: self.groupDeleteBinding,
|
|
presenting: self.groupPendingDelete)
|
|
{ group in
|
|
Button(role: .destructive) {
|
|
self.deleteGroup(group)
|
|
} label: {
|
|
Text("Delete Group")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
Button(role: .cancel) {} label: {
|
|
Text("Cancel")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
} message: { group in
|
|
Text(verbatim: String(
|
|
format: String(
|
|
localized: "Sessions in \u{201C}%@\u{201D} move back to Ungrouped."),
|
|
group))
|
|
.font(OpenClawType.caption)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
OpenClawAdaptiveHeaderRow(
|
|
title: "Sessions",
|
|
subtitle: .verbatim(self.headerDetail),
|
|
titleFont: OpenClawType.title2,
|
|
subtitleFont: OpenClawType.captionMedium)
|
|
{
|
|
if let headerSidebarAction {
|
|
OpenClawSidebarControlButton(action: headerSidebarAction)
|
|
}
|
|
} accessory: {
|
|
EmptyView()
|
|
}
|
|
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
|
}
|
|
|
|
private var sessionsPanel: some View {
|
|
ProCard(padding: 0) {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 8) {
|
|
Text(verbatim: self.panelTitle)
|
|
.font(OpenClawType.subheadBold)
|
|
Spacer(minLength: 8)
|
|
if self.isLoading {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 3)
|
|
|
|
if self.appModel.isCommandSessionListAvailable || !self.sessions.isEmpty {
|
|
Picker(selection: self.$statusScope) {
|
|
ForEach(self.availableStatusScopes, id: \.self) { scope in
|
|
Text(verbatim: scope.title)
|
|
.font(OpenClawType.captionMedium)
|
|
.tag(scope)
|
|
.accessibilityLabel(scope == .archived
|
|
? String(localized: "Show Archived") : scope.title)
|
|
.accessibilityIdentifier("Sessions.Status.\(scope.rawValue)")
|
|
}
|
|
} label: {
|
|
Text("Session status")
|
|
.font(OpenClawType.captionMedium)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.accessibilityIdentifier("Sessions.StatusScope")
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
if let loadErrorText {
|
|
CommandEmptyStateRow(
|
|
icon: "exclamationmark.triangle.fill",
|
|
title: "Sessions unavailable",
|
|
detail: .verbatim(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: .verbatim(self.emptyTitle),
|
|
detail: .verbatim(self.appModel
|
|
.isCommandSessionListAvailable
|
|
? self.emptyDetail
|
|
: String(localized: "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 {
|
|
self.sectionHeader(section)
|
|
}
|
|
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 {
|
|
switch self.statusScope {
|
|
case .active: return String(localized: "Loading recent sessions")
|
|
case .snoozed: return String(localized: "Loading snoozed sessions")
|
|
case .archived: return String(localized: "Loading archived sessions")
|
|
}
|
|
}
|
|
let count = self.visibleSessions.count
|
|
if count == 0 {
|
|
return self.emptyTitle
|
|
}
|
|
return String(
|
|
AttributedString(localized: "^[\(count) session](inflect: true)").characters)
|
|
}
|
|
|
|
private var visibleSessions: [OpenClawChatSessionEntry] {
|
|
self.sessions
|
|
.filter { CommandCenterTab.isRecentChatSession(
|
|
$0.key,
|
|
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
|
// A stale list must still obey the selected scope while its replacement loads.
|
|
.filter { self.statusScope.includes($0, at: self.now) }
|
|
}
|
|
|
|
private var sessionSections: [CommandSessionSection] {
|
|
CommandSessionGrouping.sections(from: self.visibleSessions, knownGroups: self.knownGroups)
|
|
}
|
|
|
|
private var sessionCategories: [String] {
|
|
CommandSessionGrouping.categories(from: self.sessions, knownGroups: self.knownGroups)
|
|
}
|
|
|
|
private var sessionControlsAvailable: Bool {
|
|
!self.appModel.isLocalChatFixtureEnabled && self.appModel.isOperatorGatewayConnected
|
|
}
|
|
|
|
private var availableStatusScopes: [SessionStatusScope] {
|
|
SessionStatusScope.available(isConnected: self.appModel.isOperatorGatewayConnected)
|
|
}
|
|
|
|
private var emptyTitle: String {
|
|
guard self.appModel.isCommandSessionListAvailable else {
|
|
return String(localized: "Gateway offline")
|
|
}
|
|
switch self.statusScope {
|
|
case .active: return String(localized: "No recent sessions")
|
|
case .snoozed: return String(localized: "No snoozed sessions")
|
|
case .archived: return String(localized: "No archived sessions")
|
|
}
|
|
}
|
|
|
|
private var emptyDetail: String {
|
|
switch self.statusScope {
|
|
case .active: String(localized: "Start a chat and it will appear here.")
|
|
case .snoozed: String(localized: "Snoozed sessions will appear here until they wake.")
|
|
case .archived: String(localized: "Archived sessions will appear here.")
|
|
}
|
|
}
|
|
|
|
private var panelTitle: String {
|
|
switch self.statusScope {
|
|
case .active: String(localized: "Recent sessions")
|
|
case .snoozed: String(localized: "Snoozed sessions")
|
|
case .archived: String(localized: "Archived sessions")
|
|
}
|
|
}
|
|
|
|
private var refreshID: String {
|
|
"\(self.appModel.chatViewModelIdentityID):\(self.statusScope.rawValue)"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sectionHeader(_ section: CommandSessionSection) -> some View {
|
|
let title = Text(section.title)
|
|
.font(OpenClawType.captionSemiBold)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 4)
|
|
// Group management only applies to custom categories, never the
|
|
// Pinned/Ungrouped built-ins.
|
|
if case let .category(group) = section.id, self.sessionControlsAvailable {
|
|
title.contextMenu {
|
|
self.groupMenu(for: group)
|
|
}
|
|
} else {
|
|
title
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func groupMenu(for group: String) -> some View {
|
|
Button {
|
|
self.groupDraftText = group
|
|
self.groupEditor = .rename(group)
|
|
} label: {
|
|
Label("Rename Group…", systemImage: "pencil")
|
|
.font(OpenClawType.subhead)
|
|
}
|
|
Button {
|
|
self.groupDraftText = ""
|
|
self.groupEditor = .create
|
|
} label: {
|
|
Label("New Group…", systemImage: "folder.badge.plus")
|
|
.font(OpenClawType.subhead)
|
|
}
|
|
Button(role: .destructive) {
|
|
self.groupPendingDelete = group
|
|
} label: {
|
|
Label("Delete Group…", systemImage: "trash")
|
|
.font(OpenClawType.subhead)
|
|
}
|
|
}
|
|
|
|
private var groupEditorTitle: String {
|
|
self.groupEditor == .create
|
|
? String(localized: "New Group")
|
|
: String(localized: "Rename Group")
|
|
}
|
|
|
|
private var groupEditorBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { self.groupEditor != nil },
|
|
set: { if !$0 { self.groupEditor = nil } })
|
|
}
|
|
|
|
private var groupDeleteBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { self.groupPendingDelete != nil },
|
|
set: { if !$0 { self.groupPendingDelete = nil } })
|
|
}
|
|
|
|
private func commitGroupEditor() {
|
|
let editor = self.groupEditor
|
|
self.groupEditor = nil
|
|
let name = self.groupDraftText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { return }
|
|
switch editor {
|
|
case let .rename(group):
|
|
guard name != group else { return }
|
|
self.updateStoredGroups { SessionGroupStore.renaming($0, from: group, to: name) }
|
|
self.patchGroupMembers(group, category: name)
|
|
case .create:
|
|
// Header-created groups start empty: stored-list only, no patches.
|
|
self.updateStoredGroups { SessionGroupStore.adding($0, name) }
|
|
case nil:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func deleteGroup(_ group: String) {
|
|
self.groupPendingDelete = nil
|
|
self.updateStoredGroups { SessionGroupStore.removing($0, group) }
|
|
self.patchGroupMembers(group, category: nil)
|
|
}
|
|
|
|
private func updateStoredGroups(_ transform: ([String]) -> [String]) {
|
|
let updated = transform(SessionGroupStore.load())
|
|
SessionGroupStore.save(updated)
|
|
self.knownGroups = updated
|
|
}
|
|
|
|
/// Reassigns (or clears, when `category` is nil) every member of `group`.
|
|
private func patchGroupMembers(_ group: String, category: String?) {
|
|
self.performMutation { transport in
|
|
// Enumerate every member, not the windowed visible list: archived
|
|
// members must follow a rename so restores land in the new group.
|
|
// The gateway defaults an absent `limit` to 100 rows, so ask for
|
|
// an explicitly high limit to cover the whole store.
|
|
let active = try await transport.listSessions(
|
|
limit: Self.groupMemberFetchLimit,
|
|
archived: false)
|
|
let archived = try await transport.listSessions(
|
|
limit: Self.groupMemberFetchLimit,
|
|
archived: true)
|
|
let members = CommandSessionGrouping.members(
|
|
of: group,
|
|
in: [active.sessions, archived.sessions])
|
|
// Best effort: one failed patch must not abandon the rest of the
|
|
// group; the first error still surfaces via performMutation.
|
|
var firstError: (any Error)?
|
|
for member in members {
|
|
do {
|
|
try await transport.patchSession(
|
|
key: member.key,
|
|
expectedSessionID: nil,
|
|
label: nil,
|
|
category: .some(category),
|
|
color: nil,
|
|
pinned: nil,
|
|
archived: nil,
|
|
unread: nil)
|
|
} catch {
|
|
firstError = firstError ?? error
|
|
}
|
|
}
|
|
if let firstError {
|
|
throw firstError
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sessionRow(_ session: OpenClawChatSessionEntry) -> some View {
|
|
let item = CommandCenterTab.sessionWorkItem(
|
|
for: session,
|
|
currentSessionKey: self.appModel.chatSessionKey,
|
|
now: self.now)
|
|
return Button {
|
|
self.openSessionKey(session.key)
|
|
} label: {
|
|
CommandSessionRow(item: item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.commandSessionActions(
|
|
session: session,
|
|
mainSessionKey: self.appModel.defaultChatSessionKey,
|
|
categories: self.sessionCategories,
|
|
isArchived: session.archived == true,
|
|
isEnabled: self.sessionControlsAvailable,
|
|
canArchive: ChatSessionSidebarModel.canArchiveSession(
|
|
session,
|
|
mainSessionKey: self.appModel.defaultChatSessionKey),
|
|
actions: .gateway(
|
|
session: session,
|
|
archivesSession: { self.statusScope != .archived && session.archived != true },
|
|
performMutation: self.performMutation,
|
|
fork: { self.forkSession(session) }))
|
|
}
|
|
|
|
private func openSessionKey(_ key: String) {
|
|
self.appModel.openChat(sessionKey: key)
|
|
self.dismiss()
|
|
self.openChat()
|
|
}
|
|
|
|
private func forkSession(_ session: OpenClawChatSessionEntry) {
|
|
Task {
|
|
do {
|
|
let key = try await self.appModel.makeChatTransport().forkSession(
|
|
parentKey: session.key,
|
|
fromLastCompleted: session.hasActiveRun == true)
|
|
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 {
|
|
// Pick up groups stored by other surfaces (for example the per-session
|
|
// New Group editor) alongside the fresh session list.
|
|
self.knownGroups = SessionGroupStore.load()
|
|
let requestedScope = self.statusScope
|
|
let requestsArchived = requestedScope == .archived
|
|
let sourceGatewayID = self.appModel.chatTranscriptCacheGatewayID
|
|
let sourceAgentID = self.appModel.chatDeliveryAgentId
|
|
self.isLoading = true
|
|
self.loadErrorText = nil
|
|
defer {
|
|
if requestedScope == self.statusScope { self.isLoading = false }
|
|
}
|
|
|
|
do {
|
|
let roster = try await self.appModel.loadChatSessionRoster(
|
|
limit: CommandCenterTab.recentSessionsFetchLimit,
|
|
archived: requestsArchived)
|
|
guard !Task.isCancelled, requestedScope == self.statusScope else { return }
|
|
self.sessions = roster.sessions
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
guard !Task.isCancelled, requestedScope == self.statusScope else { return }
|
|
let cached = requestsArchived ? [] : await self.appModel.loadCachedChatSessions(
|
|
gatewayID: sourceGatewayID,
|
|
agentID: sourceAgentID)
|
|
guard !Task.isCancelled, requestedScope == self.statusScope else { return }
|
|
self.sessions = cached
|
|
self.loadErrorText = self.sessions.isEmpty ? "Try again after the gateway reconnects." : nil
|
|
}
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
fileprivate var isCommandSessionListAvailable: Bool {
|
|
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
|
|
}
|
|
}
|