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:
committed by
GitHub
parent
4c69bccf56
commit
10851bd7ac
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user