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

@@ -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) {