diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt new file mode 100644 index 00000000..056e9e4c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt @@ -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, + preserveOrder: Boolean = false, +): List { + if (sessions.size < 2) { + return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) } + } + + val byVisibleId = HashMap(sessions.size * 2) + for (s in sessions) { + byVisibleId[s.id] = s + s.lineageRootId + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { byVisibleId[it] = s } + } + + val childrenByParent = HashMap>() + val nestedIds = HashSet() + 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() + + 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() + val seen = HashSet() + + 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> { 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, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index e01e6e0b..ab2e0eb0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -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). diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index a96eece2..90745c64 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @GET("api/sessions/{id}") + suspend fun getSessionInfo( + @Path("id", encoded = true) sessionId: String, + @Query("profile") profile: String? = null, + ): Response + @GET("api/model/info") suspend fun getModelInfo(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 0de2a4cb..9e5b1bbf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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 = +internal fun displayedSessions(state: SessionsUiState): List = 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 = ) } } 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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 0dc6f2b9..11f0d107 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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.pinnedFirst(): List = 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 = 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 get() = if (showHidden) sessions else sessions.filter { it.hidden != true } + + val pinnedSessions: List + 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() + + 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" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt new file mode 100644 index 00000000..c133830f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index 9b574913..33b5ea7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt index 38bdd318..5d488afa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.model import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class SessionTreeTest { @@ -8,46 +9,79 @@ class SessionTreeTest { fun nestsBranchesAndUsesDesktopFallbackNames() { val sessions = listOf( - SessionInfo(id = "unrelated", title = "Recent root"), - SessionInfo(id = "branch-b", parent_session_id = "parent"), - SessionInfo(id = "parent", title = "Original"), - SessionInfo(id = "branch-a", parent_session_id = "parent", preview = "copied prompt"), - SessionInfo(id = "nested", parent_session_id = "branch-a", title = "Named nested branch"), + SessionInfo(id = "unrelated", title = "Recent root", last_active = 10.0), + SessionInfo(id = "parent", title = "Original", last_active = 5.0), + SessionInfo(id = "branch-b", parent_session_id = "parent", last_active = 6.0), + SessionInfo( + 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 }) - assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.depth }) + // groupRecency of parent is max(5.0, 6.0, 7.0, 8.0) = 8.0 + // 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( - listOf("Recent root", "Original", "branch 1", "branch 2", "Named nested branch"), - tree.map { - it.displayTitle - }, + listOf("Recent root", "Original", "branch 1", "Named nested branch", "branch 2"), + tree.map { 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(0, 0, 1, 1, 2), tree.map { it.forkDepth }) + assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.forkDepth }) } @Test - fun deepNestingMarksEveryForkWithDepth() { - // root → a (child 1) → a1 (child 1) ; root → b (child 2, last) - // a1 is fork-depth 2 (fork of a fork), b is fork-depth 1. + fun activeForkLiftsEntireFamilyAboveFresherUnrelatedRoot() { + // Parent has old recency (1.0). Fork has fresh recency (100.0). Unrelated has recency 50.0. val sessions = listOf( - SessionInfo(id = "root", title = "Root"), - SessionInfo(id = "a", parent_session_id = "root"), - SessionInfo(id = "b", parent_session_id = "root"), - SessionInfo(id = "a1", parent_session_id = "a"), + SessionInfo(id = "unrelated", title = "Unrelated", last_active = 50.0), + SessionInfo(id = "parent", title = "Parent", last_active = 1.0), + SessionInfo(id = "fork", parent_session_id = "parent", title = "Active Fork", last_active = 100.0), ) - val tree = flattenSessionTree(sessions) + val tree = flattenSessionsWithBranches(sessions) - assertEquals(listOf("root", "a", "a1", "b"), tree.map { it.session.id }) - assertEquals(listOf(0, 1, 2, 1), tree.map { it.depth }) - assertEquals(listOf(false, true, true, true), tree.map { it.isFork }) - assertEquals(listOf(0, 1, 2, 1), tree.map { it.forkDepth }) + assertEquals(listOf("parent", "fork", "unrelated"), tree.map { it.session.id }) + assertEquals(listOf(0, 1, 0), tree.map { it.depth }) + } + + @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 @@ -59,9 +93,13 @@ class SessionTreeTest { 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("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) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 1176f89c..cc4bcb60 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -331,10 +331,8 @@ class SessionsViewModelTest { // ── Pin / unpin ──────────────────────────────────────────────────────── @Test - fun `loaded list keeps pinned sessions on top`() { + fun `loaded list retains server order and identifies pinned sessions`() { 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 Response.success( SessionListResponse( @@ -349,15 +347,22 @@ class SessionsViewModelTest { vm.loadSessions() testDispatcher.scheduler.advanceUntilIdle() + // Sessions retain server order (family grouping is handled by flattenSessionsWithBranches) assertEquals( - listOf("old-pinned", "recent"), + listOf("recent", "old-pinned"), vm.uiState.value.sessions .map { it.id }, ) + // pinnedSessions derives the pinned items + assertEquals( + listOf("old-pinned"), + vm.uiState.value.pinnedSessions + .map { it.id }, + ) } @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() coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns Response.success( @@ -374,16 +379,21 @@ class SessionsViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertEquals( - listOf("s-2", "s-1"), + listOf("s-1", "s-2"), vm.uiState.value.sessions .map { it.id }, ) assertEquals( true, vm.uiState.value.sessions - .first() + .first { it.id == "s-2" } .pinned, ) + assertEquals( + listOf("s-2"), + vm.uiState.value.pinnedSessions + .map { it.id }, + ) assertEquals("Session pinned", vm.uiState.value.toastMessage) } @@ -500,4 +510,42 @@ class SessionsViewModelTest { ) 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) + } }