feat(history): fluid infinite scroll + bigger pages for the sessions list (#885)

- Page size 20 -> 50 (matches the desktop sidebar; backend caps at 100)
- Auto-load the next page when scrolling within 6 items of the end,
  driven by real scroll position (layoutInfo-derived, not frozen state)
- Guard against offset-pagination churn: dedupe appended ids so a new
  session landing on top between page loads can't crash LazyColumn on a
  duplicate key
- Tests: append+dedupe, in-flight loadMore no-op guard

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-12 14:03:19 +03:00
1 parent ff951eaf14
commit 95178d3438
3 files changed
+110 -3

No files matched your search

@@ -27,6 +27,7 @@ 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.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -64,6 +65,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -118,6 +120,12 @@ import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.abs
/**
* Auto-load the next history page when the user scrolls to within this many
* items of the end — a pre-load buffer so paging feels continuous.
*/
private const val AUTO_LOAD_THRESHOLD = 6
/**
* Maps a session source string to a Material icon for visual identification.
*/
@@ -747,7 +755,32 @@ fun SessionsScreen(
}
// ── Session list ────────────────────────────────────
val listState = rememberLazyListState()
// Fluid infinite scroll: once the user reaches within
// AUTO_LOAD_THRESHOLD items of the end, pull the next
// page automatically. Driven by real scroll position
// (layoutInfo is snapshot state), so it recomputes on
// every scroll — not a frozen first-composition read.
val nearListEnd by remember {
derivedStateOf {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= 0 &&
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
}
}
LaunchedEffect(
nearListEnd,
state.isLoadingMore,
state.hasMore,
state.isSearchMode,
) {
if (nearListEnd && !state.isLoadingMore && state.hasMore && !state.isSearchMode) {
viewModel.loadMore()
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = listPadding,
verticalArrangement = listItemSpacing,
@@ -120,9 +120,12 @@ class SessionsViewModel :
)
}
/** Page size sent to the server — matches the default the gateway uses. */
/**
* Page size sent to the server. Matches the desktop sidebar's
* SIDEBAR_SESSIONS_PAGE_SIZE (50); the backend caps `limit` at 100.
*/
private companion object {
const val PAGE_SIZE = 20
const val PAGE_SIZE = 50
const val SEARCH_DEBOUNCE_MS = 300L
}
@@ -186,7 +189,11 @@ class SessionsViewModel :
_uiState.update {
it.copy(
isLoadingMore = false,
sessions = it.sessions + data.sessions,
// distinctBy guards offset-pagination churn: if a new
// session lands on top between page loads, offsets
// shift and the next page can repeat an id we already
// have — LazyColumn would crash on the duplicate key.
sessions = (it.sessions + data.sessions).distinctBy { s -> s.id },
total = data.total,
)
}