fix: sync team project filters with app selection

This commit is contained in:
777genius 2026-04-14 18:10:09 +03:00
parent 487dcabff5
commit 43f2426a9b
5 changed files with 400 additions and 54 deletions

View file

@ -11,12 +11,10 @@ import { Filter } from 'lucide-react';
import type { TeamSummary } from '@shared/types';
export interface TeamListFilterState {
selectedProjects: Set<string>;
selectedStatuses: Set<string>;
}
export const EMPTY_TEAM_FILTER: TeamListFilterState = {
selectedProjects: new Set(),
selectedStatuses: new Set(),
};
@ -26,31 +24,38 @@ function folderName(fullPath: string): string {
interface TeamListFilterPopoverProps {
filter: TeamListFilterState;
selectedProjectPath: string | null;
teams: TeamSummary[];
aliveTeams: string[];
onFilterChange: (filter: TeamListFilterState) => void;
onProjectChange: (projectPath: string | null) => void;
}
export const TeamListFilterPopover = ({
filter,
selectedProjectPath,
teams,
aliveTeams,
onFilterChange,
onProjectChange,
}: TeamListFilterPopoverProps): React.JSX.Element => {
const activeCount = useMemo(() => {
let count = 0;
if (filter.selectedStatuses.size > 0) count += 1;
if (filter.selectedProjects.size > 0) count += 1;
if (selectedProjectPath) count += 1;
return count;
}, [filter.selectedStatuses, filter.selectedProjects]);
}, [filter.selectedStatuses, selectedProjectPath]);
const uniqueProjects = useMemo(() => {
const paths = new Set<string>();
for (const team of teams) {
if (team.projectPath?.trim()) paths.add(team.projectPath.trim());
}
if (selectedProjectPath?.trim()) {
paths.add(selectedProjectPath.trim());
}
return [...paths].sort((a, b) => folderName(a).localeCompare(folderName(b)));
}, [teams]);
}, [selectedProjectPath, teams]);
const handleStatusToggle = (status: string): void => {
const next = new Set(filter.selectedStatuses);
@ -63,17 +68,12 @@ export const TeamListFilterPopover = ({
};
const handleProjectToggle = (project: string): void => {
const next = new Set(filter.selectedProjects);
if (next.has(project)) {
next.delete(project);
} else {
next.add(project);
}
onFilterChange({ ...filter, selectedProjects: next });
onProjectChange(selectedProjectPath === project ? null : project);
};
const handleClearAll = (): void => {
onFilterChange(EMPTY_TEAM_FILTER);
onProjectChange(null);
};
const aliveSet = useMemo(() => new Set(aliveTeams), [aliveTeams]);
@ -156,7 +156,7 @@ export const TeamListFilterPopover = ({
title={project}
>
<Checkbox
checked={filter.selectedProjects.has(project)}
checked={selectedProjectPath === project}
onCheckedChange={() => handleProjectToggle(project)}
/>
<span className="truncate">{folderName(project)}</span>

View file

@ -19,6 +19,10 @@ import {
getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive,
} from '@renderer/store/slices/teamSlice';
import {
getProjectSelectionResetState,
getWorktreeNavigationState,
} from '@renderer/store/utils/stateResetHelpers';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
@ -42,6 +46,11 @@ import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import {
findTeamProjectSelectionTarget,
resolveTeamProjectSelection,
teamMatchesProjectSelection,
} from './teamProjectSelection';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
@ -256,10 +265,10 @@ export const TeamListView = (): React.JSX.Element => {
projects,
globalTasks,
fetchAllTasks,
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectedProjectId,
activeProjectId,
branchByPath,
} = useStore(
@ -275,10 +284,10 @@ export const TeamListView = (): React.JSX.Element => {
projects: s.projects,
globalTasks: s.globalTasks,
fetchAllTasks: s.fetchAllTasks,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
selectedProjectId: s.selectedProjectId,
activeProjectId: s.activeProjectId,
branchByPath: s.branchByPath,
}))
@ -361,23 +370,26 @@ export const TeamListView = (): React.JSX.Element => {
};
}, [electronMode, showCreateDialog]);
const currentProjectPath = useMemo(() => {
if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
const path = worktree?.path ?? null;
return path ? normalizePath(path) : null;
}
const project = projects.find((p) => p.id === activeProjectId);
return project ? normalizePath(project.path) : null;
}, [
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
projects,
activeProjectId,
]);
const currentProjectSelection = useMemo(
() =>
resolveTeamProjectSelection({
repositoryGroups,
projects,
selectedRepositoryId,
selectedWorktreeId,
selectedProjectId,
activeProjectId,
}),
[
repositoryGroups,
projects,
selectedRepositoryId,
selectedWorktreeId,
selectedProjectId,
activeProjectId,
]
);
const currentProjectPath = currentProjectSelection.projectPath;
const filteredTeams = useMemo<TeamSummary[]>(() => {
let result = teamsWithProvisioning;
@ -409,21 +421,11 @@ export const TeamListView = (): React.JSX.Element => {
});
}
if (filter.selectedProjects.size > 0) {
result = result.filter(
(t) => t.projectPath != null && filter.selectedProjects.has(t.projectPath.trim())
);
if (currentProjectPath) {
result = result.filter((team) => teamMatchesProjectSelection(team, currentProjectPath));
}
const aliveSet = new Set(aliveTeams);
const matchesProject = currentProjectPath
? (t: TeamSummary): boolean => {
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
return (
t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false
);
}
: null;
result = [...result].sort((a, b) => {
// 1. Alive (running) teams first
@ -431,19 +433,12 @@ export const TeamListView = (): React.JSX.Element => {
const aliveB = aliveSet.has(b.teamName) ? 0 : 1;
if (aliveA !== aliveB) return aliveA - aliveB;
// 2. Matching current project second
if (matchesProject) {
const projA = matchesProject(a) ? 0 : 1;
const projB = matchesProject(b) ? 0 : 1;
if (projA !== projB) return projA - projB;
}
// 3. Most recently active teams first (stable secondary sort)
// 2. Most recently active teams first (stable secondary sort)
const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
if (tsA !== tsB) return tsB - tsA;
// 4. Fallback: alphabetical by team name for deterministic order
// 3. Fallback: alphabetical by team name for deterministic order
return a.teamName.localeCompare(b.teamName);
});
@ -459,6 +454,34 @@ export const TeamListView = (): React.JSX.Element => {
leadActivityByTeam,
]);
const handleProjectSelectionChange = useCallback(
(projectPath: string | null): void => {
if (!projectPath) {
useStore.setState(getProjectSelectionResetState());
return;
}
const target = findTeamProjectSelectionTarget(repositoryGroups, projects, projectPath);
if (!target) {
console.warn('Unable to resolve selected team project path:', projectPath);
return;
}
if (target.kind === 'grouped') {
useStore.setState(getWorktreeNavigationState(target.repositoryId, target.worktreeId));
void useStore.getState().fetchSessionsInitial(target.worktreeId);
return;
}
useStore.getState().selectProject(target.projectId);
},
[projects, repositoryGroups]
);
const handleResetProjectSelection = useCallback((): void => {
handleProjectSelectionChange(null);
}, [handleProjectSelectionChange]);
// Fetch branches once for all visible team project paths (no live polling)
const teamPaths = useMemo(
() => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[],
@ -731,6 +754,35 @@ export const TeamListView = (): React.JSX.Element => {
Only available in local Electron mode.
</p>
) : null}
{currentProjectPath ? (
<div className="mt-3 flex items-start justify-between gap-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Selected project
</p>
<div className="mt-1 flex items-center gap-2 text-sm text-[var(--color-text)]">
<FolderOpen size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate font-medium" title={currentProjectPath}>
{folderName(currentProjectPath)}
</span>
</div>
<p
className="mt-1 truncate text-xs text-[var(--color-text-muted)]"
title={currentProjectPath}
>
{currentProjectPath}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={handleResetProjectSelection}
>
Reset
</Button>
</div>
) : null}
{teamsWithProvisioning.length > 0 ? (
<div className="mt-3 flex items-center gap-2">
@ -749,9 +801,11 @@ export const TeamListView = (): React.JSX.Element => {
</div>
<TeamListFilterPopover
filter={filter}
selectedProjectPath={currentProjectPath}
teams={teamsWithProvisioning}
aliveTeams={aliveTeams}
onFilterChange={setFilter}
onProjectChange={handleProjectSelectionChange}
/>
</div>
) : null}
@ -794,7 +848,7 @@ export const TeamListView = (): React.JSX.Element => {
);
}
const hasActiveFilters = filter.selectedStatuses.size > 0 || filter.selectedProjects.size > 0;
const hasActiveFilters = filter.selectedStatuses.size > 0 || currentProjectPath != null;
if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">

View file

@ -0,0 +1,156 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import type { Project, RepositoryGroup } from '@renderer/types/data';
import type { TeamSummary } from '@shared/types';
export interface ResolveTeamProjectSelectionInput {
repositoryGroups: readonly RepositoryGroup[];
projects: readonly Project[];
selectedRepositoryId: string | null;
selectedWorktreeId: string | null;
selectedProjectId: string | null;
activeProjectId: string | null;
}
export interface ResolvedTeamProjectSelection {
projectPath: string | null;
repositoryId: string | null;
worktreeId: string | null;
projectId: string | null;
}
export type TeamProjectSelectionTarget =
| {
kind: 'grouped';
repositoryId: string;
worktreeId: string;
projectPath: string;
}
| {
kind: 'flat';
projectId: string;
projectPath: string;
};
function findWorktreeSelection(
repositoryGroups: readonly RepositoryGroup[],
worktreeId: string
): { repositoryId: string; worktreeId: string; projectPath: string } | null {
for (const repositoryGroup of repositoryGroups) {
const worktree = repositoryGroup.worktrees.find((candidate) => candidate.id === worktreeId);
if (worktree) {
return {
repositoryId: repositoryGroup.id,
worktreeId: worktree.id,
projectPath: worktree.path,
};
}
}
return null;
}
export function resolveTeamProjectSelection({
repositoryGroups,
projects,
selectedRepositoryId,
selectedWorktreeId,
selectedProjectId,
activeProjectId,
}: ResolveTeamProjectSelectionInput): ResolvedTeamProjectSelection {
const effectiveWorktreeId = selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null;
if (effectiveWorktreeId) {
const worktreeSelection = findWorktreeSelection(repositoryGroups, effectiveWorktreeId);
if (worktreeSelection) {
return {
projectPath: worktreeSelection.projectPath,
repositoryId: worktreeSelection.repositoryId,
worktreeId: worktreeSelection.worktreeId,
projectId: worktreeSelection.worktreeId,
};
}
}
const effectiveProjectId = activeProjectId ?? selectedProjectId ?? null;
if (effectiveProjectId) {
const project = projects.find((candidate) => candidate.id === effectiveProjectId);
if (project) {
return {
projectPath: project.path,
repositoryId: null,
worktreeId: null,
projectId: project.id,
};
}
}
if (selectedRepositoryId) {
const repositoryGroup = repositoryGroups.find(
(candidate) => candidate.id === selectedRepositoryId
);
const fallbackWorktree = repositoryGroup?.worktrees[0] ?? null;
if (fallbackWorktree) {
return {
projectPath: fallbackWorktree.path,
repositoryId: repositoryGroup?.id ?? null,
worktreeId: fallbackWorktree.id,
projectId: fallbackWorktree.id,
};
}
}
return {
projectPath: null,
repositoryId: null,
worktreeId: null,
projectId: null,
};
}
export function findTeamProjectSelectionTarget(
repositoryGroups: readonly RepositoryGroup[],
projects: readonly Project[],
projectPath: string
): TeamProjectSelectionTarget | null {
const normalizedProjectPath = normalizePath(projectPath);
for (const repositoryGroup of repositoryGroups) {
const worktree = repositoryGroup.worktrees.find(
(candidate) => normalizePath(candidate.path) === normalizedProjectPath
);
if (worktree) {
return {
kind: 'grouped',
repositoryId: repositoryGroup.id,
worktreeId: worktree.id,
projectPath: worktree.path,
};
}
}
const project = projects.find(
(candidate) => normalizePath(candidate.path) === normalizedProjectPath
);
if (project) {
return {
kind: 'flat',
projectId: project.id,
projectPath: project.path,
};
}
return null;
}
export function teamMatchesProjectSelection(team: TeamSummary, projectPath: string): boolean {
const normalizedProjectPath = normalizePath(projectPath);
if (team.projectPath && normalizePath(team.projectPath) === normalizedProjectPath) {
return true;
}
return (
team.projectPathHistory?.some(
(candidate) => normalizePath(candidate) === normalizedProjectPath
) ?? false
);
}

View file

@ -39,6 +39,20 @@ export function getWorktreeNavigationState(repoId: string, worktreeId: string):
};
}
/**
* Clear the active project/worktree selection without resetting unrelated UI state.
* Used when a screen wants to remove the current project context entirely.
*/
export function getProjectSelectionResetState(): Partial<AppState> {
return {
selectedRepositoryId: null,
selectedWorktreeId: null,
selectedProjectId: null,
activeProjectId: null,
...getSessionResetState(),
};
}
/**
* Full state reset (session + project + repository + conversation).
* Used when closing all tabs or resetting to initial state.

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
findTeamProjectSelectionTarget,
resolveTeamProjectSelection,
teamMatchesProjectSelection,
} from '@renderer/components/team/teamProjectSelection';
import type { Project, RepositoryGroup } from '@renderer/types/data';
import type { TeamSummary } from '@shared/types';
const repositoryGroups: RepositoryGroup[] = [
{
id: 'repo-headless',
identity: null,
name: 'headless',
mostRecentSession: 1,
totalSessions: 5,
worktrees: [
{
id: 'wt-headless',
path: '/Users/test/headless',
name: 'headless',
isMainWorktree: true,
source: 'git',
sessions: [],
totalSessions: 5,
createdAt: 1,
},
],
},
];
const projects: Project[] = [
{
id: 'project-standalone',
name: 'standalone',
path: '/Users/test/standalone',
sessions: [],
totalSessions: 2,
createdAt: 1,
},
];
describe('teamProjectSelection', () => {
it('resolves selected grouped worktree path', () => {
expect(
resolveTeamProjectSelection({
repositoryGroups,
projects,
selectedRepositoryId: 'repo-headless',
selectedWorktreeId: 'wt-headless',
selectedProjectId: 'wt-headless',
activeProjectId: 'wt-headless',
})
).toEqual({
projectPath: '/Users/test/headless',
repositoryId: 'repo-headless',
worktreeId: 'wt-headless',
projectId: 'wt-headless',
});
});
it('falls back to active project id when grouped ids are stale', () => {
expect(
resolveTeamProjectSelection({
repositoryGroups,
projects,
selectedRepositoryId: null,
selectedWorktreeId: null,
selectedProjectId: null,
activeProjectId: 'wt-headless',
})
).toEqual({
projectPath: '/Users/test/headless',
repositoryId: 'repo-headless',
worktreeId: 'wt-headless',
projectId: 'wt-headless',
});
});
it('finds grouped selection target by project path', () => {
expect(
findTeamProjectSelectionTarget(repositoryGroups, projects, '/users/test/headless/')
).toEqual({
kind: 'grouped',
repositoryId: 'repo-headless',
worktreeId: 'wt-headless',
projectPath: '/Users/test/headless',
});
});
it('falls back to flat projects when no grouped worktree exists', () => {
expect(
findTeamProjectSelectionTarget(repositoryGroups, projects, '/users/test/standalone/')
).toEqual({
kind: 'flat',
projectId: 'project-standalone',
projectPath: '/Users/test/standalone',
});
});
it('matches team project history against the selected project', () => {
const team = {
teamName: 'demo-team',
displayName: 'Demo Team',
description: '',
color: null,
deletedAt: null,
pendingCreate: false,
partialLaunchFailure: false,
teamLaunchState: null,
projectPath: '/Users/test/other',
projectPathHistory: ['/Users/test/headless', '/Users/test/archive'],
lastActivity: null,
members: [],
} as TeamSummary;
expect(teamMatchesProjectSelection(team, '/users/test/headless')).toBe(true);
expect(teamMatchesProjectSelection(team, '/users/test/missing')).toBe(false);
});
});