feat: move projects to sidebar rail and speed up session switching (#506)

* feat: move projects from header tabs to left sidebar rail

* refactor: remove variant from git generation session context

* feat: implemented drandrop action for navrails

* refactor: improve sidebar drag-and-drop smoothness and remove floating overlay

* fix: prevent mobile nav rail touch from closing drawer and opening menu

* fix: hide header tabs on desktop

* fix: prevent session flicker when switching projects

* style: soften chat panel divider borders

* style: improve icon contrast across core navigation

* fix: stabilize session selection during project switching

* style: unify sidebar surfaces and transparent section layers

* style: remove UI shadows and keep only scroll shadow

* perf: speed up project switching with cached session loading

* perf: speed up Git changes view and background refresh

* perf: make session switching lighter and less aggressive

* perf: optimized sessions list loading while project switching

* perf: smooth chat rendering and reduce interaction spikes

* fix: restore reliable load older messages visibility
This commit is contained in:
Bohdan Triapitsyn
2026-02-25 14:32:23 +02:00
committed by GitHub
parent 4c69bccf56
commit 10851bd7ac
51 changed files with 1995 additions and 1399 deletions
+8 -7
View File
@@ -499,18 +499,19 @@ export const useMessageStore = create<MessageStore>()(
: Math.max(baseLimit, userExpandedLimit ?? 0);
// Don't pass Infinity to API - use undefined for "fetch all".
// For finite loads, overfetch by 1 so hasMoreAbove is accurate.
const fetchLimit = noLimit ? undefined : targetLimit + 1;
// Use targetLimit directly and infer "has more" when payload fills the window,
// matching OpenCode behavior and avoiding hidden "load older" on exact-limit responses.
const fetchLimit = noLimit ? undefined : targetLimit;
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
// Filter out reverted messages first
const revertMessageId = getSessionRevertMessageId(sessionId);
const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId);
// Accurate older-history detection for finite loads.
// If server returns > targetLimit, there are older messages above current window.
// If server fills the requested window, assume there may be more above.
// This is intentionally optimistic and corrected on subsequent load-more calls.
const hasMoreAbove = typeof fetchLimit === 'number'
? messagesWithoutReverted.length > targetLimit
? messagesWithoutReverted.length >= targetLimit
: false;
const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
@@ -2626,7 +2627,7 @@ export const useMessageStore = create<MessageStore>()(
});
try {
const fetchLimit = desiredLimit + 1;
const fetchLimit = desiredLimit;
const allMessages = await executeWithSessionDirectory(
sessionId,
() => opencodeClient.getSessionMessages(sessionId, fetchLimit)
@@ -2634,7 +2635,7 @@ export const useMessageStore = create<MessageStore>()(
if (direction === "up" && currentMessages.length > 0) {
const dedupedMessages = dedupeMessagesById(allMessages);
const hasPotentialMore = allMessages.length >= fetchLimit;
const hasPotentialMore = allMessages.length >= desiredLimit;
const firstCurrentMessage = currentMessages[0];
const indexInAll = dedupedMessages.findIndex((message) => message.info.id === firstCurrentMessage.info.id);
+265 -142
View File
@@ -77,6 +77,81 @@ const readSessionSelectionMap = (): SessionSelectionMap => {
};
let sessionSelectionCache: SessionSelectionMap | null = null;
let loadSessionsRequestSeq = 0;
type ProjectSessionResult = {
projectId: string;
projectPath: string | null;
sessions: Session[];
discoveredWorktrees: WorktreeMetadata[];
validPaths: Set<string>;
};
type ProjectSessionCacheEntry = {
cachedAt: number;
result: ProjectSessionResult;
};
type ProjectRepoCacheEntry = {
cachedAt: number;
isGitRepo: boolean;
};
const PROJECT_SESSION_CACHE_TTL_MS = 30_000;
const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000;
const projectSessionCache = new Map<string, ProjectSessionCacheEntry>();
const projectRepoStatusCache = new Map<string, ProjectRepoCacheEntry>();
const getFreshProjectSessionCache = (projectPath: string): ProjectSessionResult | null => {
const key = normalizePath(projectPath) ?? projectPath;
const cached = projectSessionCache.get(key);
if (!cached) {
return null;
}
if (Date.now() - cached.cachedAt > PROJECT_SESSION_CACHE_TTL_MS) {
projectSessionCache.delete(key);
return null;
}
return cached.result;
};
const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => {
const key = normalizePath(projectPath) ?? projectPath;
projectSessionCache.set(key, { cachedAt: Date.now(), result });
};
const pruneProjectCaches = (validProjectPaths: Iterable<string>) => {
const valid = new Set<string>();
for (const path of validProjectPaths) {
const normalized = normalizePath(path) ?? path;
if (normalized) {
valid.add(normalized);
}
}
for (const key of projectSessionCache.keys()) {
if (!valid.has(key)) {
projectSessionCache.delete(key);
}
}
for (const key of projectRepoStatusCache.keys()) {
if (!valid.has(key)) {
projectRepoStatusCache.delete(key);
}
}
};
const getProjectRepoStatus = async (projectPath: string): Promise<boolean> => {
const key = normalizePath(projectPath) ?? projectPath;
const cached = projectRepoStatusCache.get(key);
if (cached && Date.now() - cached.cachedAt <= PROJECT_REPO_STATUS_CACHE_TTL_MS) {
return cached.isGitRepo;
}
const isGitRepo = await checkIsGitRepository(key).catch(() => false);
projectRepoStatusCache.set(key, { cachedAt: Date.now(), isGitRepo });
return isGitRepo;
};
const getSessionSelectionMap = (): SessionSelectionMap => {
if (!sessionSelectionCache) {
@@ -262,7 +337,8 @@ const getSessionDirectory = (sessions: Session[], sessionId: string): string | n
const hydrateSessionWorktreeMetadata = async (
sessions: Session[],
projectDirectory: string | null,
existingMetadata: Map<string, WorktreeMetadata>
existingMetadata: Map<string, WorktreeMetadata>,
preloadedWorktrees?: WorktreeMetadata[]
): Promise<Map<string, WorktreeMetadata> | null> => {
const normalizedProject = normalizePath(projectDirectory);
if (!normalizedProject || sessions.length === 0) {
@@ -278,11 +354,15 @@ const hydrateSessionWorktreeMetadata = async (
}
let worktreeEntries: WorktreeMetadata[];
try {
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
} catch (error) {
console.debug("Failed to hydrate worktree metadata from worktree list:", error);
return null;
if (Array.isArray(preloadedWorktrees)) {
worktreeEntries = preloadedWorktrees;
} else {
try {
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
} catch (error) {
console.debug("Failed to hydrate worktree metadata from worktree list:", error);
return null;
}
}
if (!Array.isArray(worktreeEntries) || worktreeEntries.length === 0) {
@@ -377,6 +457,8 @@ export const useSessionStore = create<SessionStore>()(
availableWorktreesByProject: new Map(),
loadSessions: async () => {
const requestSeq = ++loadSessionsRequestSeq;
const isLatestRequest = () => requestSeq === loadSessionsRequestSeq;
set({ isLoading: true, error: null });
try {
const directoryStore = useDirectoryStore.getState();
@@ -548,15 +630,148 @@ export const useSessionStore = create<SessionStore>()(
? projectsStore.projects
: (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []);
type ProjectSessionResult = {
projectId: string;
projectPath: string | null;
sessions: Session[];
discoveredWorktrees: WorktreeMetadata[];
validPaths: Set<string>;
const applyProjectResults = async (projectResults: ProjectSessionResult[]) => {
const sessionsByDirectory = new Map<string, Session[]>();
projectResults.forEach((result) => {
if (!result.projectPath) {
return;
}
result.validPaths.forEach((directory) => {
const directoryKey = normalizePath(directory) ?? directory;
const directorySessions = result.sessions.filter((session) => {
const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey;
return dir === directoryKey;
});
sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions));
});
});
const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat());
const stateSnapshot = get();
let nextWorktreeMetadata = stateSnapshot.worktreeMetadata;
for (const result of projectResults) {
if (!result.projectPath) {
continue;
}
try {
const hydratedMetadata = await hydrateSessionWorktreeMetadata(
result.sessions,
result.projectPath,
nextWorktreeMetadata,
result.discoveredWorktrees
);
if (hydratedMetadata) {
nextWorktreeMetadata = hydratedMetadata;
}
} catch (metadataError) {
console.debug("Failed to refresh worktree metadata during session load:", metadataError);
}
}
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
projectResults.forEach((result) => {
if (result.projectPath) {
worktreesByProject.set(result.projectPath, result.discoveredWorktrees);
}
});
const allValidPaths = new Set<string>();
projectResults.forEach((result) => {
result.validPaths.forEach((value) => {
const key = normalizePath(value) ?? value;
if (key) {
allValidPaths.add(key);
}
});
});
const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null;
const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate)
? activeDirectoryCandidate
: (activeProjectRoot ?? activeDirectoryCandidate);
const activeDirectorySessions = activeDirectory
? sessionsByDirectory.get(activeDirectory) ?? []
: mergedSessions;
const validSessionIds = new Set(mergedSessions.map((session) => session.id));
// Keep directory-scoped stored selections tidy.
for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) {
clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id));
}
const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null);
let nextCurrentId = stateSnapshot.currentSessionId;
const currentSessionInActiveDirectory = Boolean(
nextCurrentId && activeDirectorySessions.some((session) => session.id === nextCurrentId)
);
if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || (directoryChanged && !currentSessionInActiveDirectory)) {
nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null;
}
if (activeDirectory) {
const storedSelection = getStoredSessionForDirectory(activeDirectory);
if (storedSelection && validSessionIds.has(storedSelection)) {
nextCurrentId = storedSelection;
}
}
const resolvedDirectoryForCurrent = (() => {
if (!nextCurrentId) {
return activeDirectory ?? null;
}
const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path;
if (metadataPath) {
return normalizePath(metadataPath) ?? metadataPath;
}
const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId);
if (sessionDir) {
return sessionDir;
}
return activeDirectory ?? null;
})();
if (!isLatestRequest()) {
return;
}
try {
opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined);
} catch (error) {
console.warn("Failed to sync OpenCode directory after session load:", error);
}
const activeWorktrees = activeProjectRoot
? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? []
: [];
set({
sessions: mergedSessions,
sessionsByDirectory,
currentSessionId: nextCurrentId,
lastLoadedDirectory: activeDirectory ?? null,
isLoading: false,
worktreeMetadata: nextWorktreeMetadata,
availableWorktrees: activeWorktrees,
availableWorktreesByProject: worktreesByProject,
});
if (activeDirectory) {
storeSessionForDirectory(activeDirectory, nextCurrentId);
}
if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) {
storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId);
}
};
if (projectEntries.length === 0) {
if (!isLatestRequest()) {
return;
}
set({
sessions: [],
sessionsByDirectory: new Map(),
@@ -570,6 +785,29 @@ export const useSessionStore = create<SessionStore>()(
return;
}
pruneProjectCaches(projectEntries.map((entry) => entry.path));
const activeProjectId = projectsStore.activeProjectId;
const cachedProjectResults: ProjectSessionResult[] = [];
projectEntries.forEach((project) => {
const normalizedProject = normalizePath(project.path);
if (!normalizedProject) {
return;
}
const cached = getFreshProjectSessionCache(normalizedProject);
if (cached) {
cachedProjectResults.push(cached);
}
});
const hasCachedActiveProject = cachedProjectResults.some(
(result) => result.projectId === activeProjectId
);
if (hasCachedActiveProject && isLatestRequest()) {
await applyProjectResults(cachedProjectResults);
}
const projectResults: ProjectSessionResult[] = await Promise.all(
projectEntries.map(async (project: Pick<ProjectEntry, 'id' | 'path'>) => {
const normalizedProject = normalizePath(project.path);
@@ -583,7 +821,13 @@ export const useSessionStore = create<SessionStore>()(
};
}
const isGitRepo = await checkIsGitRepository(normalizedProject).catch(() => false);
const cached = getFreshProjectSessionCache(normalizedProject);
const isActiveProject = project.id === activeProjectId;
if (cached && !isActiveProject) {
return cached;
}
const isGitRepo = await getProjectRepoStatus(normalizedProject);
const parentSessions = await fetchSessionsForDirectory(normalizedProject || null);
vscodeDebugLog("projectSessions", {
projectId: project.id,
@@ -634,144 +878,23 @@ export const useSessionStore = create<SessionStore>()(
const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]);
return {
const result: ProjectSessionResult = {
projectId: project.id,
projectPath: normalizedProject,
sessions: mergedSessions,
discoveredWorktrees,
validPaths,
};
setProjectSessionCache(normalizedProject, result);
return result;
})
);
const sessionsByDirectory = new Map<string, Session[]>();
projectResults.forEach((result) => {
if (!result.projectPath) {
return;
}
result.validPaths.forEach((directory) => {
const directoryKey = normalizePath(directory) ?? directory;
const directorySessions = result.sessions.filter((session) => {
const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey;
return dir === directoryKey;
});
sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions));
});
});
const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat());
const stateSnapshot = get();
let nextWorktreeMetadata = stateSnapshot.worktreeMetadata;
for (const result of projectResults) {
if (!result.projectPath) {
continue;
}
try {
const hydratedMetadata = await hydrateSessionWorktreeMetadata(
result.sessions,
result.projectPath,
nextWorktreeMetadata
);
if (hydratedMetadata) {
nextWorktreeMetadata = hydratedMetadata;
}
} catch (metadataError) {
console.debug("Failed to refresh worktree metadata during session load:", metadataError);
}
}
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
projectResults.forEach((result) => {
if (result.projectPath) {
worktreesByProject.set(result.projectPath, result.discoveredWorktrees);
}
});
const allValidPaths = new Set<string>();
projectResults.forEach((result) => {
result.validPaths.forEach((value) => {
const key = normalizePath(value) ?? value;
if (key) {
allValidPaths.add(key);
}
});
});
const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null;
const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate)
? activeDirectoryCandidate
: (activeProjectRoot ?? activeDirectoryCandidate);
const activeDirectorySessions = activeDirectory
? sessionsByDirectory.get(activeDirectory) ?? []
: mergedSessions;
const validSessionIds = new Set(mergedSessions.map((session) => session.id));
// Keep directory-scoped stored selections tidy.
for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) {
clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id));
}
const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null);
let nextCurrentId = stateSnapshot.currentSessionId;
if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || directoryChanged) {
nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null;
}
if (activeDirectory) {
const storedSelection = getStoredSessionForDirectory(activeDirectory);
if (storedSelection && validSessionIds.has(storedSelection)) {
nextCurrentId = storedSelection;
}
}
const resolvedDirectoryForCurrent = (() => {
if (!nextCurrentId) {
return activeDirectory ?? null;
}
const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path;
if (metadataPath) {
return normalizePath(metadataPath) ?? metadataPath;
}
const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId);
if (sessionDir) {
return sessionDir;
}
return activeDirectory ?? null;
})();
try {
opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined);
} catch (error) {
console.warn("Failed to sync OpenCode directory after session load:", error);
}
const activeWorktrees = activeProjectRoot
? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? []
: [];
set({
sessions: mergedSessions,
sessionsByDirectory,
currentSessionId: nextCurrentId,
lastLoadedDirectory: activeDirectory ?? null,
isLoading: false,
worktreeMetadata: nextWorktreeMetadata,
availableWorktrees: activeWorktrees,
availableWorktreesByProject: worktreesByProject,
});
if (activeDirectory) {
storeSessionForDirectory(activeDirectory, nextCurrentId);
}
if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) {
storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId);
}
await applyProjectResults(projectResults);
} catch (error) {
if (!isLatestRequest()) {
return;
}
set({
error: error instanceof Error ? error.message : "Failed to load sessions",
isLoading: false,
+128 -20
View File
@@ -12,8 +12,10 @@ const GIT_POLL_MAX_INTERVAL = 10000;
const GIT_POLL_BACKOFF_STEP = 5000;
const LOG_STALE_THRESHOLD = 10000;
const DIFF_PREFETCH_MAX_FILES = 25;
const DIFF_PREFETCH_FOCUS_MAX_FILES = 40;
const DIFF_PREFETCH_CONCURRENCY = 4;
const DIFF_PREFETCH_TIMEOUT_MS = 15000;
const RECENT_DIRECTORIES_LIMIT = 3;
// Diff cache limits to prevent memory bloat with many modified files
const DIFF_CACHE_MAX_ENTRIES = 30;
@@ -37,6 +39,7 @@ interface GitStore {
directories: Map<string, DirectoryGitState>;
activeDirectory: string | null;
recentDirectories: string[];
isLoadingStatus: boolean;
isLoadingLog: boolean;
@@ -53,12 +56,13 @@ interface GitStore {
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean }) => Promise<void>;
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean; silentIfCached?: boolean }) => Promise<void>;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
clearDiffCache: (directory: string) => void;
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise<void>;
setLogMaxCount: (directory: string, maxCount: number) => void;
@@ -84,6 +88,28 @@ interface GitAPI {
getGitFileDiff: (directory: string, options: { path: string }) => Promise<GitFileDiffResponse>;
}
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
const diffFetchGenerationByDirectory = new Map<string, number>();
const getDiffFetchGeneration = (directory: string): number =>
diffFetchGenerationByDirectory.get(directory) ?? 0;
const bumpDiffFetchGeneration = (directory: string): number => {
const next = getDiffFetchGeneration(directory) + 1;
diffFetchGenerationByDirectory.set(directory, next);
return next;
};
const getInFlightDiffs = (directory: string): Set<string> => {
const existing = inFlightDiffFetchesByDirectory.get(directory);
if (existing) {
return existing;
}
const created = new Set<string>();
inFlightDiffFetchesByDirectory.set(directory, created);
return created;
};
const createEmptyDirectoryState = (): DirectoryGitState => ({
isGitRepo: null,
status: null,
@@ -241,6 +267,7 @@ export const useGitStore = create<GitStore>()(
(set, get) => ({
directories: new Map(),
activeDirectory: null,
recentDirectories: [],
isLoadingStatus: false,
isLoadingLog: false,
isLoadingBranches: false,
@@ -249,15 +276,26 @@ export const useGitStore = create<GitStore>()(
currentPollInterval: GIT_POLL_BASE_INTERVAL,
setActiveDirectory: (directory) => {
const { activeDirectory, directories } = get();
const { activeDirectory, directories, recentDirectories } = get();
if (activeDirectory === directory) return;
if (activeDirectory) {
bumpDiffFetchGeneration(activeDirectory);
}
if (directory) {
bumpDiffFetchGeneration(directory);
}
const nextRecentDirectories = directory
? [directory, ...recentDirectories.filter((entry) => entry !== directory)].slice(0, RECENT_DIRECTORIES_LIMIT)
: recentDirectories;
if (directory && !directories.has(directory)) {
const newDirectories = new Map(directories);
newDirectories.set(directory, createEmptyDirectoryState());
set({ activeDirectory: directory, directories: newDirectories });
set({ activeDirectory: directory, recentDirectories: nextRecentDirectories, directories: newDirectories });
} else {
set({ activeDirectory: directory });
set({ activeDirectory: directory, recentDirectories: nextRecentDirectories });
}
},
@@ -322,6 +360,9 @@ export const useGitStore = create<GitStore>()(
}
const hasFileContentChange = changedPaths.size > 0;
if (hasFileContentChange) {
bumpDiffFetchGeneration(directory);
}
newDirectories.set(directory, {
...currentDirState,
@@ -423,10 +464,12 @@ export const useGitStore = create<GitStore>()(
set({ directories: newDirectories });
}
const { force = false } = options;
const { force = false, silentIfCached = false } = options;
const now = Date.now();
await get().fetchStatus(directory, git);
await get().fetchStatus(directory, git, {
silent: silentIfCached && Boolean(dirState?.status),
});
const updatedDirState = get().directories.get(directory);
if (!updatedDirState?.isGitRepo) return;
@@ -462,6 +505,7 @@ export const useGitStore = create<GitStore>()(
},
clearDiffCache: (directory) => {
bumpDiffFetchGeneration(directory);
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory);
if (dirState) {
@@ -474,13 +518,49 @@ export const useGitStore = create<GitStore>()(
const dirState = get().directories.get(directory);
if (!dirState?.status?.files || dirState.status.files.length === 0) return;
const files = dirState.status.files;
const limitedFilesToFetch = dirState.status.files
.map((file) => file.path)
.slice(0, DIFF_PREFETCH_MAX_FILES);
await get().prefetchDiffs(directory, git, limitedFilesToFetch, { maxFiles: DIFF_PREFETCH_MAX_FILES });
},
// Find files that need fetching (no cache)
const filesToFetch = files.filter((file) => !dirState.diffCache.has(file.path));
prefetchDiffs: async (directory, git, filePaths, options = {}) => {
const dirState = get().directories.get(directory);
if (!dirState?.status?.files || dirState.status.files.length === 0 || filePaths.length === 0) return;
const limitedFilesToFetch = filesToFetch.slice(0, DIFF_PREFETCH_MAX_FILES);
if (limitedFilesToFetch.length === 0) return;
const { maxFiles = DIFF_PREFETCH_FOCUS_MAX_FILES } = options;
const availablePaths = new Set(dirState.status.files.map((file) => file.path));
const inFlight = getInFlightDiffs(directory);
const dedupedPaths: string[] = [];
const seen = new Set<string>();
for (const filePath of filePaths) {
if (!filePath || seen.has(filePath)) {
continue;
}
seen.add(filePath);
if (!availablePaths.has(filePath)) {
continue;
}
if (dirState.diffCache.has(filePath)) {
continue;
}
if (inFlight.has(filePath)) {
continue;
}
dedupedPaths.push(filePath);
}
const limitedFilePaths = dedupedPaths.slice(0, Math.max(1, maxFiles));
if (limitedFilePaths.length === 0) return;
const generation = getDiffFetchGeneration(directory);
if (typeof document !== 'undefined' && document.hidden) {
return;
}
limitedFilePaths.forEach((path) => inFlight.add(path));
let nextIndex = 0;
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
@@ -488,7 +568,7 @@ export const useGitStore = create<GitStore>()(
const takeNext = () => {
const current = nextIndex;
nextIndex += 1;
return current < limitedFilesToFetch.length ? limitedFilesToFetch[current] : null;
return current < limitedFilePaths.length ? limitedFilePaths[current] : null;
};
const fetchWithTimeout = async (filePath: string) => {
@@ -505,19 +585,30 @@ export const useGitStore = create<GitStore>()(
const worker = async () => {
for (;;) {
if (generation !== getDiffFetchGeneration(directory)) {
return;
}
const next = takeNext();
if (!next) return;
try {
results.push(await fetchWithTimeout(next.path));
results.push(await fetchWithTimeout(next));
} catch {
// Ignore individual failures/timeouts during prefetch.
} finally {
inFlight.delete(next);
}
}
};
const workerCount = Math.min(DIFF_PREFETCH_CONCURRENCY, limitedFilesToFetch.length);
const workerCount = Math.min(DIFF_PREFETCH_CONCURRENCY, limitedFilePaths.length);
await Promise.allSettled(Array.from({ length: workerCount }, () => worker()));
limitedFilePaths.forEach((path) => inFlight.delete(path));
if (generation !== getDiffFetchGeneration(directory)) {
return;
}
// Update diff cache with results
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory);
@@ -559,17 +650,34 @@ export const useGitStore = create<GitStore>()(
return;
}
const { activeDirectory } = get();
const { activeDirectory, recentDirectories } = get();
if (!activeDirectory) {
set({ pollIntervalId: schedulePoll() });
return;
}
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
if (statusChanged) {
await get().fetchLog(activeDirectory, git);
// Pre-fetch all diffs so they're ready when user opens Diff tab
void get().fetchAllDiffs(activeDirectory, git);
const pollTargets = [
activeDirectory,
...recentDirectories
.filter((directory) => directory !== activeDirectory)
.slice(0, Math.max(0, RECENT_DIRECTORIES_LIMIT - 1)),
];
let anyStatusChanged = false;
for (const targetDirectory of pollTargets) {
const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true });
if (statusChanged) {
anyStatusChanged = true;
if (targetDirectory === activeDirectory) {
await get().fetchLog(activeDirectory, git);
// Pre-fetch all diffs so they're ready when user opens Diff tab
void get().fetchAllDiffs(activeDirectory, git);
}
}
}
if (anyStatusChanged) {
// Reset to base interval on changes
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
} else {
@@ -7,6 +7,25 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { PROJECT_COLORS } from '@/lib/projectMeta';
/** Pick a color key that's least used among existing projects */
const pickAutoColor = (projects: ProjectEntry[]): string => {
const colorKeys = PROJECT_COLORS.map((c) => c.key);
const usageCounts = new Map<string, number>();
for (const key of colorKeys) {
usageCounts.set(key, 0);
}
for (const p of projects) {
if (p.color && usageCounts.has(p.color)) {
usageCounts.set(p.color, (usageCounts.get(p.color) ?? 0) + 1);
}
}
// Find minimum usage, then pick randomly among those with min usage
const minUsage = Math.min(...usageCounts.values());
const candidates = colorKeys.filter((k) => usageCounts.get(k) === minUsage);
return candidates[Math.floor(Math.random() * candidates.length)];
};
interface ProjectPathValidationResult {
ok: boolean;
@@ -280,6 +299,7 @@ export const useProjectsStore = create<ProjectsStore>()(
id,
path: normalizedPath,
label,
color: pickAutoColor(get().projects),
addedAt: now,
lastOpenedAt: now,
};
+22 -1
View File
@@ -45,6 +45,19 @@ const normalizePath = (value?: string | null): string | null => {
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
};
const sessionChoiceAnalysisSignature = new Map<string, string>();
const ENABLE_ACTIVE_SESSION_TRIM = false;
const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => {
const lastMessage = messages[messages.length - 1];
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
const lastAssistant = [...messages]
.reverse()
.find((message) => message.info?.role === 'assistant');
const lastAssistantId = typeof lastAssistant?.info?.id === 'string' ? lastAssistant.info.id : '';
return `${messages.length}:${lastMessageId}:${lastAssistantId}`;
};
const resolveSessionDirectory = (
sessions: Session[],
sessionId: string | null | undefined,
@@ -319,7 +332,9 @@ export const useSessionStore = create<SessionStore>()(
await get().loadMessages(id);
}
get().trimToViewportWindow(id, getMessageLimit());
if (ENABLE_ACTIVE_SESSION_TRIM) {
get().trimToViewportWindow(id, getMessageLimit());
}
// Analyze session messages to extract agent/model/variant choices
// This ensures context is available even when ModelControls isn't mounted
@@ -327,12 +342,18 @@ export const useSessionStore = create<SessionStore>()(
if (sessionMessages && sessionMessages.length > 0) {
const agents = useConfigStore.getState().agents;
if (agents.length > 0) {
const analysisSignature = buildSessionChoiceAnalysisSignature(sessionMessages);
if (sessionChoiceAnalysisSignature.get(id) === analysisSignature) {
get().evictLeastRecentlyUsed();
return;
}
try {
await useContextStore.getState().analyzeAndSaveExternalSessionChoices(
id,
agents,
get().messages
);
sessionChoiceAnalysisSignature.set(id, analysisSignature);
} catch (error) {
console.warn('Failed to analyze session choices:', error);
}