Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0992333
ios: paginate older turns with infinite scroll
DatScreamer Aug 15, 2026
e056c6c
feat: infinite scroll + pull-to-refresh for home sessions list
DatScreamer Aug 17, 2026
aef6af4
fix(opencode): fetch and aggregate session list on connect
DatScreamer Aug 15, 2026
c6c1b0e
android: fix restoreLocalStateAfterReconnect gating
Aug 19, 2026
f979349
home: reduce extra sessions-list top spacing from 8px to 4px
Aug 19, 2026
1d921ae
fix: don't clear session page state after full drain in warmup
Aug 19, 2026
4b42379
home: increase default session list from 10 to 20
Aug 19, 2026
6e6b7d8
ios: increase default session list from 10 to 20
Aug 19, 2026
b9bf872
fix: load first page only in warmup so infinite scroll can trigger
Aug 19, 2026
259a153
fix: load first session page synchronously on reconnect
Aug 19, 2026
660e859
revert: remove extra top padding on home dashboard
Aug 19, 2026
fbe6b26
session-card: add 4dp spacing below title
DatScreamer Aug 19, 2026
4fb130d
home: complete infinite scroll fixes for both platforms
DatScreamer Aug 24, 2026
d393140
home: exclude subagent sessions from dashboard list
DatScreamer Aug 24, 2026
09c3ffc
test: add sessionListHasMore arg to AppServerSnapshot constructions
DatScreamer Aug 25, 2026
2e5746a
fix: address session pagination review regressions
makyinmars Sep 12, 2026
2aa5973
Merge remote-tracking branch 'origin/main' into review/litter-331
makyinmars Sep 12, 2026
1012458
fix: restore native compilation for detached transport records
makyinmars Sep 12, 2026
9c6c1ab
fix: preserve pinned bridge builds and cover scoped pagination
makyinmars Sep 12, 2026
0c225d2
fix: restore production bridge lineage and align daemon pin
makyinmars Sep 12, 2026
b08ea92
fix: restore mobile CI compatibility
DatScreamer Sep 13, 2026
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 @@ -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()
Expand Down Expand Up @@ -241,12 +250,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 @@ -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) -------------------------
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<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 @@ -859,6 +887,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 @@ -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",
Expand All @@ -943,6 +980,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 @@ -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(
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
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
)
Expand Down
Loading
Loading