perf: reduce startup request fanout
Avoid repeated sidebar session and worktree refreshes Limit concurrent GitHub PR status checks Reuse cached worktree root resolution
This commit is contained in:
@@ -360,6 +360,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
syncSessionsSnapshotRef.current = liveSessions;
|
syncSessionsSnapshotRef.current = liveSessions;
|
||||||
}, [syncSessionStructureSignature, liveSessions]);
|
}, [syncSessionStructureSignature, liveSessions]);
|
||||||
|
|
||||||
|
const projectWorktreeDiscoveryKey = React.useMemo(
|
||||||
|
() => projects
|
||||||
|
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
|
||||||
|
.join('|'),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialGlobalSessionsRefreshStartedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialGlobalSessionsRefreshStartedRef.current = true;
|
||||||
|
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -397,13 +413,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
|
||||||
void discoverWorktrees();
|
void discoverWorktrees();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, syncSessionStructureSignature, projects]);
|
}, [projectWorktreeDiscoveryKey]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -1113,7 +1128,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
void refreshPrStatusTargets([...uniqueTargets.values()], {
|
void refreshPrStatusTargets([...uniqueTargets.values()], {
|
||||||
force: true,
|
|
||||||
silent: true,
|
silent: true,
|
||||||
markInitialResolved: true,
|
markInitialResolved: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { execCommand } from '@/lib/execCommands';
|
|
||||||
import {
|
import {
|
||||||
deleteRemoteBranch,
|
deleteRemoteBranch,
|
||||||
git,
|
git,
|
||||||
@@ -9,7 +8,7 @@ import {
|
|||||||
clearWorktreeBootstrapState,
|
clearWorktreeBootstrapState,
|
||||||
markWorktreeBootstrapPending,
|
markWorktreeBootstrapPending,
|
||||||
} from '@/lib/worktrees/worktreeBootstrap';
|
} from '@/lib/worktrees/worktreeBootstrap';
|
||||||
import { invalidateResolvedProjectRootCache } from '@/lib/worktrees/worktreeStatus';
|
import { invalidateResolvedProjectRootCache, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus';
|
||||||
import type {
|
import type {
|
||||||
CreateGitWorktreePayload,
|
CreateGitWorktreePayload,
|
||||||
GitWorktreeValidationResult,
|
GitWorktreeValidationResult,
|
||||||
@@ -55,66 +54,6 @@ const normalizePath = (value: string): string => {
|
|||||||
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toAbsolutePath = (baseDir: string, maybeRelativePath: string): string => {
|
|
||||||
const normalizedBase = normalizePath(baseDir);
|
|
||||||
const normalizedInput = normalizePath(maybeRelativePath);
|
|
||||||
if (!normalizedInput) return normalizedBase;
|
|
||||||
if (normalizedInput.startsWith('/')) return normalizedInput;
|
|
||||||
|
|
||||||
const stack = normalizedBase.split('/').filter(Boolean);
|
|
||||||
const parts = normalizedInput.split('/').filter(Boolean);
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part === '.') continue;
|
|
||||||
if (part === '..') {
|
|
||||||
stack.pop();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
stack.push(part);
|
|
||||||
}
|
|
||||||
return `/${stack.join('/')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const derivePrimaryWorktreeRootFromGitDir = (gitDir: string): string | null => {
|
|
||||||
const normalized = normalizePath(gitDir);
|
|
||||||
if (!normalized) return null;
|
|
||||||
if (normalized.endsWith('/.git')) {
|
|
||||||
return normalized.slice(0, -'/.git'.length) || null;
|
|
||||||
}
|
|
||||||
const worktreesMarker = '/.git/worktrees/';
|
|
||||||
const markerIndex = normalized.indexOf(worktreesMarker);
|
|
||||||
if (markerIndex > 0) {
|
|
||||||
return normalized.slice(0, markerIndex) || null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvePrimaryWorktreeDirectory = async (directory: string): Promise<string> => {
|
|
||||||
const normalizedDirectory = normalizePath(directory);
|
|
||||||
|
|
||||||
const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', normalizedDirectory);
|
|
||||||
const absoluteGitDir = normalizePath((absoluteGitDirResult.stdout || '').trim());
|
|
||||||
if (absoluteGitDirResult.success && absoluteGitDir) {
|
|
||||||
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
|
||||||
if (rootFromAbsoluteGitDir) {
|
|
||||||
return rootFromAbsoluteGitDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonDirResult = await execCommand('git rev-parse --git-common-dir', normalizedDirectory);
|
|
||||||
const rawCommonDir = normalizePath((commonDirResult.stdout || '').trim());
|
|
||||||
if (!commonDirResult.success || !rawCommonDir) {
|
|
||||||
return normalizedDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonDir = toAbsolutePath(normalizedDirectory, rawCommonDir);
|
|
||||||
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
|
||||||
if (rootFromCommonDir) {
|
|
||||||
return rootFromCommonDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedDirectory;
|
|
||||||
};
|
|
||||||
|
|
||||||
const slugifyWorktreeName = (value: string): string => {
|
const slugifyWorktreeName = (value: string): string => {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -227,7 +166,7 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
|
|||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
|
|
||||||
const promise = (async (): Promise<WorktreeMetadata[]> => {
|
const promise = (async (): Promise<WorktreeMetadata[]> => {
|
||||||
const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory);
|
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
|
||||||
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
||||||
|
|
||||||
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
|
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
|
||||||
@@ -289,7 +228,7 @@ export type CreateWorktreeArgs = {
|
|||||||
|
|
||||||
export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise<WorktreeMetadata> {
|
export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise<WorktreeMetadata> {
|
||||||
const projectDirectory = normalizePath(project.path);
|
const projectDirectory = normalizePath(project.path);
|
||||||
const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory);
|
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
|
||||||
const payload = toCreatePayload(args, projectDirectory);
|
const payload = toCreatePayload(args, projectDirectory);
|
||||||
|
|
||||||
const created = await git.worktree.create(projectDirectory, payload);
|
const created = await git.worktree.create(projectDirectory, payload);
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ const computeProjectRoot = async (directory: string): Promise<string> => {
|
|||||||
return directory;
|
return directory;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveProjectRoot = async (directory: string): Promise<string> => {
|
export const resolveProjectRoot = async (directory: string): Promise<string> => {
|
||||||
const cached = resolvedRootCache.get(directory);
|
const cached = resolvedRootCache.get(directory);
|
||||||
if (cached && Date.now() - cached.resolvedAt < RESOLVED_ROOT_TTL_MS) {
|
if (cached && Date.now() - cached.resolvedAt < RESOLVED_ROOT_TTL_MS) {
|
||||||
// Refresh recency without extending TTL.
|
// Refresh recency without extending TTL.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||||
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
|
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
|
||||||
|
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
|
|
||||||
const PR_REVALIDATE_TTL_MS = 90_000;
|
const PR_REVALIDATE_TTL_MS = 90_000;
|
||||||
@@ -10,6 +11,7 @@ const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const;
|
|||||||
const PR_OPEN_BUSY_INTERVAL_MS = 60_000;
|
const PR_OPEN_BUSY_INTERVAL_MS = 60_000;
|
||||||
const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000;
|
const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000;
|
||||||
const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
|
const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
|
||||||
|
const PR_STATUS_REFRESH_CONCURRENCY = 4;
|
||||||
const PR_PERSIST_TTL_MS = 12 * 60 * 60_000;
|
const PR_PERSIST_TTL_MS = 12 * 60 * 60_000;
|
||||||
const PR_STATUS_STORAGE_KEY = 'openchamber.github-pr-status';
|
const PR_STATUS_STORAGE_KEY = 'openchamber.github-pr-status';
|
||||||
|
|
||||||
@@ -581,7 +583,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
|
|||||||
.filter((key): key is string => Boolean(key)),
|
.filter((key): key is string => Boolean(key)),
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(keys.map((key) => get().refresh(key, options)));
|
await mapWithConcurrency(keys, PR_STATUS_REFRESH_CONCURRENCY, (key) => get().refresh(key, options));
|
||||||
},
|
},
|
||||||
|
|
||||||
updateStatus: (key, updater) => {
|
updateStatus: (key, updater) => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type GlobalSessionsState = {
|
|||||||
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
|
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 200;
|
const PAGE_SIZE = 500;
|
||||||
|
|
||||||
let inflightLoad: Promise<LoadResult> | null = null;
|
let inflightLoad: Promise<LoadResult> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1569,6 +1569,9 @@ export function SyncProvider(props: {
|
|||||||
hasEverConnected: true,
|
hasEverConnected: true,
|
||||||
connectionPhase: "connected",
|
connectionPhase: "connected",
|
||||||
})
|
})
|
||||||
|
if (isRecentBoot()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
for (const dir of childStores.children.keys()) {
|
for (const dir of childStores.children.keys()) {
|
||||||
triggerDirectoryResync(dir)
|
triggerDirectoryResync(dir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|||||||
// session recency, not whichever component happened to call sync first.
|
// session recency, not whichever component happened to call sync first.
|
||||||
const seenByDirectory = new Map<string, Set<string>>()
|
const seenByDirectory = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
// Shared across useSync() hook instances. Chat, model controls, and sidebar can
|
||||||
|
// all request the same session during startup; coalesce them into one HTTP load.
|
||||||
|
const syncSessionInflightByKey = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
type SyncMeta = {
|
type SyncMeta = {
|
||||||
limit: number
|
limit: number
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
@@ -99,7 +103,6 @@ export function useSync() {
|
|||||||
const childStores = useChildStoreManager()
|
const childStores = useChildStoreManager()
|
||||||
|
|
||||||
// Refs for mutable tracking (no re-renders)
|
// Refs for mutable tracking (no re-renders)
|
||||||
const inflight = useRef(new Map<string, Promise<void>>())
|
|
||||||
const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>())
|
const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>())
|
||||||
const meta = useRef(new Map<string, SyncMeta>())
|
const meta = useRef(new Map<string, SyncMeta>())
|
||||||
|
|
||||||
@@ -350,7 +353,7 @@ export function useSync() {
|
|||||||
const key = keyFor(sessionID)
|
const key = keyFor(sessionID)
|
||||||
|
|
||||||
// Dedup inflight requests
|
// Dedup inflight requests
|
||||||
const existing = inflight.current.get(key)
|
const existing = syncSessionInflightByKey.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
@@ -386,35 +389,40 @@ export function useSync() {
|
|||||||
})) return
|
})) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldFetchSession = !hasSession || force
|
||||||
|
const shouldLoadMessages = !cachedReady || force
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
// Fetch session info if needed
|
await Promise.all([
|
||||||
if (!hasSession || force) {
|
shouldFetchSession
|
||||||
try {
|
? (async () => {
|
||||||
const result = await retry(() => sdk.session.get({ sessionID, directory }))
|
try {
|
||||||
if (result.data) {
|
const result = await retry(() => sdk.session.get({ sessionID, directory }))
|
||||||
const s = store.getState()
|
if (result.data) {
|
||||||
const sessions = [...s.session]
|
const s = store.getState()
|
||||||
const idx = Binary.search(sessions, sessionID, (s) => s.id)
|
const sessions = [...s.session]
|
||||||
if (idx.found) {
|
const idx = Binary.search(sessions, sessionID, (s) => s.id)
|
||||||
sessions[idx.index] = result.data
|
if (idx.found) {
|
||||||
} else {
|
sessions[idx.index] = result.data
|
||||||
sessions.splice(idx.index, 0, result.data)
|
} else {
|
||||||
}
|
sessions.splice(idx.index, 0, result.data)
|
||||||
store.setState({ session: sessions })
|
}
|
||||||
}
|
store.setState({ session: sessions })
|
||||||
} catch (e) {
|
}
|
||||||
console.error("[sync] failed to fetch session", sessionID, e)
|
} catch (e) {
|
||||||
}
|
console.error("[sync] failed to fetch session", sessionID, e)
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
// Load messages if needed
|
: Promise.resolve(),
|
||||||
if (!cachedReady || force) {
|
shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(),
|
||||||
await loadMessages(sessionID)
|
])
|
||||||
}
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
inflight.current.set(key, promise)
|
syncSessionInflightByKey.set(key, promise)
|
||||||
promise.finally(() => inflight.current.delete(key))
|
promise.finally(() => {
|
||||||
|
if (syncSessionInflightByKey.get(key) === promise) {
|
||||||
|
syncSessionInflightByKey.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
return promise
|
return promise
|
||||||
},
|
},
|
||||||
[store, sdk, keyFor, touch, getMetaFor, setMetaFor, loadMessages, directory],
|
[store, sdk, keyFor, touch, getMetaFor, setMetaFor, loadMessages, directory],
|
||||||
|
|||||||
Reference in New Issue
Block a user