Files
openchamber/packages/ui/src/sync/use-sync.ts
T
Bohdan Triapitsyn 2031e3b4a8 Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
2026-06-02 00:43:05 +03:00

552 lines
20 KiB
TypeScript

import { useCallback, useRef, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { retry } from "./retry"
import { SESSION_CACHE_LIMIT, type State } from "./types"
import { pickSessionCacheEvictions } from "./session-cache"
import {
mergeOptimisticPage,
type OptimisticItem,
} from "./optimistic"
import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
import { stripMessageDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import {
shouldSkipSessionPrefetch,
getSessionPrefetch,
setSessionPrefetch,
clearSessionPrefetch,
} from "./session-prefetch-cache"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const INITIAL_MESSAGE_PAGE_SIZE = 150
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
const HISTORY_MESSAGE_PAGE_SIZE = 200
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
const MAX_SEEN_DIRS = 30
const VSCODE_SESSION_CACHE_LIMIT = 4
const MOBILE_SESSION_CACHE_LIMIT = 4
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
// Shared across useSync() instances so cache eviction is based on app-level
// session recency, not whichever component happened to call sync first.
const seenByDirectory = new Map<string, Set<string>>()
// 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>>()
type SyncMeta = {
limit: number
cursor: string | undefined
complete: boolean
loading: boolean
}
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 getConstrainedInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1]
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
}
const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false })
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
const info = getSessionPrefetch(directory, sessionID)
if (!info) return undefined
return {
limit: info.limit,
cursor: info.cursor,
complete: info.complete,
loading: false,
}
}
function sortParts(parts: Part[]) {
return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
}
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"
}
function hasUserMessage(messages: Message[] | undefined): boolean {
return Boolean(messages?.some(isUserMessage))
}
// ---------------------------------------------------------------------------
// useSync — message loading, pagination, optimistic updates
// Message loading, pagination, optimistic updates
// ---------------------------------------------------------------------------
export function useSync() {
const sdk = useSyncSDK()
const directory = useSyncDirectory()
const store = useDirectoryStore()
const childStores = useChildStoreManager()
// Refs for mutable tracking (no re-renders)
const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>())
const meta = useRef(new Map<string, SyncMeta>())
const keyFor = useCallback(
(sessionID: string) => `${directory}\n${sessionID}`,
[directory],
)
const getMetaFor = useCallback(
(sessionID: string) => {
const key = keyFor(sessionID)
return meta.current.get(key) ?? getPrefetchMeta(directory, sessionID) ?? getDefaultMeta()
},
[directory, keyFor],
)
const setMetaFor = useCallback(
(sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => {
const key = keyFor(sessionID)
const current = meta.current.get(key) ?? getPrefetchMeta(directory, sessionID) ?? getDefaultMeta()
meta.current.set(key, { ...current, ...patch })
},
[directory, keyFor],
)
// Session cache eviction — two levels of LRU:
// (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT).
// Evict all cached session data for given IDs from a directory's store
const evict = useCallback(
(dir: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
const dirStore = childStores.getChild(dir)
if (!dirStore) return
const current = dirStore.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(dirStore, sessionIDs)
dirStore.setState(draft)
// Clear meta + optimistic + prefetch cache for evicted sessions
for (const id of sessionIDs) {
optimistic.current.delete(`${dir}\n${id}`)
meta.current.delete(`${dir}\n${id}`)
}
clearSessionPrefetch(dir, sessionIDs)
},
[childStores],
)
// Get or create the seen-set for a directory. LRU reorder on access.
// When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
// LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
const seenFor = useCallback(() => {
const existing = seenByDirectory.get(directory)
if (existing) {
// LRU reorder: delete + re-insert moves to end (most recent)
seenByDirectory.delete(directory)
seenByDirectory.set(directory, existing)
return existing
}
const created = new Set<string>()
seenByDirectory.set(directory, created)
// Evict oldest directories if over limit
while (seenByDirectory.size > MAX_SEEN_DIRS) {
const first = seenByDirectory.keys().next().value
if (!first) break
const staleSessionIds = [...(seenByDirectory.get(first) ?? [])]
seenByDirectory.delete(first)
evict(first, staleSessionIds)
}
return created
}, [directory, evict])
// Touch a session — triggers both directory-level and session-level eviction
const touch = useCallback(
(sessionID: string) => {
const s = seenFor()
const protectedIds = getProtectedSessionCacheIds(store.getState())
const cacheLimit = getEffectiveSessionCacheLimit()
const stale = pickSessionCacheEvictions({
seen: s,
keep: sessionID,
limit: cacheLimit,
preserve: protectedIds,
})
evict(directory, stale)
if (isConstrainedSessionRuntime()) {
const state = store.getState()
const keep = new Set([sessionID, ...s, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(directory, prefetched)
// One very large inactive session can create memory/GC pressure that
// makes later small-session switches feel slow. Keep it while active,
// but do not retain it as a warm cache in constrained shells.
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
if (id === sessionID || protectedIds.has(id)) return false
return isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
})
if (heavyInactive.length > 0) {
for (const id of heavyInactive) s.delete(id)
evict(directory, heavyInactive)
}
}
},
[directory, seenFor, evict, store],
)
// Optimistic operations
const getOptimistic = useCallback(
(sessionID: string): OptimisticItem[] => {
const key = `${directory}\n${sessionID}`
return [...(optimistic.current.get(key)?.values() ?? [])]
},
[directory],
)
const setOptimistic = useCallback(
(sessionID: string, item: OptimisticItem) => {
const key = `${directory}\n${sessionID}`
const list = optimistic.current.get(key)
const sorted: OptimisticItem = { message: item.message, parts: sortParts(item.parts) }
if (list) {
list.set(item.message.id, sorted)
} else {
optimistic.current.set(key, new Map([[item.message.id, sorted]]))
}
},
[directory],
)
const clearOptimistic = useCallback(
(sessionID: string, messageID?: string) => {
const key = `${directory}\n${sessionID}`
if (!messageID) {
optimistic.current.delete(key)
return
}
const list = optimistic.current.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.current.delete(key)
},
[directory],
)
// Fetch messages from API
const fetchMessages = useCallback(
async (sessionID: string, limit: number, before?: string) => {
const result = await retry(async () => {
const response = await sdk.session.messages({ sessionID, directory, limit, before })
assertSdkSuccess(response, "session.messages")
return response
})
const items = (result.data ?? []).filter((x: { info?: { id?: string } }) => !!x?.info?.id)
const session = items
.map((x: { info: Message }) => stripMessageDiffSnapshots(x.info))
.sort((a: Message, b: Message) => cmp(a.id, b.id))
const part = items.map((x: { info: { id: string }; parts: Part[] }) => ({
id: x.info.id,
part: sortParts(x.parts),
}))
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
return { session, part, cursor, complete: !cursor }
},
[sdk, directory],
)
// Load messages for a session
const loadMessages = useCallback(
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => {
const m = getMetaFor(sessionID)
if (m.loading) return
setMetaFor(sessionID, { loading: true })
try {
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit
let page = await fetchMessages(sessionID, limit, options?.before)
// Constrained shells keep the initial page small for switch performance. Some
// sessions have a very large final turn, so the latest 30 records can
// contain only assistant/tool records and no user boundary. That makes
// turn projection render an empty chat until the user manually loads
// older messages. Expand only this initial tail fetch, with a hard cap.
if (!options?.before && isConstrainedSessionRuntime() && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
if (nextLimit <= limit) continue
page = await fetchMessages(sessionID, nextLimit)
if (page.complete || hasUserMessage(page.session)) break
}
}
// Merge optimistic items
const items = getOptimistic(sessionID)
const merged = mergeOptimisticPage(page, items)
for (const messageID of merged.confirmed) {
clearOptimistic(sessionID, messageID)
}
const current = store.getState()
const materialized = materializeSessionSnapshots(
current,
sessionID,
merged.session.map((info) => ({
info,
parts: merged.part.find((item) => item.id === info.id)?.part ?? [],
})),
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
)
setMetaFor(sessionID, {
limit: materialized.messages.length,
cursor: merged.cursor,
complete: merged.complete,
loading: false,
})
store.setState({ message: materialized.message, part: materialized.part })
setSessionPrefetch({
directory,
sessionID,
limit: materialized.messages.length,
cursor: merged.cursor,
complete: merged.complete,
})
} catch {
setMetaFor(sessionID, { loading: false })
}
},
[store, fetchMessages, getMetaFor, setMetaFor, getOptimistic, clearOptimistic, directory],
)
// Sync a session (load if not cached)
const syncSession = useCallback(
async (sessionID: string, force?: boolean) => {
touch(sessionID)
const key = keyFor(sessionID)
// Dedup inflight requests
const existing = syncSessionInflightByKey.get(key)
if (existing) return existing
const current = store.getState()
const m = getMetaFor(sessionID)
const materialization = getSessionMaterializationStatus(current, sessionID)
const cached = materialization.hasMessages && materialization.renderable && m.limit > 0
const prefetchInfo = !force ? getSessionPrefetch(directory, sessionID) : undefined
const knownCachedLimit = Math.max(m.limit, prefetchInfo?.limit ?? 0)
const needsConstrainedInitialTurnBoundary = isConstrainedSessionRuntime()
&& cached
&& !hasUserMessage(current.message[sessionID])
&& knownCachedLimit < getConstrainedInitialPageExpansionMax()
&& !m.complete
&& prefetchInfo?.complete !== true
&& Boolean(m.cursor ?? prefetchInfo?.cursor)
if (needsConstrainedInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) {
setMetaFor(sessionID, {
limit: prefetchInfo.limit,
cursor: prefetchInfo.cursor,
complete: prefetchInfo.complete,
})
}
const cachedReady = cached && !needsConstrainedInitialTurnBoundary
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
if (cachedReady && hasSession && !force) return
// Skip if recently fetched (TTL)
if (!force && !needsConstrainedInitialTurnBoundary) {
if (shouldSkipSessionPrefetch({
hasMessages: cachedReady,
info: prefetchInfo,
pageSize: getInitialMessagePageSize(),
})) return
}
const shouldFetchSession = !hasSession || force
const shouldLoadMessages = !cachedReady || force
const promise = (async () => {
await Promise.all([
shouldFetchSession
? (async () => {
try {
const result = await retry(async () => {
const response = await sdk.session.get({ sessionID, directory })
assertSdkSuccess(response, "session.get")
return response
})
if (result.data) {
const s = store.getState()
const sessions = [...s.session]
const idx = Binary.search(sessions, sessionID, (s) => s.id)
if (idx.found) {
sessions[idx.index] = result.data
} else {
sessions.splice(idx.index, 0, result.data)
}
store.setState({ session: sessions })
}
} catch (e) {
console.error("[sync] failed to fetch session", sessionID, e)
}
})()
: Promise.resolve(),
shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(),
])
})()
syncSessionInflightByKey.set(key, promise)
promise.finally(() => {
if (syncSessionInflightByKey.get(key) === promise) {
syncSessionInflightByKey.delete(key)
}
})
return promise
},
[store, sdk, keyFor, touch, getMetaFor, setMetaFor, loadMessages, directory],
)
// Load more (pagination)
const loadMore = useCallback(
async (sessionID: string) => {
touch(sessionID)
const m = getMetaFor(sessionID)
if (m.loading || m.complete || !m.cursor) return
await loadMessages(sessionID, { before: m.cursor, mode: "prepend" })
},
[touch, getMetaFor, loadMessages],
)
const hasMore = useCallback(
(sessionID: string) => {
const m = getMetaFor(sessionID)
return !m.complete && !!m.cursor
},
[getMetaFor],
)
const isLoading = useCallback(
(sessionID: string) => getMetaFor(sessionID).loading,
[getMetaFor],
)
// Optimistic add (for prompt submission)
const optimisticAdd = useCallback(
(input: { sessionID: string; message: Message; parts: Part[] }) => {
setOptimistic(input.sessionID, { message: input.message, parts: input.parts })
const current = store.getState()
const message = { ...current.message }
const part = { ...current.part }
// Insert message
const messages = message[input.sessionID] ? [...message[input.sessionID]] : []
const result = Binary.search(messages, input.message.id, (m) => m.id)
if (!result.found) messages.splice(result.index, 0, input.message)
message[input.sessionID] = messages
// Insert parts
part[input.message.id] = sortParts(input.parts)
store.setState({ message, part })
},
[store, setOptimistic],
)
// Optimistic remove (for rollback on error)
const optimisticRemove = useCallback(
(input: { sessionID: string; messageID: string }) => {
clearOptimistic(input.sessionID, input.messageID)
const current = store.getState()
const message = { ...current.message }
const part = { ...current.part }
const messages = message[input.sessionID]
if (messages) {
const next = [...messages]
const result = Binary.search(next, input.messageID, (m) => m.id)
if (result.found) {
next.splice(result.index, 1)
message[input.sessionID] = next
}
}
delete part[input.messageID]
store.setState({ message, part })
},
[store, clearOptimistic],
)
return useMemo(
() => ({
ensureSessionRenderable: syncSession,
syncSession,
loadMore,
hasMore,
isLoading,
optimistic: {
add: optimisticAdd,
remove: optimisticRemove,
},
}),
[syncSession, loadMore, hasMore, isLoading, optimisticAdd, optimisticRemove],
)
}