diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index a4cbf875..4d075c83 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -360,6 +360,22 @@ export const SessionSidebar: React.FC = ({ syncSessionsSnapshotRef.current = 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(() => { let cancelled = false; @@ -397,13 +413,12 @@ export const SessionSidebar: React.FC = ({ }); }; - void refreshGlobalSessions(syncSessionsSnapshotRef.current); void discoverWorktrees(); return () => { cancelled = true; }; - }, [currentDirectory, syncSessionStructureSignature, projects]); + }, [projectWorktreeDiscoveryKey]); React.useEffect(() => { let refreshTimeout: ReturnType | null = null; @@ -1113,7 +1128,6 @@ export const SessionSidebar: React.FC = ({ }); void refreshPrStatusTargets([...uniqueTargets.values()], { - force: true, silent: true, markInitialResolved: true, }); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index b54d5c81..2a09698e 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -1,6 +1,5 @@ import { substituteCommandVariables } from '@/lib/openchamberConfig'; import type { WorktreeMetadata } from '@/types/worktree'; -import { execCommand } from '@/lib/execCommands'; import { deleteRemoteBranch, git, @@ -9,7 +8,7 @@ import { clearWorktreeBootstrapState, markWorktreeBootstrapPending, } from '@/lib/worktrees/worktreeBootstrap'; -import { invalidateResolvedProjectRootCache } from '@/lib/worktrees/worktreeStatus'; +import { invalidateResolvedProjectRootCache, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; import type { CreateGitWorktreePayload, GitWorktreeValidationResult, @@ -55,66 +54,6 @@ const normalizePath = (value: string): string => { 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 => { - 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 => { return value .trim() @@ -227,7 +166,7 @@ export async function listProjectWorktrees(project: ProjectRef): Promise => { - const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); + const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); const worktrees = await git.worktree.list(projectDirectory).catch(() => []); @@ -289,7 +228,7 @@ export type CreateWorktreeArgs = { export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise { 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 created = await git.worktree.create(projectDirectory, payload); diff --git a/packages/ui/src/lib/worktrees/worktreeStatus.ts b/packages/ui/src/lib/worktrees/worktreeStatus.ts index 24ef19b3..0deec1a5 100644 --- a/packages/ui/src/lib/worktrees/worktreeStatus.ts +++ b/packages/ui/src/lib/worktrees/worktreeStatus.ts @@ -152,7 +152,7 @@ const computeProjectRoot = async (directory: string): Promise => { return directory; }; -const resolveProjectRoot = async (directory: string): Promise => { +export const resolveProjectRoot = async (directory: string): Promise => { const cached = resolvedRootCache.get(directory); if (cached && Date.now() - cached.resolvedAt < RESOLVED_ROOT_TTL_MS) { // Refresh recency without extending TTL. diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 61f7cd69..ea94d9c2 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { getSafeStorage } from './utils/safeStorage'; 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_DEFAULT_INTERVAL_MS = 2 * 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_STATUS_STORAGE_KEY = 'openchamber.github-pr-status'; @@ -581,7 +583,7 @@ export const useGitHubPrStatusStore = create()( .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) => { diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 21b313fe..e863ac67 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -23,7 +23,7 @@ type GlobalSessionsState = { archiveSessions: (ids: Iterable, archivedAt?: number) => void; }; -const PAGE_SIZE = 200; +const PAGE_SIZE = 500; let inflightLoad: Promise | null = null; diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 38cab519..0d0dfff4 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1569,6 +1569,9 @@ export function SyncProvider(props: { hasEverConnected: true, connectionPhase: "connected", }) + if (isRecentBoot()) { + return + } for (const dir of childStores.children.keys()) { triggerDirectoryResync(dir) } diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index aecfb378..0019d606 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -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. const seenByDirectory = new Map>() +// 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>() + type SyncMeta = { limit: number cursor: string | undefined @@ -99,7 +103,6 @@ export function useSync() { const childStores = useChildStoreManager() // Refs for mutable tracking (no re-renders) - const inflight = useRef(new Map>()) const optimistic = useRef(new Map>()) const meta = useRef(new Map()) @@ -350,7 +353,7 @@ export function useSync() { const key = keyFor(sessionID) // Dedup inflight requests - const existing = inflight.current.get(key) + const existing = syncSessionInflightByKey.get(key) if (existing) return existing const current = store.getState() @@ -386,35 +389,40 @@ export function useSync() { })) return } + const shouldFetchSession = !hasSession || force + const shouldLoadMessages = !cachedReady || force const promise = (async () => { - // Fetch session info if needed - if (!hasSession || force) { - try { - const result = await retry(() => sdk.session.get({ sessionID, directory })) - if (result.data) { - const s = store.getState() - const sessions = [...s.session] - const idx = Binary.search(sessions, sessionID, (s) => s.id) - if (idx.found) { - sessions[idx.index] = result.data - } else { - sessions.splice(idx.index, 0, result.data) - } - store.setState({ session: sessions }) - } - } catch (e) { - console.error("[sync] failed to fetch session", sessionID, e) - } - } - - // Load messages if needed - if (!cachedReady || force) { - await loadMessages(sessionID) - } + await Promise.all([ + shouldFetchSession + ? (async () => { + try { + const result = await retry(() => sdk.session.get({ sessionID, directory })) + if (result.data) { + const s = store.getState() + const sessions = [...s.session] + const idx = Binary.search(sessions, sessionID, (s) => s.id) + if (idx.found) { + sessions[idx.index] = result.data + } else { + sessions.splice(idx.index, 0, result.data) + } + store.setState({ session: sessions }) + } + } catch (e) { + console.error("[sync] failed to fetch session", sessionID, e) + } + })() + : Promise.resolve(), + shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(), + ]) })() - inflight.current.set(key, promise) - promise.finally(() => inflight.current.delete(key)) + syncSessionInflightByKey.set(key, promise) + promise.finally(() => { + if (syncSessionInflightByKey.get(key) === promise) { + syncSessionInflightByKey.delete(key) + } + }) return promise }, [store, sdk, keyFor, touch, getMetaFor, setMetaFor, loadMessages, directory],