fix(desktop): deduplicate sidebar rows by compression lineage in mergeSessionPage (#43487)

When auto-compression rotates the session tip (old #4 → new #5), the
incoming page carries the new tip but the previous list still holds the
old one. The old tip's id differs from the new tip's id, so the existing
id-only dedup in mergeSessionPage() preserves both as separate sidebar
rows.

Add lineage-level dedup: build a set of incoming lineage keys
(`_lineage_root_id ?? id`) and filter survivors whose lineage key
matches any incoming row. This mirrors the existing sessionPinId()
logic used for pin stability.

Fixes #43483
This commit is contained in:
liuhao1024 2026-06-11 16:02:27 +08:00 committed by GitHub
parent c94e93a648
commit 0d3e2cc539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 2 deletions

View file

@ -133,13 +133,52 @@ describe('mergeSessionPage', () => {
it('keeps a pinned session matched by its lineage root after compression', () => {
// The pin is stored on the lineage-root id, but the loaded row surfaces
// under its live compression tip. Matching on _lineage_root_id keeps it.
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })]
const incoming = [session({ id: 'other' })]
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[]
const incoming = [session({ id: 'other' })] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['root'])
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => {
// Repro of #43483: after auto-compression rotates the tip (#4 → #5),
// the sidebar showed both the old tip and the new tip as separate rows.
// The old tip must be evicted because its lineage key matches the incoming
// new tip's lineage key.
const previous = [
session({ id: 'tip-4', _lineage_root_id: 'root' }),
session({ id: 'other' }),
] as SessionInfo[]
const incoming = [
session({ id: 'tip-5', _lineage_root_id: 'root' }),
] as SessionInfo[]
// 'tip-4' is in the keep set (e.g. it was the active/working session),
// but should still be evicted because the incoming page carries the same
// lineage under a new tip id.
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
expect(merged.map(s => s.id)).toEqual(['tip-5'])
// The new tip comes from the server payload.
expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root')
})
it('preserves an unrelated pinned session even when lineage dedup is active', () => {
// Regression guard: lineage dedup must not accidentally evict sessions
// from a different lineage that happen to be in the keep set.
const previous = [
session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
session({ id: 'b', _lineage_root_id: 'lineage-b' }),
] as SessionInfo[]
const incoming = [
session({ id: 'a-new', _lineage_root_id: 'lineage-a' }),
] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
})
})
describe('workspaceCwdForNewSession', () => {

View file

@ -125,10 +125,18 @@ export function mergeSessionPage(
}
const incomingIds = new Set(incoming.map(session => session.id))
// Deduplicate by compression lineage: when auto-compression rotates the tip
// id (old #4 → new #5), the incoming page carries the new tip but the
// previous list still holds the old one. Without lineage-level dedup both
// rows survive as separate sidebar entries (fixes #43483).
const incomingLineageKeys = new Set(
incoming.map(session => session._lineage_root_id ?? session.id)
)
const survivors = previous.filter(
session =>
!incomingIds.has(session.id) &&
!incomingLineageKeys.has(session._lineage_root_id ?? session.id) &&
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
)