diff --git a/apps/ios/Sources/Design/CommandCenterSupport.swift b/apps/ios/Sources/Design/CommandCenterSupport.swift index 3b1d2e0625d..d10bb415180 100644 --- a/apps/ios/Sources/Design/CommandCenterSupport.swift +++ b/apps/ios/Sources/Design/CommandCenterSupport.swift @@ -126,7 +126,7 @@ struct CommandSessionRow: View { } private var progressLabel: String { - guard let progress = self.item.progress else { + guard let progress = item.progress else { return self.item.state } if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" { @@ -144,6 +144,34 @@ struct CommandSessionRow: View { } } +struct CommandViewMoreRow: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Text("View More") + .font(.subheadline.weight(.bold)) + .foregroundStyle(OpenClawBrand.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(self.rowFill) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(self.rowBorder, lineWidth: 1) + } + } + } + + private var rowFill: Color { + self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025) + } + + private var rowBorder: Color { + self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045) + } +} + struct CommandApprovalRow: View { let item: CommandCenterTab.ApprovalItem diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index 97b2f1b6a4d..e74966b7fae 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -5,7 +5,7 @@ struct CommandCenterTab: View { @Environment(NodeAppModel.self) private var appModel @Environment(\.colorScheme) private var colorScheme @Environment(\.scenePhase) private var scenePhase - @State private var activeChatSessions: [OpenClawChatSessionEntry] = [] + @State private var recentChatSessions: [OpenClawChatSessionEntry] = [] var openChat: () -> Void var openSettings: () -> Void @@ -45,7 +45,7 @@ struct CommandCenterTab: View { self.header self.gatewayCard self.pendingApprovals - self.activeTasks + self.recentSessions self.liveActivity self.startWorkAction } @@ -56,8 +56,8 @@ struct CommandCenterTab: View { } .navigationBarHidden(true) } - .task(id: self.activeSessionsRefreshID) { - await self.refreshActiveSessionsIfNeeded() + .task(id: self.recentSessionsRefreshID) { + await self.refreshRecentSessionsIfNeeded() } } @@ -236,29 +236,48 @@ struct CommandCenterTab: View { } } - private var activeTasks: some View { + private var recentSessions: some View { CommandPanel(padding: 0) { VStack(spacing: 0) { self.cardHeader( - title: "Active sessions", - value: self.activeSessionsSummaryText, + title: "Recent sessions", + value: nil, color: .secondary) .padding(.horizontal, 12) .padding(.top, 10) .padding(.bottom, 3) - VStack(spacing: 8) { - ForEach(self.visibleActiveSessionRows) { item in - Button { - self.open(item.route) - } label: { - CommandSessionRow(item: item) + if self.recentSessionPreviewRows.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.") + .padding(.horizontal, 10) + .padding(.bottom, 10) + } else { + VStack(spacing: 8) { + ForEach(self.recentSessionPreviewRows) { item in + Button { + self.open(item.route) + } label: { + CommandSessionRow(item: item) + } + .buttonStyle(.plain) + } + + if self.hasMoreRecentSessions { + NavigationLink { + CommandSessionsScreen(openChat: self.openChat) + } label: { + CommandViewMoreRow() + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .padding(.horizontal, 10) + .padding(.bottom, 10) } - .padding(.horizontal, 10) - .padding(.bottom, 10) } } .padding(.horizontal, OpenClawProMetric.pagePadding) @@ -378,17 +397,6 @@ struct CommandCenterTab: View { return "\(self.appModel.gatewayAgents.count)" } - private var activeSessionsSummaryText: String { - let count = self.activeSessionRows.count - if count == 0 { - return self.gatewayConnected ? "No sessions" : "Offline" - } - if self.sessionWorkItems.isEmpty { - return self.gatewayConnected ? "\(count) ready" : "Offline" - } - return "\(count) \(count == 1 ? "session" : "sessions")" - } - private var approvalItems: [ApprovalItem] { if let pendingApproval { return [ @@ -416,16 +424,20 @@ struct CommandCenterTab: View { self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022) } - private var activeSessionRows: [WorkItem] { + private var recentSessionRows: [WorkItem] { self.sessionItems } - private var visibleActiveSessionRows: [WorkItem] { - Array(self.activeSessionRows.prefix(3)) + private var recentSessionPreviewRows: [WorkItem] { + Array(self.recentSessionRows.prefix(3)) + } + + private var hasMoreRecentSessions: Bool { + self.sessionWorkItems.count > self.recentSessionPreviewRows.count } private var liveActivityTitle: String { - if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) { + if let session = recentChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) { return "\(Self.sessionTitle(session)) updated" } if self.pendingApproval != nil { @@ -435,7 +447,7 @@ struct CommandCenterTab: View { } private var liveActivityValue: String { - if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }), + if let session = recentChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }), let updatedAt = session.updatedAt, updatedAt > 0 { @@ -457,7 +469,7 @@ struct CommandCenterTab: View { return status.isEmpty ? self.gatewayStateText : status } - private var activeSessionsRefreshID: String { + private var recentSessionsRefreshID: String { [ self.appModel.isOperatorGatewayConnected ? "connected" : "offline", self.appModel.chatSessionKey, @@ -466,76 +478,18 @@ struct CommandCenterTab: View { } private var sessionItems: [WorkItem] { - let liveItems = self.sessionWorkItems - if !liveItems.isEmpty { return liveItems } - return self.defaultSessionItems + self.sessionWorkItems } private var sessionWorkItems: [WorkItem] { let currentSessionKey = self.appModel.chatSessionKey - return self.activeChatSessions + return self.recentChatSessions .filter { !Self.isHiddenInternalSession($0.key) } - .prefix(4) .map { session in - 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 ? "current" : "recent", - trailing: "chat", - color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok, - progress: nil, - route: .chat(session.key)) + Self.sessionWorkItem(for: session, currentSessionKey: currentSessionKey) } } - private var defaultSessionItems: [WorkItem] { - [ - WorkItem( - id: "main-chat", - icon: "bubble.left.and.text.bubble.right.fill", - title: "Main chat", - detail: self.appModel.activeAgentName, - state: self.gatewayConnected ? "ready" : "offline", - trailing: "session", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, - progress: nil, - route: .chat(self.appModel.defaultChatSessionKey)), - WorkItem( - id: "talk-mode", - icon: "waveform", - title: "Talk", - detail: self.appModel.talkMode.statusText, - state: self.appModel.talkMode.isEnabled ? "active" : "off", - trailing: "voice", - color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary, - progress: nil, - route: .settings), - WorkItem( - id: "device-capture", - icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display", - title: "Device capture", - detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools", - state: self.appModel.screenRecordActive ? "running" : "idle", - trailing: "device", - color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary, - progress: nil, - route: .settings), - WorkItem( - id: "agent-roster", - icon: "person.2.fill", - title: "Agents", - detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable", - state: self.gatewayConnected ? "online" : "offline", - trailing: "gateway", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, - progress: nil, - route: .settings), - ] - } - private func open(_ route: WorkRoute) { switch route { case let .chat(sessionKey): @@ -546,23 +500,23 @@ struct CommandCenterTab: View { } } - private func refreshActiveSessionsIfNeeded() async { + private func refreshRecentSessionsIfNeeded() async { guard self.scenePhase == .active else { return } guard self.appModel.isOperatorGatewayConnected else { - if !self.activeChatSessions.isEmpty { - self.activeChatSessions = [] + if !self.recentChatSessions.isEmpty { + self.recentChatSessions = [] } return } do { let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession) - let response = try await transport.listSessions(limit: 12) - self.activeChatSessions = Self.sessionChoices( + let response = try await transport.listSessions(limit: 20) + self.recentChatSessions = Self.sessionChoices( response.sessions, currentSessionKey: self.appModel.chatSessionKey) } catch { - self.activeChatSessions = [] + self.recentChatSessions = [] } } @@ -590,7 +544,24 @@ struct CommandCenterTab: View { return result } - private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String { + fileprivate 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 ? "current" : "recent", + trailing: "chat", + color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok, + progress: nil, + route: .chat(session.key)) + } + + fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String { if let title = redactedSessionTitle(for: session.key) { return title } @@ -606,7 +577,7 @@ struct CommandCenterTab: View { return session.key } - private static func redactedSessionTitle(for key: String) -> String? { + fileprivate static func redactedSessionTitle(for key: String) -> String? { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) let lowercased = trimmed.lowercased() guard !trimmed.isEmpty else { return nil } @@ -625,7 +596,7 @@ struct CommandCenterTab: View { return nil } - private static func humanizedSessionKey(_ key: String) -> String? { + fileprivate static func humanizedSessionKey(_ key: String) -> String? { let words = key .replacingOccurrences(of: "_", with: "-") .split(separator: "-") @@ -645,14 +616,14 @@ struct CommandCenterTab: View { .joined(separator: " ") } - private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String { + fileprivate static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String { if let updatedAt = session.updatedAt, updatedAt > 0 { return self.relativeTimeText(forMilliseconds: updatedAt) } return session.key } - private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String { + fileprivate static func relativeTimeText(forMilliseconds milliseconds: Double) -> String { let date = Date(timeIntervalSince1970: milliseconds / 1000) let formatter = RelativeDateTimeFormatter() formatter.dateTimeStyle = .numeric @@ -660,7 +631,7 @@ struct CommandCenterTab: View { return formatter.localizedString(for: date, relativeTo: .now) } - private static func isHiddenInternalSession(_ key: String) -> Bool { + fileprivate static func isHiddenInternalSession(_ key: String) -> Bool { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding") @@ -690,3 +661,151 @@ struct CommandCenterTab: View { return trimmed.isEmpty ? nil : trimmed } } + +private 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? + let openChat: () -> Void + + var body: some View { + ZStack { + CommandControlBackground() + ScrollView { + VStack(alignment: .leading, spacing: 10) { + self.header + self.sessionsPanel + } + .padding(.top, 16) + .padding(.bottom, 18) + } + .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) + } + .navigationTitle("Sessions") + .navigationBarTitleDisplayMode(.inline) + .task(id: self.refreshID) { + await self.refreshSessions() + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Sessions") + .font(.system(size: 27, weight: .bold, design: .rounded)) + Text(self.headerDetail) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var sessionsPanel: some View { + CommandPanel(padding: 0) { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("Recent sessions") + .font(.subheadline.weight(.bold)) + Spacer(minLength: 8) + if self.isLoading { + ProgressView() + .controlSize(.small) + } + } + .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.sessionRows.isEmpty { + CommandEmptyStateRow( + icon: self.appModel + .isOperatorGatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash", + title: self.appModel.isOperatorGatewayConnected ? "No recent sessions" : "Gateway offline", + detail: self.appModel + .isOperatorGatewayConnected ? "Start a chat and it will appear here." : + "Connect to the gateway.") + .padding(.horizontal, 10) + .padding(.bottom, 10) + } else { + VStack(spacing: 8) { + ForEach(self.sessionRows) { item in + Button { + self.open(item) + } label: { + CommandSessionRow(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 10) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var headerDetail: String { + if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" } + let count = self.sessionRows.count + if count == 0 { + return self.appModel.isOperatorGatewayConnected ? "No recent sessions" : "Gateway offline" + } + return "\(count) \(count == 1 ? "session" : "sessions")" + } + + private var sessionRows: [CommandCenterTab.WorkItem] { + self.sessions + .filter { !CommandCenterTab.isHiddenInternalSession($0.key) } + .sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + .map { + CommandCenterTab.sessionWorkItem( + for: $0, + currentSessionKey: self.appModel.chatSessionKey) + } + } + + private var refreshID: String { + self.appModel.isOperatorGatewayConnected ? "connected" : "offline" + } + + private func open(_ item: CommandCenterTab.WorkItem) { + switch item.route { + case let .chat(sessionKey): + self.appModel.openChat(sessionKey: sessionKey) + self.dismiss() + self.openChat() + case .settings: + break + } + } + + private func refreshSessions() async { + guard self.appModel.isOperatorGatewayConnected else { + self.sessions = [] + self.loadErrorText = nil + return + } + + self.isLoading = true + self.loadErrorText = nil + defer { self.isLoading = false } + + do { + let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession) + let response = try await transport.listSessions(limit: 200) + self.sessions = response.sessions + } catch { + self.sessions = [] + self.loadErrorText = "Try again after the gateway reconnects." + } + } +}