@@ -27,6 +27,8 @@ import type {
2727 ChatListChangeCallback ,
2828 ChatSummary ,
2929 MCPServerUpdatedParams ,
30+ ReconnectionCallback ,
31+ ReconnectionState ,
3032 SessionConfig ,
3133 SessionState ,
3234 SSEChatStatusPayload ,
@@ -38,6 +40,11 @@ import type {
3840/** Timeout for the initial SSE handshake (session:connected). */
3941const SSE_CONNECT_TIMEOUT_MS = 15_000 ;
4042
43+ /** Base delay between reconnection attempts (exponential backoff). */
44+ const RECONNECT_BASE_DELAY_MS = 1_000 ;
45+ /** Maximum delay between reconnection attempts. */
46+ const RECONNECT_MAX_DELAY_MS = 15_000 ;
47+
4148export class WebBridge {
4249 private api : EcaRemoteApi ;
4350 private sse : SSEClient | null = null ;
@@ -47,6 +54,14 @@ export class WebBridge {
4754 private outboundListener : ( ( e : Event ) => void ) | null = null ;
4855 private mcpServers : MCPServerUpdatedParams [ ] = [ ] ;
4956
57+ // --- Reconnection state ---
58+ private reconnecting = false ;
59+ private reconnectAttempt = 0 ;
60+ private reconnectTimer : ReturnType < typeof setTimeout > | null = null ;
61+ private onReconnectionChange : ReconnectionCallback | null = null ;
62+ /** Set to true once the bridge has successfully connected at least once. */
63+ private hasConnectedOnce = false ;
64+
5065 /**
5166 * Lightweight chat index exposed to the React shell for the sidebar.
5267 * Kept in sync as chat events flow through the bridge.
@@ -106,13 +121,15 @@ export class WebBridge {
106121 await this . connectSSE ( ) ;
107122 if ( this . disposed ) return ;
108123
124+ this . hasConnectedOnce = true ;
109125 this . registerOutboundHandler ( ) ;
110126 this . registerTransport ( ) ;
111127 }
112128
113129 disconnect ( ) : void {
114130 this . disposed = true ;
115131 this . connected = false ;
132+ this . cleanUpReconnect ( ) ;
116133 this . sse ?. disconnect ( ) ;
117134 this . sse = null ;
118135 window . __ecaWebTransport = undefined ;
@@ -127,6 +144,181 @@ export class WebBridge {
127144 return this . connected ;
128145 }
129146
147+ /** Whether the bridge is currently attempting to reconnect. */
148+ isReconnecting ( ) : boolean {
149+ return this . reconnecting ;
150+ }
151+
152+ /** Register a callback for reconnection state changes. */
153+ onReconnection ( cb : ReconnectionCallback ) : void {
154+ this . onReconnectionChange = cb ;
155+ }
156+
157+ // ---------------------------------------------------------------------------
158+ // Auto-reconnection
159+ // ---------------------------------------------------------------------------
160+
161+ /**
162+ * Attempt to re-establish the SSE connection with exponential backoff.
163+ *
164+ * Called automatically when an established SSE connection drops
165+ * (heartbeat timeout, stream end, network error). Does NOT fire for
166+ * initial connection failures — those are surfaced to the caller of
167+ * `connect()` directly.
168+ *
169+ * During reconnection the webview stays mounted with its full chat
170+ * history; only the live SSE stream is re-opened.
171+ */
172+ private scheduleReconnect ( ) : void {
173+ if ( this . disposed || this . reconnecting ) return ;
174+ this . reconnecting = true ;
175+ this . reconnectAttempt = 0 ;
176+ this . attemptReconnect ( ) ;
177+ }
178+
179+ private attemptReconnect ( ) : void {
180+ if ( this . disposed ) {
181+ this . cleanUpReconnect ( ) ;
182+ return ;
183+ }
184+
185+ this . reconnectAttempt ++ ;
186+ const delay = Math . min (
187+ RECONNECT_BASE_DELAY_MS * Math . pow ( 2 , this . reconnectAttempt - 1 ) ,
188+ RECONNECT_MAX_DELAY_MS ,
189+ ) ;
190+
191+ console . log (
192+ `[Bridge] Reconnect attempt #${ this . reconnectAttempt } in ${ delay } ms` ,
193+ ) ;
194+
195+ this . notifyReconnection ( {
196+ status : 'reconnecting' ,
197+ attempt : this . reconnectAttempt ,
198+ nextRetryMs : delay ,
199+ } ) ;
200+
201+ this . reconnectTimer = setTimeout ( async ( ) => {
202+ if ( this . disposed ) {
203+ this . cleanUpReconnect ( ) ;
204+ return ;
205+ }
206+
207+ try {
208+ // Quick health check first — fail fast if server is unreachable
209+ await this . api . health ( ) ;
210+ if ( this . disposed ) return ;
211+
212+ // Re-open SSE
213+ await this . reconnectSSE ( ) ;
214+ if ( this . disposed ) return ;
215+
216+ // Success!
217+ console . log ( `[Bridge] Reconnected after ${ this . reconnectAttempt } attempt(s)` ) ;
218+ this . reconnecting = false ;
219+ this . reconnectAttempt = 0 ;
220+
221+ // Re-sync server state with the webview
222+ await this . syncAfterReconnect ( ) ;
223+
224+ this . notifyReconnection ( {
225+ status : 'reconnected' ,
226+ attempt : this . reconnectAttempt ,
227+ } ) ;
228+ } catch ( err ) {
229+ console . warn ( '[Bridge] Reconnect attempt failed:' , err ) ;
230+ if ( ! this . disposed ) {
231+ this . attemptReconnect ( ) ;
232+ }
233+ }
234+ } , delay ) ;
235+ }
236+
237+ /**
238+ * Re-open the SSE stream (without the full connect() ceremony).
239+ * Rejects if the handshake times out or the connection fails.
240+ */
241+ private reconnectSSE ( ) : Promise < void > {
242+ return new Promise < void > ( ( resolve , reject ) => {
243+ const timeout = setTimeout (
244+ ( ) => reject ( new Error ( 'SSE reconnect timeout' ) ) ,
245+ SSE_CONNECT_TIMEOUT_MS ,
246+ ) ;
247+
248+ // Disconnect old SSE if it still exists
249+ this . sse ?. disconnect ( ) ;
250+
251+ this . sse = new SSEClient (
252+ this . api . sseUrl ( ) ,
253+ this . api . authPassword ,
254+ ( event ) => {
255+ if ( event . event === 'session:connected' && ! this . connected ) {
256+ clearTimeout ( timeout ) ;
257+ this . handleSessionConnected ( event ) ;
258+ this . connected = true ;
259+ resolve ( ) ;
260+ } else {
261+ this . handleSSEEvent ( event ) ;
262+ }
263+ } ,
264+ ( error ) => {
265+ if ( ! this . connected ) {
266+ clearTimeout ( timeout ) ;
267+ reject ( error ) ;
268+ } else {
269+ console . error ( '[Bridge] SSE error:' , error ) ;
270+ }
271+ } ,
272+ ( ) => {
273+ console . warn ( '[Bridge] SSE disconnected' ) ;
274+ this . connected = false ;
275+ this . dispatch ( 'server/statusChanged' , 'Stopped' ) ;
276+ this . scheduleReconnect ( ) ;
277+ } ,
278+ ) ;
279+
280+ this . sse . connect ( ) . catch ( ( err ) => {
281+ clearTimeout ( timeout ) ;
282+ reject ( err ) ;
283+ } ) ;
284+ } ) ;
285+ }
286+
287+ /**
288+ * After a successful reconnect, refresh session config and re-dispatch
289+ * a "Running" status so the webview knows the server is back.
290+ */
291+ private async syncAfterReconnect ( ) : Promise < void > {
292+ if ( ! this . sessionState ) return ;
293+
294+ // Re-dispatch workspace and config in case the server restarted
295+ if ( this . sessionState . workspaceFolders ) {
296+ this . dispatch ( 'server/setWorkspaceFolders' , this . sessionState . workspaceFolders ) ;
297+ }
298+ if ( this . sessionState . config ) {
299+ this . dispatch ( 'config/updated' , this . sessionState . config ) ;
300+ }
301+ if ( this . sessionState . mcpServers ) {
302+ this . mcpServers = [ ...this . sessionState . mcpServers ] ;
303+ this . dispatch ( 'tool/serversUpdated' , this . mcpServers ) ;
304+ }
305+ this . dispatch ( 'server/setTrust' , this . sessionState . trust ?? false ) ;
306+ this . dispatch ( 'server/statusChanged' , 'Running' ) ;
307+ }
308+
309+ private notifyReconnection ( state : ReconnectionState ) : void {
310+ this . onReconnectionChange ?.( state ) ;
311+ }
312+
313+ private cleanUpReconnect ( ) : void {
314+ this . reconnecting = false ;
315+ this . reconnectAttempt = 0 ;
316+ if ( this . reconnectTimer ) {
317+ clearTimeout ( this . reconnectTimer ) ;
318+ this . reconnectTimer = null ;
319+ }
320+ }
321+
130322 // ---------------------------------------------------------------------------
131323 // Chat list API (for the sidebar)
132324 // ---------------------------------------------------------------------------
@@ -220,6 +412,11 @@ export class WebBridge {
220412 console . warn ( '[Bridge] SSE disconnected' ) ;
221413 this . connected = false ;
222414 this . dispatch ( 'server/statusChanged' , 'Stopped' ) ;
415+
416+ // Auto-reconnect if this was a previously-established connection
417+ if ( this . hasConnectedOnce && ! this . disposed ) {
418+ this . scheduleReconnect ( ) ;
419+ }
223420 } ,
224421 ) ;
225422
0 commit comments