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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ class AppLifecycleController {
val retryResults = appModel.reconnectController.reconnectSavedServers()
retryResults.forEach { appModel.recordSshHostKeyChange(it.serverId, it.errorMessage) }
restoreLocalStateAfterReconnect(appModel, retryResults)
appModel.refreshSnapshot()
// Load the first page of sessions synchronously so the snapshot has
// real thread data and accurate `session_list_has_more` cursors.
// bare refreshSnapshot() captured a stale store before the
// fire-and-forget warmup could finish.
val connectedServerIds = results.map { it.serverId }
if (connectedServerIds.isNotEmpty()) {
appModel.loadSessionsPage(connectedServerIds, limit = 20u)
} else {
appModel.refreshSnapshot()
}
// If reconnecting saved alleycat servers triggered the iroh
// endpoint bind, persist any freshly-generated device key.
appModel.persistAlleycatSecretKeyIfNeeded()
Expand Down Expand Up @@ -254,12 +263,8 @@ class AppLifecycleController {
results: List<uniffi.codex_mobile_client.ReconnectResult>,
) {
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)
}
}
}
Expand Down
60 changes: 49 additions & 11 deletions apps/android/app/src/main/java/com/litter/android/state/AppModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ class AppModel private constructor(context: android.content.Context) {
/**
* Matches the iOS page sizes. Server clamps this at 100.
*/
const val INITIAL_TURN_PAGE_LIMIT: UInt = 5u
const val OLDER_TURN_PAGE_LIMIT: UInt = 5u
const val INITIAL_TURN_PAGE_LIMIT: UInt = 20u
const val OLDER_TURN_PAGE_LIMIT: UInt = 20u
}

// --- Rust bridges (singletons behind the scenes) -------------------------
Expand Down Expand Up @@ -465,7 +465,10 @@ class AppModel private constructor(context: android.content.Context) {
)
restoreStoredLocalAuthState(serverId)
try {
refreshSessions(listOf(serverId))
// First page only — the home dashboard drives the rest via
// infinite scroll. Full drains still happen on pull-to-refresh
// and the dedicated Sessions screen.
loadSessionsPage(listOf(serverId), limit = 10u)
} catch (_: Exception) {
}
refreshSnapshot()
Expand Down Expand Up @@ -508,6 +511,31 @@ class AppModel private constructor(context: android.content.Context) {
}
}

/**
* Fetch the next page of the session list for each server via
* `store.loadThreadsPage`, merging additively into the canonical store.
* Used by the home dashboard's infinite scroll. On failure for a server,
* falls back to a full `refreshSessions` drain so the home list still
* populates (the retained Rust cursor state is cleared by that drain).
*/
suspend fun loadSessionsPage(serverIds: Collection<String>, 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?,
Expand Down Expand Up @@ -885,6 +913,7 @@ class AppModel private constructor(context: android.content.Context) {
*/
private val initialTurnsLoadingKeys = mutableSetOf<ThreadKey>()
private val olderTurnsLoadingKeys = mutableSetOf<ThreadKey>()
private val sessionPageLoadingServerIds = mutableSetOf<String>()

/**
* Launch an initial-turn load on the AppModel-owned scope so it survives
Expand Down Expand Up @@ -932,19 +961,27 @@ class AppModel private constructor(context: android.content.Context) {

/**
* Fetch the next older page using the thread's stored
* `older_turns_cursor`. No-op when the cursor is null.
* `older_turns_cursor`. No-op (returns false) when the cursor is null or
* empty, or when a page is already in flight for this thread.
*
* Returns a [Job] so the caller can `join()` to drive UI state (e.g.
* spinner on the "Load earlier messages" button).
* Runs on the AppModel-owned scope so the RPC survives recomposition.
* `onResult` reports whether a page was actually merged, so the
* conversation UI can release its "requested cursor" guard once the store
* advances `olderTurnsCursor` (or retry on failure).
*/
fun loadOlderTurns(key: ThreadKey, limit: UInt = OLDER_TURN_PAGE_LIMIT): Job {
val cursor = threadSnapshot(key)?.olderTurnsCursor
if (cursor == null || !olderTurnsLoadingKeys.add(key)) {
return scope.launch { /* no-op */ }
}
fun loadOlderTurns(
key: ThreadKey,
limit: UInt = OLDER_TURN_PAGE_LIMIT,
onResult: (didLoad: Boolean) -> Unit = {},
): Job {
val cursor = threadSnapshot(key)?.olderTurnsCursor ?: return scope.launch { onResult(false) }
if (cursor.isEmpty()) return scope.launch { onResult(false) }
if (!olderTurnsLoadingKeys.add(key)) return scope.launch { onResult(false) }
return scope.launch {
var didLoad = false
try {
val outcome = store.loadThreadTurnsPage(key, cursor, limit)
didLoad = outcome.loaded
LLog.i(
"Pagination",
"loadOlderTurns",
Expand All @@ -969,6 +1006,7 @@ class AppModel private constructor(context: android.content.Context) {
_lastError.value = e.message
} finally {
olderTurnsLoadingKeys.remove(key)
onResult(didLoad)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<String?>(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<String>()) }
var streamingRenderTick by remember(threadKey) { mutableStateOf(0) }
var followScrollToken by remember(threadKey) { mutableStateOf(0) }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -436,69 +491,28 @@ fun ConversationScreen(
} else Modifier.drawWithContent { drawContent() }
),
) {
if (isWaitingForData || isInitialTurnsLoading) {
if (isWaitingForData) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1273,6 +1312,10 @@ private fun uniffi.codex_mobile_client.AppThreadSnapshot.composerContextPercent(

private fun conversationBottomAnchorIndex(turnCount: Int): Int = turnCount + 1

/// iOS parity: prefetch the next older page once the earliest visible turn is
/// within this many rows of the top of the transcript.
private const val OlderTurnsPrefetchDistance = 6

@Composable
private fun PlanContextBadge(progress: String) {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,14 @@ private fun List<HydratedConversationItem>.isExplorationGroup(): Boolean {
private fun turnIdentifier(items: List<HydratedConversationItem>, 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}"
}
}

Expand Down
Loading
Loading