From a1e069d4431ecaa2415db1929cc31ba6db1c644f Mon Sep 17 00:00:00 2001 From: bootoshi <127834715+kingbootoshi@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:24:25 -0400 Subject: [PATCH 1/2] feat(ios): browse and resume existing on-disk sessions from home The server side already surfaces every on-disk CLI session through thread/list: the alleycat claude bridge hydrates ~/.claude/projects//.jsonl into its thread index at startup, and codex app-server enumerates ~/.codex/sessions rollouts directly. SessionsScreen already lists those threads grouped by workspace with previews and resumes them over the existing thread/resume path (claude --resume under the hood). What was missing was reachability and persistence: SessionsScreen was only navigable from inside an open conversation, and the home list shows pinned threads only, so sessions started on the Mac never surfaced and never stuck. Add an All Sessions button to the home toolbar that pushes the existing SessionsScreen (scoped to the selected server, or all servers when none is selected), allow the sessions route to carry no server filter, and pin a thread when it is opened from the browser so a resumed on-disk session registers as a durable litter thread on the home list. Co-Authored-By: Claude Fable 5 --- apps/ios/Sources/Litter/LitterApp.swift | 14 +++++++++----- .../Sources/Litter/Views/HomeDashboardView.swift | 9 +++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) 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) { From b2acc07798280a27f20d1ed9ea26b7ccf5692c2b Mon Sep 17 00:00:00 2001 From: bootoshi <127834715+kingbootoshi@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:22:13 -0400 Subject: [PATCH 2/2] perf(sessions): bound thread-list hydration so huge session dirs cannot stall the UI At real scale (thousands of on-disk sessions in ~/.claude/projects and ~/.codex/sessions) the All Sessions screen froze the main thread for minutes: list_threads drained every thread/list page whenever no limit was set, and each of the thousands of resulting ThreadUpserted events pays an O(n) scan plus a full snapshot copy on the main actor, so total hydration cost grew quadratically while SwiftUI re-diffed the growing list underneath. Give list_threads a hydration budget at the fan-out source: page draining stops once the requested limit (or a 200-thread default for legacy nil-limit callers such as the pinned-listing repair paths) is reached per runtime. Cursor, search, and state-db-only queries keep their existing single-page behavior. The finalize prune now requires every runtime to have fully exhausted its cursor, since a truncated drain cannot know the true thread set. SessionsScreen requests 100 most recent per runtime and grows the budget only through an explicit Load more row, so worst-case work per user action is one bounded page instead of the entire session history. Search stays a local filter over loaded rows and triggers no hydration. Co-Authored-By: Claude Fable 5 --- .../Sources/Litter/Views/SessionsScreen.swift | 32 +++++- .../codex-mobile-client/src/ffi/client.rs | 103 ++++++++++++++---- 2 files changed, 114 insertions(+), 21 deletions(-) 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())];