perf: instant startup via cache hydration + decoupled readiness (#1650)
* perf(startup): hydrate providers/agents from cache (stale-while-revalidate)
Persist last-known provider/agent snapshots instead of stripping them, so the
model/agent pickers paint instantly on cold start. Freshness is preserved by the
background refresh in initializeApp() and activateDirectory() (which overwrite on
success) and by the existing provider/agent config-change subscriptions, so the
prior stale-provider regression stays fixed without blanking the UI during fetch.
* perf(startup): cache directory session list for instant sidebar
Persist a capped slice of each directory's session list and seed the child store
from it on creation, so the sidebar paints chats immediately on cold start.
Bootstrap phase-3 loadSessions overwrites with the fresh list; its empty-list
race guard preserves the seeded sessions during OpenCode warmup.
* perf(startup): hold API requests through OpenCode warmup instead of 503
The readiness gate returned 503 the instant OpenCode wasn't ready, pushing the
client into an exponential-backoff retry loop (500ms -> 1s -> ...) that wasted
seconds of cold-start time and could fail bootstrap outright. Now hold the
request and poll readiness up to a bounded window so the first call succeeds as
soon as OpenCode is up (typically sub-second); still 503 fast past the window so
a genuinely-down server doesn't hang. Adds coverage for both paths.
* perf(startup): surface cached providers/agents in pickers (optimistic readiness)
The model/agent pickers gated purely on isInitialized, so they showed
"Loading…" for the entire init round-trip even when provider/agent data was
already hydrated from cache — making the persisted-cache work invisible. Treat
the pickers as ready as soon as cached providers are present (stale-while-
revalidate), so they paint last-known models/agents instantly and refresh in the
background. First-ever launch (no cache) still shows Loading until init.
* perf(startup): don't abort directory bootstrap on transient phase-1 failure
A failed initial path.get OR session.status aborted the whole directory
bootstrap, stranding it in loading and skipping phase 2/3 (session load).
session.status is live data the event pipeline keeps current, and path.get is
tolerable once a project is resolved from global state. Now only a total
failure (or path.get failing with no resolved project) aborts, so the sidebar
and chat keep advancing and loading sessions through warmup hiccups.
* perf(startup): don't bootstrap directories from archived sidebar rows
Each sidebar session row called useDirectoryStore(dir), which defaulted to
bootstrap:true and triggered a full directory bootstrap. Archived sessions point
at dozens of (often deleted) worktrees, so on startup this fired a session-list
fetch + 6x2s empty-retry storm per dead directory (the logs the user saw). The
store ref there is only read on-demand via getState() in export handlers, never
subscribed, so archived rows don't need it bootstrapped. Add a { bootstrap }
option to useDirectoryStore and skip bootstrap for archived rows; active rows
still bootstrap so live cross-directory session/status keeps aggregating.
* perf(startup): stop empty-session bootstrap retry storm on web/desktop
The post-bootstrap retry re-ran the full directory bootstrap 6x2s whenever the
session list came back empty, on the theory that empty meant OpenCode wasn't
ready. But loadSessions already retries transient failures twice over
(listGlobalSessionPages throws on 5xx and retries internally), so on web/desktop
an empty result is authoritative — the directory genuinely has no sessions (e.g.
deleted worktrees referenced only by archived sessions). That produced the
dozens of '[bootstrap] sessions empty ... 6 attempts; giving up' log storms.
Gate the retry to VS Code, where the bridge can return an empty 200 during
warmup that the inner retries can't catch.
* perf(startup): scope provider/agent config to project (worktrees inherit)
Providers/agents/defaults are project-level, but were keyed per directory, so a
worktree fetched and cached its own snapshot — duplicating the parent project's
load (the trace showed initializeApp loading the worktree and activateDirectory
loading the project concurrently, ~8s of redundant background work).
- resolveConfigDirectory() maps a worktree to its owning project; loadProviders
/loadAgents/activateDirectory now key by it, so a worktree reuses one shared
project snapshot. activateDirectory resolves up-front so activeDirectoryKey and
the snapshot key always match (picker stays consistent); the OpenCode working
directory is unaffected.
- Add a 30s runtime freshness guard so the stale-while-revalidate background
refresh skips re-fetching config that was just loaded (initializeApp then
activateDirectory for the same project), and to avoid churn on rapid project
switches. Config-change invalidation clears the snapshot, which bypasses the
guard, so freshness never masks a needed refresh.
* fix(sidebar): default archived sessions to hidden to avoid startup flash
useSessionDisplayStore defaulted showArchivedSessions to true, so on startup
archived sessions rendered by default and then vanished once the persisted
preference rehydrated to hidden — a visible flash. Default to hidden so the
pre-hydration state is the quiet one; users who opted into showing archived keep
their persisted true (default change doesn't override persisted state).
* perf(startup): persist worktree->project mapping to kill cold double-load
The worktree->project map (availableWorktreesByProject) is populated by async git
discovery, so it isn't ready when initializeApp runs — a worktree's first config
load couldn't resolve to its project and duplicated the project's provider/agent
load, saturating OpenCode during cold start (the source of the slow first
createSession/send the user observed). Cache resolved worktree->project mappings
to localStorage so resolveConfigDirectory resolves synchronously at init on
subsequent launches; the project is loaded once and activateDirectory hits the
freshness guard. worktree->project is immutable so a cached entry is safe; live
resolution still populates/corrects the cache.
* perf(startup): persist worktree map for instant sidebar + first-launch keying
Worktree discovery is async (git), so availableWorktreesByProject was empty at
startup: the sidebar worktree list appeared late, and useConfigStore couldn't
resolve a worktree to its project on the first launch (causing the cold
worktree+project double-load). Persist the discovered worktree map to
localStorage and seed it synchronously on store init (stale-while-revalidate:
discovery refreshes in the background via the existing setState, which now
write-through persists). The sidebar paints worktrees instantly and
resolveConfigDirectory resolves the project from the very first launch.
* perf(startup): coalesce concurrent duplicate OpenCode reads in runtimeFetch
On cold start the sync bootstrap and the config store independently fire the same
idempotent reads (providers, config, path, agents, project) concurrently with no
shared dedup, saturating the single OpenCode process and delaying work queued
behind it (e.g. createSession). Coalesce genuinely-concurrent identical GETs to
those read endpoints at the transport layer so OpenCode does the work once; each
caller receives an independent response clone. Tightly scoped: GET only,
allowlisted read paths, never event streams, never a signal-bearing request (so
one caller's abort can't cancel the shared fetch). Entries clear on settle, so it
only shares overlapping in-flight requests — never a stale response.
* perf(startup): cache git branches so the draft branch selector paints instantly
The branch selector above the composer was the slowest-loading element: it's
gated behind a cold 'git branch' fetch (useGitStore, not persisted). Cache the
per-directory branch list to localStorage and seed the store on init (with
isGitRepo:true so the selector's gate passes), and write the cache on every
successful fetchBranches. The ChatInput draft-branch effect now refreshes on
staleness (>30s) rather than mere absence, so seeded branches show immediately
and still refresh in the background without a spinner — no stale-forever
regression. Only the branch list is cached; status/log/diff are untouched.
This commit is contained in:
committed by
GitHub
parent
928b7ff1d6
commit
e372c8d8cb
@@ -3551,7 +3551,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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() ?? '';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Promise<Response>>();
|
||||
|
||||
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<Response> => {
|
||||
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<Response> =>
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string> | null = null;
|
||||
const getWorktreeProjectMap = (): Record<string, string> => {
|
||||
if (_worktreeProjectMap === null) {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
_worktreeProjectMap = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
||||
} 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<string, number>();
|
||||
const _agentsLoadedAt = new Map<string, number>();
|
||||
const CONFIG_REFRESH_TTL_MS = 30_000;
|
||||
const isConfigFresh = (loadedAt: Map<string, number>, 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<string, DirectoryScopedConfig>,
|
||||
): Record<string, DirectoryScopedConfig> => {
|
||||
const next: Record<string, DirectoryScopedConfig> = {};
|
||||
/**
|
||||
* 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 = <T extends Partial<ConfigStore>>(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<ConfigStore> = { ...merged };
|
||||
if ((!merged.providers || merged.providers.length === 0) && snapshot.providers?.length) {
|
||||
next.providers = snapshot.providers;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const stripProviderCacheFromPersistedState = (persistedState: unknown): Partial<ConfigStore> => {
|
||||
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<ConfigStore>;
|
||||
const sanitized: Partial<ConfigStore> = {
|
||||
...persisted,
|
||||
providers: [],
|
||||
defaultProviders: {},
|
||||
};
|
||||
|
||||
if (persisted.directoryScoped) {
|
||||
sanitized.directoryScoped = clearProviderDataFromDirectoryScoped(
|
||||
persisted.directoryScoped as Record<string, DirectoryScopedConfig>,
|
||||
);
|
||||
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<ConfigStore>()(
|
||||
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<ConfigStore>()(
|
||||
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<ConfigStore>()(
|
||||
|
||||
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<ConfigStore>()(
|
||||
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<ConfigStore>()(
|
||||
|
||||
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<ConfigStore>()(
|
||||
durationMs: Math.round(loaderEnded - loaderStarted),
|
||||
agents: safeAgents.length,
|
||||
});
|
||||
_agentsLoadedAt.set(directoryKey, Date.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1971,6 +2066,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
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<ConfigStore>()(
|
||||
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<ConfigStore>()(
|
||||
{
|
||||
name: "config-store",
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...stripProviderCacheFromPersistedState(persistedState),
|
||||
}),
|
||||
merge: (persistedState, currentState) =>
|
||||
hydrateActiveDirectorySnapshot({
|
||||
...currentState,
|
||||
...(persistedState && typeof persistedState === 'object'
|
||||
? (persistedState as Partial<ConfigStore>)
|
||||
: {}),
|
||||
}),
|
||||
// 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,
|
||||
|
||||
@@ -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<string, GitBranch> => {
|
||||
try {
|
||||
const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, GitBranch>;
|
||||
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<string, DirectoryGitState> => {
|
||||
const directories = new Map<string, DirectoryGitState>();
|
||||
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<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
|
||||
@@ -373,7 +421,7 @@ const isCleanStatusFile = (file: GitStatus['files'][number]): boolean =>
|
||||
export const useGitStore = create<GitStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
directories: new Map(),
|
||||
directories: seedDirectoriesFromBranchCache(),
|
||||
activeDirectory: null,
|
||||
|
||||
setActiveDirectory: (directory) => {
|
||||
@@ -650,6 +698,7 @@ export const useGitStore = create<GitStore>()(
|
||||
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);
|
||||
|
||||
@@ -19,7 +19,11 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
|
||||
(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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<DirectoryStore> {
|
||||
// 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<DirectoryStore>()((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<DirectoryStore> {
|
||||
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
|
||||
|
||||
@@ -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<VcsInfo>(directory, "vcs"),
|
||||
projectMeta: readCache<ProjectMeta>(directory, "projectMeta"),
|
||||
icon: readCache<string>(directory, "icon"),
|
||||
sessions: readCache<Session[]>(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)
|
||||
|
||||
@@ -404,6 +404,47 @@ const writeRuntimeSessionMemory = (key: string, patch: Partial<RuntimeSessionMem
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted worktree map (stale-while-revalidate)
|
||||
//
|
||||
// Worktree discovery is async (git), so the worktree→project map isn't ready at
|
||||
// startup. Persist it so (a) the sidebar worktree list paints instantly, and
|
||||
// (b) useConfigStore.resolveConfigDirectory can map a worktree to its project on
|
||||
// the FIRST launch — yielding a single project-scoped config load instead of a
|
||||
// worktree+project double-load. Discovery refreshes it in the background.
|
||||
// ---------------------------------------------------------------------------
|
||||
const WORKTREE_MAP_STORAGE_KEY = 'oc.worktreeMap'
|
||||
|
||||
const loadPersistedWorktreeMap = (): Map<string, WorktreeMetadata[]> => {
|
||||
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<string, WorktreeMetadata[]>): 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<string, WorktreeMetadata[]>): WorktreeMetadata[] => {
|
||||
const out: WorktreeMetadata[] = []
|
||||
for (const list of map.values()) out.push(...list)
|
||||
return out
|
||||
}
|
||||
|
||||
const PERSISTED_WORKTREE_MAP = loadPersistedWorktreeMap()
|
||||
|
||||
export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
currentSessionId: null,
|
||||
currentSessionDirectory: null,
|
||||
@@ -412,8 +453,8 @@ export const useSessionUIStore = create<SessionUIState>()((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<SessionUIState>()((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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<T>(selector: (state: GlobalSyncStore) => T
|
||||
return useGlobalSyncStore(selector)
|
||||
}
|
||||
|
||||
/** Get the child store for a directory (defaults to current) */
|
||||
export function useDirectoryStore(directory?: string): StoreApi<DirectoryStore> {
|
||||
/**
|
||||
* 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<DirectoryStore> {
|
||||
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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user