feat(#530): server-backed session search (FTS5) (#555)

* feat(#530): add SessionSearch models + searchSessions endpoint + contract tests

* feat(#530): wire server-backed search into Sessions screen (debounce + render)

* style(#530): ktlint import ordering on HermesApiService

* fix(#530): ktlint-clean overlap wrap + map search snippet to title

* fix(#530): map search snippet to title so results show + highlight

* fix(#530): render search hits as honest result cards instead of faking title

Backend SessionSearchResult carries no session name (only snippet + metadata),
so mapping snippet->title made the card show raw excerpt as the title. Instead,
render a dedicated SearchResultCard: 'Match' label + session id, highlighted
snippet as body, and source/model/played-at chips. Added SessionInfo.model and
a friendly relative-time formatter. ktlint clean, compileDebugKotlin + unit
tests pass.

* fix(#530): strip JSON from search snippet + tighten result card padding

* fix(#530): strip FTS highlight markers from search snippet on client

agy's backend change re-wraps matched terms in >>>term<<< markers inside the
clean snippet, but the mobile highlights by raw query term and never consumed
those markers — so literal '>>>'/'<<<' would render in result text. Strip the
markers before passing the snippet to highlightText so the query highlight
still works on clean text.

* fix(#530): integrate tree-flattening hierarchy with search modes in SessionsScreen

* fix(#530): fix test assertion and mock ApiClient in SessionsViewModelTest

* fix(#530): resolve compose PaddingValues compile error and fix slash-preservation test path

* fix(#530): clean search snippet from JSON payload and add unit test

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-14 12:47:59 +03:00
1 parent 71c1bdded9
commit c543eb6470
8 files changed
+914 -252

No files matched your search

@@ -21,6 +21,7 @@ data class SessionInfo(
val source: String? = null,
val parent_session_id: String? = null,
val display_name: String? = null,
val model: String? = null,
)
@Serializable
@@ -0,0 +1,18 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class SessionSearchResponse(
val results: List<SessionSearchResult> = emptyList(),
)
@Serializable
data class SessionSearchResult(
val session_id: String,
val snippet: String? = null,
val role: String? = null,
val source: String? = null,
val model: String? = null,
val session_started: Double? = null,
)
@@ -70,6 +70,7 @@ import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionPromptResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest
import com.m57.hermescontrol.data.model.SessionSearchResponse
import com.m57.hermescontrol.data.model.SessionStatsResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
@@ -132,6 +133,12 @@ interface HermesApiService {
@Query("order") order: String = "recent",
): Response<SessionListResponse>
@GET("api/sessions/search")
suspend fun searchSessions(
@Query("q") q: String,
@Query("profile") profile: String? = null,
): Response<SessionSearchResponse>
@GET("api/sessions/{id}/messages")
suspend fun getSessionMessages(
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
@@ -12,6 +12,9 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -32,6 +35,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Terminal
@@ -71,12 +75,15 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.data.model.SessionTreeItem
import com.m57.hermescontrol.data.model.flattenSessionTree
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
@@ -90,8 +97,18 @@ import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.abs
/**
* Maps a session source string to a Material icon for visual identification.
@@ -156,6 +173,112 @@ private fun highlightText(
}
}
/**
* Converts a backend search hit into a display model WITHOUT faking a title.
* The backend returns no session name, only a matched `snippet` + metadata, so the
* card must render the snippet as a match excerpt (never as the title). `title` is
* deliberately left null so [SearchResultCard] shows the honest "Match" layout.
*/
private fun SessionSearchResult.toSessionInfo(): SessionInfo =
SessionInfo(
id = session_id,
title = null,
preview = snippet,
source = source,
model = model,
started_at = session_started,
// message_count/status aren't in the search payload; leave null so the
// search card hides those normal-list affordances.
)
/**
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
* string. Used for search results where the session name is unknown.
*/
private fun formatPlayedAt(epochSeconds: Double?): String? {
if (epochSeconds == null || epochSeconds <= 0.0) return null
val instant =
try {
Instant.ofEpochSecond(epochSeconds.toLong())
} catch (_: Exception) {
return null
}
val now = Instant.now()
val diffSec = abs(now.epochSecond - instant.epochSecond)
val absFormatter =
DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
return when {
diffSec < 60 -> "just now"
diffSec < 3600 -> "${diffSec / 60}m ago"
diffSec < 86400 -> "${diffSec / 3600}h ago"
diffSec < 7 * 86400 -> "${diffSec / 86400}d ago"
else -> absFormatter.format(instant)
}
}
private val searchSnippetJson = Json { isLenient = true }
/**
* Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as
* JSON, so prefer their human-readable text fields rather than rendering the entire payload.
*/
internal fun cleanSearchSnippet(snippet: String): String {
val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim()
val parsedSnippet =
runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull()
val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers
return displayText
.replace(Regex("\\s+"), " ")
.trim()
}
private fun JsonElement.searchText(): String? =
when (this) {
is JsonPrimitive -> {
contentOrNull?.takeIf(String::isNotBlank)
}
is JsonArray -> {
mapNotNull { it.searchText() }
.joinToString(" ")
.takeIf(String::isNotBlank)
}
is JsonObject -> {
val textFieldNames =
listOf("content", "text", "message", "body", "prompt", "summary", "title")
val wrapperFieldNames = listOf("data", "result", "payload", "parts")
textFieldNames
.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
}
else -> {
null
}
}
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
if (state.isSearchMode) {
state.searchResults.map { searchResult ->
val session = searchResult.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",
)
}
} else {
flattenSessionTree(state.sessions)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SessionsScreen(
@@ -169,24 +292,32 @@ fun SessionsScreen(
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
var query by remember { mutableStateOf("") }
var pruneDays by remember { mutableStateOf("7") }
val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) }
val filteredSessions =
remember(query, sessionTree) {
if (query.isBlank()) {
sessionTree
} else {
sessionTree.filter { item ->
item.displayTitle.contains(query, ignoreCase = true) ||
item.session.preview?.contains(query, ignoreCase = true) == true ||
item.session.status?.contains(query, ignoreCase = true) == true
}
}
val sessionsToDisplay =
remember(
state.isSearchMode,
state.searchQuery,
state.sessions,
state.searchResults,
) {
displayedSessions(state)
}
val hasSelection = state.selectedIds.isNotEmpty()
val visibleSessionIds =
remember(sessionsToDisplay) {
sessionsToDisplay.mapTo(linkedSetOf()) { it.session.id }
}
val allVisibleSessionsSelected =
visibleSessionIds.isNotEmpty() && visibleSessionIds.all { it in state.selectedIds }
val listPadding =
PaddingValues(
start = 12.dp,
top = 8.dp,
end = 12.dp,
bottom = if (hasSelection) 72.dp else 8.dp,
)
LaunchedEffect(Unit) {
viewModel.loadSessions()
@@ -254,7 +385,13 @@ fun SessionsScreen(
state.sessions
.find { it.id == sessionToDelete }
?.title
?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
?.takeIf { it.isNotBlank() }
?: state.searchResults
.find { it.session_id == sessionToDelete }
?.snippet
?.let(::cleanSearchSnippet)
?.take(80)
?: stringResource(R.string.history_untitled)
AlertDialog(
onDismissRequest = { viewModel.cancelDeleteSession() },
title = { Text(stringResource(R.string.sessions_delete_title)) },
@@ -319,184 +456,283 @@ fun SessionsScreen(
onRefresh = { viewModel.loadSessions() },
modifier = modifier,
) {
when {
state.isLoading && state.sessions.isEmpty() -> {
LoadingState()
}
state.errorMessage != null -> {
val errorMsg = state.errorMessage
ErrorState(
message = errorMsg ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.loadSessions() },
// Single scrolling column: search bar pinned on top, list fills below.
// (The scaffold already applies top-bar padding via its inner Box, so we
// must NOT re-apply paddingValues here.)
Column(modifier = Modifier.fillMaxSize()) {
// ── Search + bulk toggle (always visible) ─────────────
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
SearchBar(
query = state.searchQuery,
onQueryChange = { viewModel.setSearchQuery(it) },
placeholder = stringResource(R.string.sessions_search_placeholder),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(spacing.sm))
IconButton(onClick = { viewModel.toggleSelecting() }) {
Icon(
imageVector = if (state.isSelecting) Icons.Filled.Close else Icons.Filled.SelectAll,
contentDescription =
if (state.isSelecting) {
stringResource(R.string.content_desc_exit_selection)
} else {
stringResource(R.string.content_desc_enter_selection)
},
)
}
}
state.sessions.isEmpty() -> {
EmptyState(
title = stringResource(R.string.history_empty_title),
subtitle = stringResource(R.string.history_empty_desc),
icon = Icons.Filled.History,
)
}
// List/state area takes all remaining height below the search bar.
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
when {
state.isSearchMode -> {
Column(modifier = Modifier.fillMaxSize()) {
when {
state.isSearching && state.searchResults.isEmpty() -> {
LoadingState()
}
else -> {
Column(modifier = Modifier.fillMaxSize()) {
// ── Stats row ───────────────────────────────────────
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
StatCard(
label = stringResource(R.string.sessions_stat_total),
value = if (state.isLoadingStats) "…" else state.stats.total.toString(),
icon = Icons.Filled.History,
modifier = Modifier.weight(1f),
)
StatCard(
label = stringResource(R.string.sessions_stat_active),
value = if (state.isLoadingStats) "…" else state.stats.active.toString(),
icon = Icons.Filled.CheckCircle,
accentColor = statusColors.success,
modifier = Modifier.weight(1f),
)
// Prune button card
Card(
modifier = Modifier.weight(1f),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
onClick = { viewModel.showPruneDialog() },
) {
Box(
modifier = Modifier.padding(spacing.md),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(20.dp),
state.searchError != null -> {
ErrorState(
message =
state.searchError
?: stringResource(R.string.error_unknown),
onRetry = { viewModel.setSearchQuery(state.searchQuery) },
)
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = stringResource(R.string.sessions_action_prune),
style = MaterialTheme.typography.labelSmall,
color = statusColors.warning,
}
state.searchResults.isEmpty() -> {
EmptyState(
title = stringResource(R.string.sessions_search_empty_title),
subtitle =
stringResource(
R.string.sessions_search_empty_desc,
state.searchQuery,
),
icon = Icons.Filled.Search,
)
}
else -> {
Column(modifier = Modifier.fillMaxSize()) {
Text(
text =
stringResource(
R.string.sessions_search_results_header,
state.searchResults.size,
state.searchQuery,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.padding(
horizontal = spacing.md,
vertical = spacing.sm,
),
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listPadding,
verticalArrangement = listItemSpacing,
) {
items(sessionsToDisplay, key = { it.session.id }) { item ->
val session = item.session
SearchResultCard(
session = session,
query = state.searchQuery,
isSelecting = state.isSelecting,
isSelected = session.id in state.selectedIds,
isDeleting = session.id in state.deletingSessionIds,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id)
} else {
NavigationController.pendingSessionId = session.id
NavigationController.navigateTo(ChatScreen)
}
},
onCardLongClick = {
if (!state.isSelecting) {
viewModel.toggleSelecting()
viewModel.toggleSessionSelection(session.id)
}
},
onToggleSelection = {
viewModel.toggleSessionSelection(
session.id,
)
},
onDelete = { viewModel.requestDeleteSession(session.id) },
)
}
}
}
}
}
}
}
// Stats error snack
val statsError = state.statsError
if (statsError != null) {
Text(
text = statsError,
style = MaterialTheme.typography.labelSmall,
color = statusColors.error,
modifier = Modifier.padding(horizontal = spacing.md),
state.isLoading && state.sessions.isEmpty() -> {
LoadingState()
}
state.errorMessage != null -> {
val errorMsg = state.errorMessage
ErrorState(
message = errorMsg ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.loadSessions() },
)
}
// ── Search + bulk toggle ────────────────────────────
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.sessions_search_placeholder),
modifier = Modifier.weight(1f),
state.sessions.isEmpty() -> {
EmptyState(
title = stringResource(R.string.history_empty_title),
subtitle = stringResource(R.string.history_empty_desc),
icon = Icons.Filled.History,
)
Spacer(modifier = Modifier.width(spacing.sm))
IconButton(onClick = { viewModel.toggleSelecting() }) {
Icon(
imageVector = if (state.isSelecting) Icons.Filled.Close else Icons.Filled.SelectAll,
contentDescription =
if (state.isSelecting) {
stringResource(R.string.content_desc_exit_selection)
} else {
stringResource(R.string.content_desc_enter_selection)
},
)
}
}
// ── Session list ────────────────────────────────────
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(filteredSessions, key = { it.session.id }) { item ->
val session = item.session
SessionCard(
session = session,
displayTitle = item.displayTitle,
depth = item.depth,
branchStem = item.branchStem,
query = query,
isSelecting = state.isSelecting,
isSelected = session.id in state.selectedIds,
isDeleting = session.id in state.deletingSessionIds,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id)
} else {
NavigationController.pendingSessionId = session.id
NavigationController.navigateTo(ChatScreen)
}
},
onCardLongClick = {
if (!state.isSelecting) {
viewModel.toggleSelecting()
viewModel.toggleSessionSelection(session.id)
}
},
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
onDelete = { viewModel.requestDeleteSession(session.id) },
)
}
// Load more
if (state.hasMore || state.isLoadingMore) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = spacing.sm),
contentAlignment = Alignment.Center,
else -> {
Column(modifier = Modifier.fillMaxSize()) {
// ── Stats row ───────────────────────────────────────
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
StatCard(
label = stringResource(R.string.sessions_stat_total),
value = if (state.isLoadingStats) "…" else state.stats.total.toString(),
icon = Icons.Filled.History,
modifier = Modifier.weight(1f),
)
StatCard(
label = stringResource(R.string.sessions_stat_active),
value = if (state.isLoadingStats) "…" else state.stats.active.toString(),
icon = Icons.Filled.CheckCircle,
accentColor = statusColors.success,
modifier = Modifier.weight(1f),
)
// Prune button card
Card(
modifier = Modifier.weight(1f),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
onClick = { viewModel.showPruneDialog() },
) {
if (state.isLoadingMore) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Text(
text = stringResource(R.string.history_load_more),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
Box(
modifier = Modifier.padding(spacing.md),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = stringResource(R.string.sessions_action_prune),
style = MaterialTheme.typography.labelSmall,
color = statusColors.warning,
)
}
}
}
}
// Stats error snack
val statsError = state.statsError
if (statsError != null) {
Text(
text = statsError,
style = MaterialTheme.typography.labelSmall,
color = statusColors.error,
modifier = Modifier.padding(horizontal = spacing.md),
)
}
// ── Session list ────────────────────────────────────
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listPadding,
verticalArrangement = listItemSpacing,
) {
items(sessionsToDisplay, key = { it.session.id }) { item ->
val session = item.session
SessionCard(
session = session,
displayTitle = item.displayTitle,
depth = item.depth,
branchStem = item.branchStem,
query = state.searchQuery,
isSelecting = state.isSelecting,
isSelected = session.id in state.selectedIds,
isDeleting = session.id in state.deletingSessionIds,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id)
} else {
NavigationController.pendingSessionId = session.id
NavigationController.navigateTo(ChatScreen)
}
},
onCardLongClick = {
if (!state.isSelecting) {
viewModel.toggleSelecting()
viewModel.toggleSessionSelection(session.id)
}
},
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
onDelete = { viewModel.requestDeleteSession(session.id) },
)
}
// Load more
if (state.hasMore || state.isLoadingMore) {
item {
Box(
modifier =
Modifier
.testTag("load_more_sessions")
.clickable(role = Role.Button) {
viewModel.loadMore()
},
)
.fillMaxWidth()
.padding(vertical = spacing.sm),
contentAlignment = Alignment.Center,
) {
if (state.isLoadingMore) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Text(
text = stringResource(R.string.history_load_more),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.testTag("load_more_sessions")
.clickable(role = Role.Button) {
viewModel.loadMore()
},
)
}
}
}
}
}
@@ -505,92 +741,92 @@ fun SessionsScreen(
}
}
}
}
// ── Bulk action toolbar (animated) ──────────────────────────────────
AnimatedVisibility(
visible = state.isSelecting && hasSelection,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
// ── Bulk action toolbar (animated) ──────────────────────────────────
AnimatedVisibility(
visible = state.isSelecting && hasSelection,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = 8.dp,
tonalElevation = 4.dp,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = 8.dp,
tonalElevation = 4.dp,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
// Select all / deselect
OutlinedButton(
onClick = {
if (state.selectedIds.size == filteredSessions.size) {
viewModel.clearSelection()
} else {
viewModel.selectAll()
}
},
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (state.selectedIds.size == filteredSessions.size) {
Icons.Filled.Close
// Select all / deselect
OutlinedButton(
onClick = {
if (allVisibleSessionsSelected) {
viewModel.clearSelection()
} else {
Icons.Filled.SelectAll
},
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
if (state.selectedIds.size == filteredSessions.size) {
stringResource(R.string.sessions_action_deselect_all)
} else {
stringResource(R.string.sessions_action_select_all)
viewModel.selectAll(visibleSessionIds)
}
},
)
}
// Delete selected
Button(
onClick = { viewModel.requestBulkDelete() },
enabled = !state.isDeletingBulk,
colors =
ButtonDefaults.buttonColors(
containerColor = statusColors.error,
),
) {
if (state.isDeletingBulk) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = statusColors.onError,
)
} else {
) {
Icon(
imageVector = Icons.Filled.Delete,
imageVector =
if (allVisibleSessionsSelected) {
Icons.Filled.Close
} else {
Icons.Filled.SelectAll
},
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
if (allVisibleSessionsSelected) {
stringResource(R.string.sessions_action_deselect_all)
} else {
stringResource(R.string.sessions_action_select_all)
},
)
}
// Delete selected
Button(
onClick = { viewModel.requestBulkDelete() },
enabled = !state.isDeletingBulk,
colors =
ButtonDefaults.buttonColors(
containerColor = statusColors.error,
),
) {
if (state.isDeletingBulk) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White,
)
} else {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.width(spacing.xs))
Text(
stringResource(
R.string.sessions_action_delete_n,
state.selectedIds.size,
),
)
}
Spacer(modifier = Modifier.width(spacing.xs))
Text(
stringResource(
R.string.sessions_action_delete_n,
state.selectedIds.size,
),
)
}
}
}
@@ -601,7 +837,7 @@ fun SessionsScreen(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SessionCard(
session: SessionInfo,
session: com.m57.hermescontrol.data.model.SessionInfo,
displayTitle: String,
depth: Int,
branchStem: String?,
@@ -741,3 +977,160 @@ private fun SessionCard(
}
}
}
/**
* Search-result card. The backend search payload has no session title, so this card is
* honest about it: it shows a "Match" label + the highlighted snippet as the body, plus
* source / model / played-at metadata chips. It never presents the snippet as a name.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun SearchResultCard(
session: com.m57.hermescontrol.data.model.SessionInfo,
query: String,
isSelecting: Boolean,
isSelected: Boolean,
isDeleting: Boolean,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
onCardLongClick: () -> Unit,
onToggleSelection: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
val cleanSnippet = cleanSearchSnippet(snippet)
val playedAt = formatPlayedAt(session.started_at)
Card(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_card_${session.id}")
.combinedClickable(
onClick = onCardClick,
onLongClick = onCardLongClick,
),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
border =
if (isSelected) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
) {
Row(
modifier = Modifier.padding(spacing.sm),
verticalAlignment = Alignment.Top,
) {
// Checkbox in select mode
if (isSelecting) {
Checkbox(
checked = isSelected,
onCheckedChange = { onToggleSelection() },
modifier = Modifier.testTag("session_checkbox_${session.id}"),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Column(modifier = Modifier.weight(1f)) {
// Header row: "Match" label + source icon
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
val srcIcon = sourceIcon(session.source)
if (srcIcon != null && !isSelecting) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
Text(
text = stringResource(R.string.sessions_search_match_label),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 0.5.sp,
)
if (!session.id.isNullOrBlank()) {
Text(
text = "· ${session.id.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
// The matched snippet, highlighted — shown as the body, NOT as a title.
Text(
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(spacing.xs))
// Metadata chips row
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
session.source?.let { src ->
StatusBadge(
text = sourceLabel(src),
status = StatusBadgeType.NEUTRAL,
)
}
session.model?.let { mdl ->
StatusBadge(
text = stringResource(R.string.sessions_search_model_label) + ": " + mdl,
status = StatusBadgeType.INFO,
)
}
playedAt?.let { time ->
StatusBadge(
text = stringResource(R.string.sessions_search_played_at, time),
status = StatusBadgeType.NEUTRAL,
)
}
}
}
// Action buttons (not in select mode)
if (!isSelecting) {
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
if (isDeleting) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(
onClick = onDelete,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
modifier = Modifier.size(16.dp),
tint = statusColors.error,
)
}
}
}
}
}
}
}
@@ -6,11 +6,13 @@ import com.m57.hermescontrol.data.model.BulkDeleteRequest
import com.m57.hermescontrol.data.model.PruneRequest
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionRenameRequest
import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -41,8 +43,13 @@ data class SessionsUiState(
val toastMessage: String? = null,
val sessionToDeleteConfirm: String? = null,
val showBulkDeleteConfirm: Boolean = false,
val searchQuery: String = "",
val isSearching: Boolean = false,
val searchResults: List<SessionSearchResult> = emptyList(),
val searchError: String? = null,
) {
val hasMore: Boolean get() = total > sessions.size
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
}
class SessionsViewModel : ViewModel() {
@@ -55,6 +62,7 @@ class SessionsViewModel : ViewModel() {
/** Page size sent to the server — matches the default the gateway uses. */
private companion object {
const val PAGE_SIZE = 20
const val SEARCH_DEBOUNCE_MS = 300L
}
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
@@ -134,6 +142,56 @@ class SessionsViewModel : ViewModel() {
}
}
// ── Search (server-backed FTS5) ──────────────────────────────────
private var searchJob: Job? = null
/**
* Debounced server-side session search. A non-blank query schedules a search
* call after [SEARCH_DEBOUNCE_MS]; a blank query returns to the normal
* paginated list mode.
*/
fun setSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
searchJob?.cancel()
if (query.isBlank()) {
_uiState.update {
it.copy(searchResults = emptyList(), searchError = null, isSearching = false)
}
return
}
searchJob =
viewModelScope.launch {
delay(SEARCH_DEBOUNCE_MS)
_uiState.update { it.copy(isSearching = true, searchError = null) }
val result =
safeApiCall {
ApiClient.hermesApi.searchSessions(q = query, profile = null)
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSearching = false,
searchResults = result.data?.results.orEmpty(),
searchError = null,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSearching = false,
searchResults = emptyList(),
searchError = "Search failed: ${result.error.message}",
)
}
}
}
}
}
// ── Stats ────────────────────────────────────────────────────────────
fun loadStats() {
@@ -188,9 +246,9 @@ class SessionsViewModel : ViewModel() {
}
}
fun selectAll() {
fun selectAll(sessionIds: Set<String> = _uiState.value.sessions.mapTo(linkedSetOf()) { it.id }) {
_uiState.update {
it.copy(selectedIds = it.sessions.map { s -> s.id }.toSet())
it.copy(selectedIds = sessionIds.toSet())
}
}
@@ -300,7 +358,8 @@ class SessionsViewModel : ViewModel() {
it.copy(
deletingSessionIds = it.deletingSessionIds - sessionId,
sessions = it.sessions.filter { s -> s.id != sessionId },
total = it.total - 1,
searchResults = it.searchResults.filter { it.session_id != sessionId },
total = (it.total - 1).coerceAtLeast(0),
toastMessage = "Session deleted",
)
}
@@ -348,7 +407,8 @@ class SessionsViewModel : ViewModel() {
isSelecting = false,
selectedIds = emptySet(),
sessions = it.sessions.filter { s -> s.id !in ids },
total = it.total - ids.size,
searchResults = it.searchResults.filter { it.session_id !in ids },
total = (it.total - ids.size).coerceAtLeast(0),
toastMessage = "${ids.size} session(s) deleted",
)
}