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:
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) {
|
||||
|
||||
Reference in new issue
Block a user