From 68378c603c8ad5450d5d7c68a9803d620d7b8ac8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 20:58:54 +0300 Subject: [PATCH] feat: prioritize recent projects by explicit opens --- .../renderer/hooks/useOpenRecentProject.ts | 3 + .../hooks/useRecentProjectsSection.ts | 20 +- .../recent-projects/renderer/index.ts | 1 + .../utils/recentProjectOpenHistory.ts | 211 ++++++++++++++++++ .../sidebar/DateGroupedSessions.tsx | 18 +- src/renderer/components/team/TeamListView.tsx | 3 + .../utils/recentProjectOpenHistory.test.ts | 101 +++++++++ 7 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts create mode 100644 test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index e1904882..8f755deb 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -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 => { 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); } diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 60fddcd5..63d2c223 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -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(null); const [visibleProjects, setVisibleProjects] = useState(maxProjects); const [aliveTeams, setAliveTeams] = useState([]); + const [openHistoryVersion, setOpenHistoryVersion] = useState(0); const hasFetchedTasksRef = useRef(globalTasksInitialized); const recentProjectsRef = useRef(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( diff --git a/src/features/recent-projects/renderer/index.ts b/src/features/recent-projects/renderer/index.ts index 325e169a..995500fc 100644 --- a/src/features/recent-projects/renderer/index.ts +++ b/src/features/recent-projects/renderer/index.ts @@ -1 +1,2 @@ export { RecentProjectsSection } from './ui/RecentProjectsSection'; +export { recordRecentProjectOpenPaths } from './utils/recentProjectOpenHistory'; diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts new file mode 100644 index 00000000..e7b8de4f --- /dev/null +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -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; + 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(); + + 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 { + return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt])); +} + +function getProjectPaths( + project: Pick +): 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 +): 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 + ): 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); +} diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 082afb0b..59c304f3 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -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); }; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 678dd3c5..d9ea2670 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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] ); diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts new file mode 100644 index 00000000..12df0fec --- /dev/null +++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts @@ -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 { + 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']); + }); +});