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 source: String? = null,
val parent_session_id: String? = null, val parent_session_id: String? = null,
val display_name: String? = null, val display_name: String? = null,
val model: String? = null,
) )
@Serializable @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.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionPromptResponse import com.m57.hermescontrol.data.model.SessionPromptResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest 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.SessionStatsResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
@@ -132,6 +133,12 @@ interface HermesApiService {
@Query("order") order: String = "recent", @Query("order") order: String = "recent",
): Response<SessionListResponse> ): 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") @GET("api/sessions/{id}/messages")
suspend fun getSessionMessages( suspend fun getSessionMessages(
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468). // 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.DeleteSweep
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Language 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.SelectAll
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Terminal 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.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ChatScreen import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo 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.data.model.flattenSessionTree
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing 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.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing 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. * 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SessionsScreen( fun SessionsScreen(
@@ -169,24 +292,32 @@ fun SessionsScreen(
val primaryContainer = MaterialTheme.colorScheme.primaryContainer val primaryContainer = MaterialTheme.colorScheme.primaryContainer
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
var query by remember { mutableStateOf("") }
var pruneDays by remember { mutableStateOf("7") } var pruneDays by remember { mutableStateOf("7") }
val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) } val sessionsToDisplay =
val filteredSessions = remember(
remember(query, sessionTree) { state.isSearchMode,
if (query.isBlank()) { state.searchQuery,
sessionTree state.sessions,
} else { state.searchResults,
sessionTree.filter { item -> ) {
item.displayTitle.contains(query, ignoreCase = true) || displayedSessions(state)
item.session.preview?.contains(query, ignoreCase = true) == true ||
item.session.status?.contains(query, ignoreCase = true) == true
}
}
} }
val hasSelection = state.selectedIds.isNotEmpty() 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) { LaunchedEffect(Unit) {
viewModel.loadSessions() viewModel.loadSessions()
@@ -254,7 +385,13 @@ fun SessionsScreen(
state.sessions state.sessions
.find { it.id == sessionToDelete } .find { it.id == sessionToDelete }
?.title ?.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( AlertDialog(
onDismissRequest = { viewModel.cancelDeleteSession() }, onDismissRequest = { viewModel.cancelDeleteSession() },
title = { Text(stringResource(R.string.sessions_delete_title)) }, title = { Text(stringResource(R.string.sessions_delete_title)) },
@@ -319,184 +456,283 @@ fun SessionsScreen(
onRefresh = { viewModel.loadSessions() }, onRefresh = { viewModel.loadSessions() },
modifier = modifier, modifier = modifier,
) { ) {
when { // Single scrolling column: search bar pinned on top, list fills below.
state.isLoading && state.sessions.isEmpty() -> { // (The scaffold already applies top-bar padding via its inner Box, so we
LoadingState() // must NOT re-apply paddingValues here.)
} Column(modifier = Modifier.fillMaxSize()) {
// ── Search + bulk toggle (always visible) ─────────────
state.errorMessage != null -> { Row(
val errorMsg = state.errorMessage modifier =
ErrorState( Modifier
message = errorMsg ?: stringResource(R.string.error_unknown), .fillMaxWidth()
onRetry = { viewModel.loadSessions() }, .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() -> { // List/state area takes all remaining height below the search bar.
EmptyState( Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
title = stringResource(R.string.history_empty_title), when {
subtitle = stringResource(R.string.history_empty_desc), state.isSearchMode -> {
icon = Icons.Filled.History, Column(modifier = Modifier.fillMaxSize()) {
) when {
} state.isSearching && state.searchResults.isEmpty() -> {
LoadingState()
}
else -> { state.searchError != null -> {
Column(modifier = Modifier.fillMaxSize()) { ErrorState(
// ── Stats row ─────────────────────────────────────── message =
Row( state.searchError
modifier = ?: stringResource(R.string.error_unknown),
Modifier onRetry = { viewModel.setSearchQuery(state.searchQuery) },
.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),
) )
Spacer(modifier = Modifier.height(spacing.xs)) }
Text(
text = stringResource(R.string.sessions_action_prune), state.searchResults.isEmpty() -> {
style = MaterialTheme.typography.labelSmall, EmptyState(
color = statusColors.warning, 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 state.isLoading && state.sessions.isEmpty() -> {
if (statsError != null) { LoadingState()
Text( }
text = statsError,
style = MaterialTheme.typography.labelSmall, state.errorMessage != null -> {
color = statusColors.error, val errorMsg = state.errorMessage
modifier = Modifier.padding(horizontal = spacing.md), ErrorState(
message = errorMsg ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.loadSessions() },
) )
} }
// ── Search + bulk toggle ──────────────────────────── state.sessions.isEmpty() -> {
Row( EmptyState(
modifier = title = stringResource(R.string.history_empty_title),
Modifier subtitle = stringResource(R.string.history_empty_desc),
.fillMaxWidth() icon = Icons.Filled.History,
.padding(horizontal = spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
SearchBar(
query = query,
onQueryChange = { query = 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)
},
)
}
} }
// ── Session list ──────────────────────────────────── else -> {
LazyColumn( Column(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), // ── Stats row ───────────────────────────────────────
contentPadding = listContentPadding, Row(
verticalArrangement = listItemSpacing, modifier =
) { Modifier
items(filteredSessions, key = { it.session.id }) { item -> .fillMaxWidth()
val session = item.session .padding(horizontal = spacing.md, vertical = spacing.sm),
SessionCard( horizontalArrangement = Arrangement.spacedBy(spacing.sm),
session = session, ) {
displayTitle = item.displayTitle, StatCard(
depth = item.depth, label = stringResource(R.string.sessions_stat_total),
branchStem = item.branchStem, value = if (state.isLoadingStats) "…" else state.stats.total.toString(),
query = query, icon = Icons.Filled.History,
isSelecting = state.isSelecting, modifier = Modifier.weight(1f),
isSelected = session.id in state.selectedIds, )
isDeleting = session.id in state.deletingSessionIds, StatCard(
highlightBackground = primaryContainer, label = stringResource(R.string.sessions_stat_active),
highlightForeground = onPrimaryContainer, value = if (state.isLoadingStats) "…" else state.stats.active.toString(),
onCardClick = { icon = Icons.Filled.CheckCircle,
if (state.isSelecting) { accentColor = statusColors.success,
viewModel.toggleSessionSelection(session.id) modifier = Modifier.weight(1f),
} else { )
NavigationController.pendingSessionId = session.id // Prune button card
NavigationController.navigateTo(ChatScreen) Card(
} modifier = Modifier.weight(1f),
}, colors =
onCardLongClick = { CardDefaults.cardColors(
if (!state.isSelecting) { containerColor = MaterialTheme.colorScheme.surfaceContainer,
viewModel.toggleSelecting() ),
viewModel.toggleSessionSelection(session.id) onClick = { viewModel.showPruneDialog() },
}
},
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,
) { ) {
if (state.isLoadingMore) { Box(
CircularProgressIndicator( modifier = Modifier.padding(spacing.md),
modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center,
strokeWidth = 2.dp, ) {
) Column(horizontalAlignment = Alignment.CenterHorizontally) {
} else { Icon(
Text( imageVector = Icons.Filled.DeleteSweep,
text = stringResource(R.string.history_load_more), contentDescription = null,
style = MaterialTheme.typography.labelLarge, tint = statusColors.warning,
color = MaterialTheme.colorScheme.primary, 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 =
Modifier Modifier
.testTag("load_more_sessions") .fillMaxWidth()
.clickable(role = Role.Button) { .padding(vertical = spacing.sm),
viewModel.loadMore() 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) ────────────────────────────────── // ── Bulk action toolbar (animated) ──────────────────────────────────
AnimatedVisibility( AnimatedVisibility(
visible = state.isSelecting && hasSelection, visible = state.isSelecting && hasSelection,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) { ) {
Surface( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxSize(),
shadowElevation = 8.dp, contentAlignment = Alignment.BottomCenter,
tonalElevation = 4.dp,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
Row( Surface(
modifier = modifier = Modifier.fillMaxWidth(),
Modifier shadowElevation = 8.dp,
.fillMaxWidth() tonalElevation = 4.dp,
.padding(horizontal = spacing.md, vertical = spacing.sm), color = MaterialTheme.colorScheme.surfaceContainerHigh,
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) { ) {
// Select all / deselect Row(
OutlinedButton( modifier =
onClick = { Modifier
if (state.selectedIds.size == filteredSessions.size) { .fillMaxWidth()
viewModel.clearSelection() .padding(horizontal = spacing.md, vertical = spacing.sm),
} else { horizontalArrangement = Arrangement.SpaceEvenly,
viewModel.selectAll() verticalAlignment = Alignment.CenterVertically,
}
},
) { ) {
Icon( // Select all / deselect
imageVector = OutlinedButton(
if (state.selectedIds.size == filteredSessions.size) { onClick = {
Icons.Filled.Close if (allVisibleSessionsSelected) {
viewModel.clearSelection()
} else { } else {
Icons.Filled.SelectAll viewModel.selectAll(visibleSessionIds)
}, }
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)
}, },
) ) {
}
// 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( Icon(
imageVector = Icons.Filled.Delete, imageVector =
if (allVisibleSessionsSelected) {
Icons.Filled.Close
} else {
Icons.Filled.SelectAll
},
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp), 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SessionCard( private fun SessionCard(
session: SessionInfo, session: com.m57.hermescontrol.data.model.SessionInfo,
displayTitle: String, displayTitle: String,
depth: Int, depth: Int,
branchStem: String?, 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.PruneRequest
import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionRenameRequest 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.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -41,8 +43,13 @@ data class SessionsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
val sessionToDeleteConfirm: String? = null, val sessionToDeleteConfirm: String? = null,
val showBulkDeleteConfirm: Boolean = false, 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 hasMore: Boolean get() = total > sessions.size
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
} }
class SessionsViewModel : ViewModel() { class SessionsViewModel : ViewModel() {
@@ -55,6 +62,7 @@ class SessionsViewModel : ViewModel() {
/** Page size sent to the server — matches the default the gateway uses. */ /** Page size sent to the server — matches the default the gateway uses. */
private companion object { private companion object {
const val PAGE_SIZE = 20 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. */ /** 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 ──────────────────────────────────────────────────────────── // ── Stats ────────────────────────────────────────────────────────────
fun loadStats() { 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 { _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( it.copy(
deletingSessionIds = it.deletingSessionIds - sessionId, deletingSessionIds = it.deletingSessionIds - sessionId,
sessions = it.sessions.filter { s -> s.id != 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", toastMessage = "Session deleted",
) )
} }
@@ -348,7 +407,8 @@ class SessionsViewModel : ViewModel() {
isSelecting = false, isSelecting = false,
selectedIds = emptySet(), selectedIds = emptySet(),
sessions = it.sessions.filter { s -> s.id !in ids }, 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", toastMessage = "${ids.size} session(s) deleted",
) )
} }
+9
View File
@@ -482,6 +482,15 @@
<!-- Sessions Stats --> <!-- Sessions Stats -->
<string name="sessions_search_placeholder">Search sessions…</string> <string name="sessions_search_placeholder">Search sessions…</string>
<string name="sessions_search_empty_title">No results</string>
<string name="sessions_search_empty_desc">No sessions match “%1$s”</string>
<string name="sessions_search_results_header">%1$d result(s) for “%2$s”</string>
<string name="sessions_search_played_at">Played %1$s</string>
<string name="sessions_search_match_label">Match</string>
<string name="sessions_search_model_label">Model</string>
<string name="sessions_search_unknown_model">Unknown model</string>
<string name="sessions_search_source_label">Source</string>
<string name="sessions_search_open_hint">Tap to open this conversation</string>
<string name="sessions_stat_total">Total</string> <string name="sessions_stat_total">Total</string>
<string name="sessions_stat_active">Active</string> <string name="sessions_stat_active">Active</string>
<string name="sessions_action_prune">Prune</string> <string name="sessions_action_prune">Prune</string>
@@ -307,6 +307,87 @@ class HermesApiServiceMockWebServerTest {
assertNull(body.messages[0].timestamp) assertNull(body.messages[0].timestamp)
} }
@Test
fun searchSessions_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"results": [
{
"session_id": "sess-abc",
"snippet": "…matched text…",
"role": "user",
"source": "telegram",
"model": "gpt-4o",
"session_started": 1718000000
}
]
}
""".trimIndent(),
),
)
val response = api.searchSessions(q = "matched", profile = null)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(1, body!!.results.size)
val first = body.results[0]
assertEquals("sess-abc", first.session_id)
assertEquals("…matched text…", first.snippet)
assertEquals("telegram", first.source)
assertEquals("gpt-4o", first.model)
assertEquals(1718000000.0, first.session_started!!, 0.0)
}
@Test
fun searchSessions_encodesQueryAndProfileParams() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "results": [] }"""),
)
api.searchSessions(q = "hello world", profile = "work")
val request = mockServer.takeRequest()
assertTrue(
"q must be URL-encoded",
request.path!!.contains("q=hello%20world"),
)
assertTrue(
"profile param must be sent",
request.path!!.contains("profile=work"),
)
}
@Test
fun getSessionMessages_preservesSessionIdSlashes() =
runBlocking {
val sessionId = "session/with/slashes"
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "messages": [] }"""),
)
api.getSessionMessages(sessionId)
val request = mockServer.takeRequest()
assertEquals(
"path should contain the session ID with slashes preserved",
"/api/sessions/session/with/slashes/messages?offset=0",
request.path,
)
}
@Test @Test
fun getOAuthProviders_parsesList() = fun getOAuthProviders_parsesList() =
runBlocking { runBlocking {
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SessionsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private fun createViewModel(): SessionsViewModel {
val vm = SessionsViewModel()
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `blank query resets search mode`() {
val vm = createViewModel()
vm.setSearchQuery("something")
vm.setSearchQuery("")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("", vm.uiState.value.searchQuery)
assertFalse(vm.uiState.value.isSearchMode)
assertEquals(0, vm.uiState.value.searchResults.size)
assertFalse(vm.uiState.value.isSearching)
}
@Test
fun `non-blank query enters search mode and resolves`() {
val vm = createViewModel()
vm.setSearchQuery("hello")
// state is set synchronously
assertEquals("hello", vm.uiState.value.searchQuery)
assertTrue(vm.uiState.value.isSearchMode)
// advance past debounce + (failing, offline) network call
testDispatcher.scheduler.advanceTimeBy(500)
testDispatcher.scheduler.advanceUntilIdle()
// Either way the spinner must stop and the query persists.
assertFalse(vm.uiState.value.isSearching)
assertEquals("hello", vm.uiState.value.searchQuery)
}
@Test
fun `select all uses the IDs shown in the current view`() {
val vm = createViewModel()
vm.selectAll(setOf("search-session-1", "search-session-2"))
assertEquals(
setOf("search-session-1", "search-session-2"),
vm.uiState.value.selectedIds,
)
}
@Test
fun `clean search snippet extracts text from JSON payload`() {
assertEquals(
"Find the deployment logs",
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
)
}
}