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
+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 {