diff --git a/apps/ios/Sources/Litter/LitterApp.swift b/apps/ios/Sources/Litter/LitterApp.swift index 030e46660..b6be0aa54 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -636,7 +636,7 @@ private struct HomeNavigationView: View { let bottomInset: CGFloat private enum HomeNavigationRoute: Hashable { - case sessions(serverId: String, title: String) + case sessions(serverId: String?, title: String) case conversation(ThreadKey) case realtimeVoice(ThreadKey) case conversationInfo(ThreadKey) @@ -746,10 +746,11 @@ private struct HomeNavigationView: View { case let .sessions(serverId, title): SessionsScreen( onOpenConversation: { key in + homeDashboardModel.pinThread(key) openConversation(key) }, - onInfo: { - navigationPath.append(.serverInfo(serverId: serverId)) + onInfo: serverId.map { id in + { navigationPath.append(.serverInfo(serverId: id)) } } ) .navigationTitle(title) @@ -1419,6 +1420,7 @@ private struct HomeNavigationView: View { onShowSettings: { appState.showSettings = true }, onShowApps: savedAppsStore.apps.isEmpty ? nil : { navigationPath.append(.appsList) }, onShowTerminal: terminalLauncher, + onBrowseSessions: { showSessions(for: homeDashboardModel.selectedServerId) }, onPinThread: pinThread, onUnpinThread: unpinThread, onHideThread: hideThread, @@ -1465,6 +1467,7 @@ private struct HomeNavigationView: View { onShowSettings: { appState.showSettings = true }, onShowApps: savedAppsStore.apps.isEmpty ? nil : { navigationPath.append(.appsList) }, onShowTerminal: terminalLauncher, + onBrowseSessions: { showSessions(for: homeDashboardModel.selectedServerId) }, onPinThread: pinThread, onUnpinThread: unpinThread, onHideThread: hideThread, @@ -1843,7 +1846,7 @@ private struct HomeNavigationView: View { } } - private func showSessions(for serverId: String) { + private func showSessions(for serverId: String?) { appState.sessionsSelectedServerFilterId = serverId appState.sessionsShowOnlyForks = false appState.showModelSelector = false @@ -1862,7 +1865,8 @@ private struct HomeNavigationView: View { } else if case .realtimeVoice = navigationPath.last { navigationPath.removeLast() } - navigationPath.append(.sessions(serverId: serverId, title: serverTitle(for: serverId))) + let title = serverId.map(serverTitle(for:)) ?? "All Sessions" + navigationPath.append(.sessions(serverId: serverId, title: title)) } private func serverTitle(for serverId: String) -> String { diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift index f7c0634fe..df2a8a94c 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift @@ -43,6 +43,7 @@ struct HomeDashboardView: View { /// hosting navigation when a "Saved Apps" launcher should be exposed. var onShowApps: (() -> Void)? = nil var onShowTerminal: (() -> Void)? = nil + var onBrowseSessions: (() -> Void)? = nil let onPinThread: (ThreadKey) -> Void let onUnpinThread: (ThreadKey) -> Void let onHideThread: (ThreadKey) -> Void @@ -319,6 +320,14 @@ struct HomeDashboardView: View { } .accessibilityLabel("Terminal") } + if let onBrowseSessions { + Button(action: onBrowseSessions) { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(LitterTheme.textSecondary) + } + .accessibilityLabel("All Sessions") + .accessibilityIdentifier("home.allSessionsButton") + } } } ToolbarItem(placement: .principal) { diff --git a/apps/ios/Sources/Litter/Views/SessionsScreen.swift b/apps/ios/Sources/Litter/Views/SessionsScreen.swift index 5edbe069e..9e50596f3 100644 --- a/apps/ios/Sources/Litter/Views/SessionsScreen.swift +++ b/apps/ios/Sources/Litter/Views/SessionsScreen.swift @@ -31,6 +31,8 @@ struct SessionsScreen: View { @State private var sessionSearchDebounceTask: Task? @State private var hasLoadedInitialSessions = false @State private var isSessionLoadInFlight = false + @State private var sessionHydrationLimit = SessionsScreen.sessionHydrationPageSize + private static let sessionHydrationPageSize: UInt32 = 100 private let autoLoadSessions: Bool private let onOpenConversation: (ThreadKey) -> Void private let onInfo: (() -> Void)? @@ -782,6 +784,10 @@ struct SessionsScreen: View { } } } + + if derived.allThreads.count >= Int(sessionHydrationLimit) { + loadMoreSessionsRow + } } .padding(.leading, 4) .padding(.trailing, 8) @@ -1134,6 +1140,29 @@ struct SessionsScreen: View { } } + private var loadMoreSessionsRow: some View { + Button { + sessionHydrationLimit += Self.sessionHydrationPageSize + refreshSessions() + } label: { + HStack(spacing: 6) { + if isLoading { + ProgressView() + .controlSize(.small) + .tint(LitterTheme.accent) + } + Text("Load more sessions") + .litterFont(.footnote) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + .foregroundColor(LitterTheme.accent) + .disabled(isLoading) + .accessibilityIdentifier("sessions.loadMore") + } + private func loadSessions() async { let signpostID = OSSignpostID(log: sessionsScreenSignpostLog) os_signpost(.begin, log: sessionsScreenSignpostLog, name: "LoadSessions", signpostID: signpostID) @@ -1152,6 +1181,7 @@ struct SessionsScreen: View { isLoading = true let serverIds = selectedServerFilterId.map { [$0] } ?? connectedServerIds let runtimeKinds = selectedRuntimeKindFilter.map { [$0] } + let hydrationLimit = sessionHydrationLimit let client = appModel.client let failures = await withTaskGroup(of: String?.self) { group in for serverId in serverIds { @@ -1159,7 +1189,7 @@ struct SessionsScreen: View { do { try await client.listThreads( serverId: serverId, - params: AppListThreadsRequest(limit: nil, sortKey: .updatedAt, sortDirection: .desc, runtimeKinds: runtimeKinds) + params: AppListThreadsRequest(limit: hydrationLimit, sortKey: .updatedAt, sortDirection: .desc, runtimeKinds: runtimeKinds) ) return nil } catch { 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..7b33a34e1 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -54,6 +54,23 @@ macro_rules! req { const AMP_VISIBLE_MODES: [&str; 3] = ["smart", "rush", "deep"]; const MODEL_LIST_RUNTIME_TIMEOUT: Duration = Duration::from_secs(20); +const THREAD_LIST_HYDRATION_BUDGET: usize = 200; + +fn thread_list_hydration_budget(params: &types::AppListThreadsRequest) -> Option { + let hydrates_recents = params.cursor.is_none() + && params + .search_term + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + && !params.use_state_db_only; + hydrates_recents.then(|| { + params + .limit + .map_or(THREAD_LIST_HYDRATION_BUDGET, |limit| limit as usize) + }) +} fn normalize_amp_mode_name(value: &str) -> String { value @@ -699,15 +716,7 @@ 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 hydration_budget = thread_list_hydration_budget(¶ms); let params: upstream::ThreadListParams = params.into(); let session = c .get_session(&server_id) @@ -763,6 +772,7 @@ impl AppClient { let mut request_params = initial_params; let mut ids = Vec::new(); let mut completed = true; + let mut exhausted = false; loop { let response: upstream::ThreadListResponse = match rpc_runtime::( @@ -783,6 +793,7 @@ impl AppClient { break; } }; + let page_was_empty = response.data.is_empty(); let page = client.upsert_thread_list_page_for_runtime( &server_id, runtime_kind.clone(), @@ -790,19 +801,23 @@ impl AppClient { ); ids.extend(page.into_iter().map(|thread| thread.id)); let Some(next_cursor) = response.next_cursor else { + exhausted = true; break; }; - if !drain_all_pages { + let Some(budget) = hydration_budget else { + break; + }; + if page_was_empty || ids.len() >= budget { 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(ClientError::Rpc( "thread list failed for every runtime".into(), )); @@ -814,10 +829,11 @@ impl AppClient { // 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 all_completed = results.iter().all(|(_, _, ok, _)| *ok); + let all_exhausted = results.iter().all(|(_, _, _, exhausted)| *exhausted); + if all_completed && all_exhausted && hydration_budget.is_some() { let mut all_thread_ids = Vec::new(); - for (_, ids, _) in results { + for (_, ids, _, _) in results { all_thread_ids.extend(ids); } c.finalize_thread_list_sync(&server_id, all_thread_ids); @@ -2986,11 +3002,11 @@ Widget construction guidelines (for reference when making UI decisions):\n\n\ #[cfg(test)] 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, + ImageViewSource, THREAD_LIST_HYDRATION_BUDGET, 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, + thread_list_hydration_budget, }; use crate::store::snapshot::ServerTransportDiagnostics; use crate::store::{AppSnapshot, ServerHealthSnapshot, ServerSnapshot}; @@ -3122,6 +3138,53 @@ mod tests { assert!(list_runtime_kinds(Some(vec!["codex".to_string()]), &local_studio).is_empty()); } + #[test] + fn thread_list_hydration_budget_bounds_recents_and_skips_scoped_queries() { + let request = |limit: Option, + cursor: Option<&str>, + search_term: Option<&str>, + use_state_db_only: bool| { + crate::types::AppListThreadsRequest { + cursor: cursor.map(str::to_string), + limit, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + search_term: search_term.map(str::to_string), + use_state_db_only, + runtime_kinds: None, + } + }; + + assert_eq!( + thread_list_hydration_budget(&request(None, None, None, false)), + Some(THREAD_LIST_HYDRATION_BUDGET) + ); + assert_eq!( + thread_list_hydration_budget(&request(Some(100), None, None, false)), + Some(100) + ); + assert_eq!( + thread_list_hydration_budget(&request(None, None, Some(" "), false)), + Some(THREAD_LIST_HYDRATION_BUDGET) + ); + assert_eq!( + thread_list_hydration_budget(&request(None, Some("cursor"), None, false)), + None + ); + assert_eq!( + thread_list_hydration_budget(&request(None, None, Some("query"), false)), + None + ); + assert_eq!( + thread_list_hydration_budget(&request(None, None, None, true)), + None + ); + } + #[test] fn amp_mode_fallback_adds_builtin_modes() { let mut models = vec![test_model("gpt-5.2", "codex".to_string())];