feat: prioritize recent projects by explicit opens

This commit is contained in:
777genius 2026-04-14 20:58:54 +03:00
parent 1932ddcbe2
commit 68378c603c
7 changed files with 353 additions and 4 deletions

View file

@ -15,6 +15,7 @@ import {
findMatchingWorktree,
type WorktreeMatch,
} from '../utils/navigation';
import { recordRecentProjectOpenPaths } from '../utils/recentProjectOpenHistory';
const logger = createLogger('Feature:RecentProjects:open');
@ -92,6 +93,7 @@ export function useOpenRecentProject(): {
async (project: DashboardRecentProject): Promise<void> => {
try {
await openTarget(project.openTarget, project.associatedPaths);
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
} catch (error) {
logger.error('Failed to open recent project', error);
}
@ -116,6 +118,7 @@ export function useOpenRecentProject(): {
}
await openSyntheticPath(selectedPath, [selectedPath]);
recordRecentProjectOpenPaths([selectedPath]);
} catch (error) {
logger.error('Failed to select project folder', error);
}

View file

@ -7,6 +7,10 @@ import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNor
import { useShallow } from 'zustand/react/shallow';
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
import {
sortRecentProjectsByDisplayPriority,
subscribeRecentProjectOpenHistory,
} from '../utils/recentProjectOpenHistory';
import {
getRecentProjectsClientSnapshot,
loadRecentProjectsWithClientCache,
@ -74,6 +78,7 @@ export function useRecentProjectsSection(
const [error, setError] = useState<string | null>(null);
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [openHistoryVersion, setOpenHistoryVersion] = useState(0);
const hasFetchedTasksRef = useRef(globalTasksInitialized);
const recentProjectsRef = useRef<DashboardRecentProject[]>(initialSnapshot?.projects ?? []);
@ -144,6 +149,11 @@ export function useRecentProjectsSection(
}
}, [maxProjects, searchQuery]);
useEffect(
() => subscribeRecentProjectOpenHistory(() => setOpenHistoryVersion((current) => current + 1)),
[]
);
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
const activeTeamsByProject = useMemo(() => {
@ -170,12 +180,18 @@ export function useRecentProjectsSection(
const decoratedCards = useMemo(
() =>
adaptRecentProjectsSection({
projects: recentProjects,
projects: sortRecentProjectsByDisplayPriority(recentProjects),
taskCountsByProject,
activeTeamsByProject,
tasksLoading: globalTasksLoading,
}),
[activeTeamsByProject, globalTasksLoading, recentProjects, taskCountsByProject]
[
activeTeamsByProject,
globalTasksLoading,
openHistoryVersion,
recentProjects,
taskCountsByProject,
]
);
const filteredCards = useMemo(

View file

@ -1 +1,2 @@
export { RecentProjectsSection } from './ui/RecentProjectsSection';
export { recordRecentProjectOpenPaths } from './utils/recentProjectOpenHistory';

View file

@ -0,0 +1,211 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
const RECENT_PROJECT_OPEN_HISTORY_EVENT = 'recent-projects:open-history-changed';
const OPEN_PRIORITY_WINDOW_MS = 1000 * 60 * 60 * 48;
const MAX_HISTORY_ENTRIES = 120;
interface RecentProjectOpenHistoryEntry {
path: string;
openedAt: number;
}
interface RecentProjectOpenHistoryState {
version: 1;
entries: RecentProjectOpenHistoryEntry[];
}
function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function normalizeHistoryPath(projectPath: string): string | null {
const trimmed = projectPath.trim();
if (!trimmed) {
return null;
}
return normalizePath(trimmed);
}
function readHistoryState(): RecentProjectOpenHistoryState {
if (!canUseLocalStorage()) {
return { version: 1, entries: [] };
}
try {
const raw = window.localStorage.getItem(RECENT_PROJECT_OPEN_HISTORY_KEY);
if (!raw) {
return { version: 1, entries: [] };
}
const parsed = JSON.parse(raw) as Partial<RecentProjectOpenHistoryState>;
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
return {
version: 1,
entries: entries
.filter(
(entry): entry is RecentProjectOpenHistoryEntry =>
!!entry &&
typeof entry.path === 'string' &&
typeof entry.openedAt === 'number' &&
Number.isFinite(entry.openedAt)
)
.map((entry) => ({
path: entry.path,
openedAt: entry.openedAt,
})),
};
} catch {
return { version: 1, entries: [] };
}
}
function pruneEntries(
entries: readonly RecentProjectOpenHistoryEntry[]
): RecentProjectOpenHistoryEntry[] {
const byPath = new Map<string, number>();
for (const entry of entries) {
const normalizedPath = normalizeHistoryPath(entry.path);
if (!normalizedPath) {
continue;
}
byPath.set(normalizedPath, Math.max(byPath.get(normalizedPath) ?? 0, entry.openedAt));
}
return Array.from(byPath.entries())
.map(([historyPath, openedAt]) => ({ path: historyPath, openedAt }))
.sort((left, right) => right.openedAt - left.openedAt)
.slice(0, MAX_HISTORY_ENTRIES);
}
function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]): void {
if (!canUseLocalStorage()) {
return;
}
const nextState: RecentProjectOpenHistoryState = {
version: 1,
entries: pruneEntries(entries),
};
try {
window.localStorage.setItem(RECENT_PROJECT_OPEN_HISTORY_KEY, JSON.stringify(nextState));
window.dispatchEvent(new CustomEvent(RECENT_PROJECT_OPEN_HISTORY_EVENT));
} catch {
// Best-effort persistence only.
}
}
function createHistoryLookup(): Map<string, number> {
return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt]));
}
function getProjectPaths(
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
): string[] {
return [project.primaryPath, ...project.associatedPaths]
.map((projectPath) => normalizeHistoryPath(projectPath))
.filter((projectPath): projectPath is string => Boolean(projectPath));
}
export function recordRecentProjectOpenPaths(
projectPaths: readonly string[],
openedAt: number = Date.now()
): void {
const normalizedPaths = Array.from(
new Set(
projectPaths
.map((projectPath) => normalizeHistoryPath(projectPath))
.filter((projectPath): projectPath is string => Boolean(projectPath))
)
);
if (normalizedPaths.length === 0) {
return;
}
const existing = readHistoryState().entries;
writeHistoryEntries([
...existing,
...normalizedPaths.map((projectPath) => ({
path: projectPath,
openedAt,
})),
]);
}
export function getRecentProjectLastOpenedAt(
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
): number {
const historyLookup = createHistoryLookup();
return getProjectPaths(project).reduce(
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
0
);
}
export function sortRecentProjectsByDisplayPriority(
projects: readonly DashboardRecentProject[],
now: number = Date.now()
): DashboardRecentProject[] {
const historyLookup = createHistoryLookup();
const getLastOpenedAt = (
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
): number =>
getProjectPaths(project).reduce(
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
0
);
const isPriorityOpen = (openedAt: number): boolean =>
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
return [...projects].sort((left, right) => {
const leftOpenedAt = getLastOpenedAt(left);
const rightOpenedAt = getLastOpenedAt(right);
const leftPriority = isPriorityOpen(leftOpenedAt);
const rightPriority = isPriorityOpen(rightOpenedAt);
if (leftPriority !== rightPriority) {
return leftPriority ? -1 : 1;
}
if (leftPriority && rightPriority && leftOpenedAt !== rightOpenedAt) {
return rightOpenedAt - leftOpenedAt;
}
if (left.mostRecentActivity !== right.mostRecentActivity) {
return right.mostRecentActivity - left.mostRecentActivity;
}
if (leftOpenedAt !== rightOpenedAt) {
return rightOpenedAt - leftOpenedAt;
}
return left.name.localeCompare(right.name);
});
}
export function subscribeRecentProjectOpenHistory(listener: () => void): () => void {
if (typeof window === 'undefined') {
return () => undefined;
}
const handleChange = (): void => listener();
window.addEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange);
return () => {
window.removeEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange);
};
}
export function resetRecentProjectOpenHistoryForTests(): void {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(RECENT_PROJECT_OPEN_HISTORY_KEY);
}

View file

@ -7,6 +7,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import {
@ -351,8 +352,20 @@ export const DateGroupedSessions = (): React.JSX.Element => {
}, [activeProjectValue, effectiveSelectedRepositoryId, projects, repositoryGroups, viewMode]);
const handleProjectValueChange = (id: string): void => {
if (viewMode === 'grouped') selectRepository(id);
else setActiveProject(id);
if (viewMode === 'grouped') {
const repositoryGroup = repositoryGroups.find((repo) => repo.id === id);
if (repositoryGroup) {
recordRecentProjectOpenPaths(repositoryGroup.worktrees.map((worktree) => worktree.path));
}
selectRepository(id);
return;
}
const project = projects.find((candidate) => candidate.id === id);
if (project?.path) {
recordRecentProjectOpenPaths([project.path]);
}
setActiveProject(id);
};
// Worktree state
@ -368,6 +381,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
const worktreeName = activeWorktree?.name ?? 'main';
const handleSelectWorktree = (worktree: Worktree): void => {
recordRecentProjectOpenPaths([worktree.path]);
selectWorktree(worktree.id);
setIsWorktreeDropdownOpen(false);
};

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Badge } from '@renderer/components/ui/badge';
@ -475,10 +476,12 @@ export const TeamListView = (): React.JSX.Element => {
if (target.kind === 'grouped') {
useStore.setState(getWorktreeNavigationState(target.repositoryId, target.worktreeId));
void useStore.getState().fetchSessionsInitial(target.worktreeId);
recordRecentProjectOpenPaths([projectPath]);
return;
}
useStore.getState().selectProject(target.projectId);
recordRecentProjectOpenPaths([projectPath]);
},
[projects, repositoryGroups]
);

View file

@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
getRecentProjectLastOpenedAt,
recordRecentProjectOpenPaths,
resetRecentProjectOpenHistoryForTests,
sortRecentProjectsByDisplayPriority,
} from '@features/recent-projects/renderer/utils/recentProjectOpenHistory';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
function makeProject(
overrides: Partial<DashboardRecentProject> = {}
): DashboardRecentProject {
return {
id: 'repo:alpha',
name: 'alpha',
primaryPath: '/workspace/alpha',
associatedPaths: ['/workspace/alpha'],
mostRecentActivity: 1_000,
providerIds: ['anthropic'],
source: 'claude',
openTarget: {
type: 'existing-worktree',
repositoryId: 'repo-alpha',
worktreeId: 'wt-alpha',
},
...overrides,
};
}
describe('recentProjectOpenHistory', () => {
beforeEach(() => {
resetRecentProjectOpenHistoryForTests();
});
it('records normalized paths and resolves the latest explicit open across associated paths', () => {
recordRecentProjectOpenPaths(['/Users/Test/Project/', '/users/test/project'], 5_000);
recordRecentProjectOpenPaths(['/Users/Test/Project/feature'], 8_000);
expect(
getRecentProjectLastOpenedAt(
makeProject({
primaryPath: '/users/test/project',
associatedPaths: ['/users/test/project', '/Users/Test/Project/feature'],
})
)
).toBe(8_000);
});
it('prioritizes explicitly opened projects ahead of raw activity during the priority window', () => {
const openedProject = makeProject({
id: 'repo:opened',
name: 'opened',
primaryPath: '/workspace/opened',
associatedPaths: ['/workspace/opened'],
mostRecentActivity: 5_000,
});
const activeProject = makeProject({
id: 'repo:active',
name: 'active',
primaryPath: '/workspace/active',
associatedPaths: ['/workspace/active'],
mostRecentActivity: 9_000,
});
recordRecentProjectOpenPaths(['/workspace/opened'], 10_000);
expect(
sortRecentProjectsByDisplayPriority([activeProject, openedProject], 11_000).map(
(project) => project.id
)
).toEqual(['repo:opened', 'repo:active']);
});
it('falls back to activity sorting after the explicit-open priority window expires', () => {
const openedProject = makeProject({
id: 'repo:opened',
name: 'opened',
primaryPath: '/workspace/opened',
associatedPaths: ['/workspace/opened'],
mostRecentActivity: 5_000,
});
const activeProject = makeProject({
id: 'repo:active',
name: 'active',
primaryPath: '/workspace/active',
associatedPaths: ['/workspace/active'],
mostRecentActivity: 9_000,
});
recordRecentProjectOpenPaths(['/workspace/opened'], 10_000);
expect(
sortRecentProjectsByDisplayPriority(
[activeProject, openedProject],
10_000 + 1000 * 60 * 60 * 72
).map((project) => project.id)
).toEqual(['repo:active', 'repo:opened']);
});
});