diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index 4490abb5f3..9708870009 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -1132,6 +1132,65 @@ describe('overlayLiveLanes', () => { expect(overlayLiveLanes(home, [makeCwdSession('/www/app', { id: 'fresh' })])).toBe(home) }) + // #77591 C: compression rotates the live id (root -> tip) while the snapshot + // still holds the older segment. Both are one conversation, so one row. + it.each([ + ['a first compression', { id: 'root' }, { id: 'tip', _lineage_root_id: 'root', _lineage_ids: ['root', 'tip'] }], + [ + 'a deeper chain', + { id: 'mid', _lineage_root_id: 'root', _lineage_ids: ['root', 'mid'] }, + { id: 'tip', _lineage_root_id: 'root', _lineage_ids: ['root', 'mid', 'tip'] } + ] + ])('replaces the snapshot row with its compressed live tip after %s', (_label, snapshotIds, liveIds) => { + const stale = makeCwdSession('/www/app', { ...snapshotIds, git_branch: 'main', last_active: 10 }) + const tip = makeCwdSession('/www/app', { ...liveIds, git_branch: 'main', last_active: 20 }) + + const project = projectNode({ + id: '/www/app', + isAuto: true, + repos: [ + { + id: '/www/app', + label: 'app', + path: '/www/app', + sessionCount: 1, + groups: [ + lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [stale] }) + ] + } + ] + }) + + const overlaid = overlayLiveLanes(project, [tip]) + + expect(overlaid.repos[0].groups.flatMap(g => g.sessions.map(s => s.id))).toEqual(['tip']) + expect(overlaid.sessionCount).toBe(1) + // The drill-in's preview backfill must not re-add the older segment either. + expect(reconcileEnteredProjectSessions([tip], [stale]).map(s => s.id)).toEqual(['tip']) + }) + + it('keeps a compressed tip out of an ancestor project the snapshot did not give it to', () => { + // The owner map only knows the pre-compression id; the tip must inherit it + // instead of being re-placed by cwd into the umbrella project as well. + const tip = makeCwdSession('/work/repos/app-2', { + id: 'tip', + _lineage_root_id: 'root', + _lineage_ids: ['root', 'tip'], + git_repo_root: null + }) + + const ancestor = projectNode({ + id: 'p_work', + path: '/work', + repos: [{ id: '/work', label: 'work', path: '/work', groups: [], sessionCount: 0 }] + }) + + const repo = projectNode({ id: 'p_app', path: '/work/repos/app', sessionIds: ['root'] }) + const owners = projectOwnerBySessionId([ancestor, repo]) + + expect(overlayLiveLanes(ancestor, [tip], new Set(), owners).sessionCount).toBe(0) + }) + it('evicts a session from the main lane when the live overlay places it into a worktree lane', () => { // Session was in main when the backend tree was captured, but the live // $sessions cache now has it under a worktree cwd. The overlay must place @@ -1210,6 +1269,22 @@ describe('overlayLivePreviews', () => { expect(previews['/www/app'].map(s => s.id)).toEqual(['fresh', 'old']) }) + it('previews a compressed chat once, as its live tip (#77591)', () => { + const stale = makeCwdSession('/www/app', { id: 'root', last_active: 10 }) + + const tip = makeCwdSession('/www/app', { + id: 'tip', + _lineage_root_id: 'root', + _lineage_ids: ['root', 'tip'], + last_active: 20 + }) + + const project = projectNode({ id: '/www/app', previewSessions: [stale], sessionIds: ['root'] }) + const previews = overlayLivePreviews([project], [tip], [], 3) + + expect(previews['/www/app'].map(s => s.id)).toEqual(['tip']) + }) + it('evicts a deleted session from a project preview (snapshot + live)', () => { const project = projectNode({ id: '/www/app', diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 0772473960..5f85c5b75e 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -535,8 +535,59 @@ export function projectOwnerBySessionId(projects: SidebarProjectTree[]): Readonl return owners } -const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] => - [session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => sessionRecency(b) - sessionRecency(a)) +/** + * Every id a row has answered to. Compression rotates a chat's live id (root -> + * tip), so the snapshot and the live cache can each hold a different segment + * of one conversation; the projected row carries its lineage root and chain. + */ +const conversationIds = (session: SessionInfo): string[] => [ + session.id, + ...(session._lineage_root_id ? [session._lineage_root_id] : []), + ...(session._lineage_ids ?? []) +] + +/** A predicate matching any row that is the same conversation as `session`. */ +function sameConversationAs(session: SessionInfo): (row: SessionInfo) => boolean { + const ids = new Set(conversationIds(session)) + + return row => conversationIds(row).some(id => ids.has(id)) +} + +/** The snapshot's owner for a live row, found by any id its conversation has had. */ +function ownerOf(owners: ReadonlyMap, session: SessionInfo): string | undefined { + for (const id of conversationIds(session)) { + const owner = owners.get(id) + + if (owner) { + return owner + } + } + + return undefined +} + +/** Rows minus any that repeat an earlier row's conversation (first wins). */ +function uniqueConversations(rows: SessionInfo[]): SessionInfo[] { + const seen = new Set() + + return rows.filter(row => { + const ids = conversationIds(row) + + if (ids.some(id => seen.has(id))) { + return false + } + + ids.forEach(id => seen.add(id)) + + return true + }) +} + +const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] => { + const isSame = sameConversationAs(session) + + return [session, ...rows.filter(row => !isSame(row))].sort((a, b) => sessionRecency(b) - sessionRecency(a)) +} /** A live row's placement path, with an exact repo-root fallback when cwd is absent. */ function livePathForRepo(repoRoot: string, session: SessionInfo): string { @@ -688,9 +739,11 @@ export function overlayRepoLanes( // new worktree — the overlay places it into the worktree lane, but without // this eviction the stale main-lane entry persists and the session appears // under both groups until the next backend tree refresh). + const isSame = sameConversationAs(session) + for (const g of lanes) { if (g !== lane) { - const idx = g.sessions.findIndex(s => s.id === session.id) + const idx = g.sessions.findIndex(isSame) if (idx >= 0) { g.sessions = [...g.sessions.slice(0, idx), ...g.sessions.slice(idx + 1)] @@ -728,8 +781,7 @@ function overlayHomeLane( owners: ReadonlyMap ): SidebarProjectTree { const ownedElsewhere = (session: SessionInfo): boolean => { - const owner = - owners.get(session.id) ?? (session._lineage_root_id ? owners.get(session._lineage_root_id) : undefined) + const owner = ownerOf(owners, session) return Boolean(owner) && owner !== NO_PROJECT_ID } @@ -826,7 +878,7 @@ export function overlayLiveLanes( let changed = false const projectLive = live.filter(session => { - const owner = authoritativeOwners.get(session.id) + const owner = ownerOf(authoritativeOwners, session) return !owner || owner === project.id }) @@ -860,8 +912,8 @@ export function reconcileEnteredProjectSessions( return live } - const liveIds = new Set(live.map(session => session.id)) - const missingPreviews = previewSessions.filter(session => !liveIds.has(session.id)) + const liveIds = new Set(live.flatMap(conversationIds)) + const missingPreviews = previewSessions.filter(session => !conversationIds(session).some(id => liveIds.has(id))) return missingPreviews.length ? [...live, ...missingPreviews] : live } @@ -888,7 +940,7 @@ export function overlayLivePreviews( continue } - const projectId = authoritativeOwners.get(session.id) ?? sessionBucketId(session, explicitProjects) + const projectId = ownerOf(authoritativeOwners, session) ?? sessionBucketId(session, explicitProjects) if (!projectId) { continue @@ -909,16 +961,9 @@ export function overlayLivePreviews( continue } - // Live rows take precedence (fresher title/activity/working state). - const map = new Map() - - for (const session of [...liveRows, ...base]) { - if (!map.has(session.id)) { - map.set(session.id, session) - } - } - - const pool = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)) + // Live rows take precedence (fresher title/activity/working state), and a + // compressed chat's live tip stands in for the snapshot's older segment. + const pool = uniqueConversations([...liveRows, ...base]).sort((a, b) => sessionRecency(b) - sessionRecency(a)) out[node.id] = rankSessions(pool, rankIds).slice(0, limit) }