fix(#219): add pagination to sessions history tab (#357)

* fix(#219): add pagination to sessions history tab

The /api/sessions endpoint was called without pagination params, so the server returned only its default limit of 20 sessions.

- SessionListResponse: added total/limit/offset fields from server response
- HermesApiService: added @Query params (limit, offset, order) to getSessions()
- SessionsViewModel: added loadMore() that appends pages, hasMore derived from total
- SessionsScreen: added 'Load more sessions' button + loading spinner at bottom of list

* chore: remove plan file from git

* fix: compilation errors in sessions pagination

* chore: gitignore plan files

* fix: remove duplicate brace

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-25 00:58:52 +03:00
1 parent 067f4500aa
commit 4ba74c2efa
7 files changed
+114 -7

No files matched your search

+1
View File
@@ -18,3 +18,4 @@ local.properties
$PWD/ $PWD/
ktlint ktlint
.hermes/plans/
@@ -2,6 +2,9 @@ package com.m57.hermescontrol.data.model
data class SessionListResponse( data class SessionListResponse(
val sessions: List<SessionInfo>, val sessions: List<SessionInfo>,
val total: Int = 0,
val limit: Int = 0,
val offset: Int = 0,
) )
data class SessionInfo( data class SessionInfo(
@@ -67,7 +67,11 @@ interface HermesApiService {
suspend fun getStatus(): Response<StatusResponse> suspend fun getStatus(): Response<StatusResponse>
@GET("api/sessions") @GET("api/sessions")
suspend fun getSessions(): Response<SessionListResponse> suspend fun getSessions(
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0,
@Query("order") order: String = "recent",
): Response<SessionListResponse>
@GET("api/sessions/{id}/messages") @GET("api/sessions/{id}/messages")
suspend fun getSessionMessages( suspend fun getSessionMessages(
@@ -1,12 +1,14 @@
package com.m57.hermescontrol.ui.sessions package com.m57.hermescontrol.ui.sessions
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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
@@ -14,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -28,6 +31,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ChatScreen import com.m57.hermescontrol.ChatScreen
@@ -156,6 +160,38 @@ fun SessionsScreen(
} }
} }
} }
// Load more button
if (state.hasMore || state.isLoadingMore) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = spacing.sm),
contentAlignment = Alignment.Center,
) {
if (state.isLoadingMore) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Text(
text = "Load more sessions...",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.testTag("load_more_sessions")
.clickable(role = Role.Button) {
viewModel.loadMore()
},
)
}
}
}
}
} }
} }
} }
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.sessions package com.m57.hermescontrol.ui.sessions
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
@@ -9,26 +10,47 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SessionsUiState( data class SessionsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val sessions: List<SessionInfo> = emptyList(), val sessions: List<SessionInfo> = emptyList(),
val total: Int = 0,
val errorMessage: String? = null, val errorMessage: String? = null,
) ) {
val hasMore: Boolean get() = total > sessions.size
}
class SessionsViewModel : ViewModel() { class SessionsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SessionsUiState()) private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
/** Page size sent to the server — matches the default the gateway uses. */
private companion object {
const val PAGE_SIZE = 20
}
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
fun loadSessions() { fun loadSessions() {
safeLaunchLoad( safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getSessions() } }, apiCall = {
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
)
}
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data -> onSuccess = { data ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
isLoadingMore = false,
sessions = data?.sessions.orEmpty(), sessions = data?.sessions.orEmpty(),
total = data?.total ?: 0,
) )
} }
}, },
@@ -36,10 +58,49 @@ class SessionsViewModel : ViewModel() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
isLoadingMore = false,
errorMessage = "Failed to load sessions: $errorMsg", errorMessage = "Failed to load sessions: $errorMsg",
) )
} }
}, },
) )
} }
/** Load the next page and append to the existing session list. */
fun loadMore() {
val state = _uiState.value
if (state.isLoadingMore || !state.hasMore) return
_uiState.update { it.copy(isLoadingMore = true) }
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = state.sessions.size,
order = "recent",
)
}
when (result) {
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
val data = result.data
_uiState.update {
it.copy(
isLoadingMore = false,
sessions = it.sessions + data.sessions,
total = data.total,
)
}
}
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingMore = false,
errorMessage = "Failed to load more: ${result.error.message}",
)
}
}
}
}
}
} }
@@ -428,7 +428,9 @@ class E2eIntegrationTest {
fun testSessionsListing_success() = fun testSessionsListing_success() =
runTest { runTest {
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active") val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
coEvery { mockApiService.getSessions() } returns Response.success(SessionListResponse(listOf(session))) coEvery {
mockApiService.getSessions(any(), any(), any())
} returns Response.success(SessionListResponse(listOf(session)))
val viewModel = SessionsViewModel() val viewModel = SessionsViewModel()
viewModel.loadSessions() viewModel.loadSessions()
@@ -463,7 +465,7 @@ class E2eIntegrationTest {
@Test @Test
fun testSessionsLoad_failure() = fun testSessionsLoad_failure() =
runTest { runTest {
coEvery { mockApiService.getSessions() } returns createErrorResponse(500) coEvery { mockApiService.getSessions(any(), any(), any()) } returns createErrorResponse(500)
val viewModel = SessionsViewModel() val viewModel = SessionsViewModel()
viewModel.loadSessions() viewModel.loadSessions()
@@ -292,7 +292,7 @@ class ChatViewModelTest {
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle() advanceUntilIdle()
coEvery { ApiClient.hermesApi.getSessions() } returns coEvery { ApiClient.hermesApi.getSessions(any(), any(), any()) } returns
mockk(relaxed = true) { mockk(relaxed = true) {
every { isSuccessful } returns true every { isSuccessful } returns true
every { body() } returns null every { body() } returns null
@@ -301,7 +301,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/sessions") viewModel.sendMessage("/sessions")
advanceUntilIdle() advanceUntilIdle()
coVerify { ApiClient.hermesApi.getSessions() } coVerify { ApiClient.hermesApi.getSessions(any(), any(), any()) }
} }
@Test @Test