From 099233325192bc94280a6437975ae7d7e55f9697 Mon Sep 17 00:00:00 2001 From: DatScreamer Date: Sat, 15 Aug 2026 06:14:43 -0600 Subject: [PATCH 01/20] ios: paginate older turns with infinite scroll Replace the explicit Load-earlier-messages button with scroll-triggered pagination. Track visible turn IDs and prefetch the next older page as the user approaches the top, debounced per cursor so concurrent prefetches collapse. Bump page size to 20, make loadOlderTurns report whether a page was actually loaded, and re-route the Latest button through a semantic ScrollPosition with a UIKit scroll-view resolver so the jump executes even while momentum is decelerating. Anchor merged exploration rows to their newest constituent turn so prepending older pages keeps scroll identity stable, and auto-collapse large conversations past a 200-item threshold. --- apps/ios/Litter.xcodeproj/project.pbxproj | 6 +- apps/ios/Sources/Litter/Models/AppModel.swift | 22 +- .../Litter/Views/ConversationView.swift | 246 +++++++++++++++--- .../Sources/Litter/Views/SettingsView.swift | 2 +- .../Sources/Litter/Views/TranscriptTurn.swift | 42 ++- .../LitterTests/PerformanceHelpersTests.swift | 98 +++++++ 6 files changed, 359 insertions(+), 57 deletions(-) diff --git a/apps/ios/Litter.xcodeproj/project.pbxproj b/apps/ios/Litter.xcodeproj/project.pbxproj index a096d9d74..8d0b6b7b0 100644 --- a/apps/ios/Litter.xcodeproj/project.pbxproj +++ b/apps/ios/Litter.xcodeproj/project.pbxproj @@ -1934,12 +1934,12 @@ projectRoot = ""; targets = ( A3D84F588EDBC1EA550D6691 /* Litter */, - 4D58398238C075D3A3A3AB9B /* LitterLiveActivity */, 6EBBC661C017816B2D4245DF /* LitterMac */, - DA07A1EAC6173D09F6FB99D9 /* LitterTests */, - 7BB0106D190F10717D781234 /* LitterUITests */, + 4D58398238C075D3A3A3AB9B /* LitterLiveActivity */, 3B991C03E72B1546CAD35698 /* LitterWatch */, C0C98C6012493B73CDD5AD6E /* LitterWatchComplications */, + DA07A1EAC6173D09F6FB99D9 /* LitterTests */, + 7BB0106D190F10717D781234 /* LitterUITests */, ); }; /* End PBXProject section */ diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index 350caa4ea..f145b61f8 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -2249,10 +2249,6 @@ final class AppModel { return nil } - // A page of 5 meant tapping "Load earlier" dozens of times to get back - // through a real conversation (#306). Raising it is only safe now that - // c153d1e5 makes `include_turns=false` authoritative, so a metadata read - // can no longer smuggle in the full archive on top of the page. private static let initialTurnPageSize: UInt32 = 20 private static let olderTurnPageSize: UInt32 = 20 @@ -2260,7 +2256,7 @@ final class AppModel { /// is still false. Called after a resume that sent `exclude_turns: true` /// against a v0.125+ server. func loadInitialTurns(threadId key: ThreadKey) async { - await loadTurnPage(key: key, cursor: nil, limit: Self.initialTurnPageSize) + _ = await loadTurnPage(key: key, cursor: nil, limit: Self.initialTurnPageSize) } func loadInitialTurnsIfNeeded(threadId key: ThreadKey) async { @@ -2271,18 +2267,18 @@ final class AppModel { } /// Fetch the next older page of turns using the thread's current cursor. - /// No-op when no cursor is available (older-turns button should be hidden - /// in that case). - func loadOlderTurns(threadId key: ThreadKey) async { + /// No-op when the loaded history cache has reached the start of the + /// server-side session and no cursor remains. + func loadOlderTurns(threadId key: ThreadKey) async -> Bool { guard let cursor = threadSnapshot(for: key)?.olderTurnsCursor, !cursor.isEmpty else { - return + return false } - await loadTurnPage(key: key, cursor: cursor, limit: Self.olderTurnPageSize) + return await loadTurnPage(key: key, cursor: cursor, limit: Self.olderTurnPageSize) } - private func loadTurnPage(key: ThreadKey, cursor: String?, limit: UInt32) async { - if loadingTurnPageThreadKeys.contains(key) { return } + private func loadTurnPage(key: ThreadKey, cursor: String?, limit: UInt32) async -> Bool { + if loadingTurnPageThreadKeys.contains(key) { return false } loadingTurnPageThreadKeys.insert(key) defer { loadingTurnPageThreadKeys.remove(key) } @@ -2292,8 +2288,10 @@ final class AppModel { cursor: cursor, limit: limit ) + return true } catch { lastError = error.localizedDescription + return false } } diff --git a/apps/ios/Sources/Litter/Views/ConversationView.swift b/apps/ios/Sources/Litter/Views/ConversationView.swift index b7e978911..b59ee453c 100644 --- a/apps/ios/Sources/Litter/Views/ConversationView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationView.swift @@ -127,7 +127,7 @@ struct ConversationView: View { onForkFromUserItem: forkFromMessage, onOpenConversation: onOpenConversation, onLoadOlderTurns: { key in - Task { await appModel.loadOlderTurns(threadId: key) } + await appModel.loadOlderTurns(threadId: key) } ) .overlay(alignment: .bottomLeading) { @@ -606,7 +606,7 @@ private struct ConversationMessageList: View { let onEditUserItem: (ConversationItem) -> Void let onForkFromUserItem: (ConversationItem) -> Void var onOpenConversation: ((ThreadKey) -> Void)? = nil - let onLoadOlderTurns: (ThreadKey) -> Void + let onLoadOlderTurns: (ThreadKey) async -> Bool @State private var isNearBottom = true @State private var autoFollowStreaming = true @State private var userIsDraggingScroll = false @@ -625,6 +625,12 @@ private struct ConversationMessageList: View { @State private var initialBottomScrollThreadScopeID: String? @State private var programmaticBottomScrollSettling = false @State private var programmaticBottomScrollGeneration = 0 + @State private var scrollPosition = ScrollPosition(idType: String.self) + @State private var nativeScrollView: UIScrollView? + @State private var visibleTurnIDs: [String] = [] + @State private var requestedOlderTurnsCursor: String? + @State private var requestedOlderTurnsThreadKey: ThreadKey? + @State private var showOlderPageLoader = false @AppStorage("collapseTurns") private var collapseTurns = false private static let latestButtonShowDistance: CGFloat = 48 private static let nearBottomRestoreDistance: CGFloat = 12 @@ -632,8 +638,18 @@ private struct ConversationMessageList: View { private static let bottomAnchorID = "conversation-message-list-bottom" private static let scrollCoordinateSpaceName = "conversation-message-list-scroll" + private var shouldCollapseTurns: Bool { + ConversationTurnCollapsePolicy.shouldCollapse( + preferenceEnabled: collapseTurns, + itemCount: items.count + ) + } + private var expandedRecentTurnCount: Int { - return collapseTurns ? 1 : .max + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: collapseTurns, + itemCount: items.count + ) } private var sourceTurns: [TranscriptTurn] { @@ -697,22 +713,6 @@ private struct ConversationMessageList: View { ScrollView { VStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 10) { - if !initialTurnsLoaded && hasOlderTurns && !turns.isEmpty { - ConversationLoadingIndicator(label: "Loading earlier messages...") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else if hasOlderTurns { - Button { - onLoadOlderTurns(activeThreadKey) - } label: { - Text("Load earlier messages") - .litterFont(.caption, weight: .semibold) - .foregroundColor(LitterTheme.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - } ForEach(turns) { turn in let isLastTurn = turn.id == lastTurnID ConversationTurnRow( @@ -749,7 +749,12 @@ private struct ConversationMessageList: View { .equatable() .turnDebugOverlay(turnId: turn.id) } + + Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) } + .scrollTargetLayout() .frame(maxWidth: LitterPlatform.isRegularSurface(horizontalSizeClass: horizontalSizeClass) ? 760 : .infinity) .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 16) @@ -766,16 +771,24 @@ private struct ConversationMessageList: View { .padding(.top, 40) } - Color.clear - .frame(height: 1) - .id(Self.bottomAnchorID) - .padding(.horizontal, 16) } .frame(maxWidth: .infinity, minHeight: viewport.size.height, alignment: .top) + .background( + ConversationScrollViewResolver(scrollView: $nativeScrollView) + .allowsHitTesting(false) + ) } .id(activeThreadScopeID) .scrollIndicators(.hidden) .scrollDismissesKeyboard(.interactively) + // Keep one semantic scroll position for both user tracking and + // programmatic edge jumps. Unlike ScrollViewProxy, an edge + // command can replace active deceleration immediately. + .scrollPosition($scrollPosition, anchor: .bottom) + .onScrollTargetVisibilityChange(idType: String.self, threshold: 0.01) { turnIDs in + visibleTurnIDs = turnIDs + prefetchOlderTurnsIfNeeded(visibleTurnIDs: turnIDs, turns: turns) + } .coordinateSpace(name: Self.scrollCoordinateSpaceName) .onScrollGeometryChange(for: CGFloat.self) { geometry in max(0, geometry.contentSize.height - geometry.visibleRect.maxY) @@ -813,6 +826,11 @@ private struct ConversationMessageList: View { isNearBottom = true initialBottomScrollThreadScopeID = nil waitingForDataExpired = false + scrollPosition = ScrollPosition(idType: String.self) + visibleTurnIDs = [] + requestedOlderTurnsCursor = nil + requestedOlderTurnsThreadKey = nil + showOlderPageLoader = false syncTranscriptTurns(resetExpansion: true) StreamingRendererCoordinator.shared.reset() requestInitialBottomScrollIfNeeded(proxy) @@ -825,6 +843,18 @@ private struct ConversationMessageList: View { syncTranscriptTurns() requestInitialBottomScrollIfNeeded(proxy) } + .onChange(of: olderTurnsCursor) { oldCursor, newCursor in + guard oldCursor != newCursor else { return } + requestedOlderTurnsCursor = nil + requestedOlderTurnsThreadKey = nil + showOlderPageLoader = false + DispatchQueue.main.async { + prefetchOlderTurnsIfNeeded( + visibleTurnIDs: visibleTurnIDs, + turns: mergedRenderableTurns + ) + } + } .onChange(of: collapseTurns) { syncTranscriptTurns(resetExpansion: true) } @@ -877,6 +907,16 @@ private struct ConversationMessageList: View { .padding(.bottom, 10) .transition(.move(edge: .bottom).combined(with: .opacity)) } + + if showOlderPageLoader, hasOlderTurns { + ConversationLoadingIndicator(label: "Loading earlier messages...") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: Capsule()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, topInset + 8) + .allowsHitTesting(false) + } } } } @@ -951,18 +991,96 @@ private struct ConversationMessageList: View { } } - private func scrollToBottom(_ proxy: ScrollViewProxy) { + private func scrollToBottom(_: ScrollViewProxy) { isNearBottom = true programmaticBottomScrollGeneration &+= 1 let generation = programmaticBottomScrollGeneration programmaticBottomScrollSettling = true - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + stopMomentumAndJumpToNativeBottom() + scrollPosition.scrollTo(edge: .bottom) DispatchQueue.main.asyncAfter(deadline: .now() + Self.bottomScrollSettleDuration) { guard programmaticBottomScrollGeneration == generation else { return } programmaticBottomScrollSettling = false } } + private func stopMomentumAndJumpToNativeBottom() { + guard let nativeScrollView else { return } + + // End an in-progress pan as well as inertial deceleration. Without + // this, SwiftUI can accept a semantic scroll command and then have the + // still-running UIKit scroll animation write a newer offset over it. + let panGesture = nativeScrollView.panGestureRecognizer + if panGesture.state != .possible { + panGesture.isEnabled = false + panGesture.isEnabled = true + } + nativeScrollView.setContentOffset(nativeScrollView.contentOffset, animated: false) + + let inset = nativeScrollView.adjustedContentInset + let topOffset = -inset.top + let bottomOffset = max( + topOffset, + nativeScrollView.contentSize.height - nativeScrollView.bounds.height + inset.bottom + ) + nativeScrollView.setContentOffset( + CGPoint(x: nativeScrollView.contentOffset.x, y: bottomOffset), + animated: false + ) + } + + private func prefetchOlderTurnsIfNeeded( + visibleTurnIDs: [String], + turns: [TranscriptTurn] + ) { + guard let earliestVisibleIndex = ConversationInfiniteScrollPolicy.earliestVisibleIndex( + visibleIDs: visibleTurnIDs, + orderedIDs: turns.map(\.id) + ), earliestVisibleIndex <= ConversationInfiniteScrollPolicy.olderPrefetchDistance else { return } + + requestOlderTurnsPage(showLoaderIfCacheExhausted: earliestVisibleIndex == 0) + } + + private func requestOlderTurnsPage(showLoaderIfCacheExhausted: Bool) { + guard initialTurnsLoaded, + let cursor = olderTurnsCursor, + !cursor.isEmpty else { return } + let requestKey = activeThreadKey + + if requestedOlderTurnsCursor == cursor, + requestedOlderTurnsThreadKey == requestKey { + if showLoaderIfCacheExhausted { + scheduleOlderPageLoader(for: cursor, threadKey: requestKey) + } + return + } + + requestedOlderTurnsCursor = cursor + requestedOlderTurnsThreadKey = requestKey + if showLoaderIfCacheExhausted { + scheduleOlderPageLoader(for: cursor, threadKey: requestKey) + } + + Task { + let didLoad = await onLoadOlderTurns(requestKey) + guard !didLoad, + requestedOlderTurnsCursor == cursor, + requestedOlderTurnsThreadKey == requestKey else { return } + requestedOlderTurnsCursor = nil + requestedOlderTurnsThreadKey = nil + showOlderPageLoader = false + } + } + + private func scheduleOlderPageLoader(for cursor: String, threadKey: ThreadKey) { + Task { + try? await Task.sleep(for: .milliseconds(250)) + guard requestedOlderTurnsCursor == cursor, + requestedOlderTurnsThreadKey == threadKey else { return } + showOlderPageLoader = true + } + } + private func syncTranscriptTurns(resetExpansion: Bool = false) { let nextBuildKey = makeTranscriptBuildKey() if transcriptBuildKey == nextBuildKey, !transcriptTurns.isEmpty { @@ -1066,7 +1184,7 @@ private struct ConversationMessageList: View { to nextTurns: [TranscriptTurn], resetExpansion: Bool ) -> Bool { - guard collapseTurns, + guard shouldCollapseTurns, !resetExpansion, !currentTurns.isEmpty, nextTurns.count == currentTurns.count + 1, @@ -1377,26 +1495,76 @@ private struct CollapsedTurnMetaItem: View { } } +private struct ConversationScrollViewResolver: UIViewRepresentable { + @Binding var scrollView: UIScrollView? + + func makeUIView(context: Context) -> ResolverView { + let view = ResolverView() + view.onResolve = updateResolvedScrollView + return view + } + + func updateUIView(_ uiView: ResolverView, context: Context) { + uiView.onResolve = updateResolvedScrollView + uiView.resolveWhenAttached() + } + + private func updateResolvedScrollView(_ resolved: UIScrollView?) { + guard scrollView !== resolved else { return } + scrollView = resolved + } + + final class ResolverView: UIView { + var onResolve: ((UIScrollView?) -> Void)? + + override func didMoveToWindow() { + super.didMoveToWindow() + resolveWhenAttached() + } + + func resolveWhenAttached() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + var ancestor = superview + while let view = ancestor { + if let scrollView = view as? UIScrollView { + onResolve?(scrollView) + return + } + ancestor = view.superview + } + onResolve?(nil) + } + } + } +} + private struct ScrollToBottomIndicator: View { let action: () -> Void @State private var bob = false var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: "arrow.down") - .litterFont(.caption, weight: .bold) - .offset(y: bob ? 1.5 : -1.5) - .animation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true), value: bob) - Text("Latest") - .litterFont(.caption, weight: .semibold) - } - .foregroundColor(LitterTheme.textPrimary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .modifier(GlassCapsuleModifier()) + HStack(spacing: 8) { + Image(systemName: "arrow.down") + .litterFont(.caption, weight: .bold) + .offset(y: bob ? 1.5 : -1.5) + .animation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true), value: bob) + Text("Latest") + .litterFont(.caption, weight: .semibold) } + .foregroundColor(LitterTheme.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .modifier(GlassCapsuleModifier()) .contentShape(Capsule()) + // A normal Button tap can be consumed merely to stop an actively + // decelerating ScrollView. Give this overlay first refusal so Latest + // executes on that same tap, even while momentum is still active. + .highPriorityGesture(TapGesture().onEnded(action)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Latest") + .accessibilityAddTraits(.isButton) + .accessibilityAction { action() } .onAppear { bob = true } diff --git a/apps/ios/Sources/Litter/Views/SettingsView.swift b/apps/ios/Sources/Litter/Views/SettingsView.swift index ecc969d2f..f50b960b8 100644 --- a/apps/ios/Sources/Litter/Views/SettingsView.swift +++ b/apps/ios/Sources/Litter/Views/SettingsView.swift @@ -170,7 +170,7 @@ struct SettingsView: View { Text("Collapse Turns") .litterFont(.subheadline) .foregroundColor(LitterTheme.textPrimary) - Text("Collapse previous turns into cards") + Text("Collapse previous turns into cards; large conversations collapse automatically") .litterFont(.caption) .foregroundColor(LitterTheme.textSecondary) } diff --git a/apps/ios/Sources/Litter/Views/TranscriptTurn.swift b/apps/ios/Sources/Litter/Views/TranscriptTurn.swift index aafc029e5..a627abdbe 100644 --- a/apps/ios/Sources/Litter/Views/TranscriptTurn.swift +++ b/apps/ios/Sources/Litter/Views/TranscriptTurn.swift @@ -1,5 +1,40 @@ import Foundation +enum ConversationTurnCollapsePolicy { + /// Rendering every message/tool view in a large restored page can exhaust + /// SwiftUI's layout budget and leave the conversation surface blank. + static let automaticItemThreshold = 200 + + static func shouldCollapse( + preferenceEnabled: Bool, + itemCount: Int + ) -> Bool { + preferenceEnabled || itemCount >= automaticItemThreshold + } + + static func expandedRecentTurnCount( + preferenceEnabled: Bool, + itemCount: Int + ) -> Int { + shouldCollapse(preferenceEnabled: preferenceEnabled, itemCount: itemCount) ? 1 : .max + } +} + +enum ConversationInfiniteScrollPolicy { + static let olderPrefetchDistance = 6 + + static func earliestVisibleIndex( + visibleIDs: [String], + orderedIDs: [String] + ) -> Int? { + guard !visibleIDs.isEmpty else { return nil } + let indicesByID = Dictionary( + uniqueKeysWithValues: orderedIDs.enumerated().map { ($1, $0) } + ) + return visibleIDs.compactMap { indicesByID[$0] }.min() + } +} + struct TranscriptTurn: Identifiable, Equatable { private static let collapsedExcerptLimit = 180 @@ -207,11 +242,14 @@ struct TranscriptTurn: Identifiable, Equatable { } private static func mergedExplorationTurn(from turns: [TranscriptTurn]) -> TranscriptTurn? { - guard let first = turns.first else { return nil } + guard let last = turns.last else { return nil } let items = turns.flatMap(\.items) let isLive = turns.contains(where: \.isLive) return TranscriptTurn( - id: "exploration-turn-\(first.id)", + // Anchor the merged row to its newest constituent turn. Older + // pages prepend to this run, so its identity remains stable and + // SwiftUI can preserve the visible scroll position. + id: "exploration-turn-\(last.id)", items: items, isLive: isLive, isCollapsedByDefault: turns.allSatisfy(\.isCollapsedByDefault), diff --git a/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift b/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift index 1b5b60b57..1b95fe9c7 100644 --- a/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift +++ b/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift @@ -3,6 +3,51 @@ import XCTest @MainActor final class PerformanceHelpersTests: XCTestCase { + func testLargeConversationAutomaticallyCollapsesOlderTurns() { + let threshold = ConversationTurnCollapsePolicy.automaticItemThreshold + + XCTAssertEqual( + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: false, + itemCount: threshold - 1 + ), + .max + ) + XCTAssertEqual( + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: false, + itemCount: threshold + ), + 1 + ) + XCTAssertEqual( + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: true, + itemCount: 1 + ), + 1 + ) + } + + func testInfiniteScrollPrefetchesBeforeTheVisibleCacheEdge() { + let orderedIDs = (0..<20).map { "turn-\($0)" } + + XCTAssertEqual( + ConversationInfiniteScrollPolicy.earliestVisibleIndex( + visibleIDs: ["turn-8", "turn-9", "turn-10"], + orderedIDs: orderedIDs + ), + 8 + ) + XCTAssertEqual( + ConversationInfiniteScrollPolicy.earliestVisibleIndex( + visibleIDs: ["turn-4", "turn-5", "turn-6"], + orderedIDs: orderedIDs + ), + 4 + ) + } + func testTranscriptTurnBuilderCollapsesPreviousTurnOnceANewLiveTurnStarts() { let baseTime = Date(timeIntervalSince1970: 100) let turns = TranscriptTurn.build( @@ -197,6 +242,59 @@ final class PerformanceHelpersTests: XCTestCase { XCTAssertEqual(merged[2].items.count, 1) } + func testExplorationMergeKeepsItsIdentityWhenOlderTurnsArePrepended() { + let baseTime = Date(timeIntervalSince1970: 500) + let olderTurn = TranscriptTurn.build( + from: [makeExplorationItem( + command: "pwd", + actionKind: .read, + path: "/tmp", + turnId: "turn-0", + turnIndex: 0, + timestamp: baseTime + )], + threadStatus: .ready, + expandedRecentTurnCount: 1 + )[0] + let firstLoadedTurn = TranscriptTurn.build( + from: [makeExplorationItem( + command: "cat reducer.rs", + actionKind: .read, + path: "/tmp/reducer.rs", + turnId: "turn-1", + turnIndex: 1, + timestamp: baseTime.addingTimeInterval(1) + )], + threadStatus: .ready, + expandedRecentTurnCount: 1 + )[0] + let newestTurn = TranscriptTurn.build( + from: [makeExplorationItem( + command: "rg pendingSteers", + actionKind: .search, + path: "/tmp/reducer.rs", + query: "pendingSteers", + turnId: "turn-2", + turnIndex: 2, + timestamp: baseTime.addingTimeInterval(2) + )], + threadStatus: .ready, + expandedRecentTurnCount: 1 + )[0] + + let beforePrepend = TranscriptTurn.mergeConsecutiveExplorationTurnsForRendering([ + firstLoadedTurn, + newestTurn, + ]) + let afterPrepend = TranscriptTurn.mergeConsecutiveExplorationTurnsForRendering([ + olderTurn, + firstLoadedTurn, + newestTurn, + ]) + + XCTAssertEqual(beforePrepend.first?.id, afterPrepend.first?.id) + } + func testMessageRenderCacheReusesStableAssistantRevisionKey() { let cache = MessageRenderCache() let base64Pixel = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn8Vf0AAAAASUVORK5CYII=" From e056c6c0d515d3b4d1d28083e2f49f4341fd897e Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:45:03 +0000 Subject: [PATCH 02/20] feat: infinite scroll + pull-to-refresh for home sessions list Add cursor-paged session loading in the shared Rust store (AppStore::load_threads_page) with retained per-runtime cursors and a sessionListHasMore projection, so the home sessions list loads pages on scroll instead of a fixed top-10. iOS: HomeDashboardModel grows a recentLimit window (10 + 10 per load-more) fed by AppModel.loadThreadsPage, a drag-down UIRefreshControl full reload, and a near-bottom load-more trigger in the UIKit scroll view; topInset bumped 48 -> 56 for extra spacing above the list. Android: HomeDashboardScreen grows the same recentLimit window via AppModel.loadSessionsPage, adds Material3 PullToRefreshBox + snapshotFlow near-bottom load-more, and +8dp top content padding. Connect-time loads use the paged first page instead of a full drain. Android Conversation parity with the iOS infinite-scroll PR: replace the manual 'Load earlier messages' button with scroll-proximity auto-prefetch, stable LazyColumn turn keys for prepend scroll preservation, a 200-item auto-collapse policy, and turn page sizes bumped 5 -> 20. --- .../android/state/AppLifecycleController.kt | 5 +- .../java/com/litter/android/state/AppModel.kt | 60 +++++-- .../ui/conversation/ConversationScreen.kt | 165 +++++++++++------- .../android/ui/conversation/TurnGrouping.kt | 8 +- .../android/ui/home/HomeDashboardScreen.kt | 118 ++++++++++++- .../android/HomeDashboardSupportTests.kt | 1 + apps/ios/Sources/Litter/LitterApp.swift | 12 +- apps/ios/Sources/Litter/Models/AppModel.swift | 41 +++++ .../Litter/Views/HomeDashboardModel.swift | 98 ++++++++++- .../Litter/Views/HomeDashboardSupport.swift | 13 +- .../Litter/Views/HomeDashboardView.swift | 16 +- .../Litter/Views/HomeSessionsScrollView.swift | 127 ++++++++++++++ shared/rust-bridge/Cargo.lock | 55 +++--- shared/rust-bridge/Cargo.toml | 12 +- .../codex-mobile-client/src/ffi/app_store.rs | 17 ++ .../codex-mobile-client/src/ffi/client.rs | 5 + .../codex-mobile-client/src/ffi/reconnect.rs | 1 + .../src/mobile_client/mod.rs | 110 ++++++++++++ .../codex-mobile-client/src/ssh_bridge.rs | 10 +- .../codex-mobile-client/src/store/boundary.rs | 12 ++ .../codex-mobile-client/src/store/reducer.rs | 46 +++++ .../codex-mobile-client/src/store/snapshot.rs | 19 ++ .../src/types/server_requests.rs | 12 ++ 23 files changed, 827 insertions(+), 136 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt index 00751fead..2e7d454d6 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt @@ -246,7 +246,10 @@ class AppLifecycleController { } appModel.restoreStoredLocalAuthState(result.serverId) runCatching { - appModel.refreshSessions(listOf(result.serverId)) + // First page only — the home dashboard drives the rest via + // infinite scroll. Full drains still happen on pull-to-refresh + // and the dedicated Sessions screen. + appModel.loadSessionsPage(listOf(result.serverId), limit = 10u) } } } diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt index f7bd7095c..a85e85f16 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt @@ -100,8 +100,8 @@ class AppModel private constructor(context: android.content.Context) { /** * Matches the iOS page sizes. Server clamps this at 100. */ - const val INITIAL_TURN_PAGE_LIMIT: UInt = 5u - const val OLDER_TURN_PAGE_LIMIT: UInt = 5u + const val INITIAL_TURN_PAGE_LIMIT: UInt = 20u + const val OLDER_TURN_PAGE_LIMIT: UInt = 20u } // --- Rust bridges (singletons behind the scenes) ------------------------- @@ -439,7 +439,10 @@ class AppModel private constructor(context: android.content.Context) { ) restoreStoredLocalAuthState(serverId) try { - refreshSessions(listOf(serverId)) + // First page only — the home dashboard drives the rest via + // infinite scroll. Full drains still happen on pull-to-refresh + // and the dedicated Sessions screen. + loadSessionsPage(listOf(serverId), limit = 10u) } catch (_: Exception) { } refreshSnapshot() @@ -482,6 +485,31 @@ class AppModel private constructor(context: android.content.Context) { } } + /** + * Fetch the next page of the session list for each server via + * `store.loadThreadsPage`, merging additively into the canonical store. + * Used by the home dashboard's infinite scroll. On failure for a server, + * falls back to a full `refreshSessions` drain so the home list still + * populates (the retained Rust cursor state is cleared by that drain). + */ + suspend fun loadSessionsPage(serverIds: Collection, limit: UInt = 10u) { + val target = serverIds.filter { it !in sessionPageLoadingServerIds } + if (target.isEmpty()) return + target.forEach { sessionPageLoadingServerIds.add(it) } + try { + for (serverId in target) { + try { + store.loadThreadsPage(serverId, limit) + } catch (_: Exception) { + runCatching { refreshSessions(listOf(serverId)) } + } + } + } finally { + target.forEach { sessionPageLoadingServerIds.remove(it) } + } + refreshSnapshot() + } + suspend fun refreshThreadSearchSessions( query: String, runtimeKind: AgentRuntimeKind?, @@ -859,6 +887,7 @@ class AppModel private constructor(context: android.content.Context) { */ private val initialTurnsLoadingKeys = mutableSetOf() private val olderTurnsLoadingKeys = mutableSetOf() + private val sessionPageLoadingServerIds = mutableSetOf() /** * Launch an initial-turn load on the AppModel-owned scope so it survives @@ -906,19 +935,27 @@ class AppModel private constructor(context: android.content.Context) { /** * Fetch the next older page using the thread's stored - * `older_turns_cursor`. No-op when the cursor is null. + * `older_turns_cursor`. No-op (returns false) when the cursor is null or + * empty, or when a page is already in flight for this thread. * - * Returns a [Job] so the caller can `join()` to drive UI state (e.g. - * spinner on the "Load earlier messages" button). + * Runs on the AppModel-owned scope so the RPC survives recomposition. + * `onResult` reports whether a page was actually merged, so the + * conversation UI can release its "requested cursor" guard once the store + * advances `olderTurnsCursor` (or retry on failure). */ - fun loadOlderTurns(key: ThreadKey, limit: UInt = OLDER_TURN_PAGE_LIMIT): Job { - val cursor = threadSnapshot(key)?.olderTurnsCursor - if (cursor == null || !olderTurnsLoadingKeys.add(key)) { - return scope.launch { /* no-op */ } - } + fun loadOlderTurns( + key: ThreadKey, + limit: UInt = OLDER_TURN_PAGE_LIMIT, + onResult: (didLoad: Boolean) -> Unit = {}, + ): Job { + val cursor = threadSnapshot(key)?.olderTurnsCursor ?: return scope.launch { onResult(false) } + if (cursor.isEmpty()) return scope.launch { onResult(false) } + if (!olderTurnsLoadingKeys.add(key)) return scope.launch { onResult(false) } return scope.launch { + var didLoad = false try { val outcome = store.loadThreadTurnsPage(key, cursor, limit) + didLoad = outcome.loaded LLog.i( "Pagination", "loadOlderTurns", @@ -943,6 +980,7 @@ class AppModel private constructor(context: android.content.Context) { _lastError.value = e.message } finally { olderTurnsLoadingKeys.remove(key) + onResult(didLoad) } } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt index 172136d2d..4bd00a55f 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt @@ -143,7 +143,10 @@ fun ConversationScreen( buildTranscriptTurns( items = items, isStreaming = isThinking, - expandedRecentTurnCount = if (collapseTurns) 1 else Int.MAX_VALUE, + // iOS parity: auto-collapse exploration turns past 200 rendered + // items even when the user hasn't opted in, so a large restored + // page cannot exhaust the layout budget. + expandedRecentTurnCount = if (collapseTurns || items.size >= 200) 1 else Int.MAX_VALUE, ) } val transcriptTailSignature = remember(items, normalizedActiveTurnId, isThinking) { @@ -158,18 +161,19 @@ fun ConversationScreen( } // Server-paginated windowing: the Rust reducer owns which turns are // currently loaded for this thread. Kotlin just renders whatever is in - // `hydratedConversationItems` and exposes a "Load earlier" button gated - // on `olderTurnsCursor`. On legacy v0.124 servers this cursor stays null - // (all turns arrive in the resume response), so the button stays hidden. + // `hydratedConversationItems` and auto-prefetches older pages when the + // user scrolls near the top (iOS parity — replaces the manual "Load + // earlier messages" button). On legacy v0.124 servers the cursor stays + // null (all turns arrive in the resume response), so prefetch stays off. val displayedTurns = transcriptTurns val hasMoreTurnsAbove = thread?.olderTurnsCursor != null val supportsTurnPagination = server?.capabilities?.supportsTurnPagination == true - val isInitialTurnsLoading = thread != null && - !thread.initialTurnsLoaded && - supportsTurnPagination && - hasMoreTurnsAbove && - displayedTurns.isNotEmpty() - var isLoadingOlderTurns by remember(threadKey) { mutableStateOf(false) } + // Guard so a single cursor is only requested once while it is current. + // Released when the store advances `olderTurnsCursor` (a page merged). + var requestedOlderCursor by remember(threadKey) { mutableStateOf(null) } + // "Loading earlier messages…" capsule shown while a page is fetched with + // the user pinned at the very top of the transcript. + var olderPageLoaderVisible by remember(threadKey) { mutableStateOf(false) } var expandedTurnIds by remember(threadKey, collapseTurns) { mutableStateOf(setOf()) } var streamingRenderTick by remember(threadKey) { mutableStateOf(0) } var followScrollToken by remember(threadKey) { mutableStateOf(0) } @@ -382,7 +386,58 @@ fun ConversationScreen( } } - val displayedTurnCount = displayedTurns.size + (if (hasMoreTurnsAbove) 1 else 0) + // Index (within `displayedTurns`) of the earliest turn currently visible. + // Drives older-page prefetching — iOS parity uses the same + // "within N rows of the top" proximity heuristic. + val turnIndexByKey = remember(displayedTurns) { + displayedTurns.mapIndexed { index, turn -> turn.id to index }.toMap() + } + val earliestVisibleTurnIndex by remember(turnIndexByKey) { + derivedStateOf { + listState.layoutInfo.visibleItemsInfo + .mapNotNull { item -> (item.key as? String)?.let { turnIndexByKey[it] } } + .minOrNull() ?: Int.MAX_VALUE + } + } + + // Release the requested-cursor guard whenever the store advances + // `olderTurnsCursor` (a page was merged), so the proximity effect can + // prefetch the next page in the same pinned-at-top scroll session. + LaunchedEffect(threadKey, thread?.olderTurnsCursor) { + requestedOlderCursor = null + olderPageLoaderVisible = false + } + + LaunchedEffect( + earliestVisibleTurnIndex, + thread?.olderTurnsCursor, + thread?.initialTurnsLoaded, + supportsTurnPagination, + ) { + if (!supportsTurnPagination) return@LaunchedEffect + if (thread?.initialTurnsLoaded != true) return@LaunchedEffect + val cursor = thread?.olderTurnsCursor + if (cursor.isNullOrEmpty()) return@LaunchedEffect + if (earliestVisibleTurnIndex > OlderTurnsPrefetchDistance) return@LaunchedEffect + if (requestedOlderCursor == cursor) { + if (earliestVisibleTurnIndex == 0) olderPageLoaderVisible = true + return@LaunchedEffect + } + requestedOlderCursor = cursor + if (earliestVisibleTurnIndex == 0) olderPageLoaderVisible = true + val requestedAt = cursor + appModel.loadOlderTurns(threadKey) { didLoad -> + // Release the guard only on failure (cursor unchanged). On + // success the store advances the cursor and the reset effect + // above re-arms prefetching. + if (requestedOlderCursor == requestedAt && !didLoad) { + requestedOlderCursor = null + olderPageLoaderVisible = false + } + } + } + + val displayedTurnCount = displayedTurns.size LaunchedEffect(threadKey, displayedTurnCount, transcriptTailSignature, followScrollToken, streamingRenderTick) { if (shouldFollowTail && displayedTurns.isNotEmpty()) { val bottomAnchorIndex = conversationBottomAnchorIndex(displayedTurnCount) @@ -436,7 +491,7 @@ fun ConversationScreen( } else Modifier.drawWithContent { drawContent() } ), ) { - if (isWaitingForData || isInitialTurnsLoading) { + if (isWaitingForData) { item { Box( modifier = Modifier @@ -444,61 +499,20 @@ fun ConversationScreen( .padding(top = 40.dp), contentAlignment = Alignment.Center, ) { - if (isInitialTurnsLoading) { - CircularProgressIndicator( - color = LitterTheme.accent, - strokeWidth = 2.dp, - modifier = Modifier.size(20.dp), - ) - } else { - Text( - "Loading conversation…", - color = LitterTheme.textMuted, - fontSize = LitterTextStyle.caption.scaled, - ) - } - } - } - } - - if (hasMoreTurnsAbove) { - item { - TextButton( - enabled = !isLoadingOlderTurns, - onClick = { - if (isLoadingOlderTurns) return@TextButton - isLoadingOlderTurns = true - scope.launch { - try { - appModel.loadOlderTurns(threadKey).join() - } finally { - isLoadingOlderTurns = false - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) { - if (isLoadingOlderTurns) { - CircularProgressIndicator( - color = LitterTheme.accent, - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), - ) - } else { - Text( - "Load earlier messages", - color = LitterTheme.accent, - fontSize = LitterTextStyle.caption.scaled, - fontWeight = FontWeight.SemiBold, - ) - } + Text( + "Loading conversation…", + color = LitterTheme.textMuted, + fontSize = LitterTextStyle.caption.scaled, + ) } } } itemsIndexed( items = displayedTurns, - key = { index, turn -> "${turn.id}#$index" }, + // Stable keys let LazyColumn preserve scroll position + // when an older page is prepended (iOS parity). + key = { _, turn -> turn.id }, ) { _, turn -> val isExpanded = !turn.isCollapsedByDefault || expandedTurnIds.contains(turn.id) val streamingAssistantItemId = remember(turn.items, turn.isActiveTurn) { @@ -683,6 +697,31 @@ fun ConversationScreen( Icon(Icons.Default.KeyboardArrowDown, "Scroll to bottom", modifier = Modifier.size(20.dp)) } } + + // "Loading earlier messages…" capsule while an older page is + // fetched with the user pinned at the very top (iOS parity). + if (olderPageLoaderVisible && hasMoreTurnsAbove) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp) + .background(LitterTheme.surface.copy(alpha = 0.92f), RoundedCornerShape(50)) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + CircularProgressIndicator( + color = LitterTheme.accent, + strokeWidth = 2.dp, + modifier = Modifier.size(14.dp), + ) + Text( + "Loading earlier messages…", + color = LitterTheme.textMuted, + fontSize = LitterTextStyle.caption.scaled, + ) + } + } } // Bottom area: gradient fade + pinned context + composer + nav bar inset @@ -1256,6 +1295,10 @@ private fun uniffi.codex_mobile_client.AppThreadSnapshot.composerContextPercent( private fun conversationBottomAnchorIndex(turnCount: Int): Int = turnCount + 1 +/// iOS parity: prefetch the next older page once the earliest visible turn is +/// within this many rows of the top of the transcript. +private const val OlderTurnsPrefetchDistance = 6 + @Composable private fun PlanContextBadge(progress: String) { Text( diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt index 3455735ed..1b4571a03 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt @@ -225,10 +225,14 @@ private fun List.isExplorationGroup(): Boolean { private fun turnIdentifier(items: List, ordinal: Int): String { val first = items.firstOrNull() ?: return "turn-$ordinal" val sourceTurnId = items.firstNotNullOfOrNull { it.sourceTurnId } + // Anchor merged exploration runs to their newest constituent item (iOS + // parity): older pages prepend to this run, so anchoring on the first item + // would change the id and make SwiftUI/LazyColumn lose scroll position. + val anchor = if (items.isExplorationGroup()) items.last() else first return if (sourceTurnId != null) { - "turn-$sourceTurnId-${first.id}" + "turn-$sourceTurnId-${anchor.id}" } else { - "turn-${first.id}" + "turn-${anchor.id}" } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index adc561632..06ea47f46 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -36,6 +36,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -53,6 +55,7 @@ import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.automirrored.outlined.ViewQuilt import androidx.compose.material.icons.outlined.ViewStream import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -67,6 +70,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf @@ -74,6 +78,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -98,6 +103,9 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import kotlin.math.hypot import kotlin.math.roundToInt +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.launch +import uniffi.codex_mobile_client.AppSessionSummary import com.litter.android.state.AppLifecycleController import com.litter.android.state.DebugSettings import com.litter.android.state.SavedProjectStore @@ -123,7 +131,6 @@ import kotlinx.coroutines.withTimeoutOrNull import com.litter.android.ui.common.AgentRuntimeKind import uniffi.codex_mobile_client.AppProject import uniffi.codex_mobile_client.AppServerSnapshot -import uniffi.codex_mobile_client.AppSessionSummary import uniffi.codex_mobile_client.PinnedThreadKey import uniffi.codex_mobile_client.SavedApp import uniffi.codex_mobile_client.ThreadKey @@ -183,9 +190,12 @@ fun HomeDashboardScreen( // Home list = pinned first (preserving pin order). Local Studio also keeps // recent sessions visible after a pin so newly synced Pi sessions do not - // disappear behind legacy pinned rows. Hidden threads stay excluded. - val homeSessions = remember(pinnedKeys, hiddenKeys, servers, allSessions) { - mergeHomeSessions(pinnedKeys, hiddenKeys, servers, allSessions) + // disappear behind legacy pinned rows. Hidden threads stay excluded. The + // unpinned recent window grows via infinite scroll. + var recentLimit by remember { mutableIntStateOf(DefaultRecentLimit) } + var isRefreshingSessions by remember { mutableStateOf(false) } + val homeSessions = remember(pinnedKeys, hiddenKeys, servers, allSessions, recentLimit) { + mergeHomeSessions(pinnedKeys, hiddenKeys, servers, allSessions, recentLimit) } val scopedServerId = selectedProject?.serverId ?: selectedServerId @@ -194,6 +204,66 @@ fun HomeDashboardScreen( else homeSessions.filter { it.key.serverId == scopedServerId } } + // --- Infinite scroll + pull-to-refresh -------------------------------- + val hasMoreSessions = remember(snapshot, scopedServerId) { + val scoped = scopedServerId + snapshot?.servers?.any { server -> + (scoped.isNullOrEmpty() || server.serverId == scoped) && server.sessionListHasMore + } ?: false + } + val listState = rememberLazyListState() + val isLoadingMore by remember { + derivedStateOf { + val info = listState.layoutInfo + if (info.totalItemsCount == 0) return@derivedStateOf false + val last = info.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf false + last.index >= info.totalItemsCount - 3 + } + } + + fun loadMoreSessions() { + if (isRefreshingSessions) return + val targetServers = snapshot?.servers + ?.filter { server -> + (scopedServerId.isNullOrEmpty() || server.serverId == scopedServerId) && + server.sessionListHasMore + } + ?.map { it.serverId } + .orEmpty() + if (targetServers.isEmpty()) return + scope.launch { + appModel.loadSessionsPage(targetServers, limit = RecentLimitStep.toUInt()) + recentLimit += RecentLimitStep + } + } + + fun refreshAllSessions() { + if (isRefreshingSessions) return + isRefreshingSessions = true + val targetServers = snapshot?.servers + ?.filter { it.isConnected } + ?.map { it.serverId } + .orEmpty() + scope.launch { + try { + appModel.refreshSessions(targetServers) + } finally { + recentLimit = DefaultRecentLimit + isRefreshingSessions = false + } + } + } + + @OptIn(FlowPreview::class) + LaunchedEffect(recentSessions.size, hasMoreSessions, scopedServerId) { + snapshotFlow { listState.isScrollInProgress } + .collect { scrolling -> + if (!scrolling && isLoadingMore && hasMoreSessions) { + loadMoreSessions() + } + } + } + fun pinThreadOnHome(key: ThreadKey) { val displacedKeys = if (pinnedKeys.isEmpty()) { recentSessions @@ -365,6 +435,11 @@ fun HomeDashboardScreen( ) { // Sessions list fills the whole screen, with top/bottom content padding // so items don't sit under the floating chrome. + PullToRefreshBox( + isRefreshing = isRefreshingSessions, + onRefresh = { refreshAllSessions() }, + modifier = Modifier.fillMaxSize(), + ) { LazyColumn( modifier = Modifier .fillMaxSize() @@ -409,9 +484,9 @@ fun HomeDashboardScreen( val sysInsets = WindowInsets.systemBars.asPaddingValues() androidx.compose.foundation.layout.PaddingValues( top = if (topChromeHeight > 0.dp) { - topChromeHeight + topChromeHeight + 8.dp } else { - 72.dp + sysInsets.calculateTopPadding() + 72.dp + sysInsets.calculateTopPadding() + 8.dp }, bottom = 72.dp + sysInsets.calculateBottomPadding(), ) @@ -537,12 +612,37 @@ fun HomeDashboardScreen( ) } } + if (hasMoreSessions) { + item(key = "home-load-more") { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) { + if (isLoadingMore) { + CircularProgressIndicator( + color = LitterTheme.accent, + strokeWidth = 2.dp, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = if (isLoadingMore) "Loading more sessions..." else "Pull up for more", + color = LitterTheme.textMuted, + fontSize = 12.sp, + ) + } + } + } } else { item { Spacer(Modifier.height(1.dp)) } } } + } // Top chrome: header + server pill row, floating over the list with a // gradient scrim (matches iOS translucent bar). Top edge is fully @@ -1376,6 +1476,7 @@ internal fun mergeHomeSessions( hidden: List, servers: List, allSessions: List, + recentLimit: Int = DefaultRecentLimit, ): List { val hiddenSet = hidden.toSet() val candidates = allSessions.filter { @@ -1410,9 +1511,12 @@ internal fun mergeHomeSessions( } return pinnedSessions + localStudioRecent } - return candidates.take(10) + return candidates.take(recentLimit) } +private const val DefaultRecentLimit = 10 +private const val RecentLimitStep = 10 + private fun placeholderPinnedSession( pinned: PinnedThreadKey, server: AppServerSnapshot, diff --git a/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt b/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt index 7bd63e4f1..9157a407b 100644 --- a/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt +++ b/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt @@ -154,6 +154,7 @@ class HomeDashboardSupportTests { ), connectionProgress = null, usageStats = null, + sessionListHasMore = false, ) private fun session(serverId: String, threadId: String, runtimeKind: String) = AppSessionSummary( diff --git a/apps/ios/Sources/Litter/LitterApp.swift b/apps/ios/Sources/Litter/LitterApp.swift index 030e46660..82608218c 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -1440,7 +1440,11 @@ private struct HomeNavigationView: View { onInputModeChange: { mode in homeInputMode = mode }, - onSearchThreads: loadSearchThreads + onSearchThreads: loadSearchThreads, + onLoadMore: { homeDashboardModel.loadMore() }, + onRefreshSessions: { await homeDashboardModel.refreshAll() }, + hasMoreSessions: homeDashboardModel.hasMoreSessions, + isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions ) } @@ -1485,7 +1489,11 @@ private struct HomeNavigationView: View { onInputModeChange: { mode in homeInputMode = mode }, - onSearchThreads: loadSearchThreads + onSearchThreads: loadSearchThreads, + onLoadMore: { homeDashboardModel.loadMore() }, + onRefreshSessions: { await homeDashboardModel.refreshAll() }, + hasMoreSessions: homeDashboardModel.hasMoreSessions, + isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions ) } diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index f145b61f8..d63ed3328 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -156,6 +156,7 @@ final class AppModel { @ObservationIgnored private var pendingStreamingDeltaTask: Task? @ObservationIgnored private var cachedThreadSnapshots: [ThreadKey: AppThreadSnapshot] = [:] @ObservationIgnored private var loadingTurnPageThreadKeys: Set = [] + @ObservationIgnored private var loadingThreadPageServerIds: Set = [] private(set) var pendingHandoffTurnErrors: [ThreadKey: String] = [:] func reportHandoffTurnError(key: ThreadKey, message: String) { @@ -2251,6 +2252,9 @@ final class AppModel { private static let initialTurnPageSize: UInt32 = 20 private static let olderTurnPageSize: UInt32 = 20 + /// Home sessions-list page size. Kept small so the first screen of the + /// dashboard loads quickly and scrolling reveals more sessions in chunks. + static let homeSessionPageSize: UInt32 = 10 /// Fetch the first page of turns for a thread whose `initialTurnsLoaded` /// is still false. Called after a resume that sent `exclude_turns: true` @@ -2295,6 +2299,43 @@ final class AppModel { } } + /// Fetch the next page of the session list for each of the given servers + /// via `AppStore.load_threads_page`, merging additively into the store. + /// Used by the home dashboard's infinite scroll. On failure for a server, + /// falls back to a full `listThreads` drain so the home list still + /// populates (the retained Rust cursor state is cleared by that drain). + func loadThreadsPage(serverIds: [String], limit: UInt32 = Self.homeSessionPageSize) async { + let pageable = serverIds.filter { !loadingThreadPageServerIds.contains($0) } + guard !pageable.isEmpty else { return } + pageable.forEach { loadingThreadPageServerIds.insert($0) } + defer { pageable.forEach { loadingThreadPageServerIds.remove($0) } } + + let client = client + let store = store + await withTaskGroup(of: Void.self) { group in + for serverId in pageable { + group.addTask { + do { + _ = try await store.loadThreadsPage(serverId: serverId, limit: limit) + } catch { + // Safe degradation: a paged load failure should never + // leave the home list empty, so fall back to the + // existing full drain for this server. + _ = try? await client.listThreads( + serverId: serverId, + params: AppListThreadsRequest( + limit: nil, + sortKey: .updatedAt, + sortDirection: .desc, + runtimeKinds: nil + ) + ) + } + } + } + } + } + private func refreshLoadedThreadSnapshot(key: ThreadKey) async { do { if let thread = try await store.threadSnapshot(key: key) { diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift index 31bdd5dc2..8ddcb2188 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift @@ -47,6 +47,12 @@ struct HomeDashboardPersistence { @MainActor @Observable final class HomeDashboardModel { + /// Initial number of unpinned recent sessions rendered on the home list. + /// Grows by this amount on each "load more" scroll. + static let defaultRecentLimit = 10 + /// How many additional unpinned sessions to reveal on each "load more". + static let recentLimitStep = 10 + private struct Snapshot { let connectedServers: [HomeDashboardServer] let recentSessions: [HomeDashboardRecentSession] @@ -63,6 +69,15 @@ final class HomeDashboardModel { /// Every session we know about across connected servers, newest first — /// used by the search view so the user can pick any thread. private(set) var allSessions: [HomeDashboardRecentSession] = [] + /// Whether more sessions exist on the visible servers that haven't been + /// loaded into the home list yet (driven by Rust `sessionListHasMore`). + private(set) var hasMoreSessions = false + /// True while a "load more" page fetch is in flight for the visible + /// servers. Drives the loading row at the bottom of the home list. + private(set) var isLoadingMoreSessions = false + /// How many unpinned recent sessions the home list should render. Starts + /// at the page size (10) and grows as the user scrolls to load more. + private(set) var recentLimit = HomeDashboardModel.defaultRecentLimit private(set) var pinnedKeys: [SavedThreadsStore.PinnedKey] = [] private(set) var hiddenKeys: [SavedThreadsStore.PinnedKey] = [] private(set) var projects: [AppProject] = [] @@ -228,6 +243,7 @@ final class HomeDashboardModel { guard !isActive else { return } isActive = true refreshState() + loadFirstPageIfNeeded() } func deactivate() { @@ -296,11 +312,16 @@ final class HomeDashboardModel { rebuildCount += 1 connectedServers = snapshot.connectedServers allSessions = snapshot.recentSessions + let visibleServerIds = Set(visibleServerIDs(from: snapshot.connectedServers)) + hasMoreSessions = snapshot.connectedServers.contains { server in + visibleServerIds.contains(server.id) && server.sessionListHasMore + } recentSessions = Self.mergedHomeSessions( pinned: pinnedKeys, hidden: hiddenKeys, allSessions: snapshot.recentSessions, - servers: snapshot.connectedServers + servers: snapshot.connectedServers, + recentLimit: recentLimit ) lastSessionSummaries = snapshot.sessionSummaries projects = deriveProjects(sessions: snapshot.sessionSummaries) @@ -364,6 +385,74 @@ final class HomeDashboardModel { hiddenKeys = persistence.hiddenKeys() } + /// The servers whose sessions the home list currently surfaces — either + /// all launchable servers or the single selected server scope. + private func visibleServerIDs(from servers: [HomeDashboardServer]) -> [String] { + let launchable = servers.filter(\.canLaunchSessions) + guard let selected = selectedServerId, !selected.isEmpty else { + return launchable.map(\.id) + } + return launchable.filter { $0.id == selected }.map(\.id) + } + + /// Fetch the next page of sessions for the visible servers and reveal it + /// in the home list. Called when the user scrolls near the bottom. + func loadMore() { + guard let appModel, isActive, hasMoreSessions, !isLoadingMoreSessions else { return } + let serverIds = visibleServerIDs(from: connectedServers) + guard !serverIds.isEmpty else { return } + isLoadingMoreSessions = true + Task { @MainActor [weak self] in + guard let self else { return } + await appModel.loadThreadsPage(serverIds: serverIds, limit: UInt32(Self.recentLimitStep)) + // Reveal whatever is now in the store (a page was merged), then + // refresh `hasMoreSessions` from the updated snapshot. + self.recentLimit += Self.recentLimitStep + self.refreshState() + self.isLoadingMoreSessions = false + } + } + + /// First-page load for the visible servers, called when the home becomes + /// active. Cheap no-op when a previous page (or a full drain from another + /// surface) already populated the list. + func loadFirstPageIfNeeded() { + guard let appModel, isActive else { return } + let serverIds = visibleServerIDs(from: connectedServers) + guard !serverIds.isEmpty else { return } + Task { @MainActor [weak self] in + guard let self, self.isActive else { return } + await appModel.loadThreadsPage(serverIds: serverIds, limit: UInt32(Self.recentLimit)) + self.refreshState() + } + } + + /// Full reload of every session across the visible servers (pull-to- + /// refresh). Drains the whole cursor chain, resets the recent window, and + /// reconciles the home list. `completion` fires when the snapshot has + /// settled so the refresh control can end. + func refreshAll(completion: (@MainActor () -> Void)? = nil) { + guard let appModel, isActive else { return } + let serverIds = visibleServerIDs(from: connectedServers) + guard !serverIds.isEmpty else { return } + let client = appModel.client + let params = AppListThreadsRequest(limit: nil, sortKey: .updatedAt, sortDirection: .desc, runtimeKinds: nil) + Task { @MainActor [weak self] in + guard let self else { return } + await withTaskGroup(of: Void.self) { group in + for serverId in serverIds { + group.addTask { + _ = try? await client.listThreads(serverId: serverId, params: params) + } + } + } + await appModel.refreshSnapshot() + self.recentLimit = Self.defaultRecentLimit + self.refreshState() + completion?() + } + } + private func reconcileSelectedProject() { guard let serverId = selectedServerId else { selectedProject = nil @@ -402,7 +491,8 @@ final class HomeDashboardModel { pinned: [SavedThreadsStore.PinnedKey], hidden: [SavedThreadsStore.PinnedKey], allSessions: [HomeDashboardRecentSession], - servers: [HomeDashboardServer] + servers: [HomeDashboardServer], + recentLimit: Int ) -> [HomeDashboardRecentSession] { let hiddenSet = Set(hidden) let candidates = allSessions.filter { @@ -414,7 +504,7 @@ final class HomeDashboardModel { }) let resolvedPins = pinned.compactMap { byKey[$0] } guard !resolvedPins.isEmpty else { - return Array(candidates.prefix(10)) + return Array(candidates.prefix(recentLimit)) } let pinnedSet = Set(pinned) let localStudioServerIds = Set( @@ -430,7 +520,7 @@ final class HomeDashboardModel { } return resolvedPins + localStudioRecent } - return Array(candidates.prefix(10)) + return Array(candidates.prefix(recentLimit)) } /// Called when the user picks a fresh directory via the "new project" diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift b/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift index 81cc3457f..a5317566f 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift @@ -99,6 +99,10 @@ struct HomeDashboardServer: Identifiable, Equatable { let statusColor: Color let statusDotState: StatusDotState let agentRuntimes: [AgentRuntimeInfo] + /// Whether the session list on this server has more pages to load via + /// `AppStore.load_threads_page`. Rust owns this; offline/remembered + /// servers default to false. + let sessionListHasMore: Bool var deduplicationKey: String { if isLocal { @@ -127,7 +131,8 @@ struct HomeDashboardServer: Identifiable, Equatable { lhs.health == rhs.health && lhs.sourceLabel == rhs.sourceLabel && lhs.statusLabel == rhs.statusLabel && - lhs.agentRuntimes.map(agentRuntimeEqualityKey) == rhs.agentRuntimes.map(agentRuntimeEqualityKey) + lhs.agentRuntimes.map(agentRuntimeEqualityKey) == rhs.agentRuntimes.map(agentRuntimeEqualityKey) && + lhs.sessionListHasMore == rhs.sessionListHasMore } } @@ -207,7 +212,8 @@ enum HomeDashboardSupport { statusLabel: server.statusLabel, statusColor: server.statusColor, statusDotState: server.statusDotState, - agentRuntimes: server.agentRuntimes + agentRuntimes: server.agentRuntimes, + sessionListHasMore: server.sessionListHasMore ) } @@ -255,7 +261,8 @@ enum HomeDashboardSupport { statusLabel: AppServerHealth.disconnected.displayLabel, statusColor: AppServerHealth.disconnected.accentColor, statusDotState: .idle, - agentRuntimes: savedAgentRuntimes(for: saved) + agentRuntimes: savedAgentRuntimes(for: saved), + sessionListHasMore: false ) } diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift index f7c0634fe..401417dde 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift @@ -69,6 +69,16 @@ struct HomeDashboardView: View { /// new thread. var onForkThread: (@MainActor (HomeDashboardRecentSession) async -> Void)? = nil var onInputModeChange: ((HomeInputMode) -> Void)? = nil + /// Fired when the user scrolls near the bottom of the sessions list. + /// The model fetches the next page and reveals more sessions. + var onLoadMore: (() -> Void)? = nil + /// Fired by the drag-down-to-refresh control. The model drains all + /// sessions and resets the recent window. + var onRefreshSessions: (@MainActor () async -> Void)? = nil + /// Whether more sessions are available on the visible servers. + var hasMoreSessions: Bool = false + /// True while a "load more" page fetch is in flight. + var isLoadingMoreSessions: Bool = false @State private var deleteTargetThread: HomeDashboardRecentSession? @State private var replyTargetThread: HomeDashboardRecentSession? @@ -581,8 +591,12 @@ struct HomeDashboardView: View { openingKey: openingRecentSessionKey, zoomLevel: $zoomLevel, showCatFooter: chrome == .full, - topInset: 48, + topInset: 56, bottomInset: chrome == .full ? 140 : 24, + hasMoreSessions: hasMoreSessions, + isLoadingMoreSessions: isLoadingMoreSessions, + onLoadMore: onLoadMore, + onRefreshSessions: onRefreshSessions, callbacks: HomeSessionsScrollView.Callbacks( onOpen: { session in guard openingRecentSessionKey == nil else { return } diff --git a/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift b/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift index 892567ba1..d6f70962e 100644 --- a/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift +++ b/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift @@ -35,6 +35,10 @@ struct HomeSessionsScrollView: UIViewRepresentable { let showCatFooter: Bool let topInset: CGFloat let bottomInset: CGFloat + let hasMoreSessions: Bool + let isLoadingMoreSessions: Bool + let onLoadMore: (() -> Void)? + let onRefreshSessions: (@MainActor () async -> Void)? let callbacks: Callbacks /// App's text scale from `@Environment(\.textScale)`. Piped in so /// row height measurements (which depend on rendered font sizes) @@ -68,6 +72,10 @@ struct HomeSessionsScrollView: UIViewRepresentable { showCatFooter: showCatFooter, topInset: topInset, bottomInset: bottomInset, + hasMoreSessions: hasMoreSessions, + isLoadingMoreSessions: isLoadingMoreSessions, + onLoadMore: onLoadMore, + onRefreshSessions: onRefreshSessions, textScale: textScale, themeManager: themeManager, wallpaperManager: wallpaperManager, @@ -138,6 +146,8 @@ final class HomeSessionsScrollUIView: UIView { private let contentView = UIView() private let pinchVignette = PinchVignetteView() private let catFooterHostingController = UIHostingController(rootView: AnyView(EmptyView())) + private var refreshControl: UIRefreshControl? + private var loadMoreHostingController = UIHostingController(rootView: AnyView(EmptyView())) private var containers: [ThreadKey: HomeRowContainer] = [:] private var order: [ThreadKey] = [] @@ -163,6 +173,7 @@ final class HomeSessionsScrollUIView: UIView { private(set) var bottomInsetValue: CGFloat = 0 private var catFooterCountEligible = false private var catFooterHostVisible = false + private var loadMoreHostVisible = false private var catFooterEntranceStarted = false private var widthUsed: CGFloat = 0 private var lastCommittedInteger: Int = 2 @@ -179,6 +190,17 @@ final class HomeSessionsScrollUIView: UIView { /// drains the flag again. private var isPerformingDeferredMeasurements = false + // --- Infinite-scroll + pull-to-refresh state -------------------------- + /// Whether more sessions are available on the visible servers. Drives the + /// near-bottom load-more trigger and the loading row. + private var hasMoreSessions = false + /// True while a load-more page fetch is in flight. + private var isLoadingMoreSessions = false + private var onLoadMore: (() -> Void)? + /// Drag-down-to-refresh callback (async full reload). + private var refreshControlAction: (@MainActor () async -> Void)? + private var loadMoreFired = false + var zoomCommit: ((Int) -> Void)? /// Surface the scroll view's safe-area top for row containers — they @@ -223,9 +245,20 @@ final class HomeSessionsScrollUIView: UIView { scrollView.contentInsetAdjustmentBehavior = .always scrollView.delegate = self scrollView.addGestureRecognizer(pinchRecognizer) + + // Drag-down-to-reload. `alwaysBounceVertical` is already true so the + // refresh control can be revealed by overscrolling from the top. + let refreshControl = UIRefreshControl() + refreshControl.tintColor = .secondaryLabel + refreshControl.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged) + scrollView.refreshControl = refreshControl + self.refreshControl = refreshControl catFooterHostingController.view.backgroundColor = .clear catFooterHostingController.view.isHidden = true contentView.addSubview(catFooterHostingController.view) + loadMoreHostingController.view.backgroundColor = .clear + loadMoreHostingController.view.isHidden = true + contentView.addSubview(loadMoreHostingController.view) // Let pinch and scroll pan arbitrate naturally. Pinch requires 2 // touches to begin; `numberOfTouchesRequired = 2` on pinch + our // pinchActive check (which disables `scrollView.isScrollEnabled` @@ -291,11 +324,20 @@ final class HomeSessionsScrollUIView: UIView { showCatFooter: Bool, topInset: CGFloat, bottomInset: CGFloat, + hasMoreSessions: Bool, + isLoadingMoreSessions: Bool, + onLoadMore: (() -> Void)?, + onRefreshSessions: (@MainActor () async -> Void)?, textScale: CGFloat, themeManager: ThemeManager, wallpaperManager: WallpaperManager, callbacks: HomeSessionsScrollView.Callbacks ) { + self.hasMoreSessions = hasMoreSessions + self.isLoadingMoreSessions = isLoadingMoreSessions + self.onLoadMore = onLoadMore + self.refreshControlAction = onRefreshSessions + self.loadMoreFired = isLoadingMoreSessions ? true : loadMoreFired let zoomChanged = self.zoomLevel != zoomLevel && !isPinching let enteredPageFit = zoomChanged && zoomLevel == 4 self.zoomLevel = zoomLevel @@ -320,6 +362,7 @@ final class HomeSessionsScrollUIView: UIView { scrollView.contentInset = UIEdgeInsets(top: effectiveTopInset, left: 0, bottom: effectiveBottomInset, right: 0) scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: effectiveTopInset, left: 0, bottom: effectiveBottomInset, right: 0) refreshCatFooterVisibility() + refreshLoadMoreVisibility() // Text scale change → blow out every row's height cache and // propagate the new scale into each hosted SwiftUI tree. @@ -454,12 +497,22 @@ final class HomeSessionsScrollUIView: UIView { } else { footerFrame = .zero } + + let loadMoreFrame: CGRect + if shouldShowLoadMore { + let h = loadMoreRowHeight + loadMoreFrame = CGRect(x: 0, y: y, width: width, height: h) + y += h + } else { + loadMoreFrame = .zero + } let newContentSize = CGSize(width: width, height: y) if animated { UIView.animate(withDuration: zoomSnapDuration, delay: 0, options: [.curveEaseOut]) { for (container, frame) in frames { container.frame = frame } self.catFooterHostingController.view.frame = footerFrame + self.loadMoreHostingController.view.frame = loadMoreFrame self.contentView.frame = CGRect(origin: .zero, size: newContentSize) self.scrollView.contentSize = newContentSize self.updatePageBackgroundVisibility() @@ -469,6 +522,7 @@ final class HomeSessionsScrollUIView: UIView { } else { for (container, frame) in frames { container.frame = frame } catFooterHostingController.view.frame = footerFrame + loadMoreHostingController.view.frame = loadMoreFrame contentView.frame = CGRect(origin: .zero, size: newContentSize) scrollView.contentSize = newContentSize updatePageBackgroundVisibility() @@ -506,6 +560,60 @@ final class HomeSessionsScrollUIView: UIView { catFooterCountEligible && zoomLevel == 1 && !isPinching } + /// Show the "Loading more sessions…" row only when there are more sessions + /// to fetch and we're not at page-fit (zoom 4), so it doesn't obscure a + /// single full-screen card. + private var shouldShowLoadMore: Bool { + hasMoreSessions && !isPinching && zoomLevel != 4 + } + + private var loadMoreRowHeight: CGFloat { 44 } + + private func refreshLoadMoreVisibility() { + let visible = shouldShowLoadMore + // Always refresh the hosted row so the spinner/label reflects the + // current `isLoadingMoreSessions` even while already visible. + if visible { + loadMoreHostingController.rootView = AnyView(HomeLoadMoreRow(isLoading: isLoadingMoreSessions)) + } + guard loadMoreHostVisible != visible else { + loadMoreHostingController.view.isHidden = !visible + return + } + loadMoreHostVisible = visible + loadMoreHostingController.view.isHidden = !visible + } + + private func refreshControlValueChanged() { + guard let onRefresh = refreshControlAction else { + refreshControl?.endRefreshing() + return + } + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + Task { @MainActor in + await onRefresh() + refreshControl?.endRefreshing() + } + } + + @objc private func handleRefreshControl() { + refreshControlValueChanged() + } + + /// Debounced near-bottom load-more. Fires at most once per "page" (the + /// guard resets after `apply` observes `isLoadingMoreSessions` flip). + private func maybeTriggerLoadMore() { + guard hasMoreSessions, !isLoadingMoreSessions, !loadMoreFired else { return } + let threshold = bounds.height * 0.6 + let bottom = scrollView.contentOffset.y + bounds.height + let contentBottom = scrollView.contentSize.height + scrollView.adjustedContentInset.bottom + if bottom >= contentBottom - threshold { + loadMoreFired = true + onLoadMore?() + } + } + private func catFooterHeight(width: CGFloat) -> CGFloat { let videoWidth = min(max(0, width - 48), 340) return videoWidth * 9.0 / 16.0 + 32 @@ -917,6 +1025,7 @@ extension HomeSessionsScrollUIView: UIGestureRecognizerDelegate { extension HomeSessionsScrollUIView: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { updatePageBackgroundVisibility() + maybeTriggerLoadMore() } func scrollViewWillEndDragging( @@ -989,6 +1098,24 @@ extension HomeSessionsScrollUIView: UIScrollViewDelegate { } } +private struct HomeLoadMoreRow: View { + let isLoading: Bool + + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .controlSize(.small) + .tint(LitterTheme.accent) + } + Text(isLoading ? "Loading more sessions..." : "Pull up for more") + .litterFont(.caption) + .foregroundColor(LitterTheme.textMuted) + } + .frame(maxWidth: .infinity) + } +} + private struct HomeCatFooterView: View { let playEntrance: Bool diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index 8ab3f60f6..8e551695d 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -315,14 +315,13 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" dependencies = [ "alleycat-codex-proto", "anyhow", "async-trait", "base64 0.22.1", "chrono", - "dashmap", "futures", "serde", "serde_json", @@ -334,7 +333,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -344,12 +343,10 @@ dependencies = [ "chrono", "dashmap", "directories", - "futures", "serde", "serde_json", "thiserror 2.0.18", "tokio", - "tokio-util", "tracing", "tracing-subscriber", "uuid", @@ -358,18 +355,16 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" dependencies = [ - "chrono", "serde", "serde_json", - "thiserror 2.0.18", ] [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -393,7 +388,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -408,11 +403,9 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-util", "tracing", "tracing-subscriber", "uuid", - "walkdir", ] [[package]] @@ -4208,7 +4201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -4529,7 +4522,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4909,7 +4902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6955,7 +6948,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -7041,7 +7034,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -7678,7 +7671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7772,7 +7765,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8764,7 +8757,7 @@ dependencies = [ "pin-project-lite", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -8807,7 +8800,7 @@ checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ "cfg_aliases 0.2.2", "libc", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.61.2", ] @@ -8845,7 +8838,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9000,7 +8993,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -10241,7 +10234,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -10278,9 +10271,9 @@ dependencies = [ "cfg_aliases 0.2.2", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -11302,7 +11295,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11361,7 +11354,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11720,7 +11713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12979,7 +12972,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -14467,7 +14460,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index 84bc1eda2..a689dc5b5 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -23,11 +23,13 @@ codex-config = { path = "../third_party/codex/codex-rs/config" } codex-utils-absolute-path = { path = "../third_party/codex/codex-rs/utils/absolute-path" } codex-git-utils = { path = "../third_party/codex/codex-rs/git-utils" } # Pinned to the same immutable commit as the kittylitter daemon so the phone -# and host share one immutable Alleycat release revision. -alleycat-bridge-core = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } -alleycat-pi-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } -alleycat-claude-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } -alleycat-opencode-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } +# and host share one immutable Alleycat release revision. All crates come from +# the same source so the `Bridge` trait identity is consistent; forked until +# the project-aware opencode session enumeration lands upstream (dnakov/alleycat). +alleycat-bridge-core = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } +alleycat-pi-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } +alleycat-claude-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } +alleycat-opencode-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] } diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs index 265283c0e..eee1fb5cf 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs @@ -566,6 +566,23 @@ impl AppStore { }) } + /// Fetch the next page of the session list for a server, merging it + /// additively into the canonical store. Uses the retained per-runtime + /// cursors, advancing them on success. Safe to call repeatedly as the + /// user scrolls the home sessions list; returns `has_more` so the UI can + /// stop offering "load more" when exhausted. + pub async fn load_threads_page( + &self, + server_id: String, + limit: Option, + ) -> Result { + blocking_async!(self.rt, self.inner, |c| { + c.load_threads_page(&server_id, limit) + .await + .map_err(|e| ClientError::Rpc(e.to_string())) + }) + } + pub async fn unsubscribe_thread(&self, key: ThreadKey) -> Result<(), ClientError> { blocking_async!(self.rt, self.inner, |c| { c.thread_unsubscribe(&key.server_id, &key.thread_id) diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs index abc9f561a..d9fbcc1d8 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -821,6 +821,11 @@ impl AppClient { all_thread_ids.extend(ids); } c.finalize_thread_list_sync(&server_id, all_thread_ids); + // A full drain has reconciled the entire thread set, so paged + // session-list loading is complete for this server: clear the + // retained page cursors so `session_list_has_more` reads false + // and the home list stops offering "load more". + c.app_store.clear_thread_page_state(&server_id); } else if !all_completed { tracing::warn!( "list_threads: skipping finalize prune — partial fan-out result on server {}", diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs index 7e8a7c175..a27b940fb 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs @@ -601,6 +601,7 @@ mod tests { AppSnapshot { servers: HashMap::new(), threads: HashMap::new(), + session_pages: HashMap::new(), active_thread: None, pending_approvals: Vec::new(), pending_approval_seeds: HashMap::new(), diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index e82b9e252..5e8c3f1c0 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -3141,6 +3141,116 @@ impl MobileClient { } } + /// Composite action: fetch the next page of the session list for a server + /// via `thread/list` and merge it additively into the canonical store. + /// + /// - Fetches exactly ONE page per agent runtime (never drain-all), using + /// the retained per-(server, runtime) cursors from `AppStore`. + /// - Merges each page through `upsert_thread_list_page_for_runtime` + /// (additive — never prunes, so a single-page load cannot evict unseen + /// sessions the way `finalize_thread_list_sync` would). + /// - Advances the retained cursor to the page's `next_cursor` and records + /// `has_more` per runtime; the aggregate `has_more` is true when any + /// runtime still has more pages. + /// - A per-runtime RPC failure marks that runtime exhausted and continues + /// with the others rather than failing the whole load; the caller can + /// fall back to a full `list_threads` drain if desired. + pub async fn load_threads_page( + &self, + server_id: &str, + limit: Option, + ) -> Result { + let session = self.get_session(server_id)?; + let mut runtime_kinds = session.runtime_kinds(); + runtime_kinds.sort(); + runtime_kinds.dedup(); + if runtime_kinds.is_empty() { + return Ok(crate::types::AppLoadThreadsOutcome { + loaded: false, + has_more: false, + }); + } + tracing::info!( + "load_threads_page: server_id={} runtimes={:?} limit={:?}", + server_id, + runtime_kinds, + limit + ); + + let mut codex_visited = false; + let mut any_loaded = false; + let mut any_has_more = false; + for runtime_kind in runtime_kinds { + if runtime_kind == "codex" { + if codex_visited { + continue; + } + codex_visited = true; + } + let cursor = self.app_store.thread_page_cursor(server_id, &runtime_kind); + let params = upstream::ThreadListParams { + cursor, + limit, + sort_key: Some(upstream::ThreadSortKey::UpdatedAt), + sort_direction: Some(upstream::SortDirection::Desc), + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + search_term: None, + use_state_db_only: false, + }; + let request = upstream::ClientRequest::ThreadList { + request_id: upstream::RequestId::Integer(crate::next_request_id()), + params, + }; + match self + .request_typed_for_server_runtime::( + server_id, + runtime_kind.clone(), + request, + ) + .await + { + Ok(response) => { + let page = self.upsert_thread_list_page_for_runtime( + server_id, + runtime_kind.clone(), + &response.data, + ); + let next_cursor = response.next_cursor; + let has_more = next_cursor.is_some(); + self.app_store.set_thread_page_state( + server_id, + &runtime_kind, + next_cursor, + has_more, + ); + if !page.is_empty() { + any_loaded = true; + } + if has_more { + any_has_more = true; + } + } + Err(error) => { + tracing::warn!( + "load_threads_page: thread/list failed for runtime {:?} on server {}: {}", + runtime_kind, + server_id, + error + ); + self.app_store + .set_thread_page_state(server_id, &runtime_kind, None, false); + } + } + } + Ok(crate::types::AppLoadThreadsOutcome { + loaded: any_loaded, + has_more: any_has_more, + }) + } + async fn read_thread_metadata_only_for_runtime( &self, server_id: &str, diff --git a/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs b/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs index b04e091f6..be4b5e8e2 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs @@ -569,19 +569,13 @@ async fn connect_bridge_runtime_via_ssh( PiHydrator::with_sessions(Vec::new()) } }; - let builder = PiBridge::builder() + PiBridge::builder() .agent_bin(bin) .launcher(pi_launcher) .codex_home(state_dir) .pool_capacity(4) .trust_persisted_cwd(true) - .hydrator(hydrator); - let builder = if kind == crate::local_studio::RUNTIME_KIND { - builder.model_provider_prefix(crate::local_studio::RUNTIME_KIND) - } else { - builder - }; - builder + .hydrator(hydrator) .build() .await .map_err(|error| SshBridgeError::BridgeStartupFailed(error.to_string()))? diff --git a/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs b/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs index 870e731c1..ca09d65ec 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs @@ -39,6 +39,10 @@ pub struct AppServerSnapshot { pub agent_runtimes: Vec, pub connection_progress: Option, pub usage_stats: Option, + /// Whether the session list on this server has more pages to load via + /// `AppStore::load_threads_page`. `false` once a full `thread/list` + /// drain (pull-to-refresh / Sessions screen) has completed. + pub session_list_has_more: bool, } #[derive(Debug, Clone, uniffi::Enum)] @@ -483,6 +487,13 @@ impl TryFrom for AppSnapshotRecord { transport_state == AppServerTransportState::Connected; let usage_stats = compute_server_usage_stats(&snapshot, &server.server_id); + let session_list_has_more = + snapshot + .session_pages + .iter() + .any(|((page_server_id, _), page)| { + page_server_id == &server.server_id && page.has_more + }); AppServerSnapshot { server_id: server.server_id, @@ -519,6 +530,7 @@ impl TryFrom for AppSnapshotRecord { agent_runtimes: server.agent_runtimes, connection_progress: server.connection_progress, usage_stats, + session_list_has_more, } }) .collect::>(); diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs index c945b43b9..689d86daa 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs @@ -340,6 +340,49 @@ impl AppStoreReducer { .clone() } + /// Read the stored `thread/list` page cursor for a (server, runtime) pair. + /// `None` when that pair has never been paged (first page) or when a full + /// drain completed (all sessions already in the store). + pub fn thread_page_cursor( + &self, + server_id: &str, + runtime_kind: &AgentRuntimeKind, + ) -> Option { + self.snapshot + .read() + .expect("app store lock poisoned") + .session_pages + .get(&(server_id.to_string(), runtime_kind.clone()))? + .cursor + .clone() + } + + /// Persist the `thread/list` page cursor + `has_more` flag for a + /// (server, runtime) pair after a paged load. + pub fn set_thread_page_state( + &self, + server_id: &str, + runtime_kind: &AgentRuntimeKind, + cursor: Option, + has_more: bool, + ) { + let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); + snapshot.session_pages.insert( + (server_id.to_string(), runtime_kind.clone()), + super::snapshot::SessionPageCursor { cursor, has_more }, + ); + } + + /// Drop all `thread/list` page cursor state for a server. Used after a + /// full drain completes (so `session_list_has_more` reflects that + /// everything is already loaded) and on server removal. + pub fn clear_thread_page_state(&self, server_id: &str) { + let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); + snapshot + .session_pages + .retain(|(page_server_id, _), _| page_server_id != server_id); + } + pub fn subscribe(&self) -> broadcast::Receiver { self.updates_tx.subscribe() } @@ -430,6 +473,9 @@ impl AppStoreReducer { } keep }); + snapshot + .session_pages + .retain(|(page_server_id, _), _| page_server_id != server_id); if snapshot .active_thread .as_ref() diff --git a/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs b/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs index 35a4c7049..fffc0b3ca 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs @@ -488,10 +488,29 @@ impl ThreadSnapshot { } } +/// Cursor state for paged `thread/list` loading of a single (server, runtime) +/// pair, driven by the home sessions list. Mirrors +/// `ThreadSnapshot::older_turns_cursor` semantics for the session list. +/// Rust-only state; platforms read the aggregate `session_list_has_more` +/// projection on `AppServerSnapshot`. +#[derive(Debug, Clone, Default)] +pub struct SessionPageCursor { + /// Opaque server cursor pointing at the next newer page. `None` means + /// either this runtime has not been paged yet (first page) or no more + /// sessions remain. + pub cursor: Option, + /// Whether more sessions remain on this runtime. Set to `false` when the + /// last page for a runtime returned no `next_cursor`, or when a full + /// `thread/list` drain (refresh) completed for the server. + pub has_more: bool, +} + #[derive(Debug, Clone, Default)] pub struct AppSnapshot { pub servers: HashMap, pub threads: HashMap, + /// Per-(server, runtime) cursors for paged session list loading. + pub session_pages: HashMap<(String, AgentRuntimeKind), SessionPageCursor>, pub active_thread: Option, pub pending_approvals: Vec, pub(crate) pending_approval_seeds: HashMap, diff --git a/shared/rust-bridge/codex-mobile-client/src/types/server_requests.rs b/shared/rust-bridge/codex-mobile-client/src/types/server_requests.rs index 311819372..2f0526463 100644 --- a/shared/rust-bridge/codex-mobile-client/src/types/server_requests.rs +++ b/shared/rust-bridge/codex-mobile-client/src/types/server_requests.rs @@ -555,6 +555,18 @@ pub struct AppLoadThreadTurnsOutcome { pub has_more: bool, } +/// Outcome of a `load_threads_page` store action (paged session list load). +#[derive(Debug, Clone, Copy, Serialize, PartialEq, uniffi::Record)] +#[serde(rename_all = "camelCase")] +pub struct AppLoadThreadsOutcome { + /// True when at least one runtime returned a page that was merged into + /// the store. + pub loaded: bool, + /// True when any runtime reported a next cursor, i.e. more sessions + /// remain on this server. + pub has_more: bool, +} + impl From for AppListThreadTurnsResponse { fn from(value: upstream::ThreadTurnsListResponse) -> Self { let turns = crate::conversation::hydrate_turns( From aef6af41779f106bf2d66c3e88e0c313aa436407 Mon Sep 17 00:00:00 2001 From: DatScreamer Date: Fri, 14 Aug 2026 23:32:51 -0600 Subject: [PATCH 03/20] fix(opencode): fetch and aggregate session list on connect Sessions were only ever loaded from the (unreachable) sessions screen or search, so a freshly connected server never populated its session list. Fetch the thread list in the post-connect warmup via a shared refresh_thread_list fanout, and pin the alleycat bridges to a fork whose opencode bridge enumerates sessions across all project worktrees (a bare GET /session only returns the default project). Relax update-alleycat-main to treat any rev-pinned git dep as pinned so the fork pin survives. --- shared/rust-bridge/Cargo.lock | 52 +++--- .../codex-mobile-client/src/ffi/client.rs | 157 +--------------- .../src/mobile_client/mod.rs | 172 ++++++++++++++++-- .../codex-mobile-client/src/types/mod.rs | 19 ++ 4 files changed, 209 insertions(+), 191 deletions(-) diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index 8e551695d..d127da877 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -315,7 +315,7 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" dependencies = [ "alleycat-codex-proto", "anyhow", @@ -333,7 +333,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -355,7 +355,7 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" dependencies = [ "serde", "serde_json", @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -388,7 +388,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#4768825ef274b00fdc9454cec5e4e364fcbf1f99" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -492,7 +492,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -503,7 +503,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4522,7 +4522,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4902,7 +4902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6948,7 +6948,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -7034,7 +7034,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -7671,7 +7671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7765,7 +7765,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8757,7 +8757,7 @@ dependencies = [ "pin-project-lite", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -8800,7 +8800,7 @@ checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ "cfg_aliases 0.2.2", "libc", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.61.2", ] @@ -8838,7 +8838,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -10234,7 +10234,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -10271,9 +10271,9 @@ dependencies = [ "cfg_aliases 0.2.2", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -11295,7 +11295,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -11354,7 +11354,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -11713,7 +11713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -12310,7 +12310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12972,7 +12972,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -13740,7 +13740,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -14460,7 +14460,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs index d9fbcc1d8..7ab41c1f8 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -219,22 +219,6 @@ fn runtime_exposes_model_choices(runtime_kind: &str) -> bool { runtime_kind != "shell" } -fn list_runtime_kinds( - requested: Option>, - available: &[types::AgentRuntimeKind], -) -> Vec { - let mut runtimes = match requested { - Some(requested) if !requested.is_empty() => requested - .into_iter() - .filter(|kind| available.contains(kind)) - .collect(), - _ => available.to_vec(), - }; - runtimes.sort(); - runtimes.dedup(); - runtimes -} - fn append_cached_models_for_failed_runtimes( models: &mut Vec, seen_model_ids: &mut HashSet<(types::AgentRuntimeKind, String)>, @@ -699,140 +683,10 @@ impl AppClient { ) -> Result<(), ClientError> { blocking_async!(self.rt, self.inner, |c| { let requested_runtime_kinds = params.runtime_kinds.clone(); - let drain_all_pages = params.cursor.is_none() - && params.limit.is_none() - && params - .search_term - .as_deref() - .map(str::trim) - .unwrap_or_default() - .is_empty() - && !params.use_state_db_only; let params: upstream::ThreadListParams = params.into(); - let session = c - .get_session(&server_id) - .map_err(|error| ClientError::Rpc(error.to_string()))?; - let available_runtime_kinds = session.runtime_kinds(); - tracing::info!( - "list_threads: resolve runtimes server_id={} requested={:?} available={:?} search_term={:?} use_state_db_only={} limit={:?} cursor={:?}", - server_id, - requested_runtime_kinds, - available_runtime_kinds, - params.search_term, - params.use_state_db_only, - params.limit, - params.cursor - ); - let runtime_kinds = - list_runtime_kinds(requested_runtime_kinds, &available_runtime_kinds); - if runtime_kinds.is_empty() { - return Err(ClientError::Rpc( - "none of the requested agent runtimes are available on this controller" - .to_string(), - )); - } - tracing::info!( - "list_threads: fanout start server_id={} runtime_kinds={:?}", - server_id, - runtime_kinds - ); - - // Fan out per-runtime concurrently. The previous sequential loop - // exhausted Codex's full cursor pagination before non-Codex runtimes - // ever got their first page, so a Codex inbox with many threads - // would starve the other providers — the user would see only - // Codex threads while other runtimes were silently waiting their - // turn. By spawning each runtime's pagination as its own future - // and joining them, every provider's first page lands in - // parallel and the UI gets representative threads from each - // immediately. - let mut codex_visited = false; - let mut tasks = Vec::new(); - for runtime_kind in runtime_kinds { - if runtime_kind == "codex" { - if codex_visited { - continue; - } - codex_visited = true; - } - - let client = std::sync::Arc::clone(c); - let server_id = server_id.clone(); - let initial_params = params.clone(); - tasks.push(async move { - let mut request_params = initial_params; - let mut ids = Vec::new(); - let mut completed = true; - loop { - let response: upstream::ThreadListResponse = - match rpc_runtime::( - client.as_ref(), - &server_id, - runtime_kind.clone(), - req!(server_id, ThreadList, request_params.clone()), - ) - .await - { - Ok(response) => response, - Err(error) => { - tracing::warn!( - "list_threads: thread/list failed for runtime {:?} on server {}: {}", - runtime_kind, server_id, error - ); - completed = false; - break; - } - }; - let page = client.upsert_thread_list_page_for_runtime( - &server_id, - runtime_kind.clone(), - &response.data, - ); - ids.extend(page.into_iter().map(|thread| thread.id)); - let Some(next_cursor) = response.next_cursor else { - break; - }; - if !drain_all_pages { - break; - } - request_params.cursor = Some(next_cursor); - } - (runtime_kind, ids, completed) - }); - } - - let results = futures::future::join_all(tasks).await; - if results.iter().all(|(_, _, completed)| !completed) { - return Err(ClientError::Rpc( - "thread list failed for every runtime".into(), - )); - } - // Only prune if every runtime finished cleanly. A partial - // result (one runtime timed out / errored) means we don't - // know its true thread set yet, and `finalize_thread_list_sync` - // would delete unseen threads from healthy runtimes too — - // wiping pi/opencode threads from the store on a transient - // codex failure. Skip pruning in that case; the next refresh - // reconciles when the failing runtime recovers. - let all_completed = results.iter().all(|(_, _, ok)| *ok); - if all_completed && drain_all_pages { - let mut all_thread_ids = Vec::new(); - for (_, ids, _) in results { - all_thread_ids.extend(ids); - } - c.finalize_thread_list_sync(&server_id, all_thread_ids); - // A full drain has reconciled the entire thread set, so paged - // session-list loading is complete for this server: clear the - // retained page cursors so `session_list_has_more` reads false - // and the home list stops offering "load more". - c.app_store.clear_thread_page_state(&server_id); - } else if !all_completed { - tracing::warn!( - "list_threads: skipping finalize prune — partial fan-out result on server {}", - server_id - ); - } - Ok(()) + c.refresh_thread_list(&server_id, requested_runtime_kinds, params) + .await + .map_err(ClientError::Rpc) }) } @@ -2993,7 +2847,6 @@ mod tests { use super::{ ImageViewSource, append_cached_models_for_failed_runtimes, append_missing_amp_mode_models, choose_saved_app_update_server_id, image_read_command, is_mobile_hidden_skill, - list_runtime_kinds, normalize_model_info_for_runtime, normalized_image_path, runtime_exposes_model_choices, splice_generative_ui_preamble, }; @@ -3123,8 +2976,8 @@ mod tests { #[test] fn thread_list_only_queries_runtimes_the_controller_exposes() { let local_studio = vec!["local-studio".to_string()]; - assert_eq!(list_runtime_kinds(None, &local_studio), local_studio); - assert!(list_runtime_kinds(Some(vec!["codex".to_string()]), &local_studio).is_empty()); + assert_eq!(crate::types::list_runtime_kinds(None, &local_studio), local_studio); + assert!(crate::types::list_runtime_kinds(Some(vec!["codex".to_string()]), &local_studio).is_empty()); } #[test] diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index 5e8c3f1c0..cb6b58543 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -2549,6 +2549,126 @@ impl MobileClient { ); } + /// Fetch the thread list for a server, fanning out to each available + /// runtime (including opencode) and reconciling every page into the + /// store. Also runs on connect (via the post-connect warmup) so a freshly + /// connected server populates its sessions without the user needing to + /// open a sessions view first. + pub async fn refresh_thread_list( + self: &Arc, + server_id: &str, + requested_runtime_kinds: Option>, + params: upstream::ThreadListParams, + ) -> Result<(), String> { + let drain_all_pages = params.cursor.is_none() + && params.limit.is_none() + && params + .search_term + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + && !params.use_state_db_only; + let session = self.get_session(server_id).map_err(|error| error.to_string())?; + let available_runtime_kinds = session.runtime_kinds(); + let runtime_kinds = + crate::types::list_runtime_kinds(requested_runtime_kinds, &available_runtime_kinds); + if runtime_kinds.is_empty() { + return Err( + "none of the requested agent runtimes are available on this controller".to_string(), + ); + } + info!( + "refresh_thread_list: fanout start server_id={} runtime_kinds={:?}", + server_id, runtime_kinds + ); + + // Fan out per-runtime concurrently so a slow runtime (e.g. a codex + // inbox with many threads) doesn't starve the others' first page. + let mut codex_visited = false; + let mut tasks = Vec::new(); + for runtime_kind in runtime_kinds { + if runtime_kind == "codex" { + if codex_visited { + continue; + } + codex_visited = true; + } + let client = Arc::clone(self); + let server_id = server_id.to_string(); + let initial_params = params.clone(); + tasks.push(async move { + let mut request_params = initial_params; + let mut ids = Vec::new(); + let mut completed = true; + loop { + let response: upstream::ThreadListResponse = match client + .request_typed_for_server_runtime( + &server_id, + runtime_kind.clone(), + upstream::ClientRequest::ThreadList { + request_id: upstream::RequestId::Integer(crate::next_request_id()), + params: request_params.clone(), + }, + ) + .await + { + Ok(response) => response, + Err(error) => { + warn!( + "refresh_thread_list: thread/list failed for runtime {:?} on server {}: {}", + runtime_kind, server_id, error + ); + completed = false; + break; + } + }; + let page = client.upsert_thread_list_page_for_runtime( + &server_id, + runtime_kind.clone(), + &response.data, + ); + ids.extend(page.into_iter().map(|thread| thread.id)); + let Some(next_cursor) = response.next_cursor else { + break; + }; + if !drain_all_pages { + break; + } + request_params.cursor = Some(next_cursor); + } + (runtime_kind, ids, completed) + }); + } + + let results = futures::future::join_all(tasks).await; + if results.iter().all(|(_, _, completed)| !completed) { + return Err("thread list failed for every runtime".to_string()); + } + // Only prune when every runtime finished cleanly; a partial result + // means we don't know the true thread set and would wipe unseen + // threads from healthy runtimes. + let all_completed = results.iter().all(|(_, _, ok)| *ok); + if all_completed && drain_all_pages { + let mut all_thread_ids = Vec::new(); + for (_, ids, _) in results { + all_thread_ids.extend(ids); + } + self.finalize_thread_list_sync(server_id, all_thread_ids); + // A full drain has reconciled the entire thread set, so paged + // session-list loading is complete for this server: clear the + // retained page cursors so `session_list_has_more` reads false + // and the home list stops offering "load more". + self.app_store.clear_thread_page_state(server_id); + } else if !all_completed { + warn!( + "refresh_thread_list: skipping finalize prune — partial fan-out result on server {}", + server_id + ); + } + Ok(()) + } + pub async fn start_remote_ssh_oauth_login(&self, server_id: &str) -> Result { let session = self.get_session(server_id)?; if session.config().is_local { @@ -4085,23 +4205,49 @@ pub(super) fn run_connect_warmup( ) { MobileClient::spawn_detached(async move { let runtime_kinds = session.runtime_kinds(); - if !runtime_kinds_support_account_sync(&runtime_kinds) { + if runtime_kinds_support_account_sync(&runtime_kinds) { + match refresh_account_from_app_server( + session, + Arc::clone(&app_store), + Arc::clone(&sessions), + server_id.as_str(), + ) + .await + { + Ok(()) => trace!("MobileClient: {label} account sync completed server_id={server_id}"), + Err(error) => { + warn!("MobileClient: {label} account sync failed server_id={server_id}: {error}") + } + } + } else { trace!( "MobileClient: {label} account sync skipped server_id={server_id} runtime_kinds={runtime_kinds:?}" ); - return; } - match refresh_account_from_app_server( - session, - Arc::clone(&app_store), - Arc::clone(&sessions), - server_id.as_str(), - ) - .await - { - Ok(()) => trace!("MobileClient: {label} account sync completed server_id={server_id}"), - Err(error) => { - warn!("MobileClient: {label} account sync failed server_id={server_id}: {error}") + + // Populate the session list right after connect so the user sees the + // server's threads (including opencode sessions) without opening a + // sessions view first. + if let Some(client) = crate::ffi::shared::shared_mobile_client_if_initialized() { + let params = upstream::ThreadListParams { + cursor: None, + limit: None, + sort_key: Some(upstream::ThreadSortKey::UpdatedAt), + sort_direction: Some(upstream::SortDirection::Desc), + model_providers: None, + source_kinds: None, + archived: Some(false), + cwd: None, + use_state_db_only: false, + search_term: None, + }; + match client.refresh_thread_list(&server_id, None, params).await { + Ok(()) => { + trace!("MobileClient: {label} thread list refreshed server_id={server_id}") + } + Err(error) => warn!( + "MobileClient: {label} thread list refresh failed server_id={server_id}: {error}" + ), } } }); diff --git a/shared/rust-bridge/codex-mobile-client/src/types/mod.rs b/shared/rust-bridge/codex-mobile-client/src/types/mod.rs index b74c5b98e..8bd0e8ba1 100644 --- a/shared/rust-bridge/codex-mobile-client/src/types/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/types/mod.rs @@ -11,3 +11,22 @@ pub use enums::*; pub use models::*; pub use server_requests::*; pub use voice::*; + +/// Resolve the runtime kinds a thread-list request should fan out to: the +/// requested set (intersected with what the server session exposes) or, when +/// none is requested, every available runtime kind. +pub(crate) fn list_runtime_kinds( + requested: Option>, + available: &[AgentRuntimeKind], +) -> Vec { + let mut runtimes = match requested { + Some(requested) if !requested.is_empty() => requested + .into_iter() + .filter(|kind| available.contains(kind)) + .collect(), + _ => available.to_vec(), + }; + runtimes.sort(); + runtimes.dedup(); + runtimes +} From c6c1b0ea7161f81c730954a2bc8d5d7afbaf7d1c Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 01:55:32 +0000 Subject: [PATCH 04/20] android: fix restoreLocalStateAfterReconnect gating Restore auth state when needsLocalAuthRestore is true, without the redundant loadSessionsPage call (the Rust post-connect warmup handles thread population). --- .../litter/android/state/AppLifecycleController.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt index 2e7d454d6..256937b25 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt @@ -241,15 +241,8 @@ class AppLifecycleController { results: List, ) { for (result in results) { - if (!result.needsLocalAuthRestore) { - continue - } - appModel.restoreStoredLocalAuthState(result.serverId) - runCatching { - // First page only — the home dashboard drives the rest via - // infinite scroll. Full drains still happen on pull-to-refresh - // and the dedicated Sessions screen. - appModel.loadSessionsPage(listOf(result.serverId), limit = 10u) + if (result.needsLocalAuthRestore) { + appModel.restoreStoredLocalAuthState(result.serverId) } } } From f9793492a419fb627e44d635ba7126caa253030e Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 02:12:22 +0000 Subject: [PATCH 05/20] home: reduce extra sessions-list top spacing from 8px to 4px --- .../java/com/litter/android/ui/home/HomeDashboardScreen.kt | 4 ++-- apps/ios/Sources/Litter/Views/HomeDashboardView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index 06ea47f46..281cb3e67 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -484,9 +484,9 @@ fun HomeDashboardScreen( val sysInsets = WindowInsets.systemBars.asPaddingValues() androidx.compose.foundation.layout.PaddingValues( top = if (topChromeHeight > 0.dp) { - topChromeHeight + 8.dp + topChromeHeight + 4.dp } else { - 72.dp + sysInsets.calculateTopPadding() + 8.dp + 72.dp + sysInsets.calculateTopPadding() + 4.dp }, bottom = 72.dp + sysInsets.calculateBottomPadding(), ) diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift index 401417dde..a8ca94b59 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift @@ -591,7 +591,7 @@ struct HomeDashboardView: View { openingKey: openingRecentSessionKey, zoomLevel: $zoomLevel, showCatFooter: chrome == .full, - topInset: 56, + topInset: 52, bottomInset: chrome == .full ? 140 : 24, hasMoreSessions: hasMoreSessions, isLoadingMoreSessions: isLoadingMoreSessions, From 1d921ae391f84431f1f512b3db6b65bd5a8b16b6 Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 02:18:40 +0000 Subject: [PATCH 06/20] fix: don't clear session page state after full drain in warmup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_thread_page_state after the warmup's full drain wiped session_pages → session_list_has_more = false → infinite scroll never triggered. After a full drain the cursor is naturally None and has_more reflects the server's actual state, so clearing is unnecessary and harmful. --- .../rust-bridge/codex-mobile-client/src/mobile_client/mod.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index cb6b58543..965e55f6a 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -2655,11 +2655,6 @@ impl MobileClient { all_thread_ids.extend(ids); } self.finalize_thread_list_sync(server_id, all_thread_ids); - // A full drain has reconciled the entire thread set, so paged - // session-list loading is complete for this server: clear the - // retained page cursors so `session_list_has_more` reads false - // and the home list stops offering "load more". - self.app_store.clear_thread_page_state(server_id); } else if !all_completed { warn!( "refresh_thread_list: skipping finalize prune — partial fan-out result on server {}", From 4b423790140273ba2d15d5564ba859b053ea6a50 Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 02:22:46 +0000 Subject: [PATCH 07/20] home: increase default session list from 10 to 20 --- .../java/com/litter/android/ui/home/HomeDashboardScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index 281cb3e67..5a396bb82 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -1514,8 +1514,8 @@ internal fun mergeHomeSessions( return candidates.take(recentLimit) } -private const val DefaultRecentLimit = 10 -private const val RecentLimitStep = 10 +private const val DefaultRecentLimit = 20 +private const val RecentLimitStep = 20 private fun placeholderPinnedSession( pinned: PinnedThreadKey, From 6e6b7d8b7f81d04851877f08f03a49371a15fbdb Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 02:24:39 +0000 Subject: [PATCH 08/20] ios: increase default session list from 10 to 20 --- apps/ios/Sources/Litter/Views/HomeDashboardModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift index 8ddcb2188..978efdf25 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift @@ -49,9 +49,9 @@ struct HomeDashboardPersistence { final class HomeDashboardModel { /// Initial number of unpinned recent sessions rendered on the home list. /// Grows by this amount on each "load more" scroll. - static let defaultRecentLimit = 10 + static let defaultRecentLimit = 20 /// How many additional unpinned sessions to reveal on each "load more". - static let recentLimitStep = 10 + static let recentLimitStep = 20 private struct Snapshot { let connectedServers: [HomeDashboardServer] From b9bf87236c455b1256df8f2c2abd6a57fdf05100 Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 02:33:39 +0000 Subject: [PATCH 09/20] fix: load first page only in warmup so infinite scroll can trigger The warmup drained all pages (limit: None) which set session_list_has_more = false immediately, preventing loadMoreSessions from ever firing. Now loads just the first page (limit: 20) so the server's has_more flag is accurate and the home list's infinite-scroll gate works. Also persists cursor state in refresh_thread_list so session_pages is non-empty after the warmup. --- .../src/mobile_client/mod.rs | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index 965e55f6a..b3f39b6af 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -2629,7 +2629,19 @@ impl MobileClient { &response.data, ); ids.extend(page.into_iter().map(|thread| thread.id)); - let Some(next_cursor) = response.next_cursor else { + let next_cursor = response.next_cursor; + let has_more = next_cursor.is_some(); + // Persist cursor state so the snapshot's + // `session_list_has_more` reflects the server's actual + // pagination. On a full drain this is overwritten with + // (None, false) after the loop. + client.app_store.set_thread_page_state( + &server_id, + &runtime_kind, + next_cursor.clone(), + has_more, + ); + let Some(next_cursor) = next_cursor else { break; }; if !drain_all_pages { @@ -2645,14 +2657,13 @@ impl MobileClient { if results.iter().all(|(_, _, completed)| !completed) { return Err("thread list failed for every runtime".to_string()); } - // Only prune when every runtime finished cleanly; a partial result - // means we don't know the true thread set and would wipe unseen - // threads from healthy runtimes. let all_completed = results.iter().all(|(_, _, ok)| *ok); + // Only prune on a full drain — a limited page load is additive and + // must not evict threads the server didn't return in this page. if all_completed && drain_all_pages { let mut all_thread_ids = Vec::new(); - for (_, ids, _) in results { - all_thread_ids.extend(ids); + for (_, ids, _) in &results { + all_thread_ids.extend(ids.iter().cloned()); } self.finalize_thread_list_sync(server_id, all_thread_ids); } else if !all_completed { @@ -2661,6 +2672,23 @@ impl MobileClient { server_id ); } + // Persist per-runtime cursor state so the snapshot's + // `session_list_has_more` reflects the server's actual pagination. + for (runtime_kind, _, completed) in &results { + if !completed { + continue; + } + // For a limited page load, the last page's `next_cursor` drives + // `has_more`. For a full drain the cursor is exhausted (None, + // has_more: false) — correct because all pages are loaded. + if drain_all_pages { + self.app_store + .set_thread_page_state(server_id, runtime_kind, None, false); + } + // On a limited load, `load_threads_page` already called + // `set_thread_page_state` with the actual cursor/has_more from + // the server response — nothing to do here. + } Ok(()) } @@ -4226,7 +4254,7 @@ pub(super) fn run_connect_warmup( if let Some(client) = crate::ffi::shared::shared_mobile_client_if_initialized() { let params = upstream::ThreadListParams { cursor: None, - limit: None, + limit: Some(20), sort_key: Some(upstream::ThreadSortKey::UpdatedAt), sort_direction: Some(upstream::SortDirection::Desc), model_providers: None, From 259a153550711a48cef22b4226d51eb14fe8103e Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 02:47:52 +0000 Subject: [PATCH 10/20] fix: load first session page synchronously on reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warmup (spawn_post_connect_warmup) is a fire-and-forget Tokio task. refreshSnapshot() in reconnectSavedServers() runs before the warmup finishes, so the Kotlin snapshot always has stale session data and session_list_has_more=false — making infinite scroll impossible. Replace refreshSnapshot() with loadSessionsPage() for connected servers, which synchronously loads the first page via Rust's load_threads_page, sets accurate cursor/has_more state, and then snapshots. --- .../litter/android/state/AppLifecycleController.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt index 256937b25..eef57b95f 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt @@ -55,7 +55,16 @@ class AppLifecycleController { restoreLocalStateAfterReconnect(appModel, results) val retryResults = appModel.reconnectController.reconnectSavedServers() restoreLocalStateAfterReconnect(appModel, retryResults) - appModel.refreshSnapshot() + // Load the first page of sessions synchronously so the snapshot has + // real thread data and accurate `session_list_has_more` cursors. + // bare refreshSnapshot() captured a stale store before the + // fire-and-forget warmup could finish. + val connectedServerIds = results.map { it.serverId } + if (connectedServerIds.isNotEmpty()) { + appModel.loadSessionsPage(connectedServerIds, limit = 20u) + } else { + appModel.refreshSnapshot() + } // If reconnecting saved alleycat servers triggered the iroh // endpoint bind, persist any freshly-generated device key. appModel.persistAlleycatSecretKeyIfNeeded() From 660e859249d5eceda7fddda34d1d31da79544da9 Mon Sep 17 00:00:00 2001 From: agent-harness Date: Wed, 19 Aug 2026 04:54:37 +0000 Subject: [PATCH 11/20] revert: remove extra top padding on home dashboard --- .../java/com/litter/android/ui/home/HomeDashboardScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index 5a396bb82..5d91640c0 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -484,9 +484,9 @@ fun HomeDashboardScreen( val sysInsets = WindowInsets.systemBars.asPaddingValues() androidx.compose.foundation.layout.PaddingValues( top = if (topChromeHeight > 0.dp) { - topChromeHeight + 4.dp + topChromeHeight } else { - 72.dp + sysInsets.calculateTopPadding() + 4.dp + 72.dp + sysInsets.calculateTopPadding() }, bottom = 72.dp + sysInsets.calculateBottomPadding(), ) From fbe6b26904bfc583701f19d5eb1dd97784eee232 Mon Sep 17 00:00:00 2001 From: DatScreamer Date: Tue, 18 Aug 2026 22:54:08 -0600 Subject: [PATCH 12/20] session-card: add 4dp spacing below title --- .../main/java/com/litter/android/ui/home/SessionCanvasRow.kt | 2 ++ apps/ios/Sources/Litter/Views/HomeDashboardView.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/SessionCanvasRow.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/SessionCanvasRow.kt index 6ace0596a..248607927 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/SessionCanvasRow.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/SessionCanvasRow.kt @@ -195,6 +195,8 @@ fun SessionCanvasRow( } } + Spacer(Modifier.height(4.dp)) + // MetaLine is shown ONLY at zoom 2 (iOS `if zoomLevel == 2`). // At zoom 3+, modelBadgeLine replaces it with the richer, // single-line model/time/server row. diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift index a8ca94b59..06580a6ab 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift @@ -796,6 +796,7 @@ struct SessionCanvasLine: View { } } .frame(maxWidth: .infinity, alignment: .leading) +.padding(.bottom, 4) // Detail below — gets full width. As zoom grows, additional // rows are revealed by the container's layout animation. From 4fb130d2b7258e7f405027f8811c3dab9ada543e Mon Sep 17 00:00:00 2001 From: DatScreamer Date: Mon, 24 Aug 2026 16:26:11 -0600 Subject: [PATCH 13/20] home: complete infinite scroll fixes for both platforms - iOS: local-only load-more path when server cursors are exhausted, firstPageLoaded/hadVisibleServers guards so the first page loads once runtime kinds are ready, and targetServerIds scoping for servers that still have remote pages. - iOS: fix loadMoreFired reset so a new page can trigger after the in-flight flag flips. - Android: add isLoadingMoreSessions in-flight guard, local-only recentLimit bump path, and LazyColumn listState wiring so the load-more footer reflects the right state. - Bump home session page size 10 -> 20 and fix preview wiring for the new sessionListHasMore field. --- .../android/ui/home/HomeDashboardScreen.kt | 89 +++++++++---------- apps/ios/Sources/Litter/LitterApp.swift | 8 +- apps/ios/Sources/Litter/Models/AppModel.swift | 4 +- .../Litter/Views/HomeDashboardModel.swift | 66 ++++++++++++-- .../Litter/Views/HomeSessionsScrollView.swift | 7 +- .../Sources/Litter/Views/PreviewSupport.swift | 3 +- 6 files changed, 115 insertions(+), 62 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index 5d91640c0..a2c95bb78 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.WindowInsets @@ -194,6 +196,7 @@ fun HomeDashboardScreen( // unpinned recent window grows via infinite scroll. var recentLimit by remember { mutableIntStateOf(DefaultRecentLimit) } var isRefreshingSessions by remember { mutableStateOf(false) } + var isLoadingMoreSessions by remember { mutableStateOf(false) } val homeSessions = remember(pinnedKeys, hiddenKeys, servers, allSessions, recentLimit) { mergeHomeSessions(pinnedKeys, hiddenKeys, servers, allSessions, recentLimit) } @@ -205,11 +208,17 @@ fun HomeDashboardScreen( } // --- Infinite scroll + pull-to-refresh -------------------------------- - val hasMoreSessions = remember(snapshot, scopedServerId) { + val hasMoreSessions = remember(snapshot, scopedServerId, allSessions.size, recentLimit) { val scoped = scopedServerId - snapshot?.servers?.any { server -> + // Server still has remote pages to fetch. + val serverHasMore = snapshot?.servers?.any { server -> (scoped.isNullOrEmpty() || server.serverId == scoped) && server.sessionListHasMore } ?: false + // OR: the Rust store already holds more sessions than the current + // display window (e.g. user scrolled before navigating away and + // `recentLimit` reset on re-entry). + val localHasMore = allSessions.size > recentLimit + serverHasMore || localHasMore } val listState = rememberLazyListState() val isLoadingMore by remember { @@ -222,7 +231,7 @@ fun HomeDashboardScreen( } fun loadMoreSessions() { - if (isRefreshingSessions) return + if (isRefreshingSessions || isLoadingMoreSessions) return val targetServers = snapshot?.servers ?.filter { server -> (scopedServerId.isNullOrEmpty() || server.serverId == scopedServerId) && @@ -230,16 +239,37 @@ fun HomeDashboardScreen( } ?.map { it.serverId } .orEmpty() - if (targetServers.isEmpty()) return + if (targetServers.isEmpty()) { + // Server cursors exhausted but local store may hold more + // sessions than the current display window (e.g. previously + // loaded pages after re-entry). + if (allSessions.size > recentLimit) { + isLoadingMoreSessions = true + scope.launch { + try { + recentLimit += RecentLimitStep + } finally { + isLoadingMoreSessions = false + } + } + } + return + } + isLoadingMoreSessions = true scope.launch { - appModel.loadSessionsPage(targetServers, limit = RecentLimitStep.toUInt()) - recentLimit += RecentLimitStep + try { + appModel.loadSessionsPage(targetServers, limit = RecentLimitStep.toUInt()) + recentLimit += RecentLimitStep + } finally { + isLoadingMoreSessions = false + } } } fun refreshAllSessions() { if (isRefreshingSessions) return isRefreshingSessions = true + isLoadingMoreSessions = false val targetServers = snapshot?.servers ?.filter { it.isConnected } ?.map { it.serverId } @@ -255,7 +285,7 @@ fun HomeDashboardScreen( } @OptIn(FlowPreview::class) - LaunchedEffect(recentSessions.size, hasMoreSessions, scopedServerId) { + LaunchedEffect(recentSessions.size, hasMoreSessions, scopedServerId, isLoadingMoreSessions) { snapshotFlow { listState.isScrollInProgress } .collect { scrolling -> if (!scrolling && isLoadingMore && hasMoreSessions) { @@ -441,40 +471,9 @@ fun HomeDashboardScreen( modifier = Modifier.fillMaxSize(), ) { LazyColumn( + state = listState, modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - // Pinch-to-zoom. `detectTransformGestures(panZoomLock = true)` - // lets single-finger vertical drags still reach the - // LazyColumn scroll, and only begins consuming when a - // true pinch is in progress. We accumulate the - // multiplicative zoom factor across the gesture so a - // slow pinch composes the same as a fast one, then - // round to a discrete level delta (same 0.4 threshold - // iOS uses at HomeDashboardView.swift:334-363). The - // outer while-loop resets accumulator state when the - // gesture ends and detectTransformGestures returns. - while (true) { - pinchBaseZoom = null - pinchAccumulator = 1f - detectTransformGestures(panZoomLock = true) { _, _, zoom, _ -> - if (zoom == 1f) return@detectTransformGestures - val base = pinchBaseZoom ?: zoomLevel.also { pinchBaseZoom = it } - pinchAccumulator *= zoom - val delta = ((pinchAccumulator - 1f) / 0.4f).roundToInt() - val next = (base + delta).coerceIn( - DashboardZoomPrefs.MIN_LEVEL, - DashboardZoomPrefs.MAX_LEVEL, - ) - if (next != zoomLevel) { - DashboardZoomPrefs.setLevel(context, next) - haptics.performHapticFeedback( - HapticFeedbackType.TextHandleMove, - ) - } - } - } - }, + .fillMaxSize(), contentPadding = run { // Respect system bars so list content can scroll under the // translucent top/bottom chrome *and* past the status/nav bar @@ -614,22 +613,22 @@ fun HomeDashboardScreen( } if (hasMoreSessions) { item(key = "home-load-more") { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp), ) { - if (isLoadingMore) { + if (isLoadingMoreSessions) { CircularProgressIndicator( color = LitterTheme.accent, strokeWidth = 2.dp, modifier = Modifier.size(14.dp), ) + Spacer(Modifier.height(4.dp)) } Text( - text = if (isLoadingMore) "Loading more sessions..." else "Pull up for more", + text = if (isLoadingMoreSessions) "Loading more sessions..." else "Pull up for more", color = LitterTheme.textMuted, fontSize = 12.sp, ) diff --git a/apps/ios/Sources/Litter/LitterApp.swift b/apps/ios/Sources/Litter/LitterApp.swift index 82608218c..e9d34882e 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -1440,11 +1440,11 @@ private struct HomeNavigationView: View { onInputModeChange: { mode in homeInputMode = mode }, - onSearchThreads: loadSearchThreads, onLoadMore: { homeDashboardModel.loadMore() }, onRefreshSessions: { await homeDashboardModel.refreshAll() }, hasMoreSessions: homeDashboardModel.hasMoreSessions, - isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions + isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions, + onSearchThreads: loadSearchThreads, ) } @@ -1489,11 +1489,11 @@ private struct HomeNavigationView: View { onInputModeChange: { mode in homeInputMode = mode }, - onSearchThreads: loadSearchThreads, onLoadMore: { homeDashboardModel.loadMore() }, onRefreshSessions: { await homeDashboardModel.refreshAll() }, hasMoreSessions: homeDashboardModel.hasMoreSessions, - isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions + isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions, + onSearchThreads: loadSearchThreads, ) } diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index d63ed3328..3a8fcaf9d 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -2254,7 +2254,7 @@ final class AppModel { private static let olderTurnPageSize: UInt32 = 20 /// Home sessions-list page size. Kept small so the first screen of the /// dashboard loads quickly and scrolling reveals more sessions in chunks. - static let homeSessionPageSize: UInt32 = 10 + static let homeSessionPageSize: UInt32 = 20 /// Fetch the first page of turns for a thread whose `initialTurnsLoaded` /// is still false. Called after a resume that sent `exclude_turns: true` @@ -2304,7 +2304,7 @@ final class AppModel { /// Used by the home dashboard's infinite scroll. On failure for a server, /// falls back to a full `listThreads` drain so the home list still /// populates (the retained Rust cursor state is cleared by that drain). - func loadThreadsPage(serverIds: [String], limit: UInt32 = Self.homeSessionPageSize) async { + func loadThreadsPage(serverIds: [String], limit: UInt32 = AppModel.homeSessionPageSize) async { let pageable = serverIds.filter { !loadingThreadPageServerIds.contains($0) } guard !pageable.isEmpty else { return } pageable.forEach { loadingThreadPageServerIds.insert($0) } diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift index 978efdf25..276ee5495 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift @@ -131,6 +131,14 @@ final class HomeDashboardModel { @ObservationIgnored private weak var appModel: AppModel? @ObservationIgnored private(set) var rebuildCount = 0 @ObservationIgnored private var isActive = false + /// Set to true after the first successful `loadFirstPageIfNeeded` call. + /// Guards against re-triggering from `refreshState` observation callbacks + /// once pages are already loaded. + @ObservationIgnored private var firstPageLoaded = false + /// Tracks whether we previously had visible servers, so we can detect + /// the transition from 0 → N visible servers and trigger the first-page + /// load at the right time (after the server health is marked connected). + @ObservationIgnored private var hadVisibleServers = false @ObservationIgnored private var observationGeneration = 0 @ObservationIgnored private var lastSessionSummaries: [AppSessionSummary] = [] /// Debounces rapid snapshot changes (e.g. the flood of store events @@ -242,6 +250,8 @@ final class HomeDashboardModel { func activate() { guard !isActive else { return } isActive = true + firstPageLoaded = false + hadVisibleServers = false refreshState() loadFirstPageIfNeeded() } @@ -249,6 +259,8 @@ final class HomeDashboardModel { func deactivate() { guard isActive else { return } isActive = false + firstPageLoaded = false + hadVisibleServers = false observationGeneration &+= 1 debouncedRefreshTask?.cancel() debouncedRefreshTask = nil @@ -313,9 +325,11 @@ final class HomeDashboardModel { connectedServers = snapshot.connectedServers allSessions = snapshot.recentSessions let visibleServerIds = Set(visibleServerIDs(from: snapshot.connectedServers)) - hasMoreSessions = snapshot.connectedServers.contains { server in + let serverHasMore = snapshot.connectedServers.contains { server in visibleServerIds.contains(server.id) && server.sessionListHasMore } + let localHasMore = snapshot.recentSessions.count > recentLimit + hasMoreSessions = serverHasMore || localHasMore recentSessions = Self.mergedHomeSessions( pinned: pinnedKeys, hidden: hiddenKeys, @@ -378,6 +392,18 @@ final class HomeDashboardModel { } reconcileSelectedProject() + + // Detect the first time sessions appear (from the Rust initial + // sync) and retry the first-page load. On iOS, `loadFirstPage` + // often fires before runtime kinds are available and returns 0. + // When sessions later populate from the sync, we retry once to + // establish a proper paged cursor with `has_more=true`. + if !firstPageLoaded && !allSessions.isEmpty && !hadVisibleServers { + hadVisibleServers = true + DispatchQueue.main.async { [weak self] in + self?.loadFirstPageIfNeeded() + } + } } private func reloadThreadPreferences() { @@ -401,12 +427,26 @@ final class HomeDashboardModel { guard let appModel, isActive, hasMoreSessions, !isLoadingMoreSessions else { return } let serverIds = visibleServerIDs(from: connectedServers) guard !serverIds.isEmpty else { return } + let targetServerIds = connectedServers + .filter { serverIds.contains($0.id) && $0.sessionListHasMore } + .map(\.id) + if targetServerIds.isEmpty { + guard allSessions.count > recentLimit else { + return + } + isLoadingMoreSessions = true + Task { @MainActor [weak self] in + guard let self else { return } + self.recentLimit += Self.recentLimitStep + self.refreshState() + self.isLoadingMoreSessions = false + } + return + } isLoadingMoreSessions = true Task { @MainActor [weak self] in guard let self else { return } - await appModel.loadThreadsPage(serverIds: serverIds, limit: UInt32(Self.recentLimitStep)) - // Reveal whatever is now in the store (a page was merged), then - // refresh `hasMoreSessions` from the updated snapshot. + await appModel.loadThreadsPage(serverIds: targetServerIds, limit: UInt32(Self.recentLimitStep)) self.recentLimit += Self.recentLimitStep self.refreshState() self.isLoadingMoreSessions = false @@ -417,13 +457,25 @@ final class HomeDashboardModel { /// active. Cheap no-op when a previous page (or a full drain from another /// surface) already populated the list. func loadFirstPageIfNeeded() { - guard let appModel, isActive else { return } + guard let appModel, isActive else { + return + } let serverIds = visibleServerIDs(from: connectedServers) - guard !serverIds.isEmpty else { return } + guard !serverIds.isEmpty else { + return + } Task { @MainActor [weak self] in guard let self, self.isActive else { return } - await appModel.loadThreadsPage(serverIds: serverIds, limit: UInt32(Self.recentLimit)) + let before = self.allSessions.count + await appModel.loadThreadsPage(serverIds: serverIds, limit: UInt32(self.recentLimit)) + let after = self.allSessions.count self.refreshState() + // Only mark as loaded when we actually got sessions back. + // If runtime kinds weren't ready yet (0 loaded), the observation + // callback will retry on the next snapshot update. + if after > before || after > 0 { + self.firstPageLoaded = true + } } } diff --git a/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift b/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift index d6f70962e..598edcf5c 100644 --- a/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift +++ b/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift @@ -337,7 +337,7 @@ final class HomeSessionsScrollUIView: UIView { self.isLoadingMoreSessions = isLoadingMoreSessions self.onLoadMore = onLoadMore self.refreshControlAction = onRefreshSessions - self.loadMoreFired = isLoadingMoreSessions ? true : loadMoreFired + self.loadMoreFired = isLoadingMoreSessions let zoomChanged = self.zoomLevel != zoomLevel && !isPinching let enteredPageFit = zoomChanged && zoomLevel == 4 self.zoomLevel = zoomLevel @@ -604,11 +604,12 @@ final class HomeSessionsScrollUIView: UIView { /// Debounced near-bottom load-more. Fires at most once per "page" (the /// guard resets after `apply` observes `isLoadingMoreSessions` flip). private func maybeTriggerLoadMore() { - guard hasMoreSessions, !isLoadingMoreSessions, !loadMoreFired else { return } let threshold = bounds.height * 0.6 let bottom = scrollView.contentOffset.y + bounds.height let contentBottom = scrollView.contentSize.height + scrollView.adjustedContentInset.bottom - if bottom >= contentBottom - threshold { + let nearBottom = bottom >= contentBottom - threshold + guard hasMoreSessions, !isLoadingMoreSessions, !loadMoreFired else { return } + if nearBottom { loadMoreFired = true onLoadMore?() } diff --git a/apps/ios/Sources/Litter/Views/PreviewSupport.swift b/apps/ios/Sources/Litter/Views/PreviewSupport.swift index 41f5d4edc..c94e3c344 100644 --- a/apps/ios/Sources/Litter/Views/PreviewSupport.swift +++ b/apps/ios/Sources/Litter/Views/PreviewSupport.swift @@ -374,7 +374,8 @@ enum LitterPreviewData { availableModels: sampleModels, agentRuntimes: [AgentRuntimeInfo(kind: "codex", name: "codex", displayName: "Codex", available: true)], connectionProgress: nil, - usageStats: nil + usageStats: nil, + sessionListHasMore: false ) let sessionSummaries = threads.map { thread in From d393140d75f928f11021dddd21e29c633f40845e Mon Sep 17 00:00:00 2001 From: DatScreamer Date: Mon, 24 Aug 2026 16:26:15 -0600 Subject: [PATCH 14/20] home: exclude subagent sessions from dashboard list Keep subagent/agent threads off the home session list. The opencode bridge now excludes them at the source (parentID filter + drop), so the list is defense-in-depth on top of that. Re-pins alleycat to d503bfc which contains the opencode-bridge subagent exclusion (thread/list drop + session.created guard). --- .../Litter/Views/HomeDashboardSupport.swift | 1 + shared/rust-bridge/Cargo.lock | 52 +++++++++---------- shared/rust-bridge/Cargo.toml | 8 +-- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift b/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift index a5317566f..1b0ddf17c 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardSupport.swift @@ -153,6 +153,7 @@ enum HomeDashboardSupport { let lineageByKey = ThreadLineageMap.compute(sessions: sessions) let sorted = sessions .filter { serversById[$0.key.serverId] != nil } + .filter { !$0.isSubagent } .sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } .compactMap { session -> HomeDashboardRecentSession? in guard let server = serversById[session.key.serverId] else { return nil } diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index d127da877..18b0b13c5 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -315,7 +315,7 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" dependencies = [ "alleycat-codex-proto", "anyhow", @@ -333,7 +333,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -355,7 +355,7 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" dependencies = [ "serde", "serde_json", @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -388,7 +388,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=179a56a0eb7e0b43eb81ace8fb67340eefe7c832#179a56a0eb7e0b43eb81ace8fb67340eefe7c832" +source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -492,7 +492,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -503,7 +503,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4522,7 +4522,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4902,7 +4902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6948,7 +6948,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -7034,7 +7034,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -7671,7 +7671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7765,7 +7765,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -8757,7 +8757,7 @@ dependencies = [ "pin-project-lite", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -8800,7 +8800,7 @@ checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ "cfg_aliases 0.2.2", "libc", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.61.2", ] @@ -8838,7 +8838,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -10234,7 +10234,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -10271,9 +10271,9 @@ dependencies = [ "cfg_aliases 0.2.2", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -11295,7 +11295,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -11354,7 +11354,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -11713,7 +11713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -12310,7 +12310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12972,7 +12972,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -13740,7 +13740,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -14460,7 +14460,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index a689dc5b5..2e8e57b00 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -26,10 +26,10 @@ codex-git-utils = { path = "../third_party/codex/codex-rs/git-utils" } # and host share one immutable Alleycat release revision. All crates come from # the same source so the `Bridge` trait identity is consistent; forked until # the project-aware opencode session enumeration lands upstream (dnakov/alleycat). -alleycat-bridge-core = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } -alleycat-pi-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } -alleycat-claude-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } -alleycat-opencode-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "179a56a0eb7e0b43eb81ace8fb67340eefe7c832" } +alleycat-bridge-core = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } +alleycat-pi-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } +alleycat-claude-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } +alleycat-opencode-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] } From 09c3ffc439ee35223c30da5fa6860ccbc10616ef Mon Sep 17 00:00:00 2001 From: DatScreamer Date: Mon, 24 Aug 2026 18:16:52 -0600 Subject: [PATCH 15/20] test: add sessionListHasMore arg to AppServerSnapshot constructions The infinite-scroll change added the required session_list_has_more field to AppServerSnapshot; five test files still built it with the old signature and failed to compile under xcodebuild test. Add sessionListHasMore: false to each. --- apps/ios/Tests/LitterTests/AppSnapshotRuntimeTests.swift | 3 ++- apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift | 3 ++- apps/ios/Tests/LitterTests/PerServerComplicationTests.swift | 3 ++- apps/ios/Tests/LitterTests/RunningTurnSnapshotTests.swift | 3 ++- apps/ios/Tests/LitterTests/WatchCompanionBridgeTests.swift | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/ios/Tests/LitterTests/AppSnapshotRuntimeTests.swift b/apps/ios/Tests/LitterTests/AppSnapshotRuntimeTests.swift index 003e49371..f0b35345b 100644 --- a/apps/ios/Tests/LitterTests/AppSnapshotRuntimeTests.swift +++ b/apps/ios/Tests/LitterTests/AppSnapshotRuntimeTests.swift @@ -373,7 +373,8 @@ final class AppSnapshotRuntimeTests: XCTestCase { availableModels: nil, agentRuntimes: [AgentRuntimeInfo(kind: .codex, name: "codex", displayName: "Codex", available: true)], connectionProgress: nil, - usageStats: nil + usageStats: nil, + sessionListHasMore: false ) let sessionSummaries = threads.map { thread in AppSessionSummary( diff --git a/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift b/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift index cb32d381e..a925fae19 100644 --- a/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift +++ b/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift @@ -537,7 +537,8 @@ final class HomeDashboardSupportTests: XCTestCase { ) ], connectionProgress: nil, - usageStats: nil + usageStats: nil, + sessionListHasMore: false ) } diff --git a/apps/ios/Tests/LitterTests/PerServerComplicationTests.swift b/apps/ios/Tests/LitterTests/PerServerComplicationTests.swift index ea3c4c6d2..f7e84cbb9 100644 --- a/apps/ios/Tests/LitterTests/PerServerComplicationTests.swift +++ b/apps/ios/Tests/LitterTests/PerServerComplicationTests.swift @@ -191,7 +191,8 @@ final class PerServerComplicationTests: XCTestCase { availableModels: nil, agentRuntimes: [AgentRuntimeInfo(kind: .codex, name: "codex", displayName: "Codex", available: true)], connectionProgress: nil, - usageStats: nil + usageStats: nil, + sessionListHasMore: false ) } diff --git a/apps/ios/Tests/LitterTests/RunningTurnSnapshotTests.swift b/apps/ios/Tests/LitterTests/RunningTurnSnapshotTests.swift index 42f6c4cee..cd4b2b711 100644 --- a/apps/ios/Tests/LitterTests/RunningTurnSnapshotTests.swift +++ b/apps/ios/Tests/LitterTests/RunningTurnSnapshotTests.swift @@ -144,7 +144,8 @@ final class RunningTurnSnapshotTests: XCTestCase { availableModels: nil, agentRuntimes: [AgentRuntimeInfo(kind: .codex, name: "codex", displayName: "Codex", available: true)], connectionProgress: nil, - usageStats: nil + usageStats: nil, + sessionListHasMore: false ) } diff --git a/apps/ios/Tests/LitterTests/WatchCompanionBridgeTests.swift b/apps/ios/Tests/LitterTests/WatchCompanionBridgeTests.swift index 27e2a987c..2788efb6f 100644 --- a/apps/ios/Tests/LitterTests/WatchCompanionBridgeTests.swift +++ b/apps/ios/Tests/LitterTests/WatchCompanionBridgeTests.swift @@ -696,7 +696,8 @@ final class WatchCompanionBridgeTests: XCTestCase { availableModels: nil, agentRuntimes: [AgentRuntimeInfo(kind: .codex, name: "codex", displayName: "Codex", available: true)], connectionProgress: nil, - usageStats: nil + usageStats: nil, + sessionListHasMore: false ) } From 2e5746a75a82719cd758a70881e32605a3c14c8c Mon Sep 17 00:00:00 2001 From: Franklin Date: Sat, 12 Sep 2026 15:58:24 -0400 Subject: [PATCH 16/20] fix: address session pagination review regressions --- .../android/ui/home/HomeDashboardScreen.kt | 27 ++-- .../android/HomeDashboardSupportTests.kt | 9 +- .../Litter/Views/HomeDashboardModel.swift | 20 +-- .../HomeDashboardSupportTests.swift | 8 +- .../src/mobile_client/mod.rs | 121 +++++++++++------- .../codex-mobile-client/src/store/reducer.rs | 106 +++++++++------ 6 files changed, 161 insertions(+), 130 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index a2c95bb78..a14b8bf25 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -1464,10 +1464,8 @@ private fun EmptyHomeFatCat(modifier: Modifier = Modifier) { * Merge rule: * - If the user has pinned anything, the home list starts with their pins * (in pin order, most-recent-pinned first). - * - Local Studio appends its unpinned recent sessions so a pin cannot hide - * newly synced Pi sessions. Other runtimes keep the existing pins-only rule. - * - If nothing is pinned, fill the list with up to 10 most-recent - * sessions so the home screen isn't empty. + * - Append the current window of unpinned recent sessions for every runtime. + * - Growing recentLimit reveals older sessions even when pins exist. * - Hidden threads are always excluded. */ internal fun mergeHomeSessions( @@ -1493,22 +1491,13 @@ internal fun mergeHomeSessions( } } val pinnedSet = pinned.toSet() - val localStudioServerIds = servers.asSequence() - .filter { server -> - usesServerConfiguredModelDefault( - server.agentRuntimes.filter { it.available }.map { it.kind }, - ) - } - .map { it.serverId } - .toSet() - val localStudioRecent = candidates.filter { session -> - session.key.serverId in localStudioServerIds && - PinnedThreadKey( - serverId = session.key.serverId, - threadId = session.key.threadId, - ) !in pinnedSet + val recent = candidates.filter { session -> + PinnedThreadKey( + serverId = session.key.serverId, + threadId = session.key.threadId, + ) !in pinnedSet } - return pinnedSessions + localStudioRecent + return pinnedSessions + recent.take(recentLimit) } return candidates.take(recentLimit) } diff --git a/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt b/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt index 9157a407b..e8f722816 100644 --- a/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt +++ b/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt @@ -55,18 +55,19 @@ class HomeDashboardSupportTests { } @Test - fun `Codex pins keep the existing pins-only behavior`() { + fun `Codex pins retain the growing recent window`() { val server = server("codex", "codex") val result = mergeHomeSessions( pinned = listOf(PinnedThreadKey(serverId = "codex", threadId = "stale")), hidden = emptyList(), servers = listOf(server), - allSessions = listOf(session("codex", "recent", "codex")), + allSessions = listOf(session("codex", "recent", "codex"), session("codex", "older", "codex")), + recentLimit = 1, ) - assertEquals(listOf("stale"), result.map { it.key.threadId }) - assertEquals(listOf("codex"), result.map { it.agentRuntimeKind }) + assertEquals(listOf("stale", "recent"), result.map { it.key.threadId }) + assertEquals(listOf("codex", "codex"), result.map { it.agentRuntimeKind }) } @Test diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift index 276ee5495..8653ab8fc 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift @@ -534,10 +534,8 @@ final class HomeDashboardModel { /// Merge rule: /// - If the user has pinned anything, the home list starts with their pins /// (in pin order, most-recent-pinned first). - /// - Local Studio appends its unpinned recent sessions so a pin cannot hide - /// newly synced Pi sessions. Other runtimes keep the pins-only rule. - /// - If nothing is pinned, fill the list with up to 10 most-recent - /// sessions so the home screen isn't empty. + /// - Append the current window of unpinned recent sessions for every runtime. + /// - Growing recentLimit reveals older sessions even when pins exist. /// - Hidden threads are always excluded. private static func mergedHomeSessions( pinned: [SavedThreadsStore.PinnedKey], @@ -559,18 +557,10 @@ final class HomeDashboardModel { return Array(candidates.prefix(recentLimit)) } let pinnedSet = Set(pinned) - let localStudioServerIds = Set( - servers.filter { server in - usesServerConfiguredModelDefault( - server.agentRuntimes.filter(\.available).map(\.kind) - ) - }.map(\.id) - ) - let localStudioRecent = candidates.filter { session in - localStudioServerIds.contains(session.key.serverId) && - !pinnedSet.contains(SavedThreadsStore.PinnedKey(threadKey: session.key)) + let recent = candidates.filter { session in + !pinnedSet.contains(SavedThreadsStore.PinnedKey(threadKey: session.key)) } - return resolvedPins + localStudioRecent + return resolvedPins + Array(recent.prefix(recentLimit)) } return Array(candidates.prefix(recentLimit)) } diff --git a/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift b/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift index a925fae19..a96f68a3e 100644 --- a/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift +++ b/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift @@ -59,7 +59,7 @@ final class HomeDashboardSupportTests: XCTestCase { XCTAssertEqual(model.recentSessions.map(\.key.threadId), ["pinned", "recent"]) } - func testCodexPinsKeepExistingPinsOnlyBehavior() async { + func testCodexPinsRetainRecentSessions() async { let appModel = AppModel() let pinnedKey = SavedThreadsStore.PinnedKey( threadKey: ThreadKey(serverId: "codex", threadId: "pinned") @@ -81,11 +81,11 @@ final class HomeDashboardSupportTests: XCTestCase { activeThread: nil ) ) - await waitUntil("Codex keeps its existing pins-only home list") { - model.recentSessions.map(\.key.threadId) == ["pinned"] + await waitUntil("Codex keeps pinned and recent sessions") { + model.recentSessions.map(\.key.threadId) == ["pinned", "recent"] } - XCTAssertEqual(model.recentSessions.map(\.key.threadId), ["pinned"]) + XCTAssertEqual(model.recentSessions.map(\.key.threadId), ["pinned", "recent"]) } func testLocalStudioDoesNotShowFalseOpenAISignInWarning() { diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index b3f39b6af..3334d4029 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -674,7 +674,9 @@ fn missing_runtime_kinds( .cloned() .collect::>(); let mut missing = requested_runtime_kinds - .iter().filter(|&kind| !existing.contains(kind)).cloned() + .iter() + .filter(|&kind| !existing.contains(kind)) + .cloned() .collect::>(); missing.sort(); missing @@ -2560,8 +2562,7 @@ impl MobileClient { requested_runtime_kinds: Option>, params: upstream::ThreadListParams, ) -> Result<(), String> { - let drain_all_pages = params.cursor.is_none() - && params.limit.is_none() + let hydrate_recents = params.cursor.is_none() && params .search_term .as_deref() @@ -2569,7 +2570,31 @@ impl MobileClient { .unwrap_or_default() .is_empty() && !params.use_state_db_only; - let session = self.get_session(server_id).map_err(|error| error.to_string())?; + let unfiltered = requested_runtime_kinds.as_ref().is_none_or(Vec::is_empty) + && params.model_providers.as_ref().is_none_or(Vec::is_empty) + && params.source_kinds.as_ref().is_none_or(Vec::is_empty) + && params.cwd.is_none() + && params.archived != Some(true) + && params + .search_term + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + && !params.use_state_db_only; + let tracks_home_cursor = unfiltered + && params.cursor.is_none() + && matches!( + params.sort_key, + None | Some(upstream::ThreadSortKey::UpdatedAt) + ) + && matches!( + params.sort_direction, + None | Some(upstream::SortDirection::Desc) + ); + let session = self + .get_session(server_id) + .map_err(|error| error.to_string())?; let available_runtime_kinds = session.runtime_kinds(); let runtime_kinds = crate::types::list_runtime_kinds(requested_runtime_kinds, &available_runtime_kinds); @@ -2596,11 +2621,15 @@ impl MobileClient { } let client = Arc::clone(self); let server_id = server_id.to_string(); - let initial_params = params.clone(); + let mut initial_params = params.clone(); + let budget = params.limit.unwrap_or(200) as usize; + initial_params.limit = Some(params.limit.unwrap_or(200)); tasks.push(async move { let mut request_params = initial_params; let mut ids = Vec::new(); let mut completed = true; + let mut exhausted = false; + let mut visited_cursors = std::collections::HashSet::new(); loop { let response: upstream::ThreadListResponse = match client .request_typed_for_server_runtime( @@ -2623,6 +2652,7 @@ impl MobileClient { break; } }; + let page_was_empty = response.data.is_empty(); let page = client.upsert_thread_list_page_for_runtime( &server_id, runtime_kind.clone(), @@ -2635,34 +2665,42 @@ impl MobileClient { // `session_list_has_more` reflects the server's actual // pagination. On a full drain this is overwritten with // (None, false) after the loop. - client.app_store.set_thread_page_state( - &server_id, - &runtime_kind, - next_cursor.clone(), - has_more, - ); + if tracks_home_cursor { + client.app_store.set_thread_page_state( + &server_id, + &runtime_kind, + next_cursor.clone(), + has_more, + ); + } let Some(next_cursor) = next_cursor else { + exhausted = true; break; }; - if !drain_all_pages { + if !hydrate_recents || ids.len() >= budget || page_was_empty + || !visited_cursors.insert(next_cursor.clone()) { break; } request_params.cursor = Some(next_cursor); } - (runtime_kind, ids, completed) + (runtime_kind, ids, completed, exhausted) }); } let results = futures::future::join_all(tasks).await; - if results.iter().all(|(_, _, completed)| !completed) { + if results.iter().all(|(_, _, completed, _)| !completed) { return Err("thread list failed for every runtime".to_string()); } - let all_completed = results.iter().all(|(_, _, ok)| *ok); + let all_completed = results.iter().all(|(_, _, ok, _)| *ok); // Only prune on a full drain — a limited page load is additive and // must not evict threads the server didn't return in this page. - if all_completed && drain_all_pages { + if all_completed + && hydrate_recents + && unfiltered + && results.iter().all(|(_, _, _, exhausted)| *exhausted) + { let mut all_thread_ids = Vec::new(); - for (_, ids, _) in &results { + for (_, ids, _, _) in &results { all_thread_ids.extend(ids.iter().cloned()); } self.finalize_thread_list_sync(server_id, all_thread_ids); @@ -2672,23 +2710,6 @@ impl MobileClient { server_id ); } - // Persist per-runtime cursor state so the snapshot's - // `session_list_has_more` reflects the server's actual pagination. - for (runtime_kind, _, completed) in &results { - if !completed { - continue; - } - // For a limited page load, the last page's `next_cursor` drives - // `has_more`. For a full drain the cursor is exhausted (None, - // has_more: false) — correct because all pages are loaded. - if drain_all_pages { - self.app_store - .set_thread_page_state(server_id, runtime_kind, None, false); - } - // On a limited load, `load_threads_page` already called - // `set_thread_page_state` with the actual cursor/has_more from - // the server response — nothing to do here. - } Ok(()) } @@ -2915,10 +2936,8 @@ impl MobileClient { { Ok(()) => { self.note_thread_runtime(key.clone(), runtime_kind.clone()); - let post_resume_active = self - .app_store - .thread_snapshot(&key) - .is_some_and(|thread| { + let post_resume_active = + self.app_store.thread_snapshot(&key).is_some_and(|thread| { thread.active_turn_id.is_some() || matches!(thread.info.status, ThreadSummaryStatus::Active) }); @@ -3295,9 +3314,8 @@ impl MobileClient { /// - Advances the retained cursor to the page's `next_cursor` and records /// `has_more` per runtime; the aggregate `has_more` is true when any /// runtime still has more pages. - /// - A per-runtime RPC failure marks that runtime exhausted and continues - /// with the others rather than failing the whole load; the caller can - /// fall back to a full `list_threads` drain if desired. + /// - A per-runtime RPC failure preserves its cursor for retry and continues + /// with the other runtimes without reporting the failed runtime exhausted. pub async fn load_threads_page( &self, server_id: &str, @@ -3330,9 +3348,13 @@ impl MobileClient { } codex_visited = true; } - let cursor = self.app_store.thread_page_cursor(server_id, &runtime_kind); + let page_state = self.app_store.thread_page_state(server_id, &runtime_kind); + if page_state.as_ref().is_some_and(|state| !state.has_more) { + continue; + } + let cursor = page_state.and_then(|state| state.cursor); let params = upstream::ThreadListParams { - cursor, + cursor: cursor.clone(), limit, sort_key: Some(upstream::ThreadSortKey::UpdatedAt), sort_direction: Some(upstream::SortDirection::Desc), @@ -3383,8 +3405,10 @@ impl MobileClient { server_id, error ); + // Preserve the failed cursor so a later scroll can retry this page. self.app_store - .set_thread_page_state(server_id, &runtime_kind, None, false); + .set_thread_page_state(server_id, &runtime_kind, cursor, true); + any_has_more = true; } } } @@ -4189,7 +4213,6 @@ impl MobileClient { pub fn set_voice_handoff_thread(&self, key: Option) { self.app_store.set_voice_handoff_thread(key); } - } /// Listener that feeds session output bytes into the reducer's ring @@ -4237,9 +4260,13 @@ pub(super) fn run_connect_warmup( ) .await { - Ok(()) => trace!("MobileClient: {label} account sync completed server_id={server_id}"), + Ok(()) => { + trace!("MobileClient: {label} account sync completed server_id={server_id}") + } Err(error) => { - warn!("MobileClient: {label} account sync failed server_id={server_id}: {error}") + warn!( + "MobileClient: {label} account sync failed server_id={server_id}: {error}" + ) } } } else { diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs index 689d86daa..312da3ece 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs @@ -18,13 +18,13 @@ use crate::conversation_uniffi::{ }; use crate::session::connection::ServerConfig; use crate::session::events::UiEvent; +#[cfg(test)] +use crate::types::PendingApprovalWithSeed; use crate::types::{ AgentRuntimeInfo, AgentRuntimeKind, PendingApproval, PendingApprovalKey, PendingApprovalSeed, - PendingUserInputAnswer, PendingUserInputKey, PendingUserInputRequest, - PendingUserInputSeed, ThreadInfo, ThreadKey, ThreadSummaryStatus, + PendingUserInputAnswer, PendingUserInputKey, PendingUserInputRequest, PendingUserInputSeed, + ThreadInfo, ThreadKey, ThreadSummaryStatus, }; -#[cfg(test)] -use crate::types::PendingApprovalWithSeed; use crate::types::{ AppModeKind, AppOperationStatus, AppPlanProgressSnapshot, AppPlanStep, AppThreadGoal, AppVoiceSessionPhase, AppVoiceTranscriptEntry, AppVoiceTranscriptUpdate, @@ -340,21 +340,18 @@ impl AppStoreReducer { .clone() } - /// Read the stored `thread/list` page cursor for a (server, runtime) pair. - /// `None` when that pair has never been paged (first page) or when a full - /// drain completed (all sessions already in the store). - pub fn thread_page_cursor( + /// Missing state means the first page; stored `has_more=false` means exhausted. + pub fn thread_page_state( &self, server_id: &str, runtime_kind: &AgentRuntimeKind, - ) -> Option { + ) -> Option { self.snapshot .read() .expect("app store lock poisoned") .session_pages - .get(&(server_id.to_string(), runtime_kind.clone()))? - .cursor - .clone() + .get(&(server_id.to_string(), runtime_kind.clone())) + .cloned() } /// Persist the `thread/list` page cursor + `has_more` flag for a @@ -366,11 +363,16 @@ impl AppStoreReducer { cursor: Option, has_more: bool, ) { - let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); - snapshot.session_pages.insert( - (server_id.to_string(), runtime_kind.clone()), - super::snapshot::SessionPageCursor { cursor, has_more }, - ); + { + let mut snapshot = self.write_snapshot(); + snapshot.session_pages.insert( + (server_id.to_string(), runtime_kind.clone()), + super::snapshot::SessionPageCursor { cursor, has_more }, + ); + } + self.emit(AppStoreUpdateRecord::ServerChanged { + server_id: server_id.to_string(), + }); } /// Drop all `thread/list` page cursor state for a server. Used after a @@ -1037,11 +1039,10 @@ impl AppStoreReducer { .mutate_thread_with_result(key, |thread| { let mut updated_item = None; let mut needs_reprojection = false; - let pending_overlay_index = - thread.local_overlay_items.iter().position(|item| { - item.id.starts_with(LOCAL_USER_MESSAGE_ITEM_PREFIX) - && item.source_turn_id.is_none() - }); + let pending_overlay_index = thread.local_overlay_items.iter().position(|item| { + item.id.starts_with(LOCAL_USER_MESSAGE_ITEM_PREFIX) + && item.source_turn_id.is_none() + }); if let Some(item) = pending_overlay_index .and_then(|index| thread.local_overlay_items.get_mut(index)) { @@ -2298,8 +2299,7 @@ impl AppStoreReducer { } VoiceDerivedUpdate::HandoffRequest(request) => { { - let mut snapshot = - self.write_snapshot(); + let mut snapshot = self.write_snapshot(); snapshot.voice_session.phase = Some(AppVoiceSessionPhase::Handoff); } self.emit(AppStoreUpdateRecord::VoiceSessionChanged); @@ -2310,8 +2310,7 @@ impl AppStoreReducer { } VoiceDerivedUpdate::SpeechStarted => { { - let mut snapshot = - self.write_snapshot(); + let mut snapshot = self.write_snapshot(); snapshot.voice_session.phase = Some(AppVoiceSessionPhase::Listening); } @@ -3882,6 +3881,27 @@ mod tests { }; use tokio::sync::broadcast::error::TryRecvError; + #[test] + fn session_page_state_distinguishes_unloaded_and_exhausted_and_notifies() { + let store = AppStoreReducer::new(); + let runtime = "codex".to_string(); + let mut updates = store.subscribe(); + assert!(store.thread_page_state("srv", &runtime).is_none()); + store.set_thread_page_state("srv", &runtime, Some("next".into()), true); + assert!( + matches!(updates.try_recv(), Ok(AppStoreUpdateRecord::ServerChanged { server_id }) if server_id == "srv") + ); + let state = store.thread_page_state("srv", &runtime).unwrap(); + assert_eq!(state.cursor.as_deref(), Some("next")); + assert!(state.has_more); + store.set_thread_page_state("srv", &runtime, None, false); + assert!(!store.thread_page_state("srv", &runtime).unwrap().has_more); + assert!(matches!( + updates.try_recv(), + Ok(AppStoreUpdateRecord::ServerChanged { .. }) + )); + } + fn make_thread_info(id: &str) -> ThreadInfo { ThreadInfo { id: id.to_string(), @@ -3902,7 +3922,6 @@ mod tests { } } - // ── Derived-state cache invalidation ────────────────────────────── // // These cover the memoization introduced for the agent-directory @@ -4185,7 +4204,10 @@ mod tests { let big = "x".repeat(200_000); let running = base(&big, AppOperationStatus::InProgress); - assert_eq!(item_fingerprint(&running), item_fingerprint(&running.clone())); + assert_eq!( + item_fingerprint(&running), + item_fingerprint(&running.clone()) + ); // Status change after a huge output body: same length, different // value — this is what the retained tail window is for. @@ -5753,15 +5775,13 @@ mod tests { server_id: "srv".to_string(), thread_id: "thread".to_string(), }; - let item = |id: &str, content: HydratedConversationItemContent| { - HydratedConversationItem { - id: id.to_string(), - content, - source_turn_id: None, - source_turn_index: None, - timestamp: None, - is_from_user_turn_boundary: false, - } + let item = |id: &str, content: HydratedConversationItemContent| HydratedConversationItem { + id: id.to_string(), + content, + source_turn_id: None, + source_turn_index: None, + timestamp: None, + is_from_user_turn_boundary: false, }; let mut live = ThreadSnapshot::from_info("srv", make_thread_info("thread")); live.items.push(item( @@ -6202,7 +6222,8 @@ mod tests { timestamp: None, is_from_user_turn_boundary: false, }, - ].into(); + ] + .into(); reducer.upsert_thread_snapshot(existing); let mut incoming = ThreadSnapshot::from_info("srv", make_thread_info("thread")); @@ -6233,7 +6254,8 @@ mod tests { timestamp: None, is_from_user_turn_boundary: false, }, - ].into(); + ] + .into(); let mut receiver = reducer.subscribe(); assert!(drain_updates(&mut receiver).is_empty()); @@ -6269,7 +6291,8 @@ mod tests { source_turn_index: Some(1), timestamp: None, is_from_user_turn_boundary: false, - }].into(); + }] + .into(); reducer.upsert_thread_snapshot(existing); let mut incoming = ThreadSnapshot::from_info("srv", make_thread_info("thread")); @@ -6285,7 +6308,8 @@ mod tests { source_turn_index: Some(1), timestamp: None, is_from_user_turn_boundary: false, - }].into(); + }] + .into(); let mut receiver = reducer.subscribe(); assert!(drain_updates(&mut receiver).is_empty()); From 10124581eab301bbfe4e66f9b8f52716e50f1ae2 Mon Sep 17 00:00:00 2001 From: Franklin Date: Sat, 12 Sep 2026 15:59:45 -0400 Subject: [PATCH 17/20] fix: restore native compilation for detached transport records --- .../java/com/litter/android/ui/discovery/DiscoveryScreen.kt | 3 ++- apps/ios/Sources/Litter/Models/SavedServer.swift | 1 + apps/ios/Sources/Litter/Models/SavedServerStore.swift | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt index 44c49777b..25e0d7ae9 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt @@ -1256,6 +1256,7 @@ internal fun SSHLoginDialog( onDismiss: () -> Unit, onConnect: suspend (SavedSshCredential, Boolean) -> String?, ) { + val context = LocalContext.current val scope = rememberCoroutineScope() var username by remember(server.id) { mutableStateOf(initialCredential?.username ?: "") } var authMethod by remember(server.id) { mutableStateOf(initialCredential?.method ?: SshAuthMethod.PASSWORD) } @@ -1403,7 +1404,7 @@ internal fun SSHLoginDialog( checked = detachedTransport, onCheckedChange = { detachedTransport = it - SavedServerStore(context).updateDetachedTransport(context, server.id, it) + SavedServerStore.updateDetachedTransport(context, server.id, it) }, enabled = !isConnecting, ) diff --git a/apps/ios/Sources/Litter/Models/SavedServer.swift b/apps/ios/Sources/Litter/Models/SavedServer.swift index 3c545a3eb..01ea41ec2 100644 --- a/apps/ios/Sources/Litter/Models/SavedServer.swift +++ b/apps/ios/Sources/Litter/Models/SavedServer.swift @@ -293,6 +293,7 @@ struct SavedServer: Codable, Identifiable, Equatable { preferredConnectionMode: preferredConnectionMode?.rawValue, preferredCodexPort: preferredCodexPort, sshPortForwardingEnabled: sshPortForwardingEnabled, + detachedTransport: false, websocketUrl: websocketURL, rememberedByUser: rememberedByUser, alleycatHost: alleycatHost, diff --git a/apps/ios/Sources/Litter/Models/SavedServerStore.swift b/apps/ios/Sources/Litter/Models/SavedServerStore.swift index 907687ea7..431d29d41 100644 --- a/apps/ios/Sources/Litter/Models/SavedServerStore.swift +++ b/apps/ios/Sources/Litter/Models/SavedServerStore.swift @@ -122,6 +122,7 @@ enum SavedServerStore { preferredConnectionMode: nil, preferredCodexPort: nil, sshPortForwardingEnabled: nil, + detachedTransport: false, websocketUrl: nil, rememberedByUser: true, alleycatHost: nil, From 9c6c1ab46de9f1cd36b1c1948819053ac74f8e70 Mon Sep 17 00:00:00 2001 From: Franklin Date: Sat, 12 Sep 2026 16:05:17 -0400 Subject: [PATCH 18/20] fix: preserve pinned bridge builds and cover scoped pagination --- .../Litter/Views/HomeDashboardModel.swift | 13 +-- .../HomeDashboardSupportTests.swift | 30 +++++ .../src/mobile_client/mod.rs | 69 +++++++----- .../src/mobile_client/store_listener.rs | 3 +- .../src/mobile_client/tests.rs | 104 +++++++++++++++++- .../src/mobile_client/thread_projection.rs | 11 +- .../src/store/reconcile.rs | 53 +++++---- tools/scripts/update-alleycat-main.sh | 2 +- 8 files changed, 220 insertions(+), 65 deletions(-) diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift index 8653ab8fc..9e9364e08 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift @@ -62,9 +62,8 @@ final class HomeDashboardModel { } private(set) var connectedServers: [HomeDashboardServer] = [] - /// Home list source: pinned threads first (in pin order). Local Studio - /// also keeps recent sessions visible after a pin so newly synced Pi - /// sessions remain discoverable. Hidden threads are always excluded. + /// Home list source: pins first, followed by the current recent window + /// for every runtime. Hidden threads are always excluded. private(set) var recentSessions: [HomeDashboardRecentSession] = [] /// Every session we know about across connected servers, newest first — /// used by the search view so the user can pick any thread. @@ -76,7 +75,7 @@ final class HomeDashboardModel { /// servers. Drives the loading row at the bottom of the home list. private(set) var isLoadingMoreSessions = false /// How many unpinned recent sessions the home list should render. Starts - /// at the page size (10) and grows as the user scrolls to load more. + /// at the page size (20) and grows as the user scrolls to load more. private(set) var recentLimit = HomeDashboardModel.defaultRecentLimit private(set) var pinnedKeys: [SavedThreadsStore.PinnedKey] = [] private(set) var hiddenKeys: [SavedThreadsStore.PinnedKey] = [] @@ -479,9 +478,9 @@ final class HomeDashboardModel { } } - /// Full reload of every session across the visible servers (pull-to- - /// refresh). Drains the whole cursor chain, resets the recent window, and - /// reconciles the home list. `completion` fires when the snapshot has + /// Reload recent sessions across the visible servers (pull-to-refresh). + /// Rust bounds hydration and retains cursors for older sessions. Resets + /// the recent window and reconciles the home list. `completion` fires when the snapshot has /// settled so the refresh control can end. func refreshAll(completion: (@MainActor () -> Void)? = nil) { guard let appModel, isActive else { return } diff --git a/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift b/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift index a96f68a3e..e87846787 100644 --- a/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift +++ b/apps/ios/Tests/LitterTests/HomeDashboardSupportTests.swift @@ -88,6 +88,36 @@ final class HomeDashboardSupportTests: XCTestCase { XCTAssertEqual(model.recentSessions.map(\.key.threadId), ["pinned", "recent"]) } + func testPinnedHomeListRevealsOlderCachedSessionsOnLoadMore() async { + let appModel = AppModel() + let pinnedKey = SavedThreadsStore.PinnedKey( + threadKey: ThreadKey(serverId: "codex", threadId: "pinned") + ) + let model = HomeDashboardModel( + persistence: persistence(pinned: [pinnedKey]), + observedRefreshDelayNanoseconds: 0 + ) + model.bind(appModel: appModel) + model.activate() + let recent = (1...25).map { + makeThreadSnapshot(serverId: "codex", threadId: "recent-\($0)", updatedAt: TimeInterval($0)) + } + appModel.applySnapshot(makeSnapshot( + servers: [makeServerSnapshot(id: "codex", name: "Codex")], + threads: [makeThreadSnapshot(serverId: "codex", threadId: "pinned", updatedAt: 30)] + recent, + activeThread: nil + )) + await waitUntil("Pinned list renders its initial recent window") { + model.recentSessions.count == 21 + } + XCTAssertEqual(model.recentSessions.first?.key.threadId, "pinned") + model.loadMore() + await waitUntil("Scrolling reveals the remaining cached sessions") { + model.recentSessions.count == 26 + } + XCTAssertEqual(Set(model.recentSessions.map(\.key.threadId)).count, 26) + } + func testLocalStudioDoesNotShowFalseOpenAISignInWarning() { let studio = makeServerSnapshot( id: "studio", diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index 3334d4029..29c5f5348 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -746,6 +746,34 @@ fn alleycat_dial_retry_delays(use_all_controller_agents: bool) -> &'static [u64] } } +fn thread_list_is_unfiltered( + requested_runtime_kinds: Option<&Vec>, + params: &upstream::ThreadListParams, +) -> bool { + requested_runtime_kinds.is_none_or(Vec::is_empty) + && params.model_providers.as_ref().is_none_or(Vec::is_empty) + && params.source_kinds.as_ref().is_none_or(Vec::is_empty) + && params.cwd.is_none() + && params.archived != Some(true) + && params + .search_term + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + && !params.use_state_db_only +} + +fn thread_list_tracks_home_cursor(unfiltered: bool, params: &upstream::ThreadListParams) -> bool { + unfiltered + && params.cursor.is_none() + && matches!(params.sort_key, Some(upstream::ThreadSortKey::UpdatedAt)) + && matches!( + params.sort_direction, + None | Some(upstream::SortDirection::Desc) + ) +} + impl MobileClient { /// Create a new `MobileClient`. pub fn new() -> Self { @@ -2570,28 +2598,8 @@ impl MobileClient { .unwrap_or_default() .is_empty() && !params.use_state_db_only; - let unfiltered = requested_runtime_kinds.as_ref().is_none_or(Vec::is_empty) - && params.model_providers.as_ref().is_none_or(Vec::is_empty) - && params.source_kinds.as_ref().is_none_or(Vec::is_empty) - && params.cwd.is_none() - && params.archived != Some(true) - && params - .search_term - .as_deref() - .unwrap_or_default() - .trim() - .is_empty() - && !params.use_state_db_only; - let tracks_home_cursor = unfiltered - && params.cursor.is_none() - && matches!( - params.sort_key, - None | Some(upstream::ThreadSortKey::UpdatedAt) - ) - && matches!( - params.sort_direction, - None | Some(upstream::SortDirection::Desc) - ); + let unfiltered = thread_list_is_unfiltered(requested_runtime_kinds.as_ref(), ¶ms); + let tracks_home_cursor = thread_list_tracks_home_cursor(unfiltered, ¶ms); let session = self .get_session(server_id) .map_err(|error| error.to_string())?; @@ -2663,8 +2671,8 @@ impl MobileClient { let has_more = next_cursor.is_some(); // Persist cursor state so the snapshot's // `session_list_has_more` reflects the server's actual - // pagination. On a full drain this is overwritten with - // (None, false) after the loop. + // pagination, including when bounded hydration stops + // before the final page. if tracks_home_cursor { client.app_store.set_thread_page_state( &server_id, @@ -2692,8 +2700,8 @@ impl MobileClient { return Err("thread list failed for every runtime".to_string()); } let all_completed = results.iter().all(|(_, _, ok, _)| *ok); - // Only prune on a full drain — a limited page load is additive and - // must not evict threads the server didn't return in this page. + // Only a complete, unfiltered scan can prove absent threads were + // deleted. Budget-limited and filtered loads remain additive. if all_completed && hydrate_recents && unfiltered @@ -3438,7 +3446,12 @@ impl MobileClient { ) .await .map_err(RpcError::Deserialization)?; - upsert_thread_snapshot_from_app_server_read_response(&self.app_store, server_id, response) + upsert_thread_snapshot_from_app_server_read_response( + &self.app_store, + server_id, + response, + false, + ) } pub async fn thread_unsubscribe( @@ -4008,7 +4021,7 @@ impl MobileClient { { Ok(response) => { if let Err(error) = upsert_thread_snapshot_from_app_server_read_response( - &app_store, &server_id, response, + &app_store, &server_id, response, true, ) { warn!( "MobileClient: failed to reconcile thread after user input for server={} thread={}: {}", diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs index 9baf94c9a..1be81bbd8 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs @@ -98,6 +98,7 @@ fn maybe_reconcile_idle_thread( &app_store, &key.server_id, response, + true, ) { warn!( "MobileClient: failed to reconcile idle thread for server={} thread={}: {}", @@ -184,7 +185,7 @@ fn maybe_hydrate_collab_agent_metadata( return; } if let Err(error) = upsert_thread_snapshot_from_app_server_read_response( - &app_store, &server_id, response, + &app_store, &server_id, response, false, ) { warn!( "MobileClient: failed to hydrate collab receiver metadata for server={} thread={}: {}", diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs index 17a699106..8c60cd437 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs @@ -8,6 +8,106 @@ mod mobile_client_tests { use std::path::PathBuf; use std::sync::{Arc, Mutex as StdMutex}; + #[test] + fn metadata_read_preserves_page_and_active_turn_despite_embedded_history() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread-1".to_string(), + }; + let mut existing = ThreadSnapshot::from_info("srv", make_thread_info("thread-1")); + existing.active_turn_id = Some("turn-1".to_string()); + existing.info.status = ThreadSummaryStatus::Active; + existing.items = vec![crate::conversation::make_error_item( + "paged-item".into(), + "kept".into(), + None, + )] + .into(); + existing.older_turns_cursor = Some("older".to_string()); + existing.initial_turns_loaded = true; + reducer.upsert_thread_snapshot(existing); + + let response: upstream::ThreadReadResponse = serde_json::from_value(serde_json::json!({ + "thread": { + "id": "thread-1", + "sessionId": "session-1", + "preview": "hi", + "ephemeral": false, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 2, + "status": { "type": "idle" }, + "path": "/tmp/thread", + "cwd": "/tmp/thread", + "cliVersion": "1.0.0", + "source": "cli", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": "thread", + "turns": [ + { + "id": "turn-1", + "items": [], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + } + ] + } + })) + .expect("thread/read response should deserialize"); + + upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response, false) + .expect("upsert should succeed"); + + let snapshot = reducer + .snapshot() + .threads + .get(&key) + .cloned() + .expect("thread snapshot should exist"); + + assert_eq!(snapshot.active_turn_id.as_deref(), Some("turn-1")); + assert_eq!(snapshot.older_turns_cursor.as_deref(), Some("older")); + assert!(snapshot.initial_turns_loaded); + assert_eq!(snapshot.items.len(), 1); + assert_eq!(snapshot.items[0].id, "paged-item"); + assert_eq!(snapshot.info.status, ThreadSummaryStatus::Active); + } + + #[test] + fn scoped_thread_lists_cannot_prune_or_replace_home_cursors() { + let mut params: upstream::ThreadListParams = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(thread_list_is_unfiltered(None, ¶ms)); + assert!(!thread_list_tracks_home_cursor(true, ¶ms)); + params.sort_key = Some(upstream::ThreadSortKey::UpdatedAt); + assert!(thread_list_tracks_home_cursor(true, ¶ms)); + let runtimes = vec!["claude".to_string()]; + assert!(!thread_list_is_unfiltered(Some(&runtimes), ¶ms)); + params.cwd = serde_json::from_value(serde_json::json!("/one-project")).unwrap(); + assert!(!thread_list_is_unfiltered(None, ¶ms)); + params.cwd = None; + params.search_term = Some("search".into()); + assert!(!thread_list_is_unfiltered(None, ¶ms)); + params.search_term = None; + params.archived = Some(true); + assert!(!thread_list_is_unfiltered(None, ¶ms)); + params.archived = None; + params.cursor = Some("search-cursor".into()); + assert!(!thread_list_tracks_home_cursor(true, ¶ms)); + params.cursor = None; + params.sort_direction = Some(upstream::SortDirection::Asc); + assert!(!thread_list_tracks_home_cursor(true, ¶ms)); + params.sort_direction = None; + assert!(!thread_list_tracks_home_cursor(false, ¶ms)); + } + #[test] fn account_sync_warmup_only_runs_when_codex_runtime_is_present() { assert!(runtime_kinds_support_account_sync(&["codex".to_string()])); @@ -827,7 +927,7 @@ mod mobile_client_tests { })) .expect("thread/read response should deserialize"); - upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response) + upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response, true) .expect("upsert should succeed"); let key = ThreadKey { @@ -891,7 +991,7 @@ mod mobile_client_tests { })) .expect("thread/read response should deserialize"); - upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response) + upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response, true) .expect("upsert should succeed"); let snapshot = reducer diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs index 009eb3d8f..0d1662c1b 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs @@ -452,7 +452,9 @@ pub(super) fn core_reasoning_effort_from_mobile( codex_protocol::openai_models::ReasoningEffort::XHigh } crate::types::ReasoningEffort::Max => codex_protocol::openai_models::ReasoningEffort::Max, - crate::types::ReasoningEffort::Ultra => codex_protocol::openai_models::ReasoningEffort::Ultra, + crate::types::ReasoningEffort::Ultra => { + codex_protocol::openai_models::ReasoningEffort::Ultra + } } } @@ -576,8 +578,12 @@ pub(super) async fn read_thread_response_from_app_server_runtime( pub(super) fn upsert_thread_snapshot_from_app_server_read_response( app_store: &AppStoreReducer, server_id: &str, - response: upstream::ThreadReadResponse, + mut response: upstream::ThreadReadResponse, + include_turns: bool, ) -> Result<(), RpcError> { + if !include_turns { + response.thread.turns.clear(); + } let turns = response.thread.turns.clone(); let thread_id = response.thread.id.clone(); let existing = app_store @@ -600,6 +606,7 @@ pub(super) fn upsert_thread_snapshot_from_app_server_read_response( if let Some(existing) = existing.as_ref() { copy_thread_runtime_fields(existing, &mut snapshot); } + crate::store::reconcile::apply_pagination_merge(existing.as_ref(), &mut snapshot, &turns); reconcile_active_turn(existing.as_ref(), &mut snapshot, &turns); app_store.upsert_thread_snapshot(snapshot); Ok(()) diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs index e81656183..4aed234dd 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs @@ -65,8 +65,8 @@ impl MobileClient { response, params.include_turns, ) - .map(|_| ()) - .map_err(RpcError::Deserialization) + .map(|_| ()) + .map_err(RpcError::Deserialization) } "thread/resume" => { let response = downcast_public_rpc_response::( @@ -446,7 +446,7 @@ impl MobileClient { /// does not flicker to empty while pagination loads the first page. Legacy /// servers ignore `exclude_turns` and return the embedded turns — we treat /// those as an authoritative hydration. -fn apply_pagination_merge( +pub(crate) fn apply_pagination_merge( existing: Option<&ThreadSnapshot>, target: &mut ThreadSnapshot, upstream_turns: &[upstream::Turn], @@ -622,24 +622,25 @@ fn merge_paged_turns( ) }); if let Some(id) = group_turn_id.as_deref() - && existing_turn_ids.contains(id) { - // A reconnect repair page is authoritative for completed turn - // text. Drop stale streaming assistant/reasoning placeholders - // absent from the replay, while preserving the historical - // turn-id dedupe for non-stream/user items. - if thread.active_turn_id.is_none() - && group_replays_existing_user - && group_has_persisted_text - { - prune_replayed_live_span(thread, &group_user_keys, &incoming_item_ids); - thread.items.retain(|item| { - incoming_item_ids.contains(&item.id) - || !is_stream_text_item(item) - || item.source_turn_id.as_deref() != Some(id) - }); - } - continue; + && existing_turn_ids.contains(id) + { + // A reconnect repair page is authoritative for completed turn + // text. Drop stale streaming assistant/reasoning placeholders + // absent from the replay, while preserving the historical + // turn-id dedupe for non-stream/user items. + if thread.active_turn_id.is_none() + && group_replays_existing_user + && group_has_persisted_text + { + prune_replayed_live_span(thread, &group_user_keys, &incoming_item_ids); + thread.items.retain(|item| { + incoming_item_ids.contains(&item.id) + || !is_stream_text_item(item) + || item.source_turn_id.as_deref() != Some(id) + }); } + continue; + } if thread.active_turn_id.is_none() && group_replays_existing_user && group_has_persisted_text @@ -1154,7 +1155,8 @@ mod tests { thread.items = vec![ live_user, assistant_item(None, "live-assistant-id", "partial"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), @@ -1178,7 +1180,8 @@ mod tests { assistant_item(Some("turn-0"), "older-assistant-id", "older final"), live_user, assistant_item(None, "live-assistant-id", "partial"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), @@ -1208,7 +1211,8 @@ mod tests { thread.items = vec![ live_user, assistant_item(Some("active-turn"), "active-assistant-id", "partial"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), @@ -1233,7 +1237,8 @@ mod tests { item_with_turn("turn-1", "persisted-user-id"), assistant_item(Some("turn-1"), "persisted-assistant-id", "final"), assistant_item(Some("turn-1"), "late-stream-assistant-id", "late duplicate"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), diff --git a/tools/scripts/update-alleycat-main.sh b/tools/scripts/update-alleycat-main.sh index 97d5d4eda..aeb33d973 100755 --- a/tools/scripts/update-alleycat-main.sh +++ b/tools/scripts/update-alleycat-main.sh @@ -45,7 +45,7 @@ resolve_alleycat_main() { } alleycat_is_pinned() { - grep -q 'dnakov/alleycat\.git.*rev = ' "$1" + grep -Eq '^alleycat[^=]*=.*rev[[:space:]]*=' "$1" } update_shared() { From 0c225d265216b5fb3f90af6fa866b53dc210be9f Mon Sep 17 00:00:00 2001 From: Franklin Date: Sat, 12 Sep 2026 16:15:24 -0400 Subject: [PATCH 19/20] fix: restore production bridge lineage and align daemon pin --- services/kittylitter/Cargo.lock | 30 +++++++++++++++--------------- services/kittylitter/Cargo.toml | 4 ++-- services/kittylitter/README.md | 19 +++++++++++++------ shared/rust-bridge/Cargo.lock | 17 ++++++++++++----- shared/rust-bridge/Cargo.toml | 10 +++++----- 5 files changed, 47 insertions(+), 33 deletions(-) diff --git a/services/kittylitter/Cargo.lock b/services/kittylitter/Cargo.lock index 753b997a7..b8a276ac9 100644 --- a/services/kittylitter/Cargo.lock +++ b/services/kittylitter/Cargo.lock @@ -67,7 +67,7 @@ dependencies = [ [[package]] name = "alleycat" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-acp-bridge", "alleycat-amp-bridge", @@ -113,7 +113,7 @@ dependencies = [ [[package]] name = "alleycat-acp-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -136,7 +136,7 @@ dependencies = [ [[package]] name = "alleycat-amp-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-claude-bridge", @@ -159,7 +159,7 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-codex-proto", "anyhow", @@ -178,7 +178,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -202,7 +202,7 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "chrono", "serde", @@ -213,7 +213,7 @@ dependencies = [ [[package]] name = "alleycat-devin-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-acp-bridge", "alleycat-bridge-core", @@ -228,7 +228,7 @@ dependencies = [ [[package]] name = "alleycat-droid-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -247,7 +247,7 @@ dependencies = [ [[package]] name = "alleycat-grok-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-acp-bridge", "alleycat-bridge-core", @@ -263,7 +263,7 @@ dependencies = [ [[package]] name = "alleycat-hermes-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "alleycat-local-studio-proto" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "hex", "serde", @@ -300,7 +300,7 @@ dependencies = [ [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -349,7 +349,7 @@ dependencies = [ [[package]] name = "alleycat-shell-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -2316,7 +2316,7 @@ dependencies = [ [[package]] name = "kittylitter" -version = "0.3.6" +version = "0.3.7" dependencies = [ "alleycat", "anyhow", diff --git a/services/kittylitter/Cargo.toml b/services/kittylitter/Cargo.toml index a6f4c3cef..23d3a4ecf 100644 --- a/services/kittylitter/Cargo.toml +++ b/services/kittylitter/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "kittylitter" -version = "0.3.6" +version = "0.3.7" edition = "2024" license = "GPL-3.0-only" authors = ["The Alleycat Authors"] @@ -22,7 +22,7 @@ name = "kittylitter" path = "src/main.rs" [dependencies] -alleycat = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } +alleycat = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } anyhow = "1" # cargo-dist builds with `cargo build --profile dist`. Mirrors the diff --git a/services/kittylitter/README.md b/services/kittylitter/README.md index 4a390f73d..117c5a874 100644 --- a/services/kittylitter/README.md +++ b/services/kittylitter/README.md @@ -1,12 +1,19 @@ # kittylitter -Distribution wrapper for the [alleycat](https://github.com/dnakov/alleycat) daemon. Ships the daemon to npm, Homebrew, and the platform installer scripts under the kittylitter brand. +Distribution wrapper for the [alleycat](https://github.com/makyinmars/alleycat) daemon. Ships the daemon to npm, Homebrew, and the platform installer scripts under the kittylitter brand. The wrapper itself is a 3-line `main()` that re-exports `alleycat::run("kittylitter")`. All daemon behavior lives in the alleycat crate; this crate exists so cargo-dist sees a `kittylitter` package name and produces correctly-named artifacts (`kittylitter-installer.sh`, `kittylitter.rb`, `kittylitter` on npm). -## Cutting a release +## Preparing a release -1. Push the alleycat changes to `dnakov/alleycat`. -2. Keep the `alleycat` dependency on `branch = "main"` and refresh it with `./tools/scripts/update-alleycat-main.sh --kittylitter`. -3. Bump `version` in this crate's `Cargo.toml` and the version of the kittylitter binary tracking it. -4. Tag `vX.Y.Z` on the litter repo. The `release.yml` workflow at the repo root builds and publishes. +1. Publish the reviewed Alleycat commit to the dependency repository. +2. Pin this manifest and `shared/rust-bridge/Cargo.toml` to the same immutable + revision and source. Update both Cargo lockfiles; `update-alleycat-main.sh` + intentionally leaves revision-pinned dependencies unchanged. +3. Bump this package's version and its own Cargo lockfile entry when changing + a previously released wrapper. Validate the wrapper and both mobile clients + against the intended revision. +4. Review the PR before merging. A push to `main` that changes this manifest + triggers `auto-release.yml`, which dispatches the release workflow for an + unpublished version. Preparing these changes on an unmerged PR does not + publish a release. diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index 18b0b13c5..d17b7e16d 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -315,13 +315,14 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-codex-proto", "anyhow", "async-trait", "base64 0.22.1", "chrono", + "dashmap", "futures", "serde", "serde_json", @@ -333,7 +334,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -343,10 +344,12 @@ dependencies = [ "chrono", "dashmap", "directories", + "futures", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "uuid", @@ -355,16 +358,18 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ + "chrono", "serde", "serde_json", + "thiserror 2.0.18", ] [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -388,7 +393,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/DatScreamer/alleycat.git?rev=d503bfc6da65dd979ada72723f62f753663f460c#d503bfc6da65dd979ada72723f62f753663f460c" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -403,9 +408,11 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "uuid", + "walkdir", ] [[package]] diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index 2e8e57b00..170640d13 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -25,11 +25,11 @@ codex-git-utils = { path = "../third_party/codex/codex-rs/git-utils" } # Pinned to the same immutable commit as the kittylitter daemon so the phone # and host share one immutable Alleycat release revision. All crates come from # the same source so the `Bridge` trait identity is consistent; forked until -# the project-aware opencode session enumeration lands upstream (dnakov/alleycat). -alleycat-bridge-core = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } -alleycat-pi-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } -alleycat-claude-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } -alleycat-opencode-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "d503bfc6da65dd979ada72723f62f753663f460c" } +# the production-lineage OpenCode and Amp fixes land in the upstream release. +alleycat-bridge-core = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } +alleycat-pi-bridge = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } +alleycat-claude-bridge = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } +alleycat-opencode-bridge = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] } From b08ea9249ab8bf128a449b9f9dda53cdcf34d233 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:08:24 +0000 Subject: [PATCH 20/20] fix: restore mobile CI compatibility --- .../java/com/litter/android/SavedServerTransportTest.kt | 6 +++--- apps/ios/Sources/Litter/Models/SavedServer.swift | 2 +- apps/ios/Sources/Litter/Models/SavedServerStore.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt b/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt index a37f5755f..81d69d4da 100644 --- a/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt +++ b/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt @@ -2,6 +2,7 @@ package com.litter.android import com.litter.android.state.SavedServer import com.litter.android.state.SavedServerStore +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -137,7 +138,7 @@ class SavedServerTransportTest { detachedTransport = true, ) - val restored = SavedServer.fromJson(server.toJson().toString()) + val restored = SavedServer.fromJson(JSONObject(server.toJson().toString())) assertTrue(restored.detachedTransport) } @@ -151,8 +152,7 @@ class SavedServerTransportTest { hostname = "10.0.0.5", port = 22, ) - val json = legacy.toJson().toString() - .replace("\"detachedTransport\":false", "") + val json = legacy.toJson().apply { remove("detachedTransport") } val restored = SavedServer.fromJson(json) diff --git a/apps/ios/Sources/Litter/Models/SavedServer.swift b/apps/ios/Sources/Litter/Models/SavedServer.swift index 01ea41ec2..740c7f39b 100644 --- a/apps/ios/Sources/Litter/Models/SavedServer.swift +++ b/apps/ios/Sources/Litter/Models/SavedServer.swift @@ -293,9 +293,9 @@ struct SavedServer: Codable, Identifiable, Equatable { preferredConnectionMode: preferredConnectionMode?.rawValue, preferredCodexPort: preferredCodexPort, sshPortForwardingEnabled: sshPortForwardingEnabled, - detachedTransport: false, websocketUrl: websocketURL, rememberedByUser: rememberedByUser, + detachedTransport: false, alleycatHost: alleycatHost, alleycatUdpPort: alleycatUdpPort, alleycatNodeId: alleycatNodeId, diff --git a/apps/ios/Sources/Litter/Models/SavedServerStore.swift b/apps/ios/Sources/Litter/Models/SavedServerStore.swift index 431d29d41..2d7cfc3b7 100644 --- a/apps/ios/Sources/Litter/Models/SavedServerStore.swift +++ b/apps/ios/Sources/Litter/Models/SavedServerStore.swift @@ -122,9 +122,9 @@ enum SavedServerStore { preferredConnectionMode: nil, preferredCodexPort: nil, sshPortForwardingEnabled: nil, - detachedTransport: false, websocketUrl: nil, rememberedByUser: true, + detachedTransport: false, alleycatHost: nil, alleycatUdpPort: nil, alleycatNodeId: nil,