feat(sessions): branch tree flattening with group recency and collapsible pinned section (#1087)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 01:41:24 +03:00
1 parent fad2dd3b86
commit 5dc3896237
9 files changed
+482 -93

No files matched your search

@@ -100,9 +100,8 @@ import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.data.model.SessionTreeItem
import com.m57.hermescontrol.data.model.flattenSessionTree
import com.m57.hermescontrol.data.model.flattenSessionsWithBranches
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
@@ -116,6 +115,8 @@ import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.sessions.components.BranchRow
import com.m57.hermescontrol.ui.sessions.components.PinnedSectionHeader
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
import com.m57.hermescontrol.ui.sessions.components.SessionCard
import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
@@ -192,10 +193,10 @@ private fun highlightText(
}
}
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
internal fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
if (state.isSearchMode) {
state.searchResults.map { searchResult ->
val session = searchResult.toSessionInfo()
state.searchResults.map { result ->
val session = result.toSessionInfo()
SessionTreeItem(
session = session,
depth = 0,
@@ -208,7 +209,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
)
}
} else {
flattenSessionTree(state.displaySessions)
flattenSessionsWithBranches(state.displaySessions)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -238,6 +239,19 @@ fun SessionsScreen(
displayedSessions(state)
}
val pinnedItems =
remember(
state.isSearchMode,
state.pinnedSessions,
state.showHidden,
) {
if (state.isSearchMode) {
emptyList()
} else {
flattenSessionsWithBranches(state.pinnedSessions, preserveOrder = true)
}
}
val hasSelection = state.selectedIds.isNotEmpty()
val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 }
val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) }
@@ -733,42 +747,44 @@ fun SessionsScreen(
}
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) {
BranchRow(item = item) {
SessionCard(
session = session,
displayTitle = item.displayTitle,
branchStem = null,
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)
} 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) },
)
},
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,
@@ -793,6 +809,29 @@ fun SessionsScreen(
renderSessionCard = sessionCard,
)
} else {
if (pinnedItems.isNotEmpty() && !state.isSearchMode) {
item(key = "pinned_header") {
PinnedSectionHeader(
count = pinnedItems.size,
expanded = state.pinnedExpanded,
onToggle = { viewModel.togglePinnedExpanded() },
)
}
if (state.pinnedExpanded) {
items(pinnedItems, key = { "pin_${it.session.id}" }) { item ->
sessionCard(item)
}
}
item(key = "history_header") {
Text(
text = "History",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.xs),
)
}
}
items(sessionsToDisplay, key = { it.session.id }) { item ->
sessionCard(item)
}
@@ -57,13 +57,6 @@ internal fun formatCompactCount(value: Int): String {
private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.')
/**
* Stable sort: pinned sessions first. The backend only back-fills pins into
* page 1 (it doesn't lift them to the top), so the client owns the ordering.
* Stability keeps recency order intact within each group.
*/
private fun List<SessionInfo>.pinnedFirst(): List<SessionInfo> = sortedBy { it.pinned != true }
data class SessionsUiState(
val section: HistorySection = HistorySection.CONVERSATIONS,
val isLoading: Boolean = false,
@@ -96,6 +89,7 @@ data class SessionsUiState(
val searchResults: List<SessionSearchResult> = emptyList(),
val searchError: String? = null,
val showHidden: Boolean = false,
val pinnedExpanded: Boolean = true,
) {
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
@@ -104,6 +98,9 @@ data class SessionsUiState(
val displaySessions: List<SessionInfo>
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
val pinnedSessions: List<SessionInfo>
get() = displaySessions.filter { it.pinned == true }
}
class SessionsViewModel :
@@ -146,10 +143,10 @@ class SessionsViewModel :
if (requestGeneration == generation) {
rawPaginationOffset = data.nextOffset(0)
_uiState.update {
val newSessions = data.sessions.orEmpty().pinnedFirst()
val newSessions = data.sessions.orEmpty()
val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size,
receivedCount = minOf(data.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE,
backendTotal = data.total,
accumulatedCount = newSessions.size,
@@ -160,6 +157,7 @@ class SessionsViewModel :
hasMore = paging.hasMore,
)
}
stitchMissingParents(requestGeneration)
}
},
)
@@ -172,8 +170,53 @@ class SessionsViewModel :
private companion object {
const val PAGE_SIZE = 50
const val SEARCH_DEBOUNCE_MS = 300L
const val MAX_STITCH_PER_PAGE = 8
const val MAX_STITCH_ROUNDS = 3
}
private val resolvedParentCache = mutableMapOf<String, SessionInfo>()
fun togglePinnedExpanded() {
_uiState.update { it.copy(pinnedExpanded = !it.pinnedExpanded) }
}
private fun stitchMissingParents(requestGeneration: Long) =
viewModelScope.launch {
repeat(MAX_STITCH_ROUNDS) {
val loaded = _uiState.value.sessions
val known =
buildSet {
loaded.forEach {
add(it.id)
it.lineageRootId?.let(::add)
}
}
val missing =
loaded
.mapNotNull { it.parent_session_id?.trim()?.takeIf(String::isNotEmpty) }
.filter { it !in known }
.distinct()
.take(MAX_STITCH_PER_PAGE)
if (missing.isEmpty()) return@launch
val fetched =
missing.mapNotNull { pid ->
resolvedParentCache[pid] ?: when (
val r =
safeApiCall { ApiClient.hermesApi.getSessionInfo(pid) }
) {
is NetworkResult.Success -> r.data?.also { resolvedParentCache[pid] = it }
is NetworkResult.Failure -> null
}
}
if (requestGeneration != generation || fetched.isEmpty()) return@launch
_uiState.update { st ->
st.copy(sessions = (st.sessions + fetched).distinctBy { it.id })
}
}
}
private val HistorySection.source: String?
get() = if (this == HistorySection.AUTOMATIONS) "cron" else null
@@ -234,10 +277,10 @@ class SessionsViewModel :
onSuccess = { data ->
if (requestGeneration != generation) return@safeLaunchLoad
rawPaginationOffset = data.nextOffset(0)
val sessionsList = data.sessions.orEmpty().pinnedFirst()
val sessionsList = data.sessions.orEmpty()
val paging =
SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size,
receivedCount = minOf(data.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE,
backendTotal = data.total,
accumulatedCount = sessionsList.size,
@@ -252,6 +295,7 @@ class SessionsViewModel :
selectedIds = emptySet(),
)
}
stitchMissingParents(requestGeneration)
},
onError = { errorMsg ->
if (requestGeneration != generation) return@safeLaunchLoad
@@ -295,11 +339,10 @@ class SessionsViewModel :
val newSessions =
(it.sessions + data.sessions)
.distinctBy { s -> s.id }
.pinnedFirst()
val addedNewItems = newSessions.size > it.sessions.size
val paging =
SessionsPaging.resolveLoadMorePaging(
receivedCount = data.sessions.size,
receivedCount = minOf(data.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE,
addedNewItems = addedNewItems,
backendTotal = data.total,
@@ -312,6 +355,7 @@ class SessionsViewModel :
hasMore = paging.hasMore,
)
}
stitchMissingParents(requestGeneration)
}
is NetworkResult.Failure -> {
@@ -540,8 +584,7 @@ class SessionsViewModel :
it.copy(
sessions =
it.sessions
.map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s }
.pinnedFirst(),
.map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s },
toastMessage =
if (targetPinned) {
"Session pinned"
@@ -583,8 +626,7 @@ class SessionsViewModel :
it.copy(
sessions =
it.sessions
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
.pinnedFirst(),
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s },
toastMessage =
if (targetHidden) {
"Session hidden"
@@ -0,0 +1,62 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalSpacing
@Composable
fun PinnedSectionHeader(
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.clickable(role = Role.Button, onClick = onToggle)
.padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = "Pinned ($count)",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse pinned sessions" else "Expand pinned sessions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
@@ -39,17 +39,45 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionTreeItem
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
@Composable
fun BranchRow(
item: SessionTreeItem,
modifier: Modifier = Modifier,
card: @Composable (SessionTreeItem) -> Unit,
) {
val indent = 12.dp * minOf(item.depth, 2)
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (item.branchStem != null) {
Text(
text = item.branchStem,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = indent, end = 4.dp),
)
}
Box(modifier = Modifier.weight(1f)) {
card(item)
}
}
}
fun sourceIcon(source: String?): ImageVector? =
when (source?.lowercase()) {
"telegram", "tg" -> Icons.AutoMirrored.Filled.Send