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 865cb0aba..a58a033b6 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 @@ -57,7 +57,16 @@ class AppLifecycleController { val retryResults = appModel.reconnectController.reconnectSavedServers() retryResults.forEach { appModel.recordSshHostKeyChange(it.serverId, it.errorMessage) } 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() @@ -254,12 +263,8 @@ class AppLifecycleController { results: List, ) { for (result in results) { - if (!result.needsLocalAuthRestore) { - continue - } - appModel.restoreStoredLocalAuthState(result.serverId) - runCatching { - appModel.refreshSessions(listOf(result.serverId)) + if (result.needsLocalAuthRestore) { + appModel.restoreStoredLocalAuthState(result.serverId) } } } 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 1cf130f7c..1638c2f59 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 @@ -109,8 +109,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) ------------------------- @@ -465,7 +465,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() @@ -508,6 +511,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?, @@ -885,6 +913,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 @@ -932,19 +961,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", @@ -969,6 +1006,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 ce646cab8..cd8690497 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 @@ -1273,6 +1312,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 11a4b516c..e672b5aec 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 @@ -36,6 +38,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 +57,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 +72,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 +80,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 +105,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 +133,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 @@ -206,9 +215,13 @@ 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) } + var isLoadingMoreSessions by remember { mutableStateOf(false) } + val homeSessions = remember(pinnedKeys, hiddenKeys, servers, allSessions, recentLimit) { + mergeHomeSessions(pinnedKeys, hiddenKeys, servers, allSessions, recentLimit) } val scopedServerId = selectedProject?.serverId ?: selectedServerId @@ -217,6 +230,93 @@ fun HomeDashboardScreen( else homeSessions.filter { it.key.serverId == scopedServerId } } + // --- Infinite scroll + pull-to-refresh -------------------------------- + val hasMoreSessions = remember(snapshot, scopedServerId, allSessions.size, recentLimit) { + val scoped = scopedServerId + // 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 { + 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 || isLoadingMoreSessions) return + val targetServers = snapshot?.servers + ?.filter { server -> + (scopedServerId.isNullOrEmpty() || server.serverId == scopedServerId) && + server.sessionListHasMore + } + ?.map { it.serverId } + .orEmpty() + 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 { + 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 } + .orEmpty() + scope.launch { + try { + appModel.refreshSessions(targetServers) + } finally { + recentLimit = DefaultRecentLimit + isRefreshingSessions = false + } + } + } + + @OptIn(FlowPreview::class) + LaunchedEffect(recentSessions.size, hasMoreSessions, scopedServerId, isLoadingMoreSessions) { + snapshotFlow { listState.isScrollInProgress } + .collect { scrolling -> + if (!scrolling && isLoadingMore && hasMoreSessions) { + loadMoreSessions() + } + } + } + fun pinThreadOnHome(key: ThreadKey) { val displacedKeys = if (pinnedKeys.isEmpty()) { recentSessions @@ -388,41 +488,15 @@ 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( + 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 @@ -560,12 +634,37 @@ fun HomeDashboardScreen( ) } } + if (hasMoreSessions) { + item(key = "home-load-more") { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) { + if (isLoadingMoreSessions) { + CircularProgressIndicator( + color = LitterTheme.accent, + strokeWidth = 2.dp, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.height(4.dp)) + } + Text( + text = if (isLoadingMoreSessions) "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 @@ -1388,10 +1487,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( @@ -1399,6 +1496,7 @@ internal fun mergeHomeSessions( hidden: List, servers: List, allSessions: List, + recentLimit: Int = DefaultRecentLimit, ): List { val hiddenSet = hidden.toSet() val candidates = allSessions.filter { @@ -1416,26 +1514,20 @@ 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(10) + return candidates.take(recentLimit) } +private const val DefaultRecentLimit = 20 +private const val RecentLimitStep = 20 + private fun placeholderPinnedSession( pinned: PinnedThreadKey, server: AppServerSnapshot, 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/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt b/apps/android/app/src/test/java/com/litter/android/HomeDashboardSupportTests.kt index 7bd63e4f1..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 @@ -154,6 +155,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 b9e1e3483..bb8dfe953 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -1459,7 +1459,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, + onSearchThreads: loadSearchThreads, ) } @@ -1505,7 +1509,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, + onSearchThreads: loadSearchThreads, ) } diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index aff247b9d..e3520cb6c 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -162,6 +162,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) { @@ -2280,6 +2281,9 @@ final class AppModel { // 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 + /// 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 = 20 /// Fetch the first page of turns for a thread whose `initialTurnsLoaded` /// is still false. Called after a resume that sent `exclude_turns: true` @@ -2324,6 +2328,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 = AppModel.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..8653ab8fc 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 = 20 + /// How many additional unpinned sessions to reveal on each "load more". + static let recentLimitStep = 20 + 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] = [] @@ -116,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 @@ -227,12 +250,17 @@ final class HomeDashboardModel { func activate() { guard !isActive else { return } isActive = true + firstPageLoaded = false + hadVisibleServers = false refreshState() + loadFirstPageIfNeeded() } func deactivate() { guard isActive else { return } isActive = false + firstPageLoaded = false + hadVisibleServers = false observationGeneration &+= 1 debouncedRefreshTask?.cancel() debouncedRefreshTask = nil @@ -296,11 +324,18 @@ final class HomeDashboardModel { rebuildCount += 1 connectedServers = snapshot.connectedServers allSessions = snapshot.recentSessions + let visibleServerIds = Set(visibleServerIDs(from: snapshot.connectedServers)) + 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, allSessions: snapshot.recentSessions, - servers: snapshot.connectedServers + servers: snapshot.connectedServers, + recentLimit: recentLimit ) lastSessionSummaries = snapshot.sessionSummaries projects = deriveProjects(sessions: snapshot.sessionSummaries) @@ -357,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() { @@ -364,6 +411,100 @@ 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 } + 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: targetServerIds, limit: UInt32(Self.recentLimitStep)) + 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 } + 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 + } + } + } + + /// 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 @@ -393,16 +534,15 @@ 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], hidden: [SavedThreadsStore.PinnedKey], allSessions: [HomeDashboardRecentSession], - servers: [HomeDashboardServer] + servers: [HomeDashboardServer], + recentLimit: Int ) -> [HomeDashboardRecentSession] { let hiddenSet = Set(hidden) let candidates = allSessions.filter { @@ -414,23 +554,15 @@ 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( - 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(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..1b0ddf17c 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 } } @@ -148,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 } @@ -207,7 +213,8 @@ enum HomeDashboardSupport { statusLabel: server.statusLabel, statusColor: server.statusColor, statusDotState: server.statusDotState, - agentRuntimes: server.agentRuntimes + agentRuntimes: server.agentRuntimes, + sessionListHasMore: server.sessionListHasMore ) } @@ -255,7 +262,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 df2a8a94c..c98c44113 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift @@ -70,6 +70,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? @@ -590,8 +600,12 @@ struct HomeDashboardView: View { openingKey: openingRecentSessionKey, zoomLevel: $zoomLevel, showCatFooter: chrome == .full, - topInset: 48, + topInset: 52, bottomInset: chrome == .full ? 140 : 24, + hasMoreSessions: hasMoreSessions, + isLoadingMoreSessions: isLoadingMoreSessions, + onLoadMore: onLoadMore, + onRefreshSessions: onRefreshSessions, callbacks: HomeSessionsScrollView.Callbacks( onOpen: { session in guard openingRecentSessionKey == nil else { return } @@ -791,6 +805,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. diff --git a/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift b/apps/ios/Sources/Litter/Views/HomeSessionsScrollView.swift index 892567ba1..598edcf5c 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 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,61 @@ 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() { + let threshold = bounds.height * 0.6 + let bottom = scrollView.contentOffset.y + bounds.height + let contentBottom = scrollView.contentSize.height + scrollView.adjustedContentInset.bottom + let nearBottom = bottom >= contentBottom - threshold + guard hasMoreSessions, !isLoadingMoreSessions, !loadMoreFired else { return } + if nearBottom { + 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 +1026,7 @@ extension HomeSessionsScrollUIView: UIGestureRecognizerDelegate { extension HomeSessionsScrollUIView: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { updatePageBackgroundVisibility() + maybeTriggerLoadMore() } func scrollViewWillEndDragging( @@ -989,6 +1099,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/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 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..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() { @@ -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 ) } 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 3c310331b..91d4d91bb 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -291,22 +291,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)>, @@ -777,135 +761,10 @@ impl AppClient { ) -> Result<(), ClientError> { blocking_async!(self.rt, self.inner, |c| { let requested_runtime_kinds = params.runtime_kinds.clone(); - let hydration_budget = thread_list_hydration_budget(¶ms); - let can_prune = thread_list_can_prune(¶ms); 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; - let mut exhausted = false; - 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_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; - }; - 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, exhausted) - }); - } - - 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); - let all_exhausted = results.iter().all(|(_, _, _, exhausted)| *exhausted); - if all_completed && all_exhausted && can_prune { - 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); - } 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) }) } @@ -3075,9 +2934,8 @@ mod tests { ImageViewSource, THREAD_LIST_HYDRATION_BUDGET, append_cached_models_for_failed_runtimes, append_missing_amp_mode_models, append_missing_claude_family_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_can_prune, - thread_list_hydration_budget, + normalize_model_info_for_runtime, normalized_image_path, runtime_exposes_model_choices, + splice_generative_ui_preamble, thread_list_can_prune, thread_list_hydration_budget, }; use crate::store::snapshot::ServerTransportDiagnostics; use crate::store::{AppSnapshot, ServerHealthSnapshot, ServerSnapshot}; @@ -3205,8 +3063,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/ffi/reconnect.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs index 88d9c3ae0..c0be3f513 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs @@ -632,6 +632,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 14e30e7a5..a9b30252e 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 @@ -2674,6 +2674,168 @@ 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 hydrate_recents = params.cursor.is_none() + && params + .search_term + .as_deref() + .map(str::trim) + .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 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 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( + &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_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 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. + 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 !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, exhausted) + }); + } + + let results = futures::future::join_all(tasks).await; + if results.iter().all(|(_, _, completed, _)| !completed) { + 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. + if all_completed + && hydrate_recents + && unfiltered + && results.iter().all(|(_, _, _, exhausted)| *exhausted) + { + let mut all_thread_ids = Vec::new(); + 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 { + 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 { @@ -3265,6 +3427,121 @@ 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 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, + 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 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.clone(), + 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 + ); + // Preserve the failed cursor so a later scroll can retry this page. + self.app_store + .set_thread_page_state(server_id, &runtime_kind, cursor, true); + any_has_more = true; + } + } + } + Ok(crate::types::AppLoadThreadsOutcome { + loaded: any_loaded, + has_more: any_has_more, + }) + } + async fn read_thread_metadata_only_for_runtime( &self, server_id: &str, @@ -4115,23 +4392,53 @@ 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: Some(20), + 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/ssh_bridge.rs b/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs index 6e15422ba..9a6953f40 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ssh_bridge.rs @@ -557,19 +557,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 eb93422f4..06117c781 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,6 +340,51 @@ impl AppStoreReducer { .clone() } + /// 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 { + self.snapshot + .read() + .expect("app store lock poisoned") + .session_pages + .get(&(server_id.to_string(), runtime_kind.clone())) + .cloned() + } + + /// 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.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 + /// 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 +475,9 @@ impl AppStoreReducer { } keep }); + snapshot + .session_pages + .retain(|(page_server_id, _), _| page_server_id != server_id); if snapshot .active_thread .as_ref() @@ -993,11 +1041,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)) { @@ -2254,8 +2301,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); @@ -2266,8 +2312,7 @@ impl AppStoreReducer { } VoiceDerivedUpdate::SpeechStarted => { { - let mut snapshot = - self.write_snapshot(); + let mut snapshot = self.write_snapshot(); snapshot.voice_session.phase = Some(AppVoiceSessionPhase::Listening); } @@ -3883,6 +3928,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(), @@ -3903,7 +3969,6 @@ mod tests { } } - // ── Derived-state cache invalidation ────────────────────────────── // // These cover the memoization introduced for the agent-directory @@ -4186,7 +4251,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. @@ -5754,15 +5822,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( @@ -6203,7 +6269,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")); @@ -6234,7 +6301,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()); @@ -6270,7 +6338,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")); @@ -6286,7 +6355,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()); 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/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 +} 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(