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
This commit is contained in:
Peter Steinberger
2026-08-25 23:23:37 -07:00
committed by GitHub
parent 2e50bdf9fb
commit 160e2baf49
7 changed files with 310 additions and 20 deletions
+2
View File
@@ -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"}]},
+10 -4
View File
@@ -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)))"
}
+5 -1
View File
@@ -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)
}
@@ -544,7 +544,7 @@ extension SettingsProTab {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if self.directRoute == nil {
if self.canOpenNotificationsRouteFromApprovals {
Button {
self.openNotificationsRouteFromApprovals()
} label: {
+152 -13
View File
@@ -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(
@@ -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$@...",
@@ -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)