diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt new file mode 100644 index 00000000..1f3d4e41 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt @@ -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() } + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt new file mode 100644 index 00000000..7467fd79 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 0e400ca6..bd912149 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -116,17 +116,6 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.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 @@ -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 = if (state.isSearchMode) { state.searchResults.map { searchResult -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 26da29e3..0dc6f2b9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -147,11 +147,17 @@ class SessionsViewModel : rawPaginationOffset = data.nextOffset(0) _uiState.update { 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( sessions = newSessions, - total = if (hasMore) data.total else newSessions.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, ) } } @@ -229,14 +235,20 @@ class SessionsViewModel : if (requestGeneration != generation) return@safeLaunchLoad rawPaginationOffset = data.nextOffset(0) 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 { it.copy( isLoading = false, isLoadingMore = false, sessions = sessionsList, - total = if (hasMore) data.total else sessionsList.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, selectedIds = emptySet(), ) } @@ -284,14 +296,20 @@ class SessionsViewModel : (it.sessions + data.sessions) .distinctBy { s -> s.id } .pinnedFirst() - val receivedFullPage = data.sessions.size >= PAGE_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( isLoadingMore = false, sessions = newSessions, - total = if (hasMore) data.total else newSessions.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt new file mode 100644 index 00000000..89c8ed16 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt new file mode 100644 index 00000000..d8932bf3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 9d2df0e1..1176f89c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -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 fun `sections request conversations excluding cron and automations from cron`() { val vm = createViewModel()