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