Files
openchamber/packages/ui/src/sync/reconnect-recovery.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

146 lines
4.8 KiB
TypeScript

import type { SessionStatus, Message, Part } from "@opencode-ai/sdk/v2/client"
import type { Session } from "@opencode-ai/sdk/v2"
import { getSessionMaterializationStatus } from "./materialization"
type ReconnectMaterializationState = {
session: Session[]
session_status?: Record<string, SessionStatus>
message?: Record<string, Message[]>
part?: Record<string, Part[]>
}
type ViewedSessionMaterializationTarget = {
directory: string
sessionId: string
}
type ReconnectCandidateOptions = {
directory?: string
viewedSession?: ViewedSessionMaterializationTarget | null
}
type BootstrapSessionRevisionOptions = {
baselineRevision: number
eventRevision?: Record<string, number>
deletedRevision?: Record<string, number>
}
const getParentId = (session: Session): string | null | undefined => (
(session as Session & { parentID?: string | null }).parentID
)
const includeAncestorSessions = (
parentIds: string[],
includedIds: Set<string>,
sessionsById: Map<string, Session>,
excludedIds?: ReadonlySet<string>,
): void => {
while (parentIds.length > 0) {
const parentId = parentIds.pop()
if (!parentId || includedIds.has(parentId) || excludedIds?.has(parentId)) continue
const parent = sessionsById.get(parentId)
if (!parent) continue
includedIds.add(parentId)
const ancestorId = getParentId(parent)
if (ancestorId) parentIds.push(ancestorId)
}
}
export function mergeBootstrapSessions(
rootSessions: Session[],
allSessions: Session[] | null,
existingSessions: Session[],
revisions?: BootstrapSessionRevisionOptions,
): { sessions: Session[]; rootCount: number } {
const completeSessions = allSessions ?? existingSessions.filter(
(session) => Boolean(getParentId(session)),
)
const rootIds = new Set(rootSessions.map((session) => session.id))
const sessionsById = new Map(existingSessions.map((session) => [session.id, session]))
for (const session of completeSessions) sessionsById.set(session.id, session)
for (const session of rootSessions) sessionsById.set(session.id, session)
const includedIds = new Set(rootIds)
const pendingParentIds: string[] = []
for (const session of completeSessions) {
const parentId = getParentId(session)
if (!parentId) continue
includedIds.add(session.id)
pendingParentIds.push(parentId)
}
includeAncestorSessions(pendingParentIds, includedIds, sessionsById)
if (revisions) {
const deletedIds = new Set(
Object.entries(revisions.deletedRevision ?? {})
.filter(([, revision]) => revision > revisions.baselineRevision)
.map(([sessionId]) => sessionId),
)
for (const session of existingSessions) {
if ((revisions.eventRevision?.[session.id] ?? 0) <= revisions.baselineRevision) continue
sessionsById.set(session.id, session)
includedIds.add(session.id)
const parentId = getParentId(session)
if (parentId) pendingParentIds.push(parentId)
}
for (const sessionId of deletedIds) includedIds.delete(sessionId)
includeAncestorSessions(pendingParentIds, includedIds, sessionsById, deletedIds)
}
const sessions = [...includedIds]
.map((id) => sessionsById.get(id))
.filter((session): session is Session => Boolean(session))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const rootCount = sessions.reduce((count, session) => (
getParentId(session) ? count : count + 1
), 0)
return { sessions, rootCount }
}
export function getReconnectCandidateSessionIds(state: ReconnectMaterializationState, options?: ReconnectCandidateOptions) {
const ids = new Set<string>()
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
if (status && status.type !== "idle") ids.add(sessionId)
}
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
const lastMessage = messages[messages.length - 1]
if (
lastMessage
&& lastMessage.role === "assistant"
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
) {
ids.add(sessionId)
} else if (!getSessionMaterializationStatus({ message: state.message ?? {}, part: state.part ?? {} }, sessionId).renderable) {
ids.add(sessionId)
}
}
const parentIds = new Set<string>()
for (const session of state.session) {
const parentId = getParentId(session)
if (parentId) {
parentIds.add(parentId)
}
}
for (const pid of parentIds) {
ids.add(pid)
}
const viewedSession = options?.viewedSession
if (viewedSession?.sessionId && viewedSession.directory === options?.directory) {
const sessionId = viewedSession.sessionId
const sessionExists = state.session.some((session) => session.id === sessionId)
|| Object.hasOwn(state.session_status ?? {}, sessionId)
|| Object.hasOwn(state.message ?? {}, sessionId)
if (sessionExists) {
ids.add(sessionId)
}
}
return Array.from(ids)
}