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

@@ -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"))
}
}