From 4294d7edff8287e43b13739f9ec538d6a3c7b66c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:09:50 +0300 Subject: [PATCH] feat: add source filter chips to history screen (#1425) --- .../ui/sessions/SessionsScreen.kt | 93 ++++++++++--------- .../ui/sessions/SessionsViewModel.kt | 29 +++++- .../ui/sessions/components/SessionCard.kt | 16 +++- .../ui/sessions/SessionSourceFilterTest.kt | 75 +++++++++++++++ 4 files changed, 165 insertions(+), 48 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt 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 804446cf..2fec303c 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 @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions @@ -33,22 +34,18 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyboardArrowDown -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.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 @@ -82,10 +79,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -127,7 +124,9 @@ import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow import com.m57.hermescontrol.ui.sessions.components.automationGroups +import com.m57.hermescontrol.ui.sessions.components.sourceLabel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first /** * Auto-load the next history page when the user scrolls to within this many @@ -136,30 +135,6 @@ import kotlinx.coroutines.delay private const val AUTO_LOAD_THRESHOLD = 6 private const val AGE_TICK_MS = 60_000L -/** - * Maps a session source string to a Material icon for visual identification. - */ -private fun sourceIcon(source: String?): ImageVector? = - when (source?.lowercase()) { - "telegram", "tg" -> Icons.AutoMirrored.Filled.Send - "web", "dashboard" -> Icons.Filled.Language - "api", "rest" -> Icons.Filled.Code - "cli", "terminal" -> Icons.Filled.Terminal - else -> null - } - -/** - * Maps a source string to a label for tooltip / accessibility. - */ -private fun sourceLabel(source: String?): String = - when (source?.lowercase()) { - "telegram", "tg" -> "Telegram" - "web", "dashboard" -> "Web" - "api", "rest" -> "API" - "cli", "terminal" -> "CLI" - else -> source ?: "Unknown" - } - /** * Builds an annotated string with search term highlighting. */ @@ -201,19 +176,22 @@ private fun highlightText( internal fun displayedSessions(state: SessionsUiState): List = if (state.isSearchMode) { - state.searchResults.map { result -> - val session = result.toSessionInfo() - SessionTreeItem( - session = session, - depth = 0, - branchStem = null, - displayTitle = - session.title?.takeIf(String::isNotBlank) - ?: session.display_name?.takeIf(String::isNotBlank) - ?: session.preview?.takeIf(String::isNotBlank)?.take(80) - ?: "Untitled", - ) - } + state.searchResults + .map { result -> result.toSessionInfo() } + .filter { session -> + state.activeSourceFilter?.let { session.source?.lowercase() == it } ?: true + }.map { session -> + SessionTreeItem( + session = session, + depth = 0, + branchStem = null, + displayTitle = + session.title?.takeIf(String::isNotBlank) + ?: session.display_name?.takeIf(String::isNotBlank) + ?: session.preview?.takeIf(String::isNotBlank)?.take(80) + ?: "Untitled", + ) + } } else { flattenSessionsWithBranches(state.displaySessions) } @@ -241,6 +219,7 @@ fun SessionsScreen( state.sessions, state.showHidden, state.searchResults, + state.sourceFilter, ) { displayedSessions(state) } @@ -547,6 +526,25 @@ fun SessionsScreen( ) } + // Source chips: hidden until a loaded row actually carries that source. + val sources = state.availableSources + if (sources.size > 1) { + LazyRow( + modifier = Modifier.fillMaxWidth().testTag("history_source_chips"), + contentPadding = PaddingValues(horizontal = spacing.md), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + items(sources, key = { it }) { source -> + FilterChip( + selected = state.activeSourceFilter == source, + onClick = { viewModel.selectSourceFilter(source) }, + label = { Text(sourceLabel(source)) }, + modifier = Modifier.testTag("history_source_chip_$source"), + ) + } + } + } + // ── Search + bulk toggle (always visible) ───────────── Row( modifier = @@ -806,6 +804,17 @@ fun SessionsScreen( // ── Session list ──────────────────────────────────── val listState = rememberLazyListState() + // Opening (or switching section) always starts at the top. Keyed items otherwise + // keep the old anchor row when fresher sessions land above it after the refresh. + var userScrolled by remember(state.section) { mutableStateOf(false) } + LaunchedEffect(state.section) { + snapshotFlow { listState.isScrollInProgress }.first { it } + userScrolled = true + } + val firstSessionId = sessionsToDisplay.firstOrNull()?.session?.id + LaunchedEffect(state.section, firstSessionId, userScrolled) { + if (!userScrolled && firstSessionId != null) listState.scrollToItem(0) + } // Fluid infinite scroll: once the user reaches within // AUTO_LOAD_THRESHOLD items of the end, pull the next // page automatically. Driven by real scroll position 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 65cae410..421f9f40 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 @@ -110,6 +110,7 @@ data class SessionsUiState( val searchNextOffset: Int? = null, val searchLoadMoreError: String? = null, val showHidden: Boolean = false, + val sourceFilter: String? = null, val pinnedExpanded: Boolean = true, val liveStatuses: Map = emptyMap(), // Named projects used to label each row with the workspace it belongs to. @@ -120,9 +121,30 @@ data class SessionsUiState( val hasHiddenSessions: Boolean get() = sessions.any { it.hidden == true } - val displaySessions: List + // Source chips only exist for sources present in the rows currently loaded. + val availableSources: List + get() { + val rows = if (isSearchMode) searchResults.map { it.source } else visibleBySource.map { it.source } + return rows + .mapNotNull { it?.lowercase()?.takeIf(String::isNotBlank) } + .groupingBy { it } + .eachCount() + .entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .map { it.key } + } + + val activeSourceFilter: String? + get() = sourceFilter?.takeIf { it in availableSources } + + private val visibleBySource: List get() = if (showHidden) sessions else sessions.filter { it.hidden != true } + val displaySessions: List + get() = + activeSourceFilter?.let { f -> visibleBySource.filter { it.source?.lowercase() == f } } + ?: visibleBySource + val pinnedSessions: List get() = displaySessions.filter { it.pinned == true } } @@ -339,6 +361,7 @@ class SessionsViewModel( _uiState.update { it.copy( section = section, + sourceFilter = null, isLoading = false, isLoadingMore = false, sessions = emptyList(), @@ -701,6 +724,10 @@ class SessionsViewModel( // ── Bulk selection ─────────────────────────────────────────────────── + fun selectSourceFilter(source: String?) { + _uiState.update { it.copy(sourceFilter = if (it.sourceFilter == source) null else source) } + } + fun toggleShowHidden() { _uiState.update { it.copy(showHidden = !it.showHidden) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index fb1b34b4..cd31a0d9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -98,19 +98,25 @@ fun BranchRow( fun sourceIcon(source: String?): ImageVector? = when (source?.lowercase()) { "telegram", "tg" -> Icons.AutoMirrored.Filled.Send - "web", "dashboard" -> Icons.Filled.Language - "api", "rest" -> Icons.Filled.Code + "web", "dashboard", "webui" -> Icons.Filled.Language + "api", "rest", "api_server" -> Icons.Filled.Code "cli", "terminal" -> Icons.Filled.Terminal else -> null } fun sourceLabel(source: String?): String = when (source?.lowercase()) { + null, "" -> "Unknown" "telegram", "tg" -> "Telegram" - "web", "dashboard" -> "Web" - "api", "rest" -> "API" + "web", "dashboard", "webui" -> "Web" + "api", "rest", "api_server" -> "API" "cli", "terminal" -> "CLI" - else -> source ?: "Unknown" + "tui" -> "TUI" + "desktop" -> "Desktop" + "acp" -> "ACP" + "cron" -> "Cron" + "bot_room" -> "Bot room" + else -> source.replace('_', ' ').replaceFirstChar { it.uppercase() } } fun highlightText( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt new file mode 100644 index 00000000..333a3c36 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionSearchResult +import com.m57.hermescontrol.ui.sessions.components.sourceLabel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionSourceFilterTest { + private val rows = + listOf( + SessionInfo("a", source = "cli"), + SessionInfo("b", source = "CLI"), + SessionInfo("c", source = "desktop"), + SessionInfo("d", source = "api_server"), + SessionInfo("e", source = "desktop", hidden = true), + SessionInfo("f", source = null), + ) + + @Test + fun availableSources_onlyLoadedVisibleSources_orderedByCountThenName() { + val state = SessionsUiState(sessions = rows) + assertEquals(listOf("cli", "api_server", "desktop"), state.availableSources) + } + + @Test + fun availableSources_includesHiddenOnlyWhenShown() { + val onlyHidden = listOf(SessionInfo("x", source = "tui", hidden = true), SessionInfo("y", source = "cli")) + assertEquals(listOf("cli"), SessionsUiState(sessions = onlyHidden).availableSources) + assertEquals( + listOf("cli", "tui"), + SessionsUiState(sessions = onlyHidden, showHidden = true).availableSources, + ) + } + + @Test + fun filter_isCaseInsensitiveAndNarrowsList() { + val state = SessionsUiState(sessions = rows, sourceFilter = "cli") + assertEquals(listOf("a", "b"), state.displaySessions.map { it.id }) + } + + @Test + fun staleFilter_fallsBackToAll() { + val state = SessionsUiState(sessions = rows, sourceFilter = "telegram") + assertNull(state.activeSourceFilter) + assertEquals(5, state.displaySessions.size) + } + + @Test + fun searchMode_usesSearchResultSources() { + val state = + SessionsUiState( + searchQuery = "q", + sessions = rows, + searchResults = + listOf( + SessionSearchResult("s1", source = "telegram"), + SessionSearchResult("s2", source = "cli"), + ), + sourceFilter = "telegram", + ) + assertEquals(listOf("cli", "telegram"), state.availableSources) + assertEquals(listOf("s1"), displayedSessions(state).map { it.session.id }) + } + + @Test + fun sourceLabel_mapsKnownAndTitleCasesUnknown() { + assertEquals("API", sourceLabel("api_server")) + assertEquals("Desktop", sourceLabel("desktop")) + assertEquals("TUI", sourceLabel("tui")) + assertEquals("Whatsapp cloud", sourceLabel("whatsapp_cloud")) + assertEquals("Unknown", sourceLabel(null)) + } +}