diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index a9408f2b..a96eece2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 @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 @GET("api/sessions/{id}/messages") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/AutomationSessionGroup.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/AutomationSessionGroup.kt new file mode 100644 index 00000000..226b890b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/AutomationSessionGroup.kt @@ -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, +) + +// 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): List = + 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, + ) + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index fe711a88..50f3e8f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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()) } 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 6ac54fe2..26da29e3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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.pinnedFirst(): List = sortedBy { it.pinned != true } data class SessionsUiState( + val section: HistorySection = HistorySection.CONVERSATIONS, val isLoading: Boolean = false, val isLoadingMore: Boolean = false, val sessions: List = emptyList(), @@ -107,7 +113,10 @@ class SessionsViewModel : val uiState: StateFlow = _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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51a64751..038ef2bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -581,12 +581,22 @@ Model Total Messages + Messages loaded + Conversations + Automations + No automation history + Runs started by scheduled automations will appear here. + Job %1$s + Automation run + %1$d loaded run(s) Prune + Prune all history Prune Old Sessions Delete sessions older than the specified number of days. Days threshold Prune Empty + Empty in all history Delete empty sessions? Delete %1$d empty session(s). Active and archived sessions are kept. Delete diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 5540298d..b6cb56e0 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -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() diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index 590ff0ae..c2ba8202 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -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 { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/HistorySessionGroupsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/HistorySessionGroupsTest.kt new file mode 100644 index 00000000..454a0a46 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/HistorySessionGroupsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 386d1871..9d2df0e1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -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>() + 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)),