* 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 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).
|
||||
|
||||
@@ -12,6 +12,9 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.Spacer
|
||||
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.History
|
||||
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.Send
|
||||
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.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.ChatScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
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.theme.LocalHermesStatusColors
|
||||
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.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
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.
|
||||
@@ -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)
|
||||
@Composable
|
||||
fun SessionsScreen(
|
||||
@@ -169,24 +292,32 @@ fun SessionsScreen(
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var pruneDays by remember { mutableStateOf("7") }
|
||||
|
||||
val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) }
|
||||
val filteredSessions =
|
||||
remember(query, sessionTree) {
|
||||
if (query.isBlank()) {
|
||||
sessionTree
|
||||
} else {
|
||||
sessionTree.filter { item ->
|
||||
item.displayTitle.contains(query, ignoreCase = true) ||
|
||||
item.session.preview?.contains(query, ignoreCase = true) == true ||
|
||||
item.session.status?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
val sessionsToDisplay =
|
||||
remember(
|
||||
state.isSearchMode,
|
||||
state.searchQuery,
|
||||
state.sessions,
|
||||
state.searchResults,
|
||||
) {
|
||||
displayedSessions(state)
|
||||
}
|
||||
|
||||
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) {
|
||||
viewModel.loadSessions()
|
||||
@@ -254,7 +385,13 @@ fun SessionsScreen(
|
||||
state.sessions
|
||||
.find { it.id == sessionToDelete }
|
||||
?.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(
|
||||
onDismissRequest = { viewModel.cancelDeleteSession() },
|
||||
title = { Text(stringResource(R.string.sessions_delete_title)) },
|
||||
@@ -319,184 +456,283 @@ fun SessionsScreen(
|
||||
onRefresh = { viewModel.loadSessions() },
|
||||
modifier = modifier,
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.sessions.isEmpty() -> {
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
val errorMsg = state.errorMessage
|
||||
ErrorState(
|
||||
message = errorMsg ?: stringResource(R.string.error_unknown),
|
||||
onRetry = { viewModel.loadSessions() },
|
||||
// Single scrolling column: search bar pinned on top, list fills below.
|
||||
// (The scaffold already applies top-bar padding via its inner Box, so we
|
||||
// must NOT re-apply paddingValues here.)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Search + bulk toggle (always visible) ─────────────
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.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() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.history_empty_title),
|
||||
subtitle = stringResource(R.string.history_empty_desc),
|
||||
icon = Icons.Filled.History,
|
||||
)
|
||||
}
|
||||
// List/state area takes all remaining height below the search bar.
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
when {
|
||||
state.isSearchMode -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
state.isSearching && state.searchResults.isEmpty() -> {
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Stats row ───────────────────────────────────────
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.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),
|
||||
state.searchError != null -> {
|
||||
ErrorState(
|
||||
message =
|
||||
state.searchError
|
||||
?: stringResource(R.string.error_unknown),
|
||||
onRetry = { viewModel.setSearchQuery(state.searchQuery) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.warning,
|
||||
}
|
||||
|
||||
state.searchResults.isEmpty() -> {
|
||||
EmptyState(
|
||||
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
|
||||
if (statsError != null) {
|
||||
Text(
|
||||
text = statsError,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.error,
|
||||
modifier = Modifier.padding(horizontal = spacing.md),
|
||||
|
||||
state.isLoading && state.sessions.isEmpty() -> {
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
val errorMsg = state.errorMessage
|
||||
ErrorState(
|
||||
message = errorMsg ?: stringResource(R.string.error_unknown),
|
||||
onRetry = { viewModel.loadSessions() },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search + bulk toggle ────────────────────────────
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = spacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = stringResource(R.string.sessions_search_placeholder),
|
||||
modifier = Modifier.weight(1f),
|
||||
state.sessions.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.history_empty_title),
|
||||
subtitle = stringResource(R.string.history_empty_desc),
|
||||
icon = Icons.Filled.History,
|
||||
)
|
||||
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 ────────────────────────────────────
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
items(filteredSessions, key = { it.session.id }) { item ->
|
||||
val session = item.session
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
depth = item.depth,
|
||||
branchStem = item.branchStem,
|
||||
query = query,
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = spacing.sm),
|
||||
contentAlignment = Alignment.Center,
|
||||
else -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Stats row ───────────────────────────────────────
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.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() },
|
||||
) {
|
||||
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,
|
||||
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),
|
||||
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
|
||||
.testTag("load_more_sessions")
|
||||
.clickable(role = Role.Button) {
|
||||
viewModel.loadMore()
|
||||
},
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = spacing.sm),
|
||||
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) ──────────────────────────────────
|
||||
AnimatedVisibility(
|
||||
visible = state.isSelecting && hasSelection,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
// ── Bulk action toolbar (animated) ──────────────────────────────────
|
||||
AnimatedVisibility(
|
||||
visible = state.isSelecting && hasSelection,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 4.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 4.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
// Select all / deselect
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (state.selectedIds.size == filteredSessions.size) {
|
||||
viewModel.clearSelection()
|
||||
} else {
|
||||
viewModel.selectAll()
|
||||
}
|
||||
},
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (state.selectedIds.size == filteredSessions.size) {
|
||||
Icons.Filled.Close
|
||||
// Select all / deselect
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (allVisibleSessionsSelected) {
|
||||
viewModel.clearSelection()
|
||||
} else {
|
||||
Icons.Filled.SelectAll
|
||||
},
|
||||
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)
|
||||
viewModel.selectAll(visibleSessionIds)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
imageVector =
|
||||
if (allVisibleSessionsSelected) {
|
||||
Icons.Filled.Close
|
||||
} else {
|
||||
Icons.Filled.SelectAll
|
||||
},
|
||||
contentDescription = null,
|
||||
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)
|
||||
@Composable
|
||||
private fun SessionCard(
|
||||
session: SessionInfo,
|
||||
session: com.m57.hermescontrol.data.model.SessionInfo,
|
||||
displayTitle: String,
|
||||
depth: Int,
|
||||
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.SessionInfo
|
||||
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.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -41,8 +43,13 @@ data class SessionsUiState(
|
||||
val toastMessage: String? = null,
|
||||
val sessionToDeleteConfirm: String? = null,
|
||||
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 isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||
}
|
||||
|
||||
class SessionsViewModel : ViewModel() {
|
||||
@@ -55,6 +62,7 @@ class SessionsViewModel : ViewModel() {
|
||||
/** Page size sent to the server — matches the default the gateway uses. */
|
||||
private companion object {
|
||||
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. */
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
it.copy(selectedIds = it.sessions.map { s -> s.id }.toSet())
|
||||
it.copy(selectedIds = sessionIds.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +358,8 @@ class SessionsViewModel : ViewModel() {
|
||||
it.copy(
|
||||
deletingSessionIds = it.deletingSessionIds - 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",
|
||||
)
|
||||
}
|
||||
@@ -348,7 +407,8 @@ class SessionsViewModel : ViewModel() {
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user