fix: improve History search and prevent duplicate-key crashes (#1186)
This commit is contained in:
9 files changed
+458
-48
No files matched your search
@@ -5,7 +5,11 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class SessionSearchResponse(
|
||||
val results: List<SessionSearchResult> = emptyList(),
|
||||
)
|
||||
val has_more: Boolean? = null,
|
||||
val next_offset: Int? = null,
|
||||
) {
|
||||
val hasMore: Boolean get() = has_more == true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SessionSearchResult(
|
||||
@@ -15,4 +19,9 @@ data class SessionSearchResult(
|
||||
val source: String? = null,
|
||||
val model: String? = null,
|
||||
val session_started: Double? = null,
|
||||
val title: String? = null,
|
||||
val last_active: Double? = null,
|
||||
val message_count: Int? = null,
|
||||
val archived: Boolean? = null,
|
||||
val lineage_root: String? = null,
|
||||
)
|
||||
@@ -189,6 +189,8 @@ interface HermesApiService : KanbanApiService {
|
||||
@Query("profile") profile: String? = null,
|
||||
@Query("source") source: String? = null,
|
||||
@Query("exclude_sources") excludeSources: String? = null,
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): Response<SessionSearchResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -15,26 +21,27 @@ import java.time.format.FormatStyle
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Converts a backend search hit into a display model.
|
||||
* When the backend returns an actual session title, it is mapped cleanly.
|
||||
* When title is missing/null, title is kept null so [SearchResultCard] shows
|
||||
* the honest fallback "Match" layout.
|
||||
*/
|
||||
internal fun SessionSearchResult.toSessionInfo(): SessionInfo =
|
||||
SessionInfo(
|
||||
id = session_id,
|
||||
title = null,
|
||||
title = title?.takeIf(String::isNotBlank),
|
||||
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.
|
||||
last_active = last_active,
|
||||
message_count = message_count,
|
||||
lineageRootId = lineage_root,
|
||||
)
|
||||
|
||||
/**
|
||||
* Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute
|
||||
* string. Used for search results where the session name is unknown.
|
||||
* string. Used for search results where the session name is unknown or when displaying recency.
|
||||
*/
|
||||
internal fun formatPlayedAt(epochSeconds: Double?): String? {
|
||||
if (epochSeconds == null || epochSeconds <= 0.0) return null
|
||||
@@ -96,3 +103,84 @@ private fun JsonElement.searchText(): String? =
|
||||
?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() }
|
||||
}
|
||||
}
|
||||
|
||||
private val SEARCH_TOKEN_REGEX = Regex("\"([^\"]+)\"|(\\S+)")
|
||||
|
||||
/**
|
||||
* Builds an AnnotatedString highlighting terms from [query] in [text].
|
||||
* Supports multi-word queries, quoted phrases ("like this"), case-insensitivity,
|
||||
* overlapping matches, and Unicode characters safely without offset drift.
|
||||
* Renders as plain text (no HTML interpretation).
|
||||
*/
|
||||
fun highlightSearchText(
|
||||
text: String,
|
||||
query: String,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
): AnnotatedString {
|
||||
if (query.isBlank() || text.isEmpty()) {
|
||||
return AnnotatedString(text)
|
||||
}
|
||||
val terms =
|
||||
SEARCH_TOKEN_REGEX
|
||||
.findAll(query)
|
||||
.mapNotNull { match ->
|
||||
(match.groups[1]?.value ?: match.groups[2]?.value)?.trim()?.takeIf(String::isNotEmpty)
|
||||
}.distinct()
|
||||
.toList()
|
||||
|
||||
if (terms.isEmpty()) {
|
||||
return AnnotatedString(text)
|
||||
}
|
||||
|
||||
val ranges = mutableListOf<IntRange>()
|
||||
for (term in terms) {
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val found = text.indexOf(term, startIndex = startIndex, ignoreCase = true)
|
||||
if (found == -1) break
|
||||
ranges.add(found until (found + term.length))
|
||||
startIndex = found + term.length
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.isEmpty()) {
|
||||
return AnnotatedString(text)
|
||||
}
|
||||
|
||||
ranges.sortBy { it.first }
|
||||
val merged = mutableListOf<IntRange>()
|
||||
var current = ranges[0]
|
||||
for (i in 1 until ranges.size) {
|
||||
val next = ranges[i]
|
||||
if (next.first <= current.last + 1) {
|
||||
current = current.first..maxOf(current.last, next.last)
|
||||
} else {
|
||||
merged.add(current)
|
||||
current = next
|
||||
}
|
||||
}
|
||||
merged.add(current)
|
||||
|
||||
return buildAnnotatedString {
|
||||
var cursor = 0
|
||||
for (range in merged) {
|
||||
if (range.first > cursor) {
|
||||
append(text.substring(cursor, range.first))
|
||||
}
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = highlightBackground,
|
||||
color = highlightForeground,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
) {
|
||||
append(text.substring(range.first, range.last + 1))
|
||||
}
|
||||
cursor = range.last + 1
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
append(text.substring(cursor))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,6 +661,37 @@ fun SessionsScreen(
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
if (state.searchHasMore) {
|
||||
item(key = "search_paging_footer") {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
state.searchLoadMoreError?.let { error ->
|
||||
Text(error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(
|
||||
onClick = viewModel::loadMoreSearch,
|
||||
enabled = !state.isLoadingMoreSearch,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().testTag(
|
||||
"search_load_more",
|
||||
),
|
||||
) {
|
||||
if (state.isLoadingMoreSearch) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp))
|
||||
} else {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.searchLoadMoreError != null) {
|
||||
R.string.action_retry
|
||||
} else {
|
||||
R.string.history_load_more
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,10 @@ data class SessionsUiState(
|
||||
val isSearching: Boolean = false,
|
||||
val searchResults: List<SessionSearchResult> = emptyList(),
|
||||
val searchError: String? = null,
|
||||
val isLoadingMoreSearch: Boolean = false,
|
||||
val searchHasMore: Boolean = false,
|
||||
val searchNextOffset: Int? = null,
|
||||
val searchLoadMoreError: String? = null,
|
||||
val showHidden: Boolean = false,
|
||||
val pinnedExpanded: Boolean = true,
|
||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
@@ -255,6 +259,8 @@ class SessionsViewModel(
|
||||
loadJob?.cancel()
|
||||
pageJob?.cancel()
|
||||
searchJob?.cancel()
|
||||
searchPageJob?.cancel()
|
||||
searchGeneration++
|
||||
rawPaginationOffset = 0
|
||||
val query = _uiState.value.searchQuery
|
||||
_uiState.update {
|
||||
@@ -413,6 +419,8 @@ class SessionsViewModel(
|
||||
// ── Search (server-backed FTS5) ──────────────────────────────────
|
||||
|
||||
private var searchJob: Job? = null
|
||||
private var searchPageJob: Job? = null
|
||||
private var searchGeneration = 0L
|
||||
|
||||
/**
|
||||
* Debounced server-side session search. A non-blank query schedules a search
|
||||
@@ -420,53 +428,99 @@ class SessionsViewModel(
|
||||
* paginated list mode.
|
||||
*/
|
||||
fun setSearchQuery(query: String) {
|
||||
val requestGeneration = generation
|
||||
val requestGeneration = ++searchGeneration
|
||||
val section = _uiState.value.section
|
||||
_uiState.update { it.copy(searchQuery = query, searchResults = emptyList()) }
|
||||
searchJob?.cancel()
|
||||
searchPageJob?.cancel()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
searchQuery = query,
|
||||
searchResults = emptyList(),
|
||||
isSearching = query.isNotBlank(),
|
||||
searchError = null,
|
||||
isLoadingMoreSearch = false,
|
||||
searchHasMore = false,
|
||||
searchNextOffset = null,
|
||||
searchLoadMoreError = null,
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
)
|
||||
}
|
||||
if (query.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(searchResults = emptyList(), searchError = null, isSearching = false)
|
||||
}
|
||||
if (_uiState.value.sessions.isEmpty()) loadSessions()
|
||||
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,
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != generation || _uiState.value.searchQuery != query) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSearching = false,
|
||||
searchResults = result.data.results.orEmpty(),
|
||||
searchError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
requestSearchPage(query, section, requestGeneration, 0)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSearching = false,
|
||||
searchResults = emptyList(),
|
||||
searchError = "Search failed: ${result.error.message}",
|
||||
)
|
||||
fun loadMoreSearch() {
|
||||
val state = _uiState.value
|
||||
val offset = state.searchNextOffset ?: return
|
||||
if (!state.isSearchMode || state.isSearching || state.isLoadingMoreSearch || !state.searchHasMore) return
|
||||
val requestGeneration = searchGeneration
|
||||
_uiState.update { it.copy(isLoadingMoreSearch = true, searchLoadMoreError = null) }
|
||||
searchPageJob =
|
||||
viewModelScope.launch {
|
||||
requestSearchPage(state.searchQuery, state.section, requestGeneration, offset)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestSearchPage(
|
||||
query: String,
|
||||
section: HistorySection,
|
||||
requestGeneration: Long,
|
||||
offset: Int,
|
||||
) {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.searchSessions(
|
||||
q = query,
|
||||
profile = null,
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
limit = 20,
|
||||
offset = offset,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != searchGeneration || _uiState.value.section != section) return
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
val previous = if (offset == 0) emptyList() else state.searchResults
|
||||
// Dedup by final surfaced session id: legacy backends can return
|
||||
// the same continuation twice (distinct lineage roots), and
|
||||
// LazyList keys session ids — duplicates crash the screen.
|
||||
val results = (previous + result.data.results).distinctBy { it.session_id }
|
||||
val next =
|
||||
result.data.next_offset?.takeIf {
|
||||
result.data.hasMore && it > offset && results.size > previous.size
|
||||
}
|
||||
state.copy(
|
||||
isSearching = false,
|
||||
isLoadingMoreSearch = false,
|
||||
searchResults = results,
|
||||
searchError = null,
|
||||
searchLoadMoreError = null,
|
||||
searchHasMore = next != null,
|
||||
searchNextOffset = next,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
if (offset == 0) {
|
||||
it.copy(isSearching = false, searchError = "Search failed: ${result.error.message}")
|
||||
} else {
|
||||
it.copy(isLoadingMoreSearch = false, searchLoadMoreError = "Failed to load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────
|
||||
@@ -581,6 +635,10 @@ class SessionsViewModel(
|
||||
it.sessions.map { s ->
|
||||
if (s.id == sessionId) s.copy(title = newTitle) else s
|
||||
},
|
||||
searchResults =
|
||||
it.searchResults.map { s ->
|
||||
if (s.session_id == sessionId) s.copy(title = newTitle) else s
|
||||
},
|
||||
toastMessage = "Session renamed",
|
||||
)
|
||||
}
|
||||
@@ -780,6 +838,7 @@ class SessionsViewModel(
|
||||
isDeletingBulk = false,
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
searchResults = it.searchResults.filter { hit -> hit.session_id !in ids },
|
||||
toastMessage = toastMsg,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet
|
||||
import com.m57.hermescontrol.ui.sessions.formatPlayedAt
|
||||
import com.m57.hermescontrol.ui.sessions.highlightSearchText
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Search-result card with a separate title and matched excerpt. Legacy responses
|
||||
* without a title retain the Match label; a snippet is never used as a name.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -71,7 +71,7 @@ fun SearchResultCard(
|
||||
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)
|
||||
val playedAt = formatPlayedAt(session.last_active ?: session.started_at)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
@@ -145,9 +145,20 @@ fun SearchResultCard(
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
|
||||
// The matched snippet, highlighted — shown as the body, NOT as a title.
|
||||
session.title?.takeIf(String::isNotBlank)?.let { title ->
|
||||
Text(
|
||||
text = highlightSearchText(title, query, highlightBackground, highlightForeground),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag("search_title_${session.id}"),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
}
|
||||
|
||||
// The matched snippet stays separate from the conversation title.
|
||||
Text(
|
||||
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
|
||||
text = highlightSearchText(cleanSnippet, query, highlightBackground, highlightForeground),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 4,
|
||||
|
||||
Reference in new issue
Block a user