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.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -78,6 +79,14 @@ data class SessionInfo(
val display_name: String? = null, val display_name: String? = null,
val model: String? = null, val model: String? = null,
val terminal_backend: 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 // Durable "keep" flag: pinned sessions are exempt from the auto-archive
// sweep and are surfaced first in the history list (sorted client-side). // 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). // 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.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse 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.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.model.SessionRenameRequest
@@ -267,6 +268,12 @@ interface HermesApiService {
@Query("profile") profile: String? = null, @Query("profile") profile: String? = null,
): Response<SessionDetailResponse> ): 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") @GET("api/model/info")
suspend fun getModelInfo(): Response<ModelInfoResponse> suspend fun getModelInfo(): Response<ModelInfoResponse>
@@ -100,9 +100,8 @@ import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo 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.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.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState 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.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listItemSpacing 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.SearchResultCard
import com.m57.hermescontrol.ui.sessions.components.SessionCard import com.m57.hermescontrol.ui.sessions.components.SessionCard
import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar 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) { if (state.isSearchMode) {
state.searchResults.map { searchResult -> state.searchResults.map { result ->
val session = searchResult.toSessionInfo() val session = result.toSessionInfo()
SessionTreeItem( SessionTreeItem(
session = session, session = session,
depth = 0, depth = 0,
@@ -208,7 +209,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
) )
} }
} else { } else {
flattenSessionTree(state.displaySessions) flattenSessionsWithBranches(state.displaySessions)
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -238,6 +239,19 @@ fun SessionsScreen(
displayedSessions(state) 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 hasSelection = state.selectedIds.isNotEmpty()
val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 } val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 }
val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) } val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) }
@@ -733,42 +747,44 @@ fun SessionsScreen(
} }
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item -> val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
val session = item.session val session = item.session
SessionCard( BranchRow(item = item) {
session = session, SessionCard(
displayTitle = item.displayTitle, session = session,
branchStem = item.branchStem, displayTitle = item.displayTitle,
isFork = item.isFork, branchStem = null,
forkDepth = item.forkDepth, isFork = item.isFork,
query = state.searchQuery, forkDepth = item.forkDepth,
isSelecting = state.isSelecting, query = state.searchQuery,
isSelected = session.id in state.selectedIds, isSelecting = state.isSelecting,
isDeleting = session.id in state.deletingSessionIds, isSelected = session.id in state.selectedIds,
isPinned = session.pinned == true, isDeleting = session.id in state.deletingSessionIds,
isHidden = session.hidden == true, isPinned = session.pinned == true,
highlightBackground = primaryContainer, isHidden = session.hidden == true,
highlightForeground = onPrimaryContainer, highlightBackground = primaryContainer,
onCardClick = { highlightForeground = onPrimaryContainer,
if (state.isSelecting) { 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) viewModel.toggleSessionSelection(session.id)
} else { },
NavigationController.openChatSession(session.id) onRename = {
} viewModel.openRenameDialog(
}, session.id,
onToggleSelection = { viewModel.toggleSessionSelection(session.id) }, item.displayTitle,
onSelect = { )
viewModel.toggleSelecting() },
viewModel.toggleSessionSelection(session.id) onTogglePin = { viewModel.togglePin(session.id) },
}, onToggleHide = { viewModel.toggleHide(session.id) },
onRename = { onDelete = { viewModel.requestDeleteSession(session.id) },
viewModel.openRenameDialog( )
session.id, }
item.displayTitle,
)
},
onTogglePin = { viewModel.togglePin(session.id) },
onToggleHide = { viewModel.toggleHide(session.id) },
onDelete = { viewModel.requestDeleteSession(session.id) },
)
} }
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -793,6 +809,29 @@ fun SessionsScreen(
renderSessionCard = sessionCard, renderSessionCard = sessionCard,
) )
} else { } 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 -> items(sessionsToDisplay, key = { it.session.id }) { item ->
sessionCard(item) sessionCard(item)
} }
@@ -57,13 +57,6 @@ internal fun formatCompactCount(value: Int): String {
private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.') 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( data class SessionsUiState(
val section: HistorySection = HistorySection.CONVERSATIONS, val section: HistorySection = HistorySection.CONVERSATIONS,
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -96,6 +89,7 @@ data class SessionsUiState(
val searchResults: List<SessionSearchResult> = emptyList(), val searchResults: List<SessionSearchResult> = emptyList(),
val searchError: String? = null, val searchError: String? = null,
val showHidden: Boolean = false, val showHidden: Boolean = false,
val pinnedExpanded: Boolean = true,
) { ) {
val isSearchMode: Boolean get() = searchQuery.isNotBlank() val isSearchMode: Boolean get() = searchQuery.isNotBlank()
@@ -104,6 +98,9 @@ data class SessionsUiState(
val displaySessions: List<SessionInfo> val displaySessions: List<SessionInfo>
get() = if (showHidden) sessions else sessions.filter { it.hidden != true } get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
val pinnedSessions: List<SessionInfo>
get() = displaySessions.filter { it.pinned == true }
} }
class SessionsViewModel : class SessionsViewModel :
@@ -146,10 +143,10 @@ class SessionsViewModel :
if (requestGeneration == generation) { if (requestGeneration == generation) {
rawPaginationOffset = data.nextOffset(0) rawPaginationOffset = data.nextOffset(0)
_uiState.update { _uiState.update {
val newSessions = data.sessions.orEmpty().pinnedFirst() val newSessions = data.sessions.orEmpty()
val paging = val paging =
SessionsPaging.resolveInitialPaging( SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size, receivedCount = minOf(data.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
backendTotal = data.total, backendTotal = data.total,
accumulatedCount = newSessions.size, accumulatedCount = newSessions.size,
@@ -160,6 +157,7 @@ class SessionsViewModel :
hasMore = paging.hasMore, hasMore = paging.hasMore,
) )
} }
stitchMissingParents(requestGeneration)
} }
}, },
) )
@@ -172,8 +170,53 @@ class SessionsViewModel :
private companion object { private companion object {
const val PAGE_SIZE = 50 const val PAGE_SIZE = 50
const val SEARCH_DEBOUNCE_MS = 300L 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? private val HistorySection.source: String?
get() = if (this == HistorySection.AUTOMATIONS) "cron" else null get() = if (this == HistorySection.AUTOMATIONS) "cron" else null
@@ -234,10 +277,10 @@ class SessionsViewModel :
onSuccess = { data -> onSuccess = { data ->
if (requestGeneration != generation) return@safeLaunchLoad if (requestGeneration != generation) return@safeLaunchLoad
rawPaginationOffset = data.nextOffset(0) rawPaginationOffset = data.nextOffset(0)
val sessionsList = data.sessions.orEmpty().pinnedFirst() val sessionsList = data.sessions.orEmpty()
val paging = val paging =
SessionsPaging.resolveInitialPaging( SessionsPaging.resolveInitialPaging(
receivedCount = data.sessions.size, receivedCount = minOf(data.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
backendTotal = data.total, backendTotal = data.total,
accumulatedCount = sessionsList.size, accumulatedCount = sessionsList.size,
@@ -252,6 +295,7 @@ class SessionsViewModel :
selectedIds = emptySet(), selectedIds = emptySet(),
) )
} }
stitchMissingParents(requestGeneration)
}, },
onError = { errorMsg -> onError = { errorMsg ->
if (requestGeneration != generation) return@safeLaunchLoad if (requestGeneration != generation) return@safeLaunchLoad
@@ -295,11 +339,10 @@ class SessionsViewModel :
val newSessions = val newSessions =
(it.sessions + data.sessions) (it.sessions + data.sessions)
.distinctBy { s -> s.id } .distinctBy { s -> s.id }
.pinnedFirst()
val addedNewItems = newSessions.size > it.sessions.size val addedNewItems = newSessions.size > it.sessions.size
val paging = val paging =
SessionsPaging.resolveLoadMorePaging( SessionsPaging.resolveLoadMorePaging(
receivedCount = data.sessions.size, receivedCount = minOf(data.sessions.size, PAGE_SIZE),
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
addedNewItems = addedNewItems, addedNewItems = addedNewItems,
backendTotal = data.total, backendTotal = data.total,
@@ -312,6 +355,7 @@ class SessionsViewModel :
hasMore = paging.hasMore, hasMore = paging.hasMore,
) )
} }
stitchMissingParents(requestGeneration)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -540,8 +584,7 @@ class SessionsViewModel :
it.copy( it.copy(
sessions = sessions =
it.sessions it.sessions
.map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s } .map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s },
.pinnedFirst(),
toastMessage = toastMessage =
if (targetPinned) { if (targetPinned) {
"Session pinned" "Session pinned"
@@ -583,8 +626,7 @@ class SessionsViewModel :
it.copy( it.copy(
sessions = sessions =
it.sessions it.sessions
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s } .map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s },
.pinnedFirst(),
toastMessage = toastMessage =
if (targetHidden) { if (targetHidden) {
"Session hidden" "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.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo 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.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType 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? = fun sourceIcon(source: String?): ImageVector? =
when (source?.lowercase()) { when (source?.lowercase()) {
"telegram", "tg" -> Icons.AutoMirrored.Filled.Send "telegram", "tg" -> Icons.AutoMirrored.Filled.Send
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class SessionTreeTest { class SessionTreeTest {
@@ -8,46 +9,79 @@ class SessionTreeTest {
fun nestsBranchesAndUsesDesktopFallbackNames() { fun nestsBranchesAndUsesDesktopFallbackNames() {
val sessions = val sessions =
listOf( listOf(
SessionInfo(id = "unrelated", title = "Recent root"), SessionInfo(id = "unrelated", title = "Recent root", last_active = 10.0),
SessionInfo(id = "branch-b", parent_session_id = "parent"), SessionInfo(id = "parent", title = "Original", last_active = 5.0),
SessionInfo(id = "parent", title = "Original"), SessionInfo(id = "branch-b", parent_session_id = "parent", last_active = 6.0),
SessionInfo(id = "branch-a", parent_session_id = "parent", preview = "copied prompt"), SessionInfo(
SessionInfo(id = "nested", parent_session_id = "branch-a", title = "Named nested branch"), id = "branch-a",
parent_session_id = "parent",
preview = "copied prompt",
last_active = 7.0,
),
SessionInfo(
id = "nested",
parent_session_id = "branch-a",
title = "Named nested branch",
last_active = 8.0,
),
) )
val tree = flattenSessionTree(sessions) val tree = flattenSessionsWithBranches(sessions)
assertEquals(listOf("unrelated", "parent", "branch-b", "branch-a", "nested"), tree.map { it.session.id }) // groupRecency of parent is max(5.0, 6.0, 7.0, 8.0) = 8.0
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.depth }) // unrelated is 10.0 -> unrelated first, then parent family
assertEquals(listOf("unrelated", "parent", "branch-a", "nested", "branch-b"), tree.map { it.session.id })
assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.depth })
assertEquals( assertEquals(
listOf("Recent root", "Original", "branch 1", "branch 2", "Named nested branch"), listOf("Recent root", "Original", "branch 1", "Named nested branch", "branch 2"),
tree.map { tree.map { it.displayTitle },
it.displayTitle
},
) )
// Fork flag + nesting depth (1 = child of root, 2 = fork of a fork). // Fork flag + nesting depth
assertEquals(listOf(false, false, true, true, true), tree.map { it.isFork }) assertEquals(listOf(false, false, true, true, true), tree.map { it.isFork })
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.forkDepth }) assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.forkDepth })
} }
@Test @Test
fun deepNestingMarksEveryForkWithDepth() { fun activeForkLiftsEntireFamilyAboveFresherUnrelatedRoot() {
// root → a (child 1) → a1 (child 1) ; root → b (child 2, last) // Parent has old recency (1.0). Fork has fresh recency (100.0). Unrelated has recency 50.0.
// a1 is fork-depth 2 (fork of a fork), b is fork-depth 1.
val sessions = val sessions =
listOf( listOf(
SessionInfo(id = "root", title = "Root"), SessionInfo(id = "unrelated", title = "Unrelated", last_active = 50.0),
SessionInfo(id = "a", parent_session_id = "root"), SessionInfo(id = "parent", title = "Parent", last_active = 1.0),
SessionInfo(id = "b", parent_session_id = "root"), SessionInfo(id = "fork", parent_session_id = "parent", title = "Active Fork", last_active = 100.0),
SessionInfo(id = "a1", parent_session_id = "a"),
) )
val tree = flattenSessionTree(sessions) val tree = flattenSessionsWithBranches(sessions)
assertEquals(listOf("root", "a", "a1", "b"), tree.map { it.session.id }) assertEquals(listOf("parent", "fork", "unrelated"), tree.map { it.session.id })
assertEquals(listOf(0, 1, 2, 1), tree.map { it.depth }) assertEquals(listOf(0, 1, 0), tree.map { it.depth })
assertEquals(listOf(false, true, true, true), tree.map { it.isFork }) }
assertEquals(listOf(0, 1, 2, 1), tree.map { it.forkDepth })
@Test
fun aliasesLineageRootIdForCompressionProjectedTips() {
val sessions =
listOf(
SessionInfo(id = "tip_parent", lineageRootId = "root_parent", title = "Parent Tip", last_active = 5.0),
SessionInfo(id = "child", parent_session_id = "root_parent", title = "Child", last_active = 6.0),
)
val tree = flattenSessionsWithBranches(sessions)
assertEquals(listOf("tip_parent", "child"), tree.map { it.session.id })
assertEquals(listOf(0, 1), tree.map { it.depth })
}
@Test
fun preservesOrderWhenRequested() {
val sessions =
listOf(
SessionInfo(id = "pin1", title = "Pin 1", last_active = 10.0),
SessionInfo(id = "pin2", title = "Pin 2", last_active = 90.0),
)
val tree = flattenSessionsWithBranches(sessions, preserveOrder = true)
assertEquals(listOf("pin1", "pin2"), tree.map { it.session.id })
} }
@Test @Test
@@ -59,9 +93,13 @@ class SessionTreeTest {
SessionInfo(id = "b", parent_session_id = "a"), SessionInfo(id = "b", parent_session_id = "a"),
) )
val tree = flattenSessionTree(sessions) val tree = flattenSessionsWithBranches(sessions)
assertEquals(setOf("orphan", "a", "b"), tree.map { it.session.id }.toSet()) assertEquals(setOf("orphan", "a", "b"), tree.map { it.session.id }.toSet())
assertEquals("orphan preview", tree.first { it.session.id == "orphan" }.displayTitle) val orphanItem = tree.first { it.session.id == "orphan" }
assertEquals("orphan preview", orphanItem.displayTitle)
// Orphan keeps fork identity even without parent in loaded set
assertTrue(orphanItem.isFork)
assertEquals(0, orphanItem.depth)
} }
} }
@@ -331,10 +331,8 @@ class SessionsViewModelTest {
// ── Pin / unpin ──────────────────────────────────────────────────────── // ── Pin / unpin ────────────────────────────────────────────────────────
@Test @Test
fun `loaded list keeps pinned sessions on top`() { fun `loaded list retains server order and identifies pinned sessions`() {
val vm = createViewModel() 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(), null, "cron") } returns coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success( Response.success(
SessionListResponse( SessionListResponse(
@@ -349,15 +347,22 @@ class SessionsViewModelTest {
vm.loadSessions() vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
// Sessions retain server order (family grouping is handled by flattenSessionsWithBranches)
assertEquals( assertEquals(
listOf("old-pinned", "recent"), listOf("recent", "old-pinned"),
vm.uiState.value.sessions vm.uiState.value.sessions
.map { it.id }, .map { it.id },
) )
// pinnedSessions derives the pinned items
assertEquals(
listOf("old-pinned"),
vm.uiState.value.pinnedSessions
.map { it.id },
)
} }
@Test @Test
fun `togglePin moves the session to the top and sets the flag`() { fun `togglePin updates the session pinned state without mangling session order`() {
val vm = createViewModel() val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success( Response.success(
@@ -374,16 +379,21 @@ class SessionsViewModelTest {
testDispatcher.scheduler.advanceUntilIdle() testDispatcher.scheduler.advanceUntilIdle()
assertEquals( assertEquals(
listOf("s-2", "s-1"), listOf("s-1", "s-2"),
vm.uiState.value.sessions vm.uiState.value.sessions
.map { it.id }, .map { it.id },
) )
assertEquals( assertEquals(
true, true,
vm.uiState.value.sessions vm.uiState.value.sessions
.first() .first { it.id == "s-2" }
.pinned, .pinned,
) )
assertEquals(
listOf("s-2"),
vm.uiState.value.pinnedSessions
.map { it.id },
)
assertEquals("Session pinned", vm.uiState.value.toastMessage) assertEquals("Session pinned", vm.uiState.value.toastMessage)
} }
@@ -500,4 +510,42 @@ class SessionsViewModelTest {
) )
assertEquals("Session unhidden", vm.uiState.value.toastMessage) assertEquals("Session unhidden", vm.uiState.value.toastMessage)
} }
@Test
fun `stitchMissingParents fetches missing parent sessions up to cap`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions =
listOf(
SessionInfo("child", parent_session_id = "parent", title = "Child"),
),
total = 1,
),
)
coEvery { mockApi.getSessionInfo("parent") } returns
Response.success(
SessionInfo("parent", title = "Fetched Parent"),
)
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(2, vm.uiState.value.sessions.size)
assertTrue(
vm.uiState.value.sessions
.any { it.id == "parent" },
)
}
@Test
fun `togglePinnedExpanded toggles expanded state`() {
val vm = createViewModel()
assertTrue(vm.uiState.value.pinnedExpanded)
vm.togglePinnedExpanded()
assertFalse(vm.uiState.value.pinnedExpanded)
vm.togglePinnedExpanded()
assertTrue(vm.uiState.value.pinnedExpanded)
}
} }