Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions src/renderer/components/SessionHistory/SessionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,40 @@ export const SessionHistory: React.FC<SessionHistoryProps> = ({
});

// 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<string, DisplaySession>();
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<string>();
Expand Down
147 changes: 147 additions & 0 deletions tests/components/SessionHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).mockResolvedValue(
worktreeListResult
);

// Session has cwd matching worktree but no worktree property
const sessions: PreviousSession[] = [
createMockSession('session-1', 'Work on feature', 0, worktreePath),
];

await renderAndSettle(
<SessionHistory
isOpen={true}
onClose={mockOnClose}
sessions={sessions}
onResumeSession={mockOnResumeSession}
onDeleteSession={mockOnDeleteSession}
activeSessions={[]}
activeSessionId={null}
onSwitchToSession={mockOnSwitchToSession}
/>
);

// 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<typeof vi.fn>).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(
<SessionHistory
isOpen={true}
onClose={mockOnClose}
sessions={sessions}
onResumeSession={mockOnResumeSession}
onDeleteSession={mockOnDeleteSession}
activeSessions={[]}
activeSessionId={null}
onSwitchToSession={mockOnSwitchToSession}
/>
);

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<typeof vi.fn>).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(
<SessionHistory
isOpen={true}
onClose={mockOnClose}
sessions={sessions}
onResumeSession={mockOnResumeSession}
onDeleteSession={mockOnDeleteSession}
activeSessions={[]}
activeSessionId={null}
onSwitchToSession={mockOnSwitchToSession}
/>
);

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();
});
});
});