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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

@@ -21,6 +21,7 @@ data class SessionInfo(
val source: String? = null,
val parent_session_id: String? = null,
val display_name: String? = null,
val model: String? = null,
)
@Serializable
@@ -0,0 +1,18 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
data class SessionSearchResponse(
val results: List<SessionSearchResult> = emptyList(),
)
@Serializable
data class SessionSearchResult(
val session_id: String,
val snippet: String? = null,
val role: String? = null,
val source: String? = null,
val model: String? = null,
val session_started: Double? = null,
)
@@ -70,6 +70,7 @@ import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionPromptResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest
import com.m57.hermescontrol.data.model.SessionSearchResponse
import com.m57.hermescontrol.data.model.SessionStatsResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
@@ -132,6 +133,12 @@ interface HermesApiService {
@Query("order") order: String = "recent",
): Response<SessionListResponse>
@GET("api/sessions/search")
suspend fun searchSessions(
@Query("q") q: String,
@Query("profile") profile: String? = null,
): Response<SessionSearchResponse>
@GET("api/sessions/{id}/messages")
suspend fun getSessionMessages(
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).