From 43f2426a9bbe9e3894265415d90c60dd83e4ae00 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 18:10:09 +0300 Subject: [PATCH] fix: sync team project filters with app selection --- .../components/team/TeamListFilterPopover.tsx | 26 +-- src/renderer/components/team/TeamListView.tsx | 136 ++++++++++----- .../components/team/teamProjectSelection.ts | 156 ++++++++++++++++++ src/renderer/store/utils/stateResetHelpers.ts | 14 ++ .../team/teamProjectSelection.test.ts | 122 ++++++++++++++ 5 files changed, 400 insertions(+), 54 deletions(-) create mode 100644 src/renderer/components/team/teamProjectSelection.ts create mode 100644 test/renderer/components/team/teamProjectSelection.test.ts diff --git a/src/renderer/components/team/TeamListFilterPopover.tsx b/src/renderer/components/team/TeamListFilterPopover.tsx index 4bcbe185..3c70e159 100644 --- a/src/renderer/components/team/TeamListFilterPopover.tsx +++ b/src/renderer/components/team/TeamListFilterPopover.tsx @@ -11,12 +11,10 @@ import { Filter } from 'lucide-react'; import type { TeamSummary } from '@shared/types'; export interface TeamListFilterState { - selectedProjects: Set; selectedStatuses: Set; } 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(); 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} > handleProjectToggle(project)} /> {folderName(project)} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 9a6a56a6..8e884231 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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(() => { 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.

) : null} + {currentProjectPath ? ( +
+
+

+ Selected project +

+
+ + + {folderName(currentProjectPath)} + +
+

+ {currentProjectPath} +

+
+ +
+ ) : null} {teamsWithProvisioning.length > 0 ? (
@@ -749,9 +801,11 @@ export const TeamListView = (): React.JSX.Element => {
) : 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 (
diff --git a/src/renderer/components/team/teamProjectSelection.ts b/src/renderer/components/team/teamProjectSelection.ts new file mode 100644 index 00000000..2fbe04f7 --- /dev/null +++ b/src/renderer/components/team/teamProjectSelection.ts @@ -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 + ); +} diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts index d8407e34..60ecab71 100644 --- a/src/renderer/store/utils/stateResetHelpers.ts +++ b/src/renderer/store/utils/stateResetHelpers.ts @@ -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 { + 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. diff --git a/test/renderer/components/team/teamProjectSelection.test.ts b/test/renderer/components/team/teamProjectSelection.test.ts new file mode 100644 index 00000000..376ea2d3 --- /dev/null +++ b/test/renderer/components/team/teamProjectSelection.test.ts @@ -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); + }); +});