fix(desktop): collapse stale compression tips before nesting sidebar branches
This commit is contained in:
@@ -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' })
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user