fix: stabilize codex recent project selection

This commit is contained in:
777genius 2026-04-14 16:52:44 +03:00
parent c8f9d9bbdd
commit f0f43be064
4 changed files with 98 additions and 13 deletions

View file

@ -29,6 +29,7 @@ export interface ListDashboardRecentProjectsDeps<TViewModel> {
export class ListDashboardRecentProjectsUseCase<TViewModel> {
readonly #cacheTtlMs: number;
readonly #degradedCacheTtlMs: number;
readonly #inFlightByCacheKey = new Map<string, Promise<TViewModel>>();
constructor(private readonly deps: ListDashboardRecentProjectsDeps<TViewModel>) {
this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
@ -41,6 +42,20 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
return cached;
}
const inFlight = this.#inFlightByCacheKey.get(cacheKey);
if (inFlight) {
return inFlight;
}
const execution = this.#executeUncached(cacheKey).finally(() => {
this.#inFlightByCacheKey.delete(cacheKey);
});
this.#inFlightByCacheKey.set(cacheKey, execution);
return execution;
}
async #executeUncached(cacheKey: string): Promise<TViewModel> {
const startedAt = this.deps.clock.now();
const results = await Promise.all(
this.deps.sources.map((source, index) => this.#loadSource(source, index))

View file

@ -12,10 +12,10 @@ import type { RecentProjectIdentityResolver } from '@features/recent-projects/ma
import type { ServiceContext } from '@main/services';
const CODEX_THREAD_LIMIT = 40;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 1_200;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 1_800;
const CODEX_REQUEST_TIMEOUT_MS = 1_800;
const CODEX_SOURCE_TIMEOUT_MS = 1_500;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
const CODEX_REQUEST_TIMEOUT_MS = 4_500;
const CODEX_SOURCE_TIMEOUT_MS = 5_200;
const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150;
function isInteractiveSource(source: unknown): boolean {

View file

@ -1,4 +1,6 @@
import { execCli } from '@main/utils/childProcess';
import { constants as fsConstants } from 'node:fs';
import * as fsp from 'node:fs/promises';
import path from 'node:path';
const CACHE_VERIFY_TTL_MS = 30_000;
@ -6,13 +8,60 @@ let cachedBinaryPath: string | null | undefined;
let cacheVerifiedAt = 0;
let resolveInFlight: Promise<string | null> | null = null;
async function verifyBinary(candidate: string): Promise<string | null> {
async function fileExists(filePath: string): Promise<boolean> {
try {
await execCli(candidate, ['--version'], { timeout: 2_000, windowsHide: true });
return candidate;
await fsp.access(filePath, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
function expandWindowsExtensions(candidate: string): string[] {
if (process.platform !== 'win32') {
return [candidate];
}
const pathext = process.env.PATHEXT?.split(';').filter(Boolean) ?? [
'.EXE',
'.CMD',
'.BAT',
'.COM',
];
const hasKnownExtension = pathext.some((ext) =>
candidate.toLowerCase().endsWith(ext.toLowerCase())
);
if (hasKnownExtension) {
return [candidate];
}
return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)];
}
async function verifyBinary(candidate: string): Promise<string | null> {
const expandedCandidates = expandWindowsExtensions(candidate);
if (path.isAbsolute(candidate) || candidate.includes(path.sep)) {
for (const expandedCandidate of expandedCandidates) {
if (await fileExists(expandedCandidate)) {
return expandedCandidate;
}
}
return null;
}
const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
for (const pathEntry of pathEntries) {
for (const expandedCandidate of expandedCandidates) {
const resolvedCandidate = path.join(pathEntry, expandedCandidate);
if (await fileExists(resolvedCandidate)) {
return resolvedCandidate;
}
}
}
return null;
}
export class CodexBinaryResolver {

View file

@ -325,7 +325,28 @@ export const DateGroupedSessions = (): React.JSX.Element => {
});
}, [viewMode, repositoryGroups, projects]);
const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId;
const effectiveSelectedWorktreeId =
selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null;
const effectiveSelectedRepositoryId = useMemo(() => {
if (selectedRepositoryId) {
return selectedRepositoryId;
}
if (!effectiveSelectedWorktreeId) {
return null;
}
return (
repositoryGroups.find((repo) =>
repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId)
)?.id ?? null
);
}, [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId]);
const activeProjectValue =
viewMode === 'grouped'
? effectiveSelectedRepositoryId
: (activeProjectId ?? selectedProjectId ?? null);
const handleProjectValueChange = (id: string): void => {
if (viewMode === 'grouped') selectRepository(id);
@ -333,8 +354,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
};
// Worktree state
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
const activeRepo = repositoryGroups.find((r) => r.id === effectiveSelectedRepositoryId);
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === effectiveSelectedWorktreeId);
const worktrees = (activeRepo?.worktrees ?? []).filter(
(w) => (w.totalSessions ?? w.sessions.length) > 0
);
@ -684,7 +705,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
{mainWorktree && (
<WorktreeItem
worktree={mainWorktree}
isSelected={mainWorktree.id === selectedWorktreeId}
isSelected={mainWorktree.id === effectiveSelectedWorktreeId}
onSelect={() => handleSelectWorktree(mainWorktree)}
/>
)}
@ -705,7 +726,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
<WorktreeItem
key={worktree.id}
worktree={worktree}
isSelected={worktree.id === selectedWorktreeId}
isSelected={worktree.id === effectiveSelectedWorktreeId}
onSelect={() => handleSelectWorktree(worktree)}
/>
))}