feat(sessions): branch tree flattening with group recency and collapsible pinned section (#1087)
This commit is contained in:
9 files changed
+448
-59
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,10 +747,11 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||||
val session = item.session
|
val session = item.session
|
||||||
|
BranchRow(item = item) {
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
displayTitle = item.displayTitle,
|
displayTitle = item.displayTitle,
|
||||||
branchStem = item.branchStem,
|
branchStem = null,
|
||||||
isFork = item.isFork,
|
isFork = item.isFork,
|
||||||
forkDepth = item.forkDepth,
|
forkDepth = item.forkDepth,
|
||||||
query = state.searchQuery,
|
query = state.searchQuery,
|
||||||
@@ -770,6 +785,7 @@ fun SessionsScreen(
|
|||||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -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,6 +170,51 @@ 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?
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user