Skip to content

Commit e8623d3

Browse files
JDis03shanturpascalandr
authored
fix(ui): SSE connection status indicators for mobile network drops (NeuralNomadsAI#549)
## Problem Mobile users experience SSE disconnections (wifi drops, Cloudflare idle timeouts) but get no visual feedback. The per-instance status dots remain green even when the SSE transport is down, because instance status updates travel over the broken SSE pipe. ## Solution This PR implements **immediate connection status feedback** by propagating SSE transport state to all instance status indicators: ### Changes 1. **server-events.ts**: Add `onDisconnect()` handler - Mirrors existing `onOpen()` pattern - Fire `disconnectHandlers` in `scheduleReconnect()` when connection drops 2. **sse-manager.ts**: Register SSE lifecycle handlers - `onDisconnect`: set ALL instances to `connecting` (amber dot) - `onOpen`: clear `connecting` status (green dot restored by subsequent events) - Log transitions for debug visibility 3. **AGENTS.md**: Add PR Review Principles - Check regressions first, look for better implementations - Be the PR gatekeeper, ruthless code quality - Test before responding, UI/server version parity ## Verification - ✅ TypeScript compilation clean (pre-existing SDK errors unrelated) - ✅ Vite build successful - ✅ Mobile testing: SSE disconnect → immediate amber dots - ✅ Reconnect → green dots restored ## Edge Cases Handled - **Transient drop**: amber → green on reconnect (no false disconnect modal) - **Instance dies during outage**: amber → green → `disconnected` event → red - **0 instances**: Map empty, loop is no-op - **Rapid reconnect cycles**: idempotent (setting `connecting` on `connecting` is no-op) ## Related - Complements PR NeuralNomadsAI#519 (pong retry with timeout) - merged upstream - Addresses mobile UX gap: workspace-level SSE state now visible to all users (not just debug overlay) --------- Co-authored-by: Shantur Rathore <i@shantur.com> Co-authored-by: Pascal André <pascalandr@gmail.com>
1 parent 3141452 commit e8623d3

7 files changed

Lines changed: 202 additions & 13 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
import { deriveDisplayConnectionStatus } from "./connection-status.ts"
4+
5+
describe("deriveDisplayConnectionStatus", () => {
6+
it("overlays connecting while transport is down for connected instances", () => {
7+
assert.equal(deriveDisplayConnectionStatus("connected", "disconnected"), "connecting")
8+
})
9+
10+
it("restores previous connected status when transport reconnects", () => {
11+
assert.equal(deriveDisplayConnectionStatus("connected", "connected"), "connected")
12+
})
13+
14+
it("preserves disconnected instance status while transport is down", () => {
15+
assert.equal(deriveDisplayConnectionStatus("disconnected", "disconnected"), "disconnected")
16+
})
17+
18+
it("preserves error instance status while transport is down", () => {
19+
assert.equal(deriveDisplayConnectionStatus("error", "disconnected"), "error")
20+
})
21+
22+
it("does not clear legitimate instance connecting status after transport opens", () => {
23+
assert.equal(deriveDisplayConnectionStatus("connecting", "connected"), "connecting")
24+
})
25+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { InstanceStreamStatus } from "../../../server/src/api-types"
2+
import type { WorkspaceEventTransportStatus } from "./event-transport"
3+
4+
export type ConnectionStatus = InstanceStreamStatus
5+
6+
export function deriveDisplayConnectionStatus(
7+
instanceStatus: ConnectionStatus | null,
8+
workspaceTransportStatus: WorkspaceEventTransportStatus,
9+
): ConnectionStatus | null {
10+
if (instanceStatus === "disconnected" || instanceStatus === "error") {
11+
return instanceStatus
12+
}
13+
14+
if (workspaceTransportStatus !== "connected") {
15+
return "connecting"
16+
}
17+
18+
return instanceStatus
19+
}

packages/ui/src/lib/event-transport.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,30 @@ export interface WorkspaceEventTransportCallbacks {
1515
onBatch: (events: WorkspaceEventPayload[]) => void
1616
onError?: () => void
1717
onOpen?: () => void
18+
onStatus?: (status: WorkspaceEventTransportStatus) => void
1819
onPing?: (payload: { ts?: number }) => void
1920
}
2021

22+
export type WorkspaceEventTransportStatus = "connecting" | "connected" | "disconnected"
23+
2124
export interface WorkspaceEventConnection {
2225
disconnect: () => void
2326
}
2427

2528
async function connectBrowserWorkspaceEvents(
2629
callbacks: WorkspaceEventTransportCallbacks,
2730
): Promise<WorkspaceEventConnection> {
31+
const notifyDisconnected = () => {
32+
callbacks.onStatus?.("disconnected")
33+
callbacks.onError?.()
34+
}
2835
const source = serverApi.connectEvents((event) => {
2936
callbacks.onBatch([event])
30-
}, callbacks.onError, callbacks.onPing)
31-
source.onopen = () => callbacks.onOpen?.()
37+
}, notifyDisconnected, callbacks.onPing)
38+
source.onopen = () => {
39+
callbacks.onStatus?.("connected")
40+
callbacks.onOpen?.()
41+
}
3242
return {
3343
disconnect() {
3444
source.close()

packages/ui/src/lib/native/desktop-events.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import assert from "node:assert/strict"
22
import { describe, it } from "node:test"
3-
import { createTerminalErrorNotifier } from "./desktop-events.ts"
3+
import {
4+
connectTauriWorkspaceEvents,
5+
createTerminalErrorNotifier,
6+
mapDesktopEventTransportStatus,
7+
} from "./desktop-events.ts"
48

59
describe("createTerminalErrorNotifier", () => {
610
it("calls onError once for repeated terminal notifications", () => {
@@ -17,3 +21,78 @@ describe("createTerminalErrorNotifier", () => {
1721
assert.equal(errors, 1)
1822
})
1923
})
24+
25+
describe("mapDesktopEventTransportStatus", () => {
26+
it("maps native connected state to shared connected state", () => {
27+
assert.equal(mapDesktopEventTransportStatus("connected"), "connected")
28+
})
29+
30+
it("maps native connecting state to shared connecting state", () => {
31+
assert.equal(mapDesktopEventTransportStatus("connecting"), "connecting")
32+
})
33+
34+
it("maps native transient failures to shared disconnected state", () => {
35+
assert.equal(mapDesktopEventTransportStatus("disconnected"), "disconnected")
36+
assert.equal(mapDesktopEventTransportStatus("error"), "disconnected")
37+
assert.equal(mapDesktopEventTransportStatus("unauthorized"), "disconnected")
38+
})
39+
})
40+
41+
describe("connectTauriWorkspaceEvents", () => {
42+
it("marks the transport connected when a batch opens the native stream", async () => {
43+
let batchHandler: ((event: { payload: any }) => void) | undefined
44+
const unlistened: string[] = []
45+
const bridge = {
46+
invoke: async (command: string) => {
47+
if (command === "desktop_events_start") {
48+
return { started: true, generation: 1 }
49+
}
50+
if (command === "desktop_events_stop") {
51+
return undefined
52+
}
53+
throw new Error(`Unexpected command: ${command}`)
54+
},
55+
listen: async (eventName: string, handler: (event: { payload: any }) => void) => {
56+
if (eventName === "desktop:event-batch") {
57+
batchHandler = handler
58+
}
59+
return () => {
60+
unlistened.push(eventName)
61+
}
62+
},
63+
} as any
64+
65+
const statuses: string[] = []
66+
const batches: unknown[] = []
67+
let opens = 0
68+
69+
const connection = await connectTauriWorkspaceEvents(
70+
{
71+
onBatch: (events) => batches.push(events),
72+
onOpen: () => {
73+
opens += 1
74+
},
75+
onStatus: (status) => statuses.push(status),
76+
},
77+
{ reconnect: {} },
78+
bridge,
79+
)
80+
81+
assert.ok(batchHandler)
82+
batchHandler({
83+
payload: {
84+
generation: 1,
85+
sequence: 1,
86+
emittedAt: Date.now(),
87+
events: [{ type: "server.heartbeat" }],
88+
},
89+
})
90+
91+
assert.deepEqual(statuses, ["connected"])
92+
assert.equal(opens, 1)
93+
assert.equal(batches.length, 1)
94+
95+
connection.disconnect()
96+
assert.deepEqual(unlistened, ["desktop:event-batch", "desktop:event-stream-status"])
97+
})
98+
})

packages/ui/src/lib/native/desktop-events.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import type { WorkspaceEventPayload } from "../../../../server/src/api-types"
44
import type {
55
DesktopEventsStartResult,
66
DesktopEventTransportStartOptions,
7+
DesktopEventTransportState,
78
DesktopEventTransportStatusPayload,
89
} from "../event-transport-contract"
9-
import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport"
10+
import type {
11+
WorkspaceEventConnection,
12+
WorkspaceEventTransportCallbacks,
13+
WorkspaceEventTransportStatus,
14+
} from "../event-transport"
1015
import { getLogger } from "../logger"
1116

1217
const log = getLogger("sse")
@@ -18,6 +23,16 @@ interface WorkspaceEventBatchPayload {
1823
events: WorkspaceEventPayload[]
1924
}
2025

26+
interface DesktopEventTransportBridge {
27+
invoke: typeof invoke
28+
listen: typeof listen
29+
}
30+
31+
const defaultDesktopEventTransportBridge: DesktopEventTransportBridge = {
32+
invoke,
33+
listen,
34+
}
35+
2136
export function createTerminalErrorNotifier(callbacks: Pick<WorkspaceEventTransportCallbacks, "onError">) {
2237
let raised = false
2338
return () => {
@@ -27,9 +42,18 @@ export function createTerminalErrorNotifier(callbacks: Pick<WorkspaceEventTransp
2742
}
2843
}
2944

45+
export function mapDesktopEventTransportStatus(
46+
state: DesktopEventTransportState,
47+
): WorkspaceEventTransportStatus {
48+
if (state === "connected") return "connected"
49+
if (state === "connecting") return "connecting"
50+
return "disconnected"
51+
}
52+
3053
export async function connectTauriWorkspaceEvents(
3154
callbacks: WorkspaceEventTransportCallbacks,
3255
options: DesktopEventTransportStartOptions,
56+
bridge: DesktopEventTransportBridge = defaultDesktopEventTransportBridge,
3357
): Promise<WorkspaceEventConnection> {
3458
let closed = false
3559
let opened = false
@@ -45,6 +69,7 @@ export async function connectTauriWorkspaceEvents(
4569

4670
if (!opened) {
4771
opened = true
72+
callbacks.onStatus?.("connected")
4873
callbacks.onOpen?.()
4974
}
5075

@@ -59,6 +84,8 @@ export async function connectTauriWorkspaceEvents(
5984
const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => {
6085
if (!payload || !matchesGeneration(payload.generation)) return
6186

87+
callbacks.onStatus?.(mapDesktopEventTransportStatus(payload.state))
88+
6289
if (payload.state === "connected" && !opened) {
6390
opened = true
6491
callbacks.onOpen?.()
@@ -107,7 +134,7 @@ export async function connectTauriWorkspaceEvents(
107134
}
108135
}
109136

110-
const unlistenBatch = await listen<WorkspaceEventBatchPayload>("desktop:event-batch", (event) => {
137+
const unlistenBatch = await bridge.listen<WorkspaceEventBatchPayload>("desktop:event-batch", (event) => {
111138
if (closed) return
112139
const payload = event.payload
113140
if (!payload) return
@@ -118,7 +145,7 @@ export async function connectTauriWorkspaceEvents(
118145
handleBatchPayload(payload)
119146
})
120147

121-
const unlistenStatus = await listen<DesktopEventTransportStatusPayload>("desktop:event-stream-status", (event) => {
148+
const unlistenStatus = await bridge.listen<DesktopEventTransportStatusPayload>("desktop:event-stream-status", (event) => {
122149
if (closed) return
123150
const payload = event.payload
124151
if (!payload) return
@@ -130,7 +157,7 @@ export async function connectTauriWorkspaceEvents(
130157
})
131158

132159
try {
133-
const result = await invoke<DesktopEventsStartResult>("desktop_events_start", { request: options })
160+
const result = await bridge.invoke<DesktopEventsStartResult>("desktop_events_start", { request: options })
134161
if (!result?.started) {
135162
throw new Error(result?.reason ?? "desktop event transport unavailable")
136163
}
@@ -151,7 +178,7 @@ export async function connectTauriWorkspaceEvents(
151178
closed = true
152179
unlistenBatch()
153180
unlistenStatus()
154-
void invoke("desktop_events_stop").catch((error) => {
181+
void bridge.invoke("desktop_events_stop").catch((error) => {
155182
log.warn("Failed to stop native desktop event transport", error)
156183
})
157184
},

packages/ui/src/lib/server-events.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { batch as solidBatch } from "solid-js"
22
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
33
import { serverApi } from "./api-client"
44
import { getClientIdentity } from "./client-identity"
5-
import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport"
5+
import {
6+
connectWorkspaceEvents,
7+
type WorkspaceEventConnection,
8+
type WorkspaceEventTransportStatus,
9+
} from "./event-transport"
610
import { getLogger } from "./logger"
711
import { retryWithBackoff, isRetryableError } from "./retry-utils"
812

@@ -21,6 +25,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
2125
class ServerEvents {
2226
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
2327
private openHandlers = new Set<() => void>()
28+
private statusHandlers = new Set<(status: WorkspaceEventTransportStatus) => void>()
2429
private connection: WorkspaceEventConnection | null = null
2530
private connectGeneration = 0
2631
private retryDelay = RETRY_BASE_DELAY
@@ -50,6 +55,12 @@ class ServerEvents {
5055
}
5156
this.scheduleReconnect()
5257
},
58+
onStatus: (status) => {
59+
if (generation !== this.connectGeneration) {
60+
return
61+
}
62+
this.emitTransportStatus(status)
63+
},
5364
onOpen: () => {
5465
if (generation !== this.connectGeneration) {
5566
return
@@ -105,6 +116,8 @@ class ServerEvents {
105116
this.connection = null
106117
}
107118

119+
this.emitTransportStatus("disconnected")
120+
108121
logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay })
109122
this.retryTimer = setTimeout(() => {
110123
this.retryTimer = null
@@ -140,6 +153,10 @@ class ServerEvents {
140153
})
141154
}
142155

156+
private emitTransportStatus(status: WorkspaceEventTransportStatus) {
157+
this.statusHandlers.forEach((handler) => handler(status))
158+
}
159+
143160
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
144161
if (!this.handlers.has(type)) {
145162
this.handlers.set(type, new Set())
@@ -154,6 +171,11 @@ class ServerEvents {
154171
return () => this.openHandlers.delete(handler)
155172
}
156173

174+
onTransportStatus(handler: (status: WorkspaceEventTransportStatus) => void): () => void {
175+
this.statusHandlers.add(handler)
176+
return () => this.statusHandlers.delete(handler)
177+
}
178+
157179
restart(reason = "manual restart"): void {
158180
this.retryDelay = RETRY_BASE_DELAY
159181
this.clearReconnectTimer()

packages/ui/src/lib/sse-manager.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ import type {
2424
} from "@opencode-ai/sdk/v2"
2525
import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent } from "../types/permission"
2626
import { serverEvents } from "./server-events"
27+
import type { WorkspaceEventTransportStatus } from "./event-transport"
2728
import type {
2829
BackgroundProcess,
2930
InstanceStreamEvent,
30-
InstanceStreamStatus,
3131
WorkspaceEventPayload,
3232
} from "../../../server/src/api-types"
3333
import { getLogger } from "./logger"
34+
import { deriveDisplayConnectionStatus, type ConnectionStatus } from "./connection-status"
3435

3536
const log = getLogger("sse")
3637

@@ -98,12 +99,13 @@ type SSEEvent =
9899
| ServerInstanceDisposedEvent
99100
| { type: string; properties?: Record<string, unknown> }
100101

101-
type ConnectionStatus = InstanceStreamStatus
102-
103102
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
103+
const [transportStatus, setTransportStatus] = createSignal<WorkspaceEventTransportStatus>("connecting")
104104

105105
class SSEManager {
106106
constructor() {
107+
log.info("sseManager initialized: listening for SSE disconnect and reconnect")
108+
107109
serverEvents.on("instance.eventStatus", (event) => {
108110
const payload = event as InstanceStatusPayload
109111
this.updateConnectionStatus(payload.instanceId, payload.status)
@@ -121,6 +123,11 @@ class SSEManager {
121123
this.updateConnectionStatus(payload.instanceId, "connected")
122124
this.handleEvent(payload.instanceId, payload.event as SSEEvent)
123125
})
126+
127+
serverEvents.onTransportStatus((status) => {
128+
log.info("SSE transport status changed", { status })
129+
setTransportStatus(status)
130+
})
124131
}
125132

126133
seedStatus(instanceId: string, status: ConnectionStatus) {
@@ -246,7 +253,7 @@ class SSEManager {
246253
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
247254

248255
getStatus(instanceId: string): ConnectionStatus | null {
249-
return connectionStatus().get(instanceId) ?? null
256+
return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus())
250257
}
251258

252259
getStatuses() {

0 commit comments

Comments
 (0)