* 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:
1 parent
067f4500aa
commit
4ba74c2efa
7 files changed
+114
-7
No files matched your search
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user