feat: add source filter chips to history screen (#1425)

This commit is contained in:
m57 authored and GitHub committed 2026-10-01 11:09:50 +03:00
1 parent 9cf8c3818c
commit 4294d7edff
4 files changed
+154 -37

No files matched your search

@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions 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.Icons
import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.automirrored.filled.CallSplit
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.Add
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close 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.Delete
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyboardArrowDown 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.PushPin
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll 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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -82,10 +79,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role 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.SessionsDialogs
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
import com.m57.hermescontrol.ui.sessions.components.automationGroups import com.m57.hermescontrol.ui.sessions.components.automationGroups
import com.m57.hermescontrol.ui.sessions.components.sourceLabel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
/** /**
* Auto-load the next history page when the user scrolls to within this many * 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 AUTO_LOAD_THRESHOLD = 6
private const val AGE_TICK_MS = 60_000L 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. * Builds an annotated string with search term highlighting.
*/ */
@@ -201,8 +176,11 @@ private fun highlightText(
internal fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> = internal fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
if (state.isSearchMode) { if (state.isSearchMode) {
state.searchResults.map { result -> state.searchResults
val session = result.toSessionInfo() .map { result -> result.toSessionInfo() }
.filter { session ->
state.activeSourceFilter?.let { session.source?.lowercase() == it } ?: true
}.map { session ->
SessionTreeItem( SessionTreeItem(
session = session, session = session,
depth = 0, depth = 0,
@@ -241,6 +219,7 @@ fun SessionsScreen(
state.sessions, state.sessions,
state.showHidden, state.showHidden,
state.searchResults, state.searchResults,
state.sourceFilter,
) { ) {
displayedSessions(state) 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) ───────────── // ── Search + bulk toggle (always visible) ─────────────
Row( Row(
modifier = modifier =
@@ -806,6 +804,17 @@ fun SessionsScreen(
// ── Session list ──────────────────────────────────── // ── Session list ────────────────────────────────────
val listState = rememberLazyListState() 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 // Fluid infinite scroll: once the user reaches within
// AUTO_LOAD_THRESHOLD items of the end, pull the next // AUTO_LOAD_THRESHOLD items of the end, pull the next
// page automatically. Driven by real scroll position // page automatically. Driven by real scroll position
@@ -110,6 +110,7 @@ data class SessionsUiState(
val searchNextOffset: Int? = null, val searchNextOffset: Int? = null,
val searchLoadMoreError: String? = null, val searchLoadMoreError: String? = null,
val showHidden: Boolean = false, val showHidden: Boolean = false,
val sourceFilter: String? = null,
val pinnedExpanded: Boolean = true, val pinnedExpanded: Boolean = true,
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(), val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
// Named projects used to label each row with the workspace it belongs to. // Named projects used to label each row with the workspace it belongs to.
@@ -120,9 +121,30 @@ data class SessionsUiState(
val hasHiddenSessions: Boolean val hasHiddenSessions: Boolean
get() = sessions.any { it.hidden == true } get() = sessions.any { it.hidden == true }
val displaySessions: List<SessionInfo> // Source chips only exist for sources present in the rows currently loaded.
val availableSources: List<String>
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<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.map { it.key }
}
val activeSourceFilter: String?
get() = sourceFilter?.takeIf { it in availableSources }
private val visibleBySource: List<SessionInfo>
get() = if (showHidden) sessions else sessions.filter { it.hidden != true } get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
val displaySessions: List<SessionInfo>
get() =
activeSourceFilter?.let { f -> visibleBySource.filter { it.source?.lowercase() == f } }
?: visibleBySource
val pinnedSessions: List<SessionInfo> val pinnedSessions: List<SessionInfo>
get() = displaySessions.filter { it.pinned == true } get() = displaySessions.filter { it.pinned == true }
} }
@@ -339,6 +361,7 @@ class SessionsViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
section = section, section = section,
sourceFilter = null,
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
sessions = emptyList(), sessions = emptyList(),
@@ -701,6 +724,10 @@ class SessionsViewModel(
// ── Bulk selection ─────────────────────────────────────────────────── // ── Bulk selection ───────────────────────────────────────────────────
fun selectSourceFilter(source: String?) {
_uiState.update { it.copy(sourceFilter = if (it.sourceFilter == source) null else source) }
}
fun toggleShowHidden() { fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) } _uiState.update { it.copy(showHidden = !it.showHidden) }
} }
@@ -98,19 +98,25 @@ fun BranchRow(
fun sourceIcon(source: String?): ImageVector? = fun sourceIcon(source: String?): ImageVector? =
when (source?.lowercase()) { when (source?.lowercase()) {
"telegram", "tg" -> Icons.AutoMirrored.Filled.Send "telegram", "tg" -> Icons.AutoMirrored.Filled.Send
"web", "dashboard" -> Icons.Filled.Language "web", "dashboard", "webui" -> Icons.Filled.Language
"api", "rest" -> Icons.Filled.Code "api", "rest", "api_server" -> Icons.Filled.Code
"cli", "terminal" -> Icons.Filled.Terminal "cli", "terminal" -> Icons.Filled.Terminal
else -> null else -> null
} }
fun sourceLabel(source: String?): String = fun sourceLabel(source: String?): String =
when (source?.lowercase()) { when (source?.lowercase()) {
null, "" -> "Unknown"
"telegram", "tg" -> "Telegram" "telegram", "tg" -> "Telegram"
"web", "dashboard" -> "Web" "web", "dashboard", "webui" -> "Web"
"api", "rest" -> "API" "api", "rest", "api_server" -> "API"
"cli", "terminal" -> "CLI" "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( fun highlightText(
@@ -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))
}
}