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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user