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
@@ -0,0 +1,116 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
private fun recency(s: SessionInfo): Double = s.last_active ?: s.started_at ?: 0.0
|
||||
|
||||
/**
|
||||
* Port of desktop session-branch-tree.ts#flattenSessionsWithBranches.
|
||||
*
|
||||
* - Aliases _lineage_root_id so forks find compression-projected parents.
|
||||
* - Sorts sibling branches by own recency; sorts roots by groupRecency
|
||||
* (max over subtree) so activity on any branch lifts the whole family.
|
||||
* - preserveOrder keeps caller-chosen root order (pinned section).
|
||||
* - Cycle-guarded; trailing sweep never drops an input row.
|
||||
*/
|
||||
fun flattenSessionsWithBranches(
|
||||
sessions: List<SessionInfo>,
|
||||
preserveOrder: Boolean = false,
|
||||
): List<SessionTreeItem> {
|
||||
if (sessions.size < 2) {
|
||||
return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) }
|
||||
}
|
||||
|
||||
val byVisibleId = HashMap<String, SessionInfo>(sessions.size * 2)
|
||||
for (s in sessions) {
|
||||
byVisibleId[s.id] = s
|
||||
s.lineageRootId
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { byVisibleId[it] = s }
|
||||
}
|
||||
|
||||
val childrenByParent = HashMap<String, MutableList<SessionInfo>>()
|
||||
val nestedIds = HashSet<String>()
|
||||
for (s in sessions) {
|
||||
val parentId = s.parent_session_id?.trim()?.takeIf(String::isNotEmpty) ?: continue
|
||||
val parent = byVisibleId[parentId] ?: continue
|
||||
if (parent.id == s.id) continue
|
||||
nestedIds += s.id
|
||||
childrenByParent.getOrPut(parent.id) { mutableListOf() } += s
|
||||
}
|
||||
childrenByParent.values.forEach { it.sortByDescending(::recency) }
|
||||
|
||||
// Group sorts by its freshest member; memo pre-seed doubles as cycle guard.
|
||||
val groupRecencyMemo = HashMap<String, Double>()
|
||||
|
||||
fun groupRecency(s: SessionInfo): Double {
|
||||
groupRecencyMemo[s.id]?.let { return it }
|
||||
groupRecencyMemo[s.id] = recency(s)
|
||||
val max =
|
||||
childrenByParent[s.id]
|
||||
.orEmpty()
|
||||
.fold(recency(s)) { acc, child -> maxOf(acc, groupRecency(child)) }
|
||||
groupRecencyMemo[s.id] = max
|
||||
return max
|
||||
}
|
||||
|
||||
val out = mutableListOf<SessionTreeItem>()
|
||||
val seen = HashSet<String>()
|
||||
|
||||
fun emit(
|
||||
s: SessionInfo,
|
||||
depth: Int,
|
||||
siblingIndex: Int,
|
||||
siblingCount: Int,
|
||||
) {
|
||||
if (!seen.add(s.id)) return
|
||||
out += s.toTreeItem(depth, siblingIndex, siblingCount)
|
||||
val kids = childrenByParent[s.id].orEmpty()
|
||||
kids.forEachIndexed { i, child -> emit(child, depth + 1, i, kids.size) }
|
||||
}
|
||||
|
||||
val roots = sessions.withIndex().filter { it.value.id !in nestedIds }
|
||||
val ordered =
|
||||
if (preserveOrder) {
|
||||
roots
|
||||
} else {
|
||||
roots.sortedWith(
|
||||
compareByDescending<IndexedValue<SessionInfo>> { groupRecency(it.value) }
|
||||
.thenBy { it.index },
|
||||
)
|
||||
}
|
||||
ordered.forEachIndexed { i, r -> emit(r.value, 0, i, ordered.size) }
|
||||
sessions.filter { it.id !in seen }.forEach { emit(it, 0, 0, 1) } // trailing sweep
|
||||
return out
|
||||
}
|
||||
|
||||
private fun SessionInfo.toTreeItem(
|
||||
depth: Int,
|
||||
siblingIndex: Int,
|
||||
siblingCount: Int,
|
||||
): SessionTreeItem {
|
||||
// G4 fix: fork identity from the field itself, never from load-set membership.
|
||||
val isFork = !parent_session_id.isNullOrBlank()
|
||||
val title =
|
||||
title?.takeIf(String::isNotBlank)
|
||||
?: display_name?.takeIf(String::isNotBlank)
|
||||
?: if (isFork && depth > 0) {
|
||||
"branch ${siblingIndex + 1}"
|
||||
} else {
|
||||
preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
|
||||
}
|
||||
return SessionTreeItem(
|
||||
session = this,
|
||||
depth = depth,
|
||||
branchStem =
|
||||
if (depth == 0) {
|
||||
null
|
||||
} else if (siblingIndex == siblingCount - 1) {
|
||||
"└─"
|
||||
} else {
|
||||
"├─"
|
||||
},
|
||||
isFork = isFork,
|
||||
forkDepth = if (depth > 0) depth else 0,
|
||||
displayTitle = title,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
@@ -78,6 +79,14 @@ data class SessionInfo(
|
||||
val display_name: String? = null,
|
||||
val model: String? = null,
|
||||
val terminal_backend: String? = null,
|
||||
// Epoch seconds of the latest message (backend-computed, present on all
|
||||
// list rows). Recency source of truth; started_at is the fallback.
|
||||
val last_active: Double? = null,
|
||||
// Original root id when this row is a compression-projected tip. Branch
|
||||
// children reference the ROOT id in parent_session_id, so tree building
|
||||
// must alias this id to this row (desktop byVisibleId parity).
|
||||
@SerialName("_lineage_root_id")
|
||||
val lineageRootId: String? = null,
|
||||
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
|
||||
// sweep and are surfaced first in the history list (sorted client-side).
|
||||
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
||||
|
||||
@@ -95,6 +95,7 @@ import com.m57.hermescontrol.data.model.RenameProfileRequest
|
||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||
import com.m57.hermescontrol.data.model.ScanStatus
|
||||
import com.m57.hermescontrol.data.model.SessionDetailResponse
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||
@@ -267,6 +268,12 @@ interface HermesApiService {
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<SessionDetailResponse>
|
||||
|
||||
@GET("api/sessions/{id}")
|
||||
suspend fun getSessionInfo(
|
||||
@Path("id", encoded = true) sessionId: String,
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<SessionInfo>
|
||||
|
||||
@GET("api/model/info")
|
||||
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ import com.m57.hermescontrol.ChatScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.model.SessionTreeItem
|
||||
import com.m57.hermescontrol.data.model.flattenSessionTree
|
||||
import com.m57.hermescontrol.data.model.flattenSessionsWithBranches
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -116,6 +115,8 @@ import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.sessions.components.BranchRow
|
||||
import com.m57.hermescontrol.ui.sessions.components.PinnedSectionHeader
|
||||
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
|
||||
@@ -192,10 +193,10 @@ private fun highlightText(
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
||||
internal fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
||||
if (state.isSearchMode) {
|
||||
state.searchResults.map { searchResult ->
|
||||
val session = searchResult.toSessionInfo()
|
||||
state.searchResults.map { result ->
|
||||
val session = result.toSessionInfo()
|
||||
SessionTreeItem(
|
||||
session = session,
|
||||
depth = 0,
|
||||
@@ -208,7 +209,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
||||
)
|
||||
}
|
||||
} else {
|
||||
flattenSessionTree(state.displaySessions)
|
||||
flattenSessionsWithBranches(state.displaySessions)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -238,6 +239,19 @@ fun SessionsScreen(
|
||||
displayedSessions(state)
|
||||
}
|
||||
|
||||
val pinnedItems =
|
||||
remember(
|
||||
state.isSearchMode,
|
||||
state.pinnedSessions,
|
||||
state.showHidden,
|
||||
) {
|
||||
if (state.isSearchMode) {
|
||||
emptyList()
|
||||
} else {
|
||||
flattenSessionsWithBranches(state.pinnedSessions, preserveOrder = true)
|
||||
}
|
||||
}
|
||||
|
||||
val hasSelection = state.selectedIds.isNotEmpty()
|
||||
val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 }
|
||||
val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) }
|
||||
@@ -733,42 +747,44 @@ fun SessionsScreen(
|
||||
}
|
||||
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||
val session = item.session
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
branchStem = item.branchStem,
|
||||
isFork = item.isFork,
|
||||
forkDepth = item.forkDepth,
|
||||
query = state.searchQuery,
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
if (state.isSelecting) {
|
||||
BranchRow(item = item) {
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = item.displayTitle,
|
||||
branchStem = null,
|
||||
isFork = item.isFork,
|
||||
forkDepth = item.forkDepth,
|
||||
query = state.searchQuery,
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
if (state.isSelecting) {
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.openChatSession(session.id)
|
||||
}
|
||||
},
|
||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||
onSelect = {
|
||||
viewModel.toggleSelecting()
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
} else {
|
||||
NavigationController.openChatSession(session.id)
|
||||
}
|
||||
},
|
||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||
onSelect = {
|
||||
viewModel.toggleSelecting()
|
||||
viewModel.toggleSessionSelection(session.id)
|
||||
},
|
||||
onRename = {
|
||||
viewModel.openRenameDialog(
|
||||
session.id,
|
||||
item.displayTitle,
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
},
|
||||
onRename = {
|
||||
viewModel.openRenameDialog(
|
||||
session.id,
|
||||
item.displayTitle,
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
@@ -793,6 +809,29 @@ fun SessionsScreen(
|
||||
renderSessionCard = sessionCard,
|
||||
)
|
||||
} else {
|
||||
if (pinnedItems.isNotEmpty() && !state.isSearchMode) {
|
||||
item(key = "pinned_header") {
|
||||
PinnedSectionHeader(
|
||||
count = pinnedItems.size,
|
||||
expanded = state.pinnedExpanded,
|
||||
onToggle = { viewModel.togglePinnedExpanded() },
|
||||
)
|
||||
}
|
||||
if (state.pinnedExpanded) {
|
||||
items(pinnedItems, key = { "pin_${it.session.id}" }) { item ->
|
||||
sessionCard(item)
|
||||
}
|
||||
}
|
||||
item(key = "history_header") {
|
||||
Text(
|
||||
text = "History",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = spacing.xs),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(sessionsToDisplay, key = { it.session.id }) { item ->
|
||||
sessionCard(item)
|
||||
}
|
||||
|
||||
@@ -57,13 +57,6 @@ internal fun formatCompactCount(value: Int): String {
|
||||
|
||||
private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.')
|
||||
|
||||
/**
|
||||
* Stable sort: pinned sessions first. The backend only back-fills pins into
|
||||
* page 1 (it doesn't lift them to the top), so the client owns the ordering.
|
||||
* Stability keeps recency order intact within each group.
|
||||
*/
|
||||
private fun List<SessionInfo>.pinnedFirst(): List<SessionInfo> = sortedBy { it.pinned != true }
|
||||
|
||||
data class SessionsUiState(
|
||||
val section: HistorySection = HistorySection.CONVERSATIONS,
|
||||
val isLoading: Boolean = false,
|
||||
@@ -96,6 +89,7 @@ data class SessionsUiState(
|
||||
val searchResults: List<SessionSearchResult> = emptyList(),
|
||||
val searchError: String? = null,
|
||||
val showHidden: Boolean = false,
|
||||
val pinnedExpanded: Boolean = true,
|
||||
) {
|
||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||
|
||||
@@ -104,6 +98,9 @@ data class SessionsUiState(
|
||||
|
||||
val displaySessions: List<SessionInfo>
|
||||
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
|
||||
|
||||
val pinnedSessions: List<SessionInfo>
|
||||
get() = displaySessions.filter { it.pinned == true }
|
||||
}
|
||||
|
||||
class SessionsViewModel :
|
||||
@@ -146,10 +143,10 @@ class SessionsViewModel :
|
||||
if (requestGeneration == generation) {
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
_uiState.update {
|
||||
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||
val newSessions = data.sessions.orEmpty()
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = data.sessions.size,
|
||||
receivedCount = minOf(data.sessions.size, PAGE_SIZE),
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = newSessions.size,
|
||||
@@ -160,6 +157,7 @@ class SessionsViewModel :
|
||||
hasMore = paging.hasMore,
|
||||
)
|
||||
}
|
||||
stitchMissingParents(requestGeneration)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -172,8 +170,53 @@ class SessionsViewModel :
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 50
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
const val MAX_STITCH_PER_PAGE = 8
|
||||
const val MAX_STITCH_ROUNDS = 3
|
||||
}
|
||||
|
||||
private val resolvedParentCache = mutableMapOf<String, SessionInfo>()
|
||||
|
||||
fun togglePinnedExpanded() {
|
||||
_uiState.update { it.copy(pinnedExpanded = !it.pinnedExpanded) }
|
||||
}
|
||||
|
||||
private fun stitchMissingParents(requestGeneration: Long) =
|
||||
viewModelScope.launch {
|
||||
repeat(MAX_STITCH_ROUNDS) {
|
||||
val loaded = _uiState.value.sessions
|
||||
val known =
|
||||
buildSet {
|
||||
loaded.forEach {
|
||||
add(it.id)
|
||||
it.lineageRootId?.let(::add)
|
||||
}
|
||||
}
|
||||
val missing =
|
||||
loaded
|
||||
.mapNotNull { it.parent_session_id?.trim()?.takeIf(String::isNotEmpty) }
|
||||
.filter { it !in known }
|
||||
.distinct()
|
||||
.take(MAX_STITCH_PER_PAGE)
|
||||
if (missing.isEmpty()) return@launch
|
||||
|
||||
val fetched =
|
||||
missing.mapNotNull { pid ->
|
||||
resolvedParentCache[pid] ?: when (
|
||||
val r =
|
||||
safeApiCall { ApiClient.hermesApi.getSessionInfo(pid) }
|
||||
) {
|
||||
is NetworkResult.Success -> r.data?.also { resolvedParentCache[pid] = it }
|
||||
is NetworkResult.Failure -> null
|
||||
}
|
||||
}
|
||||
if (requestGeneration != generation || fetched.isEmpty()) return@launch
|
||||
|
||||
_uiState.update { st ->
|
||||
st.copy(sessions = (st.sessions + fetched).distinctBy { it.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val HistorySection.source: String?
|
||||
get() = if (this == HistorySection.AUTOMATIONS) "cron" else null
|
||||
|
||||
@@ -234,10 +277,10 @@ class SessionsViewModel :
|
||||
onSuccess = { data ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||
val sessionsList = data.sessions.orEmpty()
|
||||
val paging =
|
||||
SessionsPaging.resolveInitialPaging(
|
||||
receivedCount = data.sessions.size,
|
||||
receivedCount = minOf(data.sessions.size, PAGE_SIZE),
|
||||
pageSize = PAGE_SIZE,
|
||||
backendTotal = data.total,
|
||||
accumulatedCount = sessionsList.size,
|
||||
@@ -252,6 +295,7 @@ class SessionsViewModel :
|
||||
selectedIds = emptySet(),
|
||||
)
|
||||
}
|
||||
stitchMissingParents(requestGeneration)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
if (requestGeneration != generation) return@safeLaunchLoad
|
||||
@@ -295,11 +339,10 @@ class SessionsViewModel :
|
||||
val newSessions =
|
||||
(it.sessions + data.sessions)
|
||||
.distinctBy { s -> s.id }
|
||||
.pinnedFirst()
|
||||
val addedNewItems = newSessions.size > it.sessions.size
|
||||
val paging =
|
||||
SessionsPaging.resolveLoadMorePaging(
|
||||
receivedCount = data.sessions.size,
|
||||
receivedCount = minOf(data.sessions.size, PAGE_SIZE),
|
||||
pageSize = PAGE_SIZE,
|
||||
addedNewItems = addedNewItems,
|
||||
backendTotal = data.total,
|
||||
@@ -312,6 +355,7 @@ class SessionsViewModel :
|
||||
hasMore = paging.hasMore,
|
||||
)
|
||||
}
|
||||
stitchMissingParents(requestGeneration)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
@@ -540,8 +584,7 @@ class SessionsViewModel :
|
||||
it.copy(
|
||||
sessions =
|
||||
it.sessions
|
||||
.map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s }
|
||||
.pinnedFirst(),
|
||||
.map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s },
|
||||
toastMessage =
|
||||
if (targetPinned) {
|
||||
"Session pinned"
|
||||
@@ -583,8 +626,7 @@ class SessionsViewModel :
|
||||
it.copy(
|
||||
sessions =
|
||||
it.sessions
|
||||
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
|
||||
.pinnedFirst(),
|
||||
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s },
|
||||
toastMessage =
|
||||
if (targetHidden) {
|
||||
"Session hidden"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.m57.hermescontrol.ui.sessions.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
|
||||
@Composable
|
||||
fun PinnedSectionHeader(
|
||||
count: Int,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(role = Role.Button, onClick = onToggle)
|
||||
.padding(vertical = spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PushPin,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = "Pinned ($count)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse pinned sessions" else "Expand pinned sessions",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -39,17 +39,45 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionTreeItem
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
|
||||
@Composable
|
||||
fun BranchRow(
|
||||
item: SessionTreeItem,
|
||||
modifier: Modifier = Modifier,
|
||||
card: @Composable (SessionTreeItem) -> Unit,
|
||||
) {
|
||||
val indent = 12.dp * minOf(item.depth, 2)
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (item.branchStem != null) {
|
||||
Text(
|
||||
text = item.branchStem,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = indent, end = 4.dp),
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
card(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceIcon(source: String?): ImageVector? =
|
||||
when (source?.lowercase()) {
|
||||
"telegram", "tg" -> Icons.AutoMirrored.Filled.Send
|
||||
|
||||
Reference in new issue
Block a user