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
+208 -19

No files matched your search

@@ -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
@@ -207,6 +207,14 @@ interface HermesApiService {
@Body body: SessionRenameRequest,
): 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")
suspend fun bulkDeleteSessions(
@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.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) },
@@ -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<SessionInfo>.pinnedFirst(): List<SessionInfo> = 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) {