From 160e2baf495bb6945d65d217ba85bd2a85f927ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 23:23:37 -0700 Subject: [PATCH] fix(ios): page session rosters and restore approval guidance (#112004) * fix(ios): page session rosters and restore approval guidance * test(ios): align lifecycle guard and native locale inventory * fix(ios): bound advancing session roster scans --- apps/.i18n/native-source.json | 2 + .../ios/Sources/Design/CommandCenterTab.swift | 14 +- apps/ios/Sources/Design/SettingsProTab.swift | 6 +- .../Design/SettingsProTabSections.swift | 2 +- apps/ios/Sources/RootSidebarModel.swift | 165 ++++++++++++++++-- .../ios/Tests/RootTabsPresentationTests.swift | 133 ++++++++++++++ apps/ios/Tests/RootTabsSourceGuardTests.swift | 8 +- 7 files changed, 310 insertions(+), 20 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 0ede233bd9c5..dff577c9be61 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -3591,6 +3591,7 @@ {"id":"native.apple.6f167dc8a11d0c47","source":"Show reasoning & tool activity","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/ChatProTab.swift"}]}, {"id":"native.apple.c9009c9a9599a439","source":"Show talk transcript","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTalkActivityViews.swift"}]}, {"id":"native.apple.7a36d1dbcf3ee77c","source":"Show the Debug page with development utilities.","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/macos/Sources/OpenClaw/GeneralSettings.swift"}]}, + {"id":"native.apple.c0998dff769c3504","source":"Showing %lld of %lld sessions. Refresh to load the rest.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootSidebarModel.swift"}]}, {"id":"native.apple.8b7ad79ccffd6b4e","source":"Shows the full gateway status label","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/ChatProTab.swift"}]}, {"id":"native.apple.2fb7291439473673","source":"Sidebar Agents","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/SettingsProTabSections.swift"}]}, {"id":"native.apple.0ad29d33b340a6f4","source":"Sifting","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift"}]}, @@ -3621,6 +3622,7 @@ {"id":"native.apple.80006987fdb875be","source":"Skipping…","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift"}]}, {"id":"native.apple.10f82d82056b15dc","source":"Snapping","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift"}]}, {"id":"native.apple.820504301d33764a","source":"Some approvals need renewal","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift"}]}, + {"id":"native.apple.7e25b2a702a07140","source":"Some sessions could not be loaded. Refresh to try again.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootSidebarModel.swift"}]}, {"id":"native.apple.9bd57456b80dbeeb","source":"Sounds","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift"}]}, {"id":"native.apple.649e6748080b4252","source":"Source","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/ios/Sources/Design/AgentProTab+Skills.swift"}]}, {"id":"native.apple.39f6d32204cc5d60","source":"Speak failed: %@","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Voice/TalkModeManager.swift"}]}, diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index 952902b446ff..aadf55a5f06b 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -123,13 +123,13 @@ struct CommandCenterTab: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) { self.threadTile( title: String(localized: "Sessions"), - value: self.overviewSessions.count.formatted()) + value: self.overviewCountText(self.overviewSessions.count)) self.threadTile( title: String(localized: "Live"), - value: self.overviewLiveCount.formatted()) + value: self.overviewCountText(self.overviewLiveCount)) self.threadTile( title: String(localized: "Unread"), - value: self.overviewUnreadCount.formatted()) + value: self.overviewCountText(self.overviewUnreadCount)) self.threadTile( title: String(localized: "Tokens"), value: self.overviewTokenText) @@ -282,8 +282,14 @@ struct CommandCenterTab: View { self.overviewSessions.count { $0.unread == true } } + private func overviewCountText(_ count: Int) -> String { + "\(count.formatted())\(self.dashboardModel?.isSessionRosterComplete == false ? "+" : "")" + } + private var overviewTokenText: String { - let summary = RootSidebarModel.tokenUsageSummary(for: self.overviewSessions) + let summary = RootSidebarModel.tokenUsageSummary( + for: self.overviewSessions, + rosterIsComplete: self.dashboardModel?.isSessionRosterComplete ?? true) guard let total = summary.total else { return "n/a" } return "\(summary.isPartial ? "~" : "")\(total.formatted(.number.notation(.compactName)))" } diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index 45151eb10cc2..40f71861f8c2 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -360,8 +360,12 @@ struct SettingsProTab: View { self.onGatewaySetupRequestHandled?(gatewaySetupRequest.id) } + var canOpenNotificationsRouteFromApprovals: Bool { + self.ownsNavigationStack ? self.directRoute == nil : self.navigateToRoute != nil + } + func openNotificationsRouteFromApprovals() { - guard self.directRoute == nil else { return } + guard self.canOpenNotificationsRouteFromApprovals else { return } if let approvalID = ExecApprovalIdentifier.exact(self.appModel.pendingExecApprovalPrompt?.id) { self.onApprovalNotificationsRoute?(approvalID) } diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 8ec513055fe7..bb5aa21dcc66 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -544,7 +544,7 @@ extension SettingsProTab { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - if self.directRoute == nil { + if self.canOpenNotificationsRouteFromApprovals { Button { self.openNotificationsRouteFromApprovals() } label: { diff --git a/apps/ios/Sources/RootSidebarModel.swift b/apps/ios/Sources/RootSidebarModel.swift index 6cee84ff7bf8..e762dafe9ab2 100644 --- a/apps/ios/Sources/RootSidebarModel.swift +++ b/apps/ios/Sources/RootSidebarModel.swift @@ -5,8 +5,87 @@ import OpenClawKit import OpenClawProtocol struct ChatSessionRosterSnapshot: Sendable { + private static let maximumPageCount = 50 + private static let maximumSessionCount = 10000 + let sessions: [OpenClawChatSessionEntry] let isCached: Bool + let totalCount: Int? + let isComplete: Bool + + init( + sessions: [OpenClawChatSessionEntry], + isCached: Bool, + totalCount: Int? = nil, + isComplete: Bool = true) + { + self.sessions = sessions + self.isCached = isCached + self.totalCount = totalCount + self.isComplete = isComplete + } + + @MainActor + static func collect( + fetchPage: @MainActor (Int) async throws -> OpenClawChatSessionsListResponse) async throws -> Self + { + var sessions: [OpenClawChatSessionEntry] = [] + var rowIndices: [String: Int] = [:] + var totalCount: Int? + var offset = 0 + var pageCount = 0 + + while true { + try Task.checkCancellation() + // Match sessions.list's bounded scan so a changing Gateway snapshot + // cannot keep a sidebar refresh alive forever. + guard pageCount < Self.maximumPageCount, sessions.count < Self.maximumSessionCount else { + return Self(sessions: sessions, isCached: false, totalCount: totalCount, isComplete: false) + } + pageCount += 1 + let response: OpenClawChatSessionsListResponse + do { + response = try await fetchPage(offset) + } catch is CancellationError { + throw CancellationError() + } catch { + guard !sessions.isEmpty else { throw error } + return Self(sessions: sessions, isCached: false, totalCount: totalCount, isComplete: false) + } + try Task.checkCancellation() + + if let count = response.totalCount { + totalCount = count + } + for session in response.sessions { + if let index = rowIndices[session.key] { + sessions[index] = session + } else { + guard sessions.count < Self.maximumSessionCount else { + return Self(sessions: sessions, isCached: false, totalCount: totalCount, isComplete: false) + } + rowIndices[session.key] = sessions.count + sessions.append(session) + } + } + + let advancedOffset = offset + response.sessions.count + let hasMore = response.hasMore ?? totalCount.map { advancedOffset < $0 } ?? false + guard hasMore else { + let isComplete = totalCount.map { sessions.count >= $0 } ?? true + return Self(sessions: sessions, isCached: false, totalCount: totalCount, isComplete: isComplete) + } + + let nextOffset = response.nextOffset ?? advancedOffset + guard !response.sessions.isEmpty, + nextOffset > offset, + totalCount.map({ nextOffset < $0 }) ?? true + else { + return Self(sessions: sessions, isCached: false, totalCount: totalCount, isComplete: false) + } + offset = nextOffset + } + } } extension NodeAppModel { @@ -19,21 +98,61 @@ extension NodeAppModel { guard allowCachedFallback else { throw URLError(.notConnectedToInternet) } return await ChatSessionRosterSnapshot( sessions: archived ? [] : self.loadCachedChatSessions(), - isCached: true) + isCached: true, + isComplete: false) } do { - let response = try await self.makeChatTransport().listSessions(limit: limit, archived: archived) - if !archived { - self.reconcileChatSessionReadState(response.sessions) - await self.storeCachedChatSessions(response.sessions) + let sourceGatewayID = self.chatTranscriptCacheGatewayID + let snapshot: ChatSessionRosterSnapshot + if self.isLocalChatFixtureEnabled { + let response = try await self.makeChatTransport().listSessions(limit: limit, archived: archived) + snapshot = ChatSessionRosterSnapshot( + sessions: response.sessions, + isCached: false, + totalCount: response.totalCount, + isComplete: response.hasMore != true) + } else { + guard let sourceGatewayID, + let route = await self.operatorSession.currentRoute(ifGatewayID: sourceGatewayID) + else { throw URLError(.notConnectedToInternet) } + + // Every page belongs to one physical authenticated Gateway route; + // reconnects and gateway switches must never splice two rosters. + snapshot = try await ChatSessionRosterSnapshot.collect { offset in + let request = OpenClawChatGatewayRequests.sessionsList( + limit: limit, + search: nil, + archived: archived, + offset: offset) + let data = try await self.operatorSession.request(request, ifCurrentRoute: route) + return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) + } + guard GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID) else { + throw CancellationError() + } } - return ChatSessionRosterSnapshot(sessions: response.sessions, isCached: false) + + if !archived { + self.reconcileChatSessionReadState(snapshot.sessions) + // An interrupted page must not replace a more complete offline roster. + if snapshot.isComplete { + await self.storeCachedChatSessions(snapshot.sessions) + if let sourceGatewayID, + !GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID) + { + throw CancellationError() + } + } + } + return snapshot + } catch is CancellationError { + throw CancellationError() } catch { guard allowCachedFallback, !archived else { throw error } let cached = await self.loadCachedChatSessions() guard !cached.isEmpty else { throw error } - return ChatSessionRosterSnapshot(sessions: cached, isCached: true) + return ChatSessionRosterSnapshot(sessions: cached, isCached: true, isComplete: false) } } } @@ -59,6 +178,7 @@ final class RootSidebarModel { private(set) var cronJobs: [CronJob] = [] private(set) var isRefreshing = false private(set) var sessionErrorText: String? + private(set) var isSessionRosterComplete = true private var rosterGeneration = 0 private var dashboardGeneration = 0 private var sessionObserverVisibility = false @@ -113,8 +233,7 @@ final class RootSidebarModel { if rosterGeneration == self.rosterGeneration { switch loadedRoster { case let .success(loadedRoster): - self.sessions = loadedRoster.sessions - self.sessionErrorText = nil + self.applyRoster(loadedRoster) case let .failure(message): self.sessionErrorText = message case .cancelled: @@ -147,8 +266,7 @@ final class RootSidebarModel { guard !Task.isCancelled, rosterGeneration == self.rosterGeneration else { return } switch loadedRoster { case let .success(roster): - self.sessions = roster.sessions - self.sessionErrorText = nil + self.applyRoster(roster) case let .failure(message): self.sessionErrorText = message case .cancelled: @@ -357,11 +475,32 @@ final class RootSidebarModel { self.sessionErrorText = error.localizedDescription } - static func tokenUsageSummary(for sessions: [OpenClawChatSessionEntry]) -> TokenUsageSummary { + static func tokenUsageSummary( + for sessions: [OpenClawChatSessionEntry], + rosterIsComplete: Bool = true) -> TokenUsageSummary + { let knownTotals = sessions.compactMap(\.totalTokens) return TokenUsageSummary( total: knownTotals.isEmpty ? nil : knownTotals.reduce(0, +), - isPartial: knownTotals.count < sessions.count || sessions.contains { $0.totalTokensFresh == false }) + isPartial: !rosterIsComplete || knownTotals.count < sessions.count || + sessions.contains { $0.totalTokensFresh == false }) + } + + private func applyRoster(_ roster: ChatSessionRosterSnapshot) { + self.sessions = roster.sessions + self.isSessionRosterComplete = roster.isComplete + guard !roster.isComplete, !roster.isCached else { + self.sessionErrorText = nil + return + } + if let totalCount = roster.totalCount { + self.sessionErrorText = String( + format: String(localized: "Showing %lld of %lld sessions. Refresh to load the rest."), + roster.sessions.count, + totalCount) + } else { + self.sessionErrorText = String(localized: "Some sessions could not be loaded. Refresh to try again.") + } } private func loadRoster( diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index 6d9eee157e5f..f8ea5368dbec 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -71,6 +71,9 @@ struct RootTabsPresentationTests { Self.sessionEntry(key: "two", totalTokens: 80), ]) let unknown = RootSidebarModel.tokenUsageSummary(for: [Self.sessionEntry(key: "unknown")]) + let incompleteRoster = RootSidebarModel.tokenUsageSummary( + for: [Self.sessionEntry(key: "known", totalTokens: 45, totalTokensFresh: true)], + rosterIsComplete: false) #expect(summary.total == 1500) #expect(summary.isPartial) @@ -78,6 +81,122 @@ struct RootTabsPresentationTests { #expect(!complete.isPartial) #expect(unknown.total == nil) #expect(unknown.isPartial) + #expect(incompleteRoster.total == 45) + #expect(incompleteRoster.isPartial) + } + + @Test func `session roster loads every gateway page beyond the former thousand-row ceiling`() async throws { + let entries = (0..<1205).map { Self.sessionEntry(key: "session-\($0)") } + var offsets: [Int] = [] + + let snapshot = try await ChatSessionRosterSnapshot.collect { offset in + offsets.append(offset) + let page = Array(entries.dropFirst(offset).prefix(200)) + let nextOffset = offset + page.count + return OpenClawChatSessionsListResponse( + ts: nil, + path: nil, + count: page.count, + totalCount: entries.count, + offset: offset, + nextOffset: nextOffset < entries.count ? nextOffset : nil, + hasMore: nextOffset < entries.count, + defaults: nil, + sessions: page) + } + + #expect(offsets == [0, 200, 400, 600, 800, 1000, 1200]) + #expect(snapshot.sessions.count == 1205) + #expect(snapshot.sessions.last?.key == "session-1204") + #expect(snapshot.totalCount == 1205) + #expect(snapshot.isComplete) + } + + @Test func `session roster bounds an endlessly advancing gateway snapshot without dropping rows`() async throws { + var requestCount = 0 + + let snapshot = try await ChatSessionRosterSnapshot.collect { offset in + requestCount += 1 + guard requestCount <= 51 else { throw URLError(.networkConnectionLost) } + let sessions = (offset..<(offset + 200)).map { Self.sessionEntry(key: "session-\($0)") } + return OpenClawChatSessionsListResponse( + ts: nil, + path: nil, + count: sessions.count, + totalCount: offset + 400, + offset: offset, + nextOffset: offset + sessions.count, + hasMore: true, + defaults: nil, + sessions: sessions) + } + + #expect(requestCount == 50) + #expect(snapshot.sessions.count == 10000) + #expect(snapshot.sessions.last?.key == "session-9999") + #expect(!snapshot.isComplete) + } + + @Test func `session roster preserves successful pages when a later request fails`() async throws { + let firstPage = (0..<200).map { Self.sessionEntry(key: "session-\($0)") } + + let snapshot = try await ChatSessionRosterSnapshot.collect { offset in + guard offset == 0 else { throw URLError(.networkConnectionLost) } + return OpenClawChatSessionsListResponse( + ts: nil, + path: nil, + count: firstPage.count, + totalCount: 350, + offset: offset, + nextOffset: firstPage.count, + hasMore: true, + defaults: nil, + sessions: firstPage) + } + + #expect(snapshot.sessions.count == 200) + #expect(snapshot.totalCount == 350) + #expect(!snapshot.isComplete) + } + + @Test func `session roster rejects a nonadvancing gateway cursor without losing rows`() async throws { + var requestCount = 0 + + let snapshot = try await ChatSessionRosterSnapshot.collect { offset in + requestCount += 1 + return OpenClawChatSessionsListResponse( + ts: nil, + path: nil, + count: 1, + totalCount: 2, + offset: offset, + nextOffset: offset, + hasMore: true, + defaults: nil, + sessions: [Self.sessionEntry(key: "first")]) + } + + #expect(requestCount == 1) + #expect(snapshot.sessions.map(\.key) == ["first"]) + #expect(!snapshot.isComplete) + } + + @Test func `session roster propagates cancellation after a successful page`() async { + await #expect(throws: CancellationError.self) { + _ = try await ChatSessionRosterSnapshot.collect { offset in + guard offset == 0 else { throw CancellationError() } + return OpenClawChatSessionsListResponse( + ts: nil, + path: nil, + count: 1, + totalCount: 2, + offset: offset, + nextOffset: 1, + hasMore: true, + defaults: nil, + sessions: [Self.sessionEntry(key: "first")]) + } + } } @Test func `usage list shows the latest fourteen days newest first`() { @@ -543,6 +662,20 @@ struct RootTabsPresentationTests { #expect(!embedded.ownsNavigationStack) } + @Test func `direct approvals exposes notification remediation only when a navigation owner exists`() { + let routedApprovals = SettingsProTab( + directRoute: .approvals, + ownsNavigationStack: false, + navigateToRoute: { _ in }) + let isolatedApprovals = SettingsProTab(directRoute: .approvals) + let unroutedEmbeddedSettings = SettingsProTab(ownsNavigationStack: false) + + #expect(routedApprovals.canOpenNotificationsRouteFromApprovals) + #expect(!isolatedApprovals.canOpenNotificationsRouteFromApprovals) + #expect(!unroutedEmbeddedSettings.canOpenNotificationsRouteFromApprovals) + #expect(SettingsProTab().canOpenNotificationsRouteFromApprovals) + } + @Test func `localized QR status matcher accepts positional placeholders`() { #expect(SettingsProTab.localizedFormat( "qr loaded. connecting to %1$@:%2$@...", diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index ecb17333b9c9..849951b9874e 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -165,9 +165,15 @@ struct RootTabsSourceGuardTests { source, from: "func refreshSessions(appModel: NodeAppModel) async {", to: "func reportSessionError(_ error: any Error) {") - let rosterCommit = try #require(refresh.range(of: "self.sessions = loadedRoster.sessions")) + let rosterOwner = try Self.extract( + source, + from: "private func applyRoster(_ roster: ChatSessionRosterSnapshot) {", + to: "private func loadRoster(") + let rosterCommit = try #require(refresh.range(of: "self.applyRoster(loadedRoster)")) let dashboardWait = try #require(refresh.range(of: "let loadedDashboard = await dashboard")) + #expect(rosterOwner.contains("self.sessions = roster.sessions")) + #expect(rosterOwner.contains("self.isSessionRosterComplete = roster.isComplete")) #expect(source.contains("private var rosterGeneration = 0")) #expect(source.contains("private var dashboardGeneration = 0")) #expect(source.matches(of: /self\.rosterGeneration &\+= 1/).count == 2)