hermes-agent/apps/desktop/src/lib/session-branch-tree.ts
2026-06-25 16:40:27 -05:00

100 lines
3.1 KiB
TypeScript

import type { SessionInfo } from '@/types/hermes'
export interface SidebarSessionEntry {
branchStem?: string
session: SessionInfo
}
const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0
/** Flat list with branch/fork sessions nested visually under their parent. */
export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] {
if (sessions.length < 2) {
return sessions.map(session => ({ session }))
}
const byVisibleId = new Map<string, SessionInfo>()
for (const session of sessions) {
byVisibleId.set(session.id, session)
const rootId = session._lineage_root_id?.trim()
if (rootId) {
byVisibleId.set(rootId, session)
}
}
const childrenByParent = new Map<string, SessionInfo[]>()
const nestedIds = new Set<string>()
for (const session of sessions) {
const parentId = session.parent_session_id?.trim()
if (!parentId) {
continue
}
const parent = byVisibleId.get(parentId)
if (!parent || parent.id === session.id) {
continue
}
nestedIds.add(session.id)
const siblings = childrenByParent.get(parent.id) ?? []
siblings.push(session)
childrenByParent.set(parent.id, siblings)
}
for (const siblings of childrenByParent.values()) {
siblings.sort((left, right) => recency(right) - recency(left))
}
// A group sorts by its freshest member, so activity on any branch lifts the
// whole parent→branches cluster together instead of stranding the parent at
// its own stale timestamp. Memoized — each subtree is folded at most once.
const groupRecencyMemo = new Map<string, number>()
const groupRecency = (session: SessionInfo): number => {
const cached = groupRecencyMemo.get(session.id)
if (cached !== undefined) {
return cached
}
groupRecencyMemo.set(session.id, recency(session)) // cycle guard
const max = (childrenByParent.get(session.id) ?? []).reduce(
(acc, child) => Math.max(acc, groupRecency(child)),
recency(session)
)
groupRecencyMemo.set(session.id, max)
return max
}
// Depth-first so a branch-of-a-branch still renders under its own parent. The
// `seen` set guards against pathological parent cycles, and the trailing sweep
// emits anything the walk somehow missed — nothing in the input is ever dropped.
const out: SidebarSessionEntry[] = []
const seen = new Set<string>()
const emit = (session: SessionInfo, branchStem?: string) => {
if (seen.has(session.id)) {
return
}
seen.add(session.id)
out.push(branchStem ? { branchStem, session } : { session })
const children = childrenByParent.get(session.id)
children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ '))
}
sessions
.filter(session => !nestedIds.has(session.id))
.map((session, index) => ({ index, session }))
.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
.forEach(({ session }) => emit(session))
for (const session of sessions) {
if (!seen.has(session.id)) {
out.push({ session })
}
}
return out
}