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
+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) {
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user