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

@@ -0,0 +1,116 @@
package com.m57.hermescontrol.data.model
private fun recency(s: SessionInfo): Double = s.last_active ?: s.started_at ?: 0.0
/**
* Port of desktop session-branch-tree.ts#flattenSessionsWithBranches.
*
* - Aliases _lineage_root_id so forks find compression-projected parents.
* - Sorts sibling branches by own recency; sorts roots by groupRecency
* (max over subtree) so activity on any branch lifts the whole family.
* - preserveOrder keeps caller-chosen root order (pinned section).
* - Cycle-guarded; trailing sweep never drops an input row.
*/
fun flattenSessionsWithBranches(
sessions: List<SessionInfo>,
preserveOrder: Boolean = false,
): List<SessionTreeItem> {
if (sessions.size < 2) {
return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) }
}
val byVisibleId = HashMap<String, SessionInfo>(sessions.size * 2)
for (s in sessions) {
byVisibleId[s.id] = s
s.lineageRootId
?.trim()
?.takeIf(String::isNotEmpty)
?.let { byVisibleId[it] = s }
}
val childrenByParent = HashMap<String, MutableList<SessionInfo>>()
val nestedIds = HashSet<String>()
for (s in sessions) {
val parentId = s.parent_session_id?.trim()?.takeIf(String::isNotEmpty) ?: continue
val parent = byVisibleId[parentId] ?: continue
if (parent.id == s.id) continue
nestedIds += s.id
childrenByParent.getOrPut(parent.id) { mutableListOf() } += s
}
childrenByParent.values.forEach { it.sortByDescending(::recency) }
// Group sorts by its freshest member; memo pre-seed doubles as cycle guard.
val groupRecencyMemo = HashMap<String, Double>()
fun groupRecency(s: SessionInfo): Double {
groupRecencyMemo[s.id]?.let { return it }
groupRecencyMemo[s.id] = recency(s)
val max =
childrenByParent[s.id]
.orEmpty()
.fold(recency(s)) { acc, child -> maxOf(acc, groupRecency(child)) }
groupRecencyMemo[s.id] = max
return max
}
val out = mutableListOf<SessionTreeItem>()
val seen = HashSet<String>()
fun emit(
s: SessionInfo,
depth: Int,
siblingIndex: Int,
siblingCount: Int,
) {
if (!seen.add(s.id)) return
out += s.toTreeItem(depth, siblingIndex, siblingCount)
val kids = childrenByParent[s.id].orEmpty()
kids.forEachIndexed { i, child -> emit(child, depth + 1, i, kids.size) }
}
val roots = sessions.withIndex().filter { it.value.id !in nestedIds }
val ordered =
if (preserveOrder) {
roots
} else {
roots.sortedWith(
compareByDescending<IndexedValue<SessionInfo>> { groupRecency(it.value) }
.thenBy { it.index },
)
}
ordered.forEachIndexed { i, r -> emit(r.value, 0, i, ordered.size) }
sessions.filter { it.id !in seen }.forEach { emit(it, 0, 0, 1) } // trailing sweep
return out
}
private fun SessionInfo.toTreeItem(
depth: Int,
siblingIndex: Int,
siblingCount: Int,
): SessionTreeItem {
// G4 fix: fork identity from the field itself, never from load-set membership.
val isFork = !parent_session_id.isNullOrBlank()
val title =
title?.takeIf(String::isNotBlank)
?: display_name?.takeIf(String::isNotBlank)
?: if (isFork && depth > 0) {
"branch ${siblingIndex + 1}"
} else {
preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
}
return SessionTreeItem(
session = this,
depth = depth,
branchStem =
if (depth == 0) {
null
} else if (siblingIndex == siblingCount - 1) {
"└─"
} else {
"├─"
},
isFork = isFork,
forkDepth = if (depth > 0) depth else 0,
displayTitle = title,
)
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -78,6 +79,14 @@ data class SessionInfo(
val display_name: String? = null,
val model: String? = null,
val terminal_backend: String? = null,
// Epoch seconds of the latest message (backend-computed, present on all
// list rows). Recency source of truth; started_at is the fallback.
val last_active: Double? = null,
// Original root id when this row is a compression-projected tip. Branch
// children reference the ROOT id in parent_session_id, so tree building
// must alias this id to this row (desktop byVisibleId parity).
@SerialName("_lineage_root_id")
val lineageRootId: String? = null,
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
// sweep and are surfaced first in the history list (sorted client-side).
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
@@ -95,6 +95,7 @@ import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest
@@ -267,6 +268,12 @@ interface HermesApiService {
@Query("profile") profile: String? = null,
): Response<SessionDetailResponse>
@GET("api/sessions/{id}")
suspend fun getSessionInfo(
@Path("id", encoded = true) sessionId: String,
@Query("profile") profile: String? = null,
): Response<SessionInfo>
@GET("api/model/info")
suspend fun getModelInfo(): Response<ModelInfoResponse>
@@ -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