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