From 95178d343849b43d8e555f293222079de317570d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:03:19 +0300 Subject: [PATCH] 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 --- .../ui/sessions/SessionsScreen.kt | 33 +++++++++ .../ui/sessions/SessionsViewModel.kt | 13 +++- .../ui/sessions/SessionsViewModelTest.kt | 67 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index a8b5b415..24d61f2f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 54e1fb43..f13a79e2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index da5c004c..10e9d91b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -1,7 +1,11 @@ package com.m57.hermescontrol.ui.sessions +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -19,6 +23,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class SessionsViewModelTest { @@ -90,4 +95,66 @@ class SessionsViewModelTest { cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"), ) } + + @Test + fun `loadMore appends the next page and dedupes overlapping ids`() { + val vm = createViewModel() + + // Page 1: 2 sessions of 3 total — hasMore stays true. + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")), + total = 3, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) + assertTrue(vm.uiState.value.hasMore) + assertFalse(vm.uiState.value.isLoadingMore) + + // Page 2 overlaps page 1 (offset churn: a new session landed on top + // between loads) — the duplicate id must not double-append. + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = listOf(SessionInfo("s-3"), SessionInfo("s-2")), + total = 3, + ), + ) + vm.loadMore() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("s-1", "s-2", "s-3"), vm.uiState.value.sessions.map { it.id }) + assertEquals(3, vm.uiState.value.total) + assertFalse(vm.uiState.value.hasMore) + assertFalse(vm.uiState.value.isLoadingMore) + } + + @Test + fun `loadMore is a no-op while a load is already running`() { + val vm = createViewModel() + + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = listOf(SessionInfo("s-1")), + total = 2, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + // Fire two loadMore calls back-to-back before the dispatcher runs: the + // first sets isLoadingMore=true synchronously, the second must be dropped. + vm.loadMore() + vm.loadMore() + testDispatcher.scheduler.advanceUntilIdle() + + // loadSessions (1) + exactly one loadMore (1) = 2 API hits total. + coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) } + assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id }) + assertFalse(vm.uiState.value.isLoadingMore) + } }