feat(sessions): separate conversation and automation history

This commit is contained in:
AlexDev_ committed 2026-09-05 19:55:57 +02:00
1 parent fd485a6999
commit acc9c7ce9a
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 {