feat(sessions): separate conversation and automation history
This commit is contained in:
9 files changed
+628
-166
No files matched your search
@@ -174,12 +174,16 @@ interface HermesApiService {
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("offset") offset: Int = 0,
|
||||
@Query("order") order: String = "recent",
|
||||
@Query("source") source: String? = null,
|
||||
@Query("exclude_sources") excludeSources: String? = null,
|
||||
): Response<SessionListResponse>
|
||||
|
||||
@GET("api/sessions/search")
|
||||
suspend fun searchSessions(
|
||||
@Query("q") q: String,
|
||||
@Query("profile") profile: String? = null,
|
||||
@Query("source") source: String? = null,
|
||||
@Query("exclude_sources") excludeSources: String? = null,
|
||||
): Response<SessionSearchResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
|
||||
internal data class AutomationSessionGroup(
|
||||
val key: String,
|
||||
val jobId: String?,
|
||||
val sessions: List<SessionInfo>,
|
||||
)
|
||||
|
||||
// cron.scheduler mints durable run IDs as cron_{job_id}_{YYYYMMDD_HHMMSS}.
|
||||
// Never infer job identity from a title: different jobs may have the same name.
|
||||
private val CRON_RUN_ID = Regex("^cron_(.+)_[0-9]{8}_[0-9]{6}$")
|
||||
|
||||
internal fun automationGroups(sessions: List<SessionInfo>): List<AutomationSessionGroup> =
|
||||
sessions
|
||||
.distinctBy { it.id }
|
||||
.groupBy { session ->
|
||||
val jobId =
|
||||
if (session.source == "cron") CRON_RUN_ID.matchEntire(session.id)?.groupValues?.get(1) else null
|
||||
jobId?.let { "job:$it" } ?: "session:${session.id}"
|
||||
}.map { (key, runs) ->
|
||||
AutomationSessionGroup(
|
||||
key = key,
|
||||
jobId = key.takeIf { it.startsWith("job:") }?.removePrefix("job:"),
|
||||
sessions = runs,
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -60,6 +62,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -310,6 +313,7 @@ fun SessionsScreen(
|
||||
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
var pruneDays by remember { mutableStateOf("7") }
|
||||
var expandedAutomationGroups by remember { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
val sessionsToDisplay =
|
||||
remember(
|
||||
@@ -323,6 +327,9 @@ fun SessionsScreen(
|
||||
}
|
||||
|
||||
val hasSelection = state.selectedIds.isNotEmpty()
|
||||
val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 }
|
||||
val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) }
|
||||
val sessionItemsById = remember(sessionsToDisplay) { sessionsToDisplay.associateBy { it.session.id } }
|
||||
val visibleSessionIds =
|
||||
remember(sessionsToDisplay) {
|
||||
sessionsToDisplay.mapTo(linkedSetOf()) { it.session.id }
|
||||
@@ -339,7 +346,6 @@ fun SessionsScreen(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSessions()
|
||||
viewModel.loadStats()
|
||||
}
|
||||
|
||||
// Toast effect
|
||||
@@ -554,6 +560,27 @@ fun SessionsScreen(
|
||||
// (The scaffold already applies top-bar padding via its inner Box, so we
|
||||
// must NOT re-apply paddingValues here.)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.section == HistorySection.CONVERSATIONS,
|
||||
onClick = { viewModel.selectSection(HistorySection.CONVERSATIONS) },
|
||||
label = { Text(stringResource(R.string.sessions_tab_conversations)) },
|
||||
modifier = Modifier.testTag("history_tab_conversations"),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.section == HistorySection.AUTOMATIONS,
|
||||
onClick = { viewModel.selectSection(HistorySection.AUTOMATIONS) },
|
||||
label = { Text(stringResource(R.string.sessions_tab_automations)) },
|
||||
modifier = Modifier.testTag("history_tab_automations"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search + bulk toggle (always visible) ─────────────
|
||||
Row(
|
||||
modifier =
|
||||
@@ -569,26 +596,29 @@ fun SessionsScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
// Empty-session cleanup (issue #787) — grey/disabled when 0.
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (state.emptyCount > 0) {
|
||||
Badge { Text("${state.emptyCount}") }
|
||||
}
|
||||
},
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requestEmptyCleanup() },
|
||||
enabled = state.emptyCount > 0,
|
||||
contentPadding = PaddingValues(horizontal = spacing.md, vertical = 8.dp),
|
||||
// This API operates on all history, so do not present it as scoped
|
||||
// to the Automations tab.
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (state.emptyCount > 0) {
|
||||
Badge { Text("${state.emptyCount}") }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.sessions_empty_cleanup_desc))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requestEmptyCleanup() },
|
||||
enabled = state.emptyCount > 0,
|
||||
contentPadding = PaddingValues(horizontal = spacing.md, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.sessions_empty_cleanup_all_history))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,10 +734,29 @@ fun SessionsScreen(
|
||||
|
||||
state.sessions.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.history_empty_title),
|
||||
subtitle = stringResource(R.string.history_empty_desc),
|
||||
title =
|
||||
stringResource(
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
R.string.sessions_automations_empty_title
|
||||
} else {
|
||||
R.string.history_empty_title
|
||||
},
|
||||
),
|
||||
subtitle =
|
||||
stringResource(
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
R.string.sessions_automations_empty_desc
|
||||
} else {
|
||||
R.string.history_empty_desc
|
||||
},
|
||||
),
|
||||
icon = Icons.Filled.History,
|
||||
actionLabel = stringResource(R.string.empty_action_start_chat),
|
||||
actionLabel =
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
stringResource(R.string.empty_action_start_chat)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onAction = { NavigationController.navigateTo(ChatScreen) },
|
||||
)
|
||||
}
|
||||
@@ -735,63 +784,55 @@ fun SessionsScreen(
|
||||
) {
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_total),
|
||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.total),
|
||||
value = formatCompactCount(state.total),
|
||||
icon = Icons.Filled.History,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_messages),
|
||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.messages),
|
||||
label = stringResource(R.string.sessions_stat_messages_loaded),
|
||||
value = formatCompactCount(loadedMessageCount),
|
||||
icon = Icons.Filled.Email,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
// Prune button — compact card sized to its label, so the
|
||||
// stat cards keep enough width for large counts.
|
||||
Card(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
onClick = { viewModel.showPruneDialog() },
|
||||
) {
|
||||
Box(
|
||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
||||
// An unweighted Row child with fillMaxSize expands to the
|
||||
// full row width and starves the weighted stat cards.
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
||||
contentAlignment = Alignment.Center,
|
||||
if (state.section == HistorySection.CONVERSATIONS) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
onClick = { viewModel.showPruneDialog() },
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
Box(
|
||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
||||
// An unweighted Row child with fillMaxSize expands to the
|
||||
// full row width and starves the weighted stat cards.
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune_all_history),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stats error snack
|
||||
val statsError = state.statsError
|
||||
if (statsError != null) {
|
||||
Text(
|
||||
text = statsError,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.error,
|
||||
modifier = Modifier.padding(horizontal = spacing.md),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Session list ────────────────────────────────────
|
||||
val listState = rememberLazyListState()
|
||||
@@ -819,54 +860,125 @@ fun SessionsScreen(
|
||||
state.hasMore,
|
||||
state.isSearchMode,
|
||||
) {
|
||||
if (nearListEnd && !state.isLoadingMore && state.hasMore && !state.isSearchMode) {
|
||||
if (
|
||||
state.section == HistorySection.CONVERSATIONS &&
|
||||
nearListEnd &&
|
||||
!state.isLoadingMore &&
|
||||
state.hasMore &&
|
||||
!state.isSearchMode
|
||||
) {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
}
|
||||
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||
val session = item.session
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
branchStem = item.branchStem,
|
||||
isFork = item.isFork,
|
||||
forkDepth = item.forkDepth,
|
||||
query = state.searchQuery,
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.openChatSession(session.id)
|
||||
}
|
||||
},
|
||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||
onSelect = {
|
||||
viewModel.toggleSelecting()
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
},
|
||||
onRename = {
|
||||
viewModel.openRenameDialog(
|
||||
session.id,
|
||||
item.displayTitle,
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||
val session = item.session
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
branchStem = item.branchStem,
|
||||
isFork = item.isFork,
|
||||
forkDepth = item.forkDepth,
|
||||
query = state.searchQuery,
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.openChatSession(session.id)
|
||||
if (state.section == HistorySection.AUTOMATIONS) {
|
||||
automationSessionGroups.forEach { group ->
|
||||
item(key = "automation-group:${group.key}") {
|
||||
val expanded = group.key in expandedAutomationGroups
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
expandedAutomationGroups =
|
||||
if (expanded) {
|
||||
expandedAutomationGroups - group.key
|
||||
} else {
|
||||
expandedAutomationGroups + group.key
|
||||
}
|
||||
}.padding(horizontal = spacing.sm, vertical = spacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (expanded) {
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
} else {
|
||||
Icons.Filled.KeyboardArrowRight
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Column {
|
||||
Text(
|
||||
text =
|
||||
group.jobId?.let {
|
||||
stringResource(
|
||||
R.string.sessions_section_automation_job,
|
||||
it,
|
||||
)
|
||||
} ?: stringResource(
|
||||
R.string.sessions_section_automation_unknown,
|
||||
),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.sessions_section_automation_runs,
|
||||
group.sessions.size,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||
onSelect = {
|
||||
viewModel.toggleSelecting()
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
},
|
||||
onRename = {
|
||||
viewModel.openRenameDialog(
|
||||
session.id,
|
||||
item.displayTitle,
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
if (group.key in expandedAutomationGroups) {
|
||||
items(group.sessions, key = { it.id }) { session ->
|
||||
sessionItemsById[session.id]?.let { sessionCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||
sessionCard(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Load more
|
||||
|
||||
@@ -28,6 +28,11 @@ data class SessionStats(
|
||||
val messages: Int = 0,
|
||||
)
|
||||
|
||||
enum class HistorySection {
|
||||
CONVERSATIONS,
|
||||
AUTOMATIONS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact count for stat cards: max 3 digits + unit letter.
|
||||
* 987 → "987", 100987 → "100k", 1234567 → "1.23m", 100000000 → "100m".
|
||||
@@ -60,6 +65,7 @@ private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.
|
||||
private fun List<SessionInfo>.pinnedFirst(): List<SessionInfo> = sortedBy { it.pinned != true }
|
||||
|
||||
data class SessionsUiState(
|
||||
val section: HistorySection = HistorySection.CONVERSATIONS,
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val sessions: List<SessionInfo> = emptyList(),
|
||||
@@ -107,7 +113,10 @@ class SessionsViewModel :
|
||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var pageJob: Job? = null
|
||||
private var statsJob: Job? = null
|
||||
private var generation: Long = 0
|
||||
private var rawPaginationOffset: Int = 0
|
||||
|
||||
init {
|
||||
// Issue #784: gateway broadcasts sessions.changed — refresh the list
|
||||
@@ -115,23 +124,36 @@ class SessionsViewModel :
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.SESSIONS,
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
)
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
when (
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
)
|
||||
}
|
||||
) {
|
||||
is NetworkResult.Success -> NetworkResult.Success(requestGeneration to result.data)
|
||||
is NetworkResult.Failure -> result
|
||||
}
|
||||
},
|
||||
onSuccess = { data ->
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||
it.copy(
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
onSuccess = { (requestGeneration, data) ->
|
||||
if (requestGeneration == generation) {
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||
it.copy(
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -146,8 +168,47 @@ class SessionsViewModel :
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
}
|
||||
|
||||
private val HistorySection.source: String?
|
||||
get() = if (this == HistorySection.AUTOMATIONS) "cron" else null
|
||||
|
||||
private val HistorySection.excludeSources: String?
|
||||
get() = if (this == HistorySection.CONVERSATIONS) "cron" else null
|
||||
|
||||
private fun com.m57.hermescontrol.data.model.SessionListResponse.nextOffset(requestOffset: Int): Int =
|
||||
if (limit > 0) offset + limit else requestOffset + PAGE_SIZE
|
||||
|
||||
fun selectSection(section: HistorySection) {
|
||||
if (_uiState.value.section == section) return
|
||||
generation++
|
||||
loadJob?.cancel()
|
||||
pageJob?.cancel()
|
||||
searchJob?.cancel()
|
||||
rawPaginationOffset = 0
|
||||
val query = _uiState.value.searchQuery
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
section = section,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
sessions = emptyList(),
|
||||
total = 0,
|
||||
hasMore = false,
|
||||
errorMessage = null,
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
isSearching = false,
|
||||
searchResults = emptyList(),
|
||||
searchError = null,
|
||||
)
|
||||
}
|
||||
if (query.isBlank()) loadSessions() else setSearchQuery(query)
|
||||
}
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions() {
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
pageJob?.cancel()
|
||||
loadEmptyCount()
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
@@ -158,11 +219,15 @@ class SessionsViewModel :
|
||||
limit = PAGE_SIZE,
|
||||
offset = 0,
|
||||
order = "recent",
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
)
|
||||
}
|
||||
},
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
onSuccess = { data ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
||||
_uiState.update {
|
||||
@@ -177,6 +242,7 @@ class SessionsViewModel :
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
@@ -191,48 +257,55 @@ class SessionsViewModel :
|
||||
/** Load the next page and append to the existing session list. */
|
||||
fun loadMore() {
|
||||
val state = _uiState.value
|
||||
if (state.isLoadingMore || !state.hasMore) return
|
||||
if (state.isLoading || state.isLoadingMore || !state.hasMore || state.isSearchMode) return
|
||||
val requestGeneration = generation
|
||||
val offset = rawPaginationOffset
|
||||
|
||||
_uiState.update { it.copy(isLoadingMore = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = state.sessions.size,
|
||||
order = "recent",
|
||||
)
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
_uiState.update {
|
||||
val newSessions =
|
||||
(it.sessions + data.sessions)
|
||||
.distinctBy { s -> s.id }
|
||||
.pinnedFirst()
|
||||
val receivedFullPage = data.sessions.size >= PAGE_SIZE
|
||||
val addedNewItems = newSessions.size > it.sessions.size
|
||||
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
pageJob =
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessions(
|
||||
limit = PAGE_SIZE,
|
||||
offset = offset,
|
||||
order = "recent",
|
||||
source = state.section.source,
|
||||
excludeSources = state.section.excludeSources,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (requestGeneration != generation) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
rawPaginationOffset = data.nextOffset(offset)
|
||||
_uiState.update {
|
||||
val newSessions =
|
||||
(it.sessions + data.sessions)
|
||||
.distinctBy { s -> s.id }
|
||||
.pinnedFirst()
|
||||
val receivedFullPage = data.sessions.size >= PAGE_SIZE
|
||||
val addedNewItems = newSessions.size > it.sessions.size
|
||||
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
sessions = newSessions,
|
||||
total = if (hasMore) data.total else newSessions.size,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load more: ${result.error.message}",
|
||||
)
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingMore = false,
|
||||
errorMessage = "Failed to load more: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search (server-backed FTS5) ──────────────────────────────────
|
||||
@@ -245,12 +318,15 @@ class SessionsViewModel :
|
||||
* paginated list mode.
|
||||
*/
|
||||
fun setSearchQuery(query: String) {
|
||||
_uiState.update { it.copy(searchQuery = query) }
|
||||
val requestGeneration = generation
|
||||
val section = _uiState.value.section
|
||||
_uiState.update { it.copy(searchQuery = query, searchResults = emptyList()) }
|
||||
searchJob?.cancel()
|
||||
if (query.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(searchResults = emptyList(), searchError = null, isSearching = false)
|
||||
}
|
||||
if (_uiState.value.sessions.isEmpty()) loadSessions()
|
||||
return
|
||||
}
|
||||
searchJob =
|
||||
@@ -259,8 +335,14 @@ class SessionsViewModel :
|
||||
_uiState.update { it.copy(isSearching = true, searchError = null) }
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.searchSessions(q = query, profile = null)
|
||||
ApiClient.hermesApi.searchSessions(
|
||||
q = query,
|
||||
profile = null,
|
||||
source = section.source,
|
||||
excludeSources = section.excludeSources,
|
||||
)
|
||||
}
|
||||
if (requestGeneration != generation || _uiState.value.searchQuery != query) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
|
||||
Reference in new issue
Block a user