* 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:
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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+81
@@ -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\"}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user