diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt index 00751fead..eef57b95f 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppLifecycleController.kt @@ -55,7 +55,16 @@ class AppLifecycleController { restoreLocalStateAfterReconnect(appModel, results) val retryResults = appModel.reconnectController.reconnectSavedServers() restoreLocalStateAfterReconnect(appModel, retryResults) - appModel.refreshSnapshot() + // Load the first page of sessions synchronously so the snapshot has + // real thread data and accurate `session_list_has_more` cursors. + // bare refreshSnapshot() captured a stale store before the + // fire-and-forget warmup could finish. + val connectedServerIds = results.map { it.serverId } + if (connectedServerIds.isNotEmpty()) { + appModel.loadSessionsPage(connectedServerIds, limit = 20u) + } else { + appModel.refreshSnapshot() + } // If reconnecting saved alleycat servers triggered the iroh // endpoint bind, persist any freshly-generated device key. appModel.persistAlleycatSecretKeyIfNeeded() @@ -241,12 +250,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 f7bd7095c..a85e85f16 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt @@ -100,8 +100,8 @@ class AppModel private constructor(context: android.content.Context) { /** * Matches the iOS page sizes. Server clamps this at 100. */ - const val INITIAL_TURN_PAGE_LIMIT: UInt = 5u - const val OLDER_TURN_PAGE_LIMIT: UInt = 5u + const val INITIAL_TURN_PAGE_LIMIT: UInt = 20u + const val OLDER_TURN_PAGE_LIMIT: UInt = 20u } // --- Rust bridges (singletons behind the scenes) ------------------------- @@ -439,7 +439,10 @@ class AppModel private constructor(context: android.content.Context) { ) restoreStoredLocalAuthState(serverId) try { - refreshSessions(listOf(serverId)) + // First page only — the home dashboard drives the rest via + // infinite scroll. Full drains still happen on pull-to-refresh + // and the dedicated Sessions screen. + loadSessionsPage(listOf(serverId), limit = 10u) } catch (_: Exception) { } refreshSnapshot() @@ -482,6 +485,31 @@ class AppModel private constructor(context: android.content.Context) { } } + /** + * Fetch the next page of the session list for each server via + * `store.loadThreadsPage`, merging additively into the canonical store. + * Used by the home dashboard's infinite scroll. On failure for a server, + * falls back to a full `refreshSessions` drain so the home list still + * populates (the retained Rust cursor state is cleared by that drain). + */ + suspend fun loadSessionsPage(serverIds: Collection, limit: UInt = 10u) { + val target = serverIds.filter { it !in sessionPageLoadingServerIds } + if (target.isEmpty()) return + target.forEach { sessionPageLoadingServerIds.add(it) } + try { + for (serverId in target) { + try { + store.loadThreadsPage(serverId, limit) + } catch (_: Exception) { + runCatching { refreshSessions(listOf(serverId)) } + } + } + } finally { + target.forEach { sessionPageLoadingServerIds.remove(it) } + } + refreshSnapshot() + } + suspend fun refreshThreadSearchSessions( query: String, runtimeKind: AgentRuntimeKind?, @@ -859,6 +887,7 @@ class AppModel private constructor(context: android.content.Context) { */ private val initialTurnsLoadingKeys = mutableSetOf() private val olderTurnsLoadingKeys = mutableSetOf() + private val sessionPageLoadingServerIds = mutableSetOf() /** * Launch an initial-turn load on the AppModel-owned scope so it survives @@ -906,19 +935,27 @@ class AppModel private constructor(context: android.content.Context) { /** * Fetch the next older page using the thread's stored - * `older_turns_cursor`. No-op when the cursor is null. + * `older_turns_cursor`. No-op (returns false) when the cursor is null or + * empty, or when a page is already in flight for this thread. * - * Returns a [Job] so the caller can `join()` to drive UI state (e.g. - * spinner on the "Load earlier messages" button). + * Runs on the AppModel-owned scope so the RPC survives recomposition. + * `onResult` reports whether a page was actually merged, so the + * conversation UI can release its "requested cursor" guard once the store + * advances `olderTurnsCursor` (or retry on failure). */ - fun loadOlderTurns(key: ThreadKey, limit: UInt = OLDER_TURN_PAGE_LIMIT): Job { - val cursor = threadSnapshot(key)?.olderTurnsCursor - if (cursor == null || !olderTurnsLoadingKeys.add(key)) { - return scope.launch { /* no-op */ } - } + fun loadOlderTurns( + key: ThreadKey, + limit: UInt = OLDER_TURN_PAGE_LIMIT, + onResult: (didLoad: Boolean) -> Unit = {}, + ): Job { + val cursor = threadSnapshot(key)?.olderTurnsCursor ?: return scope.launch { onResult(false) } + if (cursor.isEmpty()) return scope.launch { onResult(false) } + if (!olderTurnsLoadingKeys.add(key)) return scope.launch { onResult(false) } return scope.launch { + var didLoad = false try { val outcome = store.loadThreadTurnsPage(key, cursor, limit) + didLoad = outcome.loaded LLog.i( "Pagination", "loadOlderTurns", @@ -943,6 +980,7 @@ class AppModel private constructor(context: android.content.Context) { _lastError.value = e.message } finally { olderTurnsLoadingKeys.remove(key) + onResult(didLoad) } } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt index 172136d2d..4bd00a55f 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt @@ -143,7 +143,10 @@ fun ConversationScreen( buildTranscriptTurns( items = items, isStreaming = isThinking, - expandedRecentTurnCount = if (collapseTurns) 1 else Int.MAX_VALUE, + // iOS parity: auto-collapse exploration turns past 200 rendered + // items even when the user hasn't opted in, so a large restored + // page cannot exhaust the layout budget. + expandedRecentTurnCount = if (collapseTurns || items.size >= 200) 1 else Int.MAX_VALUE, ) } val transcriptTailSignature = remember(items, normalizedActiveTurnId, isThinking) { @@ -158,18 +161,19 @@ fun ConversationScreen( } // Server-paginated windowing: the Rust reducer owns which turns are // currently loaded for this thread. Kotlin just renders whatever is in - // `hydratedConversationItems` and exposes a "Load earlier" button gated - // on `olderTurnsCursor`. On legacy v0.124 servers this cursor stays null - // (all turns arrive in the resume response), so the button stays hidden. + // `hydratedConversationItems` and auto-prefetches older pages when the + // user scrolls near the top (iOS parity — replaces the manual "Load + // earlier messages" button). On legacy v0.124 servers the cursor stays + // null (all turns arrive in the resume response), so prefetch stays off. val displayedTurns = transcriptTurns val hasMoreTurnsAbove = thread?.olderTurnsCursor != null val supportsTurnPagination = server?.capabilities?.supportsTurnPagination == true - val isInitialTurnsLoading = thread != null && - !thread.initialTurnsLoaded && - supportsTurnPagination && - hasMoreTurnsAbove && - displayedTurns.isNotEmpty() - var isLoadingOlderTurns by remember(threadKey) { mutableStateOf(false) } + // Guard so a single cursor is only requested once while it is current. + // Released when the store advances `olderTurnsCursor` (a page merged). + var requestedOlderCursor by remember(threadKey) { mutableStateOf(null) } + // "Loading earlier messages…" capsule shown while a page is fetched with + // the user pinned at the very top of the transcript. + var olderPageLoaderVisible by remember(threadKey) { mutableStateOf(false) } var expandedTurnIds by remember(threadKey, collapseTurns) { mutableStateOf(setOf()) } var streamingRenderTick by remember(threadKey) { mutableStateOf(0) } var followScrollToken by remember(threadKey) { mutableStateOf(0) } @@ -382,7 +386,58 @@ fun ConversationScreen( } } - val displayedTurnCount = displayedTurns.size + (if (hasMoreTurnsAbove) 1 else 0) + // Index (within `displayedTurns`) of the earliest turn currently visible. + // Drives older-page prefetching — iOS parity uses the same + // "within N rows of the top" proximity heuristic. + val turnIndexByKey = remember(displayedTurns) { + displayedTurns.mapIndexed { index, turn -> turn.id to index }.toMap() + } + val earliestVisibleTurnIndex by remember(turnIndexByKey) { + derivedStateOf { + listState.layoutInfo.visibleItemsInfo + .mapNotNull { item -> (item.key as? String)?.let { turnIndexByKey[it] } } + .minOrNull() ?: Int.MAX_VALUE + } + } + + // Release the requested-cursor guard whenever the store advances + // `olderTurnsCursor` (a page was merged), so the proximity effect can + // prefetch the next page in the same pinned-at-top scroll session. + LaunchedEffect(threadKey, thread?.olderTurnsCursor) { + requestedOlderCursor = null + olderPageLoaderVisible = false + } + + LaunchedEffect( + earliestVisibleTurnIndex, + thread?.olderTurnsCursor, + thread?.initialTurnsLoaded, + supportsTurnPagination, + ) { + if (!supportsTurnPagination) return@LaunchedEffect + if (thread?.initialTurnsLoaded != true) return@LaunchedEffect + val cursor = thread?.olderTurnsCursor + if (cursor.isNullOrEmpty()) return@LaunchedEffect + if (earliestVisibleTurnIndex > OlderTurnsPrefetchDistance) return@LaunchedEffect + if (requestedOlderCursor == cursor) { + if (earliestVisibleTurnIndex == 0) olderPageLoaderVisible = true + return@LaunchedEffect + } + requestedOlderCursor = cursor + if (earliestVisibleTurnIndex == 0) olderPageLoaderVisible = true + val requestedAt = cursor + appModel.loadOlderTurns(threadKey) { didLoad -> + // Release the guard only on failure (cursor unchanged). On + // success the store advances the cursor and the reset effect + // above re-arms prefetching. + if (requestedOlderCursor == requestedAt && !didLoad) { + requestedOlderCursor = null + olderPageLoaderVisible = false + } + } + } + + val displayedTurnCount = displayedTurns.size LaunchedEffect(threadKey, displayedTurnCount, transcriptTailSignature, followScrollToken, streamingRenderTick) { if (shouldFollowTail && displayedTurns.isNotEmpty()) { val bottomAnchorIndex = conversationBottomAnchorIndex(displayedTurnCount) @@ -436,7 +491,7 @@ fun ConversationScreen( } else Modifier.drawWithContent { drawContent() } ), ) { - if (isWaitingForData || isInitialTurnsLoading) { + if (isWaitingForData) { item { Box( modifier = Modifier @@ -444,61 +499,20 @@ fun ConversationScreen( .padding(top = 40.dp), contentAlignment = Alignment.Center, ) { - if (isInitialTurnsLoading) { - CircularProgressIndicator( - color = LitterTheme.accent, - strokeWidth = 2.dp, - modifier = Modifier.size(20.dp), - ) - } else { - Text( - "Loading conversation…", - color = LitterTheme.textMuted, - fontSize = LitterTextStyle.caption.scaled, - ) - } - } - } - } - - if (hasMoreTurnsAbove) { - item { - TextButton( - enabled = !isLoadingOlderTurns, - onClick = { - if (isLoadingOlderTurns) return@TextButton - isLoadingOlderTurns = true - scope.launch { - try { - appModel.loadOlderTurns(threadKey).join() - } finally { - isLoadingOlderTurns = false - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) { - if (isLoadingOlderTurns) { - CircularProgressIndicator( - color = LitterTheme.accent, - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), - ) - } else { - Text( - "Load earlier messages", - color = LitterTheme.accent, - fontSize = LitterTextStyle.caption.scaled, - fontWeight = FontWeight.SemiBold, - ) - } + Text( + "Loading conversation…", + color = LitterTheme.textMuted, + fontSize = LitterTextStyle.caption.scaled, + ) } } } itemsIndexed( items = displayedTurns, - key = { index, turn -> "${turn.id}#$index" }, + // Stable keys let LazyColumn preserve scroll position + // when an older page is prepended (iOS parity). + key = { _, turn -> turn.id }, ) { _, turn -> val isExpanded = !turn.isCollapsedByDefault || expandedTurnIds.contains(turn.id) val streamingAssistantItemId = remember(turn.items, turn.isActiveTurn) { @@ -683,6 +697,31 @@ fun ConversationScreen( Icon(Icons.Default.KeyboardArrowDown, "Scroll to bottom", modifier = Modifier.size(20.dp)) } } + + // "Loading earlier messages…" capsule while an older page is + // fetched with the user pinned at the very top (iOS parity). + if (olderPageLoaderVisible && hasMoreTurnsAbove) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp) + .background(LitterTheme.surface.copy(alpha = 0.92f), RoundedCornerShape(50)) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + CircularProgressIndicator( + color = LitterTheme.accent, + strokeWidth = 2.dp, + modifier = Modifier.size(14.dp), + ) + Text( + "Loading earlier messages…", + color = LitterTheme.textMuted, + fontSize = LitterTextStyle.caption.scaled, + ) + } + } } // Bottom area: gradient fade + pinned context + composer + nav bar inset @@ -1256,6 +1295,10 @@ private fun uniffi.codex_mobile_client.AppThreadSnapshot.composerContextPercent( private fun conversationBottomAnchorIndex(turnCount: Int): Int = turnCount + 1 +/// iOS parity: prefetch the next older page once the earliest visible turn is +/// within this many rows of the top of the transcript. +private const val OlderTurnsPrefetchDistance = 6 + @Composable private fun PlanContextBadge(progress: String) { Text( diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt index 3455735ed..1b4571a03 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt @@ -225,10 +225,14 @@ private fun List.isExplorationGroup(): Boolean { private fun turnIdentifier(items: List, ordinal: Int): String { val first = items.firstOrNull() ?: return "turn-$ordinal" val sourceTurnId = items.firstNotNullOfOrNull { it.sourceTurnId } + // Anchor merged exploration runs to their newest constituent item (iOS + // parity): older pages prepend to this run, so anchoring on the first item + // would change the id and make SwiftUI/LazyColumn lose scroll position. + val anchor = if (items.isExplorationGroup()) items.last() else first return if (sourceTurnId != null) { - "turn-$sourceTurnId-${first.id}" + "turn-$sourceTurnId-${anchor.id}" } else { - "turn-${first.id}" + "turn-${anchor.id}" } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt index 44c49777b..25e0d7ae9 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/discovery/DiscoveryScreen.kt @@ -1256,6 +1256,7 @@ internal fun SSHLoginDialog( onDismiss: () -> Unit, onConnect: suspend (SavedSshCredential, Boolean) -> String?, ) { + val context = LocalContext.current val scope = rememberCoroutineScope() var username by remember(server.id) { mutableStateOf(initialCredential?.username ?: "") } var authMethod by remember(server.id) { mutableStateOf(initialCredential?.method ?: SshAuthMethod.PASSWORD) } @@ -1403,7 +1404,7 @@ internal fun SSHLoginDialog( checked = detachedTransport, onCheckedChange = { detachedTransport = it - SavedServerStore(context).updateDetachedTransport(context, server.id, it) + SavedServerStore.updateDetachedTransport(context, server.id, it) }, enabled = !isConnecting, ) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt index adc561632..a14b8bf25 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt @@ -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 @@ -183,9 +192,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 @@ -194,6 +207,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 @@ -365,41 +465,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 @@ -537,12 +611,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 @@ -1365,10 +1464,8 @@ private fun EmptyHomeFatCat(modifier: Modifier = Modifier) { * Merge rule: * - If the user has pinned anything, the home list starts with their pins * (in pin order, most-recent-pinned first). - * - Local Studio appends its unpinned recent sessions so a pin cannot hide - * newly synced Pi sessions. Other runtimes keep the existing pins-only rule. - * - If nothing is pinned, fill the list with up to 10 most-recent - * sessions so the home screen isn't empty. + * - Append the current window of unpinned recent sessions for every runtime. + * - Growing recentLimit reveals older sessions even when pins exist. * - Hidden threads are always excluded. */ internal fun mergeHomeSessions( @@ -1376,6 +1473,7 @@ internal fun mergeHomeSessions( hidden: List, servers: List, allSessions: List, + recentLimit: Int = DefaultRecentLimit, ): List { val hiddenSet = hidden.toSet() val candidates = allSessions.filter { @@ -1393,26 +1491,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/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt b/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt index a37f5755f..81d69d4da 100644 --- a/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt +++ b/apps/android/app/src/test/java/com/litter/android/SavedServerTransportTest.kt @@ -2,6 +2,7 @@ package com.litter.android import com.litter.android.state.SavedServer import com.litter.android.state.SavedServerStore +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -137,7 +138,7 @@ class SavedServerTransportTest { detachedTransport = true, ) - val restored = SavedServer.fromJson(server.toJson().toString()) + val restored = SavedServer.fromJson(JSONObject(server.toJson().toString())) assertTrue(restored.detachedTransport) } @@ -151,8 +152,7 @@ class SavedServerTransportTest { hostname = "10.0.0.5", port = 22, ) - val json = legacy.toJson().toString() - .replace("\"detachedTransport\":false", "") + val json = legacy.toJson().apply { remove("detachedTransport") } val restored = SavedServer.fromJson(json) diff --git a/apps/ios/Litter.xcodeproj/project.pbxproj b/apps/ios/Litter.xcodeproj/project.pbxproj index a096d9d74..8d0b6b7b0 100644 --- a/apps/ios/Litter.xcodeproj/project.pbxproj +++ b/apps/ios/Litter.xcodeproj/project.pbxproj @@ -1934,12 +1934,12 @@ projectRoot = ""; targets = ( A3D84F588EDBC1EA550D6691 /* Litter */, - 4D58398238C075D3A3A3AB9B /* LitterLiveActivity */, 6EBBC661C017816B2D4245DF /* LitterMac */, - DA07A1EAC6173D09F6FB99D9 /* LitterTests */, - 7BB0106D190F10717D781234 /* LitterUITests */, + 4D58398238C075D3A3A3AB9B /* LitterLiveActivity */, 3B991C03E72B1546CAD35698 /* LitterWatch */, C0C98C6012493B73CDD5AD6E /* LitterWatchComplications */, + DA07A1EAC6173D09F6FB99D9 /* LitterTests */, + 7BB0106D190F10717D781234 /* LitterUITests */, ); }; /* End PBXProject section */ diff --git a/apps/ios/Sources/Litter/LitterApp.swift b/apps/ios/Sources/Litter/LitterApp.swift index 030e46660..e9d34882e 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -1440,7 +1440,11 @@ private struct HomeNavigationView: View { onInputModeChange: { mode in homeInputMode = mode }, - onSearchThreads: loadSearchThreads + onLoadMore: { homeDashboardModel.loadMore() }, + onRefreshSessions: { await homeDashboardModel.refreshAll() }, + hasMoreSessions: homeDashboardModel.hasMoreSessions, + isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions, + onSearchThreads: loadSearchThreads, ) } @@ -1485,7 +1489,11 @@ private struct HomeNavigationView: View { onInputModeChange: { mode in homeInputMode = mode }, - onSearchThreads: loadSearchThreads + onLoadMore: { homeDashboardModel.loadMore() }, + onRefreshSessions: { await homeDashboardModel.refreshAll() }, + hasMoreSessions: homeDashboardModel.hasMoreSessions, + isLoadingMoreSessions: homeDashboardModel.isLoadingMoreSessions, + onSearchThreads: loadSearchThreads, ) } diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index 350caa4ea..3a8fcaf9d 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -156,6 +156,7 @@ final class AppModel { @ObservationIgnored private var pendingStreamingDeltaTask: Task? @ObservationIgnored private var cachedThreadSnapshots: [ThreadKey: AppThreadSnapshot] = [:] @ObservationIgnored private var loadingTurnPageThreadKeys: Set = [] + @ObservationIgnored private var loadingThreadPageServerIds: Set = [] private(set) var pendingHandoffTurnErrors: [ThreadKey: String] = [:] func reportHandoffTurnError(key: ThreadKey, message: String) { @@ -2249,18 +2250,17 @@ final class AppModel { return nil } - // A page of 5 meant tapping "Load earlier" dozens of times to get back - // through a real conversation (#306). Raising it is only safe now that - // c153d1e5 makes `include_turns=false` authoritative, so a metadata read - // can no longer smuggle in the full archive on top of the page. private static let initialTurnPageSize: UInt32 = 20 private static let olderTurnPageSize: UInt32 = 20 + /// 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` /// against a v0.125+ server. func loadInitialTurns(threadId key: ThreadKey) async { - await loadTurnPage(key: key, cursor: nil, limit: Self.initialTurnPageSize) + _ = await loadTurnPage(key: key, cursor: nil, limit: Self.initialTurnPageSize) } func loadInitialTurnsIfNeeded(threadId key: ThreadKey) async { @@ -2271,18 +2271,18 @@ final class AppModel { } /// Fetch the next older page of turns using the thread's current cursor. - /// No-op when no cursor is available (older-turns button should be hidden - /// in that case). - func loadOlderTurns(threadId key: ThreadKey) async { + /// No-op when the loaded history cache has reached the start of the + /// server-side session and no cursor remains. + func loadOlderTurns(threadId key: ThreadKey) async -> Bool { guard let cursor = threadSnapshot(for: key)?.olderTurnsCursor, !cursor.isEmpty else { - return + return false } - await loadTurnPage(key: key, cursor: cursor, limit: Self.olderTurnPageSize) + return await loadTurnPage(key: key, cursor: cursor, limit: Self.olderTurnPageSize) } - private func loadTurnPage(key: ThreadKey, cursor: String?, limit: UInt32) async { - if loadingTurnPageThreadKeys.contains(key) { return } + private func loadTurnPage(key: ThreadKey, cursor: String?, limit: UInt32) async -> Bool { + if loadingTurnPageThreadKeys.contains(key) { return false } loadingTurnPageThreadKeys.insert(key) defer { loadingTurnPageThreadKeys.remove(key) } @@ -2292,8 +2292,47 @@ final class AppModel { cursor: cursor, limit: limit ) + return true } catch { lastError = error.localizedDescription + return false + } + } + + /// 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 + ) + ) + } + } + } } } diff --git a/apps/ios/Sources/Litter/Models/SavedServer.swift b/apps/ios/Sources/Litter/Models/SavedServer.swift index 3c545a3eb..740c7f39b 100644 --- a/apps/ios/Sources/Litter/Models/SavedServer.swift +++ b/apps/ios/Sources/Litter/Models/SavedServer.swift @@ -295,6 +295,7 @@ struct SavedServer: Codable, Identifiable, Equatable { sshPortForwardingEnabled: sshPortForwardingEnabled, websocketUrl: websocketURL, rememberedByUser: rememberedByUser, + detachedTransport: false, alleycatHost: alleycatHost, alleycatUdpPort: alleycatUdpPort, alleycatNodeId: alleycatNodeId, diff --git a/apps/ios/Sources/Litter/Models/SavedServerStore.swift b/apps/ios/Sources/Litter/Models/SavedServerStore.swift index 907687ea7..2d7cfc3b7 100644 --- a/apps/ios/Sources/Litter/Models/SavedServerStore.swift +++ b/apps/ios/Sources/Litter/Models/SavedServerStore.swift @@ -124,6 +124,7 @@ enum SavedServerStore { sshPortForwardingEnabled: nil, websocketUrl: nil, rememberedByUser: true, + detachedTransport: false, alleycatHost: nil, alleycatUdpPort: nil, alleycatNodeId: nil, diff --git a/apps/ios/Sources/Litter/Views/ConversationView.swift b/apps/ios/Sources/Litter/Views/ConversationView.swift index bf6e44f50..7cd98f18f 100644 --- a/apps/ios/Sources/Litter/Views/ConversationView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationView.swift @@ -127,7 +127,7 @@ struct ConversationView: View { onForkFromUserItem: forkFromMessage, onOpenConversation: onOpenConversation, onLoadOlderTurns: { key in - Task { await appModel.loadOlderTurns(threadId: key) } + await appModel.loadOlderTurns(threadId: key) } ) .overlay(alignment: .bottomLeading) { @@ -606,7 +606,7 @@ private struct ConversationMessageList: View { let onEditUserItem: (ConversationItem) -> Void let onForkFromUserItem: (ConversationItem) -> Void var onOpenConversation: ((ThreadKey) -> Void)? = nil - let onLoadOlderTurns: (ThreadKey) -> Void + let onLoadOlderTurns: (ThreadKey) async -> Bool @State private var isNearBottom = true @State private var autoFollowStreaming = true @State private var userIsDraggingScroll = false @@ -625,6 +625,12 @@ private struct ConversationMessageList: View { @State private var initialBottomScrollThreadScopeID: String? @State private var programmaticBottomScrollSettling = false @State private var programmaticBottomScrollGeneration = 0 + @State private var scrollPosition = ScrollPosition(idType: String.self) + @State private var nativeScrollView: UIScrollView? + @State private var visibleTurnIDs: [String] = [] + @State private var requestedOlderTurnsCursor: String? + @State private var requestedOlderTurnsThreadKey: ThreadKey? + @State private var showOlderPageLoader = false @AppStorage("collapseTurns") private var collapseTurns = false private static let latestButtonShowDistance: CGFloat = 48 private static let nearBottomRestoreDistance: CGFloat = 12 @@ -632,8 +638,18 @@ private struct ConversationMessageList: View { private static let bottomAnchorID = "conversation-message-list-bottom" private static let scrollCoordinateSpaceName = "conversation-message-list-scroll" + private var shouldCollapseTurns: Bool { + ConversationTurnCollapsePolicy.shouldCollapse( + preferenceEnabled: collapseTurns, + itemCount: items.count + ) + } + private var expandedRecentTurnCount: Int { - return collapseTurns ? 1 : .max + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: collapseTurns, + itemCount: items.count + ) } private var sourceTurns: [TranscriptTurn] { @@ -697,22 +713,6 @@ private struct ConversationMessageList: View { ScrollView { VStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 10) { - if !initialTurnsLoaded && hasOlderTurns && !turns.isEmpty { - ConversationLoadingIndicator(label: "Loading earlier messages...") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else if hasOlderTurns { - Button { - onLoadOlderTurns(activeThreadKey) - } label: { - Text("Load earlier messages") - .litterFont(.caption, weight: .semibold) - .foregroundColor(LitterTheme.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - } ForEach(turns) { turn in let isLastTurn = turn.id == lastTurnID ConversationTurnRow( @@ -749,7 +749,12 @@ private struct ConversationMessageList: View { .equatable() .turnDebugOverlay(turnId: turn.id) } + + Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) } + .scrollTargetLayout() .frame(maxWidth: LitterPlatform.isRegularSurface(horizontalSizeClass: horizontalSizeClass) ? 760 : .infinity) .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 16) @@ -766,16 +771,24 @@ private struct ConversationMessageList: View { .padding(.top, 40) } - Color.clear - .frame(height: 1) - .id(Self.bottomAnchorID) - .padding(.horizontal, 16) } .frame(maxWidth: .infinity, minHeight: viewport.size.height, alignment: .top) + .background( + ConversationScrollViewResolver(scrollView: $nativeScrollView) + .allowsHitTesting(false) + ) } .id(activeThreadScopeID) .scrollIndicators(.hidden) .scrollDismissesKeyboard(.interactively) + // Keep one semantic scroll position for both user tracking and + // programmatic edge jumps. Unlike ScrollViewProxy, an edge + // command can replace active deceleration immediately. + .scrollPosition($scrollPosition, anchor: .bottom) + .onScrollTargetVisibilityChange(idType: String.self, threshold: 0.01) { turnIDs in + visibleTurnIDs = turnIDs + prefetchOlderTurnsIfNeeded(visibleTurnIDs: turnIDs, turns: turns) + } .coordinateSpace(name: Self.scrollCoordinateSpaceName) .onScrollGeometryChange(for: CGFloat.self) { geometry in max(0, geometry.contentSize.height - geometry.visibleRect.maxY) @@ -813,6 +826,11 @@ private struct ConversationMessageList: View { isNearBottom = true initialBottomScrollThreadScopeID = nil waitingForDataExpired = false + scrollPosition = ScrollPosition(idType: String.self) + visibleTurnIDs = [] + requestedOlderTurnsCursor = nil + requestedOlderTurnsThreadKey = nil + showOlderPageLoader = false syncTranscriptTurns(resetExpansion: true) StreamingRendererCoordinator.shared.reset() requestInitialBottomScrollIfNeeded(proxy) @@ -825,6 +843,18 @@ private struct ConversationMessageList: View { syncTranscriptTurns() requestInitialBottomScrollIfNeeded(proxy) } + .onChange(of: olderTurnsCursor) { oldCursor, newCursor in + guard oldCursor != newCursor else { return } + requestedOlderTurnsCursor = nil + requestedOlderTurnsThreadKey = nil + showOlderPageLoader = false + DispatchQueue.main.async { + prefetchOlderTurnsIfNeeded( + visibleTurnIDs: visibleTurnIDs, + turns: mergedRenderableTurns + ) + } + } .onChange(of: collapseTurns) { syncTranscriptTurns(resetExpansion: true) } @@ -877,6 +907,16 @@ private struct ConversationMessageList: View { .padding(.bottom, 10) .transition(.move(edge: .bottom).combined(with: .opacity)) } + + if showOlderPageLoader, hasOlderTurns { + ConversationLoadingIndicator(label: "Loading earlier messages...") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: Capsule()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, topInset + 8) + .allowsHitTesting(false) + } } } } @@ -951,18 +991,96 @@ private struct ConversationMessageList: View { } } - private func scrollToBottom(_ proxy: ScrollViewProxy) { + private func scrollToBottom(_: ScrollViewProxy) { isNearBottom = true programmaticBottomScrollGeneration &+= 1 let generation = programmaticBottomScrollGeneration programmaticBottomScrollSettling = true - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + stopMomentumAndJumpToNativeBottom() + scrollPosition.scrollTo(edge: .bottom) DispatchQueue.main.asyncAfter(deadline: .now() + Self.bottomScrollSettleDuration) { guard programmaticBottomScrollGeneration == generation else { return } programmaticBottomScrollSettling = false } } + private func stopMomentumAndJumpToNativeBottom() { + guard let nativeScrollView else { return } + + // End an in-progress pan as well as inertial deceleration. Without + // this, SwiftUI can accept a semantic scroll command and then have the + // still-running UIKit scroll animation write a newer offset over it. + let panGesture = nativeScrollView.panGestureRecognizer + if panGesture.state != .possible { + panGesture.isEnabled = false + panGesture.isEnabled = true + } + nativeScrollView.setContentOffset(nativeScrollView.contentOffset, animated: false) + + let inset = nativeScrollView.adjustedContentInset + let topOffset = -inset.top + let bottomOffset = max( + topOffset, + nativeScrollView.contentSize.height - nativeScrollView.bounds.height + inset.bottom + ) + nativeScrollView.setContentOffset( + CGPoint(x: nativeScrollView.contentOffset.x, y: bottomOffset), + animated: false + ) + } + + private func prefetchOlderTurnsIfNeeded( + visibleTurnIDs: [String], + turns: [TranscriptTurn] + ) { + guard let earliestVisibleIndex = ConversationInfiniteScrollPolicy.earliestVisibleIndex( + visibleIDs: visibleTurnIDs, + orderedIDs: turns.map(\.id) + ), earliestVisibleIndex <= ConversationInfiniteScrollPolicy.olderPrefetchDistance else { return } + + requestOlderTurnsPage(showLoaderIfCacheExhausted: earliestVisibleIndex == 0) + } + + private func requestOlderTurnsPage(showLoaderIfCacheExhausted: Bool) { + guard initialTurnsLoaded, + let cursor = olderTurnsCursor, + !cursor.isEmpty else { return } + let requestKey = activeThreadKey + + if requestedOlderTurnsCursor == cursor, + requestedOlderTurnsThreadKey == requestKey { + if showLoaderIfCacheExhausted { + scheduleOlderPageLoader(for: cursor, threadKey: requestKey) + } + return + } + + requestedOlderTurnsCursor = cursor + requestedOlderTurnsThreadKey = requestKey + if showLoaderIfCacheExhausted { + scheduleOlderPageLoader(for: cursor, threadKey: requestKey) + } + + Task { + let didLoad = await onLoadOlderTurns(requestKey) + guard !didLoad, + requestedOlderTurnsCursor == cursor, + requestedOlderTurnsThreadKey == requestKey else { return } + requestedOlderTurnsCursor = nil + requestedOlderTurnsThreadKey = nil + showOlderPageLoader = false + } + } + + private func scheduleOlderPageLoader(for cursor: String, threadKey: ThreadKey) { + Task { + try? await Task.sleep(for: .milliseconds(250)) + guard requestedOlderTurnsCursor == cursor, + requestedOlderTurnsThreadKey == threadKey else { return } + showOlderPageLoader = true + } + } + private func syncTranscriptTurns(resetExpansion: Bool = false) { let nextBuildKey = makeTranscriptBuildKey() if transcriptBuildKey == nextBuildKey, !transcriptTurns.isEmpty { @@ -1066,7 +1184,7 @@ private struct ConversationMessageList: View { to nextTurns: [TranscriptTurn], resetExpansion: Bool ) -> Bool { - guard collapseTurns, + guard shouldCollapseTurns, !resetExpansion, !currentTurns.isEmpty, nextTurns.count == currentTurns.count + 1, @@ -1377,26 +1495,76 @@ private struct CollapsedTurnMetaItem: View { } } +private struct ConversationScrollViewResolver: UIViewRepresentable { + @Binding var scrollView: UIScrollView? + + func makeUIView(context: Context) -> ResolverView { + let view = ResolverView() + view.onResolve = updateResolvedScrollView + return view + } + + func updateUIView(_ uiView: ResolverView, context: Context) { + uiView.onResolve = updateResolvedScrollView + uiView.resolveWhenAttached() + } + + private func updateResolvedScrollView(_ resolved: UIScrollView?) { + guard scrollView !== resolved else { return } + scrollView = resolved + } + + final class ResolverView: UIView { + var onResolve: ((UIScrollView?) -> Void)? + + override func didMoveToWindow() { + super.didMoveToWindow() + resolveWhenAttached() + } + + func resolveWhenAttached() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + var ancestor = superview + while let view = ancestor { + if let scrollView = view as? UIScrollView { + onResolve?(scrollView) + return + } + ancestor = view.superview + } + onResolve?(nil) + } + } + } +} + private struct ScrollToBottomIndicator: View { let action: () -> Void @State private var bob = false var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: "arrow.down") - .litterFont(.caption, weight: .bold) - .offset(y: bob ? 1.5 : -1.5) - .animation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true), value: bob) - Text("Latest") - .litterFont(.caption, weight: .semibold) - } - .foregroundColor(LitterTheme.textPrimary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .modifier(GlassCapsuleModifier()) + HStack(spacing: 8) { + Image(systemName: "arrow.down") + .litterFont(.caption, weight: .bold) + .offset(y: bob ? 1.5 : -1.5) + .animation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true), value: bob) + Text("Latest") + .litterFont(.caption, weight: .semibold) } + .foregroundColor(LitterTheme.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .modifier(GlassCapsuleModifier()) .contentShape(Capsule()) + // A normal Button tap can be consumed merely to stop an actively + // decelerating ScrollView. Give this overlay first refusal so Latest + // executes on that same tap, even while momentum is still active. + .highPriorityGesture(TapGesture().onEnded(action)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Latest") + .accessibilityAddTraits(.isButton) + .accessibilityAction { action() } .onAppear { bob = true } diff --git a/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift b/apps/ios/Sources/Litter/Views/HomeDashboardModel.swift index 31bdd5dc2..9e9364e08 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] @@ -56,13 +62,21 @@ final class HomeDashboardModel { } private(set) var connectedServers: [HomeDashboardServer] = [] - /// Home list source: pinned threads first (in pin order). Local Studio - /// also keeps recent sessions visible after a pin so newly synced Pi - /// sessions remain discoverable. Hidden threads are always excluded. + /// Home list source: pins first, followed by the current recent window + /// for every runtime. Hidden threads are always excluded. private(set) var recentSessions: [HomeDashboardRecentSession] = [] /// Every session we know about across connected servers, newest first — /// used by the search view so the user can pick any thread. 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 (20) and grows as the user scrolls to load more. + private(set) var recentLimit = HomeDashboardModel.defaultRecentLimit private(set) var pinnedKeys: [SavedThreadsStore.PinnedKey] = [] private(set) var hiddenKeys: [SavedThreadsStore.PinnedKey] = [] private(set) var projects: [AppProject] = [] @@ -116,6 +130,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 +249,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 +323,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 +391,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 +410,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 + } + } + } + + /// Reload recent sessions across the visible servers (pull-to-refresh). + /// Rust bounds hydration and retains cursors for older sessions. Resets + /// the recent window and reconciles the home list. `completion` fires when the snapshot has + /// settled so the refresh control can end. + func refreshAll(completion: (@MainActor () -> Void)? = nil) { + guard let appModel, isActive else { return } + 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 +533,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 +553,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 f7c0634fe..06580a6ab 100644 --- a/apps/ios/Sources/Litter/Views/HomeDashboardView.swift +++ b/apps/ios/Sources/Litter/Views/HomeDashboardView.swift @@ -69,6 +69,16 @@ struct HomeDashboardView: View { /// new thread. var onForkThread: (@MainActor (HomeDashboardRecentSession) async -> Void)? = nil var onInputModeChange: ((HomeInputMode) -> Void)? = nil + /// Fired when the user scrolls near the bottom of the sessions list. + /// The model fetches the next page and reveals more sessions. + var onLoadMore: (() -> Void)? = nil + /// Fired by the drag-down-to-refresh control. The model drains all + /// sessions and resets the recent window. + var onRefreshSessions: (@MainActor () async -> Void)? = nil + /// Whether more sessions are available on the visible servers. + var hasMoreSessions: Bool = false + /// True while a "load more" page fetch is in flight. + var isLoadingMoreSessions: Bool = false @State private var deleteTargetThread: HomeDashboardRecentSession? @State private var replyTargetThread: HomeDashboardRecentSession? @@ -581,8 +591,12 @@ struct HomeDashboardView: View { openingKey: openingRecentSessionKey, zoomLevel: $zoomLevel, showCatFooter: chrome == .full, - topInset: 48, + topInset: 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 } @@ -782,6 +796,7 @@ struct SessionCanvasLine: View { } } .frame(maxWidth: .infinity, alignment: .leading) +.padding(.bottom, 4) // Detail below — gets full width. As zoom grows, additional // rows are revealed by the container's layout animation. 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/Sources/Litter/Views/SettingsView.swift b/apps/ios/Sources/Litter/Views/SettingsView.swift index ecc969d2f..f50b960b8 100644 --- a/apps/ios/Sources/Litter/Views/SettingsView.swift +++ b/apps/ios/Sources/Litter/Views/SettingsView.swift @@ -170,7 +170,7 @@ struct SettingsView: View { Text("Collapse Turns") .litterFont(.subheadline) .foregroundColor(LitterTheme.textPrimary) - Text("Collapse previous turns into cards") + Text("Collapse previous turns into cards; large conversations collapse automatically") .litterFont(.caption) .foregroundColor(LitterTheme.textSecondary) } diff --git a/apps/ios/Sources/Litter/Views/TranscriptTurn.swift b/apps/ios/Sources/Litter/Views/TranscriptTurn.swift index aafc029e5..a627abdbe 100644 --- a/apps/ios/Sources/Litter/Views/TranscriptTurn.swift +++ b/apps/ios/Sources/Litter/Views/TranscriptTurn.swift @@ -1,5 +1,40 @@ import Foundation +enum ConversationTurnCollapsePolicy { + /// Rendering every message/tool view in a large restored page can exhaust + /// SwiftUI's layout budget and leave the conversation surface blank. + static let automaticItemThreshold = 200 + + static func shouldCollapse( + preferenceEnabled: Bool, + itemCount: Int + ) -> Bool { + preferenceEnabled || itemCount >= automaticItemThreshold + } + + static func expandedRecentTurnCount( + preferenceEnabled: Bool, + itemCount: Int + ) -> Int { + shouldCollapse(preferenceEnabled: preferenceEnabled, itemCount: itemCount) ? 1 : .max + } +} + +enum ConversationInfiniteScrollPolicy { + static let olderPrefetchDistance = 6 + + static func earliestVisibleIndex( + visibleIDs: [String], + orderedIDs: [String] + ) -> Int? { + guard !visibleIDs.isEmpty else { return nil } + let indicesByID = Dictionary( + uniqueKeysWithValues: orderedIDs.enumerated().map { ($1, $0) } + ) + return visibleIDs.compactMap { indicesByID[$0] }.min() + } +} + struct TranscriptTurn: Identifiable, Equatable { private static let collapsedExcerptLimit = 180 @@ -207,11 +242,14 @@ struct TranscriptTurn: Identifiable, Equatable { } private static func mergedExplorationTurn(from turns: [TranscriptTurn]) -> TranscriptTurn? { - guard let first = turns.first else { return nil } + guard let last = turns.last else { return nil } let items = turns.flatMap(\.items) let isLive = turns.contains(where: \.isLive) return TranscriptTurn( - id: "exploration-turn-\(first.id)", + // Anchor the merged row to its newest constituent turn. Older + // pages prepend to this run, so its identity remains stable and + // SwiftUI can preserve the visible scroll position. + id: "exploration-turn-\(last.id)", items: items, isLive: isLive, isCollapsedByDefault: turns.allSatisfy(\.isCollapsedByDefault), diff --git a/apps/ios/Tests/LitterTests/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..e87846787 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,41 @@ 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 testPinnedHomeListRevealsOlderCachedSessionsOnLoadMore() async { + let appModel = AppModel() + let pinnedKey = SavedThreadsStore.PinnedKey( + threadKey: ThreadKey(serverId: "codex", threadId: "pinned") + ) + let model = HomeDashboardModel( + persistence: persistence(pinned: [pinnedKey]), + observedRefreshDelayNanoseconds: 0 + ) + model.bind(appModel: appModel) + model.activate() + let recent = (1...25).map { + makeThreadSnapshot(serverId: "codex", threadId: "recent-\($0)", updatedAt: TimeInterval($0)) + } + appModel.applySnapshot(makeSnapshot( + servers: [makeServerSnapshot(id: "codex", name: "Codex")], + threads: [makeThreadSnapshot(serverId: "codex", threadId: "pinned", updatedAt: 30)] + recent, + activeThread: nil + )) + await waitUntil("Pinned list renders its initial recent window") { + model.recentSessions.count == 21 + } + XCTAssertEqual(model.recentSessions.first?.key.threadId, "pinned") + model.loadMore() + await waitUntil("Scrolling reveals the remaining cached sessions") { + model.recentSessions.count == 26 + } + XCTAssertEqual(Set(model.recentSessions.map(\.key.threadId)).count, 26) } func testLocalStudioDoesNotShowFalseOpenAISignInWarning() { @@ -537,7 +567,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/PerformanceHelpersTests.swift b/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift index 1b5b60b57..1b95fe9c7 100644 --- a/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift +++ b/apps/ios/Tests/LitterTests/PerformanceHelpersTests.swift @@ -3,6 +3,51 @@ import XCTest @MainActor final class PerformanceHelpersTests: XCTestCase { + func testLargeConversationAutomaticallyCollapsesOlderTurns() { + let threshold = ConversationTurnCollapsePolicy.automaticItemThreshold + + XCTAssertEqual( + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: false, + itemCount: threshold - 1 + ), + .max + ) + XCTAssertEqual( + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: false, + itemCount: threshold + ), + 1 + ) + XCTAssertEqual( + ConversationTurnCollapsePolicy.expandedRecentTurnCount( + preferenceEnabled: true, + itemCount: 1 + ), + 1 + ) + } + + func testInfiniteScrollPrefetchesBeforeTheVisibleCacheEdge() { + let orderedIDs = (0..<20).map { "turn-\($0)" } + + XCTAssertEqual( + ConversationInfiniteScrollPolicy.earliestVisibleIndex( + visibleIDs: ["turn-8", "turn-9", "turn-10"], + orderedIDs: orderedIDs + ), + 8 + ) + XCTAssertEqual( + ConversationInfiniteScrollPolicy.earliestVisibleIndex( + visibleIDs: ["turn-4", "turn-5", "turn-6"], + orderedIDs: orderedIDs + ), + 4 + ) + } + func testTranscriptTurnBuilderCollapsesPreviousTurnOnceANewLiveTurnStarts() { let baseTime = Date(timeIntervalSince1970: 100) let turns = TranscriptTurn.build( @@ -197,6 +242,59 @@ final class PerformanceHelpersTests: XCTestCase { XCTAssertEqual(merged[2].items.count, 1) } + func testExplorationMergeKeepsItsIdentityWhenOlderTurnsArePrepended() { + let baseTime = Date(timeIntervalSince1970: 500) + let olderTurn = TranscriptTurn.build( + from: [makeExplorationItem( + command: "pwd", + actionKind: .read, + path: "/tmp", + turnId: "turn-0", + turnIndex: 0, + timestamp: baseTime + )], + threadStatus: .ready, + expandedRecentTurnCount: 1 + )[0] + let firstLoadedTurn = TranscriptTurn.build( + from: [makeExplorationItem( + command: "cat reducer.rs", + actionKind: .read, + path: "/tmp/reducer.rs", + turnId: "turn-1", + turnIndex: 1, + timestamp: baseTime.addingTimeInterval(1) + )], + threadStatus: .ready, + expandedRecentTurnCount: 1 + )[0] + let newestTurn = TranscriptTurn.build( + from: [makeExplorationItem( + command: "rg pendingSteers", + actionKind: .search, + path: "/tmp/reducer.rs", + query: "pendingSteers", + turnId: "turn-2", + turnIndex: 2, + timestamp: baseTime.addingTimeInterval(2) + )], + threadStatus: .ready, + expandedRecentTurnCount: 1 + )[0] + + let beforePrepend = TranscriptTurn.mergeConsecutiveExplorationTurnsForRendering([ + firstLoadedTurn, + newestTurn, + ]) + let afterPrepend = TranscriptTurn.mergeConsecutiveExplorationTurnsForRendering([ + olderTurn, + firstLoadedTurn, + newestTurn, + ]) + + XCTAssertEqual(beforePrepend.first?.id, afterPrepend.first?.id) + } + func testMessageRenderCacheReusesStableAssistantRevisionKey() { let cache = MessageRenderCache() let base64Pixel = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn8Vf0AAAAASUVORK5CYII=" 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/services/kittylitter/Cargo.lock b/services/kittylitter/Cargo.lock index 753b997a7..b8a276ac9 100644 --- a/services/kittylitter/Cargo.lock +++ b/services/kittylitter/Cargo.lock @@ -67,7 +67,7 @@ dependencies = [ [[package]] name = "alleycat" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-acp-bridge", "alleycat-amp-bridge", @@ -113,7 +113,7 @@ dependencies = [ [[package]] name = "alleycat-acp-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -136,7 +136,7 @@ dependencies = [ [[package]] name = "alleycat-amp-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-claude-bridge", @@ -159,7 +159,7 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-codex-proto", "anyhow", @@ -178,7 +178,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -202,7 +202,7 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "chrono", "serde", @@ -213,7 +213,7 @@ dependencies = [ [[package]] name = "alleycat-devin-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-acp-bridge", "alleycat-bridge-core", @@ -228,7 +228,7 @@ dependencies = [ [[package]] name = "alleycat-droid-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -247,7 +247,7 @@ dependencies = [ [[package]] name = "alleycat-grok-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-acp-bridge", "alleycat-bridge-core", @@ -263,7 +263,7 @@ dependencies = [ [[package]] name = "alleycat-hermes-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "alleycat-local-studio-proto" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "hex", "serde", @@ -300,7 +300,7 @@ dependencies = [ [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -349,7 +349,7 @@ dependencies = [ [[package]] name = "alleycat-shell-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -2316,7 +2316,7 @@ dependencies = [ [[package]] name = "kittylitter" -version = "0.3.6" +version = "0.3.7" dependencies = [ "alleycat", "anyhow", diff --git a/services/kittylitter/Cargo.toml b/services/kittylitter/Cargo.toml index a6f4c3cef..23d3a4ecf 100644 --- a/services/kittylitter/Cargo.toml +++ b/services/kittylitter/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "kittylitter" -version = "0.3.6" +version = "0.3.7" edition = "2024" license = "GPL-3.0-only" authors = ["The Alleycat Authors"] @@ -22,7 +22,7 @@ name = "kittylitter" path = "src/main.rs" [dependencies] -alleycat = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } +alleycat = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } anyhow = "1" # cargo-dist builds with `cargo build --profile dist`. Mirrors the diff --git a/services/kittylitter/README.md b/services/kittylitter/README.md index 4a390f73d..117c5a874 100644 --- a/services/kittylitter/README.md +++ b/services/kittylitter/README.md @@ -1,12 +1,19 @@ # kittylitter -Distribution wrapper for the [alleycat](https://github.com/dnakov/alleycat) daemon. Ships the daemon to npm, Homebrew, and the platform installer scripts under the kittylitter brand. +Distribution wrapper for the [alleycat](https://github.com/makyinmars/alleycat) daemon. Ships the daemon to npm, Homebrew, and the platform installer scripts under the kittylitter brand. The wrapper itself is a 3-line `main()` that re-exports `alleycat::run("kittylitter")`. All daemon behavior lives in the alleycat crate; this crate exists so cargo-dist sees a `kittylitter` package name and produces correctly-named artifacts (`kittylitter-installer.sh`, `kittylitter.rb`, `kittylitter` on npm). -## Cutting a release +## Preparing a release -1. Push the alleycat changes to `dnakov/alleycat`. -2. Keep the `alleycat` dependency on `branch = "main"` and refresh it with `./tools/scripts/update-alleycat-main.sh --kittylitter`. -3. Bump `version` in this crate's `Cargo.toml` and the version of the kittylitter binary tracking it. -4. Tag `vX.Y.Z` on the litter repo. The `release.yml` workflow at the repo root builds and publishes. +1. Publish the reviewed Alleycat commit to the dependency repository. +2. Pin this manifest and `shared/rust-bridge/Cargo.toml` to the same immutable + revision and source. Update both Cargo lockfiles; `update-alleycat-main.sh` + intentionally leaves revision-pinned dependencies unchanged. +3. Bump this package's version and its own Cargo lockfile entry when changing + a previously released wrapper. Validate the wrapper and both mobile clients + against the intended revision. +4. Review the PR before merging. A push to `main` that changes this manifest + triggers `auto-release.yml`, which dispatches the release workflow for an + unpublished version. Preparing these changes on an unmerged PR does not + publish a release. diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index 8ab3f60f6..d17b7e16d 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -315,7 +315,7 @@ dependencies = [ [[package]] name = "alleycat-bridge-core" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-codex-proto", "anyhow", @@ -334,7 +334,7 @@ dependencies = [ [[package]] name = "alleycat-claude-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "alleycat-codex-proto" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "chrono", "serde", @@ -369,7 +369,7 @@ dependencies = [ [[package]] name = "alleycat-opencode-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "anyhow", @@ -393,7 +393,7 @@ dependencies = [ [[package]] name = "alleycat-pi-bridge" version = "0.1.0" -source = "git+https://github.com/dnakov/alleycat.git?rev=417f2a9fe38cbed63754f0af7df61f32ec3034e6#417f2a9fe38cbed63754f0af7df61f32ec3034e6" +source = "git+https://github.com/makyinmars/alleycat.git?rev=5dd425f23bb321252ccfdedfae592c60c04f7366#5dd425f23bb321252ccfdedfae592c60c04f7366" dependencies = [ "alleycat-bridge-core", "alleycat-codex-proto", @@ -4208,7 +4208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -4529,7 +4529,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4909,7 +4909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6955,7 +6955,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -7041,7 +7041,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -7678,7 +7678,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7772,7 +7772,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8764,7 +8764,7 @@ dependencies = [ "pin-project-lite", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -8807,7 +8807,7 @@ checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" dependencies = [ "cfg_aliases 0.2.2", "libc", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.61.2", ] @@ -8845,7 +8845,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9000,7 +9000,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -10241,7 +10241,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -10278,9 +10278,9 @@ dependencies = [ "cfg_aliases 0.2.2", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -11302,7 +11302,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11361,7 +11361,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11720,7 +11720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12979,7 +12979,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -14467,7 +14467,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index 84bc1eda2..170640d13 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -23,11 +23,13 @@ codex-config = { path = "../third_party/codex/codex-rs/config" } codex-utils-absolute-path = { path = "../third_party/codex/codex-rs/utils/absolute-path" } codex-git-utils = { path = "../third_party/codex/codex-rs/git-utils" } # Pinned to the same immutable commit as the kittylitter daemon so the phone -# and host share one immutable Alleycat release revision. -alleycat-bridge-core = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } -alleycat-pi-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } -alleycat-claude-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } -alleycat-opencode-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" } +# and host share one immutable Alleycat release revision. All crates come from +# the same source so the `Bridge` trait identity is consistent; forked until +# the production-lineage OpenCode and Amp fixes land in the upstream release. +alleycat-bridge-core = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } +alleycat-pi-bridge = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } +alleycat-claude-bridge = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } +alleycat-opencode-bridge = { git = "https://github.com/makyinmars/alleycat.git", rev = "5dd425f23bb321252ccfdedfae592c60c04f7366" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] } diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs index 265283c0e..eee1fb5cf 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs @@ -566,6 +566,23 @@ impl AppStore { }) } + /// Fetch the next page of the session list for a server, merging it + /// additively into the canonical store. Uses the retained per-runtime + /// cursors, advancing them on success. Safe to call repeatedly as the + /// user scrolls the home sessions list; returns `has_more` so the UI can + /// stop offering "load more" when exhausted. + pub async fn load_threads_page( + &self, + server_id: String, + limit: Option, + ) -> Result { + blocking_async!(self.rt, self.inner, |c| { + c.load_threads_page(&server_id, limit) + .await + .map_err(|e| ClientError::Rpc(e.to_string())) + }) + } + pub async fn unsubscribe_thread(&self, key: ThreadKey) -> Result<(), ClientError> { blocking_async!(self.rt, self.inner, |c| { c.thread_unsubscribe(&key.server_id, &key.thread_id) diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs index abc9f561a..7ab41c1f8 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -219,22 +219,6 @@ fn runtime_exposes_model_choices(runtime_kind: &str) -> bool { runtime_kind != "shell" } -fn list_runtime_kinds( - requested: Option>, - available: &[types::AgentRuntimeKind], -) -> Vec { - let mut runtimes = match requested { - Some(requested) if !requested.is_empty() => requested - .into_iter() - .filter(|kind| available.contains(kind)) - .collect(), - _ => available.to_vec(), - }; - runtimes.sort(); - runtimes.dedup(); - runtimes -} - fn append_cached_models_for_failed_runtimes( models: &mut Vec, seen_model_ids: &mut HashSet<(types::AgentRuntimeKind, String)>, @@ -699,135 +683,10 @@ impl AppClient { ) -> Result<(), ClientError> { blocking_async!(self.rt, self.inner, |c| { let requested_runtime_kinds = params.runtime_kinds.clone(); - let drain_all_pages = params.cursor.is_none() - && params.limit.is_none() - && params - .search_term - .as_deref() - .map(str::trim) - .unwrap_or_default() - .is_empty() - && !params.use_state_db_only; let params: upstream::ThreadListParams = params.into(); - let session = c - .get_session(&server_id) - .map_err(|error| ClientError::Rpc(error.to_string()))?; - let available_runtime_kinds = session.runtime_kinds(); - tracing::info!( - "list_threads: resolve runtimes server_id={} requested={:?} available={:?} search_term={:?} use_state_db_only={} limit={:?} cursor={:?}", - server_id, - requested_runtime_kinds, - available_runtime_kinds, - params.search_term, - params.use_state_db_only, - params.limit, - params.cursor - ); - let runtime_kinds = - list_runtime_kinds(requested_runtime_kinds, &available_runtime_kinds); - if runtime_kinds.is_empty() { - return Err(ClientError::Rpc( - "none of the requested agent runtimes are available on this controller" - .to_string(), - )); - } - tracing::info!( - "list_threads: fanout start server_id={} runtime_kinds={:?}", - server_id, - runtime_kinds - ); - - // Fan out per-runtime concurrently. The previous sequential loop - // exhausted Codex's full cursor pagination before non-Codex runtimes - // ever got their first page, so a Codex inbox with many threads - // would starve the other providers — the user would see only - // Codex threads while other runtimes were silently waiting their - // turn. By spawning each runtime's pagination as its own future - // and joining them, every provider's first page lands in - // parallel and the UI gets representative threads from each - // immediately. - let mut codex_visited = false; - let mut tasks = Vec::new(); - for runtime_kind in runtime_kinds { - if runtime_kind == "codex" { - if codex_visited { - continue; - } - codex_visited = true; - } - - let client = std::sync::Arc::clone(c); - let server_id = server_id.clone(); - let initial_params = params.clone(); - tasks.push(async move { - let mut request_params = initial_params; - let mut ids = Vec::new(); - let mut completed = true; - loop { - let response: upstream::ThreadListResponse = - match rpc_runtime::( - client.as_ref(), - &server_id, - runtime_kind.clone(), - req!(server_id, ThreadList, request_params.clone()), - ) - .await - { - Ok(response) => response, - Err(error) => { - tracing::warn!( - "list_threads: thread/list failed for runtime {:?} on server {}: {}", - runtime_kind, server_id, error - ); - completed = false; - break; - } - }; - let page = client.upsert_thread_list_page_for_runtime( - &server_id, - runtime_kind.clone(), - &response.data, - ); - ids.extend(page.into_iter().map(|thread| thread.id)); - let Some(next_cursor) = response.next_cursor else { - break; - }; - if !drain_all_pages { - break; - } - request_params.cursor = Some(next_cursor); - } - (runtime_kind, ids, completed) - }); - } - - let results = futures::future::join_all(tasks).await; - if results.iter().all(|(_, _, completed)| !completed) { - return Err(ClientError::Rpc( - "thread list failed for every runtime".into(), - )); - } - // Only prune if every runtime finished cleanly. A partial - // result (one runtime timed out / errored) means we don't - // know its true thread set yet, and `finalize_thread_list_sync` - // would delete unseen threads from healthy runtimes too — - // wiping pi/opencode threads from the store on a transient - // codex failure. Skip pruning in that case; the next refresh - // reconciles when the failing runtime recovers. - let all_completed = results.iter().all(|(_, _, ok)| *ok); - if all_completed && drain_all_pages { - let mut all_thread_ids = Vec::new(); - for (_, ids, _) in results { - all_thread_ids.extend(ids); - } - c.finalize_thread_list_sync(&server_id, all_thread_ids); - } 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) }) } @@ -2988,7 +2847,6 @@ mod tests { use super::{ ImageViewSource, append_cached_models_for_failed_runtimes, append_missing_amp_mode_models, choose_saved_app_update_server_id, image_read_command, is_mobile_hidden_skill, - list_runtime_kinds, normalize_model_info_for_runtime, normalized_image_path, runtime_exposes_model_choices, splice_generative_ui_preamble, }; @@ -3118,8 +2976,8 @@ mod tests { #[test] fn thread_list_only_queries_runtimes_the_controller_exposes() { let local_studio = vec!["local-studio".to_string()]; - assert_eq!(list_runtime_kinds(None, &local_studio), local_studio); - assert!(list_runtime_kinds(Some(vec!["codex".to_string()]), &local_studio).is_empty()); + assert_eq!(crate::types::list_runtime_kinds(None, &local_studio), local_studio); + assert!(crate::types::list_runtime_kinds(Some(vec!["codex".to_string()]), &local_studio).is_empty()); } #[test] diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs index 521eb2abb..b2a1d0278 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs @@ -601,6 +601,7 @@ mod tests { AppSnapshot { servers: HashMap::new(), threads: HashMap::new(), + session_pages: HashMap::new(), active_thread: None, pending_approvals: Vec::new(), pending_approval_seeds: HashMap::new(), diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index e82b9e252..29c5f5348 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -674,7 +674,9 @@ fn missing_runtime_kinds( .cloned() .collect::>(); let mut missing = requested_runtime_kinds - .iter().filter(|&kind| !existing.contains(kind)).cloned() + .iter() + .filter(|&kind| !existing.contains(kind)) + .cloned() .collect::>(); missing.sort(); missing @@ -744,6 +746,34 @@ fn alleycat_dial_retry_delays(use_all_controller_agents: bool) -> &'static [u64] } } +fn thread_list_is_unfiltered( + requested_runtime_kinds: Option<&Vec>, + params: &upstream::ThreadListParams, +) -> bool { + requested_runtime_kinds.is_none_or(Vec::is_empty) + && params.model_providers.as_ref().is_none_or(Vec::is_empty) + && params.source_kinds.as_ref().is_none_or(Vec::is_empty) + && params.cwd.is_none() + && params.archived != Some(true) + && params + .search_term + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + && !params.use_state_db_only +} + +fn thread_list_tracks_home_cursor(unfiltered: bool, params: &upstream::ThreadListParams) -> bool { + unfiltered + && params.cursor.is_none() + && matches!(params.sort_key, Some(upstream::ThreadSortKey::UpdatedAt)) + && matches!( + params.sort_direction, + None | Some(upstream::SortDirection::Desc) + ) +} + impl MobileClient { /// Create a new `MobileClient`. pub fn new() -> Self { @@ -2549,6 +2579,148 @@ 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 = thread_list_is_unfiltered(requested_runtime_kinds.as_ref(), ¶ms); + let tracks_home_cursor = thread_list_tracks_home_cursor(unfiltered, ¶ms); + let session = self + .get_session(server_id) + .map_err(|error| error.to_string())?; + 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, including when bounded hydration stops + // before the final page. + 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 a complete, unfiltered scan can prove absent threads were + // deleted. Budget-limited and filtered loads remain additive. + 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 { @@ -2772,10 +2944,8 @@ impl MobileClient { { Ok(()) => { self.note_thread_runtime(key.clone(), runtime_kind.clone()); - let post_resume_active = self - .app_store - .thread_snapshot(&key) - .is_some_and(|thread| { + let post_resume_active = + self.app_store.thread_snapshot(&key).is_some_and(|thread| { thread.active_turn_id.is_some() || matches!(thread.info.status, ThreadSummaryStatus::Active) }); @@ -3141,6 +3311,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, @@ -3161,7 +3446,12 @@ impl MobileClient { ) .await .map_err(RpcError::Deserialization)?; - upsert_thread_snapshot_from_app_server_read_response(&self.app_store, server_id, response) + upsert_thread_snapshot_from_app_server_read_response( + &self.app_store, + server_id, + response, + false, + ) } pub async fn thread_unsubscribe( @@ -3731,7 +4021,7 @@ impl MobileClient { { Ok(response) => { if let Err(error) = upsert_thread_snapshot_from_app_server_read_response( - &app_store, &server_id, response, + &app_store, &server_id, response, true, ) { warn!( "MobileClient: failed to reconcile thread after user input for server={} thread={}: {}", @@ -3936,7 +4226,6 @@ impl MobileClient { pub fn set_voice_handoff_thread(&self, key: Option) { self.app_store.set_voice_handoff_thread(key); } - } /// Listener that feeds session output bytes into the reducer's ring @@ -3975,23 +4264,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/mobile_client/store_listener.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs index 9baf94c9a..1be81bbd8 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/store_listener.rs @@ -98,6 +98,7 @@ fn maybe_reconcile_idle_thread( &app_store, &key.server_id, response, + true, ) { warn!( "MobileClient: failed to reconcile idle thread for server={} thread={}: {}", @@ -184,7 +185,7 @@ fn maybe_hydrate_collab_agent_metadata( return; } if let Err(error) = upsert_thread_snapshot_from_app_server_read_response( - &app_store, &server_id, response, + &app_store, &server_id, response, false, ) { warn!( "MobileClient: failed to hydrate collab receiver metadata for server={} thread={}: {}", diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs index 17a699106..8c60cd437 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/tests.rs @@ -8,6 +8,106 @@ mod mobile_client_tests { use std::path::PathBuf; use std::sync::{Arc, Mutex as StdMutex}; + #[test] + fn metadata_read_preserves_page_and_active_turn_despite_embedded_history() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread-1".to_string(), + }; + let mut existing = ThreadSnapshot::from_info("srv", make_thread_info("thread-1")); + existing.active_turn_id = Some("turn-1".to_string()); + existing.info.status = ThreadSummaryStatus::Active; + existing.items = vec![crate::conversation::make_error_item( + "paged-item".into(), + "kept".into(), + None, + )] + .into(); + existing.older_turns_cursor = Some("older".to_string()); + existing.initial_turns_loaded = true; + reducer.upsert_thread_snapshot(existing); + + let response: upstream::ThreadReadResponse = serde_json::from_value(serde_json::json!({ + "thread": { + "id": "thread-1", + "sessionId": "session-1", + "preview": "hi", + "ephemeral": false, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 2, + "status": { "type": "idle" }, + "path": "/tmp/thread", + "cwd": "/tmp/thread", + "cliVersion": "1.0.0", + "source": "cli", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": "thread", + "turns": [ + { + "id": "turn-1", + "items": [], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + } + ] + } + })) + .expect("thread/read response should deserialize"); + + upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response, false) + .expect("upsert should succeed"); + + let snapshot = reducer + .snapshot() + .threads + .get(&key) + .cloned() + .expect("thread snapshot should exist"); + + assert_eq!(snapshot.active_turn_id.as_deref(), Some("turn-1")); + assert_eq!(snapshot.older_turns_cursor.as_deref(), Some("older")); + assert!(snapshot.initial_turns_loaded); + assert_eq!(snapshot.items.len(), 1); + assert_eq!(snapshot.items[0].id, "paged-item"); + assert_eq!(snapshot.info.status, ThreadSummaryStatus::Active); + } + + #[test] + fn scoped_thread_lists_cannot_prune_or_replace_home_cursors() { + let mut params: upstream::ThreadListParams = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(thread_list_is_unfiltered(None, ¶ms)); + assert!(!thread_list_tracks_home_cursor(true, ¶ms)); + params.sort_key = Some(upstream::ThreadSortKey::UpdatedAt); + assert!(thread_list_tracks_home_cursor(true, ¶ms)); + let runtimes = vec!["claude".to_string()]; + assert!(!thread_list_is_unfiltered(Some(&runtimes), ¶ms)); + params.cwd = serde_json::from_value(serde_json::json!("/one-project")).unwrap(); + assert!(!thread_list_is_unfiltered(None, ¶ms)); + params.cwd = None; + params.search_term = Some("search".into()); + assert!(!thread_list_is_unfiltered(None, ¶ms)); + params.search_term = None; + params.archived = Some(true); + assert!(!thread_list_is_unfiltered(None, ¶ms)); + params.archived = None; + params.cursor = Some("search-cursor".into()); + assert!(!thread_list_tracks_home_cursor(true, ¶ms)); + params.cursor = None; + params.sort_direction = Some(upstream::SortDirection::Asc); + assert!(!thread_list_tracks_home_cursor(true, ¶ms)); + params.sort_direction = None; + assert!(!thread_list_tracks_home_cursor(false, ¶ms)); + } + #[test] fn account_sync_warmup_only_runs_when_codex_runtime_is_present() { assert!(runtime_kinds_support_account_sync(&["codex".to_string()])); @@ -827,7 +927,7 @@ mod mobile_client_tests { })) .expect("thread/read response should deserialize"); - upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response) + upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response, true) .expect("upsert should succeed"); let key = ThreadKey { @@ -891,7 +991,7 @@ mod mobile_client_tests { })) .expect("thread/read response should deserialize"); - upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response) + upsert_thread_snapshot_from_app_server_read_response(&reducer, "srv", response, true) .expect("upsert should succeed"); let snapshot = reducer diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs index 009eb3d8f..0d1662c1b 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/thread_projection.rs @@ -452,7 +452,9 @@ pub(super) fn core_reasoning_effort_from_mobile( codex_protocol::openai_models::ReasoningEffort::XHigh } crate::types::ReasoningEffort::Max => codex_protocol::openai_models::ReasoningEffort::Max, - crate::types::ReasoningEffort::Ultra => codex_protocol::openai_models::ReasoningEffort::Ultra, + crate::types::ReasoningEffort::Ultra => { + codex_protocol::openai_models::ReasoningEffort::Ultra + } } } @@ -576,8 +578,12 @@ pub(super) async fn read_thread_response_from_app_server_runtime( pub(super) fn upsert_thread_snapshot_from_app_server_read_response( app_store: &AppStoreReducer, server_id: &str, - response: upstream::ThreadReadResponse, + mut response: upstream::ThreadReadResponse, + include_turns: bool, ) -> Result<(), RpcError> { + if !include_turns { + response.thread.turns.clear(); + } let turns = response.thread.turns.clone(); let thread_id = response.thread.id.clone(); let existing = app_store @@ -600,6 +606,7 @@ pub(super) fn upsert_thread_snapshot_from_app_server_read_response( if let Some(existing) = existing.as_ref() { copy_thread_runtime_fields(existing, &mut snapshot); } + crate::store::reconcile::apply_pagination_merge(existing.as_ref(), &mut snapshot, &turns); reconcile_active_turn(existing.as_ref(), &mut snapshot, &turns); app_store.upsert_thread_snapshot(snapshot); Ok(()) diff --git a/shared/rust-bridge/codex-mobile-client/src/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/reconcile.rs b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs index e81656183..4aed234dd 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs @@ -65,8 +65,8 @@ impl MobileClient { response, params.include_turns, ) - .map(|_| ()) - .map_err(RpcError::Deserialization) + .map(|_| ()) + .map_err(RpcError::Deserialization) } "thread/resume" => { let response = downcast_public_rpc_response::( @@ -446,7 +446,7 @@ impl MobileClient { /// does not flicker to empty while pagination loads the first page. Legacy /// servers ignore `exclude_turns` and return the embedded turns — we treat /// those as an authoritative hydration. -fn apply_pagination_merge( +pub(crate) fn apply_pagination_merge( existing: Option<&ThreadSnapshot>, target: &mut ThreadSnapshot, upstream_turns: &[upstream::Turn], @@ -622,24 +622,25 @@ fn merge_paged_turns( ) }); if let Some(id) = group_turn_id.as_deref() - && existing_turn_ids.contains(id) { - // A reconnect repair page is authoritative for completed turn - // text. Drop stale streaming assistant/reasoning placeholders - // absent from the replay, while preserving the historical - // turn-id dedupe for non-stream/user items. - if thread.active_turn_id.is_none() - && group_replays_existing_user - && group_has_persisted_text - { - prune_replayed_live_span(thread, &group_user_keys, &incoming_item_ids); - thread.items.retain(|item| { - incoming_item_ids.contains(&item.id) - || !is_stream_text_item(item) - || item.source_turn_id.as_deref() != Some(id) - }); - } - continue; + && existing_turn_ids.contains(id) + { + // A reconnect repair page is authoritative for completed turn + // text. Drop stale streaming assistant/reasoning placeholders + // absent from the replay, while preserving the historical + // turn-id dedupe for non-stream/user items. + if thread.active_turn_id.is_none() + && group_replays_existing_user + && group_has_persisted_text + { + prune_replayed_live_span(thread, &group_user_keys, &incoming_item_ids); + thread.items.retain(|item| { + incoming_item_ids.contains(&item.id) + || !is_stream_text_item(item) + || item.source_turn_id.as_deref() != Some(id) + }); } + continue; + } if thread.active_turn_id.is_none() && group_replays_existing_user && group_has_persisted_text @@ -1154,7 +1155,8 @@ mod tests { thread.items = vec![ live_user, assistant_item(None, "live-assistant-id", "partial"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), @@ -1178,7 +1180,8 @@ mod tests { assistant_item(Some("turn-0"), "older-assistant-id", "older final"), live_user, assistant_item(None, "live-assistant-id", "partial"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), @@ -1208,7 +1211,8 @@ mod tests { thread.items = vec![ live_user, assistant_item(Some("active-turn"), "active-assistant-id", "partial"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), @@ -1233,7 +1237,8 @@ mod tests { item_with_turn("turn-1", "persisted-user-id"), assistant_item(Some("turn-1"), "persisted-assistant-id", "final"), assistant_item(Some("turn-1"), "late-stream-assistant-id", "late duplicate"), - ].into(); + ] + .into(); let page = AppListThreadTurnsResponse { turns: vec![ item_with_turn("turn-1", "persisted-user-id"), diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs index c945b43b9..312da3ece 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs @@ -18,13 +18,13 @@ use crate::conversation_uniffi::{ }; use crate::session::connection::ServerConfig; use crate::session::events::UiEvent; +#[cfg(test)] +use crate::types::PendingApprovalWithSeed; use crate::types::{ AgentRuntimeInfo, AgentRuntimeKind, PendingApproval, PendingApprovalKey, PendingApprovalSeed, - PendingUserInputAnswer, PendingUserInputKey, PendingUserInputRequest, - PendingUserInputSeed, ThreadInfo, ThreadKey, ThreadSummaryStatus, + PendingUserInputAnswer, PendingUserInputKey, PendingUserInputRequest, PendingUserInputSeed, + ThreadInfo, ThreadKey, ThreadSummaryStatus, }; -#[cfg(test)] -use crate::types::PendingApprovalWithSeed; use crate::types::{ AppModeKind, AppOperationStatus, AppPlanProgressSnapshot, AppPlanStep, AppThreadGoal, AppVoiceSessionPhase, AppVoiceTranscriptEntry, AppVoiceTranscriptUpdate, @@ -340,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() @@ -991,11 +1039,10 @@ impl AppStoreReducer { .mutate_thread_with_result(key, |thread| { let mut updated_item = None; let mut needs_reprojection = false; - let pending_overlay_index = - thread.local_overlay_items.iter().position(|item| { - item.id.starts_with(LOCAL_USER_MESSAGE_ITEM_PREFIX) - && item.source_turn_id.is_none() - }); + let pending_overlay_index = thread.local_overlay_items.iter().position(|item| { + item.id.starts_with(LOCAL_USER_MESSAGE_ITEM_PREFIX) + && item.source_turn_id.is_none() + }); if let Some(item) = pending_overlay_index .and_then(|index| thread.local_overlay_items.get_mut(index)) { @@ -2252,8 +2299,7 @@ impl AppStoreReducer { } VoiceDerivedUpdate::HandoffRequest(request) => { { - let mut snapshot = - self.write_snapshot(); + let mut snapshot = self.write_snapshot(); snapshot.voice_session.phase = Some(AppVoiceSessionPhase::Handoff); } self.emit(AppStoreUpdateRecord::VoiceSessionChanged); @@ -2264,8 +2310,7 @@ impl AppStoreReducer { } VoiceDerivedUpdate::SpeechStarted => { { - let mut snapshot = - self.write_snapshot(); + let mut snapshot = self.write_snapshot(); snapshot.voice_session.phase = Some(AppVoiceSessionPhase::Listening); } @@ -3836,6 +3881,27 @@ mod tests { }; use tokio::sync::broadcast::error::TryRecvError; + #[test] + fn session_page_state_distinguishes_unloaded_and_exhausted_and_notifies() { + let store = AppStoreReducer::new(); + let runtime = "codex".to_string(); + let mut updates = store.subscribe(); + assert!(store.thread_page_state("srv", &runtime).is_none()); + store.set_thread_page_state("srv", &runtime, Some("next".into()), true); + assert!( + matches!(updates.try_recv(), Ok(AppStoreUpdateRecord::ServerChanged { server_id }) if server_id == "srv") + ); + let state = store.thread_page_state("srv", &runtime).unwrap(); + assert_eq!(state.cursor.as_deref(), Some("next")); + assert!(state.has_more); + store.set_thread_page_state("srv", &runtime, None, false); + assert!(!store.thread_page_state("srv", &runtime).unwrap().has_more); + assert!(matches!( + updates.try_recv(), + Ok(AppStoreUpdateRecord::ServerChanged { .. }) + )); + } + fn make_thread_info(id: &str) -> ThreadInfo { ThreadInfo { id: id.to_string(), @@ -3856,7 +3922,6 @@ mod tests { } } - // ── Derived-state cache invalidation ────────────────────────────── // // These cover the memoization introduced for the agent-directory @@ -4139,7 +4204,10 @@ mod tests { let big = "x".repeat(200_000); let running = base(&big, AppOperationStatus::InProgress); - assert_eq!(item_fingerprint(&running), item_fingerprint(&running.clone())); + assert_eq!( + item_fingerprint(&running), + item_fingerprint(&running.clone()) + ); // Status change after a huge output body: same length, different // value — this is what the retained tail window is for. @@ -5707,15 +5775,13 @@ mod tests { server_id: "srv".to_string(), thread_id: "thread".to_string(), }; - let item = |id: &str, content: HydratedConversationItemContent| { - HydratedConversationItem { - id: id.to_string(), - content, - source_turn_id: None, - source_turn_index: None, - timestamp: None, - is_from_user_turn_boundary: false, - } + let item = |id: &str, content: HydratedConversationItemContent| HydratedConversationItem { + id: id.to_string(), + content, + source_turn_id: None, + source_turn_index: None, + timestamp: None, + is_from_user_turn_boundary: false, }; let mut live = ThreadSnapshot::from_info("srv", make_thread_info("thread")); live.items.push(item( @@ -6156,7 +6222,8 @@ mod tests { timestamp: None, is_from_user_turn_boundary: false, }, - ].into(); + ] + .into(); reducer.upsert_thread_snapshot(existing); let mut incoming = ThreadSnapshot::from_info("srv", make_thread_info("thread")); @@ -6187,7 +6254,8 @@ mod tests { timestamp: None, is_from_user_turn_boundary: false, }, - ].into(); + ] + .into(); let mut receiver = reducer.subscribe(); assert!(drain_updates(&mut receiver).is_empty()); @@ -6223,7 +6291,8 @@ mod tests { source_turn_index: Some(1), timestamp: None, is_from_user_turn_boundary: false, - }].into(); + }] + .into(); reducer.upsert_thread_snapshot(existing); let mut incoming = ThreadSnapshot::from_info("srv", make_thread_info("thread")); @@ -6239,7 +6308,8 @@ mod tests { source_turn_index: Some(1), timestamp: None, is_from_user_turn_boundary: false, - }].into(); + }] + .into(); let mut receiver = reducer.subscribe(); assert!(drain_updates(&mut receiver).is_empty()); 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( diff --git a/tools/scripts/update-alleycat-main.sh b/tools/scripts/update-alleycat-main.sh index 97d5d4eda..aeb33d973 100755 --- a/tools/scripts/update-alleycat-main.sh +++ b/tools/scripts/update-alleycat-main.sh @@ -45,7 +45,7 @@ resolve_alleycat_main() { } alleycat_is_pinned() { - grep -q 'dnakov/alleycat\.git.*rev = ' "$1" + grep -Eq '^alleycat[^=]*=.*rev[[:space:]]*=' "$1" } update_shared() {