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.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<SessionTreeItem> =
|
||||
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
|
||||
|
||||
@@ -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<String, SessionLiveStatus> = 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<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 }
|
||||
|
||||
val displaySessions: List<SessionInfo>
|
||||
get() =
|
||||
activeSourceFilter?.let { f -> visibleBySource.filter { it.source?.lowercase() == f } }
|
||||
?: visibleBySource
|
||||
|
||||
val pinnedSessions: List<SessionInfo>
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user