feat(sessions): separate conversation and automation history
This commit is contained in:
9 files changed
+628
-166
No files matched your search
@@ -174,12 +174,16 @@ interface HermesApiService {
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("offset") offset: Int = 0,
|
||||
@Query("order") order: String = "recent",
|
||||
@Query("source") source: String? = null,
|
||||
@Query("exclude_sources") excludeSources: String? = null,
|
||||
): Response<SessionListResponse>
|
||||
|
||||
@GET("api/sessions/search")
|
||||
suspend fun searchSessions(
|
||||
@Query("q") q: String,
|
||||
@Query("profile") profile: String? = null,
|
||||
@Query("source") source: String? = null,
|
||||
@Query("exclude_sources") excludeSources: String? = null,
|
||||
): Response<SessionSearchResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
|
||||
internal data class AutomationSessionGroup(
|
||||
val key: String,
|
||||
val jobId: String?,
|
||||
val sessions: List<SessionInfo>,
|
||||
)
|
||||
|
||||
// cron.scheduler mints durable run IDs as cron_{job_id}_{YYYYMMDD_HHMMSS}.
|
||||
// Never infer job identity from a title: different jobs may have the same name.
|
||||
private val CRON_RUN_ID = Regex("^cron_(.+)_[0-9]{8}_[0-9]{6}$")
|
||||
|
||||
internal fun automationGroups(sessions: List<SessionInfo>): List<AutomationSessionGroup> =
|
||||
sessions
|
||||
.distinctBy { it.id }
|
||||
.groupBy { session ->
|
||||
val jobId =
|
||||
if (session.source == "cron") CRON_RUN_ID.matchEntire(session.id)?.groupValues?.get(1) else null
|
||||
jobId?.let { "job:$it" } ?: "session:${session.id}"
|
||||
}.map { (key, runs) ->
|
||||
AutomationSessionGroup(
|
||||
key = key,
|
||||
jobId = key.takeIf { it.startsWith("job:") }?.removePrefix("job:"),
|
||||
sessions = runs,
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -60,6 +62,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -310,6 +313,7 @@ fun SessionsScreen(
|
||||
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
var pruneDays by remember { mutableStateOf("7") }
|
||||
var expandedAutomationGroups by remember { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
val sessionsToDisplay =
|
||||
remember(
|
||||
@@ -323,6 +327,9 @@ fun SessionsScreen(
|
||||
}
|
||||
|
||||
val hasSelection = state.selectedIds.isNotEmpty()
|
||||
val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 }
|
||||
val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) }
|
||||
val sessionItemsById = remember(sessionsToDisplay) { sessionsToDisplay.associateBy { it.session.id } }
|
||||
val visibleSessionIds =
|
||||
remember(sessionsToDisplay) {
|
||||
sessionsToDisplay.mapTo(linkedSetOf()) { it.session.id }
|
||||
@@ -339,7 +346,6 @@ fun SessionsScreen(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSessions()
|
||||
viewModel.loadStats()
|
||||
}
|
||||
|
||||
// Toast effect
|
||||
@@ -554,6 +560,27 @@ fun SessionsScreen(
|
||||
// (The scaffold already applies top-bar padding via its inner Box, so we
|
||||
// must NOT re-apply paddingValues here.)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.section == HistorySection.CONVERSATIONS,
|
||||
onClick = { viewModel.selectSection(HistorySection.CONVERSATIONS) },
|
||||
label = { Text(stringResource(R.string.sessions_tab_conversations)) },
|
||||
modifier = Modifier.testTag("history_tab_conversations"),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.section == HistorySection.AUTOMATIONS,
|
||||
onClick = { viewModel.selectSection(HistorySection.AUTOMATIONS) },
|
||||
label = { Text(stringResource(R.string.sessions_tab_automations)) },
|
||||
modifier = Modifier.testTag("history_tab_automations"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search + bulk toggle (always visible) ─────────────
|
||||
Row(
|
||||
modifier =
|
||||
@@ -569,26 +596,29 @@ fun SessionsScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
// Empty-session cleanup (issue #787) — grey/disabled when 0.
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (state.emptyCount > 0) {
|
||||
Badge { Text("${state.emptyCount}") }
|
||||
}
|
||||
},
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requestEmptyCleanup() },
|
||||
enabled = state.emptyCount > 0,
|
||||
contentPadding = PaddingValues(horizontal = spacing.md, vertical = 8.dp),
|
||||
// This API operates on all history, so do not present it as scoped
|
||||
// to the Automations tab.
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (state.emptyCount > 0) {
|
||||
Badge { Text("${state.emptyCount}") }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.sessions_empty_cleanup_desc))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requestEmptyCleanup() },
|
||||
enabled = state.emptyCount > 0,
|
||||
contentPadding = PaddingValues(horizontal = spacing.md, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.sessions_empty_cleanup_all_history))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,10 +734,29 @@ fun SessionsScreen(
|
||||
|
||||
state.sessions.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.history_empty_title),
|
||||
subtitle = stringResource(R.string.history_empty_desc),
|
||||
title =
|
||||
stringResource(
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
R.string.sessions_automations_empty_title
|
||||
} else {
|
||||
R.string.history_empty_title
|
||||
},
|
||||
),
|
||||
subtitle =
|
||||
stringResource(
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
R.string.sessions_automations_empty_desc
|
||||
} else {
|
||||
R.string.history_empty_desc
|
||||
},
|
||||
),
|
||||
icon = Icons.Filled.History,
|
||||
actionLabel = stringResource(R.string.empty_action_start_chat),
|
||||
actionLabel =
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
stringResource(R.string.empty_action_start_chat)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onAction = { NavigationController.navigateTo(ChatScreen) },
|
||||
)
|
||||
}
|
||||
@@ -735,63 +784,55 @@ fun SessionsScreen(
|
||||
) {
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_total),
|
||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.total),
|
||||
value = formatCompactCount(state.total),
|
||||
icon = Icons.Filled.History,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_messages),
|
||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.messages),
|
||||
label = stringResource(R.string.sessions_stat_messages_loaded),
|
||||
value = formatCompactCount(loadedMessageCount),
|
||||
icon = Icons.Filled.Email,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
// Prune button — compact card sized to its label, so the
|
||||
// stat cards keep enough width for large counts.
|
||||
Card(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
onClick = { viewModel.showPruneDialog() },
|
||||
) {
|
||||
Box(
|
||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
||||
// An unweighted Row child with fillMaxSize expands to the
|
||||
// full row width and starves the weighted stat cards.
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
||||
contentAlignment = Alignment.Center,
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
onClick = { viewModel.showPruneDialog() },
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
Box(
|
||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
||||
// An unweighted Row child with fillMaxSize expands to the
|
||||
// full row width and starves the weighted stat cards.
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune_all_history),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 ────────────────────────────────────
|
||||
val listState = rememberLazyListState()
|
||||
@@ -819,54 +860,125 @@ fun SessionsScreen(
|
||||
state.hasMore,
|
||||
state.isSearchMode,
|
||||
) {
|
||||
if (nearListEnd && !state.isLoadingMore && state.hasMore && !state.isSearchMode) {
|
||||
if (
|
||||
state.section == HistorySection.CONVERSATIONS &&
|
||||
nearListEnd &&
|
||||
!state.isLoadingMore &&
|
||||
state.hasMore &&
|
||||
!state.isSearchMode
|
||||
) {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
}
|
||||
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||
val session = item.session
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
branchStem = item.branchStem,
|
||||
isFork = item.isFork,
|
||||
forkDepth = item.forkDepth,
|
||||
query = state.searchQuery,
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.openChatSession(session.id)
|
||||
}
|
||||
},
|
||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||
onSelect = {
|
||||
viewModel.toggleSelecting()
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
},
|
||||
onRename = {
|
||||
viewModel.openRenameDialog(
|
||||
session.id,
|
||||
item.displayTitle,
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||
val session = item.session
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
branchStem = item.branchStem,
|
||||
isFork = item.isFork,
|
||||
forkDepth = item.forkDepth,
|
||||
query = state.searchQuery,
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.openChatSession(session.id)
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
automationSessionGroups.forEach { group ->
|
||||
item(key = "automation-group:${group.key}") {
|
||||
val expanded = group.key in expandedAutomationGroups
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
expandedAutomationGroups =
|
||||
if (expanded) {
|
||||
expandedAutomationGroups - group.key
|
||||
} else {
|
||||
expandedAutomationGroups + group.key
|
||||
}
|
||||
}.padding(horizontal = spacing.sm, vertical = spacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (expanded) {
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
} else {
|
||||
Icons.Filled.KeyboardArrowRight
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Column {
|
||||
Text(
|
||||
text =
|
||||
group.jobId?.let {
|
||||
stringResource(
|
||||
R.string.sessions_section_automation_job,
|
||||
it,
|
||||
)
|
||||
} ?: stringResource(
|
||||
R.string.sessions_section_automation_unknown,
|
||||
),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.sessions_section_automation_runs,
|
||||
group.sessions.size,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||
onSelect = {
|
||||
viewModel.toggleSelecting()
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
},
|
||||
onRename = {
|
||||
viewModel.openRenameDialog(
|
||||
session.id,
|
||||
item.displayTitle,
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
if (group.key in expandedAutomationGroups) {
|
||||
items(group.sessions, key = { it.id }) { session ->
|
||||
sessionItemsById[session.id]?.let { sessionCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||
sessionCard(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Load more
|
||||
|
||||
@@ -28,6 +28,11 @@ data class SessionStats(
|
||||
val messages: Int = 0,
|
||||
)
|
||||
|
||||
enum class HistorySection {
|
||||
CONVERSATIONS,
|
||||
AUTOMATIONS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact count for stat cards: max 3 digits + unit letter.
|
||||
* 987 → "987", 100987 → "100k", 1234567 → "1.23m", 100000000 → "100m".
|
||||
@@ -60,6 +65,7 @@ private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.
|
||||
private fun List<SessionInfo>.pinnedFirst(): List<SessionInfo> = sortedBy { it.pinned != true }
|
||||
|
||||
data class SessionsUiState(
|
||||
val section: HistorySection = HistorySection.CONVERSATIONS,
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val sessions: List<SessionInfo> = emptyList(),
|
||||
@@ -107,7 +113,10 @@ class SessionsViewModel :
|
||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var pageJob: Job? = null
|
||||
private var statsJob: Job? = null
|
||||
private var generation: Long = 0
|
||||
private var rawPaginationOffset: Int = 0
|
||||
|
||||
init {
|
||||
// Issue #784: gateway broadcasts sessions.changed — refresh the list
|
||||
@@ -115,23 +124,36 @@ class SessionsViewModel :
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.SESSIONS,
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
)
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
when (
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
)
|
||||
}
|
||||
) {
|
||||
is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data)
|
||||
is NetworkResult.Failure -> result
|
||||
}
|
||||
},
|
||||
onSuccess = { data ->
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||
it.copy(
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
onSuccess = { (requestGeneration, data) ->
|
||||
if (requestGeneration == generation) {
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||
it.copy(
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -146,8 +168,47 @@ class SessionsViewModel :
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
}
|
||||
|
||||
private val HistorySection.source: String?
|
||||
get() = if (this == HistorySection.AUTOMATIONS) "cron" else null
|
||||
|
||||
private val HistorySection.excludeSources: String?
|
||||
get() = if (this == HistorySection.CONVERSATIONS) "cron" else null
|
||||
|
||||
private fun com.m57.hermescontrol.data.model.SessionListResponse.nextOffset(requestOffset: Int): Int =
|
||||
if (limit > 0) offset + limit else requestOffset + PAGE_SIZE
|
||||
|
||||
fun selectSection(section: HistorySection) {
|
||||
if (_uiState.value.section == section) return
|
||||
generation++
|
||||
loadJob?.cancel()
|
||||
pageJob?.cancel()
|
||||
searchJob?.cancel()
|
||||
rawPaginationOffset = 0
|
||||
val query = _uiState.value.searchQuery
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
section = section,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
sessions = emptyList(),
|
||||
total = 0,
|
||||
hasMore = false,
|
||||
errorMessage = null,
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
isSearching = false,
|
||||
searchResults = emptyList(),
|
||||
searchError = null,
|
||||
)
|
||||
}
|
||||
if (query.isBlank()) loadSessions() else setSearchQuery(query)
|
||||
}
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions() {
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
pageJob?.cancel()
|
||||
loadEmptyCount()
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
@@ -158,11 +219,15 @@ class SessionsViewModel :
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
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
|
||||
_uiState.update {
|
||||
@@ -177,6 +242,7 @@ class SessionsViewModel :
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
@@ -191,48 +257,55 @@ class SessionsViewModel :
|
||||
/** Load the next page and append to the existing session list. */
|
||||
fun loadMore() {
|
||||
val state = _uiState.value
|
||||
if (state.isLoadingMore || !state.hasMore) return
|
||||
if (state.isLoading || state.isLoadingMore || !state.hasMore || state.isSearchMode) return
|
||||
val requestGeneration = generation
|
||||
val offset = rawPaginationOffset
|
||||
|
||||
_uiState.update { it.copy(isLoadingMore = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = state.sessions.size,
|
||||
order = "recent",
|
||||
)
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
_uiState.update {
|
||||
val newSessions =
|
||||
(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
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
pageJob =
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = offset,
|
||||
order = "recent",
|
||||
source = state.section.source,
|
||||
excludeSources = state.section.excludeSources,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (requestGeneration != generation) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
rawPaginationOffset = data.nextOffset(offset)
|
||||
_uiState.update {
|
||||
val newSessions =
|
||||
(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
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load more: ${result.error.message}",
|
||||
)
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load more: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search (server-backed FTS5) ──────────────────────────────────
|
||||
@@ -245,12 +318,15 @@ class SessionsViewModel :
|
||||
* paginated list mode.
|
||||
*/
|
||||
fun setSearchQuery(query: String) {
|
||||
_uiState.update { it.copy(searchQuery = query) }
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
_uiState.update { it.copy(searchQuery = query, searchResults = emptyList()) }
|
||||
searchJob?.cancel()
|
||||
if (query.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(searchResults = emptyList(), searchError = null, isSearching = false)
|
||||
}
|
||||
if (_uiState.value.sessions.isEmpty()) loadSessions()
|
||||
return
|
||||
}
|
||||
searchJob =
|
||||
@@ -259,8 +335,14 @@ class SessionsViewModel :
|
||||
_uiState.update { it.copy(isSearching = true, searchError = null) }
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.searchSessions(q = query, profile = null)
|
||||
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 {
|
||||
|
||||
@@ -581,12 +581,22 @@
|
||||
<string name="sessions_search_model_label">Model</string>
|
||||
<string name="sessions_stat_total">Total</string>
|
||||
<string name="sessions_stat_messages">Messages</string>
|
||||
<string name="sessions_stat_messages_loaded">Messages loaded</string>
|
||||
<string name="sessions_tab_conversations">Conversations</string>
|
||||
<string name="sessions_tab_automations">Automations</string>
|
||||
<string name="sessions_automations_empty_title">No automation history</string>
|
||||
<string name="sessions_automations_empty_desc">Runs started by scheduled automations will appear here.</string>
|
||||
<string name="sessions_section_automation_job">Job %1$s</string>
|
||||
<string name="sessions_section_automation_unknown">Automation run</string>
|
||||
<string name="sessions_section_automation_runs">%1$d loaded run(s)</string>
|
||||
<string name="sessions_action_prune">Prune</string>
|
||||
<string name="sessions_action_prune_all_history">Prune all history</string>
|
||||
<string name="sessions_prune_title">Prune Old Sessions</string>
|
||||
<string name="sessions_prune_desc">Delete sessions older than the specified number of days.</string>
|
||||
<string name="sessions_prune_days_label">Days threshold</string>
|
||||
<string name="sessions_prune_confirm">Prune</string>
|
||||
<string name="sessions_empty_cleanup_desc">Empty</string>
|
||||
<string name="sessions_empty_cleanup_all_history">Empty in all history</string>
|
||||
<string name="sessions_empty_cleanup_title">Delete empty sessions?</string>
|
||||
<string name="sessions_empty_cleanup_message">Delete %1$d empty session(s). Active and archived sessions are kept.</string>
|
||||
<string name="sessions_empty_cleanup_confirm">Delete</string>
|
||||
|
||||
@@ -629,7 +629,7 @@ class E2eIntegrationTest {
|
||||
runTest {
|
||||
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
|
||||
coEvery {
|
||||
mockApiService.getSessions(any(), any(), any())
|
||||
mockApiService.getSessions(any(), any(), any(), null, "cron")
|
||||
} returns Response.success(SessionListResponse(listOf(session)))
|
||||
|
||||
val viewModel = SessionsViewModel()
|
||||
@@ -665,7 +665,7 @@ class E2eIntegrationTest {
|
||||
@Test
|
||||
fun testSessionsLoad_failure() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSessions(any(), any(), any()) } returns createErrorResponse(500)
|
||||
coEvery { mockApiService.getSessions(any(), any(), any(), null, "cron") } returns createErrorResponse(500)
|
||||
|
||||
val viewModel = SessionsViewModel()
|
||||
viewModel.loadSessions()
|
||||
|
||||
+23
@@ -205,6 +205,29 @@ class HermesApiServiceMockWebServerTest {
|
||||
assertEquals(0, body.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun historyFiltersAreSentOnListAndSearch() =
|
||||
runBlocking {
|
||||
mockServer.enqueue(MockResponse().setBody("""{"sessions":[],"total":0}"""))
|
||||
api.getSessions(source = "cron", offset = 50)
|
||||
val automationRequest = mockServer.takeRequest().requestUrl!!
|
||||
assertEquals("cron", automationRequest.queryParameter("source"))
|
||||
assertEquals("50", automationRequest.queryParameter("offset"))
|
||||
assertEquals(null, automationRequest.queryParameter("exclude_sources"))
|
||||
|
||||
mockServer.enqueue(MockResponse().setBody("""{"sessions":[],"total":0}"""))
|
||||
api.getSessions(excludeSources = "cron")
|
||||
assertEquals("cron", mockServer.takeRequest().requestUrl!!.queryParameter("exclude_sources"))
|
||||
|
||||
mockServer.enqueue(MockResponse().setBody("""{"results":[]}"""))
|
||||
api.searchSessions(q = "test", source = "cron")
|
||||
assertEquals("cron", mockServer.takeRequest().requestUrl!!.queryParameter("source"))
|
||||
|
||||
mockServer.enqueue(MockResponse().setBody("""{"results":[]}"""))
|
||||
api.searchSessions(q = "test", excludeSources = "cron")
|
||||
assertEquals("cron", mockServer.takeRequest().requestUrl!!.queryParameter("exclude_sources"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSessionMessages_parsesResponse() =
|
||||
runBlocking {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class HistorySessionGroupsTest {
|
||||
@Test
|
||||
fun groupsDurableJobIdsNotTitles() {
|
||||
val groups =
|
||||
automationGroups(
|
||||
listOf(
|
||||
SessionInfo("cron_job_one_20260905_100000", title = "Same", source = "cron"),
|
||||
SessionInfo("cron_job_two_20260905_100000", title = "Same", source = "cron"),
|
||||
SessionInfo("cron_job_one_20260905_110000", title = "Renamed", source = "cron"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("job_one", "job_two"), groups.map { it.jobId })
|
||||
assertEquals(2, groups.first().sessions.size)
|
||||
assertEquals(
|
||||
"cron_job_one_20260905_110000",
|
||||
groups
|
||||
.first()
|
||||
.sessions
|
||||
.last()
|
||||
.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRunIdsRemainIndependentlyAccessible() {
|
||||
val groups =
|
||||
automationGroups(
|
||||
listOf(
|
||||
SessionInfo("legacy-a", title = "Same", source = "cron"),
|
||||
SessionInfo("legacy-b", title = "Same", source = "cron"),
|
||||
),
|
||||
)
|
||||
assertEquals(2, groups.size)
|
||||
assertNull(groups.first().jobId)
|
||||
assertEquals(listOf("legacy-a", "legacy-b"), groups.flatMap { it.sessions }.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedPagePinsDoNotDuplicateRuns() {
|
||||
val run = SessionInfo("cron_job_20260905_100000", source = "cron", pinned = true)
|
||||
assertEquals(1, automationGroups(listOf(run, run)).single().sessions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cronLikeConversationIdDoesNotInventAJob() {
|
||||
val groups = automationGroups(listOf(SessionInfo("cron_job_20260905_100000", source = "cli")))
|
||||
assertNull(groups.single().jobId)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResponse
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.coEvery
|
||||
@@ -98,6 +100,127 @@ class SessionsViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sections request conversations excluding cron and automations from cron`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(50, 0, any(), null, "cron") } returns
|
||||
Response.success(SessionListResponse(listOf(SessionInfo("conversation"))))
|
||||
coEvery { mockApi.getSessions(50, 0, any(), "cron", null) } returns
|
||||
Response.success(SessionListResponse(listOf(SessionInfo("automation", source = "cron"))))
|
||||
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
vm.toggleSelecting()
|
||||
vm.toggleSessionSelection("conversation")
|
||||
|
||||
vm.selectSection(HistorySection.AUTOMATIONS)
|
||||
assertFalse(vm.uiState.value.isSelecting)
|
||||
assertTrue(
|
||||
vm.uiState.value.selectedIds
|
||||
.isEmpty(),
|
||||
)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
listOf("automation"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
coVerify(exactly = 1) { mockApi.getSessions(50, 0, any(), null, "cron") }
|
||||
coVerify(exactly = 1) { mockApi.getSessions(50, 0, any(), "cron", null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `section change clears old results and repeats query in the new scope`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.searchSessions("deploy", null, null, "cron") } returns
|
||||
Response.success(
|
||||
SessionSearchResponse(listOf(SessionSearchResult(session_id = "conversation-hit"))),
|
||||
)
|
||||
coEvery { mockApi.searchSessions("deploy", null, "cron", null) } returns
|
||||
Response.success(
|
||||
SessionSearchResponse(listOf(SessionSearchResult(session_id = "automation-hit", source = "cron"))),
|
||||
)
|
||||
|
||||
vm.setSearchQuery("deploy")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(
|
||||
"conversation-hit",
|
||||
vm.uiState.value.searchResults
|
||||
.single()
|
||||
.session_id,
|
||||
)
|
||||
vm.selectAll(setOf("conversation-hit"))
|
||||
|
||||
vm.selectSection(HistorySection.AUTOMATIONS)
|
||||
assertEquals("deploy", vm.uiState.value.searchQuery)
|
||||
assertTrue(
|
||||
vm.uiState.value.searchResults
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.selectedIds
|
||||
.isEmpty(),
|
||||
)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
"automation-hit",
|
||||
vm.uiState.value.searchResults
|
||||
.single()
|
||||
.session_id,
|
||||
)
|
||||
coVerify(exactly = 1) { mockApi.searchSessions("deploy", null, null, "cron") }
|
||||
coVerify(exactly = 1) { mockApi.searchSessions("deploy", null, "cron", null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `automation pagination keeps the source filter and combines runs by job`() {
|
||||
val vm = createViewModel()
|
||||
val firstPage = (0 until 50).map { SessionInfo("cron_job-a_20260905_1800%02d".format(it), source = "cron") }
|
||||
val lastRun = SessionInfo("cron_job-a_20260905_190000", source = "cron")
|
||||
coEvery { mockApi.getSessions(50, 0, any(), "cron", null) } returns
|
||||
Response.success(SessionListResponse(firstPage, total = 51, limit = 50))
|
||||
coEvery { mockApi.getSessions(50, 50, any(), "cron", null) } returns
|
||||
Response.success(SessionListResponse(listOf(lastRun), total = 51, limit = 50, offset = 50))
|
||||
|
||||
vm.selectSection(HistorySection.AUTOMATIONS)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertTrue(vm.uiState.value.hasMore)
|
||||
vm.loadMore()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertFalse(vm.uiState.value.hasMore)
|
||||
assertEquals(51, vm.uiState.value.total)
|
||||
assertEquals(firstPage + lastRun, automationGroups(vm.uiState.value.sessions).single().sessions)
|
||||
coVerify(exactly = 1) { mockApi.getSessions(50, 50, any(), "cron", null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late load from a previous section cannot replace the current rows`() {
|
||||
val vm = createViewModel()
|
||||
val oldResponse = kotlinx.coroutines.CompletableDeferred<Response<SessionListResponse>>()
|
||||
coEvery { mockApi.getSessions(50, 0, any(), null, "cron") } coAnswers {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { oldResponse.await() }
|
||||
}
|
||||
coEvery { mockApi.getSessions(50, 0, any(), "cron", null) } returns
|
||||
Response.success(SessionListResponse(listOf(SessionInfo("automation", source = "cron"))))
|
||||
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
vm.selectSection(HistorySection.AUTOMATIONS)
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
oldResponse.complete(Response.success(SessionListResponse(listOf(SessionInfo("old-conversation")))))
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(HistorySection.AUTOMATIONS, vm.uiState.value.section)
|
||||
assertEquals(
|
||||
listOf("automation"),
|
||||
vm.uiState.value.sessions
|
||||
.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pagination (fluid load-more) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -106,7 +229,7 @@ class SessionsViewModelTest {
|
||||
|
||||
// Server reports total = 10 (e.g. cross-profile count), but returns only 2 sessions (< PAGE_SIZE 50).
|
||||
// hasMore must be false and total must be capped at 2 to prevent infinite auto-load loops.
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
||||
@@ -132,7 +255,15 @@ class SessionsViewModelTest {
|
||||
|
||||
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
|
||||
// Page 1: 50 sessions of 51 total — hasMore stays true.
|
||||
coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns
|
||||
coEvery {
|
||||
mockApi.getSessions(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
order = any(),
|
||||
source = null,
|
||||
excludeSources = "cron",
|
||||
)
|
||||
} returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = page1Sessions,
|
||||
@@ -147,7 +278,15 @@ class SessionsViewModelTest {
|
||||
|
||||
// Page 2 overlaps page 1 (offset churn: a new session landed on top
|
||||
// between loads) — the duplicate id must not double-append.
|
||||
coEvery { mockApi.getSessions(limit = 50, offset = 50, order = any()) } returns
|
||||
coEvery {
|
||||
mockApi.getSessions(
|
||||
limit = 50,
|
||||
offset = 50,
|
||||
order = any(),
|
||||
source = null,
|
||||
excludeSources = "cron",
|
||||
)
|
||||
} returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")),
|
||||
@@ -168,7 +307,15 @@ class SessionsViewModelTest {
|
||||
val vm = createViewModel()
|
||||
|
||||
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
|
||||
coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns
|
||||
coEvery {
|
||||
mockApi.getSessions(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
order = any(),
|
||||
source = null,
|
||||
excludeSources = "cron",
|
||||
)
|
||||
} returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = page1Sessions,
|
||||
@@ -185,7 +332,7 @@ class SessionsViewModelTest {
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// loadSessions (1) + exactly one loadMore (1) = 2 API hits total.
|
||||
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) }
|
||||
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any(), null, "cron") }
|
||||
assertFalse(vm.uiState.value.isLoadingMore)
|
||||
}
|
||||
|
||||
@@ -196,7 +343,7 @@ class SessionsViewModelTest {
|
||||
val vm = createViewModel()
|
||||
// Backend returns recency order with the pinned flag set; the client
|
||||
// must lift pins above the rest while keeping the rest's order.
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions =
|
||||
@@ -220,7 +367,7 @@ class SessionsViewModelTest {
|
||||
@Test
|
||||
fun `togglePin moves the session to the top and sets the flag`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
||||
@@ -251,7 +398,7 @@ class SessionsViewModelTest {
|
||||
@Test
|
||||
fun `togglePin failure keeps the order and surfaces a toast`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1", pinned = true), SessionInfo("s-2")),
|
||||
@@ -285,7 +432,7 @@ class SessionsViewModelTest {
|
||||
@Test
|
||||
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions =
|
||||
@@ -328,7 +475,7 @@ class SessionsViewModelTest {
|
||||
@Test
|
||||
fun `toggleHide updates hidden status and surfaces toast`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1", hidden = false)),
|
||||
|
||||
Reference in new issue
Block a user