diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 5e2a6985..e01e6e0b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 2f81b314..e6d5149c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -216,6 +216,14 @@ interface HermesApiService { @Body body: SessionRenameRequest, ): Response + // 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 + @POST("api/sessions/bulk-delete") suspend fun bulkDeleteSessions( @Body body: BulkDeleteRequest, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index a905cca3..bcefaa5b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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 = ) } } 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) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 7b0c9308..e8182e00 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -89,8 +89,15 @@ data class SessionsUiState( val isSearching: Boolean = false, val searchResults: List = 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 + 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) { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 38f2446f..a285a430 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -406,6 +406,12 @@ 전체 선택 선택 해제 %1$d개 삭제 + 숨기기 + 숨김 해제 + 숨겨진 세션 표시 + 숨겨진 세션 숨기기 + 숨김 + 모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요. 툴셋이 없습니다 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9e66e094..15a976fe 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -523,6 +523,12 @@ 重命名 置顶 取消置顶 + 隐藏 + 取消隐藏 + 显示已隐藏的会话 + 隐藏已隐藏的会话 + 已隐藏 + 所有会话当前均已隐藏。点击下方以显示已隐藏的会话。 已置顶 分支会话 重命名会话 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f47e7ff9..1f270a38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -599,6 +599,12 @@ Rename Pin Unpin + Hide + Unhide + Show hidden sessions + Hide hidden sessions + Hidden + All sessions are currently hidden. Tap below to show hidden sessions. Pinned Forked session Rename Session diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 94ce2d81..2fda9252 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -312,6 +312,41 @@ class ModelSerializationTest { assertEquals(false, s8.pinned) } + @Test + fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() { + val s1 = json.decodeFromString("""{"id": "s1", "hidden": 0}""") + assertEquals(false, s1.hidden) + + val s2 = json.decodeFromString("""{"id": "s2", "hidden": 1}""") + assertEquals(true, s2.hidden) + + val s3 = json.decodeFromString("""{"id": "s3", "hidden": false}""") + assertEquals(false, s3.hidden) + + val s4 = json.decodeFromString("""{"id": "s4", "hidden": true}""") + assertEquals(true, s4.hidden) + + val s5 = json.decodeFromString("""{"id": "s5", "hidden": null}""") + assertNull(s5.hidden) + + val s6 = json.decodeFromString("""{"id": "s6"}""") + assertNull(s6.hidden) + + val s7 = json.decodeFromString("""{"id": "s7", "hidden": "1"}""") + assertEquals(true, s7.hidden) + + val s8 = json.decodeFromString("""{"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 = "{}" diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt index e5f07aeb..7714b5a6 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt @@ -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() + } } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 1ec675fe..386d1871 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -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) + } }