/* eslint-disable react-refresh/only-export-components */ import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react" import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2" import type { StoreApi } from "zustand" import { useStore } from "zustand" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" import { useGlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" import { aggregateLiveSessions, aggregateLiveSessionStatuses, areSessionListsEquivalent, areStatusMapsEquivalent, findLiveSession, findLiveSessionStatus, } from "./live-aggregate" import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" import { retry } from "./retry" import { updateStreamingState } from "./streaming" import { setActionRefs } from "./session-actions" import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds } from "./reconnect-recovery" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" import { useConfigStore } from "@/stores/useConfigStore" import { useTodosPersistStore } from "@/stores/useTodosPersistStore" import { toast } from "@/components/ui" import { appendNotification } from "./notification-store" import { applyGlobalSessionStatusEvent } from "./global-session-status" import type { State } from "./types" import type { SessionStatus } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@/types/permission" import type { QuestionRequest } from "@/types/question" import * as sessionActions from "./session-actions" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { openSessionFromToast } from "./session-navigation" import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory" import { getRuntimeKey } from "@/lib/runtime-switch" import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { setSessionPrefetch } from "./session-prefetch-cache" import { listGlobalSessionPages } from "@/stores/globalSessions" import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- type SyncSystem = { childStores: ChildStoreManager sdk: OpencodeClient directory: string } const SYNC_CONTEXT_GLOBAL_KEY = "__openchamber_sync_context__" type SyncGlobal = typeof globalThis & { [SYNC_CONTEXT_GLOBAL_KEY]?: React.Context } const syncGlobal = globalThis as SyncGlobal const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext(null) syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext type SdkResult = { 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" && "message" in error && typeof (error as { message?: unknown }).message === "string") { return (error as { message: string }).message } try { return JSON.stringify(error) } catch { return String(error) } } function assertSdkSuccess(result: SdkResult, operation: string): T | undefined { if (!result.error) return result.data const status = result.response?.status throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) } function useSyncSystem() { const ctx = useContext(SyncContext) if (!ctx) throw new Error("useSyncSystem must be used within ") return ctx } function getLiveStates(childStores: ChildStoreManager): State[] { return Array.from(childStores.children.values(), (store) => store.getState()) } function useLiveSyncSelector(selector: (states: State[]) => T, isEqual: (left: T, right: T) => boolean = Object.is): T { const { childStores } = useSyncSystem() const cacheRef = useRef(undefined) const initializedRef = useRef(false) const getSnapshot = useCallback(() => { const next = selector(getLiveStates(childStores)) if (initializedRef.current && isEqual(cacheRef.current as T, next)) { return cacheRef.current as T } cacheRef.current = next initializedRef.current = true return next }, [childStores, isEqual, selector]) return React.useSyncExternalStore( useCallback((notify) => childStores.subscribeAll(notify), [childStores]), getSnapshot, getSnapshot, ) } // --------------------------------------------------------------------------- // Event handler — applies one SSE event at a time to the live store. // Each event reads live state, creates a shallow draft, applies, writes back. // React 18 batches synchronous setState calls automatically. // --------------------------------------------------------------------------- /** Read status for a session across all directories */ export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined { return useLiveSyncSelector( useCallback((states) => findLiveSessionStatus(states, sessionId), [sessionId]), ) } /** Read all session statuses (for sidebar) */ export function useAllSessionStatuses(): Record { return useLiveSyncSelector( useCallback((states) => aggregateLiveSessionStatuses(states), []), areStatusMapsEquivalent, ) } export function useAllLiveSessions(): Session[] { return useLiveSyncSelector( useCallback((states) => aggregateLiveSessions(states), []), areSessionListsEquivalent, ) } // Boot debounce — suppresses redundant refresh/re-bootstrap events during startup. let bootingRoot = false let bootedAt = 0 let globalBootstrapGeneration = 0 const BOOT_DEBOUNCE_MS = 1500 const RECONNECT_MESSAGE_LIMIT = 30 const SESSION_MATERIALIZATION_MESSAGE_LIMIT = 30 const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const ACTIVE_SESSION_WATCHDOG_INTERVAL_MS = 5_000 const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000 const ACTIVE_SESSION_STALE_EVENT_MS = 20_000 const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000 const CHILD_SESSION_DISCOVERY_INTERVAL_MS = 15_000 const requestSignature = (items: Array<{ id: string }> | undefined): string => { if (!items || items.length === 0) return "" return items .map((item) => item.id) .sort(cmp) .join("|") } const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const syncSnapshotSignature = (value: unknown): string => JSON.stringify(value) function haveEquivalentSyncSnapshots(left: unknown, right: unknown): boolean { return syncSnapshotSignature(left) === syncSnapshotSignature(right) } // --------------------------------------------------------------------------- // Session materialization scheduler — when local message/part state is incomplete, // fetch the canonical session snapshot and materialize messages and parts together. // Tracked per-directory, deduplicated, and auto-expiring. // --------------------------------------------------------------------------- type PendingSessionMaterialization = { sessionID: string directory: string enqueuedAt: number } const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000 const pendingSessionMaterializations = new Map() // key: directory:sessionID const materializationKey = (directory: string, sessionID: string) => `${directory}:${sessionID}` function enqueueSessionMaterialization(directory: string, sessionID: string, childStores: ChildStoreManager) { if (!directory || directory === "global" || !sessionID) return const k = materializationKey(directory, sessionID) const existing = pendingSessionMaterializations.get(k) if (existing && Date.now() - existing.enqueuedAt < SESSION_MATERIALIZATION_COOLDOWN_MS) return pendingSessionMaterializations.set(k, { sessionID, directory, enqueuedAt: Date.now() }) // Defer to next microtask so we don't hold up the current event batch void Promise.resolve().then(async () => { const store = childStores.getChild(directory) if (!store) { pendingSessionMaterializations.delete(k) return } try { await materializeSessionFromServer(directory, sessionID, store) } catch { // Transient failure — next SSE event or reconnect will catch up. } finally { pendingSessionMaterializations.delete(k) } }) } async function materializeSessionFromServer( directory: string, sessionID: string, store: StoreApi, options?: { isStale?: () => boolean }, ) { const scopedClient = opencodeClient.getScopedSdkClient(directory) const result = await retry(async () => { const response = await scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }) assertSdkSuccess(response, "session.messages") return response }) const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id) if (records.length === 0) return const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined setSessionPrefetch({ directory, sessionID, limit: records.length, cursor, complete: !cursor, }) if (options?.isStale?.()) return store.setState((state: DirectoryStore) => { const materialized = materializeSessionSnapshots( state, sessionID, records.map((record: { info: Message; parts?: Part[] }) => ({ info: stripMessageDiffSnapshots(record.info), parts: record.parts ?? [], })), { skipPartTypes: RECONNECT_SKIP_PARTS }, ) return { message: materialized.message, part: materialized.part } }) } // Module-level refs for notification viewed check. // Used to determine if user is currently viewing the session when a notification arrives. let _activeDirectory = "" let _activeSession = "" const externallyViewedSessions = new Map() const EXTERNAL_VIEW_TTL_MS = 15_000 const viewedSessionKey = (directory: string, sessionId: string) => `${directory}\n${sessionId}` function pruneExternallyViewedSessions(now = Date.now()) { for (const [key, expiresAt] of externallyViewedSessions.entries()) { if (expiresAt <= now) { externallyViewedSessions.delete(key) } } } const pendingQuestionToastIds = new Set() const pendingPermissionToastIds = new Set() const getQuestionToastKey = (sessionID?: string, requestID?: string) => { if (!sessionID || !requestID) return null return `${sessionID}:${requestID}` } const getPermissionToastKey = (sessionID?: string, requestID?: string) => { if (!sessionID || !requestID) return null return `${sessionID}:${requestID}` } type UiNotificationPayload = { title?: unknown body?: unknown tag?: unknown kind?: unknown sessionId?: unknown directory?: unknown requireHidden?: unknown desktopNotificationDelivered?: unknown desktopStdoutActive?: unknown } const asOptionalString = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined const trimmed = value.trim() return trimmed.length > 0 ? trimmed : undefined } const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): boolean => { if ((payload as { type?: unknown }).type !== "openchamber:notification") { return false } const properties = (payload as { properties?: unknown }).properties if (!properties || typeof properties !== "object") { return true } const notification = properties as UiNotificationPayload if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") { return true } const notifications = getRegisteredRuntimeAPIs()?.notifications if (!notifications?.notifyAgentCompletion) { return true } void notifications.notifyAgentCompletion({ title: asOptionalString(notification.title), body: asOptionalString(notification.body), tag: asOptionalString(notification.tag), kind: asOptionalString(notification.kind), sessionId: asOptionalString(notification.sessionId), directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined), requireHidden: notification.requireHidden === true, }).catch((error) => { console.warn("[notifications] failed to dispatch UI notification", error) }) return true } export function setActiveSession(directory: string, sessionId: string) { _activeDirectory = directory _activeSession = sessionId } export function setExternallyViewedSession(directory: string, sessionId: string, viewed: boolean) { if (!directory || !sessionId) return const key = viewedSessionKey(directory, sessionId) if (!viewed) { externallyViewedSessions.delete(key) return } externallyViewedSessions.set(key, Date.now() + EXTERNAL_VIEW_TTL_MS) } // The window must actually be focused for the active session to count as // "seen": if the app is minimized or in the background, a turn finishing in the // currently-selected session should still raise an unseen marker (in the tray // and in-app), since the user isn't looking at it. function isWindowFocused(): boolean { return typeof document !== "undefined" && document.hasFocus() } function isViewedInCurrentSession(directory: string, sessionId?: string): boolean { if (!sessionId) return false if ( _activeDirectory && _activeSession && directory === _activeDirectory && sessionId === _activeSession && isWindowFocused() ) return true pruneExternallyViewedSessions() return externallyViewedSessions.has(viewedSessionKey(directory, sessionId)) } function isRecentBoot() { return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS } function getViewedSessionMaterializationTarget(directory: string) { if (!_activeDirectory || !_activeSession) return null if (directory !== _activeDirectory) return null return { directory: _activeDirectory, sessionId: _activeSession, } } function toSessionStatus(status: Awaited>[string] | undefined): SessionStatus | undefined { if (!status) return undefined if (status.type === "idle" || status.type === "busy") { return { type: status.type } } if ( status.type === "retry" && typeof status.attempt === "number" && typeof status.message === "string" && typeof status.next === "number" ) { return { type: "retry", attempt: status.attempt, message: status.message, next: status.next, } } return undefined } function getActiveSessionCandidateIds(directory: string, state: DirectoryStore): string[] { return getReconnectCandidateSessionIds(state, { directory, viewedSession: getViewedSessionMaterializationTarget(directory), }) } type DirectorySessionStatusSnapshot = NonNullable< Awaited> > // How a /session/status snapshot is reconciled into the store. // // The directory-scoped snapshot lists only active (busy/retry) sessions; an // absent candidate means "idle per this snapshot". // // - "monotonic": only confirm/raise active status. Never lowers a busy/retry // session to idle. Used by the periodic watchdog poll — real idle arrives via // SSE (session.status / session.idle) or via an authoritative resync that the // watchdog escalates to when it detects a stale busy entry. This keeps the // blind 5s poll from clobbering live state on a transient/misscoped snapshot. // - "authoritative": treat the snapshot as ground truth — absent/idle candidates // are lowered to idle. Used by reconnect/escalated resyncs, a deliberate edge // where the live server snapshot is the source of truth (mirrors the bootstrap // snapshot). The snapshot wins over any derived message state here. type StatusSnapshotMode = "monotonic" | "authoritative" export function applySessionStatusSnapshot( store: StoreApi, snapshot: DirectorySessionStatusSnapshot, candidateSessionIds: string[], mode: StatusSnapshotMode, ): boolean { if (candidateSessionIds.length === 0) return false let changed = false store.setState((state: DirectoryStore) => { const current = state.session_status ?? {} let next: Record | undefined const draft = () => (next ??= { ...current }) for (const sessionId of candidateSessionIds) { const incoming = toSessionStatus(snapshot[sessionId]) if (incoming && incoming.type !== "idle") { // Confirm or raise active status (catches a busy event the SSE missed). if (!haveEquivalentSyncSnapshots(current[sessionId], incoming)) { draft()[sessionId] = incoming changed = true } continue } // Snapshot reports this candidate idle (absent, or explicit idle). // Monotonic never lowers; authoritative trusts the snapshot as truth. if (mode === "monotonic") continue const existing = current[sessionId] if (existing && existing.type !== "idle") { draft()[sessionId] = { type: "idle" } changed = true } } return next ? { session_status: next } : state }) return changed } async function resyncDirectorySessionStatuses( directory: string, store: StoreApi, candidateSessionIds: string[], mode: StatusSnapshotMode, ): Promise { const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory) // null = fetch failed; preserve existing state. {} or populated = a snapshot // of active sessions — reconciled per `mode` (absence ≠ idle under monotonic). if (nextStatuses === null) return null applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode) return nextStatuses } // After a monotonic poll, decide whether to escalate to a full authoritative // resync: the store believes the session is active but the snapshot reports it // idle/absent — a suspected missed idle that the monotonic poll deliberately // won't lower on its own. The authoritative resync is the recovery path. export function needsSnapshotAfterStatusPoll( state: DirectoryStore, sessionId: string, snapshotEntry: DirectorySessionStatusSnapshot[string] | undefined, ): boolean { const incoming = toSessionStatus(snapshotEntry) if (incoming && incoming.type !== "idle") return false const currentStatus = state.session_status?.[sessionId] return Boolean(currentStatus && currentStatus.type !== "idle") } // Decide whether the event stream is genuinely stale and warrants a full // resync. Uses stream activity that includes heartbeats, so a quiet-but- // connected session (only receiving heartbeats) is NOT considered stale. // A stale signal means no events at all — including no heartbeats — for the // configured threshold, which is strong evidence the connection is dead. // Returns false when lastStreamActivityAt is 0 (no events received yet), // so the watchdog does not fire before the stream has delivered its first // heartbeat. export function shouldTriggerStaleResync( lastStreamActivityAt: number, lastFullResyncAt: number, now: number, staleThresholdMs: number = ACTIVE_SESSION_STALE_EVENT_MS, resyncCooldownMs: number = ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS, ): boolean { if (lastStreamActivityAt <= 0) return false if (now - lastStreamActivityAt < staleThresholdMs) return false if (now - lastFullResyncAt < resyncCooldownMs) return false return true } type EventRoutingIndex = { sessionDirectoryById: Map messageSessionById: Map sessionMessageIdsById: Map> } const SHOULD_DISPATCH_VSCODE_NOTIFICATIONS = isVSCodeRuntime() const dispatchVSCodeRuntimeNotificationEvent = (directory: string, payload: Event) => { if (!SHOULD_DISPATCH_VSCODE_NOTIFICATIONS || typeof window === "undefined") return window.dispatchEvent(new CustomEvent("openchamber:vscode-notification-event", { detail: { directory, payload }, })) } const createEventRoutingIndex = (): EventRoutingIndex => ({ sessionDirectoryById: new Map(), messageSessionById: new Map(), sessionMessageIdsById: new Map(), }) const normalizeEventDirectory = (rawDirectory: string): string => { if (!rawDirectory || rawDirectory === "global") { return rawDirectory } const normalized = rawDirectory.replace(/\\/g, "/").replace(/^([a-z]):/, (_, l: string) => l.toUpperCase() + ":") // Strip trailing slashes to match child store keys (normalizeDirectoryPath in useDirectoryStore) return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized } const getSessionIdFromPayload = (event: Event): string | null => { const properties = (event as { properties?: unknown }).properties if (!properties || typeof properties !== "object") { return null } const props = properties as Record if (event.type === "message.updated") { const info = props.info if (!info || typeof info !== "object") { return null } const sessionID = (info as { sessionID?: unknown }).sessionID return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : null } if ( event.type === "message.removed" || event.type === "session.status" || event.type === "todo.updated" || event.type === "permission.asked" || event.type === "permission.replied" || event.type === "question.asked" || event.type === "question.replied" || event.type === "question.rejected" || event.type === "session.deleted" ) { const sessionID = props.sessionID return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : null } if (event.type === "message.part.updated") { const sessionID = props.sessionID if (typeof sessionID === "string" && sessionID.length > 0) { return sessionID } const part = props.part if (!part || typeof part !== "object") { return null } const partSessionID = (part as { sessionID?: unknown }).sessionID return typeof partSessionID === "string" && partSessionID.length > 0 ? partSessionID : null } if (event.type === "message.part.delta" || event.type === "message.part.removed") { const sessionID = props.sessionID return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : null } if (event.type === "session.created" || event.type === "session.updated") { const info = props.info if (!info || typeof info !== "object") { return null } const id = (info as { id?: unknown }).id return typeof id === "string" && id.length > 0 ? id : null } return null } const getSessionInfoFromPayload = (event: Event): Session | null => { if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") { return null } const properties = (event as { properties?: unknown }).properties if (!properties || typeof properties !== "object") { return null } const info = (properties as { info?: unknown }).info if (!info || typeof info !== "object") { return null } const session = info as Partial if (typeof session.id !== "string" || !session.time) { return null } return stripSessionDiffSnapshots(session as Session) } const applySessionEventToGlobalSessions = (payload: Event) => { if (payload.type === "session.created" || payload.type === "session.updated") { const session = getSessionInfoFromPayload(payload) if (session) { useGlobalSessionsStore.getState().upsertSession(session) } return } if (payload.type === "session.deleted") { const sessionID = getSessionIdFromPayload(payload) ?? getSessionInfoFromPayload(payload)?.id if (sessionID) { useGlobalSessionsStore.getState().removeSessions([sessionID]) } } } const getMessageIdFromPayload = (event: Event): string | null => { const properties = (event as { properties?: unknown }).properties if (!properties || typeof properties !== "object") { return null } const props = properties as Record if (event.type === "message.updated") { const info = props.info if (!info || typeof info !== "object") { return null } const id = (info as { id?: unknown }).id return typeof id === "string" && id.length > 0 ? id : null } if (event.type === "message.removed" || event.type === "message.part.delta" || event.type === "message.part.removed") { const messageID = props.messageID return typeof messageID === "string" && messageID.length > 0 ? messageID : null } if (event.type === "message.part.updated") { const part = props.part if (!part || typeof part !== "object") { return null } const partMessageID = (part as { messageID?: unknown }).messageID return typeof partMessageID === "string" && partMessageID.length > 0 ? partMessageID : null } return null } const setIndexedSessionDirectory = (routingIndex: EventRoutingIndex, sessionID: string, directory: string) => { if (!sessionID || !directory || directory === "global") { return } routingIndex.sessionDirectoryById.set(sessionID, directory) } const setIndexedSessionMessages = ( routingIndex: EventRoutingIndex, sessionID: string, directory: string, messages: Message[], ) => { if (!sessionID) { return } setIndexedSessionDirectory(routingIndex, sessionID, directory) const previous = routingIndex.sessionMessageIdsById.get(sessionID) const next = new Set() for (const message of messages) { if (!message?.id) { continue } next.add(message.id) routingIndex.messageSessionById.set(message.id, sessionID) } if (previous) { for (const previousMessageID of previous) { if (!next.has(previousMessageID)) { routingIndex.messageSessionById.delete(previousMessageID) } } } routingIndex.sessionMessageIdsById.set(sessionID, next) } const setIndexedMessage = ( routingIndex: EventRoutingIndex, sessionID: string, messageID: string, directory: string, ) => { if (!sessionID || !messageID) { return } setIndexedSessionDirectory(routingIndex, sessionID, directory) routingIndex.messageSessionById.set(messageID, sessionID) const existing = routingIndex.sessionMessageIdsById.get(sessionID) if (existing) { existing.add(messageID) } else { routingIndex.sessionMessageIdsById.set(sessionID, new Set([messageID])) } } const removeIndexedMessage = ( routingIndex: EventRoutingIndex, messageID: string, sessionHint?: string | null, ) => { if (!messageID) { return } const sessionID = sessionHint ?? routingIndex.messageSessionById.get(messageID) routingIndex.messageSessionById.delete(messageID) if (!sessionID) { return } const messageIds = routingIndex.sessionMessageIdsById.get(sessionID) if (!messageIds) { return } messageIds.delete(messageID) if (messageIds.size === 0) { routingIndex.sessionMessageIdsById.delete(sessionID) } } const removeIndexedSession = (routingIndex: EventRoutingIndex, sessionID: string) => { if (!sessionID) { return } routingIndex.sessionDirectoryById.delete(sessionID) const messageIds = routingIndex.sessionMessageIdsById.get(sessionID) if (messageIds) { for (const messageID of messageIds) { routingIndex.messageSessionById.delete(messageID) } } routingIndex.sessionMessageIdsById.delete(sessionID) } const ingestDirectoryStateIntoRoutingIndex = ( routingIndex: EventRoutingIndex, directory: string, state: State, ) => { const nextSessionIds = new Set() for (const session of state.session) { if (!session?.id) { continue } nextSessionIds.add(session.id) setIndexedSessionDirectory(routingIndex, session.id, directory) } for (const sessionID of Object.keys(state.message)) { nextSessionIds.add(sessionID) setIndexedSessionDirectory(routingIndex, sessionID, directory) setIndexedSessionMessages(routingIndex, sessionID, directory, state.message[sessionID] ?? EMPTY_MESSAGES) } for (const [indexedSessionID, indexedDirectory] of routingIndex.sessionDirectoryById) { if (indexedDirectory !== directory) { continue } if (!nextSessionIds.has(indexedSessionID)) { removeIndexedSession(routingIndex, indexedSessionID) } } } const findSessionInChildStores = ( sessionID: string, childStores: ChildStoreManager, routingIndex: EventRoutingIndex, ): string | null => { for (const [dir, store] of childStores.children) { const state = store.getState() if ( state.session.some((s) => s.id === sessionID) || Object.prototype.hasOwnProperty.call(state.message, sessionID) || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID) ) { // Self-heal: populate the routing index so future events resolve instantly setIndexedSessionDirectory(routingIndex, sessionID, dir) return dir } } return null } const childStoreHasSessionState = ( childStores: ChildStoreManager, directory: string, sessionID: string, ): boolean => { const store = childStores.getChild(directory) if (!store) return false const state = store.getState() return state.session.some((session) => session.id === sessionID) || Object.prototype.hasOwnProperty.call(state.message, sessionID) || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID) } const childStoreHasMessagePartState = ( childStores: ChildStoreManager, directory: string, messageID: string, ): boolean => { const store = childStores.getChild(directory) if (!store) return false return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) } const resolveDirectoryFromRoutingIndex = ( routingIndex: EventRoutingIndex, rawDirectory: string, payload: Event, childStores: ChildStoreManager, ): string => { const normalizedDirectory = normalizeEventDirectory(rawDirectory) const sessionID = getSessionIdFromPayload(payload) if (sessionID) { if (normalizedDirectory && normalizedDirectory !== "global" && childStoreHasSessionState(childStores, normalizedDirectory, sessionID)) { setIndexedSessionDirectory(routingIndex, sessionID, normalizedDirectory) return normalizedDirectory } const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionID) if (indexedDirectory && childStores.getChild(indexedDirectory)) { return indexedDirectory } // Routing index miss — scan child stores for this session. // Covers optimistic sessions not yet indexed and events with wrong/empty directory. const found = findSessionInChildStores(sessionID, childStores, routingIndex) if (found) { return found } } const messageID = getMessageIdFromPayload(payload) if (messageID) { if (normalizedDirectory && normalizedDirectory !== "global" && childStoreHasMessagePartState(childStores, normalizedDirectory, messageID)) { return normalizedDirectory } const sessionFromMessage = routingIndex.messageSessionById.get(messageID) if (sessionFromMessage) { const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionFromMessage) if (indexedDirectory && childStores.getChild(indexedDirectory)) { return indexedDirectory } } // Scan child stores for a store that has parts for this message for (const [dir, store] of childStores.children) { if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { return dir } } } // Single-store fallback: if there's only one directory, use it if ( (sessionID || messageID) && (!normalizedDirectory || normalizedDirectory === "global") && childStores.children.size === 1 ) { const onlyDirectory = childStores.children.keys().next().value if (typeof onlyDirectory === "string" && onlyDirectory.length > 0) { return onlyDirectory } } return normalizedDirectory } const updateRoutingIndexFromEvent = ( routingIndex: EventRoutingIndex, directory: string, payload: Event, ) => { if (!directory || directory === "global") { return } const sessionID = getSessionIdFromPayload(payload) if (sessionID) { setIndexedSessionDirectory(routingIndex, sessionID, directory) } switch (payload.type) { case "session.created": case "session.updated": { const info = (payload.properties as { info?: Session }).info if (info?.id) { setIndexedSessionDirectory(routingIndex, info.id, directory) } return } case "session.deleted": { const deletedSessionID = (payload.properties as { sessionID?: string }).sessionID if (deletedSessionID) { removeIndexedSession(routingIndex, deletedSessionID) } return } case "message.updated": { const info = (payload.properties as { info?: Message }).info if (info?.id && info.sessionID) { setIndexedMessage(routingIndex, info.sessionID, info.id, directory) } return } case "message.removed": { const props = payload.properties as { sessionID?: string; messageID?: string } if (props.messageID) { removeIndexedMessage(routingIndex, props.messageID, props.sessionID) } return } case "message.part.updated": { const props = payload.properties as { sessionID?: string; part?: Part } const part = props.part as (Part & { sessionID?: string; messageID?: string }) | undefined const sessionID = part?.sessionID ?? props.sessionID const messageID = part?.messageID if (messageID && sessionID) { setIndexedMessage(routingIndex, sessionID, messageID, directory) } return } default: return } } /** * Re-fetch pending questions and permissions for a directory and merge them * into the directory's child store, preserving any in-flight SSE updates that * arrived while the request was pending. Used by reconnect/materialization * recovery paths only; normal session switches rely on primary SSE reducer * state for `question.asked` / `permission.asked` events. When * `candidateSessionIds` is omitted, every session known to the directory store * is treated as a candidate. */ export async function resyncBlockingRequestsForDirectory( directory: string, store: StoreApi, candidateSessionIds?: string[], ) { const before = store.getState() const knownSessionIds = new Set([ ...before.session.map((session) => session.id), ...Object.keys(before.message ?? {}), ...Object.keys(before.session_status ?? {}), ...Object.keys(before.question ?? {}), ...Object.keys(before.permission ?? {}), ]) const candidates = candidateSessionIds ?? Array.from(knownSessionIds) if (candidates.length === 0) return // Re-fetch pending questions that may have been asked during an SSE gap, // reconnect window, or directory materialization gap. try { const beforeSignatures = new Map( candidates.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]), ) const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] }) const grouped: Record = {} for (const q of pendingQuestions) { if (!q?.id || !q.sessionID) continue if (!knownSessionIds.has(q.sessionID)) continue const list = grouped[q.sessionID] if (list) list.push(q) else grouped[q.sessionID] = [q] } for (const sessionId of Object.keys(grouped)) { grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) } for (const [sessionId, questions] of Object.entries(grouped)) { const knownIds = new Set((before.question[sessionId] ?? []).map((item) => item.id)) const isViewed = isViewedInCurrentSession(directory, sessionId) if (isViewed) continue for (const question of questions) { if (knownIds.has(question.id)) continue const toastKey = getQuestionToastKey(sessionId, question.id) if (!toastKey || pendingQuestionToastIds.has(toastKey)) continue pendingQuestionToastIds.add(toastKey) const firstQuestion = question.questions?.[0] const title = firstQuestion?.header?.trim() || "Input needed" const description = firstQuestion?.question?.trim() || "Agent is waiting for your response" toast.info(title, { id: `question-${toastKey}`, description, action: { label: "Open session", onClick: () => openSessionFromToast(sessionId, directory), }, }) } } store.setState((state: DirectoryStore) => { const merged = { ...state.question } for (const [sessionId, questions] of Object.entries(grouped)) { merged[sessionId] = questions } for (const sessionId of candidates) { if (grouped[sessionId]) continue const beforeSignature = beforeSignatures.get(sessionId) ?? "" const currentSignature = requestSignature(state.question[sessionId]) if (currentSignature !== beforeSignature) continue delete merged[sessionId] } return { question: merged } }) } catch { // Non-fatal: question resync best-effort } // Re-fetch pending permissions — same rationale as questions. try { const beforeSignatures = new Map( candidates.map((sessionId) => [sessionId, requestSignature(before.permission[sessionId])]), ) const pendingPermissions = await opencodeClient.listPendingPermissions({ directories: [directory] }) const grouped: Record = {} for (const permission of pendingPermissions) { if (!permission?.id || !permission.sessionID) continue if (!knownSessionIds.has(permission.sessionID)) continue const list = grouped[permission.sessionID] if (list) list.push(permission) else grouped[permission.sessionID] = [permission] } for (const sessionId of Object.keys(grouped)) { grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) } const permissionStore = usePermissionStore.getState() const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) if (autoAcceptingSessionIds.length > 0) { const acceptedIdsBySession = new Map>() await Promise.all(autoAcceptingSessionIds.flatMap((sessionId) => (grouped[sessionId] ?? []).map(async (permission) => { try { await sessionActions.respondToPermission(permission.sessionID, permission.id, "once") const accepted = acceptedIdsBySession.get(sessionId) ?? new Set() accepted.add(permission.id) acceptedIdsBySession.set(sessionId, accepted) } catch { // Keep failed auto-accept permissions in UI state so the user can act. } }), )) for (const sessionId of autoAcceptingSessionIds) { const acceptedIds = acceptedIdsBySession.get(sessionId) if (!acceptedIds) continue const remaining = (grouped[sessionId] ?? []).filter((permission) => !acceptedIds.has(permission.id)) if (remaining.length > 0) grouped[sessionId] = remaining else delete grouped[sessionId] } } for (const [sessionId, permissions] of Object.entries(grouped)) { const knownIds = new Set((before.permission[sessionId] ?? []).map((item) => item.id)) const isViewed = isViewedInCurrentSession(directory, sessionId) if (isViewed) continue for (const permission of permissions) { if (knownIds.has(permission.id)) continue const toastKey = getPermissionToastKey(sessionId, permission.id) if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue pendingPermissionToastIds.add(toastKey) const description = typeof permission.permission === "string" && permission.permission.trim().length > 0 ? permission.permission : "Agent needs your approval" toast.info("Permission needed", { id: `permission-${toastKey}`, description, action: { label: "Open session", onClick: () => openSessionFromToast(sessionId, directory), }, }) } } store.setState((state: DirectoryStore) => { const merged = { ...state.permission } for (const [sessionId, permissions] of Object.entries(grouped)) { merged[sessionId] = permissions } for (const sessionId of candidates) { if (grouped[sessionId]) continue const beforeSignature = beforeSignatures.get(sessionId) ?? "" const currentSignature = requestSignature(state.permission[sessionId]) if (currentSignature !== beforeSignature) continue delete merged[sessionId] } return { permission: merged } }) } catch { // Non-fatal: permission resync best-effort } } async function resyncDirectoryAfterReconnect( directory: string, store: StoreApi, routingIndex: EventRoutingIndex, ) { const current = store.getState() const candidateSessionIds = getActiveSessionCandidateIds(directory, current) if (candidateSessionIds.length === 0) return await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "authoritative") const scopedClient = opencodeClient.getScopedSdkClient(directory) await Promise.all(candidateSessionIds.map(async (sessionId) => { const [sessionResponse, messageResponse] = await Promise.all([ retry(async () => { const response = await scopedClient.session.get({ sessionID: sessionId }) assertSdkSuccess(response, "session.get") return response }).catch(() => null), retry(async () => { const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }) assertSdkSuccess(response, "session.messages") return response }).catch(() => null), ]) const session = sessionResponse?.data const records = messageResponse?.data if (!session || !records) return const cursor = messageResponse.response?.headers?.get?.("x-next-cursor") ?? undefined setSessionPrefetch({ directory, sessionID: sessionId, limit: records.length, cursor, complete: !cursor, }) const nextSession = stripSessionDiffSnapshots(session) const nextMessages = records .filter((record) => !!record?.info?.id) .map((record) => stripMessageDiffSnapshots(record.info)) .sort((a, b) => cmp(a.id, b.id)) store.setState((state: DirectoryStore) => { const sessionIndex = state.session.findIndex((item) => item.id === nextSession.id) let sessions = state.session let sessionChanged = false let sessionTotal = state.sessionTotal if (sessionIndex >= 0) { if (!haveEquivalentSyncSnapshots(sessions[sessionIndex], nextSession)) { sessions = [...state.session] sessions[sessionIndex] = nextSession sessionChanged = true } } else { sessions = [...state.session] sessions.push(nextSession) sessions.sort((a, b) => cmp(a.id, b.id)) if (!nextSession.parentID) sessionTotal += 1 sessionChanged = true } const materialized = materializeSessionSnapshots( state, sessionId, records.map((record) => ({ info: stripMessageDiffSnapshots(record.info), parts: record.parts ?? [], })), { skipPartTypes: RECONNECT_SKIP_PARTS }, ) const messagesChanged = materialized.messagesChanged const partsChanged = materialized.partsChanged if (!sessionChanged && !messagesChanged && !partsChanged) { return state } return { ...(sessionChanged ? { session: sessions, sessionTotal } : {}), ...(messagesChanged ? { message: materialized.message } : {}), ...(partsChanged ? { part: materialized.part } : {}), } }) setIndexedSessionDirectory(routingIndex, nextSession.id, directory) setIndexedSessionMessages(routingIndex, sessionId, directory, nextMessages) })) await resyncBlockingRequestsForDirectory(directory, store, candidateSessionIds) ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) } function handleEvent( rawDirectory: string, payload: Event, childStores: ChildStoreManager, routingIndex: EventRoutingIndex, ) { const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores) if (handleUiNotificationEvent(payload, directory)) { return } applySessionEventToGlobalSessions(payload) // Keep the cross-project status map current for ALL directories (mirrors the // global-session handling above). Child stores remain the primary source for // synced directories; this map covers sessions a child store doesn't list // (unopened directories, or list/status races for just-created sessions). applyGlobalSessionStatusEvent(directory, payload) // Global events if (directory === "global" || !directory) { const recent = isRecentBoot() const result = reduceGlobalEvent(payload) if (!result) return if (result.type === "refresh") { // Suppress refresh during/shortly after bootstrap if (!recent) { useGlobalSyncStore.setState({ reload: "pending" }) } } else if (result.type === "project") { const current = useGlobalSyncStore.getState() useGlobalSyncStore.setState({ projects: applyGlobalProject(current, result.project).projects, }) } // On server.connected / global.disposed, re-bootstrap all directories // but only if not during recent boot if (payload.type === "server.connected" || payload.type === "global.disposed") { if (!recent) { for (const dir of childStores.children.keys()) { const store = childStores.getChild(dir) if (store && store.getState().status !== "loading") { // Mark as loading to trigger re-bootstrap store.setState({ status: "loading" as const }) childStores.ensureChild(dir) } } } } return } // Directory events let store = childStores.getChild(directory) let resolvedDirectory = directory if (!store) { // Store not found for this directory — attempt recovery by scanning // child stores for the session. This handles directory mismatches // (trailing slashes, case differences, events with wrong directory). const sessionID = getSessionIdFromPayload(payload) if (sessionID) { const fallbackDir = findSessionInChildStores(sessionID, childStores, routingIndex) if (fallbackDir) { store = childStores.getChild(fallbackDir) resolvedDirectory = fallbackDir } } } if (!store) { // Try as global event for unknown directories const result = reduceGlobalEvent(payload) if (result?.type === "refresh") { useGlobalSyncStore.setState({ reload: "pending" }) } else if (result?.type === "project") { const current = useGlobalSyncStore.getState() useGlobalSyncStore.setState({ projects: applyGlobalProject(current, result.project).projects, }) } return } childStores.mark(resolvedDirectory) if (payload.type === "permission.asked") { const permission = payload.properties as PermissionRequest const permissionStore = usePermissionStore.getState() if (permissionStore.isSessionAutoAccepting(permission.sessionID)) { updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) return } const toastKey = getPermissionToastKey(permission.sessionID, permission.id) const isViewed = isViewedInCurrentSession(resolvedDirectory, permission.sessionID) if (!isViewed && toastKey && !pendingPermissionToastIds.has(toastKey)) { pendingPermissionToastIds.add(toastKey) const description = typeof permission.permission === "string" && permission.permission.trim().length > 0 ? permission.permission : "Agent needs your approval" toast.info("Permission needed", { id: `permission-${toastKey}`, description, action: { label: "Open session", onClick: () => openSessionFromToast(permission.sessionID, resolvedDirectory), }, }) } } if (payload.type === "permission.replied") { const props = payload.properties as { sessionID?: string; requestID?: string } const toastKey = getPermissionToastKey(props.sessionID, props.requestID) if (toastKey) { pendingPermissionToastIds.delete(toastKey) toast.dismiss(`permission-${toastKey}`) } } if (payload.type === "question.asked") { const question = payload.properties as QuestionRequest const sessionID = question.sessionID const toastKey = getQuestionToastKey(sessionID, question.id) const isViewed = isViewedInCurrentSession(resolvedDirectory, sessionID) if (!isViewed && toastKey && !pendingQuestionToastIds.has(toastKey)) { pendingQuestionToastIds.add(toastKey) const firstQuestion = question.questions?.[0] const title = firstQuestion?.header?.trim() || "Input needed" const description = firstQuestion?.question?.trim() || "Agent is waiting for your response" toast.info(title, { id: `question-${toastKey}`, description, action: { label: "Open session", onClick: () => openSessionFromToast(sessionID, resolvedDirectory), }, }) } } if (payload.type === "question.replied" || payload.type === "question.rejected") { const props = payload.properties as { sessionID?: string; requestID?: string } const toastKey = getQuestionToastKey(props.sessionID, props.requestID) if (toastKey) { pendingQuestionToastIds.delete(toastKey) toast.dismiss(`question-${toastKey}`) } } // Notification dispatch for session turn-complete and error events. // These are NOT handled by the event reducer — only the notification store. if (payload.type === "session.idle" || payload.type === "session.error") { const props = payload.properties as { sessionID?: string; error?: { message?: string; code?: string } } const sessionID = props.sessionID // Skip subtask sessions — only top-level sessions generate notifications const storeState = store.getState() const session = storeState.session.find((s) => s.id === sessionID) if (session && (session as { parentID?: string }).parentID) { // subtask — skip notification } else if (sessionID) { appendNotification({ directory: resolvedDirectory, session: sessionID, time: Date.now(), viewed: isViewedInCurrentSession(resolvedDirectory, sessionID), ...(payload.type === "session.error" ? { type: "error" as const, error: props.error } : { type: "turn-complete" as const }), }) } } // Sync-layer parent resync: when a child session goes idle, recover // the parent session snapshot. This ensures the // parent's task tool part reflects the child's completion even when // no ToolPart component is mounted. if (payload.type === "session.idle") { const idleSessionId = getSessionIdFromPayload(payload) if (idleSessionId && resolvedDirectory && resolvedDirectory !== "global") { const sessionState = store.getState() const idleSession = sessionState.session.find((s) => s.id === idleSessionId) const parentID = idleSession ? (idleSession as Session & { parentID?: string | null }).parentID : null if (parentID) { enqueueSessionMaterialization(resolvedDirectory, parentID, childStores) } } } // Read live state, create targeted draft cloning ONLY fields that event // type will mutate. This preserves reference identity for untouched slices // so Zustand selectors skip re-renders for unrelated subscribers. const current = store.getState() const draft: State = { ...current } switch (payload.type) { case "session.created": case "session.updated": case "session.deleted": draft.session = [...current.session] draft.permission = { ...current.permission } draft.todo = { ...current.todo } draft.part = { ...current.part } break case "session.diff": draft.session_diff = { ...current.session_diff } break case "session.status": case "session.idle": case "session.error": draft.session_status = { ...(current.session_status ?? {}) } break case "todo.updated": draft.todo = { ...current.todo } break case "message.updated": draft.message = { ...current.message } break case "message.removed": draft.message = { ...current.message } draft.part = { ...current.part } break case "message.part.updated": case "message.part.removed": case "message.part.delta": draft.part = { ...current.part } break case "vcs.branch.updated": break case "permission.asked": case "permission.replied": draft.permission = { ...current.permission } break case "question.asked": case "question.replied": case "question.rejected": draft.question = { ...current.question } break case "lsp.updated": draft.lsp = [...current.lsp] break default: break } const reducerResult = applyDirectoryEvent(draft, payload, { onSetSessionTodo: (sessionID, todos) => { useTodosPersistStore.getState().setSessionTodos(sessionID, todos) }, }) const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization if (reducerChanged) { store.setState(draft) const sessionID = getSessionIdFromPayload(payload) ?? undefined const messageID = getMessageIdFromPayload(payload) ?? undefined syncDebug.dispatch.eventApplied(payload.type, sessionID, messageID) // Snapshot materialization on message.updated: if the message was inserted or // replaced but draft.part[messageID] is empty, the parts were lost or // never arrived. Recover the session so the UI doesn't render a blank bubble. if (sessionID && messageID && payload.type === "message.updated") { const after = store.getState() const info = (payload.properties as { info: Message }).info if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) { enqueueSessionMaterialization(resolvedDirectory, sessionID, childStores) } } } else { const sessionID = getSessionIdFromPayload(payload) ?? undefined const messageID = getMessageIdFromPayload(payload) ?? undefined syncDebug.dispatch.eventNoChange(payload.type, sessionID, messageID) } // Snapshot materialization is driven by typed reducer outcomes, not by // inferring meaning from a generic false/no-change result. if (materializationResult) { const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined if (materializationSessionID) { enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) } } updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) } // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => { if (typeof window === "undefined") return window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload })) } export function SyncProvider(props: { sdk: OpencodeClient directory: string children: React.ReactNode }) { const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport) const childStoresRef = useRef(null) if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager() const childStores = childStoresRef.current const routingIndexRef = useRef(null) if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex() const routingIndex = routingIndexRef.current const lastStreamActivityAtRef = useRef(0) const lastStatusPollAtByDirectoryRef = useRef(new Map()) const lastFullResyncAtByDirectoryRef = useRef(new Map()) const lastChildDiscoveryAtByDirectoryRef = useRef(new Map()) const resyncingDirectoriesRef = useRef(new Set()) const statusPollingDirectoriesRef = useRef(new Set()) const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) const system = useMemo( () => ({ childStores, sdk: props.sdk, directory: props.directory, }), [childStores, props.sdk, props.directory], ) const triggerDirectoryResync = useCallback((directory: string) => { const store = childStores.children.get(directory) if (!store) return const resyncing = resyncingDirectoriesRef.current if (resyncing.has(directory)) return lastFullResyncAtByDirectoryRef.current.set(directory, Date.now()) resyncing.add(directory) void resyncDirectoryAfterReconnect(directory, store, routingIndex) .catch(() => { // Transient failure — the watchdog, next SSE event, or reconnect will catch up. }) .finally(() => { resyncing.delete(directory) }) }, [childStores, routingIndex]) // Configure child store manager useEffect(() => { const bootingDirs = new Set() childStores.configure({ onBootstrap: (directory) => { if (bootingDirs.has(directory)) return bootingDirs.add(directory) const store = childStores.getChild(directory) if (!store) return const runBootstrap = async (attempt: number) => { const globalState = useGlobalSyncStore.getState() await bootstrapDirectory({ directory, sdk: props.sdk, getState: () => store.getState(), set: (patch) => { store.setState(patch) if (patch.session || patch.message) { ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) } }, global: { config: globalState.config, projects: globalState.projects, }, loadSessions: (dir) => retry(async () => { const rootSessions = (await listGlobalSessionPages(props.sdk, { directory: dir, archived: false, roots: true, pageSize: 500, })) .filter((s) => !!s?.id) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // Also load child sessions (sub-agent delegations) so they // appear in the sidebar immediately instead of relying on // the async global session store. let allSessions: typeof rootSessions = [] try { const allResult = await props.sdk.session.list({ directory: dir, limit: 200, }) const allError = (allResult as { error?: unknown }).error if (!allError) { allSessions = ((allResult as { data?: unknown }).data ?? []) as typeof rootSessions } } catch { // Child load is best-effort; fall back to roots only } // Merge: keep root sessions from the first query (for accurate // sessionTotal), plus any child sessions from the broader query. const rootIds = new Set(rootSessions.map((s: { id: string }) => s.id)) const childSessions = allSessions.filter((s: { id: string; parentID?: string | null }) => s?.id && !rootIds.has(s.id) && s.parentID) const sessions = rootSessions.concat(childSessions) // Race guard: if the list came back empty but event pipeline // already populated the store, don't clobber. OpenCode can // answer HTTP with empty sessions while WS delivers session // events for the same data (disk warmup race on app launch). const currentSessions = store.getState().session if (sessions.length === 0 && currentSessions.length > 0) { console.warn( `[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`, ) return } store.setState({ session: sessions, sessionTotal: rootSessions.length, limit: Math.max(sessions.length, 50) }) ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) }), }) // VS Code-only race: the bridge can answer with an empty 200 (instead // of a retryable 503) while OpenCode is still warming up, which the two // retry layers inside loadSessions can't catch. Re-run a few times there. // // On web/desktop this retry is both redundant and harmful: loadSessions // already retries transient failures (listGlobalSessionPages throws on // 5xx and retries internally), so an empty result here is AUTHORITATIVE — // the directory genuinely has no sessions (e.g. a deleted worktree only // referenced by archived sessions). Re-running the full bootstrap 6×2s // per such directory is the startup log storm. if (isVSCodeRuntime()) { const state = store.getState() if (state.session.length === 0 && attempt < 5) { console.warn(`[bootstrap] sessions empty for ${directory} after attempt ${attempt + 1}; retrying in 2s`) await new Promise((r) => setTimeout(r, 2000)) store.setState({ status: "loading" as const }) await runBootstrap(attempt + 1) } else if (state.session.length === 0) { console.warn(`[bootstrap] sessions empty for ${directory} after ${attempt + 1} attempts; giving up`) } } } runBootstrap(0).finally(() => { bootingDirs.delete(directory) }) }, onDispose: (directory) => { bootingDirs.delete(directory) }, isBooting: (directory) => bootingDirs.has(directory), isLoadingSessions: () => false, }) }, [childStores, props.sdk, routingIndex]) // Bootstrap global state — set bootingRoot/bootedAt to suppress // redundant refresh events during startup useEffect(() => { const generation = ++globalBootstrapGeneration bootingRoot = true const globalActions = useGlobalSyncStore.getState().actions bootstrapGlobal(props.sdk, (patch) => { if (globalBootstrapGeneration === generation) { globalActions.set(patch) } }) .then(() => { if (globalBootstrapGeneration === generation) { bootedAt = Date.now() } }) .finally(() => { if (globalBootstrapGeneration === generation) { bootingRoot = false } }) return () => { if (globalBootstrapGeneration === generation) { bootingRoot = false } } }, [props.sdk]) // Event pipeline — created once per mount. No class, no start/stop. // Abort controller owned by the pipeline closure. Cleanup aborts + flushes. useEffect(() => { const pipeline = createEventPipeline({ sdk: props.sdk, transport: messageStreamTransport, routeDirectory: (directory, payload) => { return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores) }, onEvent: (directory, payload) => { // Track ALL stream activity (including heartbeats) as proof of // connection health. The watchdog stale check uses this to distinguish // a genuinely dead stream (no heartbeats for 20s) from a quiet-but- // connected session that is only receiving heartbeats. Excluding // heartbeats here caused issue #1656: the stale timer fired for any // quiet session, triggering redundant full resyncs every ~15s. lastStreamActivityAtRef.current = Date.now() dispatchVSCodeRuntimeNotificationEvent(directory, payload) if (payload.type === "installation.update-available") { const version = typeof (payload.properties as { version?: unknown })?.version === "string" ? (payload.properties as { version: string }).version : "" if (version) { dispatchOpenCodeUpdateAvailable({ version }) } } handleEvent(directory, payload, childStores, routingIndex) }, onReconnect: () => { useConfigStore.setState({ isConnected: true, hasEverConnected: true, connectionPhase: "connected", }) if (isRecentBoot()) { return } for (const dir of childStores.children.keys()) { triggerDirectoryResync(dir) } }, onDisconnect: (reason) => { const { hasEverConnected } = useConfigStore.getState() useConfigStore.setState({ isConnected: false, connectionPhase: hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: reason, }) }, onTransportSwitch: () => { // Transport changes are gap-prone in real networks. Treat them like a // reconnect and refresh active session snapshots from HTTP. useConfigStore.setState({ isConnected: true, hasEverConnected: true, connectionPhase: "connected", }) for (const dir of childStores.children.keys()) { triggerDirectoryResync(dir) } }, }) pipelineReconnectRef.current = pipeline.reconnect return () => { if (pipelineReconnectRef.current === pipeline.reconnect) { pipelineReconnectRef.current = null } pipeline.cleanup() } }, [props.sdk, childStores, routingIndex, messageStreamTransport, triggerDirectoryResync]) useEffect(() => { let stopped = false let running = false const discoverChildSessions = async ( directory: string, store: StoreApi, parentSessionIds: string[], ) => { if (parentSessionIds.length === 0) return try { const scopedClient = opencodeClient.getScopedSdkClient(directory) const result = await scopedClient.session.list({ directory, limit: 200 }) const allSessions = ((result as { data?: unknown }).data ?? []) as Session[] const state = store.getState() const existingIds = new Set(state.session.map((s) => s.id)) const parentIdSet = new Set(parentSessionIds) const newChildSessions: Session[] = [] for (const session of allSessions) { if ( session?.id && !existingIds.has(session.id) && (session as { parentID?: string | null }).parentID && parentIdSet.has((session as { parentID: string }).parentID) ) { newChildSessions.push(session) } } if (newChildSessions.length === 0) return // Collect unique parent IDs for materialization const parentIdsForMaterialization = new Set() for (const session of newChildSessions) { const pid = (session as { parentID?: string | null }).parentID if (pid) parentIdsForMaterialization.add(pid) } store.setState((state: DirectoryStore) => { const sessions = [...state.session, ...newChildSessions].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0 ) return { session: sessions, limit: Math.max(sessions.length, 50) } }) // Trigger parent session materialization so the task tool part // state (metadata, sessionId, output) is refreshed. for (const pid of parentIdsForMaterialization) { enqueueSessionMaterialization(directory, pid, childStores) } } catch { // Best-effort — next tick will retry. } } const pollDirectoryStatuses = async ( directory: string, store: StoreApi, candidateSessionIds: string[], ) => { const polling = statusPollingDirectoriesRef.current if (polling.has(directory)) return polling.add(directory) try { const before = store.getState() const statuses = await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic") if (!statuses) return const needsSnapshot = candidateSessionIds.some((sessionId) => ( needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId]) )) if (needsSnapshot) { triggerDirectoryResync(directory) } } finally { polling.delete(directory) } } const tick = () => { if (running || stopped) return running = true void Promise.resolve() .then(() => { if (stopped) return const now = Date.now() for (const [directory, store] of childStores.children.entries()) { const state = store.getState() const candidateSessionIds = getActiveSessionCandidateIds(directory, state) if (candidateSessionIds.length === 0) { lastStatusPollAtByDirectoryRef.current.delete(directory) lastFullResyncAtByDirectoryRef.current.delete(directory) continue } const lastStatusPollAt = lastStatusPollAtByDirectoryRef.current.get(directory) ?? 0 if (now - lastStatusPollAt >= ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS) { lastStatusPollAtByDirectoryRef.current.set(directory, now) void pollDirectoryStatuses(directory, store, candidateSessionIds).catch(() => undefined) } const lastFullResyncAt = lastFullResyncAtByDirectoryRef.current.get(directory) ?? 0 if (shouldTriggerStaleResync(lastStreamActivityAtRef.current, lastFullResyncAt, now)) { pipelineReconnectRef.current?.("active_stream_stale") triggerDirectoryResync(directory) } // Discover child sessions created by other OpenCode instances // that didn't broadcast a session.created event on this stream. const lastChildDiscoveryAt = lastChildDiscoveryAtByDirectoryRef.current.get(directory) ?? 0 if (now - lastChildDiscoveryAt >= CHILD_SESSION_DISCOVERY_INTERVAL_MS) { lastChildDiscoveryAtByDirectoryRef.current.set(directory, now) void discoverChildSessions(directory, store, candidateSessionIds) } } }) .finally(() => { running = false if (stopped) { statusPollingDirectoriesRef.current.clear() } }) } const interval = setInterval(tick, ACTIVE_SESSION_WATCHDOG_INTERVAL_MS) tick() return () => { stopped = true clearInterval(interval) } }, [childStores, triggerDirectoryResync]) // Ensure current directory's child store exists useEffect(() => { let seedExpiryTimer: ReturnType | undefined if (props.directory) { const store = childStores.ensureChild(props.directory) const statusSeed = getRuntimeLiveStatusSeed(getRuntimeKey(), props.directory) if (statusSeed) { store.setState((state: DirectoryStore) => ({ session_status: { ...state.session_status, [statusSeed.sessionId]: state.session_status[statusSeed.sessionId] ?? statusSeed.status, }, })) seedExpiryTimer = setTimeout(() => { store.setState((state: DirectoryStore) => { if (state.session_status[statusSeed.sessionId] !== statusSeed.status) { return state } return { session_status: { ...state.session_status, [statusSeed.sessionId]: { type: "idle" as const }, }, } }) }, LIVE_STATUS_TTL_MS) } ingestDirectoryStateIntoRoutingIndex(routingIndex, props.directory, store.getState()) } return () => { if (seedExpiryTimer) clearTimeout(seedExpiryTimer) } }, [props.directory, childStores, routingIndex]) // Set refs so non-React code (session-actions, session-ui-store) can access sync state useEffect(() => { setSyncRefs(props.sdk, childStores, props.directory, (sessionID, dir) => { setIndexedSessionDirectory(routingIndex, sessionID, dir) }) setActionRefs( props.sdk, childStores, () => opencodeClient.getDirectory() || props.directory, ) }, [props.sdk, props.directory, childStores, routingIndex]) // Subscribe to child store for streaming state derivation useEffect(() => { if (!props.directory) return const store = childStores.getChild(props.directory) if (!store) return updateStreamingState(store.getState()) const unsubscribe = store.subscribe((state) => { updateStreamingState(state) }) return unsubscribe }, [props.directory, childStores]) return {props.children} } // --------------------------------------------------------------------------- // Hooks // --------------------------------------------------------------------------- /** * Get the child store for a directory (defaults to current). * * Pass `{ bootstrap: false }` when you only need the store reference for an * on-demand `getState()` (not live subscription) and must NOT trigger a full * directory bootstrap. This avoids storms of pointless session-list fetches + * empty-retry loops for directories that are merely referenced by sidebar rows * (e.g. archived sessions on deleted worktrees). */ export function useDirectoryStore( directory?: string, options?: { bootstrap?: boolean }, ): StoreApi { const system = useSyncSystem() const dir = directory ?? system.directory return system.childStores.ensureChild(dir, options) } /** Select from the current directory's store */ export function useDirectorySync(selector: (state: State) => T, directory?: string): T { const store = useDirectoryStore(directory) return useStore(store, selector) } /** Get session messages for a specific session */ export function useSessionMessages(sessionID: string, directory?: string) { const store = useDirectoryStore(directory) const getSnapshot = useCallback(() => { if (!sessionID) return EMPTY_MESSAGES return store.getState().message[sessionID] ?? EMPTY_MESSAGES }, [sessionID, store]) const subscribe = useCallback((notify: () => void) => { if (!sessionID) return () => undefined return store.subscribe(notify) }, [sessionID, store]) return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } /** Check whether the message list for a session has been loaded into sync state. */ export function useSessionMessagesResolved(sessionID: string, directory?: string): boolean { return useDirectorySync( useCallback((state: State) => { if (!sessionID) return false return Object.prototype.hasOwnProperty.call(state.message, sessionID) }, [sessionID]), directory, ) } /** Get parts for a specific message */ export function useSessionParts(messageID: string, directory?: string) { return useDirectorySync( useCallback((state: State) => state.part[messageID] ?? EMPTY_PARTS, [messageID]), directory, ) } /** Get status for a specific session */ export function useSessionStatus(sessionID: string, directory?: string) { const store = useDirectoryStore(directory) const getSnapshot = useCallback(() => { if (!sessionID) return undefined return store.getState().session_status?.[sessionID] }, [sessionID, store]) const subscribe = useCallback((notify: () => void) => { if (!sessionID) return () => undefined return store.subscribe(notify) }, [sessionID, store]) return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } /** Get permissions for a specific session */ export function useSessionPermissions(sessionID: string, directory?: string) { const store = useDirectoryStore(directory) const getSnapshot = useCallback(() => { if (!sessionID) return EMPTY_PERMISSION_REQUESTS return store.getState().permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS }, [sessionID, store]) const subscribe = useCallback((notify: () => void) => { if (!sessionID) return () => undefined return store.subscribe(notify) }, [sessionID, store]) return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } /** Get questions for a specific session */ export function useSessionQuestions(sessionID: string, directory?: string) { return useDirectorySync( useCallback((state: State) => state.question[sessionID] ?? EMPTY_QUESTION_REQUESTS, [sessionID]), directory, ) } /** Get sessions list for a directory */ export function useSessions(directory?: string) { return useDirectorySync( useCallback((state: State) => state.session, []), directory, ) } const selectPermissionRequestsBySession = (state: State) => state.permission const selectQuestionRequestsBySession = (state: State) => state.question type ScopedBlockingRequestCache = { sessionID: string | null sessions: Session[] | null requestsBySession: Record | null result: T[] } function useScopedBlockingRequests( sessionID: string | null, directory: string | undefined, selectRequestsBySession: (state: State) => Record, empty: T[], ): T[] { const cacheRef = useRef>({ sessionID: null, sessions: null, requestsBySession: null, result: empty, }) return useDirectorySync( useCallback((state: State) => { const requestsBySession = selectRequestsBySession(state) const cache = cacheRef.current if ( cache.sessionID === sessionID && cache.sessions === state.session && cache.requestsBySession === requestsBySession ) { return cache.result } const next = collectScopedBlockingRequests(state.session, requestsBySession, sessionID, empty) const result = areRequestArraysReferentiallyEqual(cache.result, next) ? cache.result : next cacheRef.current = { sessionID, sessions: state.session, requestsBySession, result, } return result }, [empty, selectRequestsBySession, sessionID]), directory, ) } export function useScopedBlockingPermissions(sessionID: string | null, directory?: string): PermissionRequest[] { return useScopedBlockingRequests(sessionID, directory, selectPermissionRequestsBySession, EMPTY_PERMISSION_REQUESTS) } export function useScopedBlockingQuestions(sessionID: string | null, directory?: string): QuestionRequest[] { return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS) } export function useParentSession(sessionID: string | null, directory?: string): Session | null { return useDirectorySync( useCallback((state: State) => { if (!sessionID) return null const current = state.session.find((s) => s.id === sessionID) if (!current?.parentID) return null return state.session.find((s) => s.id === current.parentID) ?? getAllSyncSessions().find((s) => s.id === current.parentID) ?? null }, [sessionID]), directory, ) } /** Get one session by id for a directory */ export function useSession(sessionID?: string | null, directory?: string) { const { childStores } = useSyncSystem() const getSnapshot = useCallback(() => { if (directory) { return childStores.getChild(directory)?.getState().session.find((session) => session.id === sessionID) } return findLiveSession(getLiveStates(childStores), sessionID) }, [childStores, directory, sessionID]) const subscribe = useCallback((notify: () => void) => { if (directory) { return childStores.ensureChild(directory).subscribe(notify) } return childStores.subscribeAll(notify) }, [childStores, directory]) return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } /** Get one session directory by id for a directory */ export function useSessionDirectory(sessionID?: string | null, directory?: string): string | undefined { const session = useSession(sessionID, directory) return (session as (typeof session & { directory?: string | null }) | undefined)?.directory ?? undefined } /** Get the SDK client */ export function useSyncSDK() { return useSyncSystem().sdk } /** Get the current directory */ export function useSyncDirectory() { return useSyncSystem().directory } /** Get the child store manager (for advanced operations) */ export function useChildStoreManager() { return useSyncSystem().childStores } export type SessionTextMessage = { id: string role: string | null text: string } const getPartText = (part: Part): string => { if (part?.type !== "text") return "" const text = (part as { text?: unknown }).text return typeof text === "string" ? text : "" } const getConcatenatedTextFromParts = (parts: Part[]): string => { let text = "" for (const part of parts) { text += getPartText(part) } return text } type SessionMessageRecord = { info: Message; parts: Part[] } const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = [] type SessionMessageRecordsSnapshot = { sessionID: string sourceMessages: Message[] visibleMessages: Message[] revertMessageID?: string suspendPartUpdates: boolean suspendedPartUpdatesMessageID?: string list: SessionMessageRecord[] byId: Map } const SESSION_MESSAGE_RECORDS_CACHE_MAX = 40 const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4 const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30 const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4 const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30 const sessionMessageRecordsCache = new WeakMap, Map>() const getSessionMessageRecordsCacheKey = ( sessionID: string, suspendPartUpdates: boolean, suspendedPartUpdatesMessageID?: string, ): string => ( `${sessionID}\u0000${suspendPartUpdates ? 1 : 0}\u0000${suspendedPartUpdatesMessageID ?? ""}` ) const getSessionMessageRecordsCache = (store: StoreApi): Map => { let cache = sessionMessageRecordsCache.get(store) if (!cache) { cache = new Map() sessionMessageRecordsCache.set(store, cache) } return cache } const readCachedSessionMessageRecordsSnapshot = ( store: StoreApi, sessionID: string, suspendPartUpdates: boolean, suspendedPartUpdatesMessageID?: string, ): SessionMessageRecordsSnapshot | undefined => { const cache = sessionMessageRecordsCache.get(store) if (!cache) return undefined const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID) const cached = cache.get(key) if (!cached) return undefined cache.delete(key) cache.set(key, cached) return cached } const rememberSessionMessageRecordsSnapshot = ( store: StoreApi, snapshot: SessionMessageRecordsSnapshot, ): void => { if (!snapshot.sessionID) return const cache = getSessionMessageRecordsCache(store) const key = getSessionMessageRecordsCacheKey( snapshot.sessionID, snapshot.suspendPartUpdates, snapshot.suspendedPartUpdatesMessageID, ) const constrainedMaxMessages = isVSCodeRuntime() ? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES : isMobileSurfaceRuntime() ? MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES : null if (constrainedMaxMessages !== null && snapshot.list.length > constrainedMaxMessages) { cache.delete(key) return } cache.delete(key) cache.set(key, snapshot) const max = isVSCodeRuntime() ? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX : isMobileSurfaceRuntime() ? MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX : SESSION_MESSAGE_RECORDS_CACHE_MAX while (cache.size > max) { const oldest = cache.keys().next().value if (typeof oldest !== "string") break cache.delete(oldest) } } export function dropCachedSessionMessageRecordsSnapshots( store: StoreApi, sessionIDs: Iterable, ): void { const cache = sessionMessageRecordsCache.get(store) if (!cache) return for (const sessionID of sessionIDs) { if (!sessionID) continue const prefix = `${sessionID}\u0000` for (const key of [...cache.keys()]) { if (key.startsWith(prefix)) { cache.delete(key) } } } } const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => { for (const record of snapshot.list) { if (snapshot.suspendPartUpdates) { const suspendedID = snapshot.suspendedPartUpdatesMessageID if (!suspendedID || record.info.id === suspendedID) { continue } } if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) { return false } } return true } const getReusableSessionMessageRecordsSnapshot = ( store: StoreApi, state: State, sessionID: string, suspendPartUpdates: boolean, suspendedPartUpdatesMessageID?: string, ): SessionMessageRecordsSnapshot | undefined => { const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID) if (!cached) return undefined const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES const session = state.session.find((candidate) => candidate.id === sessionID) const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID if ( cached.sourceMessages === sourceMessages && cached.revertMessageID === revertMessageID && cached.suspendPartUpdates === suspendPartUpdates && cached.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID && snapshotPartsMatchState(cached, state) ) { return cached } return undefined } function getVisibleMessagesForSession(state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot): { sourceMessages: Message[] visibleMessages: Message[] revertMessageID?: string } { const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES const session = state.session.find((candidate) => candidate.id === sessionID) const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID if ( previous && previous.sourceMessages === sourceMessages && previous.revertMessageID === revertMessageID ) { return { sourceMessages, visibleMessages: previous.visibleMessages, revertMessageID, } } return { sourceMessages, visibleMessages: revertMessageID ? sourceMessages.filter((message) => message.id < revertMessageID) : sourceMessages, revertMessageID, } } export function buildSessionMessageRecordsSnapshot( state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot, suspendPartUpdates = false, suspendedPartUpdatesMessageID?: string, ): SessionMessageRecordsSnapshot { const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous) const nextById = new Map() const nextList = visibleMessages.map((message) => { const previousRecord = previous?.byId.get(message.id) const shouldSuspendParts = suspendPartUpdates && previousRecord && (!suspendedPartUpdatesMessageID || message.id === suspendedPartUpdatesMessageID) const parts = shouldSuspendParts ? previousRecord.parts : (state.part[message.id] ?? EMPTY_PARTS) const nextRecord = previousRecord && previousRecord.info === message && previousRecord.parts === parts ? previousRecord : { info: message, parts } nextById.set(message.id, nextRecord) return nextRecord }) const unchanged = Boolean(previous) && previous?.visibleMessages === visibleMessages && previous.suspendPartUpdates === suspendPartUpdates && previous.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID && previous.list.length === nextList.length && previous.list.every((record, index) => record === nextList[index]) if (unchanged && previous) { return previous } return { sessionID, sourceMessages, visibleMessages, revertMessageID, suspendPartUpdates, suspendedPartUpdatesMessageID, list: nextList, byId: nextById, } } export function useSessionMessageCount(sessionID: string, directory?: string): number { return useDirectorySync( useCallback((state: State) => { if (!sessionID) return 0 return state.message[sessionID]?.length ?? 0 }, [sessionID]), directory, ) } export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] { const records = useSessionMessageRecords(sessionID, directory) return useMemo( () => records.map((record) => ({ id: record.info.id, role: typeof record.info.role === "string" ? record.info.role : null, text: getConcatenatedTextFromParts(record.parts), })), [records], ) } export function useUserMessageHistory(sessionID: string, directory?: string): string[] { const store = useDirectoryStore(directory) const snapshotRef = useRef(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT) const getSnapshot = useCallback(() => { const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current) snapshotRef.current = next return next.history }, [sessionID, store]) const subscribe = useCallback((notify: () => void) => { if (!sessionID) return () => undefined return store.subscribe(notify) }, [sessionID, store]) return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } /** * Get messages for a session in the old {info, parts}[] format. * Uses visible messages (filtered by revert state). * * Uses a ref-stable parts lookup that only triggers re-renders when * a part array for one of our displayed messages actually changes. */ export function useSessionMessageRecords( sessionID: string, directory?: string, options?: { suspendPartUpdates?: boolean; suspendPartUpdatesForMessageId?: string | null }, ) { const store = useDirectoryStore(directory) const snapshotRef = useRef({ sessionID, sourceMessages: EMPTY_MESSAGES, visibleMessages: EMPTY_MESSAGES, revertMessageID: undefined, suspendPartUpdates: Boolean(options?.suspendPartUpdates), suspendedPartUpdatesMessageID: options?.suspendPartUpdatesForMessageId ?? undefined, list: [], byId: new Map(), }) const getSnapshot = useCallback(() => { if (!sessionID) { return EMPTY_SESSION_MESSAGE_RECORDS } const state = store.getState() const suspendPartUpdates = Boolean(options?.suspendPartUpdates) const suspendedPartUpdatesMessageID = options?.suspendPartUpdatesForMessageId ?? undefined const reusableSnapshot = getReusableSessionMessageRecordsSnapshot( store, state, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID, ) if (reusableSnapshot) { snapshotRef.current = reusableSnapshot return reusableSnapshot.list } const previousSnapshot = snapshotRef.current.sessionID === sessionID ? snapshotRef.current : readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID) const nextSnapshot = buildSessionMessageRecordsSnapshot( state, sessionID, previousSnapshot, suspendPartUpdates, suspendedPartUpdatesMessageID, ) snapshotRef.current = nextSnapshot rememberSessionMessageRecordsSnapshot(store, nextSnapshot) return nextSnapshot.list }, [options?.suspendPartUpdates, options?.suspendPartUpdatesForMessageId, sessionID, store]) const subscribe = useCallback((notify: () => void) => { if (!sessionID) return () => undefined return store.subscribe(notify) }, [sessionID, store]) return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } /** * Ensures a session's messages are loaded into the sync store. * If the session exists in state.session but messages haven't been fetched * (state.message[sessionID] is absent), triggers a background API fetch. * * This covers the case where a user navigates to an old parent session * whose child session messages were never loaded — bootstrap only loads * session metadata, not messages. */ // Module-level in-flight tracking for useEnsureSessionMessages. // Prevents redundant parallel fetches when multiple component instances // (e.g. multiple ToolParts) request the same session's messages. const _ensureMessagesLoading = new Set() export function useEnsureSessionMessages(sessionID: string, directory?: string) { const syncDirectory = useSyncDirectory() const resolvedDirectory = directory ?? syncDirectory const store = useDirectoryStore(resolvedDirectory) const requestGenerationRef = React.useRef(0) React.useEffect(() => { if (!sessionID) return const state = store.getState() // Already loaded into a renderable message/part snapshot — nothing to do. if (getSessionMaterializationStatus(state, sessionID).renderable) return // Session doesn't exist — nothing to load if (!state.session.some((s) => s.id === sessionID)) return const loadingKey = `${resolvedDirectory}:${sessionID}` // Already loading this session for this directory if (_ensureMessagesLoading.has(loadingKey)) return const generation = ++requestGenerationRef.current const isStale = () => generation !== requestGenerationRef.current _ensureMessagesLoading.add(loadingKey) void (async () => { try { await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale }) } catch { // Transient failure — next navigation or reconnect will retry } finally { _ensureMessagesLoading.delete(loadingKey) } })() }, [sessionID, store, resolvedDirectory]) } const EMPTY_MESSAGES: Message[] = [] const EMPTY_PARTS: Part[] = [] const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = []