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:
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -64,6 +65,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -118,6 +120,12 @@ import java.time.format.DateTimeFormatter
|
|||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import kotlin.math.abs
|
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.
|
* Maps a session source string to a Material icon for visual identification.
|
||||||
*/
|
*/
|
||||||
@@ -747,7 +755,32 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Session list ────────────────────────────────────
|
// ── 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(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = listPadding,
|
contentPadding = listPadding,
|
||||||
verticalArrangement = listItemSpacing,
|
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 {
|
private companion object {
|
||||||
const val PAGE_SIZE = 20
|
const val PAGE_SIZE = 50
|
||||||
const val SEARCH_DEBOUNCE_MS = 300L
|
const val SEARCH_DEBOUNCE_MS = 300L
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +189,11 @@ class SessionsViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoadingMore = false,
|
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,
|
total = data.total,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.m57.hermescontrol.ui.sessions
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
@@ -19,6 +23,7 @@ import org.junit.Assert.assertFalse
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class SessionsViewModelTest {
|
class SessionsViewModelTest {
|
||||||
@@ -90,4 +95,66 @@ class SessionsViewModelTest {
|
|||||||
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user