feat(sessions): separate conversation and automation history
This commit is contained in:
@@ -174,12 +174,16 @@ interface HermesApiService {
|
|||||||
@Query("limit") limit: Int = 20,
|
@Query("limit") limit: Int = 20,
|
||||||
@Query("offset") offset: Int = 0,
|
@Query("offset") offset: Int = 0,
|
||||||
@Query("order") order: String = "recent",
|
@Query("order") order: String = "recent",
|
||||||
|
@Query("source") source: String? = null,
|
||||||
|
@Query("exclude_sources") excludeSources: String? = null,
|
||||||
): Response<SessionListResponse>
|
): Response<SessionListResponse>
|
||||||
|
|
||||||
@GET("api/sessions/search")
|
@GET("api/sessions/search")
|
||||||
suspend fun searchSessions(
|
suspend fun searchSessions(
|
||||||
@Query("q") q: String,
|
@Query("q") q: String,
|
||||||
@Query("profile") profile: String? = null,
|
@Query("profile") profile: String? = null,
|
||||||
|
@Query("source") source: String? = null,
|
||||||
|
@Query("exclude_sources") excludeSources: String? = null,
|
||||||
): Response<SessionSearchResponse>
|
): Response<SessionSearchResponse>
|
||||||
|
|
||||||
@GET("api/sessions/{id}/messages")
|
@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.Edit
|
||||||
import androidx.compose.material.icons.filled.Email
|
import androidx.compose.material.icons.filled.Email
|
||||||
import androidx.compose.material.icons.filled.History
|
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.Language
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.Search
|
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.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -310,6 +313,7 @@ fun SessionsScreen(
|
|||||||
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
var pruneDays by remember { mutableStateOf("7") }
|
var pruneDays by remember { mutableStateOf("7") }
|
||||||
|
var expandedAutomationGroups by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
|
||||||
val sessionsToDisplay =
|
val sessionsToDisplay =
|
||||||
remember(
|
remember(
|
||||||
@@ -323,6 +327,9 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hasSelection = state.selectedIds.isNotEmpty()
|
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 =
|
val visibleSessionIds =
|
||||||
remember(sessionsToDisplay) {
|
remember(sessionsToDisplay) {
|
||||||
sessionsToDisplay.mapTo(linkedSetOf()) { it.session.id }
|
sessionsToDisplay.mapTo(linkedSetOf()) { it.session.id }
|
||||||
@@ -339,7 +346,6 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadSessions()
|
viewModel.loadSessions()
|
||||||
viewModel.loadStats()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast effect
|
// Toast effect
|
||||||
@@ -554,6 +560,27 @@ fun SessionsScreen(
|
|||||||
// (The scaffold already applies top-bar padding via its inner Box, so we
|
// (The scaffold already applies top-bar padding via its inner Box, so we
|
||||||
// must NOT re-apply paddingValues here.)
|
// must NOT re-apply paddingValues here.)
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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) ─────────────
|
// ── Search + bulk toggle (always visible) ─────────────
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -569,7 +596,9 @@ fun SessionsScreen(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
// Empty-session cleanup (issue #787) — grey/disabled when 0.
|
// This API operates on all history, so do not present it as scoped
|
||||||
|
// to the Automations tab.
|
||||||
|
if (state.section == HistorySection.CONVERSATIONS) {
|
||||||
BadgedBox(
|
BadgedBox(
|
||||||
badge = {
|
badge = {
|
||||||
if (state.emptyCount > 0) {
|
if (state.emptyCount > 0) {
|
||||||
@@ -588,7 +617,8 @@ fun SessionsScreen(
|
|||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
Text(stringResource(R.string.sessions_empty_cleanup_desc))
|
Text(stringResource(R.string.sessions_empty_cleanup_all_history))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,10 +734,29 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
state.sessions.isEmpty() -> {
|
state.sessions.isEmpty() -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.history_empty_title),
|
title =
|
||||||
subtitle = stringResource(R.string.history_empty_desc),
|
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,
|
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) },
|
onAction = { NavigationController.navigateTo(ChatScreen) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -735,18 +784,19 @@ fun SessionsScreen(
|
|||||||
) {
|
) {
|
||||||
StatCard(
|
StatCard(
|
||||||
label = stringResource(R.string.sessions_stat_total),
|
label = stringResource(R.string.sessions_stat_total),
|
||||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.total),
|
value = formatCompactCount(state.total),
|
||||||
icon = Icons.Filled.History,
|
icon = Icons.Filled.History,
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
StatCard(
|
StatCard(
|
||||||
label = stringResource(R.string.sessions_stat_messages),
|
label = stringResource(R.string.sessions_stat_messages_loaded),
|
||||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.messages),
|
value = formatCompactCount(loadedMessageCount),
|
||||||
icon = Icons.Filled.Email,
|
icon = Icons.Filled.Email,
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
// Prune button — compact card sized to its label, so the
|
// Prune button — compact card sized to its label, so the
|
||||||
// stat cards keep enough width for large counts.
|
// stat cards keep enough width for large counts.
|
||||||
|
if (state.section == HistorySection.CONVERSATIONS) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
colors =
|
colors =
|
||||||
@@ -773,7 +823,7 @@ fun SessionsScreen(
|
|||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sessions_action_prune),
|
text = stringResource(R.string.sessions_action_prune_all_history),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = statusColors.warning,
|
color = statusColors.warning,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
@@ -782,15 +832,6 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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 ────────────────────────────────────
|
// ── Session list ────────────────────────────────────
|
||||||
@@ -819,17 +860,17 @@ fun SessionsScreen(
|
|||||||
state.hasMore,
|
state.hasMore,
|
||||||
state.isSearchMode,
|
state.isSearchMode,
|
||||||
) {
|
) {
|
||||||
if (nearListEnd && !state.isLoadingMore && state.hasMore && !state.isSearchMode) {
|
if (
|
||||||
|
state.section == HistorySection.CONVERSATIONS &&
|
||||||
|
nearListEnd &&
|
||||||
|
!state.isLoadingMore &&
|
||||||
|
state.hasMore &&
|
||||||
|
!state.isSearchMode
|
||||||
|
) {
|
||||||
viewModel.loadMore()
|
viewModel.loadMore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(
|
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = listPadding,
|
|
||||||
verticalArrangement = listItemSpacing,
|
|
||||||
) {
|
|
||||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
|
||||||
val session = item.session
|
val session = item.session
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
@@ -868,6 +909,77 @@ fun SessionsScreen(
|
|||||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = listPadding,
|
||||||
|
verticalArrangement = listItemSpacing,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
// Load more
|
||||||
if (state.hasMore || state.isLoadingMore) {
|
if (state.hasMore || state.isLoadingMore) {
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ data class SessionStats(
|
|||||||
val messages: Int = 0,
|
val messages: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class HistorySection {
|
||||||
|
CONVERSATIONS,
|
||||||
|
AUTOMATIONS,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact count for stat cards: max 3 digits + unit letter.
|
* Compact count for stat cards: max 3 digits + unit letter.
|
||||||
* 987 → "987", 100987 → "100k", 1234567 → "1.23m", 100000000 → "100m".
|
* 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 }
|
private fun List<SessionInfo>.pinnedFirst(): List<SessionInfo> = sortedBy { it.pinned != true }
|
||||||
|
|
||||||
data class SessionsUiState(
|
data class SessionsUiState(
|
||||||
|
val section: HistorySection = HistorySection.CONVERSATIONS,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val sessions: List<SessionInfo> = emptyList(),
|
val sessions: List<SessionInfo> = emptyList(),
|
||||||
@@ -107,7 +113,10 @@ class SessionsViewModel :
|
|||||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
|
private var pageJob: Job? = null
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
|
private var generation: Long = 0
|
||||||
|
private var rawPaginationOffset: Int = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Issue #784: gateway broadcasts sessions.changed — refresh the list
|
// Issue #784: gateway broadcasts sessions.changed — refresh the list
|
||||||
@@ -115,15 +124,27 @@ class SessionsViewModel :
|
|||||||
refreshOnChange(
|
refreshOnChange(
|
||||||
eventType = ChangeEvents.SESSIONS,
|
eventType = ChangeEvents.SESSIONS,
|
||||||
apiCall = {
|
apiCall = {
|
||||||
|
val requestGeneration = generation
|
||||||
|
val section = _uiState.value.section
|
||||||
|
when (
|
||||||
|
val result =
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.getSessions(
|
ApiClient.hermesApi.getSessions(
|
||||||
limit = PAGE_SIZE,
|
limit = PAGE_SIZE,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
order = "recent",
|
order = "recent",
|
||||||
|
source = section.source,
|
||||||
|
excludeSources = section.excludeSources,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data)
|
||||||
|
is NetworkResult.Failure -> result
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess = { data ->
|
onSuccess = { (requestGeneration, data) ->
|
||||||
|
if (requestGeneration == generation) {
|
||||||
|
rawPaginationOffset = data.nextOffset(0)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||||
@@ -133,6 +154,7 @@ class SessionsViewModel :
|
|||||||
hasMore = hasMore,
|
hasMore = hasMore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -146,8 +168,47 @@ class SessionsViewModel :
|
|||||||
const val SEARCH_DEBOUNCE_MS = 300L
|
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. */
|
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||||
fun loadSessions() {
|
fun loadSessions() {
|
||||||
|
val requestGeneration = generation
|
||||||
|
val section = _uiState.value.section
|
||||||
|
pageJob?.cancel()
|
||||||
loadEmptyCount()
|
loadEmptyCount()
|
||||||
loadJob =
|
loadJob =
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
@@ -158,11 +219,15 @@ class SessionsViewModel :
|
|||||||
limit = PAGE_SIZE,
|
limit = PAGE_SIZE,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
order = "recent",
|
order = "recent",
|
||||||
|
source = section.source,
|
||||||
|
excludeSources = section.excludeSources,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
|
if (requestGeneration != generation) return@safeLaunchLoad
|
||||||
|
rawPaginationOffset = data.nextOffset(0)
|
||||||
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -177,6 +242,7 @@ class SessionsViewModel :
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { errorMsg ->
|
onError = { errorMsg ->
|
||||||
|
if (requestGeneration != generation) return@safeLaunchLoad
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -191,21 +257,28 @@ class SessionsViewModel :
|
|||||||
/** Load the next page and append to the existing session list. */
|
/** Load the next page and append to the existing session list. */
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
val state = _uiState.value
|
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) }
|
_uiState.update { it.copy(isLoadingMore = true) }
|
||||||
|
pageJob =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.getSessions(
|
ApiClient.hermesApi.getSessions(
|
||||||
limit = PAGE_SIZE,
|
limit = PAGE_SIZE,
|
||||||
offset = state.sessions.size,
|
offset = offset,
|
||||||
order = "recent",
|
order = "recent",
|
||||||
|
source = state.section.source,
|
||||||
|
excludeSources = state.section.excludeSources,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (requestGeneration != generation) return@launch
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val data = result.data
|
val data = result.data
|
||||||
|
rawPaginationOffset = data.nextOffset(offset)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
val newSessions =
|
val newSessions =
|
||||||
(it.sessions + data.sessions)
|
(it.sessions + data.sessions)
|
||||||
@@ -245,12 +318,15 @@ class SessionsViewModel :
|
|||||||
* paginated list mode.
|
* paginated list mode.
|
||||||
*/
|
*/
|
||||||
fun setSearchQuery(query: String) {
|
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()
|
searchJob?.cancel()
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(searchResults = emptyList(), searchError = null, isSearching = false)
|
it.copy(searchResults = emptyList(), searchError = null, isSearching = false)
|
||||||
}
|
}
|
||||||
|
if (_uiState.value.sessions.isEmpty()) loadSessions()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
searchJob =
|
searchJob =
|
||||||
@@ -259,8 +335,14 @@ class SessionsViewModel :
|
|||||||
_uiState.update { it.copy(isSearching = true, searchError = null) }
|
_uiState.update { it.copy(isSearching = true, searchError = null) }
|
||||||
val result =
|
val result =
|
||||||
safeApiCall {
|
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) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
|||||||
@@ -581,12 +581,22 @@
|
|||||||
<string name="sessions_search_model_label">Model</string>
|
<string name="sessions_search_model_label">Model</string>
|
||||||
<string name="sessions_stat_total">Total</string>
|
<string name="sessions_stat_total">Total</string>
|
||||||
<string name="sessions_stat_messages">Messages</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">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_title">Prune Old Sessions</string>
|
||||||
<string name="sessions_prune_desc">Delete sessions older than the specified number of days.</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_days_label">Days threshold</string>
|
||||||
<string name="sessions_prune_confirm">Prune</string>
|
<string name="sessions_prune_confirm">Prune</string>
|
||||||
<string name="sessions_empty_cleanup_desc">Empty</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_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_message">Delete %1$d empty session(s). Active and archived sessions are kept.</string>
|
||||||
<string name="sessions_empty_cleanup_confirm">Delete</string>
|
<string name="sessions_empty_cleanup_confirm">Delete</string>
|
||||||
|
|||||||
@@ -629,7 +629,7 @@ class E2eIntegrationTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
|
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApiService.getSessions(any(), any(), any())
|
mockApiService.getSessions(any(), any(), any(), null, "cron")
|
||||||
} returns Response.success(SessionListResponse(listOf(session)))
|
} returns Response.success(SessionListResponse(listOf(session)))
|
||||||
|
|
||||||
val viewModel = SessionsViewModel()
|
val viewModel = SessionsViewModel()
|
||||||
@@ -665,7 +665,7 @@ class E2eIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testSessionsLoad_failure() =
|
fun testSessionsLoad_failure() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { mockApiService.getSessions(any(), any(), any()) } returns createErrorResponse(500)
|
coEvery { mockApiService.getSessions(any(), any(), any(), null, "cron") } returns createErrorResponse(500)
|
||||||
|
|
||||||
val viewModel = SessionsViewModel()
|
val viewModel = SessionsViewModel()
|
||||||
viewModel.loadSessions()
|
viewModel.loadSessions()
|
||||||
|
|||||||
+23
@@ -205,6 +205,29 @@ class HermesApiServiceMockWebServerTest {
|
|||||||
assertEquals(0, body.total)
|
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
|
@Test
|
||||||
fun getSessionMessages_parsesResponse() =
|
fun getSessionMessages_parsesResponse() =
|
||||||
runBlocking {
|
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.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
import io.mockk.coEvery
|
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) ──────────────────────────────────────
|
// ── Pagination (fluid load-more) ──────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -106,7 +229,7 @@ class SessionsViewModelTest {
|
|||||||
|
|
||||||
// Server reports total = 10 (e.g. cross-profile count), but returns only 2 sessions (< PAGE_SIZE 50).
|
// 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.
|
// 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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
||||||
@@ -132,7 +255,15 @@ class SessionsViewModelTest {
|
|||||||
|
|
||||||
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
|
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
|
||||||
// Page 1: 50 sessions of 51 total — hasMore stays true.
|
// 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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = page1Sessions,
|
sessions = page1Sessions,
|
||||||
@@ -147,7 +278,15 @@ class SessionsViewModelTest {
|
|||||||
|
|
||||||
// Page 2 overlaps page 1 (offset churn: a new session landed on top
|
// Page 2 overlaps page 1 (offset churn: a new session landed on top
|
||||||
// between loads) — the duplicate id must not double-append.
|
// 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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")),
|
sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")),
|
||||||
@@ -168,7 +307,15 @@ class SessionsViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
|
|
||||||
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
|
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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = page1Sessions,
|
sessions = page1Sessions,
|
||||||
@@ -185,7 +332,7 @@ class SessionsViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
// loadSessions (1) + exactly one loadMore (1) = 2 API hits total.
|
// 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)
|
assertFalse(vm.uiState.value.isLoadingMore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +343,7 @@ class SessionsViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
// Backend returns recency order with the pinned flag set; the client
|
// Backend returns recency order with the pinned flag set; the client
|
||||||
// must lift pins above the rest while keeping the rest's order.
|
// 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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions =
|
sessions =
|
||||||
@@ -220,7 +367,7 @@ class SessionsViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `togglePin moves the session to the top and sets the flag`() {
|
fun `togglePin moves the session to the top and sets the flag`() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
||||||
@@ -251,7 +398,7 @@ class SessionsViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `togglePin failure keeps the order and surfaces a toast`() {
|
fun `togglePin failure keeps the order and surfaces a toast`() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-1", pinned = true), SessionInfo("s-2")),
|
sessions = listOf(SessionInfo("s-1", pinned = true), SessionInfo("s-2")),
|
||||||
@@ -285,7 +432,7 @@ class SessionsViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
|
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions =
|
sessions =
|
||||||
@@ -328,7 +475,7 @@ class SessionsViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `toggleHide updates hidden status and surfaces toast`() {
|
fun `toggleHide updates hidden status and surfaces toast`() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-1", hidden = false)),
|
sessions = listOf(SessionInfo("s-1", hidden = false)),
|
||||||
|
|||||||
Reference in New Issue
Block a user