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
This commit is contained in:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -10,14 +10,11 @@ import { useInputStore } from "./input-store"
|
||||
import type { ChildStoreManager } from "./child-store"
|
||||
import { computeSubtreeIds } from "./scoped-blocking-requests"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
import { useConfigStore } from "@/stores/useConfigStore"
|
||||
import { registerSessionDirectory } from "./sync-refs"
|
||||
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||
import { retry } from "./retry"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
||||
import { materializeSessionSnapshots } from "./materialization"
|
||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { sessionEvents } from "@/lib/sessionEvents"
|
||||
import {
|
||||
@@ -28,6 +25,9 @@ import {
|
||||
type SessionMetadataRecord,
|
||||
} from "@/lib/sessionReviewMetadata"
|
||||
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
|
||||
import { getImperativeSessionMessageLoader } from "./session-message-loader"
|
||||
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
|
||||
const MESSAGE_REFETCH_LIMIT = 100
|
||||
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||
@@ -49,6 +49,37 @@ let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null
|
||||
let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null
|
||||
let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null
|
||||
|
||||
function sessionMutationPatch(
|
||||
state: ReturnType<DirectoryStoreApi["getState"]>,
|
||||
sessionId: string,
|
||||
deleted: boolean,
|
||||
) {
|
||||
const revision = (state.sessionRevision ?? 0) + 1
|
||||
const sessionEventRevision = { ...(state.sessionEventRevision ?? {}) }
|
||||
const sessionDeletedRevision = { ...(state.sessionDeletedRevision ?? {}) }
|
||||
if (deleted) {
|
||||
sessionDeletedRevision[sessionId] = revision
|
||||
delete sessionEventRevision[sessionId]
|
||||
} else {
|
||||
sessionEventRevision[sessionId] = revision
|
||||
delete sessionDeletedRevision[sessionId]
|
||||
}
|
||||
return {
|
||||
sessionListSource: "live" as const,
|
||||
sessionRevision: revision,
|
||||
sessionEventRevision,
|
||||
sessionDeletedRevision,
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateSessionLoads(sessionId: string, directories: Iterable<string | null | undefined>): void {
|
||||
const loader = getImperativeSessionMessageLoader()
|
||||
if (!loader) return
|
||||
for (const directory of new Set(directories)) {
|
||||
if (directory) loader.invalidateSession({ directory, sessionID: sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type SdkResult<T> = {
|
||||
@@ -263,6 +294,7 @@ function reconcileSessionMove(
|
||||
question: questions.source,
|
||||
message: messages.source,
|
||||
part: parts.source,
|
||||
...sessionMutationPatch(sourceState, session.id, true),
|
||||
})
|
||||
destinationStore.setState({
|
||||
session: destinationSessions,
|
||||
@@ -276,6 +308,7 @@ function reconcileSessionMove(
|
||||
question: questions.destination,
|
||||
message: messages.destination,
|
||||
part: parts.destination,
|
||||
...sessionMutationPatch(destinationState, session.id, false),
|
||||
})
|
||||
|
||||
return movedSession
|
||||
@@ -294,6 +327,8 @@ export async function moveSessionToDirectory(
|
||||
})
|
||||
assertSdkSuccess(result, "Move session")
|
||||
|
||||
invalidateSessionLoads(session.id, [sourceDirectory, destinationDirectory])
|
||||
|
||||
const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory)
|
||||
|
||||
registerSessionDirectory(session.id, destinationDirectory)
|
||||
@@ -368,7 +403,6 @@ export async function waitForConnectionOrThrow(): Promise<void> {
|
||||
|
||||
type SessionListSnapshot = {
|
||||
directory: string
|
||||
sessions: Session[]
|
||||
}
|
||||
|
||||
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
|
||||
@@ -378,14 +412,11 @@ function getGlobalSessionSnapshot(sessionId: string): Session | null {
|
||||
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
|
||||
}
|
||||
|
||||
function restoreGlobalSessionSnapshot(session: Session | null): void {
|
||||
if (!session) return
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
}
|
||||
|
||||
function getSessionDirectory(sessionId: string): string | undefined {
|
||||
const globalSession = getGlobalSessionSnapshot(sessionId)
|
||||
return findSessionDirectoryInChildStores(sessionId)
|
||||
|| useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||
|| (globalSession ? resolveGlobalSessionDirectory(globalSession) ?? undefined : undefined)
|
||||
|| dir()
|
||||
}
|
||||
|
||||
@@ -622,8 +653,8 @@ async function cleanupReviewMetadataBeforeDelete(sessionId: string, directory?:
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistically remove a session from every live child store that has it. */
|
||||
function optimisticRemoveSession(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] {
|
||||
/** Remove a server-confirmed session from every live child store that has it. */
|
||||
function removeSessionFromLiveStores(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] {
|
||||
if (!_childStores) return []
|
||||
|
||||
const snapshots: SessionListSnapshot[] = []
|
||||
@@ -648,45 +679,46 @@ function optimisticRemoveSession(sessionId: string, preferredDirectory?: string)
|
||||
if (!current.session.some((session) => session.id === sessionId)) {
|
||||
continue
|
||||
}
|
||||
snapshots.push({ directory, sessions: current.session })
|
||||
store.setState({ session: current.session.filter((session) => session.id !== sessionId) })
|
||||
snapshots.push({ directory })
|
||||
store.setState({
|
||||
session: current.session.filter((session) => session.id !== sessionId),
|
||||
...sessionMutationPatch(current, sessionId, true),
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void {
|
||||
if (!_childStores) return
|
||||
for (const snapshot of snapshots) {
|
||||
const store = _childStores.children.get(snapshot.directory)
|
||||
if (!store) continue
|
||||
store.setState({ session: snapshot.sessions })
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSessionWorktreeMetadata(sessionId: string): void {
|
||||
useSessionUIStore.getState().setWorktreeMetadata(sessionId, null)
|
||||
}
|
||||
|
||||
function finalizeConfirmedSessionDeletion(sessionId: string, sessionDirectory?: string): void {
|
||||
const snapshots = removeSessionFromLiveStores(sessionId, sessionDirectory)
|
||||
invalidateSessionLoads(sessionId, [...snapshots.map((snapshot) => snapshot.directory), sessionDirectory])
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
|
||||
cleanupSessionWorktreeMetadata(sessionId)
|
||||
if (sessionDirectory) {
|
||||
cleanupPersistedSessionState({
|
||||
runtimeKey: getRuntimeKey(),
|
||||
directory: sessionDirectory,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
|
||||
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) {
|
||||
ui.setCurrentSession(null)
|
||||
}
|
||||
try {
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory)
|
||||
const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory)
|
||||
if (deleted !== true) {
|
||||
throw new Error("session.delete failed: server did not confirm deletion")
|
||||
}
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
cleanupSessionWorktreeMetadata(sessionId)
|
||||
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] deleteSession failed", error)
|
||||
@@ -694,66 +726,50 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
|
||||
// Subsequent delete attempts for those children return 404; treat as
|
||||
// success since the session was already deleted by the cascade.
|
||||
if ((error as { status?: number })?.status === 404) {
|
||||
cleanupSessionWorktreeMetadata(sessionId)
|
||||
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory)
|
||||
return true
|
||||
}
|
||||
restoreSessionListSnapshots(snapshots)
|
||||
restoreGlobalSessionSnapshot(globalSnapshot)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */
|
||||
export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise<boolean> {
|
||||
if (!_childStores) return false
|
||||
const snapshots = optimisticRemoveSession(sessionId, directory)
|
||||
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
|
||||
try {
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, directory)
|
||||
const deleted = await opencodeClient.deleteSession(sessionId, directory)
|
||||
if (deleted !== true) {
|
||||
throw new Error("session.delete failed: server did not confirm deletion")
|
||||
}
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionId])
|
||||
cleanupSessionWorktreeMetadata(sessionId)
|
||||
finalizeConfirmedSessionDeletion(sessionId, directory)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] deleteSessionInDirectory failed", error)
|
||||
if ((error as { status?: number })?.status === 404) {
|
||||
cleanupSessionWorktreeMetadata(sessionId)
|
||||
finalizeConfirmedSessionDeletion(sessionId, directory)
|
||||
return true
|
||||
}
|
||||
restoreSessionListSnapshots(snapshots)
|
||||
restoreGlobalSessionSnapshot(globalSnapshot)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<boolean> {
|
||||
const sessionDirectory = getSessionDirectory(sessionId)
|
||||
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
|
||||
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
|
||||
const archivedAt = Date.now()
|
||||
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) {
|
||||
ui.setCurrentSession(null)
|
||||
}
|
||||
try {
|
||||
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory)
|
||||
const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory)
|
||||
if (!archived) {
|
||||
throw new Error("session.update failed: server did not return the archived session")
|
||||
}
|
||||
const snapshots = removeSessionFromLiveStores(sessionId, sessionDirectory)
|
||||
invalidateSessionLoads(sessionId, [...snapshots.map((snapshot) => snapshot.directory), sessionDirectory])
|
||||
useGlobalSessionsStore.getState().upsertSession(archived)
|
||||
const ui = useSessionUIStore.getState()
|
||||
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("[session-actions] archiveSession failed", error)
|
||||
restoreSessionListSnapshots(snapshots)
|
||||
restoreGlobalSessionSnapshot(globalSnapshot)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1288,6 +1304,16 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
|
||||
export async function refetchSessionMessages(sessionId: string): Promise<void> {
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const loader = getImperativeSessionMessageLoader()
|
||||
if (loader && directory) {
|
||||
await loader.refreshTail({ directory, sessionID: sessionId }, MESSAGE_REFETCH_LIMIT)
|
||||
const snapshot = loader.getSnapshot({ directory, sessionID: sessionId })
|
||||
if (snapshot.status === "error") throw snapshot.error ?? new Error("Session message refresh failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Actions can run in isolated tests before SyncProvider binds the shared
|
||||
// loader. The application runtime always takes the shared path above.
|
||||
const result = await sdk().session.messages({ sessionID: sessionId, directory, limit: MESSAGE_REFETCH_LIMIT })
|
||||
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
||||
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
||||
@@ -1393,75 +1419,11 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
|
||||
restoreFilePartsToInput(fileParts)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imperative fetch path — starts message loading on the same tick as
|
||||
// setCurrentSession, before the React commit cycle fires useEffect.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FETCH_MESSAGES_LOADING = new Set<string>()
|
||||
const DESKTOP_INITIAL_PAGE_SIZE = 50
|
||||
const CONSTRAINED_INITIAL_PAGE_SIZE = 30
|
||||
|
||||
const getFetchPageSize = () => {
|
||||
if (isVSCodeRuntime() || isMobileSurfaceRuntime()) return CONSTRAINED_INITIAL_PAGE_SIZE
|
||||
return DESKTOP_INITIAL_PAGE_SIZE
|
||||
}
|
||||
|
||||
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
|
||||
const resolvedDir = directory ?? dir()
|
||||
if (!resolvedDir) return
|
||||
|
||||
const loadingKey = `${resolvedDir}:${sessionID}`
|
||||
if (FETCH_MESSAGES_LOADING.has(loadingKey)) return
|
||||
|
||||
FETCH_MESSAGES_LOADING.add(loadingKey)
|
||||
|
||||
try {
|
||||
const s = sdk()
|
||||
const store = directory
|
||||
? dirStoreForDirectory(directory)
|
||||
: dirStore()
|
||||
|
||||
if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return
|
||||
|
||||
const result = await retry(async () => {
|
||||
const response = await s.session.messages({
|
||||
sessionID,
|
||||
directory: resolvedDir,
|
||||
limit: getFetchPageSize(),
|
||||
})
|
||||
return response
|
||||
})
|
||||
|
||||
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
||||
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
||||
if (records.length === 0) return
|
||||
|
||||
// Staleness guard: a rapid session switch may have moved the user off this
|
||||
// session while the fetch was in flight. Skip the write so a slow fetch
|
||||
// can't repopulate (and un-evict) a session already navigated away from.
|
||||
if (useSessionUIStore.getState().currentSessionId !== sessionID) return
|
||||
|
||||
const latestState = store.getState()
|
||||
const latestStatus = getSessionMaterializationStatus(latestState, sessionID)
|
||||
if (latestStatus.renderable && (latestState.message[sessionID]?.length ?? 0) >= records.length) return
|
||||
|
||||
store.setState((state) => {
|
||||
const materialized = materializeSessionSnapshots(
|
||||
state,
|
||||
sessionID,
|
||||
records.map((record: { info: Message; parts?: Part[] }) => ({
|
||||
info: stripMessageDiffSnapshots(record.info),
|
||||
parts: record.parts ?? [],
|
||||
})),
|
||||
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
|
||||
)
|
||||
if (!materialized.messagesChanged && !materialized.partsChanged) return state
|
||||
return { message: materialized.message, part: materialized.part }
|
||||
})
|
||||
} catch {
|
||||
// Transient failure — the reactive path in ChatContainer will retry
|
||||
} finally {
|
||||
FETCH_MESSAGES_LOADING.delete(loadingKey)
|
||||
}
|
||||
await getImperativeSessionMessageLoader()?.ensure(
|
||||
{ directory: resolvedDir, sessionID },
|
||||
{ reason: "navigation" },
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user