diff --git a/src/renderer/components/SessionHistory/SessionHistory.tsx b/src/renderer/components/SessionHistory/SessionHistory.tsx index a8dcacb..337b3c9 100644 --- a/src/renderer/components/SessionHistory/SessionHistory.tsx +++ b/src/renderer/components/SessionHistory/SessionHistory.tsx @@ -303,10 +303,40 @@ export const SessionHistory: React.FC = ({ }); // Filter out any previous sessions that are now active (shouldn't happen but just in case) + // Enrich previous sessions with worktree data from worktreeMap (matched by cwd) const activeIds = new Set(activeSessions.map((t) => t.id)); - const filteredPrevious: DisplaySession[] = sessions + const enrichedPrevious: DisplaySession[] = sessions .filter((s) => !activeIds.has(s.sessionId)) - .map((s) => ({ ...s, isActive: false })); + .map((s) => { + const worktreeData = s.cwd ? worktreeMap.get(s.cwd) : undefined; + const worktree = worktreeData + ? { + id: worktreeData.id, + repoPath: worktreeData.repoPath, + branch: worktreeData.branch, + worktreePath: worktreeData.worktreePath, + status: worktreeData.status, + diskUsage: worktreeData.diskUsage, + } + : s.worktree; + return { ...s, isActive: false, worktree }; + }); + + // Deduplicate: for sessions sharing the same worktree, keep only the most recent + const bestWorktreeSession = new Map(); + for (const session of enrichedPrevious) { + const wtPath = session.worktree?.worktreePath; + if (!wtPath) continue; + const existing = bestWorktreeSession.get(wtPath); + if (!existing || new Date(session.modifiedTime) > new Date(existing.modifiedTime)) { + bestWorktreeSession.set(wtPath, session); + } + } + const filteredPrevious = enrichedPrevious.filter((session) => { + const wtPath = session.worktree?.worktreePath; + if (!wtPath) return true; + return bestWorktreeSession.get(wtPath)?.sessionId === session.sessionId; + }); // Collect worktree paths that are already represented const coveredWorktreePaths = new Set(); diff --git a/tests/components/SessionHistory.test.tsx b/tests/components/SessionHistory.test.tsx index 0f29d15..e68fd19 100644 --- a/tests/components/SessionHistory.test.tsx +++ b/tests/components/SessionHistory.test.tsx @@ -851,4 +851,151 @@ describe('SessionHistory Component', () => { expect(screen.getByText('2')).toBeInTheDocument(); }); }); + + describe('Worktree Session Deduplication', () => { + const worktreePath = '/Users/dev/.copilot-sessions/repo--feature-branch'; + const worktreeListResult = { + sessions: [ + { + id: 'repo--feature-branch', + repoPath: '/Users/dev/repo', + branch: 'feature/branch', + worktreePath, + status: 'active' as const, + lastAccessedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }, + ], + }; + + it('enriches previous sessions with worktree data from worktreeMap and prevents standalone duplicate', async () => { + // Mock listSessions to return a worktree matching the session's cwd + (window.electronAPI.worktree.listSessions as ReturnType).mockResolvedValue( + worktreeListResult + ); + + // Session has cwd matching worktree but no worktree property + const sessions: PreviousSession[] = [ + createMockSession('session-1', 'Work on feature', 0, worktreePath), + ]; + + await renderAndSettle( + + ); + + // Wait for worktreeMap to be populated via useEffect + await waitFor(() => { + // Should show the branch name (enriched from worktreeMap) + expect(screen.getByText('feature/branch')).toBeInTheDocument(); + }); + + // Should only appear once — no standalone worktree duplicate + const branchElements = screen.getAllByText('feature/branch'); + expect(branchElements).toHaveLength(1); + }); + + it('deduplicates previous sessions sharing the same worktree path', async () => { + (window.electronAPI.worktree.listSessions as ReturnType).mockResolvedValue( + worktreeListResult + ); + + const olderDate = new Date(); + olderDate.setHours(olderDate.getHours() - 2); + + // Two SDK sessions pointing to the same worktree + const sessions: PreviousSession[] = [ + { + sessionId: 'session-older', + name: 'Older session on branch', + modifiedTime: olderDate.toISOString(), + cwd: worktreePath, + }, + { + sessionId: 'session-newer', + name: 'Newer session on branch', + modifiedTime: new Date().toISOString(), + cwd: worktreePath, + }, + ]; + + await renderAndSettle( + + ); + + await waitFor(() => { + expect(screen.getByText('feature/branch')).toBeInTheDocument(); + }); + + // Only the most recent session should remain + expect(screen.getByText('Newer session on branch')).toBeInTheDocument(); + expect(screen.queryByText('Older session on branch')).not.toBeInTheDocument(); + + // Branch should appear exactly once (no standalone duplicate either) + expect(screen.getAllByText('feature/branch')).toHaveLength(1); + }); + + it('does not deduplicate sessions with different worktree paths', async () => { + const worktreePath2 = '/Users/dev/.copilot-sessions/repo--other-branch'; + (window.electronAPI.worktree.listSessions as ReturnType).mockResolvedValue({ + sessions: [ + ...worktreeListResult.sessions, + { + id: 'repo--other-branch', + repoPath: '/Users/dev/repo', + branch: 'other/branch', + worktreePath: worktreePath2, + status: 'active' as const, + lastAccessedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }, + ], + }); + + const sessions: PreviousSession[] = [ + createMockSession('session-1', 'Work on feature', 0, worktreePath), + createMockSession('session-2', 'Work on other', 0, worktreePath2), + ]; + + await renderAndSettle( + + ); + + await waitFor(() => { + expect(screen.getByText('feature/branch')).toBeInTheDocument(); + }); + + // Both sessions should be visible — different worktrees + expect(screen.getByText('Work on feature')).toBeInTheDocument(); + expect(screen.getByText('Work on other')).toBeInTheDocument(); + expect(screen.getByText('feature/branch')).toBeInTheDocument(); + expect(screen.getByText('other/branch')).toBeInTheDocument(); + }); + }); });