@myrialabs/clopen 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +61 -27
  2. package/backend/chat/stream-manager.ts +11 -7
  3. package/backend/engine/adapters/opencode/stream.ts +37 -19
  4. package/backend/index.ts +17 -0
  5. package/backend/mcp/servers/browser-automation/browser.ts +2 -0
  6. package/backend/preview/browser/browser-mcp-control.ts +16 -0
  7. package/backend/preview/browser/browser-navigation-tracker.ts +219 -34
  8. package/backend/preview/browser/browser-pool.ts +1 -1
  9. package/backend/preview/browser/browser-preview-service.ts +23 -34
  10. package/backend/preview/browser/browser-tab-manager.ts +16 -1
  11. package/backend/preview/browser/browser-video-capture.ts +15 -3
  12. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +39 -4
  14. package/backend/preview/browser/types.ts +7 -6
  15. package/backend/ws/preview/browser/interact.ts +46 -50
  16. package/backend/ws/preview/browser/webcodecs.ts +35 -15
  17. package/backend/ws/preview/index.ts +8 -0
  18. package/frontend/components/chat/input/ChatInput.svelte +3 -3
  19. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  20. package/frontend/components/files/FileNode.svelte +16 -58
  21. package/frontend/components/git/CommitForm.svelte +1 -1
  22. package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
  23. package/frontend/components/preview/browser/components/Canvas.svelte +158 -64
  24. package/frontend/components/preview/browser/components/Container.svelte +26 -8
  25. package/frontend/components/preview/browser/components/Toolbar.svelte +35 -18
  26. package/frontend/components/preview/browser/core/coordinator.svelte.ts +26 -1
  27. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +66 -9
  28. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  29. package/frontend/components/workspace/PanelHeader.svelte +8 -6
  30. package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
  31. package/frontend/services/chat/chat.service.ts +25 -3
  32. package/frontend/services/notification/push.service.ts +2 -2
  33. package/frontend/services/preview/browser/browser-webcodecs.service.ts +277 -61
  34. package/package.json +2 -2
@@ -132,6 +132,10 @@ export class BrowserWebCodecsService {
132
132
  // Navigation state - when true, DataChannel close is expected and recovery is suppressed
133
133
  private isNavigating = false;
134
134
  private navigationCleanupFn: (() => void) | null = null;
135
+ private navigationSafetyTimeout: ReturnType<typeof setTimeout> | null = null;
136
+
137
+ // SPA navigation frame freeze — holds last frame briefly during SPA transitions
138
+ private spaFreezeUntil = 0;
135
139
 
136
140
  // WebSocket cleanup
137
141
  private wsCleanupFunctions: Array<() => void> = [];
@@ -142,11 +146,32 @@ export class BrowserWebCodecsService {
142
146
  // Bandwidth logging interval
143
147
  private bandwidthLogIntervalId: ReturnType<typeof setInterval> | null = null;
144
148
 
149
+ // User gesture listener for AudioContext resume (needed after page refresh)
150
+ private userGestureHandler: (() => void) | null = null;
151
+
145
152
  constructor(projectId: string) {
146
153
  if (!projectId) {
147
154
  throw new Error('projectId is required for BrowserWebCodecsService');
148
155
  }
149
156
  this.projectId = projectId;
157
+
158
+ // Register a one-time user gesture listener to resume suspended AudioContext.
159
+ // After page refresh, AudioContext cannot resume without a user gesture.
160
+ // This listener fires on the first click/keydown and resumes it.
161
+ this.userGestureHandler = () => {
162
+ if (this.audioContext && this.audioContext.state === 'suspended') {
163
+ this.audioContext.resume().catch(() => {});
164
+ debug.log('webcodecs', 'AudioContext resumed via user gesture');
165
+ }
166
+ // Remove listeners after first successful gesture
167
+ if (this.userGestureHandler) {
168
+ document.removeEventListener('click', this.userGestureHandler);
169
+ document.removeEventListener('keydown', this.userGestureHandler);
170
+ this.userGestureHandler = null;
171
+ }
172
+ };
173
+ document.addEventListener('click', this.userGestureHandler, { once: false });
174
+ document.addEventListener('keydown', this.userGestureHandler, { once: false });
150
175
  }
151
176
 
152
177
  /**
@@ -218,8 +243,10 @@ export class BrowserWebCodecsService {
218
243
  this.setupEventListeners();
219
244
 
220
245
  // Request server to start streaming and get offer
246
+ // Send explicit tabId to ensure backend targets the correct tab
247
+ // even if user switches tabs during the async negotiation
221
248
  debug.log('webcodecs', `[DIAG] Sending preview:browser-stream-start for session: ${sessionId}`);
222
- const response = await ws.http('preview:browser-stream-start', {}, 30000);
249
+ const response = await ws.http('preview:browser-stream-start', { tabId: sessionId }, 30000);
223
250
  debug.log('webcodecs', `[DIAG] preview:browser-stream-start response: success=${response.success}, hasOffer=${!!response.offer}, message=${response.message}`);
224
251
 
225
252
  if (!response.success) {
@@ -237,16 +264,32 @@ export class BrowserWebCodecsService {
237
264
  sdp: response.offer.sdp
238
265
  });
239
266
  } else {
240
- debug.log('webcodecs', `[DIAG] No offer in stream-start response, fetching via stream-offer`);
241
- const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
242
- debug.log('webcodecs', `[DIAG] preview:browser-stream-offer response: hasOffer=${!!offerResponse.offer}`);
243
- if (offerResponse.offer) {
267
+ // Offer not ready yet peer may still be initializing. Retry with backoff.
268
+ debug.log('webcodecs', `[DIAG] No offer in stream-start response, retrying stream-offer with backoff`);
269
+
270
+ let offer: { type: string; sdp?: string } | undefined;
271
+ const offerMaxRetries = 5;
272
+ const offerRetryDelay = 200;
273
+
274
+ for (let attempt = 0; attempt < offerMaxRetries; attempt++) {
275
+ if (attempt > 0) {
276
+ await new Promise(resolve => setTimeout(resolve, offerRetryDelay * attempt));
277
+ }
278
+ debug.log('webcodecs', `[DIAG] stream-offer attempt ${attempt + 1}/${offerMaxRetries}`);
279
+ const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
280
+ if (offerResponse.offer) {
281
+ offer = offerResponse.offer;
282
+ break;
283
+ }
284
+ }
285
+
286
+ if (offer) {
244
287
  await this.handleOffer({
245
- type: offerResponse.offer.type as RTCSdpType,
246
- sdp: offerResponse.offer.sdp
288
+ type: offer.type as RTCSdpType,
289
+ sdp: offer.sdp
247
290
  });
248
291
  } else {
249
- throw new Error('No offer received from server');
292
+ throw new Error('No offer received from server after retries');
250
293
  }
251
294
  }
252
295
 
@@ -289,6 +332,11 @@ export class BrowserWebCodecsService {
289
332
 
290
333
  this.dataChannel.onclose = () => {
291
334
  debug.log('webcodecs', 'DataChannel closed');
335
+ // Clear navigation safety timeout — DataChannel closed normally
336
+ if (this.navigationSafetyTimeout) {
337
+ clearTimeout(this.navigationSafetyTimeout);
338
+ this.navigationSafetyTimeout = null;
339
+ }
292
340
  // Trigger recovery if channel closed while we were connected
293
341
  // BUT skip recovery if we're navigating (expected behavior during page navigation)
294
342
  if (this.isConnected && !this.isCleaningUp && !this.isNavigating && this.onConnectionFailed) {
@@ -300,8 +348,8 @@ export class BrowserWebCodecsService {
300
348
  if (this.onReconnectingStart) {
301
349
  this.onReconnectingStart();
302
350
  }
303
- // Backend needs ~500ms to restart streaming with new peer
304
- // Wait for backend to be ready, then trigger FAST reconnection (no delay)
351
+ // Backend is already ready (navigation-complete fires after backend setup).
352
+ // Short delay to let state settle, then trigger fast reconnection.
305
353
  setTimeout(() => {
306
354
  if (this.isCleaningUp) return;
307
355
  debug.log('webcodecs', '🔄 Triggering fast reconnection after navigation');
@@ -313,16 +361,22 @@ export class BrowserWebCodecsService {
313
361
  // Fallback to regular recovery if no navigation handler
314
362
  this.onConnectionFailed();
315
363
  }
316
- }, 700); // Wait 700ms for backend to restart (usually takes ~500ms)
364
+ }, 100);
317
365
  }
318
366
  };
319
367
 
320
368
  this.dataChannel.onerror = (error) => {
369
+ // Suppress error log during intentional cleanup (User-Initiated Abort is expected)
370
+ if (this.isCleaningUp) {
371
+ debug.log('webcodecs', 'DataChannel error during cleanup (expected, suppressed)');
372
+ return;
373
+ }
374
+
321
375
  debug.error('webcodecs', 'DataChannel error:', error);
322
- debug.log('webcodecs', `DataChannel error state: isConnected=${this.isConnected}, isCleaningUp=${this.isCleaningUp}, isNavigating=${this.isNavigating}`);
376
+ debug.log('webcodecs', `DataChannel error state: isConnected=${this.isConnected}, isNavigating=${this.isNavigating}`);
323
377
  // Trigger recovery on DataChannel error
324
378
  // BUT skip recovery if we're navigating (expected behavior during page navigation)
325
- if (this.isConnected && !this.isCleaningUp && !this.isNavigating && this.onConnectionFailed) {
379
+ if (this.isConnected && !this.isNavigating && this.onConnectionFailed) {
326
380
  debug.warn('webcodecs', 'DataChannel error - triggering recovery');
327
381
  this.onConnectionFailed();
328
382
  } else if (this.isNavigating) {
@@ -338,16 +392,21 @@ export class BrowserWebCodecsService {
338
392
  // Handle ICE candidates
339
393
  this.peerConnection.onicecandidate = (event) => {
340
394
  if (event.candidate && this.sessionId) {
341
- // Backend uses active tab automatically
342
- ws.http('preview:browser-stream-ice', {
343
- candidate: {
344
- candidate: event.candidate.candidate,
345
- sdpMid: event.candidate.sdpMid,
346
- sdpMLineIndex: event.candidate.sdpMLineIndex
347
- }
348
- }).catch((error) => {
395
+ const candidateInit: RTCIceCandidateInit = {
396
+ candidate: event.candidate.candidate,
397
+ sdpMid: event.candidate.sdpMid,
398
+ sdpMLineIndex: event.candidate.sdpMLineIndex
399
+ };
400
+
401
+ ws.http('preview:browser-stream-ice', { candidate: candidateInit, tabId: this.sessionId }).catch((error) => {
349
402
  debug.warn('webcodecs', 'Failed to send ICE candidate:', error);
350
403
  });
404
+
405
+ // Also send loopback version for VPN compatibility (same-machine peers)
406
+ const loopback = this.createLoopbackCandidate(candidateInit);
407
+ if (loopback) {
408
+ ws.http('preview:browser-stream-ice', { candidate: loopback, tabId: this.sessionId }).catch(() => {});
409
+ }
351
410
  }
352
411
  };
353
412
 
@@ -420,12 +479,12 @@ export class BrowserWebCodecsService {
420
479
  debug.log('webcodecs', 'Local description set');
421
480
 
422
481
  if (this.sessionId) {
423
- // Backend uses active tab automatically
424
482
  await ws.http('preview:browser-stream-answer', {
425
483
  answer: {
426
484
  type: answer.type,
427
485
  sdp: answer.sdp
428
- }
486
+ },
487
+ tabId: this.sessionId
429
488
  });
430
489
  }
431
490
  }
@@ -548,6 +607,11 @@ export class BrowserWebCodecsService {
548
607
  output: (frame) => this.handleDecodedVideoFrame(frame),
549
608
  error: (e) => {
550
609
  debug.error('webcodecs', 'VideoDecoder error:', e);
610
+ // Null out so next keyframe triggers reinitialization.
611
+ // Without this, the decoder stays in 'closed' state but non-null,
612
+ // causing handleVideoChunk's `!this.videoDecoder` check to never fire
613
+ // → all subsequent frames permanently dropped (stuck frames bug).
614
+ this.videoDecoder = null;
551
615
  }
552
616
  });
553
617
 
@@ -579,6 +643,8 @@ export class BrowserWebCodecsService {
579
643
  output: (frame) => this.handleDecodedAudioFrame(frame),
580
644
  error: (e) => {
581
645
  debug.error('webcodecs', 'AudioDecoder error:', e);
646
+ // Null out so next chunk triggers reinitialization.
647
+ this.audioDecoder = null;
582
648
  }
583
649
  });
584
650
 
@@ -624,6 +690,17 @@ export class BrowserWebCodecsService {
624
690
  return;
625
691
  }
626
692
 
693
+ // During SPA navigation freeze, skip rendering to hold the last frame
694
+ // This prevents brief white flashes during SPA page transitions
695
+ if (this.spaFreezeUntil > 0 && Date.now() < this.spaFreezeUntil) {
696
+ frame.close();
697
+ return;
698
+ }
699
+ // Auto-reset freeze after it expires
700
+ if (this.spaFreezeUntil > 0) {
701
+ this.spaFreezeUntil = 0;
702
+ }
703
+
627
704
  try {
628
705
  // Update stats
629
706
  this.stats.videoFramesDecoded++;
@@ -658,6 +735,10 @@ export class BrowserWebCodecsService {
658
735
  if (this.isNavigating) {
659
736
  debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
660
737
  this.isNavigating = false;
738
+ if (this.navigationSafetyTimeout) {
739
+ clearTimeout(this.navigationSafetyTimeout);
740
+ this.navigationSafetyTimeout = null;
741
+ }
661
742
  }
662
743
  }
663
744
 
@@ -701,6 +782,10 @@ export class BrowserWebCodecsService {
701
782
  this.isRenderingFrame = false;
702
783
 
703
784
  if (!this.pendingFrame || this.isCleaningUp || !this.canvas || !this.ctx) {
785
+ if (this.pendingFrame) {
786
+ this.pendingFrame.close();
787
+ this.pendingFrame = null;
788
+ }
704
789
  return;
705
790
  }
706
791
 
@@ -710,15 +795,15 @@ export class BrowserWebCodecsService {
710
795
  const frameTimestamp = (this.pendingFrame.timestamp - this.firstFrameTimestamp) / 1000; // convert to ms
711
796
  const elapsedTime = timestamp - this.startTime;
712
797
 
713
- // Check if we're ahead of schedule (frame came too early)
714
- // If so, we might want to delay rendering, but for low-latency
715
- // we render immediately to minimize lag
716
798
  const timeDrift = elapsedTime - frameTimestamp;
717
799
 
718
- // Only log significant drift (for debugging)
719
- if (Math.abs(timeDrift) > 100) {
720
- // Drift more than 100ms is significant
721
- debug.warn('webcodecs', `Frame timing drift: ${timeDrift.toFixed(0)}ms`);
800
+ // Auto-reset timing when drift exceeds 2 seconds.
801
+ // This handles stale timing state after reconnects or rapid tab switches
802
+ // where startTime/firstFrameTimestamp were not properly reset.
803
+ if (Math.abs(timeDrift) > 2000 && this.startTime > 0) {
804
+ debug.warn('webcodecs', `Frame timing drift reset (was ${timeDrift.toFixed(0)}ms)`);
805
+ this.startTime = timestamp;
806
+ this.firstFrameTimestamp = this.pendingFrame.timestamp;
722
807
  }
723
808
 
724
809
  // Render to canvas with optimal settings
@@ -849,9 +934,16 @@ export class BrowserWebCodecsService {
849
934
 
850
935
  const cleanupNavComplete = ws.on('preview:browser-navigation', (data) => {
851
936
  if (data.sessionId === this.sessionId) {
852
- // Keep isNavigating true for a short period to allow reconnection
853
- // Will be reset when new frames arrive or reconnection completes
854
937
  debug.log('webcodecs', `Navigation completed (direct WS) for session ${data.sessionId}`);
938
+
939
+ // If isNavigating was NOT set by navigation-loading (SPA-like case where
940
+ // framenavigated fires without a document request), set it now so the
941
+ // subsequent DataChannel close triggers fast reconnect instead of full recovery
942
+ if (!this.isNavigating) {
943
+ this.isNavigating = true;
944
+ debug.log('webcodecs', '✅ Set isNavigating=true on navigation complete (no loading event preceded)');
945
+ }
946
+
855
947
  // Signal reconnecting state IMMEDIATELY when navigation completes
856
948
  // This eliminates the gap between isNavigating=false and DataChannel close
857
949
  // ensuring the overlay stays visible continuously
@@ -859,14 +951,62 @@ export class BrowserWebCodecsService {
859
951
  debug.log('webcodecs', '🔄 Pre-emptive reconnecting state on navigation complete');
860
952
  this.onReconnectingStart();
861
953
  }
954
+
955
+ // Fast safety timeout: if DataChannel doesn't close within 100ms,
956
+ // force a full recovery (stop + start fresh stream). Sometimes
957
+ // the old WebRTC connection lingers for 10-16s before ICE timeout.
958
+ // Using onConnectionFailed triggers attemptRecovery (full stop+start),
959
+ // which is the same path as tab switching and takes ~100ms to first frame.
960
+ if (this.navigationSafetyTimeout) {
961
+ clearTimeout(this.navigationSafetyTimeout);
962
+ }
963
+ this.navigationSafetyTimeout = setTimeout(() => {
964
+ this.navigationSafetyTimeout = null;
965
+ if (this.isCleaningUp || !this.isNavigating) return;
966
+ debug.warn('webcodecs', '⏰ Navigation safety timeout - forcing full recovery');
967
+ this.isNavigating = false;
968
+ if (this.onConnectionFailed) {
969
+ this.onConnectionFailed();
970
+ }
971
+ }, 100);
972
+ }
973
+ });
974
+
975
+ // Listen for SPA navigation events (pushState/replaceState/hash changes)
976
+ // Reset isNavigating if it was set by a preceding navigation-loading event
977
+ // that the SPA router intercepted (cancelled the full navigation)
978
+ const cleanupNavSpa = ws.on('preview:browser-navigation-spa', (data) => {
979
+ if (data.sessionId === this.sessionId && this.isNavigating) {
980
+ debug.log('webcodecs', '🔄 SPA navigation received - resetting isNavigating (no stream restart needed)');
981
+ this.isNavigating = false;
862
982
  }
863
983
  });
864
984
 
865
- this.wsCleanupFunctions = [cleanupIce, cleanupState, cleanupCursor, cleanupNavLoading, cleanupNavComplete];
985
+ this.wsCleanupFunctions = [cleanupIce, cleanupState, cleanupCursor, cleanupNavLoading, cleanupNavComplete, cleanupNavSpa];
986
+ }
987
+
988
+ /**
989
+ * Create a loopback (127.0.0.1) copy of a host ICE candidate.
990
+ * Ensures WebRTC connects via loopback when VPN (e.g. Cloudflare WARP)
991
+ * interferes with host candidate connectivity between same-machine peers.
992
+ */
993
+ private createLoopbackCandidate(candidate: RTCIceCandidateInit): RTCIceCandidateInit | null {
994
+ if (!candidate.candidate) return null;
995
+ if (!candidate.candidate.includes('typ host')) return null;
996
+
997
+ const parts = candidate.candidate.split(' ');
998
+ if (parts.length < 8) return null;
999
+
1000
+ // Index 4 is the address field in ICE candidate format
1001
+ const address = parts[4];
1002
+ if (address === '127.0.0.1' || address === '::1') return null;
1003
+
1004
+ parts[4] = '127.0.0.1';
1005
+ return { ...candidate, candidate: parts.join(' ') };
866
1006
  }
867
1007
 
868
1008
  /**
869
- * Add ICE candidate
1009
+ * Add ICE candidate (+ loopback variant for VPN compatibility)
870
1010
  */
871
1011
  private async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
872
1012
  if (!this.peerConnection) return;
@@ -876,6 +1016,16 @@ export class BrowserWebCodecsService {
876
1016
  } catch (error) {
877
1017
  debug.warn('webcodecs', 'Add ICE candidate error:', error);
878
1018
  }
1019
+
1020
+ // Also try loopback version for VPN compatibility (same-machine peers)
1021
+ const loopback = this.createLoopbackCandidate(candidate);
1022
+ if (loopback) {
1023
+ try {
1024
+ await this.peerConnection.addIceCandidate(new RTCIceCandidate(loopback));
1025
+ } catch {
1026
+ // Expected to fail if loopback is not applicable
1027
+ }
1028
+ }
879
1029
  }
880
1030
 
881
1031
  /**
@@ -1014,7 +1164,7 @@ export class BrowserWebCodecsService {
1014
1164
  await this.createPeerConnection();
1015
1165
 
1016
1166
  // Get offer from backend's existing peer (don't start new streaming)
1017
- const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
1167
+ const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
1018
1168
  if (offerResponse.offer) {
1019
1169
  await this.handleOffer({
1020
1170
  type: offerResponse.offer.type as RTCSdpType,
@@ -1057,13 +1207,19 @@ export class BrowserWebCodecsService {
1057
1207
  }
1058
1208
 
1059
1209
  // Cleanup WebSocket listeners
1060
- this.wsCleanupFunctions.forEach((cleanup) => cleanup());
1210
+ try {
1211
+ this.wsCleanupFunctions.forEach((cleanup) => cleanup());
1212
+ } catch (e) {
1213
+ debug.warn('webcodecs', 'Error in ws cleanup:', e);
1214
+ }
1061
1215
  this.wsCleanupFunctions = [];
1062
1216
 
1063
- // Close decoders
1217
+ // Close decoders immediately (reset + close, no flush).
1218
+ // Flushing processes all queued frames which is slow and can fire
1219
+ // stale callbacks during rapid tab switching.
1064
1220
  if (this.videoDecoder) {
1065
1221
  try {
1066
- await this.videoDecoder.flush();
1222
+ this.videoDecoder.reset();
1067
1223
  this.videoDecoder.close();
1068
1224
  } catch (e) {}
1069
1225
  this.videoDecoder = null;
@@ -1071,7 +1227,7 @@ export class BrowserWebCodecsService {
1071
1227
 
1072
1228
  if (this.audioDecoder) {
1073
1229
  try {
1074
- await this.audioDecoder.flush();
1230
+ this.audioDecoder.reset();
1075
1231
  this.audioDecoder.close();
1076
1232
  } catch (e) {}
1077
1233
  this.audioDecoder = null;
@@ -1144,22 +1300,22 @@ export class BrowserWebCodecsService {
1144
1300
  }
1145
1301
 
1146
1302
  // Cleanup WebSocket listeners
1147
- this.wsCleanupFunctions.forEach((cleanup) => cleanup());
1303
+ try {
1304
+ this.wsCleanupFunctions.forEach((cleanup) => cleanup());
1305
+ } catch (e) {
1306
+ debug.warn('webcodecs', 'Error in ws cleanup:', e);
1307
+ }
1148
1308
  this.wsCleanupFunctions = [];
1149
1309
 
1150
- // Notify server
1310
+ // Notify server with explicit tabId (fire-and-forget for speed during rapid switching)
1151
1311
  if (this.sessionId) {
1152
- try {
1153
- await ws.http('preview:browser-stream-stop', {});
1154
- } catch (error) {
1155
- debug.warn('webcodecs', 'Failed to notify server:', error);
1156
- }
1312
+ ws.http('preview:browser-stream-stop', { tabId: this.sessionId }).catch(() => {});
1157
1313
  }
1158
1314
 
1159
- // Close decoders
1315
+ // Close decoders immediately (reset + close, no flush for speed)
1160
1316
  if (this.videoDecoder) {
1161
1317
  try {
1162
- await this.videoDecoder.flush();
1318
+ this.videoDecoder.reset();
1163
1319
  this.videoDecoder.close();
1164
1320
  } catch (e) {}
1165
1321
  this.videoDecoder = null;
@@ -1167,17 +1323,16 @@ export class BrowserWebCodecsService {
1167
1323
 
1168
1324
  if (this.audioDecoder) {
1169
1325
  try {
1170
- await this.audioDecoder.flush();
1326
+ this.audioDecoder.reset();
1171
1327
  this.audioDecoder.close();
1172
1328
  } catch (e) {}
1173
1329
  this.audioDecoder = null;
1174
1330
  }
1175
1331
 
1176
- // Close audio context
1177
- if (this.audioContext && this.audioContext.state !== 'closed') {
1178
- await this.audioContext.close().catch(() => {});
1179
- this.audioContext = null;
1180
- }
1332
+ // Keep AudioContext alive across stop/start cycles — it was created during
1333
+ // a user gesture in startStreaming() and closing it means we can't resume
1334
+ // without another user gesture. Only destroy() should close it.
1335
+ // Just reset audio playback scheduling state below.
1181
1336
 
1182
1337
  // Close data channel
1183
1338
  if (this.dataChannel) {
@@ -1247,6 +1402,10 @@ export class BrowserWebCodecsService {
1247
1402
 
1248
1403
  // Reset navigation state
1249
1404
  this.isNavigating = false;
1405
+ if (this.navigationSafetyTimeout) {
1406
+ clearTimeout(this.navigationSafetyTimeout);
1407
+ this.navigationSafetyTimeout = null;
1408
+ }
1250
1409
 
1251
1410
  if (this.onConnectionChange) {
1252
1411
  this.onConnectionChange(false);
@@ -1276,12 +1435,16 @@ export class BrowserWebCodecsService {
1276
1435
  this.pendingFrame = null;
1277
1436
  }
1278
1437
 
1279
- this.wsCleanupFunctions.forEach((cleanup) => cleanup());
1438
+ try {
1439
+ this.wsCleanupFunctions.forEach((cleanup) => cleanup());
1440
+ } catch (e) {
1441
+ debug.warn('webcodecs', 'Error in ws cleanup:', e);
1442
+ }
1280
1443
  this.wsCleanupFunctions = [];
1281
1444
 
1282
1445
  if (this.videoDecoder) {
1283
1446
  try {
1284
- await this.videoDecoder.flush();
1447
+ this.videoDecoder.reset();
1285
1448
  this.videoDecoder.close();
1286
1449
  } catch (e) {}
1287
1450
  this.videoDecoder = null;
@@ -1289,16 +1452,13 @@ export class BrowserWebCodecsService {
1289
1452
 
1290
1453
  if (this.audioDecoder) {
1291
1454
  try {
1292
- await this.audioDecoder.flush();
1455
+ this.audioDecoder.reset();
1293
1456
  this.audioDecoder.close();
1294
1457
  } catch (e) {}
1295
1458
  this.audioDecoder = null;
1296
1459
  }
1297
1460
 
1298
- if (this.audioContext && this.audioContext.state !== 'closed') {
1299
- await this.audioContext.close().catch(() => {});
1300
- this.audioContext = null;
1301
- }
1461
+ // Keep AudioContext alive same rationale as cleanup()
1302
1462
 
1303
1463
  if (this.dataChannel) {
1304
1464
  this.dataChannel.close();
@@ -1313,6 +1473,10 @@ export class BrowserWebCodecsService {
1313
1473
  this.isConnected = false;
1314
1474
  this.sessionId = null;
1315
1475
 
1476
+ // Reset stats (prevent stale firstFrameRendered from skipping timing reset)
1477
+ this.stats.firstFrameRendered = false;
1478
+ this.stats.isConnected = false;
1479
+
1316
1480
  // Reset audio playback state
1317
1481
  this.nextAudioPlayTime = 0;
1318
1482
  this.audioBufferQueue = [];
@@ -1334,6 +1498,12 @@ export class BrowserWebCodecsService {
1334
1498
  this.lastVideoTimestamp = 0;
1335
1499
  this.lastVideoRealTime = 0;
1336
1500
 
1501
+ // Clear navigation safety timeout
1502
+ if (this.navigationSafetyTimeout) {
1503
+ clearTimeout(this.navigationSafetyTimeout);
1504
+ this.navigationSafetyTimeout = null;
1505
+ }
1506
+
1337
1507
  this.isCleaningUp = false;
1338
1508
  }
1339
1509
 
@@ -1361,6 +1531,24 @@ export class BrowserWebCodecsService {
1361
1531
  return this.isConnected;
1362
1532
  }
1363
1533
 
1534
+ /**
1535
+ * Immediately block frame rendering without closing the WebRTC connection.
1536
+ * Call this as soon as a tab switch is detected to prevent the old session's
1537
+ * frames from painting onto the canvas after it has been cleared/snapshot-restored.
1538
+ * The cleanup + new connection will complete asynchronously via stopStreaming/startStreaming.
1539
+ */
1540
+ pauseRendering(): void {
1541
+ this.isCleaningUp = true;
1542
+ if (this.renderFrameId !== null) {
1543
+ cancelAnimationFrame(this.renderFrameId);
1544
+ this.renderFrameId = null;
1545
+ }
1546
+ if (this.pendingFrame) {
1547
+ this.pendingFrame.close();
1548
+ this.pendingFrame = null;
1549
+ }
1550
+ }
1551
+
1364
1552
  // Event handlers
1365
1553
  setConnectionChangeHandler(handler: (connected: boolean) => void): void {
1366
1554
  this.onConnectionChange = handler;
@@ -1382,6 +1570,15 @@ export class BrowserWebCodecsService {
1382
1570
  this.onFirstFrame = handler;
1383
1571
  }
1384
1572
 
1573
+ /**
1574
+ * Freeze frame rendering briefly during SPA navigation.
1575
+ * Holds the current canvas content to prevent white flash during
1576
+ * SPA page transitions (pushState/replaceState).
1577
+ */
1578
+ freezeForSpaNavigation(durationMs = 150): void {
1579
+ this.spaFreezeUntil = Date.now() + durationMs;
1580
+ }
1581
+
1385
1582
  setErrorHandler(handler: (error: Error) => void): void {
1386
1583
  this.onError = handler;
1387
1584
  }
@@ -1401,6 +1598,11 @@ export class BrowserWebCodecsService {
1401
1598
  */
1402
1599
  setNavigating(navigating: boolean): void {
1403
1600
  this.isNavigating = navigating;
1601
+ // Clear safety timeout when navigation state is externally reset
1602
+ if (!navigating && this.navigationSafetyTimeout) {
1603
+ clearTimeout(this.navigationSafetyTimeout);
1604
+ this.navigationSafetyTimeout = null;
1605
+ }
1404
1606
  debug.log('webcodecs', `Navigation state set: ${navigating}`);
1405
1607
  }
1406
1608
 
@@ -1416,6 +1618,13 @@ export class BrowserWebCodecsService {
1416
1618
  */
1417
1619
  destroy(): void {
1418
1620
  this.cleanup();
1621
+
1622
+ // Close AudioContext only on full destroy (not reused after this)
1623
+ if (this.audioContext && this.audioContext.state !== 'closed') {
1624
+ this.audioContext.close().catch(() => {});
1625
+ this.audioContext = null;
1626
+ }
1627
+
1419
1628
  this.onConnectionChange = null;
1420
1629
  this.onConnectionFailed = null;
1421
1630
  this.onNavigationReconnect = null;
@@ -1423,5 +1632,12 @@ export class BrowserWebCodecsService {
1423
1632
  this.onError = null;
1424
1633
  this.onStats = null;
1425
1634
  this.onCursorChange = null;
1635
+
1636
+ // Remove user gesture listener
1637
+ if (this.userGestureHandler) {
1638
+ document.removeEventListener('click', this.userGestureHandler);
1639
+ document.removeEventListener('keydown', this.userGestureHandler);
1640
+ this.userGestureHandler = null;
1641
+ }
1426
1642
  }
1427
1643
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
5
5
  "author": "Myria Labs",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@
13
13
  "type": "git",
14
14
  "url": "https://github.com/myrialabs/clopen.git"
15
15
  },
16
- "homepage": "https://github.com/myrialabs/clopen#readme",
16
+ "homepage": "https://clopen.myrialabs.dev",
17
17
  "bugs": {
18
18
  "url": "https://github.com/myrialabs/clopen/issues"
19
19
  },