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