feat: add source filter chips to history screen (#1425)
This commit is contained in:
4 files changed
+165
-48
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,19 +176,22 @@ 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() }
|
||||||
SessionTreeItem(
|
.filter { session ->
|
||||||
session = session,
|
state.activeSourceFilter?.let { session.source?.lowercase() == it } ?: true
|
||||||
depth = 0,
|
}.map { session ->
|
||||||
branchStem = null,
|
SessionTreeItem(
|
||||||
displayTitle =
|
session = session,
|
||||||
session.title?.takeIf(String::isNotBlank)
|
depth = 0,
|
||||||
?: session.display_name?.takeIf(String::isNotBlank)
|
branchStem = null,
|
||||||
?: session.preview?.takeIf(String::isNotBlank)?.take(80)
|
displayTitle =
|
||||||
?: "Untitled",
|
session.title?.takeIf(String::isNotBlank)
|
||||||
)
|
?: session.display_name?.takeIf(String::isNotBlank)
|
||||||
}
|
?: session.preview?.takeIf(String::isNotBlank)?.take(80)
|
||||||
|
?: "Untitled",
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
flattenSessionsWithBranches(state.displaySessions)
|
flattenSessionsWithBranches(state.displaySessions)
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user