Files
openchamber/packages/ui/src/sync/persist-cache.ts
T
Bohdan Triapitsyn 85400459e9 perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
2026-07-21 20:52:20 +03:00

258 lines
9.1 KiB
TypeScript

/**
* Persisted child-store metadata caches.
*
* VCS info, project metadata, icons, and a bounded session-list snapshot are
* cached to localStorage per runtime and directory so they survive reloads.
* Message/part data is always loaded from the server.
*/
import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProjectMeta } from "./types"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics"
/** Cap persisted session lists so localStorage stays bounded per directory. */
const PERSISTED_SESSION_LIMIT = 50
const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const
const SESSION_PERSIST_DEBOUNCE_MS = 50
type PendingSessionWrite = {
runtimeKey: string
key: string
legacyKey: string
sessions: Session[]
}
const pendingSessionWrites = new Map<string, PendingSessionWrite>()
let pendingSessionWriteTimer: ReturnType<typeof setTimeout> | undefined
// ---------------------------------------------------------------------------
// Storage key generation
// ---------------------------------------------------------------------------
function hashCode(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const chr = str.charCodeAt(i)
hash = ((hash << 5) - hash) + chr
hash |= 0
}
return Math.abs(hash).toString(36)
}
function legacyStoragePrefix(directory: string): string {
const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_")
return `oc.dir.${head}.${hashCode(directory)}`
}
function storagePrefix(directory: string): string {
return storagePrefixForRuntime(getRuntimeKey() || "local", directory)
}
function storagePrefixForRuntime(runtimeKey: string, directory: string): string {
const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_")
return `oc.dir.v2.${head}.${hashCode(`${runtimeKey}\0${directory}`)}`
}
// ---------------------------------------------------------------------------
// Typed cache helpers
// ---------------------------------------------------------------------------
type CacheKey = "vcs" | "projectMeta" | "icon" | "sessions"
function cacheKey(directory: string, key: CacheKey): string {
return `${storagePrefix(directory)}.${key}`
}
function legacyCacheKey(directory: string, key: CacheKey): string {
return `${legacyStoragePrefix(directory)}.${key}`
}
function readCache<T>(directory: string, key: CacheKey): T | undefined {
try {
const currentKey = cacheKey(directory, key)
if (key === "sessions") {
const pending = pendingSessionWrites.get(currentKey)
if (pending) return pending.sessions as T
}
const raw = localStorage.getItem(currentKey)
?? localStorage.getItem(legacyCacheKey(directory, key))
if (!raw) return undefined
return JSON.parse(raw) as T
} catch {
return undefined
}
}
function writeCache<T>(directory: string, key: CacheKey, value: T | undefined): void {
try {
const currentKey = cacheKey(directory, key)
if (value === undefined) {
localStorage.removeItem(currentKey)
localStorage.removeItem(legacyCacheKey(directory, key))
} else {
localStorage.setItem(currentKey, JSON.stringify(value))
localStorage.removeItem(legacyCacheKey(directory, key))
}
} catch {
// localStorage quota exceeded — ignore
}
}
function sessionRecencyTimestamp(session: Session): number {
const updated = session.time?.updated
if (typeof updated === "number" && Number.isFinite(updated)) return updated
const created = session.time?.created
return typeof created === "number" && Number.isFinite(created) ? created : 0
}
function selectRecentSessions(sessions: Session[], limit: number): Session[] {
if (sessions.length <= limit) return sessions
const recentIds = new Set(
[...sessions]
.sort((left, right) => sessionRecencyTimestamp(right) - sessionRecencyTimestamp(left) || right.id.localeCompare(left.id))
.slice(0, limit)
.map((session) => session.id),
)
return sessions.filter((session) => recentIds.has(session.id))
}
function tryWriteCacheValue<T>(key: string, legacyKey: string, value: T): boolean {
try {
const serialized = JSON.stringify(value)
countSyncPersistenceSerialization(serialized)
countSyncPersistenceStorageWrite()
localStorage.setItem(key, serialized)
localStorage.removeItem(legacyKey)
return true
} catch {
return false
}
}
function writeSessionCache(key: string, legacyKey: string, sessions: Session[]): void {
const recentSessions = selectRecentSessions(sessions, PERSISTED_SESSION_LIMIT)
if (tryWriteCacheValue(key, legacyKey, recentSessions)) return
// Replacing a stale value can fail when unrelated localStorage data has
// grown. Remove that value and retain as much recent history as still fits.
try {
localStorage.removeItem(key)
localStorage.removeItem(legacyKey)
} catch {
return
}
for (const limit of SESSION_CACHE_FALLBACK_LIMITS) {
const candidate = selectRecentSessions(recentSessions, limit)
if (tryWriteCacheValue(key, legacyKey, candidate)) return
}
// An empty v2 value is a tombstone: never resurrect stale legacy sessions.
tryWriteCacheValue(key, legacyKey, [])
}
function flushPendingSessionWrites(): void {
if (pendingSessionWriteTimer !== undefined) {
clearTimeout(pendingSessionWriteTimer)
pendingSessionWriteTimer = undefined
}
if (pendingSessionWrites.size === 0) return
const writes = [...pendingSessionWrites.values()]
pendingSessionWrites.clear()
const currentRuntimeKey = getRuntimeKey() || "local"
for (const pending of writes) {
if (pending.runtimeKey !== currentRuntimeKey) continue
writeSessionCache(pending.key, pending.legacyKey, pending.sessions)
}
}
function scheduleSessionCacheWrite(directory: string, sessions: Session[]): void {
const runtimeKey = getRuntimeKey() || "local"
const key = `${storagePrefixForRuntime(runtimeKey, directory)}.sessions`
for (const [pendingKey, pending] of pendingSessionWrites) {
if (pending.runtimeKey !== runtimeKey) pendingSessionWrites.delete(pendingKey)
}
pendingSessionWrites.set(key, { runtimeKey, key, legacyKey: legacyCacheKey(directory, "sessions"), sessions })
if (pendingSessionWriteTimer !== undefined) return
pendingSessionWriteTimer = setTimeout(flushPendingSessionWrites, SESSION_PERSIST_DEBOUNCE_MS)
}
function cancelPendingSessionWrites(runtimeKey: string): void {
for (const [key, pending] of pendingSessionWrites) {
if (pending.runtimeKey === runtimeKey) pendingSessionWrites.delete(key)
}
if (pendingSessionWrites.size === 0 && pendingSessionWriteTimer !== undefined) {
clearTimeout(pendingSessionWriteTimer)
pendingSessionWriteTimer = undefined
}
}
subscribeRuntimeEndpointWillChange(({ previousRuntimeKey }) => cancelPendingSessionWrites(previousRuntimeKey))
if (typeof window !== "undefined") {
window.addEventListener("pagehide", flushPendingSessionWrites, { capture: true })
window.addEventListener("beforeunload", flushPendingSessionWrites, { capture: true })
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushPendingSessionWrites()
})
document.addEventListener("freeze", flushPendingSessionWrites)
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export type PersistedDirCache = {
vcs: VcsInfo | undefined
projectMeta: ProjectMeta | undefined
icon: string | undefined
sessions: Session[] | undefined
}
/** Read all cached metadata for a directory */
export function readDirCache(directory: string): PersistedDirCache {
return {
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 {
const key = cacheKey(directory, "sessions")
if (!sessions) {
pendingSessionWrites.delete(key)
writeCache(directory, "sessions", undefined)
return
}
if (sessions.length === 0) {
pendingSessionWrites.delete(key)
writeSessionCache(key, legacyCacheKey(directory, "sessions"), sessions)
return
}
scheduleSessionCacheWrite(directory, sessions)
}
/** Write vcs info to cache */
export function persistVcs(directory: string, vcs: VcsInfo | undefined): void {
writeCache(directory, "vcs", vcs)
}
/** Write project metadata to cache */
export function persistProjectMeta(directory: string, meta: ProjectMeta | undefined): void {
writeCache(directory, "projectMeta", meta)
}
/** Write icon to cache */
export function persistIcon(directory: string, icon: string | undefined): void {
writeCache(directory, "icon", icon)
}