feat(history): pin sessions to the top of the history tab (#886)

- 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-12 14:32:48 +03:00
1 parent 95178d3438
commit 685b870cad
6 files changed
+196 -7

No files matched your search

@@ -23,6 +23,9 @@ data class SessionInfo(
val display_name: String? = null, val display_name: String? = null,
val model: String? = null, val model: String? = null,
val terminal_backend: 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 @Serializable
@@ -35,7 +38,10 @@ data class SessionStatsResponse(
@Serializable @Serializable
data class SessionRenameRequest( 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 @Serializable
@@ -207,6 +207,14 @@ interface HermesApiService {
@Body body: SessionRenameRequest, @Body body: SessionRenameRequest,
): Response<Unit> ): Response<Unit>
// 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<Unit>
@POST("api/sessions/bulk-delete") @POST("api/sessions/bulk-delete")
suspend fun bulkDeleteSessions( suspend fun bulkDeleteSessions(
@Body body: BulkDeleteRequest, @Body body: BulkDeleteRequest,
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Language 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.Search
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Terminal
@@ -796,6 +797,7 @@ fun SessionsScreen(
isSelecting = state.isSelecting, isSelecting = state.isSelecting,
isSelected = session.id in state.selectedIds, isSelected = session.id in state.selectedIds,
isDeleting = session.id in state.deletingSessionIds, isDeleting = session.id in state.deletingSessionIds,
isPinned = session.pinned == true,
highlightBackground = primaryContainer, highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer, highlightForeground = onPrimaryContainer,
onCardClick = { onCardClick = {
@@ -816,6 +818,7 @@ fun SessionsScreen(
item.displayTitle, item.displayTitle,
) )
}, },
onTogglePin = { viewModel.togglePin(session.id) },
onDelete = { viewModel.requestDeleteSession(session.id) }, onDelete = { viewModel.requestDeleteSession(session.id) },
) )
} }
@@ -961,12 +964,14 @@ private fun SessionCard(
isSelecting: Boolean, isSelecting: Boolean,
isSelected: Boolean, isSelected: Boolean,
isDeleting: Boolean, isDeleting: Boolean,
isPinned: Boolean,
highlightBackground: Color, highlightBackground: Color,
highlightForeground: Color, highlightForeground: Color,
onCardClick: () -> Unit, onCardClick: () -> Unit,
onToggleSelection: () -> Unit, onToggleSelection: () -> Unit,
onSelect: () -> Unit, onSelect: () -> Unit,
onRename: () -> Unit, onRename: () -> Unit,
onTogglePin: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
@@ -1040,6 +1045,7 @@ private fun SessionCard(
// Main content // Main content
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = text =
if (query.isNotBlank()) { if (query.isNotBlank()) {
@@ -1051,7 +1057,18 @@ private fun SessionCard(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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)) Spacer(modifier = Modifier.height(spacing.xs))
@@ -1079,6 +1096,11 @@ private fun SessionCard(
expanded = menuExpanded, expanded = menuExpanded,
onDismiss = { menuExpanded = false }, onDismiss = { menuExpanded = false },
isDeleting = isDeleting, isDeleting = isDeleting,
isPinned = isPinned,
onTogglePin = {
menuExpanded = false
onTogglePin()
},
onSelect = { onSelect = {
menuExpanded = false menuExpanded = false
onSelect() onSelect()
@@ -1253,21 +1275,40 @@ private fun SearchResultCard(
} }
/** /**
* Per-session hold menu (issue #785): Select / Rename / Delete. Opened by * Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete.
* long-pressing a session card. Delete lives here instead of on the card, * Opened by long-pressing a session card. The pin item is only shown when
* and Rename now lands on the working PATCH /api/sessions/{id} route. * [onTogglePin] is provided (history cards; search-result cards omit it).
*/ */
@Composable @Composable
private fun SessionActionMenu( private fun SessionActionMenu(
expanded: Boolean, expanded: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
isDeleting: Boolean, isDeleting: Boolean,
isPinned: Boolean = false,
onTogglePin: (() -> Unit)? = null,
onSelect: () -> Unit, onSelect: () -> Unit,
onRename: () -> Unit, onRename: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { 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( DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_select)) }, text = { Text(stringResource(R.string.sessions_action_select)) },
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
@@ -52,6 +52,13 @@ internal fun formatCompactCount(value: Int): String {
private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.') 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<SessionInfo>.pinnedFirst(): List<SessionInfo> = sortedBy { it.pinned != true }
data class SessionsUiState( data class SessionsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
@@ -112,7 +119,7 @@ class SessionsViewModel :
onSuccess = { data -> onSuccess = { data ->
_uiState.update { _uiState.update {
it.copy( it.copy(
sessions = data.sessions.orEmpty(), sessions = data.sessions.orEmpty().pinnedFirst(),
total = data.total, total = data.total,
) )
} }
@@ -150,7 +157,7 @@ class SessionsViewModel :
it.copy( it.copy(
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
sessions = data.sessions.orEmpty(), sessions = data.sessions.orEmpty().pinnedFirst(),
total = data.total, total = data.total,
selectedIds = emptySet(), selectedIds = emptySet(),
) )
@@ -193,7 +200,11 @@ class SessionsViewModel :
// session lands on top between page loads, offsets // session lands on top between page loads, offsets
// shift and the next page can repeat an id we already // shift and the next page can repeat an id we already
// have — LazyColumn would crash on the duplicate key. // 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, 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) ────────────────────────────────────────────────── // ── Delete (single) ──────────────────────────────────────────────────
fun requestDeleteSession(sessionId: String) { fun requestDeleteSession(sessionId: String) {
+3
View File
@@ -543,6 +543,9 @@
<string name="sessions_action_delete_n">Delete %1$d</string> <string name="sessions_action_delete_n">Delete %1$d</string>
<string name="sessions_action_select">Select</string> <string name="sessions_action_select">Select</string>
<string name="sessions_action_rename">Rename</string> <string name="sessions_action_rename">Rename</string>
<string name="sessions_action_pin">Pin</string>
<string name="sessions_action_unpin">Unpin</string>
<string name="sessions_pinned_indicator">Pinned</string>
<string name="sessions_rename_title">Rename Session</string> <string name="sessions_rename_title">Rename Session</string>
<string name="sessions_rename_hint">Session title</string> <string name="sessions_rename_hint">Session title</string>
@@ -17,9 +17,11 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
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
@@ -96,6 +98,8 @@ class SessionsViewModelTest {
) )
} }
// ── Pagination (fluid load-more) ──────────────────────────────────────
@Test @Test
fun `loadMore appends the next page and dedupes overlapping ids`() { fun `loadMore appends the next page and dedupes overlapping ids`() {
val vm = createViewModel() val vm = createViewModel()
@@ -157,4 +161,74 @@ class SessionsViewModelTest {
assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id }) assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id })
assertFalse(vm.uiState.value.isLoadingMore) 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"))
}
} }