diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b749139a..96cef170 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -3551,7 +3551,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null; const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); + const selectedDraftProjectBranchesFetchedAt = useGitStore( + (s) => (selectedDraftProjectPath ? s.directories.get(selectedDraftProjectPath)?.lastBranchesFetch ?? 0 : 0), + ); const selectedDraftProjectIsGitRepo = useIsGitRepo(selectedDraftProjectPath); + const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all); const fetchGitStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false); @@ -3570,13 +3574,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - if (selectedDraftProjectBranches?.all) { + // Stale-while-revalidate: branches seeded from the persisted cache show + // instantly. Refresh based on staleness (not mere presence) so a cached + // list can't go stale, while only showing the discovering spinner when + // there is nothing to display yet. + const DRAFT_BRANCHES_SWR_TTL_MS = 30_000; + const isStale = + !selectedDraftProjectBranchesFetchedAt || + Date.now() - selectedDraftProjectBranchesFetchedAt > DRAFT_BRANCHES_SWR_TTL_MS; + + if (hasDraftBranchList && !isStale) { setIsDiscoveringDraftBranches(false); return; } let cancelled = false; - setIsDiscoveringDraftBranches(true); + setIsDiscoveringDraftBranches(!hasDraftBranchList); void fetchBranches(selectedDraftProjectPath, runtimeGit) .finally(() => { @@ -3588,7 +3601,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return () => { cancelled = true; }; - }, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]); + }, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranchesFetchedAt, hasDraftBranchList, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]); const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? ''; diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 54149bc3..9b8d0494 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -306,7 +306,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); - const directoryStore = useDirectoryStore(sessionDirectory ?? undefined); + // Archived rows are historical and never need live state, yet they point at + // dozens of (often deleted) worktrees — bootstrapping each from the sidebar + // triggers a pointless session-list fetch + 6×2s empty-retry storm on startup. + // Skip bootstrap for archived rows; the store ref is only read on-demand via + // getState() in the export handlers (never subscribed). Active rows keep + // bootstrapping so live cross-directory session/status still aggregates. + const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket }); const sync = useSync(); const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); diff --git a/packages/ui/src/hooks/useOpenCodeReadiness.ts b/packages/ui/src/hooks/useOpenCodeReadiness.ts index cda696b2..2986cbda 100644 --- a/packages/ui/src/hooks/useOpenCodeReadiness.ts +++ b/packages/ui/src/hooks/useOpenCodeReadiness.ts @@ -4,11 +4,19 @@ export function useOpenCodeReadiness() { const isInitialized = useConfigStore((s) => s.isInitialized); const connectionPhase = useConfigStore((s) => s.connectionPhase); const lastDisconnectReason = useConfigStore((s) => s.lastDisconnectReason); - const isUnavailable = !isInitialized && lastDisconnectReason === 'init_error'; + // Stale-while-revalidate: when provider data was hydrated from the persisted + // cache, treat the pickers as ready immediately so they paint last-known + // models/agents while initializeApp() refreshes in the background. Without + // this, the cache is invisible — the pickers stay on "Loading…" until the + // full init round-trip completes even though the data is already in the store. + const hasCachedProviders = useConfigStore((s) => s.providers.length > 0); + const isReady = isInitialized || hasCachedProviders; + // Only surface "unavailable" when we have nothing to show AND init failed. + const isUnavailable = !isReady && lastDisconnectReason === 'init_error'; return { - isReady: isInitialized, - isLoading: !isInitialized && !isUnavailable, + isReady, + isLoading: !isReady && !isUnavailable, isUnavailable, connectionPhase, }; diff --git a/packages/ui/src/lib/runtime-fetch.test.ts b/packages/ui/src/lib/runtime-fetch.test.ts index 73387b14..d034e7a1 100644 --- a/packages/ui/src/lib/runtime-fetch.test.ts +++ b/packages/ui/src/lib/runtime-fetch.test.ts @@ -287,3 +287,76 @@ describe('runtimeFetch transport contract', () => { } }); }); + +describe('runtimeFetch read coalescing', () => { + test('coalesces concurrent identical GET reads into one fetch', async () => { + const previous = getRuntimeUrlResolver(); + let calls = 0; + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + globalThis.fetch = (async () => { + calls += 1; + await new Promise((r) => setTimeout(r, 20)); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + + const [a, b] = await Promise.all([ + runtimeFetch('/api/config/providers'), + runtimeFetch('/api/config/providers'), + ]); + + expect(calls).toBe(1); + // Each caller gets an independently-readable clone. + expect(await a.json()).toEqual({ ok: true }); + expect(await b.json()).toEqual({ ok: true }); + + // After settle the entry is gone — a later call re-fetches. + await runtimeFetch('/api/config/providers'); + expect(calls).toBe(2); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); + + test('does not coalesce non-GET, non-allowlisted, or signal-bearing requests', async () => { + const previous = getRuntimeUrlResolver(); + let calls = 0; + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' }); + globalThis.fetch = (async () => { + calls += 1; + await new Promise((r) => setTimeout(r, 10)); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + + // POST to an allowlisted path → not coalesced. + await Promise.all([ + runtimeFetch('/api/config/providers', { method: 'POST' }), + runtimeFetch('/api/config/providers', { method: 'POST' }), + ]); + expect(calls).toBe(2); + + calls = 0; + // GET to a non-allowlisted path → not coalesced. + await Promise.all([ + runtimeFetch('/api/session'), + runtimeFetch('/api/session'), + ]); + expect(calls).toBe(2); + + calls = 0; + // GET to an allowlisted path but carrying an AbortSignal → not coalesced. + await Promise.all([ + runtimeFetch('/api/config/providers', { signal: AbortSignal.timeout(1000) }), + runtimeFetch('/api/config/providers', { signal: AbortSignal.timeout(1000) }), + ]); + expect(calls).toBe(2); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + clearRuntimeAuthCredentialProvider(); + } + }); +}); diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index 3f364b83..a5304a59 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -120,18 +120,68 @@ const resolveRuntimeFetchInput = (input: string | URL | Request, query?: Runtime return target === input.url ? input : new Request(target, input); }; +// --------------------------------------------------------------------------- +// In-flight read coalescing +// +// On cold start two independent data layers (the sync bootstrap and the config +// store) fire the SAME idempotent reads — providers, config, path, agents, +// project — concurrently, with no shared dedup. That saturates the single +// OpenCode process and delays everything queued behind it (e.g. createSession). +// Coalesce genuinely-concurrent identical GETs to those read endpoints so +// OpenCode does the work once; every caller gets an independent `clone()`. +// +// Scope is deliberately tight: GET only, an allowlist of read paths, never an +// event stream, and never a request carrying an AbortSignal (so one caller +// aborting can't cancel the shared fetch for the others). The entry is removed +// as soon as the request settles, so this only ever shares overlapping in-flight +// requests — it never serves a stale/cached response. +// --------------------------------------------------------------------------- +const COALESCE_READ_PATH = /\/api\/(config|path|app\/agents|agent|project|command)(\b|\/|\?|$)/; +const READ_COALESCE = new Map>(); + +const coalesceReadKey = (method: string, url: string, hasSignal: boolean): string | null => { + if (hasSignal) return null; + if (method !== 'GET') return null; + if (url.includes('/event')) return null; + if (!COALESCE_READ_PATH.test(url)) return null; + return `GET ${url}`; +}; + export const runtimeFetch = async (input: string | URL | Request, init: RuntimeFetchOptions = {}): Promise => { const { query, ...requestInit } = init; const resolvedInput = resolveRuntimeFetchInput(input, query); const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined; const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput)); - return resolvedInput instanceof Request - ? fetch(new Request(resolvedInput, { ...requestInit, headers })) - : fetch(resolvedInput, { - ...requestInit, - headers, - }); + const doFetch = (): Promise => + resolvedInput instanceof Request + ? fetch(new Request(resolvedInput, { ...requestInit, headers })) + : fetch(resolvedInput, { ...requestInit, headers }); + + const url = + resolvedInput instanceof Request ? resolvedInput.url + : resolvedInput instanceof URL ? resolvedInput.toString() + : String(resolvedInput); + const method = String( + requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'), + ).toUpperCase(); + // A Request always carries a (possibly default) signal; treat any Request, or + // an explicit init.signal, as "has signal" and skip coalescing for safety. + const hasSignal = requestInit.signal != null || resolvedInput instanceof Request; + + const key = coalesceReadKey(method, url, hasSignal); + if (!key) return doFetch(); + + const existing = READ_COALESCE.get(key); + if (existing) return existing.then((res) => res.clone()); + + const pending = doFetch(); + READ_COALESCE.set(key, pending); + pending.then( + () => READ_COALESCE.delete(key), + () => READ_COALESCE.delete(key), + ); + return pending.then((res) => res.clone()); }; let runtimeFetchBridgeInstalled = false; diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 3f2f18bf..22636027 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -187,7 +187,7 @@ describe('useConfigStore provider persistence', () => { }); }); - test('strips persisted provider snapshots while preserving other directory state', async () => { + test('hydrates persisted provider snapshots for instant paint, then refreshes to live data', async () => { storage.set(STORAGE_KEY, JSON.stringify({ state: { activeDirectoryKey: DIRECTORY, @@ -223,14 +223,16 @@ describe('useConfigStore provider persistence', () => { await useConfigStore.persist.rehydrate(); + // Stale-while-revalidate: the persisted snapshot is hydrated as-is so the + // pickers can paint instantly on cold start, instead of being stripped to empty. const hydrated = useConfigStore.getState(); - expect(hydrated.providers).toEqual([]); - expect(hydrated.defaultProviders).toEqual({}); - expect(hydrated.directoryScoped[DIRECTORY]?.providers).toEqual([]); - expect(hydrated.directoryScoped[DIRECTORY]?.defaultProviders).toEqual({}); + expect(hydrated.providers.map((entry) => entry.id)).toEqual(['stale']); + expect(hydrated.defaultProviders).toEqual({ default: 'stale' }); + expect(hydrated.directoryScoped[DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['stale']); + expect(hydrated.directoryScoped[DIRECTORY]?.defaultProviders).toEqual({ default: 'stale' }); expect(hydrated.directoryScoped[DIRECTORY]?.agents).toEqual([{ name: 'build', mode: 'primary' }]); expect(hydrated.directoryScoped[DIRECTORY]?.currentAgentName).toBe('build'); - expect(hydrated.directoryScoped[OTHER_DIRECTORY]?.providers).toEqual([]); + expect(hydrated.directoryScoped[OTHER_DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['other-stale']); liveProviderId = 'fresh'; await hydrated.initializeApp(); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4dd93052..d4b1c0c8 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -699,7 +699,85 @@ const resolveInitialDirectoryKey = (): string => { } const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory; - return toDirectoryKey(directory); + return toConfigDirectoryKey(directory); +}; + +// Persisted worktree→project mapping. The runtime worktree map +// (availableWorktreesByProject) is populated by async git discovery and isn't +// ready when initializeApp runs on startup — so without this, a worktree's first +// config load can't resolve to its project and duplicates the project's load. +// We cache resolved mappings to localStorage so subsequent launches resolve the +// project synchronously at init time. worktree→project is effectively immutable, +// so a cached entry is safe to trust. +const WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap'; +let _worktreeProjectMap: Record | null = null; +const getWorktreeProjectMap = (): Record => { + if (_worktreeProjectMap === null) { + try { + const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null; + _worktreeProjectMap = raw ? (JSON.parse(raw) as Record) : {}; + } catch { + _worktreeProjectMap = {}; + } + } + return _worktreeProjectMap; +}; +const rememberWorktreeProject = (worktree: string, project: string): void => { + if (!worktree || !project || worktree === project) return; + const map = getWorktreeProjectMap(); + if (map[worktree] === project) return; + map[worktree] = project; + try { + localStorage.setItem(WORKTREE_PROJECT_MAP_KEY, JSON.stringify(map)); + } catch { + // localStorage quota exceeded — ignore; live resolution still works. + } +}; + +/** + * Map a directory to its CONFIG scope. Providers/agents/defaults are defined at + * the PROJECT level (opencode.json), so a worktree must inherit its parent + * project's config instead of maintaining — and re-fetching — its own + * per-worktree snapshot. Returns the owning project's path when the directory is + * a known worktree, else the directory unchanged. + */ +const resolveConfigDirectory = (directory: string | null | undefined): string | null => { + const dir = typeof directory === 'string' && directory.trim().length > 0 ? directory : null; + if (!dir) return dir; + // 1. Persisted mapping — resolves synchronously at startup, before the async + // git worktree discovery has populated the runtime map. + const cached = getWorktreeProjectMap()[dir]; + if (cached) return cached; + // 2. Live resolution via projects + discovered worktree map; cache the hit. + try { + const project = resolveProjectForSessionDirectory( + useProjectsStore.getState().projects, + useSessionUIStore.getState().availableWorktreesByProject, + dir, + ); + if (project?.path && project.path !== dir) { + rememberWorktreeProject(dir, project.path); + return project.path; + } + } catch { + return dir; + } + return dir; +}; + +const toConfigDirectoryKey = (directory: string | null | undefined): string => + toDirectoryKey(resolveConfigDirectory(directory)); + +// Runtime freshness tracking (NOT persisted) for the stale-while-revalidate +// background refresh, keyed by config-directory key. Prevents re-fetching +// project-scoped providers/agents we just loaded — e.g. initializeApp loading a +// project, then activateDirectory firing for the same project moments later. +const _providersLoadedAt = new Map(); +const _agentsLoadedAt = new Map(); +const CONFIG_REFRESH_TTL_MS = 30_000; +const isConfigFresh = (loadedAt: Map, key: string): boolean => { + const at = loadedAt.get(key); + return typeof at === 'number' && Date.now() - at < CONFIG_REFRESH_TTL_MS; }; interface DirectoryScopedConfig { @@ -715,41 +793,32 @@ interface DirectoryScopedConfig { defaultProviders: { [key: string]: string }; } -const clearProviderDataFromDirectoryScoped = ( - directoryScoped: Record, -): Record => { - const next: Record = {}; +/** + * Lift the active directory's cached provider/agent snapshot into the top-level + * fields the pickers read (`providers`, `agents`, selections), so a cold start + * paints instantly from persisted data. Falls back to whatever top-level data + * was persisted; handles legacy persisted blobs that only stored directoryScoped. + */ +const hydrateActiveDirectorySnapshot = >(merged: T): T => { + const directoryScoped = merged.directoryScoped; + const activeKey = merged.activeDirectoryKey; + if (!directoryScoped || !activeKey) return merged; + const snapshot = directoryScoped[activeKey]; + if (!snapshot) return merged; - for (const [directoryKey, snapshot] of Object.entries(directoryScoped)) { - next[directoryKey] = { - ...snapshot, - providers: [], - defaultProviders: {}, - }; + const next: Partial = { ...merged }; + if ((!merged.providers || merged.providers.length === 0) && snapshot.providers?.length) { + next.providers = snapshot.providers; } - - return next; -}; - -const stripProviderCacheFromPersistedState = (persistedState: unknown): Partial => { - if (!persistedState || typeof persistedState !== 'object') { - return {}; + if ((!merged.agents || merged.agents.length === 0) && snapshot.agents?.length) { + next.agents = snapshot.agents; } - - const persisted = persistedState as Partial; - const sanitized: Partial = { - ...persisted, - providers: [], - defaultProviders: {}, - }; - - if (persisted.directoryScoped) { - sanitized.directoryScoped = clearProviderDataFromDirectoryScoped( - persisted.directoryScoped as Record, - ); + if (!merged.defaultProviders || Object.keys(merged.defaultProviders).length === 0) { + if (snapshot.defaultProviders && Object.keys(snapshot.defaultProviders).length > 0) { + next.defaultProviders = snapshot.defaultProviders; + } } - - return sanitized; + return next as T; }; interface ConfigStore { @@ -1164,7 +1233,11 @@ export const useConfigStore = create()( return 500; })(), activateDirectory: async (directory) => { - const directoryKey = toDirectoryKey(directory); + // Resolve the worktree to its owning project up-front so the + // active key + snapshot key always match and stay project-scoped. + // Everything below operates on this key unchanged; the OpenCode + // working directory (opencodeClient.getDirectory()) is separate. + const directoryKey = toConfigDirectoryKey(directory); let snapshotHadProviders = false; let snapshotHadAgents = false; @@ -1204,14 +1277,28 @@ export const useConfigStore = create()( return; } + // Stale-while-revalidate: when a cached snapshot already + // populated the pickers, refresh in the background so the UI + // stays instant but never shows stale provider/agent data for + // longer than one fetch. Only block when there is nothing to show. if (snapshotHadProviders) { - markStartupTrace('activateDirectory:skipProviders', { directoryKey }); + if (isConfigFresh(_providersLoadedAt, directoryKey)) { + markStartupTrace('activateDirectory:providersFresh', { directoryKey }); + } else { + markStartupTrace('activateDirectory:refreshProvidersBackground', { directoryKey }); + void get().loadProviders({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory:refresh' }); + } } else { await get().loadProviders({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' }); } if (snapshotHadAgents) { - markStartupTrace('activateDirectory:skipAgents', { directoryKey }); + if (isConfigFresh(_agentsLoadedAt, directoryKey)) { + markStartupTrace('activateDirectory:agentsFresh', { directoryKey }); + } else { + markStartupTrace('activateDirectory:refreshAgentsBackground', { directoryKey }); + void get().loadAgents({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory:refresh' }); + } } else { await get().loadAgents({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' }); } @@ -1270,8 +1357,11 @@ export const useConfigStore = create()( loadProviders: async (options) => { const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey); - const effectiveDirectory = requestedDirectory ?? opencodeClient.getDirectory() ?? null; - const directoryKey = toDirectoryKey(requestedDirectory); + // Providers are project-scoped: resolve a worktree to its project + // so it reuses one shared snapshot instead of its own. + const configDirectory = resolveConfigDirectory(requestedDirectory); + const effectiveDirectory = configDirectory ?? opencodeClient.getDirectory() ?? null; + const directoryKey = toDirectoryKey(configDirectory); const source = options?.source ?? 'unknown'; markStartupTrace('loadProviders:called', { directoryKey, source, requestedDirectory, effectiveDirectory }); @@ -1391,6 +1481,7 @@ export const useConfigStore = create()( providers: processedProviders.length, models: processedProviders.reduce((count, provider) => count + provider.models.length, 0), }); + _providersLoadedAt.set(directoryKey, Date.now()); return; } catch (error) { lastError = error; @@ -1685,8 +1776,11 @@ export const useConfigStore = create()( loadAgents: async (options) => { const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey); - const effectiveDirectory = requestedDirectory ?? opencodeClient.getDirectory() ?? null; - const directoryKey = toDirectoryKey(requestedDirectory); + // Agents are project-scoped: resolve a worktree to its project + // so it reuses one shared snapshot instead of its own. + const configDirectory = resolveConfigDirectory(requestedDirectory); + const effectiveDirectory = configDirectory ?? opencodeClient.getDirectory() ?? null; + const directoryKey = toDirectoryKey(configDirectory); const source = options?.source ?? 'unknown'; markStartupTrace('loadAgents:called', { directoryKey, source, requestedDirectory, effectiveDirectory }); @@ -1859,6 +1953,7 @@ export const useConfigStore = create()( durationMs: Math.round(loaderEnded - loaderStarted), agents: safeAgents.length, }); + _agentsLoadedAt.set(directoryKey, Date.now()); return true; } @@ -1971,6 +2066,7 @@ export const useConfigStore = create()( durationMs: Math.round(loaderEnded - loaderStarted), agents: safeAgents.length, }); + _agentsLoadedAt.set(directoryKey, Date.now()); return true; } catch (error) { lastError = error; @@ -2634,7 +2730,11 @@ export const useConfigStore = create()( if (debug) console.log("Initializing app..."); markStartupTrace('initApp:skipped', { reason: 'checkConnection already verified health' }); - get().invalidateProviderCache(); + // Stale-while-revalidate: do NOT invalidate the hydrated + // provider snapshot here. The pickers keep showing the + // last-known providers/agents while loadProviders/loadAgents + // below fetch fresh data and overwrite on success. Clearing + // first would blank the UI for the duration of the fetch. // Config (providers/agents/defaults) lives at the PROJECT level. If the // app starts on a worktree directory, load config under the owning @@ -2736,20 +2836,30 @@ export const useConfigStore = create()( { name: "config-store", storage: createJSONStorage(() => getSafeStorage()), - merge: (persistedState, currentState) => ({ - ...currentState, - ...stripProviderCacheFromPersistedState(persistedState), - }), + merge: (persistedState, currentState) => + hydrateActiveDirectorySnapshot({ + ...currentState, + ...(persistedState && typeof persistedState === 'object' + ? (persistedState as Partial) + : {}), + }), + // Stale-while-revalidate: persist the last-known provider/agent + // snapshots so the model/agent pickers paint instantly on cold + // start. Freshness is guaranteed by the background refresh in + // initializeApp() / activateDirectory() (which overwrite these on + // success) and by the provider/agent config-change subscriptions. partialize: (state) => ({ activeDirectoryKey: state.activeDirectoryKey, - directoryScoped: clearProviderDataFromDirectoryScoped(state.directoryScoped), + directoryScoped: state.directoryScoped, + providers: state.providers, + agents: state.agents, currentProviderId: state.currentProviderId, currentModelId: state.currentModelId, currentVariant: state.currentVariant, currentAgentName: state.currentAgentName, selectedProviderId: state.selectedProviderId, agentModelSelections: state.agentModelSelections, - defaultProviders: {}, + defaultProviders: state.defaultProviders, settingsDefaultModel: state.settingsDefaultModel, settingsDefaultVariant: state.settingsDefaultVariant, settingsDefaultAgent: state.settingsDefaultAgent, diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index b910d5be..96aaa0dd 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -7,6 +7,7 @@ import type { GitLogResponse, GitIdentitySummary, } from '@/lib/api/types'; +import { getSafeStorage } from '@/stores/utils/safeStorage'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -140,6 +141,53 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({ isLoadingIdentity: false, }); +// --------------------------------------------------------------------------- +// Persisted branch cache (stale-while-revalidate) +// +// `git branch` is slow on cold start and the draft branch selector above the +// composer is gated behind it — it's the slowest-loading composer element. +// Cache the per-directory branch list to localStorage and seed the store on +// init so the selector paints instantly from the last-known branches; a stale +// refresh runs in the background (see ChatInput's draft-branch effect). Only the +// branch list is cached — never status/log/diff. +// --------------------------------------------------------------------------- +const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache'; + +const readBranchCache = (): Record => { + try { + const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +}; + +const writeCachedBranches = (directory: string, branches: GitBranch): void => { + if (!directory || !branches) return; + try { + const cache = readBranchCache(); + cache[directory] = branches; + getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); + } catch { + // quota / serialization — ignore; live fetch still refreshes the store + } +}; + +const seedDirectoriesFromBranchCache = (): Map => { + const directories = new Map(); + const cache = readBranchCache(); + for (const [directory, branches] of Object.entries(cache)) { + if (!directory || !branches || !Array.isArray(branches.all)) continue; + // A cached branch list implies the directory was a git repo. Seed isGitRepo + // so the selector's gate passes immediately; lastBranchesFetch stays 0 so the + // ChatInput effect treats it as stale and refreshes in the background. + directories.set(directory, { ...createEmptyDirectoryState(), isGitRepo: true, branches }); + } + return directories; +}; + // LRU eviction helper for diff cache const evictDiffCacheIfNeeded = ( diffCache: Map, @@ -373,7 +421,7 @@ const isCleanStatusFile = (file: GitStatus['files'][number]): boolean => export const useGitStore = create()( devtools( (set, get) => ({ - directories: new Map(), + directories: seedDirectoriesFromBranchCache(), activeDirectory: null, setActiveDirectory: (directory) => { @@ -650,6 +698,7 @@ export const useGitStore = create()( const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState(); newDirectories.set(directory, { ...dirState, branches, isLoadingBranches: false, lastBranchesFetch: Date.now() }); set({ directories: newDirectories }); + writeCachedBranches(directory, branches); } catch (error) { console.error('Failed to fetch git branches:', error); const newDirectories = new Map(get().directories); diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts index 36a02102..09760226 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -19,7 +19,11 @@ export const useSessionDisplayStore = create()( (set) => ({ displayMode: 'minimal', showRecentSection: true, - showArchivedSessions: true, + // Default to HIDDEN so the pre-hydration state matches the quiet/safe + // option: archived sessions must never flash visible on startup and then + // disappear once the persisted preference rehydrates. Users who opted into + // showing archived have `true` persisted, which is preserved on rehydrate. + showArchivedSessions: false, setDisplayMode: (mode) => set({ displayMode: mode }), setShowRecentSection: (show) => set({ showRecentSection: show }), setShowArchivedSessions: (show) => set({ showArchivedSessions: show }), diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index 3a9c130c..0b37c630 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -169,13 +169,19 @@ export async function bootstrapDirectory(input: { .filter((r): r is PromiseRejectedResult => r.status === "rejected") .map((r) => r.reason) - // path.get and session.status have no global-state fallback. - // If either fails, the UI cannot safely advance to "complete". - const [, , , pathResult, sessionStatusResult] = phase1Results - const criticalPhase1Failed = - pathResult.status === "rejected" || sessionStatusResult.status === "rejected" + // De-block the UI: only a total failure (OpenCode genuinely unreachable) + // should abort the directory. Don't let one transient initial fetch strand + // the directory in "loading" forever and skip phase 2/3 (sessions). + // - session.status is LIVE data the event pipeline keeps current — a failed + // initial snapshot is harmless; SSE will deliver the real status. + // - path.get feeds project resolution, but if we already resolved a project + // (from global projects) its failure is tolerable; the worktree path is + // refreshed by later events. + const [, , , pathResult] = phase1Results + const pathFailedWithoutProject = + pathResult.status === "rejected" && !getState().project - if (phase1Errors.length === phase1Results.length || criticalPhase1Failed) { + if (phase1Errors.length === phase1Results.length || pathFailedWithoutProject) { console.error(`[bootstrap] directory bootstrap failed for ${directory}`, phase1Errors[0]) return } diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts index cd4fa437..89355856 100644 --- a/packages/ui/src/sync/child-store.ts +++ b/packages/ui/src/sync/child-store.ts @@ -2,7 +2,7 @@ import { create, type StoreApi } from "zustand" import type { DirState, State } from "./types" import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types" import { pickDirectoriesToEvict, canDisposeDirectory, hasPendingBlockingRequests } from "./eviction" -import { readDirCache, persistVcs, persistProjectMeta, persistIcon } from "./persist-cache" +import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessions } from "./persist-cache" export type DirectoryStore = State & { /** Apply a partial state update */ @@ -15,11 +15,19 @@ function createDirectoryStore(directory: string): StoreApi { // Restore cached metadata from localStorage const cached = readDirCache(directory) + // Stale-while-revalidate: seed the session list from cache so the sidebar + // paints chats instantly. Bootstrap phase-3 loadSessions overwrites with the + // fresh list (its empty-list race guard preserves these until then). + const cachedSessions = cached.sessions ?? INITIAL_STATE.session + const store = create()((set) => ({ ...INITIAL_STATE, vcs: cached.vcs ?? INITIAL_STATE.vcs, projectMeta: cached.projectMeta ?? INITIAL_STATE.projectMeta, icon: cached.icon ?? INITIAL_STATE.icon, + session: cachedSessions, + sessionTotal: cachedSessions.length, + limit: Math.max(cachedSessions.length, INITIAL_STATE.limit), patch: (partial) => set(partial), replace: (next) => set(next), })) @@ -29,6 +37,7 @@ function createDirectoryStore(directory: string): StoreApi { if (state.vcs !== prev.vcs) persistVcs(directory, state.vcs) if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta) if (state.icon !== prev.icon) persistIcon(directory, state.icon) + if (state.session !== prev.session) persistSessions(directory, state.session) }) return store diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts index 7be0cafe..7e5e5035 100644 --- a/packages/ui/src/sync/persist-cache.ts +++ b/packages/ui/src/sync/persist-cache.ts @@ -7,9 +7,12 @@ * from the server via SSE bootstrap. */ -import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client" import type { ProjectMeta } from "./types" +/** Cap persisted session lists so localStorage stays bounded per directory. */ +const PERSISTED_SESSION_LIMIT = 50 + // --------------------------------------------------------------------------- // Storage key generation // --------------------------------------------------------------------------- @@ -33,7 +36,7 @@ function storagePrefix(directory: string): string { // Typed cache helpers // --------------------------------------------------------------------------- -type CacheKey = "vcs" | "projectMeta" | "icon" +type CacheKey = "vcs" | "projectMeta" | "icon" | "sessions" function cacheKey(directory: string, key: CacheKey): string { return `${storagePrefix(directory)}.${key}` @@ -84,6 +87,7 @@ export type PersistedDirCache = { vcs: VcsInfo | undefined projectMeta: ProjectMeta | undefined icon: string | undefined + sessions: Session[] | undefined } /** Read all cached metadata for a directory */ @@ -92,9 +96,26 @@ export function readDirCache(directory: string): PersistedDirCache { vcs: readCache(directory, "vcs"), projectMeta: readCache(directory, "projectMeta"), icon: readCache(directory, "icon"), + sessions: readCache(directory, "sessions"), } } +/** + * Write a capped slice of the directory session list to cache so the sidebar + * can paint chats instantly on cold start. Refreshed by bootstrap loadSessions. + */ +export function persistSessions(directory: string, sessions: Session[] | undefined): void { + if (!sessions || sessions.length === 0) { + writeCache(directory, "sessions", undefined) + return + } + // Keep the most recent N by id (ids are time-ordered hex) to bound storage. + const capped = sessions.length > PERSISTED_SESSION_LIMIT + ? [...sessions].sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, PERSISTED_SESSION_LIMIT) + : sessions + writeCache(directory, "sessions", capped) +} + /** Write vcs info to cache */ export function persistVcs(directory: string, vcs: VcsInfo | undefined): void { writeCache(directory, "vcs", vcs) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index a2f86d4f..f4960e01 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -404,6 +404,47 @@ const writeRuntimeSessionMemory = (key: string, patch: Partial => { + try { + const raw = getSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) + if (!raw) return new Map() + const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]> + if (!Array.isArray(entries)) return new Map() + return new Map( + entries.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && Array.isArray(entry[1])), + ) + } catch { + return new Map() + } +} + +const persistWorktreeMap = (map: Map): void => { + try { + getSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) + } catch { + // quota / serialization error — ignore; discovery still refreshes at runtime + } +} + +const flattenWorktreeMap = (map: Map): WorktreeMetadata[] => { + const out: WorktreeMetadata[] = [] + for (const list of map.values()) out.push(...list) + return out +} + +const PERSISTED_WORKTREE_MAP = loadPersistedWorktreeMap() + export const useSessionUIStore = create()((set, get) => ({ currentSessionId: null, currentSessionDirectory: null, @@ -412,8 +453,8 @@ export const useSessionUIStore = create()((set, get) => ({ abortPromptExpiresAt: null, error: null, worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), + availableWorktrees: flattenWorktreeMap(PERSISTED_WORKTREE_MAP), + availableWorktreesByProject: PERSISTED_WORKTREE_MAP, webUICreatedSessions: new Set(), sessionAbortFlags: new Map(), abortControllers: new Map(), @@ -1418,3 +1459,12 @@ export const useSessionUIStore = create()((set, get) => ({ setSessionOpener((sessionID, directory) => { useSessionUIStore.getState().setCurrentSession(sessionID, directory) }) + +// Write-through persist of the worktree map whenever discovery refreshes it. +// Cheap reference-equality guard — this fires only when the map actually +// changes (discovery / worktree create/remove), not on hot session updates. +useSessionUIStore.subscribe((state, prev) => { + if (state.availableWorktreesByProject !== prev.availableWorktreesByProject) { + persistWorktreeMap(state.availableWorktreesByProject) + } +}) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 2979c7ae..473a4043 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1710,16 +1710,26 @@ export function SyncProvider(props: { }), }) - // VS Code race: if sessions are still empty after bootstrap, OpenCode - // wasn't ready yet (bridge returned 503). Retry a few times. - const state = store.getState() - if (state.session.length === 0 && attempt < 5) { - console.warn(`[bootstrap] sessions empty for ${directory} after attempt ${attempt + 1}; retrying in 2s`) - await new Promise((r) => setTimeout(r, 2000)) - store.setState({ status: "loading" as const }) - await runBootstrap(attempt + 1) - } else if (state.session.length === 0) { - console.warn(`[bootstrap] sessions empty for ${directory} after ${attempt + 1} attempts; giving up`) + // VS Code-only race: the bridge can answer with an empty 200 (instead + // of a retryable 503) while OpenCode is still warming up, which the two + // retry layers inside loadSessions can't catch. Re-run a few times there. + // + // On web/desktop this retry is both redundant and harmful: loadSessions + // already retries transient failures (listGlobalSessionPages throws on + // 5xx and retries internally), so an empty result here is AUTHORITATIVE — + // the directory genuinely has no sessions (e.g. a deleted worktree only + // referenced by archived sessions). Re-running the full bootstrap 6×2s + // per such directory is the startup log storm. + if (isVSCodeRuntime()) { + const state = store.getState() + if (state.session.length === 0 && attempt < 5) { + console.warn(`[bootstrap] sessions empty for ${directory} after attempt ${attempt + 1}; retrying in 2s`) + await new Promise((r) => setTimeout(r, 2000)) + store.setState({ status: "loading" as const }) + await runBootstrap(attempt + 1) + } else if (state.session.length === 0) { + console.warn(`[bootstrap] sessions empty for ${directory} after ${attempt + 1} attempts; giving up`) + } } } @@ -2042,11 +2052,22 @@ export function useGlobalSyncSelector(selector: (state: GlobalSyncStore) => T return useGlobalSyncStore(selector) } -/** Get the child store for a directory (defaults to current) */ -export function useDirectoryStore(directory?: string): StoreApi { +/** + * Get the child store for a directory (defaults to current). + * + * Pass `{ bootstrap: false }` when you only need the store reference for an + * on-demand `getState()` (not live subscription) and must NOT trigger a full + * directory bootstrap. This avoids storms of pointless session-list fetches + + * empty-retry loops for directories that are merely referenced by sidebar rows + * (e.g. archived sessions on deleted worktrees). + */ +export function useDirectoryStore( + directory?: string, + options?: { bootstrap?: boolean }, +): StoreApi { const system = useSyncSystem() const dir = directory ?? system.directory - return system.childStores.ensureChild(dir) + return system.childStores.ensureChild(dir, options) } /** Select from the current directory's store */ diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 47eb6c98..327e2232 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -468,8 +468,26 @@ export const registerOpenCodeProxy = (app, deps) => { next(); }); - // Readiness gate — return 503 while OpenCode is starting/restarting - app.use('/api', (req, res, next) => { + // Readiness gate — while OpenCode is starting/restarting, HOLD the request and + // poll readiness instead of returning 503 immediately. A bare 503 pushes the + // client into an exponential-backoff retry loop (500ms → 1s → …) that wastes + // seconds of cold-start time and can fail bootstrap outright. Holding the + // request until OpenCode is ready (typically well under a second) lets the + // first call simply succeed. We still 503 if readiness doesn't arrive within a + // bounded window so genuinely-down servers fail fast. + const READINESS_HOLD_POLL_MS = 75; + const READINESS_HOLD_MAX_MS = 6000; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const isStillWaiting = (runtimeState) => { + const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince; + return ( + (!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || + runtimeState.isRestartingOpenCode || + !runtimeState.openCodePort + ); + }; + + app.use('/api', async (req, res, next) => { if ( req.path.startsWith('/themes/custom') || req.path.startsWith('/push') || @@ -483,21 +501,26 @@ export const registerOpenCodeProxy = (app, deps) => { return next(); } - const runtimeState = getRuntime(); - const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince; - const stillWaiting = - (!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || - runtimeState.isRestartingOpenCode || - !runtimeState.openCodePort; + if (!isStillWaiting(getRuntime())) { + return next(); + } - if (stillWaiting) { - return res.status(503).json({ + const deadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS); + while (Date.now() < deadline) { + // Client gave up (closed/aborted) — stop holding. + if (res.writableEnded || req.aborted) return; + await sleep(READINESS_HOLD_POLL_MS); + if (!isStillWaiting(getRuntime())) { + return next(); + } + } + + if (!res.headersSent) { + res.status(503).json({ error: 'OpenCode is restarting', restarting: true, }); } - - next(); }); // Windows: session merge for cross-directory session listing diff --git a/packages/web/server/opencode-proxy.test.js b/packages/web/server/opencode-proxy.test.js index f5dfc852..da4259b5 100644 --- a/packages/web/server/opencode-proxy.test.js +++ b/packages/web/server/opencode-proxy.test.js @@ -82,6 +82,68 @@ describe('OpenCode proxy SSE forwarding', () => { expect(seenAuthorization).toBe('Bearer test-token'); }); + it('holds a request through OpenCode warmup and succeeds once ready (no 503/backoff)', async () => { + const upstream = express(); + upstream.get('/config/providers', (_req, res) => { + res.json({ ok: true }); + }); + upstreamServer = await listen(upstream); + const upstreamPort = upstreamServer.address().port; + + const runtime = { + openCodePort: upstreamPort, + isOpenCodeReady: false, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }; + // OpenCode becomes ready shortly after the request arrives. + setTimeout(() => { runtime.isOpenCodeReady = true; }, 200); + + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + OPEN_CODE_READY_GRACE_MS: 5000, + getRuntime: () => runtime, + getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }), + buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:${upstreamPort}${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/config/providers`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); + + it('returns 503 fast when OpenCode never becomes ready', async () => { + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + // Zero grace → hold window collapses to nothing → fail fast. + OPEN_CODE_READY_GRACE_MS: 0, + getRuntime: () => ({ + openCodePort: 0, + isOpenCodeReady: false, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }), + getOpenCodeAuthHeaders: () => ({}), + buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:1${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/config/providers`); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ restarting: true }); + }); + it('waits for drain when writing to a slow SSE response', async () => { const writes = []; const res = new EventEmitter();