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

View file

@ -19,6 +19,10 @@ import {
getCurrentProvisioningProgressForTeam, getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive, isTeamProvisioningActive,
} from '@renderer/store/slices/teamSlice'; } from '@renderer/store/slices/teamSlice';
import {
getProjectSelectionResetState,
getWorktreeNavigationState,
} from '@renderer/store/utils/stateResetHelpers';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
@ -42,6 +46,11 @@ import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { TeamEmptyState } from './TeamEmptyState'; import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import {
findTeamProjectSelectionTarget,
resolveTeamProjectSelection,
teamMatchesProjectSelection,
} from './teamProjectSelection';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamListFilterState } from './TeamListFilterPopover';
@ -256,10 +265,10 @@ export const TeamListView = (): React.JSX.Element => {
projects, projects,
globalTasks, globalTasks,
fetchAllTasks, fetchAllTasks,
viewMode,
repositoryGroups, repositoryGroups,
selectedRepositoryId, selectedRepositoryId,
selectedWorktreeId, selectedWorktreeId,
selectedProjectId,
activeProjectId, activeProjectId,
branchByPath, branchByPath,
} = useStore( } = useStore(
@ -275,10 +284,10 @@ export const TeamListView = (): React.JSX.Element => {
projects: s.projects, projects: s.projects,
globalTasks: s.globalTasks, globalTasks: s.globalTasks,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId, selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId, selectedWorktreeId: s.selectedWorktreeId,
selectedProjectId: s.selectedProjectId,
activeProjectId: s.activeProjectId, activeProjectId: s.activeProjectId,
branchByPath: s.branchByPath, branchByPath: s.branchByPath,
})) }))
@ -361,23 +370,26 @@ export const TeamListView = (): React.JSX.Element => {
}; };
}, [electronMode, showCreateDialog]); }, [electronMode, showCreateDialog]);
const currentProjectPath = useMemo(() => { const currentProjectSelection = useMemo(
if (viewMode === 'grouped') { () =>
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); resolveTeamProjectSelection({
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId); repositoryGroups,
const path = worktree?.path ?? null; projects,
return path ? normalizePath(path) : null; selectedRepositoryId,
} selectedWorktreeId,
const project = projects.find((p) => p.id === activeProjectId); selectedProjectId,
return project ? normalizePath(project.path) : null; activeProjectId,
}, [ }),
viewMode, [
repositoryGroups, repositoryGroups,
selectedRepositoryId, projects,
selectedWorktreeId, selectedRepositoryId,
projects, selectedWorktreeId,
activeProjectId, selectedProjectId,
]); activeProjectId,
]
);
const currentProjectPath = currentProjectSelection.projectPath;
const filteredTeams = useMemo<TeamSummary[]>(() => { const filteredTeams = useMemo<TeamSummary[]>(() => {
let result = teamsWithProvisioning; let result = teamsWithProvisioning;
@ -409,21 +421,11 @@ export const TeamListView = (): React.JSX.Element => {
}); });
} }
if (filter.selectedProjects.size > 0) { if (currentProjectPath) {
result = result.filter( result = result.filter((team) => teamMatchesProjectSelection(team, currentProjectPath));
(t) => t.projectPath != null && filter.selectedProjects.has(t.projectPath.trim())
);
} }
const aliveSet = new Set(aliveTeams); 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) => { result = [...result].sort((a, b) => {
// 1. Alive (running) teams first // 1. Alive (running) teams first
@ -431,19 +433,12 @@ export const TeamListView = (): React.JSX.Element => {
const aliveB = aliveSet.has(b.teamName) ? 0 : 1; const aliveB = aliveSet.has(b.teamName) ? 0 : 1;
if (aliveA !== aliveB) return aliveA - aliveB; if (aliveA !== aliveB) return aliveA - aliveB;
// 2. Matching current project second // 2. Most recently active teams first (stable secondary sort)
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)
const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
if (tsA !== tsB) return tsB - tsA; 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); return a.teamName.localeCompare(b.teamName);
}); });
@ -459,6 +454,34 @@ export const TeamListView = (): React.JSX.Element => {
leadActivityByTeam, 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) // Fetch branches once for all visible team project paths (no live polling)
const teamPaths = useMemo( const teamPaths = useMemo(
() => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[], () => 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. Only available in local Electron mode.
</p> </p>
) : null} ) : 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 ? ( {teamsWithProvisioning.length > 0 ? (
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
@ -749,9 +801,11 @@ export const TeamListView = (): React.JSX.Element => {
</div> </div>
<TeamListFilterPopover <TeamListFilterPopover
filter={filter} filter={filter}
selectedProjectPath={currentProjectPath}
teams={teamsWithProvisioning} teams={teamsWithProvisioning}
aliveTeams={aliveTeams} aliveTeams={aliveTeams}
onFilterChange={setFilter} onFilterChange={setFilter}
onProjectChange={handleProjectSelectionChange}
/> />
</div> </div>
) : null} ) : 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)) { if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
return ( return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]"> <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). * Full state reset (session + project + repository + conversation).
* Used when closing all tabs or resetting to initial state. * 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);
});
});