refactor(sessions): extract search formatting and paging logic

Extract SessionSearchFormatting and SessionsPaging helpers.
Deduplicate paging calculation in SessionsViewModel and add unit tests.
This commit is contained in:
Hy4ri committed 2026-09-11 17:53:02 +03:00
1 parent f0c2d2f87b
commit 6876db0393
7 files changed
+315 -112

No files matched your search

@@ -0,0 +1,98 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionSearchResult
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
/**
* 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.
*/
internal 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.
*/
internal 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.
*/
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() }
}
}
@@ -0,0 +1,42 @@
package com.m57.hermescontrol.ui.sessions
data class SessionPaginationResolution(
val hasMore: Boolean,
val total: Int,
)
object SessionsPaging {
/**
* Computes hasMore and effective total when refreshing or loading the initial page of sessions.
*/
fun resolveInitialPaging(
receivedCount: Int,
pageSize: Int,
backendTotal: Int,
accumulatedCount: Int,
): SessionPaginationResolution {
val hasMore = receivedCount >= pageSize && backendTotal > accumulatedCount
return SessionPaginationResolution(
hasMore = hasMore,
total = if (hasMore) backendTotal else accumulatedCount,
)
}
/**
* Computes hasMore and effective total when appending a page of sessions.
*/
fun resolveLoadMorePaging(
receivedCount: Int,
pageSize: Int,
addedNewItems: Boolean,
backendTotal: Int,
accumulatedCount: Int,
): SessionPaginationResolution {
val receivedFullPage = receivedCount >= pageSize
val hasMore = receivedFullPage && addedNewItems && backendTotal > accumulatedCount
return SessionPaginationResolution(
hasMore = hasMore,
total = if (hasMore) backendTotal else accumulatedCount,
)
}
}
@@ -116,17 +116,6 @@ 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.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
/** /**
* Auto-load the next history page when the user scrolls to within this many * Auto-load the next history page when the user scrolls to within this many
@@ -197,89 +186,6 @@ 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() }
}
}
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> = private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
if (state.isSearchMode) { if (state.isSearchMode) {
state.searchResults.map { searchResult -> state.searchResults.map { searchResult ->
@@ -147,11 +147,17 @@ class SessionsViewModel :
rawPaginationOffset = data.nextOffset(0) rawPaginationOffset = data.nextOffset(0)
_uiState.update { _uiState.update {
val newSessions = data.sessions.orEmpty().pinnedFirst() val newSessions = data.sessions.orEmpty().pinnedFirst()
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size,
pageSize = PAGE_SIZE,
backendTotal = data.total,
accumulatedCount = newSessions.size,
)
it.copy( it.copy(
sessions = newSessions, sessions = newSessions,
total = if (hasMore) data.total else newSessions.size, total = paging.total,
hasMore = hasMore, hasMore = paging.hasMore,
) )
} }
} }
@@ -229,14 +235,20 @@ class SessionsViewModel :
if (requestGeneration != generation) return@safeLaunchLoad if (requestGeneration != generation) return@safeLaunchLoad
rawPaginationOffset = data.nextOffset(0) rawPaginationOffset = data.nextOffset(0)
val sessionsList = data.sessions.orEmpty().pinnedFirst() val sessionsList = data.sessions.orEmpty().pinnedFirst()
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size,
pageSize = PAGE_SIZE,
backendTotal = data.total,
accumulatedCount = sessionsList.size,
)
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
sessions = sessionsList, sessions = sessionsList,
total = if (hasMore) data.total else sessionsList.size, total = paging.total,
hasMore = hasMore, hasMore = paging.hasMore,
selectedIds = emptySet(), selectedIds = emptySet(),
) )
} }
@@ -284,14 +296,20 @@ class SessionsViewModel :
(it.sessions + data.sessions) (it.sessions + data.sessions)
.distinctBy { s -> s.id } .distinctBy { s -> s.id }
.pinnedFirst() .pinnedFirst()
val receivedFullPage = data.sessions.size >= PAGE_SIZE
val addedNewItems = newSessions.size > it.sessions.size val addedNewItems = newSessions.size > it.sessions.size
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size val paging =
SessionsPaging.resolveLoadMorePaging(
receivedCount = data.sessions.size,
pageSize = PAGE_SIZE,
addedNewItems = addedNewItems,
backendTotal = data.total,
accumulatedCount = newSessions.size,
)
it.copy( it.copy(
isLoadingMore = false, isLoadingMore = false,
sessions = newSessions, sessions = newSessions,
total = if (hasMore) data.total else newSessions.size, total = paging.total,
hasMore = hasMore, hasMore = paging.hasMore,
) )
} }
} }
@@ -0,0 +1,72 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionSearchResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class SessionSearchFormattingTest {
@Test
fun `clean search snippet strips highlight markers from plain text`() {
assertEquals(
"Find the deployment logs",
cleanSearchSnippet(">>>Find<<< the deployment logs"),
)
}
@Test
fun `clean search snippet extracts text from JSON payload`() {
assertEquals(
"Find the deployment logs",
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
)
}
@Test
fun `clean search snippet extracts nested text array`() {
assertEquals(
"First part second part",
cleanSearchSnippet("{\"parts\":[\">>>First<<< part\",\"second part\"]}"),
)
}
@Test
fun `clean search snippet normalizes whitespace`() {
assertEquals(
"hello world",
cleanSearchSnippet(" hello world \n "),
)
}
@Test
fun `toSessionInfo leaves title null for match layout`() {
val searchResult =
SessionSearchResult(
session_id = "test-123",
session_started = 1700000000.0,
snippet = "matched query excerpt",
source = "cli",
model = "test-model",
)
val info = searchResult.toSessionInfo()
assertEquals("test-123", info.id)
assertNull(info.title)
assertEquals("matched query excerpt", info.preview)
assertEquals("cli", info.source)
assertEquals("test-model", info.model)
}
@Test
fun `formatPlayedAt handles null and negative epoch`() {
assertNull(formatPlayedAt(null))
assertNull(formatPlayedAt(0.0))
assertNull(formatPlayedAt(-100.0))
}
@Test
fun `formatPlayedAt returns formatted string for valid timestamp`() {
val result = formatPlayedAt(1700000000.0)
assertNotNull(result)
}
}
@@ -0,0 +1,75 @@
package com.m57.hermescontrol.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionsPagingTest {
@Test
fun `resolveInitialPaging hasMore when received full page and total exceeds accumulated`() {
val resolution =
SessionsPaging.resolveInitialPaging(
receivedCount = 50,
pageSize = 50,
backendTotal = 120,
accumulatedCount = 50,
)
assertTrue(resolution.hasMore)
assertEquals(120, resolution.total)
}
@Test
fun `resolveInitialPaging no more when received less than page size`() {
val resolution =
SessionsPaging.resolveInitialPaging(
receivedCount = 30,
pageSize = 50,
backendTotal = 30,
accumulatedCount = 30,
)
assertFalse(resolution.hasMore)
assertEquals(30, resolution.total)
}
@Test
fun `resolveInitialPaging no more when backendTotal equals accumulated`() {
val resolution =
SessionsPaging.resolveInitialPaging(
receivedCount = 50,
pageSize = 50,
backendTotal = 50,
accumulatedCount = 50,
)
assertFalse(resolution.hasMore)
assertEquals(50, resolution.total)
}
@Test
fun `resolveLoadMorePaging hasMore when full page added new items and total exceeds accumulated`() {
val resolution =
SessionsPaging.resolveLoadMorePaging(
receivedCount = 50,
pageSize = 50,
addedNewItems = true,
backendTotal = 150,
accumulatedCount = 100,
)
assertTrue(resolution.hasMore)
assertEquals(150, resolution.total)
}
@Test
fun `resolveLoadMorePaging no more when no new items added`() {
val resolution =
SessionsPaging.resolveLoadMorePaging(
receivedCount = 50,
pageSize = 50,
addedNewItems = false,
backendTotal = 150,
accumulatedCount = 100,
)
assertFalse(resolution.hasMore)
assertEquals(100, resolution.total)
}
}
@@ -92,14 +92,6 @@ class SessionsViewModelTest {
) )
} }
@Test
fun `clean search snippet extracts text from JSON payload`() {
assertEquals(
"Find the deployment logs",
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
)
}
@Test @Test
fun `sections request conversations excluding cron and automations from cron`() { fun `sections request conversations excluding cron and automations from cron`() {
val vm = createViewModel() val vm = createViewModel()