/* 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 { StoreApi } from "zustand" import { useStore } from "zustand" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createEventPipeline } from "./event-pipeline" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" import { retry } from "./retry" import { updateStreamingState } from "./streaming" import { setActionRefs } from "./session-actions" import { setSyncRefs } from "./sync-refs" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" import { autoRespondsPermission, normalizeDirectory } from "@/stores/utils/permissionAutoAccept" import { appendNotification } from "./notification-store" 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 { create } from "zustand" import * as sessionActions from "./session-actions" // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- type SyncSystem = { childStores: ChildStoreManager sdk: OpencodeClient directory: string } const SyncContext = createContext(null) function useSyncSystem() { const ctx = useContext(SyncContext) if (!ctx) throw new Error("useSyncSystem must be used within ") return ctx } // --------------------------------------------------------------------------- // 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. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Global session status store — cross-directory status tracking. // // OpenCode isolates sessions behind project navrails, so per-directory // session_status is sufficient. OpenChamber shows all sessions in one sidebar, // so we need a global view. Updated from handleEvent on every session.status. // --------------------------------------------------------------------------- interface GlobalSessionStatusStore { statuses: Record } const useGlobalSessionStatusStore = create(() => ({ statuses: {}, })) function setGlobalSessionStatus(sessionId: string, status: SessionStatus) { const current = useGlobalSessionStatusStore.getState().statuses if (current[sessionId] === status) return useGlobalSessionStatusStore.setState({ statuses: { ...current, [sessionId]: status }, }) } /** Read status for a session across all directories */ export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined { return useGlobalSessionStatusStore((s) => s.statuses[sessionId]) } /** Read all session statuses (for sidebar) */ export function useAllSessionStatuses(): Record { return useGlobalSessionStatusStore((s) => s.statuses) } // Boot debounce — suppresses redundant refresh/re-bootstrap events during startup. let bootingRoot = false let bootedAt = 0 const BOOT_DEBOUNCE_MS = 1500 // 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 = "" export function setActiveSession(directory: string, sessionId: string) { _activeDirectory = directory _activeSession = sessionId } function isViewedInCurrentSession(directory: string, sessionId?: string): boolean { if (!_activeDirectory || !_activeSession || !sessionId) return false if (directory !== _activeDirectory) return false return sessionId === _activeSession } function isRecentBoot() { return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS } function handleEvent( directory: string, payload: Event, childStores: ChildStoreManager, ) { // 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 const store = childStores.getChild(directory) 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(directory) // 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, session: sessionID, time: Date.now(), viewed: isViewedInCurrentSession(directory, sessionID), ...(payload.type === "session.error" ? { type: "error" as const, error: props.error } : { type: "turn-complete" as const }), }) } } // Read live state, create targeted draft cloning ONLY fields the 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": 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 } if (applyDirectoryEvent(draft, payload)) { store.setState(draft) } // Update global session status for cross-directory sidebar visibility if (payload.type === "session.status") { const props = payload.properties as { sessionID: string; status: SessionStatus } setGlobalSessionStatus(props.sessionID, props.status) } if (payload.type === "permission.asked") { const normalizedDirectory = normalizeDirectory(directory) if (!normalizedDirectory) { return } const permission = payload.properties as PermissionRequest const sessions = store.getState().session const autoAccept = usePermissionStore.getState().autoAccept if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: normalizedDirectory })) { void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) } } } // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- export function SyncProvider(props: { sdk: OpencodeClient directory: string children: React.ReactNode }) { const childStoresRef = useRef(null) if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager() const childStores = childStoresRef.current const system = useMemo( () => ({ childStores, sdk: props.sdk, directory: props.directory, }), [childStores, props.sdk, props.directory], ) // 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_status) { const current = useGlobalSessionStatusStore.getState().statuses const merged = { ...current, ...patch.session_status } useGlobalSessionStatusStore.setState({ statuses: merged }) } }, global: { config: globalState.config, projects: globalState.projects, providers: globalState.providers, }, loadSessions: (dir) => retry(async () => { const result = await props.sdk.session.list({ directory: dir, roots: true, limit: 50, }) // SDK returns { error } instead of { data } on non-ok responses (503). // Throw so retry() retries and allSettled marks it as rejected. if ((result as { error?: unknown }).error) { throw new Error("session.list failed: " + String((result as { error?: unknown }).error)) } const sessions = (result.data ?? []) .filter((s) => !!s?.id) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) }) }), }) // VS Code race: if sessions are still empty after bootstrap, OpenCode // wasn't ready yet (bridge returned 503). Retry a few times. const state = store.getState() if (state.session.length === 0 && attempt < 5) { await new Promise((r) => setTimeout(r, 2000)) store.setState({ status: "loading" as const }) await runBootstrap(attempt + 1) } } runBootstrap(0).finally(() => { bootingDirs.delete(directory) }) }, onDispose: (directory) => { bootingDirs.delete(directory) }, isBooting: (directory) => bootingDirs.has(directory), isLoadingSessions: () => false, }) }, [childStores, props.sdk]) // Bootstrap global state — set bootingRoot/bootedAt to suppress // redundant refresh events during startup useEffect(() => { bootingRoot = true const globalActions = useGlobalSyncStore.getState().actions bootstrapGlobal(props.sdk, globalActions.set) .then(() => { bootedAt = Date.now() }) .finally(() => { 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 { cleanup } = createEventPipeline({ sdk: props.sdk, onEvent: (directory, payload) => { handleEvent(directory, payload, childStores) }, }) return cleanup }, [props.sdk, childStores]) // Ensure current directory's child store exists useEffect(() => { if (props.directory) { childStores.ensureChild(props.directory) } }, [props.directory, childStores]) // Set refs so non-React code (session-actions, session-ui-store) can access sync state useEffect(() => { setSyncRefs(props.sdk, childStores, props.directory) setActionRefs( props.sdk, childStores, () => opencodeClient.getDirectory() || props.directory, ) }, [props.sdk, props.directory, childStores]) // Subscribe to child store for streaming state derivation useEffect(() => { if (!props.directory) return const store = childStores.getChild(props.directory) if (!store) return const unsubscribe = store.subscribe((state) => { updateStreamingState(state) }) return unsubscribe }, [props.directory, childStores]) return {props.children} } // --------------------------------------------------------------------------- // Hooks // --------------------------------------------------------------------------- /** Access the global sync store */ export function useGlobalSync() { return useGlobalSyncStore() } /** Access the global sync store with a selector */ export function useGlobalSyncSelector(selector: (state: GlobalSyncStore) => T): T { return useGlobalSyncStore(selector) } /** Get the child store for a directory (defaults to current) */ export function useDirectoryStore(directory?: string): StoreApi { const system = useSyncSystem() const dir = directory ?? system.directory return system.childStores.ensureChild(dir) } /** 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 the revert messageID for a session (if reverted) */ export function useSessionRevertMessageID(sessionID: string, directory?: string): string | undefined { return useDirectorySync( useCallback((state: State) => { const session = state.session.find((s) => s.id === sessionID) return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID }, [sessionID]), directory, ) } /** Get session messages for a specific session */ export function useSessionMessages(sessionID: string, directory?: string) { return useDirectorySync( useCallback((state: State) => state.message[sessionID] ?? EMPTY_MESSAGES, [sessionID]), directory, ) } /** * Get visible session messages — filters out reverted messages. * Filters out reverted messages (id >= session.revert.messageID). */ export function useVisibleSessionMessages(sessionID: string, directory?: string) { const messages = useSessionMessages(sessionID, directory) const revertMessageID = useSessionRevertMessageID(sessionID, directory) return useMemo(() => { if (!revertMessageID) return messages return messages.filter((m) => m.id < revertMessageID) }, [messages, revertMessageID]) } /** 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) { return useDirectorySync( useCallback((state: State) => state.session_status?.[sessionID], [sessionID]), directory, ) } /** Get permissions for a specific session */ export function useSessionPermissions(sessionID: string, directory?: string) { return useDirectorySync( useCallback((state: State) => state.permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS, [sessionID]), directory, ) } /** 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, ) } /** 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 } /** * 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) { const messages = useVisibleSessionMessages(sessionID, directory) const store = useDirectoryStore(directory) // Track parts with a ref to avoid subscribing to entire state.part map. // Re-derive only when messages list changes or on store subscription. const prevPartsRef = useRef>({}) const [partsSnapshot, setPartsSnapshot] = React.useState>({}) React.useEffect(() => { const messageIds = messages.map((m) => m.id) let timer: ReturnType | null = null let pending = false const flush = () => { timer = null pending = false const state = store.getState() const prev = prevPartsRef.current let changed = false const next: Record = {} for (const id of messageIds) { const parts = state.part[id] ?? EMPTY_PARTS // Preserve existing reference if parts haven't changed in the store next[id] = prev[id] === parts ? prev[id] : parts if (next[id] !== prev[id]) changed = true } if (changed || Object.keys(prev).length !== messageIds.length) { prevPartsRef.current = next setPartsSnapshot(next) } } // Initial sync flush() // Throttled subscription — batch rapid delta events into ~100ms updates const unsub = store.subscribe(() => { if (timer) { pending = true return } timer = setTimeout(() => { flush() if (pending) { pending = false timer = setTimeout(flush, 100) } }, 100) }) return () => { unsub() if (timer) clearTimeout(timer) } }, [messages, store]) return useMemo( () => messages.map((msg) => ({ info: msg, parts: partsSnapshot[msg.id] ?? EMPTY_PARTS, })), [messages, partsSnapshot], ) } /** * Determines if a session is actively working. * Checks session_status AND incomplete assistant messages as fallback. * Returns false when permissions are pending (permission indicator takes priority). */ export function useIsSessionWorking(sessionID: string, directory?: string): boolean { const status = useSessionStatus(sessionID, directory) const permissions = useSessionPermissions(sessionID, directory) const messages = useSessionMessages(sessionID, directory) return useMemo(() => { // Permissions pending → not "working" (show permission indicator instead) if (permissions.length > 0) return false // Check session_status const statusWorking = status !== undefined && status.type !== "idle" // Check for incomplete assistant message (fallback if status event delayed) let hasPendingAssistant = false for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i] if (m.role === "assistant" && typeof (m as { time?: { completed?: number } }).time?.completed !== "number") { hasPendingAssistant = true break } } return statusWorking || hasPendingAssistant }, [status, permissions, messages]) } const EMPTY_MESSAGES: Message[] = [] const EMPTY_PARTS: Part[] = [] const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = []