diff --git a/packages/ui/src/lib/connection-status.test.ts b/packages/ui/src/lib/connection-status.test.ts new file mode 100644 index 000000000..b61da207a --- /dev/null +++ b/packages/ui/src/lib/connection-status.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { deriveDisplayConnectionStatus } from "./connection-status.ts" + +describe("deriveDisplayConnectionStatus", () => { + it("overlays connecting while transport is down for connected instances", () => { + assert.equal(deriveDisplayConnectionStatus("connected", "disconnected"), "connecting") + }) + + it("restores previous connected status when transport reconnects", () => { + assert.equal(deriveDisplayConnectionStatus("connected", "connected"), "connected") + }) + + it("preserves disconnected instance status while transport is down", () => { + assert.equal(deriveDisplayConnectionStatus("disconnected", "disconnected"), "disconnected") + }) + + it("preserves error instance status while transport is down", () => { + assert.equal(deriveDisplayConnectionStatus("error", "disconnected"), "error") + }) + + it("does not clear legitimate instance connecting status after transport opens", () => { + assert.equal(deriveDisplayConnectionStatus("connecting", "connected"), "connecting") + }) +}) diff --git a/packages/ui/src/lib/connection-status.ts b/packages/ui/src/lib/connection-status.ts new file mode 100644 index 000000000..c162e550c --- /dev/null +++ b/packages/ui/src/lib/connection-status.ts @@ -0,0 +1,19 @@ +import type { InstanceStreamStatus } from "../../../server/src/api-types" +import type { WorkspaceEventTransportStatus } from "./event-transport" + +export type ConnectionStatus = InstanceStreamStatus + +export function deriveDisplayConnectionStatus( + instanceStatus: ConnectionStatus | null, + workspaceTransportStatus: WorkspaceEventTransportStatus, +): ConnectionStatus | null { + if (instanceStatus === "disconnected" || instanceStatus === "error") { + return instanceStatus + } + + if (workspaceTransportStatus !== "connected") { + return "connecting" + } + + return instanceStatus +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index 24b69591d..5de633c37 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -15,9 +15,12 @@ export interface WorkspaceEventTransportCallbacks { onBatch: (events: WorkspaceEventPayload[]) => void onError?: () => void onOpen?: () => void + onStatus?: (status: WorkspaceEventTransportStatus) => void onPing?: (payload: { ts?: number }) => void } +export type WorkspaceEventTransportStatus = "connecting" | "connected" | "disconnected" + export interface WorkspaceEventConnection { disconnect: () => void } @@ -25,10 +28,17 @@ export interface WorkspaceEventConnection { async function connectBrowserWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, ): Promise { + const notifyDisconnected = () => { + callbacks.onStatus?.("disconnected") + callbacks.onError?.() + } const source = serverApi.connectEvents((event) => { callbacks.onBatch([event]) - }, callbacks.onError, callbacks.onPing) - source.onopen = () => callbacks.onOpen?.() + }, notifyDisconnected, callbacks.onPing) + source.onopen = () => { + callbacks.onStatus?.("connected") + callbacks.onOpen?.() + } return { disconnect() { source.close() diff --git a/packages/ui/src/lib/native/desktop-events.test.ts b/packages/ui/src/lib/native/desktop-events.test.ts index c414b04b4..75c324217 100644 --- a/packages/ui/src/lib/native/desktop-events.test.ts +++ b/packages/ui/src/lib/native/desktop-events.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { createTerminalErrorNotifier } from "./desktop-events.ts" +import { createTerminalErrorNotifier, mapDesktopEventTransportStatus } from "./desktop-events.ts" describe("createTerminalErrorNotifier", () => { it("calls onError once for repeated terminal notifications", () => { @@ -17,3 +17,19 @@ describe("createTerminalErrorNotifier", () => { assert.equal(errors, 1) }) }) + +describe("mapDesktopEventTransportStatus", () => { + it("maps native connected state to shared connected state", () => { + assert.equal(mapDesktopEventTransportStatus("connected"), "connected") + }) + + it("maps native connecting state to shared connecting state", () => { + assert.equal(mapDesktopEventTransportStatus("connecting"), "connecting") + }) + + it("maps native transient failures to shared disconnected state", () => { + assert.equal(mapDesktopEventTransportStatus("disconnected"), "disconnected") + assert.equal(mapDesktopEventTransportStatus("error"), "disconnected") + assert.equal(mapDesktopEventTransportStatus("unauthorized"), "disconnected") + }) +}) diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts index 01c9af6ea..bd14892b7 100644 --- a/packages/ui/src/lib/native/desktop-events.ts +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -4,9 +4,14 @@ import type { WorkspaceEventPayload } from "../../../../server/src/api-types" import type { DesktopEventsStartResult, DesktopEventTransportStartOptions, + DesktopEventTransportState, DesktopEventTransportStatusPayload, } from "../event-transport-contract" -import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport" +import type { + WorkspaceEventConnection, + WorkspaceEventTransportCallbacks, + WorkspaceEventTransportStatus, +} from "../event-transport" import { getLogger } from "../logger" const log = getLogger("sse") @@ -27,6 +32,14 @@ export function createTerminalErrorNotifier(callbacks: Pick { if (!payload || !matchesGeneration(payload.generation)) return + callbacks.onStatus?.(mapDesktopEventTransportStatus(payload.state)) + if (payload.state === "connected" && !opened) { opened = true callbacks.onOpen?.() diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 4145367dd..bb747f1a0 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -2,7 +2,11 @@ import { batch as solidBatch } from "solid-js" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" -import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport" +import { + connectWorkspaceEvents, + type WorkspaceEventConnection, + type WorkspaceEventTransportStatus, +} from "./event-transport" import { getLogger } from "./logger" import { retryWithBackoff, isRetryableError } from "./retry-utils" @@ -21,6 +25,7 @@ function logSse(message: string, context?: Record) { class ServerEvents { private handlers = new Map void>>() private openHandlers = new Set<() => void>() + private statusHandlers = new Set<(status: WorkspaceEventTransportStatus) => void>() private connection: WorkspaceEventConnection | null = null private connectGeneration = 0 private retryDelay = RETRY_BASE_DELAY @@ -50,6 +55,12 @@ class ServerEvents { } this.scheduleReconnect() }, + onStatus: (status) => { + if (generation !== this.connectGeneration) { + return + } + this.emitTransportStatus(status) + }, onOpen: () => { if (generation !== this.connectGeneration) { return @@ -105,6 +116,8 @@ class ServerEvents { this.connection = null } + this.emitTransportStatus("disconnected") + logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay }) this.retryTimer = setTimeout(() => { this.retryTimer = null @@ -140,6 +153,10 @@ class ServerEvents { }) } + private emitTransportStatus(status: WorkspaceEventTransportStatus) { + this.statusHandlers.forEach((handler) => handler(status)) + } + on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()) @@ -154,6 +171,11 @@ class ServerEvents { return () => this.openHandlers.delete(handler) } + onTransportStatus(handler: (status: WorkspaceEventTransportStatus) => void): () => void { + this.statusHandlers.add(handler) + return () => this.statusHandlers.delete(handler) + } + restart(reason = "manual restart"): void { this.retryDelay = RETRY_BASE_DELAY this.clearReconnectTimer() diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 47b9ea802..359f9d303 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -24,13 +24,14 @@ import type { } from "@opencode-ai/sdk/v2" import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent } from "../types/permission" import { serverEvents } from "./server-events" +import type { WorkspaceEventTransportStatus } from "./event-transport" import type { BackgroundProcess, InstanceStreamEvent, - InstanceStreamStatus, WorkspaceEventPayload, } from "../../../server/src/api-types" import { getLogger } from "./logger" +import { deriveDisplayConnectionStatus, type ConnectionStatus } from "./connection-status" const log = getLogger("sse") @@ -95,12 +96,13 @@ type SSEEvent = | ServerInstanceDisposedEvent | { type: string; properties?: Record } -type ConnectionStatus = InstanceStreamStatus - const [connectionStatus, setConnectionStatus] = createSignal>(new Map()) +const [transportStatus, setTransportStatus] = createSignal("connecting") class SSEManager { constructor() { + log.info("sseManager initialized: listening for SSE disconnect and reconnect") + serverEvents.on("instance.eventStatus", (event) => { const payload = event as InstanceStatusPayload this.updateConnectionStatus(payload.instanceId, payload.status) @@ -118,6 +120,11 @@ class SSEManager { this.updateConnectionStatus(payload.instanceId, "connected") this.handleEvent(payload.instanceId, payload.event as SSEEvent) }) + + serverEvents.onTransportStatus((status) => { + log.info("SSE transport status changed", { status }) + setTransportStatus(status) + }) } seedStatus(instanceId: string, status: ConnectionStatus) { @@ -240,7 +247,7 @@ class SSEManager { onConnectionLost?: (instanceId: string, reason: string) => void | Promise getStatus(instanceId: string): ConnectionStatus | null { - return connectionStatus().get(instanceId) ?? null + return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus()) } getStatuses() {