From 685b870cad983cf5f2f717061a5ff4d27038103d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:32:48 +0300 Subject: [PATCH] feat(history): pin sessions to the top of the history tab (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hold-menu Pin/Unpin action on session cards (PATCH /api/sessions/{id} with {pinned}, verified against the backend SessionRename model) - Pinned sessions sort to the top on every load (client-side stable sort — the backend back-fills pins into page 1 but does not lift them) - Pin indicator on pinned cards; search-result cards omit the pin action - Tests: pin-first sort on load, toggle success (moves to top), failure (order kept + toast) Co-authored-by: M57 --- .../data/model/SessionListResponse.kt | 8 +- .../data/remote/HermesApiService.kt | 8 ++ .../ui/sessions/SessionsScreen.kt | 71 ++++++++++++++---- .../ui/sessions/SessionsViewModel.kt | 63 +++++++++++++++- app/src/main/res/values/strings.xml | 3 + .../ui/sessions/SessionsViewModelTest.kt | 74 +++++++++++++++++++ 6 files changed, 208 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 68387351..a7a0e1fa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -23,6 +23,9 @@ data class SessionInfo( val display_name: String? = null, val model: String? = null, val terminal_backend: String? = null, + // Durable "keep" flag: pinned sessions are exempt from the auto-archive + // sweep and are surfaced first in the history list (sorted client-side). + val pinned: Boolean? = null, ) @Serializable @@ -35,7 +38,10 @@ data class SessionStatsResponse( @Serializable data class SessionRenameRequest( - val title: String, + // Both fields are optional: the backend PATCH accepts any subset + // (SessionRename model: title/archived/pinned, omitted = unchanged). + val title: String? = null, + val pinned: Boolean? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 9cc57a22..81eb673c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -207,6 +207,14 @@ interface HermesApiService { @Body body: SessionRenameRequest, ): Response + // Pin/unpin rides the same PATCH /api/sessions/{id} — body carries only + // {pinned} (backend SessionRename model, any subset accepted). + @PATCH("api/sessions/{id}") + suspend fun setSessionPinned( + @Path("id", encoded = true) sessionId: String, + @Body body: SessionRenameRequest, + ): Response + @POST("api/sessions/bulk-delete") suspend fun bulkDeleteSessions( @Body body: BulkDeleteRequest, 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 24d61f2f..e18e19fc 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 @@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Terminal @@ -796,6 +797,7 @@ fun SessionsScreen( isSelecting = state.isSelecting, isSelected = session.id in state.selectedIds, isDeleting = session.id in state.deletingSessionIds, + isPinned = session.pinned == true, highlightBackground = primaryContainer, highlightForeground = onPrimaryContainer, onCardClick = { @@ -816,6 +818,7 @@ fun SessionsScreen( item.displayTitle, ) }, + onTogglePin = { viewModel.togglePin(session.id) }, onDelete = { viewModel.requestDeleteSession(session.id) }, ) } @@ -961,12 +964,14 @@ private fun SessionCard( isSelecting: Boolean, isSelected: Boolean, isDeleting: Boolean, + isPinned: Boolean, highlightBackground: Color, highlightForeground: Color, onCardClick: () -> Unit, onToggleSelection: () -> Unit, onSelect: () -> Unit, onRename: () -> Unit, + onTogglePin: () -> Unit, onDelete: () -> Unit, ) { val spacing = LocalSpacing.current @@ -1040,18 +1045,30 @@ private fun SessionCard( // Main content Column(modifier = Modifier.weight(1f)) { - Text( - text = - if (query.isNotBlank()) { - highlightText(displayTitle, query, highlightBackground, highlightForeground) - } else { - AnnotatedString(displayTitle) - }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + if (query.isNotBlank()) { + highlightText(displayTitle, query, highlightBackground, highlightForeground) + } else { + AnnotatedString(displayTitle) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (isPinned) { + Spacer(modifier = Modifier.width(spacing.xs)) + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.sessions_pinned_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + } Spacer(modifier = Modifier.height(spacing.xs)) @@ -1079,6 +1096,11 @@ private fun SessionCard( expanded = menuExpanded, onDismiss = { menuExpanded = false }, isDeleting = isDeleting, + isPinned = isPinned, + onTogglePin = { + menuExpanded = false + onTogglePin() + }, onSelect = { menuExpanded = false onSelect() @@ -1253,21 +1275,40 @@ private fun SearchResultCard( } /** - * Per-session hold menu (issue #785): Select / Rename / Delete. Opened by - * long-pressing a session card. Delete lives here instead of on the card, - * and Rename now lands on the working PATCH /api/sessions/{id} route. + * Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete. + * Opened by long-pressing a session card. The pin item is only shown when + * [onTogglePin] is provided (history cards; search-result cards omit it). */ @Composable private fun SessionActionMenu( expanded: Boolean, onDismiss: () -> Unit, isDeleting: Boolean, + isPinned: Boolean = false, + onTogglePin: (() -> Unit)? = null, onSelect: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, ) { val statusColors = LocalHermesStatusColors.current DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (onTogglePin != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isPinned) { + R.string.sessions_action_unpin + } else { + R.string.sessions_action_pin + }, + ), + ) + }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = onTogglePin, + ) + } DropdownMenuItem( text = { Text(stringResource(R.string.sessions_action_select)) }, leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, 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 f13a79e2..f5a10e6d 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 @@ -52,6 +52,13 @@ internal fun formatCompactCount(value: Int): String { private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.') +/** + * Stable sort: pinned sessions first. The backend only back-fills pins into + * page 1 (it doesn't lift them to the top), so the client owns the ordering. + * Stability keeps recency order intact within each group. + */ +private fun List.pinnedFirst(): List = sortedBy { it.pinned != true } + data class SessionsUiState( val isLoading: Boolean = false, val isLoadingMore: Boolean = false, @@ -112,7 +119,7 @@ class SessionsViewModel : onSuccess = { data -> _uiState.update { it.copy( - sessions = data.sessions.orEmpty(), + sessions = data.sessions.orEmpty().pinnedFirst(), total = data.total, ) } @@ -150,7 +157,7 @@ class SessionsViewModel : it.copy( isLoading = false, isLoadingMore = false, - sessions = data.sessions.orEmpty(), + sessions = data.sessions.orEmpty().pinnedFirst(), total = data.total, selectedIds = emptySet(), ) @@ -193,7 +200,11 @@ class SessionsViewModel : // 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 }, + // pinnedFirst keeps pinned sessions above the rest. + sessions = + (it.sessions + data.sessions) + .distinctBy { s -> s.id } + .pinnedFirst(), total = data.total, ) } @@ -387,6 +398,52 @@ class SessionsViewModel : } } + // ── Pin / unpin (durable "keep" flag, exempts from auto-archive) ────── + + /** + * Toggle the pinned flag on a session via PATCH /api/sessions/{id} + * ({pinned}). On success the session is flagged and re-sorted to the top + * of the history list immediately; the backend back-fills pins into + * page 1 on every load, so the pinned-first sort stays consistent. + */ + fun togglePin(sessionId: String) { + val session = _uiState.value.sessions.find { it.id == sessionId } ?: return + val targetPinned = session.pinned != true + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.setSessionPinned( + sessionId = sessionId, + body = SessionRenameRequest(pinned = targetPinned), + ) + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + sessions = + it.sessions + .map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s } + .pinnedFirst(), + toastMessage = + if (targetPinned) { + "Session pinned" + } else { + "Session unpinned" + }, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Pin failed: ${result.error.message}") + } + } + } + } + } + // ── Delete (single) ────────────────────────────────────────────────── fun requestDeleteSession(sessionId: String) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0889bee..0cc9fde7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -543,6 +543,9 @@ Delete %1$d Select Rename + Pin + Unpin + Pinned Rename Session Session title 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 10e9d91b..ef2ba99b 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 @@ -17,9 +17,11 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -96,6 +98,8 @@ class SessionsViewModelTest { ) } + // ── Pagination (fluid load-more) ────────────────────────────────────── + @Test fun `loadMore appends the next page and dedupes overlapping ids`() { val vm = createViewModel() @@ -157,4 +161,74 @@ class SessionsViewModelTest { assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id }) assertFalse(vm.uiState.value.isLoadingMore) } + + // ── Pin / unpin ──────────────────────────────────────────────────────── + + @Test + fun `loaded list keeps pinned sessions on top`() { + val vm = createViewModel() + // Backend returns recency order with the pinned flag set; the client + // must lift pins above the rest while keeping the rest's order. + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = + listOf( + SessionInfo("recent", pinned = false), + SessionInfo("old-pinned", pinned = true), + ), + total = 2, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("old-pinned", "recent"), vm.uiState.value.sessions.map { it.id }) + } + + @Test + fun `togglePin moves the session to the top and sets the flag`() { + val vm = createViewModel() + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")), + total = 2, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + coEvery { mockApi.setSessionPinned(any(), any()) } returns Response.success(Unit) + vm.togglePin("s-2") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("s-2", "s-1"), vm.uiState.value.sessions.map { it.id }) + assertEquals(true, vm.uiState.value.sessions.first().pinned) + assertEquals("Session pinned", vm.uiState.value.toastMessage) + } + + @Test + fun `togglePin failure keeps the order and surfaces a toast`() { + val vm = createViewModel() + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = listOf(SessionInfo("s-1", pinned = true), SessionInfo("s-2")), + total = 2, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + coEvery { mockApi.setSessionPinned(any(), any()) } returns + retrofit2.Response.error(500, "".toResponseBody(null)) + vm.togglePin("s-2") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) + assertFalse(vm.uiState.value.sessions[1].pinned ?: false) + assertNotNull(vm.uiState.value.toastMessage) + assertTrue(vm.uiState.value.toastMessage!!.contains("Pin failed")) + } }