diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8ad7a789..a81bef1e 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1520,7 +1520,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo else if (commandName === 'compact' && currentSessionId) { try { if (!useConfigStore.getState().isConnected) { - throw new Error("Connection lost. Please wait for reconnection."); + const reason = useConfigStore.getState().lastDisconnectReason; + const suffix = reason ? ` (${reason})` : " (never connected)"; + throw new Error(`Connection lost${suffix}. Please wait for reconnection.`); } const { opencodeClient } = await import('@/lib/opencode/client'); const sdk = opencodeClient.getSdkClient(); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 9226e888..d8903c35 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -468,6 +468,7 @@ interface ConfigStore { agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; defaultProviders: { [key: string]: string }; isConnected: boolean; + lastDisconnectReason: string | null; isInitialized: boolean; modelsMetadata: Map; // OpenChamber settings-based defaults (take precedence over agent preferences) @@ -588,6 +589,7 @@ export const useConfigStore = create()( agentModelSelections: {}, defaultProviders: {}, isConnected: false, + lastDisconnectReason: null, isInitialized: false, modelsMetadata: new Map(), settingsDefaultModel: undefined, diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index 49220065..2ce3d99b 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -34,7 +34,7 @@ export type EventPipelineInput = { /** Called after stream reconnects (visibility restore or heartbeat timeout). */ onReconnect?: () => void /** Called when the stream disconnects (heartbeat timeout, network error, or transport failure). */ - onDisconnect?: () => void + onDisconnect?: (reason: string) => void transport?: "auto" | "ws" | "sse" } @@ -429,6 +429,7 @@ export function createEventPipeline(input: EventPipelineInput) { if (frame.type === "error") { const error = new Error(frame.message || "Message stream WebSocket error") + ;(error as Error & { reason?: string }).reason = `ws_error_frame:${frame.message || "unknown"}` setFallbackCode(error) settleReject(error) try { @@ -463,13 +464,16 @@ export function createEventPipeline(input: EventPipelineInput) { void 0 } - socket.onclose = () => { + socket.onclose = (event) => { if (signal.aborted) { settleResolve() return } const error = new Error("Global message stream WebSocket closed") + ;(error as Error & { reason?: string }).reason = opened + ? `ws_closed:code=${event?.code ?? "?"}` + : "ws_closed_before_ready" setFallbackCode(error) settleReject(error) } @@ -521,7 +525,18 @@ export function createEventPipeline(input: EventPipelineInput) { // setState calls on every failed retry attempt. if (!disconnected) { disconnected = true - onDisconnect?.() + const taggedReason = typeof error === "object" && error !== null + ? (error as { reason?: unknown }).reason + : undefined + const message = typeof error === "object" && error !== null + ? (error as { message?: unknown }).message + : undefined + const reason = typeof taggedReason === "string" && taggedReason.length > 0 + ? taggedReason + : typeof message === "string" && message.length > 0 + ? `${currentTransport}_error:${message.slice(0, 80)}` + : `${currentTransport}_error:unknown` + onDisconnect?.(reason) } } } finally { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index bfbbaca8..25b576c2 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -54,6 +54,12 @@ function dir() { return _getDirectory() || undefined } +function connectionLostError(): Error { + const reason = useConfigStore.getState().lastDisconnectReason + const suffix = reason ? ` (${reason})` : " (never connected)" + return new Error(`Connection lost${suffix}. Please wait for reconnection.`) +} + function getSessionDirectory(sessionId: string): string | undefined { return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir() } @@ -327,7 +333,7 @@ export async function optimisticSend(input: { } if (!useConfigStore.getState().isConnected) { - throw new Error("Connection lost. Please wait for reconnection.") + throw connectionLostError() } const store = dirStore() @@ -414,7 +420,7 @@ export async function respondToPermission( response: "once" | "always" | "reject", ): Promise { if (!useConfigStore.getState().isConnected) { - throw new Error("Connection lost. Please wait for reconnection.") + throw connectionLostError() } const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ requestID: requestId, @@ -430,7 +436,7 @@ export async function dismissPermission( requestId: string, ): Promise { if (!useConfigStore.getState().isConnected) { - throw new Error("Connection lost. Please wait for reconnection.") + throw connectionLostError() } const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({ requestID: requestId, @@ -451,7 +457,7 @@ export async function respondToQuestion( answers: string[] | string[][], ): Promise { if (!useConfigStore.getState().isConnected) { - throw new Error("Connection lost. Please wait for reconnection.") + throw connectionLostError() } const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({ requestID: requestId, @@ -467,7 +473,7 @@ export async function rejectQuestion( requestId: string, ): Promise { if (!useConfigStore.getState().isConnected) { - throw new Error("Connection lost. Please wait for reconnection.") + throw connectionLostError() } const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({ requestID: requestId, diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index cbac4822..ef15d374 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1347,7 +1347,7 @@ export function SyncProvider(props: { handleEvent(directory, payload, childStores, routingIndex) }, onReconnect: () => { - useConfigStore.setState({ isConnected: true }) + useConfigStore.setState({ isConnected: true, lastDisconnectReason: null }) for (const [dir, store] of childStores.children) { if (reconnectResyncing.has(dir)) continue if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue @@ -1362,8 +1362,8 @@ export function SyncProvider(props: { }) } }, - onDisconnect: () => { - useConfigStore.setState({ isConnected: false }) + onDisconnect: (reason) => { + useConfigStore.setState({ isConnected: false, lastDisconnectReason: reason }) }, }) return cleanup diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index a4be1884..bf8781c5 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -319,12 +319,17 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } try { - const response = await fetch(buildOpenCodeUrl('/session', ''), { + const response = await fetch(buildOpenCodeUrl('/global/health', ''), { method: 'GET', - headers: getOpenCodeAuthHeaders(), - signal: AbortSignal.timeout(2000), + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(5000), }); - return response.ok; + if (!response.ok) return false; + const body = await response.json().catch(() => null); + return body?.healthy === true; } catch { return false; }