fix(desktop): collapse stale compression tips before nesting sidebar branches

This commit is contained in:
brooklyn!
2026-09-24 00:32:40 -05:00
parent ef70b3661c
commit 78b133127d
2 changed files with 103 additions and 3 deletions

View File

@@ -32,6 +32,60 @@ describe('flattenSessionsWithBranches', () => {
])
})
it('collapses a stale compression tip into its continuation instead of nesting it (#82290)', () => {
// Old tip (#3) and its continuation (#4) both survived in the store. They
// share one lineage root, so they are one conversation: a single row for
// the live tip, no └─ stem.
const oldTip = session('old-tip', { _lineage_root_id: 'root', last_active: 100, started_at: 50 })
const continuation = session('continuation', {
_lineage_root_id: 'root',
last_active: 100,
parent_session_id: 'old-tip',
started_at: 100
})
expect(flattenSessionsWithBranches([oldTip, continuation])).toEqual([{ session: continuation }])
expect(flattenSessionsWithBranches([continuation, oldTip], { preserveOrder: true })).toEqual([
{ session: continuation }
])
})
it('collapses the lineage root row once its continuation carries the root id', () => {
const root = session('root', { last_active: 40 })
const continuation = session('continuation', {
_lineage_root_id: 'root',
last_active: 60,
parent_session_id: 'root'
})
const branch = session('branch', { last_active: 50, parent_session_id: 'root' })
expect(flattenSessionsWithBranches([root, continuation, branch])).toEqual([
{ session: continuation },
{ branchStem: '└─ ', session: branch }
])
})
it('keeps same-lineage ids from different profiles apart', () => {
const work = session('tip', { _lineage_root_id: 'root', last_active: 20, profile: 'work' })
const home = session('tip-home', { _lineage_root_id: 'root', last_active: 10, profile: 'home' })
expect(flattenSessionsWithBranches([work, home]).map(e => e.session.id)).toEqual(['tip', 'tip-home'])
})
it('still nests a real branch of a compressed conversation under the live tip', () => {
const oldTip = session('old-tip', { _lineage_root_id: 'root', last_active: 30 })
const tip = session('tip', { _lineage_root_id: 'root', last_active: 90, parent_session_id: 'old-tip' })
const branch = session('branch', { last_active: 70, parent_session_id: 'tip' })
expect(flattenSessionsWithBranches([oldTip, tip, branch])).toEqual([
{ session: tip },
{ branchStem: '└─ ', session: branch }
])
})
it('keeps orphan branches at the top level when the parent is missing', () => {
const branch = session('branch', { parent_session_id: 'missing' })

View File

@@ -17,11 +17,56 @@ export interface FlattenSessionsOptions {
const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0
// Profile-qualified compression lineage, same key as mergeSessionPage (store/session.ts). The
// backend's `_lineage_root_id` follows compression edges only; /branch and /new children get
// their own root, so rows sharing a key are one conversation, never a fork.
const lineageKey = (session: SessionInfo): string =>
`${(session.profile ?? '').trim() || 'default'}::${session._lineage_root_id?.trim() || session.id}`
/**
* One row per compression lineage. A stale tip can outlive its rotation in the store (#82290);
* keep the row nothing in its lineage continues from, then the freshest by mergeSessionPage's
* recency rule (first in input on a tie).
*/
function collapseCompressionLineages(sessions: readonly SessionInfo[]): readonly SessionInfo[] {
const groups = new Map<string, SessionInfo[]>()
for (const session of sessions) {
const key = lineageKey(session)
const group = groups.get(key)
if (group) {
group.push(session)
} else {
groups.set(key, [session])
}
}
if (groups.size === sessions.length) {
return sessions
}
const winners = new Set<SessionInfo>()
for (const group of groups.values()) {
const continuedIds = new Set(group.map(session => session.parent_session_id?.trim()))
const score = (session: SessionInfo) =>
continuedIds.has(session.id) ? -Infinity : Math.max(session.last_active || 0, session.started_at || 0)
winners.add(group.reduce((best, session) => (score(session) > score(best) ? session : best)))
}
return sessions.filter(session => winners.has(session))
}
/** Flat list with branch/fork sessions nested visually under their parent. */
export function flattenSessionsWithBranches(
sessions: readonly SessionInfo[],
input: readonly SessionInfo[],
options: FlattenSessionsOptions = {}
): SidebarSessionEntry[] {
const sessions = collapseCompressionLineages(input)
if (sessions.length < 2) {
return sessions.map(session => ({ session }))
}
@@ -49,7 +94,8 @@ export function flattenSessionsWithBranches(
const parent = byVisibleId.get(parentId)
if (!parent || parent.id === session.id) {
// Compression ancestry is not a branch: never nest a row under its own lineage.
if (!parent || lineageKey(parent) === lineageKey(session)) {
continue
}
@@ -90,7 +136,7 @@ export function flattenSessionsWithBranches(
// 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.
// emits anything the walk somehow missed — nothing past the lineage collapse is dropped.
const out: SidebarSessionEntry[] = []
const seen = new Set<string>()