Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/ios/Sources/Litter/LitterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions apps/ios/Sources/Litter/Views/HomeDashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 31 additions & 1 deletion apps/ios/Sources/Litter/Views/SessionsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ struct SessionsScreen: View {
@State private var sessionSearchDebounceTask: Task<Void, Never>?
@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)?
Expand Down Expand Up @@ -782,6 +784,10 @@ struct SessionsScreen: View {
}
}
}

if derived.allThreads.count >= Int(sessionHydrationLimit) {
loadMoreSessionsRow
}
}
.padding(.leading, 4)
.padding(.trailing, 8)
Expand Down Expand Up @@ -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)
Expand All @@ -1152,14 +1181,15 @@ 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 {
group.addTask {
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 {
Expand Down
103 changes: 83 additions & 20 deletions shared/rust-bridge/codex-mobile-client/src/ffi/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
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
Expand Down Expand Up @@ -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(&params);
let params: upstream::ThreadListParams = params.into();
let session = c
.get_session(&server_id)
Expand Down Expand Up @@ -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::<upstream::ThreadListResponse>(
Expand All @@ -783,26 +793,31 @@ 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(),
&response.data,
);
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(),
));
Expand All @@ -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);
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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<u32>,
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())];
Expand Down