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
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user