Files
openchamber/packages/ui/src/sync/use-sync.ts
T
c_w_xiaohei fa1503a038 perf(ui): narrow session switch fanout
Keep sidebar rows independent from the active sync directory by moving export history loading behind an explicit-directory command.

Make active-project selection preserve project topology and remove activeProjectId from mounted group render props. Reuse a per-session-array ID index so subscription snapshots do not repeatedly scan session lists.
2026-08-26 00:44:14 +08:00

430 lines
16 KiB
TypeScript

import { useCallback, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { upsertSessionRecord } from "./session-records"
import { retry } from "./retry"
import { SESSION_CACHE_LIMIT, type State } from "./types"
import { dropSessionCaches, getProtectedSessionCacheIds, pickSessionCacheEvictions } from "./session-cache"
import {
dropCachedSessionMessageRecordsSnapshots,
useChildStoreManager,
useDirectoryStore,
useSessionMessageLoader,
useSyncDirectory,
useSyncSDK,
useSyncRuntime,
resyncBlockingRequestsForDirectory,
buildSessionMessageRecordsSnapshot,
} from "./sync-context"
import { stripSessionDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import { clearSessionPrefetch } from "./session-prefetch-cache"
import { getSessionMaterializationStatus } from "./materialization"
import { getRuntimeKey } from "@/lib/runtime-switch"
const INITIAL_MESSAGE_PAGE_SIZE = 50
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
const MAX_SEEN_DIRS = 30
const VSCODE_SESSION_CACHE_LIMIT = 4
const MOBILE_SESSION_CACHE_LIMIT = 4
// Shared across useSync() instances so cache eviction is based on app-level
// session recency, not whichever component happened to call sync first.
type SeenDirectoryEntry = {
runtimeKey: string
directory: string
sessions: Set<string>
}
const seenByDirectory = new Map<string, SeenDirectoryEntry>()
// Shared across useSync() hook instances. Chat, model controls, and sidebar can
// all request the same session during startup; coalesce them into one HTTP load.
const syncSessionInflightByKey = new Map<string, Promise<void>>()
// Per-session generation counter. When a newer syncSession request starts for
// the same session, older in-flight requests become stale and must not write
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
// sidebar) from having each completed fetch fight for focus.
const syncSessionGenerationByKey = new Map<string, number>()
type SdkResult<T> = {
data?: T
error?: unknown
response?: {
status?: number
headers?: { get?: (name: string) => string | null }
}
}
function formatSdkError(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object") {
const message = (error as { message?: unknown }).message
if (typeof message === "string" && message.length > 0) return message
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): void {
if (!result.error) return
const status = result.response?.status
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`)
}
const isConstrainedSessionRuntime = () => isVSCodeRuntime() || isMobileSurfaceRuntime()
const getEffectiveSessionCacheLimit = () => {
if (isVSCodeRuntime()) return VSCODE_SESSION_CACHE_LIMIT
if (isMobileSurfaceRuntime()) return MOBILE_SESSION_CACHE_LIMIT
return SESSION_CACHE_LIMIT
}
const getInitialMessagePageSize = () => {
if (isVSCodeRuntime()) return VSCODE_INITIAL_MESSAGE_PAGE_SIZE
if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE
return INITIAL_MESSAGE_PAGE_SIZE
}
function isHeavyConstrainedSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
const messages = state.message[sessionID]
if (!messages || messages.length === 0) return false
return messages.length > getInitialMessagePageSize()
}
function isUserMessage(message: Message): boolean {
const info = message as Message & { clientRole?: unknown; role?: unknown }
const role = typeof info.clientRole === "string" ? info.clientRole : info.role
return role === "user"
}
export function hasUserMessage(messages: Message[] | undefined): boolean {
return Boolean(messages?.some(isUserMessage))
}
export function shouldFetchSessionForRenderableSync(input: {
hasSession: boolean
shouldLoadMessages: boolean
force?: boolean
}): boolean {
return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages
}
function useSessionCacheTouch() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const evict = useCallback(
(directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
const store = childStores.getChild(directory)
if (!store) return
const current = store.getState()
const draft = {
message: { ...current.message },
part: { ...current.part },
session_status: { ...current.session_status },
session_diff: { ...current.session_diff },
todo: { ...current.todo },
permission: { ...current.permission },
question: { ...current.question },
}
dropSessionCaches(draft, sessionIDs)
dropCachedSessionMessageRecordsSnapshots(store, sessionIDs)
store.setState(draft)
for (const sessionID of sessionIDs) messageLoader.invalidateSession({ directory, sessionID })
clearSessionPrefetch(directory, sessionIDs)
},
[childStores, messageLoader, runtimeKey],
)
const seenFor = useCallback((directory: string) => {
const cacheKey = `${runtimeKey}\n${directory}`
const existing = seenByDirectory.get(cacheKey)
if (existing) {
seenByDirectory.delete(cacheKey)
seenByDirectory.set(cacheKey, existing)
return existing.sessions
}
const created: SeenDirectoryEntry = { runtimeKey, directory, sessions: new Set() }
seenByDirectory.set(cacheKey, created)
while (seenByDirectory.size > MAX_SEEN_DIRS) {
const oldestKey = seenByDirectory.keys().next().value
if (!oldestKey) break
const oldest = seenByDirectory.get(oldestKey)
seenByDirectory.delete(oldestKey)
if (oldest?.runtimeKey === runtimeKey) evict(oldest.directory, [...oldest.sessions])
}
return created.sessions
}, [evict, runtimeKey])
return useCallback((sessionID: string, directory: string) => {
if (getRuntimeKey() !== runtimeKey) return
const seen = seenFor(directory)
const store = childStores.ensureChild(directory, { bootstrap: false })
const protectedIds = getProtectedSessionCacheIds(store.getState())
const stale = pickSessionCacheEvictions({
seen,
keep: sessionID,
limit: getEffectiveSessionCacheLimit(),
preserve: protectedIds,
})
evict(directory, stale)
if (!isConstrainedSessionRuntime()) return
const state = store.getState()
const keep = new Set([sessionID, ...seen, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(directory, prefetched)
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => (
id !== sessionID && !protectedIds.has(id) && isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
))
for (const id of heavyInactive) seen.delete(id)
evict(directory, heavyInactive)
}, [childStores, evict, runtimeKey, seenFor])
}
export function useSync() {
const sdk = useSyncSDK()
const directory = useSyncDirectory()
const store = useDirectoryStore()
const childStores = useChildStoreManager()
const messageLoader = useSessionMessageLoader()
const runtimeKey = getRuntimeKey()
const touch = useSessionCacheTouch()
const recoverPendingQuestions = useCallback(
async (sessionID: string, directoryOverride?: string): Promise<boolean> => {
const targetDirectory = directoryOverride || directory
if (!sessionID || !targetDirectory || getRuntimeKey() !== runtimeKey) return false
const targetStore = childStores.ensureChild(targetDirectory, {
priority: "selected",
reason: "selected-session",
})
await resyncBlockingRequestsForDirectory(targetDirectory, targetStore, [sessionID], {
includePermissions: false,
})
if (getRuntimeKey() !== runtimeKey) return false
return (targetStore.getState().question[sessionID]?.length ?? 0) > 0
},
[childStores, directory, runtimeKey],
)
const keyFor = useCallback(
(sessionID: string, directoryOverride = directory) => `${runtimeKey}\n${directoryOverride}\n${sessionID}`,
[directory, runtimeKey],
)
// Sync a session (load if not cached)
const syncSession = useCallback(
async (sessionID: string, force?: boolean, directoryOverride?: string) => {
if (getRuntimeKey() !== runtimeKey) return
const targetDirectory = directoryOverride || directory
touch(sessionID, targetDirectory)
const key = keyFor(sessionID, targetDirectory)
// Dedup inflight requests
const existing = syncSessionInflightByKey.get(key)
if (existing) return existing
// This is a new request. Bump generation so any older request that
// might still be finishing (e.g. from a previous component lifecycle)
// knows it is stale and should not write to the store.
const generation = (syncSessionGenerationByKey.get(key) ?? 0) + 1
syncSessionGenerationByKey.set(key, generation)
const isStale = () => syncSessionGenerationByKey.get(key) !== generation
const targetStore = targetDirectory === directory
? store
: childStores.ensureChild(targetDirectory, { bootstrap: false })
const current = targetStore.getState()
const materialization = getSessionMaterializationStatus(current, sessionID)
const cachedReady = materialization.hasMessages && materialization.renderable
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
if (cachedReady && hasSession && !force) return
const shouldLoadMessages = Boolean(!cachedReady || force)
const shouldFetchSession = shouldFetchSessionForRenderableSync({ hasSession, shouldLoadMessages, force: Boolean(force) })
const promise = (async () => {
await Promise.all([
shouldFetchSession
? (async () => {
try {
const result = await retry(async () => {
const response = await sdk.session.get({ sessionID, directory: targetDirectory })
assertSdkSuccess(response, "session.get")
return response
})
if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = targetStore.getState()
const sessions = upsertSessionRecord(s.session, nextSession)
if (sessions !== s.session && !isStale()) {
targetStore.setState({ session: sessions })
}
}
} catch (e) {
console.error("[sync] failed to fetch session", sessionID, e)
}
})()
: Promise.resolve(),
shouldLoadMessages
? messageLoader.ensure(
{ directory: targetDirectory, sessionID },
{ force, reason: "reactive" },
)
: Promise.resolve(),
])
})()
syncSessionInflightByKey.set(key, promise)
const clearInflightRequest = () => {
if (syncSessionInflightByKey.get(key) === promise) {
syncSessionInflightByKey.delete(key)
if (syncSessionGenerationByKey.get(key) === generation) {
syncSessionGenerationByKey.delete(key)
}
}
}
void promise.then(clearInflightRequest, clearInflightRequest)
return promise
},
[childStores, directory, keyFor, messageLoader, runtimeKey, sdk, store, touch],
)
// Load more (pagination)
const loadMore = useCallback(
async (sessionID: string, targetDirectory: string) => {
touch(sessionID, targetDirectory)
await messageLoader.loadOlder({ directory: targetDirectory, sessionID })
},
[messageLoader, touch],
)
const loadCompleteHistory = useCallback(
async (sessionID: string, targetDirectory: string) => {
touch(sessionID, targetDirectory)
await messageLoader.loadComplete({ directory: targetDirectory, sessionID })
},
[messageLoader, touch],
)
const prefetchSession = useCallback(
async (sessionID: string, targetDirectory: string) => {
if (getRuntimeKey() !== runtimeKey) return
await messageLoader.prefetch({ directory: targetDirectory, sessionID })
if (messageLoader.getSnapshot({ directory: targetDirectory, sessionID }).status === "ready") {
touch(sessionID, targetDirectory)
}
},
[messageLoader, runtimeKey, touch],
)
const hasMore = useCallback(
(sessionID: string, directoryOverride?: string) => {
const state = messageLoader.getSnapshot({ directory: directoryOverride || directory, sessionID })
return !state.complete && Boolean(state.cursor)
},
[directory, messageLoader],
)
const isLoading = useCallback(
(sessionID: string, directoryOverride?: string) => messageLoader
.getSnapshot({ directory: directoryOverride || directory, sessionID }).status === "loading",
[directory, messageLoader],
)
// True only when a fetch has positively confirmed the history is fully
// loaded (no next cursor). Distinct from !hasMore(), which is also true for
// sessions whose meta simply hasn't been populated yet.
const isComplete = useCallback(
(sessionID: string, directoryOverride?: string) => messageLoader
.getSnapshot({ directory: directoryOverride || directory, sessionID }).complete,
[directory, messageLoader],
)
// Optimistic add (for prompt submission)
const optimisticAdd = useCallback(
(input: { sessionID: string; directory?: string | null; message: Message; parts: Part[] }) => {
messageLoader.optimisticAdd({
directory: input.directory || directory,
sessionID: input.sessionID,
message: input.message,
parts: input.parts,
})
},
[directory, messageLoader],
)
// Optimistic remove (for rollback on error)
const optimisticRemove = useCallback(
(input: { sessionID: string; directory?: string | null; messageID: string }) => {
messageLoader.optimisticRemove({
directory: input.directory || directory,
sessionID: input.sessionID,
messageID: input.messageID,
})
},
[directory, messageLoader],
)
const optimisticConfirm = useCallback(
(input: { sessionID: string; directory?: string | null; messageID: string }) => {
messageLoader.optimisticConfirm({
directory: input.directory || directory,
sessionID: input.sessionID,
messageID: input.messageID,
})
},
[directory, messageLoader],
)
return useMemo(
() => ({
ensureSessionRenderable: syncSession,
syncSession,
prefetchSession,
loadMore,
loadCompleteHistory,
hasMore,
isLoading,
isComplete,
recoverPendingQuestions,
optimistic: {
add: optimisticAdd,
remove: optimisticRemove,
confirm: optimisticConfirm,
},
}),
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, recoverPendingQuestions, optimisticAdd, optimisticRemove, optimisticConfirm],
)
}
export function usePrefetchSessionMessages() {
const { messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return
await messageLoader.prefetch({ directory, sessionID })
if (messageLoader.getSnapshot({ directory, sessionID }).status !== "ready") return
touch(sessionID, directory)
}, [messageLoader, runtimeKey, touch])
}
export function useSessionMessageRecordsForExport() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return null
const store = childStores.ensureChild(directory, { bootstrap: false })
touch(sessionID, directory)
await messageLoader.loadComplete({ directory, sessionID })
if (getRuntimeKey() !== runtimeKey) return null
return buildSessionMessageRecordsSnapshot(store.getState(), sessionID).list
}, [childStores, messageLoader, runtimeKey, touch])
}