- Updated WorktreeGrouper to normalize project paths before resolving identities and branches, ensuring consistent behavior across platforms. - Enhanced TeamDataService to improve lead name resolution and prevent self-notification for comments on lead-owned tasks in solo teams. - Added new utility functions in TeamDataService for better lead name handling and solo team validation. - Improved TeamMemberLogsFinder to exclude owner sessions when the owner is the team lead, refining log retrieval logic. - Updated UI components to reflect changes in team member suggestions based on solo team status. Made-with: Cursor
219 lines
7.8 KiB
TypeScript
219 lines
7.8 KiB
TypeScript
/**
|
|
* WorktreeGrouper - Groups projects by git repository.
|
|
*
|
|
* Responsibilities:
|
|
* - Group projects that belong to the same git repository
|
|
* - Handle worktrees (main repo + worktrees grouped together)
|
|
* - Filter out empty worktrees (no visible sessions)
|
|
* - Sort worktrees by main first, then by most recent activity
|
|
*/
|
|
|
|
import {
|
|
type Project,
|
|
type RepositoryGroup,
|
|
type RepositoryIdentity,
|
|
type Worktree,
|
|
} from '@main/types';
|
|
import { extractBaseDir } from '@main/utils/pathDecoder';
|
|
import * as path from 'path';
|
|
|
|
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
|
|
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
|
|
|
import { SessionContentFilter } from './SessionContentFilter';
|
|
import { subprojectRegistry } from './SubprojectRegistry';
|
|
|
|
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
|
|
|
|
/**
|
|
* WorktreeGrouper provides methods for grouping projects by git repository.
|
|
*/
|
|
export class WorktreeGrouper {
|
|
private readonly projectsDir: string;
|
|
private readonly fsProvider: FileSystemProvider;
|
|
|
|
constructor(projectsDir: string, fsProvider?: FileSystemProvider) {
|
|
this.projectsDir = projectsDir;
|
|
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
|
|
}
|
|
|
|
/**
|
|
* Groups projects by git repository.
|
|
* Projects belonging to the same git repository (main repo + worktrees)
|
|
* are grouped together under a single RepositoryGroup.
|
|
* Non-git projects are represented as single-worktree groups.
|
|
*
|
|
* Sessions are filtered to exclude noise-only sessions, so counts
|
|
* accurately reflect visible sessions in the UI.
|
|
*
|
|
* @param projects - List of projects to group
|
|
* @returns Promise resolving to RepositoryGroups sorted by most recent activity
|
|
*/
|
|
async groupByRepository(projects: Project[]): Promise<RepositoryGroup[]> {
|
|
if (projects.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// 1. Resolve repository identity for each project
|
|
const projectIdentities = new Map<string, RepositoryIdentity | null>();
|
|
const projectBranches = new Map<string, string | null>();
|
|
|
|
await Promise.all(
|
|
projects.map(async (project) => {
|
|
const normalizedProjectPath = path.normalize(project.path);
|
|
const identity = await gitIdentityResolver.resolveIdentity(normalizedProjectPath);
|
|
projectIdentities.set(project.id, identity);
|
|
|
|
// Also get branch name for display
|
|
const branch = await gitIdentityResolver.getBranch(normalizedProjectPath);
|
|
projectBranches.set(project.id, branch);
|
|
})
|
|
);
|
|
|
|
// 2. Filter sessions for each project to only include non-noise sessions
|
|
const projectFilteredSessions = new Map<string, string[]>();
|
|
// Fast-first default for both local and SSH: avoid full-file scans during dashboard load.
|
|
// Can be re-enabled for strict parity debugging.
|
|
const shouldFilterNoise = process.env.CLAUDE_DEVTOOLS_STRICT_SESSION_FILTER === '1';
|
|
await Promise.all(
|
|
projects.map(async (project) => {
|
|
const baseDir = extractBaseDir(project.id);
|
|
const projectPath = path.join(this.projectsDir, baseDir);
|
|
const sessionFilter = subprojectRegistry.getSessionFilter(project.id);
|
|
const filteredSessions: string[] = [];
|
|
|
|
for (const sessionId of project.sessions) {
|
|
// Skip sessions that don't belong to this subproject
|
|
if (sessionFilter && !sessionFilter.has(sessionId)) {
|
|
continue;
|
|
}
|
|
if (!shouldFilterNoise) {
|
|
filteredSessions.push(sessionId);
|
|
continue;
|
|
}
|
|
|
|
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
|
|
if (await SessionContentFilter.hasNonNoiseMessages(sessionPath, this.fsProvider)) {
|
|
filteredSessions.push(sessionId);
|
|
}
|
|
}
|
|
|
|
projectFilteredSessions.set(project.id, filteredSessions);
|
|
})
|
|
);
|
|
|
|
// 3. Group projects by repository
|
|
const repoGroups = new Map<
|
|
string,
|
|
{
|
|
identity: RepositoryIdentity | null;
|
|
projects: Project[];
|
|
branches: Map<string, string | null>;
|
|
}
|
|
>();
|
|
|
|
for (const project of projects) {
|
|
const identity = projectIdentities.get(project.id) ?? null;
|
|
const branch = projectBranches.get(project.id) ?? null;
|
|
|
|
// Use repository ID if available, otherwise use project ID (for non-git projects)
|
|
const groupId = identity?.id ?? project.id;
|
|
|
|
if (!repoGroups.has(groupId)) {
|
|
repoGroups.set(groupId, {
|
|
identity,
|
|
projects: [],
|
|
branches: new Map(),
|
|
});
|
|
}
|
|
|
|
const group = repoGroups.get(groupId)!;
|
|
group.projects.push(project);
|
|
group.branches.set(project.id, branch);
|
|
}
|
|
|
|
// 4. Convert to RepositoryGroup[]
|
|
const repositoryGroups: RepositoryGroup[] = [];
|
|
|
|
for (const [groupId, group] of repoGroups) {
|
|
const worktrees: Worktree[] = await Promise.all(
|
|
group.projects.map(async (project) => {
|
|
const normalizedProjectPath = path.normalize(project.path);
|
|
const branch = group.branches.get(project.id) ?? null;
|
|
const isMainWorktree = !(await gitIdentityResolver.isWorktree(normalizedProjectPath));
|
|
// Use filtered sessions instead of raw sessions
|
|
const filteredSessions = projectFilteredSessions.get(project.id) ?? [];
|
|
// Detect worktree source for badge display
|
|
// project.path may use forward slashes (e.g. decodePath() returns "C:/...").
|
|
// detectWorktreeSource splits on path.sep, so normalize to the current platform first.
|
|
const source = await gitIdentityResolver.detectWorktreeSource(normalizedProjectPath);
|
|
// Use source-aware display name generation
|
|
const displayName = await gitIdentityResolver.getWorktreeDisplayName(
|
|
normalizedProjectPath,
|
|
source,
|
|
branch,
|
|
isMainWorktree
|
|
);
|
|
|
|
return {
|
|
id: project.id,
|
|
path: project.path,
|
|
name: displayName,
|
|
gitBranch: branch ?? undefined,
|
|
isMainWorktree,
|
|
source,
|
|
sessions: filteredSessions,
|
|
createdAt: project.createdAt,
|
|
mostRecentSession: project.mostRecentSession,
|
|
};
|
|
})
|
|
);
|
|
|
|
// Filter out worktrees with 0 visible sessions
|
|
const nonEmptyWorktrees = worktrees.filter((wt) => wt.sessions.length > 0);
|
|
|
|
// Skip this repository group if all worktrees are empty
|
|
if (nonEmptyWorktrees.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
// Sort worktrees: main first, then by most recent activity
|
|
nonEmptyWorktrees.sort((a, b) => {
|
|
if (a.isMainWorktree && !b.isMainWorktree) return -1;
|
|
if (!a.isMainWorktree && b.isMainWorktree) return 1;
|
|
return (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0);
|
|
});
|
|
|
|
const totalSessions = nonEmptyWorktrees.reduce((sum, wt) => sum + wt.sessions.length, 0);
|
|
const mostRecentSession = Math.max(
|
|
...nonEmptyWorktrees.map((wt) => wt.mostRecentSession ?? 0)
|
|
);
|
|
|
|
repositoryGroups.push({
|
|
id: groupId,
|
|
identity: group.identity,
|
|
worktrees: nonEmptyWorktrees,
|
|
name: group.identity?.name ?? group.projects[0].name,
|
|
mostRecentSession: mostRecentSession > 0 ? mostRecentSession : undefined,
|
|
totalSessions,
|
|
});
|
|
}
|
|
|
|
// 5. Sort repository groups by most recent activity
|
|
repositoryGroups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
|
|
|
return repositoryGroups;
|
|
}
|
|
|
|
/**
|
|
* Lists sessions for a specific worktree.
|
|
* This is a convenience method that returns the worktree ID.
|
|
*
|
|
* @param worktreeId - The worktree ID (same as project ID)
|
|
* @returns The worktree ID for delegation to listSessions
|
|
*/
|
|
getWorktreeProjectId(worktreeId: string): string {
|
|
// Worktree ID is the same as project ID
|
|
return worktreeId;
|
|
}
|
|
}
|