diff --git a/packages/ui/src/components/chat/ChatEmptyState.tsx b/packages/ui/src/components/chat/ChatEmptyState.tsx index a76f10cd..bb673dfd 100644 --- a/packages/ui/src/components/chat/ChatEmptyState.tsx +++ b/packages/ui/src/components/chat/ChatEmptyState.tsx @@ -1,16 +1,27 @@ import React from 'react'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useGlobalSyncStore } from '@/sync/global-sync-store'; const ChatEmptyState: React.FC = () => { const { currentTheme } = useThemeSystem(); + const initError = useGlobalSyncStore((s) => s.error); const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)'; return (
- Start a new chat + {initError ? ( +
+ OpenCode is not reachable + + {initError.message} + +
+ ) : ( + Start a new chat + )}
); }; diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index 4ad2c4ce..7ff85bf7 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -50,7 +50,27 @@ export async function bootstrapGlobal( console.error("[bootstrap] global bootstrap failed", errors[0]) } - set({ ready: true }) + // If ALL requests failed, OpenCode is likely down — fetch the OpenChamber + // health endpoint (outside the readiness gate) to get the actual error reason. + if (errors.length === results.length) { + let message = errors[0] instanceof Error ? errors[0].message : String(errors[0]) + try { + const healthRes = await fetch("/health", { signal: AbortSignal.timeout(4000) }) + if (healthRes.ok) { + const health = await healthRes.json() + if (health.lastOpenCodeError) { + message = health.lastOpenCodeError + } else if (!health.openCodeRunning) { + message = "OpenCode process is not running" + } + } + } catch { + // health endpoint itself unreachable — use the original error + } + set({ ready: true, error: { type: "init", message } }) + } else { + set({ ready: true, error: undefined }) + } } // --------------------------------------------------------------------------- diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 525ecc0e..9002805c 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -11,6 +11,7 @@ import type { DirectoryStore } from "./child-store" import type { StoreApi } from "zustand" import { opencodeClient } from "@/lib/opencode/client" import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" +import { registerSessionDirectory } from "./sync-refs" // Reference set by SyncProvider — allows actions to access SDK and stores let _sdk: OpencodeClient | null = null @@ -93,6 +94,11 @@ export async function createSession( if (!session) return null const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null + // Pre-populate routing index so SSE events arriving before session.created + // can be routed to the correct child store + if (sessionDirectory) { + registerSessionDirectory(session.id, sessionDirectory) + } useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory) useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) useGlobalSessionsStore.getState().upsertSession(session) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 3f66030e..2f866ad3 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -194,7 +194,9 @@ const normalizeEventDirectory = (rawDirectory: string): string => { if (!rawDirectory || rawDirectory === "global") { return rawDirectory } - return rawDirectory.replace(/\\/g, "/").replace(/^([a-z]):/, (_, l: string) => l.toUpperCase() + ":") + 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 => { @@ -419,6 +421,26 @@ const ingestDirectoryStateIntoRoutingIndex = ( } } +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 resolveDirectoryFromRoutingIndex = ( routingIndex: EventRoutingIndex, rawDirectory: string, @@ -433,6 +455,13 @@ const resolveDirectoryFromRoutingIndex = ( if (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) @@ -444,8 +473,16 @@ const resolveDirectoryFromRoutingIndex = ( 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") @@ -653,7 +690,22 @@ function handleEvent( } // Directory events - const store = childStores.getChild(directory) + 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 @@ -669,7 +721,7 @@ function handleEvent( return } - childStores.mark(directory) + childStores.mark(resolvedDirectory) // Notification dispatch for session turn-complete and error events. // These are NOT handled by the event reducer — only the notification store. @@ -683,10 +735,10 @@ function handleEvent( // subtask — skip notification } else if (sessionID) { appendNotification({ - directory, + directory: resolvedDirectory, session: sessionID, time: Date.now(), - viewed: isViewedInCurrentSession(directory, sessionID), + viewed: isViewedInCurrentSession(resolvedDirectory, sessionID), ...(payload.type === "session.error" ? { type: "error" as const, error: props.error } : { type: "turn-complete" as const }), @@ -752,7 +804,7 @@ function handleEvent( store.setState(draft) } - updateRoutingIndexFromEvent(routingIndex, directory, payload) + updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) // Update global session status for cross-directory sidebar visibility if (payload.type === "session.status") { @@ -761,15 +813,15 @@ function handleEvent( } if (payload.type === "permission.asked") { - const normalizedDirectory = normalizeDirectory(directory) - if (!normalizedDirectory) { + const nd = normalizeDirectory(resolvedDirectory) + if (!nd) { 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 })) { + if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: nd })) { void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) } } @@ -928,13 +980,15 @@ export function SyncProvider(props: { // Set refs so non-React code (session-actions, session-ui-store) can access sync state useEffect(() => { - setSyncRefs(props.sdk, childStores, props.directory) + 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]) + }, [props.sdk, props.directory, childStores, routingIndex]) // Subscribe to child store for streaming state derivation useEffect(() => { diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index a5d21c19..f156abc4 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -12,15 +12,27 @@ import type { State } from "./types" let _sdk: OpencodeClient | null = null let _childStores: ChildStoreManager | null = null let _directory: string = "" +let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null export function setSyncRefs( sdk: OpencodeClient, childStores: ChildStoreManager, directory: string, + registerSessionDirectory?: (sessionID: string, directory: string) => void, ) { _sdk = sdk _childStores = childStores _directory = directory + if (registerSessionDirectory) { + _registerSessionDirectory = registerSessionDirectory + } +} + +/** Pre-register a session→directory mapping in the routing index. + * Called from session-actions when creating sessions so SSE events + * arriving before session.created can be routed correctly. */ +export function registerSessionDirectory(sessionID: string, directory: string) { + _registerSessionDirectory?.(sessionID, directory) } export function getSyncSDK(): OpencodeClient { diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index ece1826a..ff095759 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -204,7 +204,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } try { - const response = await fetch(`http://127.0.0.1:${state.openCodePort}/session`, { + const response = await fetch(buildOpenCodeUrl('/session', ''), { method: 'GET', headers: getOpenCodeAuthHeaders(), signal: AbortSignal.timeout(2000), diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index eb4a7c5d..83928230 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -31,6 +31,45 @@ export const registerOpenCodeProxy = (app, deps) => { app.set('opencodeProxyConfigured', true); const isAbortError = (error) => error?.name === 'AbortError'; + const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902'; + + const normalizeProxyTarget = (candidate) => { + if (typeof candidate !== 'string') { + return null; + } + + const trimmed = candidate.trim(); + if (!trimmed) { + return null; + } + + return trimmed.replace(/\/+$/, ''); + }; + + // Keep generic proxy requests on the same upstream base URL that health checks + // and direct fetch helpers use. This avoids split-brain state where /health + // succeeds against an external host but /api/* still proxies to 127.0.0.1. + const resolveProxyTarget = () => { + try { + const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', '')); + if (resolved) { + return resolved; + } + } catch { + } + + const runtimeState = getRuntime(); + const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl); + if (externalBase) { + return externalBase; + } + + if (runtimeState.openCodePort) { + return `http://localhost:${runtimeState.openCodePort}`; + } + + return FALLBACK_PROXY_TARGET; + }; const forwardSseRequest = async (req, res) => { const abortController = new AbortController(); @@ -79,6 +118,12 @@ export const registerOpenCodeProxy = (app, deps) => { res.flushHeaders(); } + // Disable TCP Nagle's algorithm so small SSE chunks are sent immediately + // instead of being buffered up to ~200ms by the TCP stack. + if (res.socket && typeof res.socket.setNoDelay === 'function') { + res.socket.setNoDelay(true); + } + reader = upstream.body.getReader(); while (!abortController.signal.aborted) { const { done, value } = await reader.read(); @@ -233,14 +278,11 @@ export const registerOpenCodeProxy = (app, deps) => { // Generic proxy for non-SSE OpenCode API routes. const apiProxy = createProxyMiddleware({ - target: `http://127.0.0.1:${runtime.openCodePort || 3902}`, + target: resolveProxyTarget(), changeOrigin: true, pathRewrite: { '^/api': '' }, // Dynamic target — port can change after restart - router: () => { - const rt = getRuntime(); - return `http://127.0.0.1:${rt.openCodePort || 3902}`; - }, + router: () => resolveProxyTarget(), on: { proxyReq: (proxyReq) => { // Inject OpenCode auth headers