diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 96a5ab35..d73de9cc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt new file mode 100644 index 00000000..ebbdf4c8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionSearchModels.kt @@ -0,0 +1,18 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionSearchResponse( + val results: List = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index fa73b0ef..7ce957b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @GET("api/sessions/search") + suspend fun searchSessions( + @Query("q") q: String, + @Query("profile") profile: String? = null, + ): Response + @GET("api/sessions/{id}/messages") suspend fun getSessionMessages( // Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468). diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 0ab1f3f6..dfa3e96c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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 = + 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, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 54a900fa..06b96db0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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 = 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 = _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", ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 56291a03..b2bcb8eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -482,6 +482,15 @@ Search sessions… + No results + No sessions match “%1$s” + %1$d result(s) for “%2$s” + Played %1$s + Match + Model + Unknown model + Source + Tap to open this conversation Total Active Prune diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index 2fbb36d8..c084ba58 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -307,6 +307,87 @@ class HermesApiServiceMockWebServerTest { 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 fun getOAuthProviders_parsesList() = runBlocking { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt new file mode 100644 index 00000000..da5c004c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -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(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\"}"), + ) + } +}