feat: support session-level hidden flag in SessionListResponse and SessionsScreen (#1033)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-09-01 16:56:28 +03:00
1 parent aa28d96b5d
commit 908255f5ad
10 files changed
+316 -20

No files matched your search

@@ -83,6 +83,10 @@ data class SessionInfo(
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
@Serializable(with = LenientNullableBooleanSerializer::class)
val pinned: Boolean? = null,
// Session-level hidden flag (issue #1019): background agent sessions or internal routines.
// Uses LenientNullableBooleanSerializer to tolerate booleans, raw SQLite ints (0/1), and strings.
@Serializable(with = LenientNullableBooleanSerializer::class)
val hidden: Boolean? = null,
)
@Serializable
@@ -95,10 +99,11 @@ data class SessionStatsResponse(
@Serializable
data class SessionRenameRequest(
// Both fields are optional: the backend PATCH accepts any subset
// (SessionRename model: title/archived/pinned, omitted = unchanged).
// All fields are optional: the backend PATCH accepts any subset
// (SessionRename model: title/archived/pinned/hidden, omitted = unchanged).
val title: String? = null,
val pinned: Boolean? = null,
val hidden: Boolean? = null,
)
@Serializable
@@ -216,6 +216,14 @@ interface HermesApiService {
@Body body: SessionRenameRequest,
): Response<Unit>
// Hide/unhide rides the same PATCH /api/sessions/{id} — body carries only
// {hidden} (backend SessionRename model, any subset accepted; issue #1019).
@PATCH("api/sessions/{id}")
suspend fun setSessionHidden(
@Path("id", encoded = true) sessionId: String,
@Body body: SessionRenameRequest,
): Response<Unit>
@POST("api/sessions/bulk-delete")
suspend fun bulkDeleteSessions(
@Body body: BulkDeleteRequest,
@@ -46,6 +46,8 @@ 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
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
@@ -59,6 +61,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -290,7 +293,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
)
}
} else {
flattenSessionTree(state.sessions)
flattenSessionTree(state.displaySessions)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -313,6 +316,7 @@ fun SessionsScreen(
state.isSearchMode,
state.searchQuery,
state.sessions,
state.showHidden,
state.searchResults,
) {
displayedSessions(state)
@@ -521,6 +525,29 @@ fun SessionsScreen(
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadSessions() },
actions = {
if (state.hasHiddenSessions) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
modifier = Modifier.testTag("sessions_action_toggle_hidden"),
) {
Icon(
imageVector =
if (state.showHidden) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription =
if (state.showHidden) {
stringResource(R.string.sessions_hide_hidden)
} else {
stringResource(R.string.sessions_show_hidden)
},
)
}
}
},
modifier = modifier,
) {
// Single scrolling column: search bar pinned on top, list fills below.
@@ -685,6 +712,16 @@ fun SessionsScreen(
)
}
state.displaySessions.isEmpty() -> {
EmptyState(
title = stringResource(R.string.history_empty_title),
subtitle = stringResource(R.string.sessions_all_hidden_desc),
icon = Icons.Filled.VisibilityOff,
actionLabel = stringResource(R.string.sessions_show_hidden),
onAction = { viewModel.toggleShowHidden() },
)
}
else -> {
Column(modifier = Modifier.fillMaxSize()) {
// ── Stats row ───────────────────────────────────────
@@ -801,6 +838,7 @@ fun SessionsScreen(
isSelected = session.id in state.selectedIds,
isDeleting = session.id in state.deletingSessionIds,
isPinned = session.pinned == true,
isHidden = session.hidden == true,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
@@ -822,6 +860,7 @@ fun SessionsScreen(
)
},
onTogglePin = { viewModel.togglePin(session.id) },
onToggleHide = { viewModel.toggleHide(session.id) },
onDelete = { viewModel.requestDeleteSession(session.id) },
)
}
@@ -969,6 +1008,7 @@ private fun SessionCard(
isSelected: Boolean,
isDeleting: Boolean,
isPinned: Boolean,
isHidden: Boolean = false,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
@@ -976,6 +1016,7 @@ private fun SessionCard(
onSelect: () -> Unit,
onRename: () -> Unit,
onTogglePin: () -> Unit,
onToggleHide: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
@@ -1095,6 +1136,13 @@ private fun SessionCard(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (session.hidden == true) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (!session.status.isNullOrBlank()) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
@@ -1116,6 +1164,11 @@ private fun SessionCard(
menuExpanded = false
onTogglePin()
},
isHidden = isHidden,
onToggleHide = {
menuExpanded = false
onToggleHide()
},
onSelect = {
menuExpanded = false
onSelect()
@@ -1301,6 +1354,8 @@ private fun SessionActionMenu(
isDeleting: Boolean,
isPinned: Boolean = false,
onTogglePin: (() -> Unit)? = null,
isHidden: Boolean = false,
onToggleHide: (() -> Unit)? = null,
onSelect: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
@@ -1324,6 +1379,33 @@ private fun SessionActionMenu(
onClick = onTogglePin,
)
}
if (onToggleHide != null) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (isHidden) {
R.string.sessions_action_unhide
} else {
R.string.sessions_action_hide
},
),
)
},
leadingIcon = {
Icon(
imageVector =
if (isHidden) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription = null,
)
},
onClick = onToggleHide,
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_select)) },
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
@@ -89,8 +89,15 @@ data class SessionsUiState(
val isSearching: Boolean = false,
val searchResults: List<SessionSearchResult> = emptyList(),
val searchError: String? = null,
val showHidden: Boolean = false,
) {
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
val hasHiddenSessions: Boolean
get() = sessions.any { it.hidden == true }
val displaySessions: List<SessionInfo>
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
}
class SessionsViewModel :
@@ -315,6 +322,10 @@ class SessionsViewModel :
// ── Bulk selection ───────────────────────────────────────────────────
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
fun toggleSelecting() {
_uiState.update {
it.copy(
@@ -455,6 +466,49 @@ class SessionsViewModel :
}
}
// ── Hide / unhide (issue #1019) ──────────────────────────────────────
/**
* Toggle the hidden flag on a session via PATCH /api/sessions/{id} ({hidden}).
*/
fun toggleHide(sessionId: String) {
val session = _uiState.value.sessions.find { it.id == sessionId } ?: return
val targetHidden = session.hidden != true
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.setSessionHidden(
sessionId = sessionId,
body = SessionRenameRequest(hidden = targetHidden),
)
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
sessions =
it.sessions
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
.pinnedFirst(),
toastMessage =
if (targetHidden) {
"Session hidden"
} else {
"Session unhidden"
},
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Hide failed: ${result.error.message}")
}
}
}
}
}
// ── Delete (single) ──────────────────────────────────────────────────
fun requestDeleteSession(sessionId: String) {
+6
View File
@@ -406,6 +406,12 @@
<string name="sessions_action_select_all">전체 선택</string>
<string name="sessions_action_deselect_all">선택 해제</string>
<string name="sessions_action_delete_n">%1$d개 삭제</string>
<string name="sessions_action_hide">숨기기</string>
<string name="sessions_action_unhide">숨김 해제</string>
<string name="sessions_show_hidden">숨겨진 세션 표시</string>
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
<string name="sessions_hidden_badge">숨김</string>
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
<!-- Toolsets Screen -->
<string name="toolsets_empty_title">툴셋이 없습니다</string>
+6
View File
@@ -523,6 +523,12 @@
<string name="sessions_action_rename">重命名</string>
<string name="sessions_action_pin">置顶</string>
<string name="sessions_action_unpin">取消置顶</string>
<string name="sessions_action_hide">隐藏</string>
<string name="sessions_action_unhide">取消隐藏</string>
<string name="sessions_show_hidden">显示已隐藏的会话</string>
<string name="sessions_hide_hidden">隐藏已隐藏的会话</string>
<string name="sessions_hidden_badge">已隐藏</string>
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
<string name="sessions_pinned_indicator">已置顶</string>
<string name="sessions_fork_indicator">分支会话</string>
<string name="sessions_rename_title">重命名会话</string>
+6
View File
@@ -599,6 +599,12 @@
<string name="sessions_action_rename">Rename</string>
<string name="sessions_action_pin">Pin</string>
<string name="sessions_action_unpin">Unpin</string>
<string name="sessions_action_hide">Hide</string>
<string name="sessions_action_unhide">Unhide</string>
<string name="sessions_show_hidden">Show hidden sessions</string>
<string name="sessions_hide_hidden">Hide hidden sessions</string>
<string name="sessions_hidden_badge">Hidden</string>
<string name="sessions_all_hidden_desc">All sessions are currently hidden. Tap below to show hidden sessions.</string>
<string name="sessions_pinned_indicator">Pinned</string>
<string name="sessions_fork_indicator">Forked session</string>
<string name="sessions_rename_title">Rename Session</string>
@@ -312,6 +312,41 @@ class ModelSerializationTest {
assertEquals(false, s8.pinned)
}
@Test
fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() {
val s1 = json.decodeFromString<SessionInfo>("""{"id": "s1", "hidden": 0}""")
assertEquals(false, s1.hidden)
val s2 = json.decodeFromString<SessionInfo>("""{"id": "s2", "hidden": 1}""")
assertEquals(true, s2.hidden)
val s3 = json.decodeFromString<SessionInfo>("""{"id": "s3", "hidden": false}""")
assertEquals(false, s3.hidden)
val s4 = json.decodeFromString<SessionInfo>("""{"id": "s4", "hidden": true}""")
assertEquals(true, s4.hidden)
val s5 = json.decodeFromString<SessionInfo>("""{"id": "s5", "hidden": null}""")
assertNull(s5.hidden)
val s6 = json.decodeFromString<SessionInfo>("""{"id": "s6"}""")
assertNull(s6.hidden)
val s7 = json.decodeFromString<SessionInfo>("""{"id": "s7", "hidden": "1"}""")
assertEquals(true, s7.hidden)
val s8 = json.decodeFromString<SessionInfo>("""{"id": "s8", "hidden": "false"}""")
assertEquals(false, s8.hidden)
}
@Test
fun testSessionRenameRequestSerialization_includesHidden() {
val req = SessionRenameRequest(title = "new name", hidden = true)
val encoded = json.encodeToString(SessionRenameRequest.serializer(), req)
assertTrue(encoded.contains("\"hidden\":true"))
assertTrue(encoded.contains("\"title\":\"new name\""))
}
@Test
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
val jsonStr = "{}"
@@ -31,13 +31,19 @@ class RefreshOnChangeTest {
private class TestViewModel : ViewModel() {
val state = MutableStateFlow("initial")
var job: kotlinx.coroutines.Job? = null
init {
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { NetworkResult.Success("refreshed") },
onSuccess = { state.value = it },
)
job =
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { NetworkResult.Success("refreshed") },
onSuccess = { state.value = it },
)
}
fun cleanup() {
job?.cancel()
}
}
@@ -55,23 +61,31 @@ class RefreshOnChangeTest {
fun testRefreshOnChange_updatesOnMatchingEvent() =
runTest(testDispatcher) {
val viewModel = TestViewModel()
// Let the init coroutine run so the collector actually subscribes
// before the event fires (replay=0 — events before subscription
// are dropped, matching production where VMs subscribe at
// construction and events arrive afterwards).
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
advanceUntilIdle()
assertEquals("refreshed", viewModel.state.value)
try {
// Let the init coroutine run so the collector actually subscribes
// before the event fires (replay=0 — events before subscription
// are dropped, matching production where VMs subscribe at
// construction and events arrive afterwards).
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
advanceUntilIdle()
assertEquals("refreshed", viewModel.state.value)
} finally {
viewModel.cleanup()
}
}
@Test
fun testRefreshOnChange_ignoresOtherEventTypes() =
runTest(testDispatcher) {
val viewModel = TestViewModel()
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
advanceUntilIdle()
assertEquals("initial", viewModel.state.value)
try {
advanceUntilIdle()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
advanceUntilIdle()
assertEquals("initial", viewModel.state.value)
} finally {
viewModel.cleanup()
}
}
}
@@ -281,4 +281,84 @@ class SessionsViewModelTest {
.contains("Pin failed"),
)
}
@Test
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any()) } returns
Response.success(
SessionListResponse(
sessions =
listOf(
SessionInfo("s-visible-1", hidden = false),
SessionInfo("s-hidden", hidden = true),
SessionInfo("s-visible-2", hidden = null),
),
total = 3,
),
)
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.hasHiddenSessions)
assertFalse(vm.uiState.value.showHidden)
assertEquals(
listOf("s-visible-1", "s-visible-2"),
vm.uiState.value.displaySessions
.map { it.id },
)
vm.toggleShowHidden()
assertTrue(vm.uiState.value.showHidden)
assertEquals(
listOf("s-visible-1", "s-hidden", "s-visible-2"),
vm.uiState.value.displaySessions
.map { it.id },
)
vm.toggleShowHidden()
assertFalse(vm.uiState.value.showHidden)
assertEquals(
listOf("s-visible-1", "s-visible-2"),
vm.uiState.value.displaySessions
.map { it.id },
)
}
@Test
fun `toggleHide updates hidden status and surfaces toast`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any()) } returns
Response.success(
SessionListResponse(
sessions = listOf(SessionInfo("s-1", hidden = false)),
total = 1,
),
)
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
coEvery { mockApi.setSessionHidden(any(), any()) } returns Response.success(Unit)
vm.toggleHide("s-1")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
true,
vm.uiState.value.sessions
.first()
.hidden,
)
assertEquals("Session hidden", vm.uiState.value.toastMessage)
vm.toggleHide("s-1")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
false,
vm.uiState.value.sessions
.first()
.hidden,
)
assertEquals("Session unhidden", vm.uiState.value.toastMessage)
}
}