From 78b133127df18c55c7b9e81e60d2f55f72c928eb Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 24 Sep 2026 00:32:40 -0500 Subject: [PATCH] fix(desktop): collapse stale compression tips before nesting sidebar branches --- .../src/lib/session-branch-tree.test.ts | 54 +++++++++++++++++++ apps/desktop/src/lib/session-branch-tree.ts | 52 ++++++++++++++++-- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/session-branch-tree.test.ts b/apps/desktop/src/lib/session-branch-tree.test.ts index 8de1803012..15cce5de23 100644 --- a/apps/desktop/src/lib/session-branch-tree.test.ts +++ b/apps/desktop/src/lib/session-branch-tree.test.ts @@ -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' }) diff --git a/apps/desktop/src/lib/session-branch-tree.ts b/apps/desktop/src/lib/session-branch-tree.ts index 07ef3c9911..40bedf3396 100644 --- a/apps/desktop/src/lib/session-branch-tree.ts +++ b/apps/desktop/src/lib/session-branch-tree.ts @@ -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() + + 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() + + 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()