@myrialabs/clopen 0.2.10 → 0.2.12

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 (54) hide show
  1. package/README.md +61 -27
  2. package/backend/chat/stream-manager.ts +114 -16
  3. package/backend/database/queries/project-queries.ts +1 -4
  4. package/backend/database/queries/session-queries.ts +36 -1
  5. package/backend/database/queries/snapshot-queries.ts +122 -0
  6. package/backend/database/utils/connection.ts +17 -11
  7. package/backend/engine/adapters/claude/stream.ts +12 -2
  8. package/backend/engine/adapters/opencode/stream.ts +37 -19
  9. package/backend/index.ts +18 -2
  10. package/backend/mcp/servers/browser-automation/browser.ts +2 -0
  11. package/backend/preview/browser/browser-mcp-control.ts +16 -0
  12. package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
  13. package/backend/preview/browser/browser-preview-service.ts +0 -34
  14. package/backend/preview/browser/browser-video-capture.ts +13 -1
  15. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  16. package/backend/preview/browser/types.ts +7 -6
  17. package/backend/snapshot/blob-store.ts +52 -72
  18. package/backend/snapshot/snapshot-service.ts +24 -0
  19. package/backend/terminal/stream-manager.ts +41 -2
  20. package/backend/ws/chat/stream.ts +14 -7
  21. package/backend/ws/engine/claude/accounts.ts +6 -8
  22. package/backend/ws/preview/browser/interact.ts +46 -50
  23. package/backend/ws/preview/browser/webcodecs.ts +24 -15
  24. package/backend/ws/projects/crud.ts +72 -7
  25. package/backend/ws/sessions/crud.ts +119 -2
  26. package/backend/ws/system/operations.ts +14 -39
  27. package/frontend/components/auth/SetupPage.svelte +1 -1
  28. package/frontend/components/chat/input/ChatInput.svelte +14 -1
  29. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  30. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  31. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  32. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  33. package/frontend/components/files/FileNode.svelte +16 -73
  34. package/frontend/components/git/CommitForm.svelte +1 -1
  35. package/frontend/components/history/HistoryModal.svelte +94 -19
  36. package/frontend/components/history/HistoryView.svelte +29 -36
  37. package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
  38. package/frontend/components/preview/browser/components/Container.svelte +18 -3
  39. package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
  40. package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
  41. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
  42. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  44. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  45. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  46. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  47. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  48. package/frontend/services/chat/chat.service.ts +111 -16
  49. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  50. package/frontend/services/notification/push.service.ts +2 -2
  51. package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
  52. package/frontend/stores/core/app.svelte.ts +10 -2
  53. package/frontend/stores/core/sessions.svelte.ts +4 -1
  54. package/package.json +2 -2
@@ -132,6 +132,7 @@ 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;
135
136
 
136
137
  // SPA navigation frame freeze — holds last frame briefly during SPA transitions
137
138
  private spaFreezeUntil = 0;
@@ -145,11 +146,32 @@ export class BrowserWebCodecsService {
145
146
  // Bandwidth logging interval
146
147
  private bandwidthLogIntervalId: ReturnType<typeof setInterval> | null = null;
147
148
 
149
+ // User gesture listener for AudioContext resume (needed after page refresh)
150
+ private userGestureHandler: (() => void) | null = null;
151
+
148
152
  constructor(projectId: string) {
149
153
  if (!projectId) {
150
154
  throw new Error('projectId is required for BrowserWebCodecsService');
151
155
  }
152
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 });
153
175
  }
154
176
 
155
177
  /**
@@ -221,8 +243,10 @@ export class BrowserWebCodecsService {
221
243
  this.setupEventListeners();
222
244
 
223
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
224
248
  debug.log('webcodecs', `[DIAG] Sending preview:browser-stream-start for session: ${sessionId}`);
225
- const response = await ws.http('preview:browser-stream-start', {}, 30000);
249
+ const response = await ws.http('preview:browser-stream-start', { tabId: sessionId }, 30000);
226
250
  debug.log('webcodecs', `[DIAG] preview:browser-stream-start response: success=${response.success}, hasOffer=${!!response.offer}, message=${response.message}`);
227
251
 
228
252
  if (!response.success) {
@@ -252,7 +276,7 @@ export class BrowserWebCodecsService {
252
276
  await new Promise(resolve => setTimeout(resolve, offerRetryDelay * attempt));
253
277
  }
254
278
  debug.log('webcodecs', `[DIAG] stream-offer attempt ${attempt + 1}/${offerMaxRetries}`);
255
- const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
279
+ const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
256
280
  if (offerResponse.offer) {
257
281
  offer = offerResponse.offer;
258
282
  break;
@@ -308,6 +332,11 @@ export class BrowserWebCodecsService {
308
332
 
309
333
  this.dataChannel.onclose = () => {
310
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
+ }
311
340
  // Trigger recovery if channel closed while we were connected
312
341
  // BUT skip recovery if we're navigating (expected behavior during page navigation)
313
342
  if (this.isConnected && !this.isCleaningUp && !this.isNavigating && this.onConnectionFailed) {
@@ -319,8 +348,8 @@ export class BrowserWebCodecsService {
319
348
  if (this.onReconnectingStart) {
320
349
  this.onReconnectingStart();
321
350
  }
322
- // Backend needs ~500ms to restart streaming with new peer
323
- // 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.
324
353
  setTimeout(() => {
325
354
  if (this.isCleaningUp) return;
326
355
  debug.log('webcodecs', '🔄 Triggering fast reconnection after navigation');
@@ -332,16 +361,22 @@ export class BrowserWebCodecsService {
332
361
  // Fallback to regular recovery if no navigation handler
333
362
  this.onConnectionFailed();
334
363
  }
335
- }, 700); // Wait 700ms for backend to restart (usually takes ~500ms)
364
+ }, 100);
336
365
  }
337
366
  };
338
367
 
339
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
+
340
375
  debug.error('webcodecs', 'DataChannel error:', error);
341
- 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}`);
342
377
  // Trigger recovery on DataChannel error
343
378
  // BUT skip recovery if we're navigating (expected behavior during page navigation)
344
- if (this.isConnected && !this.isCleaningUp && !this.isNavigating && this.onConnectionFailed) {
379
+ if (this.isConnected && !this.isNavigating && this.onConnectionFailed) {
345
380
  debug.warn('webcodecs', 'DataChannel error - triggering recovery');
346
381
  this.onConnectionFailed();
347
382
  } else if (this.isNavigating) {
@@ -363,15 +398,14 @@ export class BrowserWebCodecsService {
363
398
  sdpMLineIndex: event.candidate.sdpMLineIndex
364
399
  };
365
400
 
366
- // Backend uses active tab automatically
367
- ws.http('preview:browser-stream-ice', { candidate: candidateInit }).catch((error) => {
401
+ ws.http('preview:browser-stream-ice', { candidate: candidateInit, tabId: this.sessionId }).catch((error) => {
368
402
  debug.warn('webcodecs', 'Failed to send ICE candidate:', error);
369
403
  });
370
404
 
371
405
  // Also send loopback version for VPN compatibility (same-machine peers)
372
406
  const loopback = this.createLoopbackCandidate(candidateInit);
373
407
  if (loopback) {
374
- ws.http('preview:browser-stream-ice', { candidate: loopback }).catch(() => {});
408
+ ws.http('preview:browser-stream-ice', { candidate: loopback, tabId: this.sessionId }).catch(() => {});
375
409
  }
376
410
  }
377
411
  };
@@ -445,12 +479,12 @@ export class BrowserWebCodecsService {
445
479
  debug.log('webcodecs', 'Local description set');
446
480
 
447
481
  if (this.sessionId) {
448
- // Backend uses active tab automatically
449
482
  await ws.http('preview:browser-stream-answer', {
450
483
  answer: {
451
484
  type: answer.type,
452
485
  sdp: answer.sdp
453
- }
486
+ },
487
+ tabId: this.sessionId
454
488
  });
455
489
  }
456
490
  }
@@ -573,6 +607,11 @@ export class BrowserWebCodecsService {
573
607
  output: (frame) => this.handleDecodedVideoFrame(frame),
574
608
  error: (e) => {
575
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;
576
615
  }
577
616
  });
578
617
 
@@ -604,6 +643,8 @@ export class BrowserWebCodecsService {
604
643
  output: (frame) => this.handleDecodedAudioFrame(frame),
605
644
  error: (e) => {
606
645
  debug.error('webcodecs', 'AudioDecoder error:', e);
646
+ // Null out so next chunk triggers reinitialization.
647
+ this.audioDecoder = null;
607
648
  }
608
649
  });
609
650
 
@@ -694,6 +735,10 @@ export class BrowserWebCodecsService {
694
735
  if (this.isNavigating) {
695
736
  debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
696
737
  this.isNavigating = false;
738
+ if (this.navigationSafetyTimeout) {
739
+ clearTimeout(this.navigationSafetyTimeout);
740
+ this.navigationSafetyTimeout = null;
741
+ }
697
742
  }
698
743
  }
699
744
 
@@ -737,6 +782,10 @@ export class BrowserWebCodecsService {
737
782
  this.isRenderingFrame = false;
738
783
 
739
784
  if (!this.pendingFrame || this.isCleaningUp || !this.canvas || !this.ctx) {
785
+ if (this.pendingFrame) {
786
+ this.pendingFrame.close();
787
+ this.pendingFrame = null;
788
+ }
740
789
  return;
741
790
  }
742
791
 
@@ -746,15 +795,15 @@ export class BrowserWebCodecsService {
746
795
  const frameTimestamp = (this.pendingFrame.timestamp - this.firstFrameTimestamp) / 1000; // convert to ms
747
796
  const elapsedTime = timestamp - this.startTime;
748
797
 
749
- // Check if we're ahead of schedule (frame came too early)
750
- // If so, we might want to delay rendering, but for low-latency
751
- // we render immediately to minimize lag
752
798
  const timeDrift = elapsedTime - frameTimestamp;
753
799
 
754
- // Only log significant drift (for debugging)
755
- if (Math.abs(timeDrift) > 100) {
756
- // Drift more than 100ms is significant
757
- 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;
758
807
  }
759
808
 
760
809
  // Render to canvas with optimal settings
@@ -902,6 +951,24 @@ export class BrowserWebCodecsService {
902
951
  debug.log('webcodecs', '🔄 Pre-emptive reconnecting state on navigation complete');
903
952
  this.onReconnectingStart();
904
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);
905
972
  }
906
973
  });
907
974
 
@@ -1097,7 +1164,7 @@ export class BrowserWebCodecsService {
1097
1164
  await this.createPeerConnection();
1098
1165
 
1099
1166
  // Get offer from backend's existing peer (don't start new streaming)
1100
- const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
1167
+ const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
1101
1168
  if (offerResponse.offer) {
1102
1169
  await this.handleOffer({
1103
1170
  type: offerResponse.offer.type as RTCSdpType,
@@ -1140,13 +1207,19 @@ export class BrowserWebCodecsService {
1140
1207
  }
1141
1208
 
1142
1209
  // Cleanup WebSocket listeners
1143
- 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
+ }
1144
1215
  this.wsCleanupFunctions = [];
1145
1216
 
1146
- // 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.
1147
1220
  if (this.videoDecoder) {
1148
1221
  try {
1149
- await this.videoDecoder.flush();
1222
+ this.videoDecoder.reset();
1150
1223
  this.videoDecoder.close();
1151
1224
  } catch (e) {}
1152
1225
  this.videoDecoder = null;
@@ -1154,7 +1227,7 @@ export class BrowserWebCodecsService {
1154
1227
 
1155
1228
  if (this.audioDecoder) {
1156
1229
  try {
1157
- await this.audioDecoder.flush();
1230
+ this.audioDecoder.reset();
1158
1231
  this.audioDecoder.close();
1159
1232
  } catch (e) {}
1160
1233
  this.audioDecoder = null;
@@ -1227,22 +1300,22 @@ export class BrowserWebCodecsService {
1227
1300
  }
1228
1301
 
1229
1302
  // Cleanup WebSocket listeners
1230
- 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
+ }
1231
1308
  this.wsCleanupFunctions = [];
1232
1309
 
1233
- // Notify server
1310
+ // Notify server with explicit tabId (fire-and-forget for speed during rapid switching)
1234
1311
  if (this.sessionId) {
1235
- try {
1236
- await ws.http('preview:browser-stream-stop', {});
1237
- } catch (error) {
1238
- debug.warn('webcodecs', 'Failed to notify server:', error);
1239
- }
1312
+ ws.http('preview:browser-stream-stop', { tabId: this.sessionId }).catch(() => {});
1240
1313
  }
1241
1314
 
1242
- // Close decoders
1315
+ // Close decoders immediately (reset + close, no flush for speed)
1243
1316
  if (this.videoDecoder) {
1244
1317
  try {
1245
- await this.videoDecoder.flush();
1318
+ this.videoDecoder.reset();
1246
1319
  this.videoDecoder.close();
1247
1320
  } catch (e) {}
1248
1321
  this.videoDecoder = null;
@@ -1250,17 +1323,16 @@ export class BrowserWebCodecsService {
1250
1323
 
1251
1324
  if (this.audioDecoder) {
1252
1325
  try {
1253
- await this.audioDecoder.flush();
1326
+ this.audioDecoder.reset();
1254
1327
  this.audioDecoder.close();
1255
1328
  } catch (e) {}
1256
1329
  this.audioDecoder = null;
1257
1330
  }
1258
1331
 
1259
- // Close audio context
1260
- if (this.audioContext && this.audioContext.state !== 'closed') {
1261
- await this.audioContext.close().catch(() => {});
1262
- this.audioContext = null;
1263
- }
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.
1264
1336
 
1265
1337
  // Close data channel
1266
1338
  if (this.dataChannel) {
@@ -1330,6 +1402,10 @@ export class BrowserWebCodecsService {
1330
1402
 
1331
1403
  // Reset navigation state
1332
1404
  this.isNavigating = false;
1405
+ if (this.navigationSafetyTimeout) {
1406
+ clearTimeout(this.navigationSafetyTimeout);
1407
+ this.navigationSafetyTimeout = null;
1408
+ }
1333
1409
 
1334
1410
  if (this.onConnectionChange) {
1335
1411
  this.onConnectionChange(false);
@@ -1359,12 +1435,16 @@ export class BrowserWebCodecsService {
1359
1435
  this.pendingFrame = null;
1360
1436
  }
1361
1437
 
1362
- 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
+ }
1363
1443
  this.wsCleanupFunctions = [];
1364
1444
 
1365
1445
  if (this.videoDecoder) {
1366
1446
  try {
1367
- await this.videoDecoder.flush();
1447
+ this.videoDecoder.reset();
1368
1448
  this.videoDecoder.close();
1369
1449
  } catch (e) {}
1370
1450
  this.videoDecoder = null;
@@ -1372,16 +1452,13 @@ export class BrowserWebCodecsService {
1372
1452
 
1373
1453
  if (this.audioDecoder) {
1374
1454
  try {
1375
- await this.audioDecoder.flush();
1455
+ this.audioDecoder.reset();
1376
1456
  this.audioDecoder.close();
1377
1457
  } catch (e) {}
1378
1458
  this.audioDecoder = null;
1379
1459
  }
1380
1460
 
1381
- if (this.audioContext && this.audioContext.state !== 'closed') {
1382
- await this.audioContext.close().catch(() => {});
1383
- this.audioContext = null;
1384
- }
1461
+ // Keep AudioContext alive same rationale as cleanup()
1385
1462
 
1386
1463
  if (this.dataChannel) {
1387
1464
  this.dataChannel.close();
@@ -1396,6 +1473,10 @@ export class BrowserWebCodecsService {
1396
1473
  this.isConnected = false;
1397
1474
  this.sessionId = null;
1398
1475
 
1476
+ // Reset stats (prevent stale firstFrameRendered from skipping timing reset)
1477
+ this.stats.firstFrameRendered = false;
1478
+ this.stats.isConnected = false;
1479
+
1399
1480
  // Reset audio playback state
1400
1481
  this.nextAudioPlayTime = 0;
1401
1482
  this.audioBufferQueue = [];
@@ -1417,6 +1498,12 @@ export class BrowserWebCodecsService {
1417
1498
  this.lastVideoTimestamp = 0;
1418
1499
  this.lastVideoRealTime = 0;
1419
1500
 
1501
+ // Clear navigation safety timeout
1502
+ if (this.navigationSafetyTimeout) {
1503
+ clearTimeout(this.navigationSafetyTimeout);
1504
+ this.navigationSafetyTimeout = null;
1505
+ }
1506
+
1420
1507
  this.isCleaningUp = false;
1421
1508
  }
1422
1509
 
@@ -1444,6 +1531,24 @@ export class BrowserWebCodecsService {
1444
1531
  return this.isConnected;
1445
1532
  }
1446
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
+
1447
1552
  // Event handlers
1448
1553
  setConnectionChangeHandler(handler: (connected: boolean) => void): void {
1449
1554
  this.onConnectionChange = handler;
@@ -1493,6 +1598,11 @@ export class BrowserWebCodecsService {
1493
1598
  */
1494
1599
  setNavigating(navigating: boolean): void {
1495
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
+ }
1496
1606
  debug.log('webcodecs', `Navigation state set: ${navigating}`);
1497
1607
  }
1498
1608
 
@@ -1508,6 +1618,13 @@ export class BrowserWebCodecsService {
1508
1618
  */
1509
1619
  destroy(): void {
1510
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
+
1511
1628
  this.onConnectionChange = null;
1512
1629
  this.onConnectionFailed = null;
1513
1630
  this.onNavigationReconnect = null;
@@ -1515,5 +1632,12 @@ export class BrowserWebCodecsService {
1515
1632
  this.onError = null;
1516
1633
  this.onStats = null;
1517
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
+ }
1518
1642
  }
1519
1643
  }
@@ -129,10 +129,18 @@ export function syncGlobalStateFromSession(sessionId: string): void {
129
129
  }
130
130
 
131
131
  /**
132
- * Remove a session's process state entry (e.g. when deleting a session).
132
+ * Remove all app-level state for a deleted session
133
+ * (process state, unread status, etc.)
133
134
  */
134
- export function clearSessionProcessState(sessionId: string): void {
135
+ export function clearSessionState(sessionId: string): void {
135
136
  delete appState.sessionStates[sessionId];
137
+
138
+ if (appState.unreadSessions.has(sessionId)) {
139
+ const next = new Map(appState.unreadSessions);
140
+ next.delete(sessionId);
141
+ appState.unreadSessions = next;
142
+ persistUnreadSessions();
143
+ }
136
144
  }
137
145
 
138
146
  // ========================================
@@ -13,7 +13,7 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
13
  import ws, { onWsReconnect } from '$frontend/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/stores/ui/edit-mode.svelte';
16
- import { markSessionUnread, markSessionRead, appState } from '$frontend/stores/core/app.svelte';
16
+ import { markSessionUnread, markSessionRead, clearSessionState, appState } from '$frontend/stores/core/app.svelte';
17
17
  import { debug } from '$shared/utils/logger';
18
18
 
19
19
  interface SessionState {
@@ -164,6 +164,9 @@ export function removeSession(sessionId: string) {
164
164
  sessionState.sessions.splice(index, 1);
165
165
  }
166
166
 
167
+ // Clear all app-level state for this session (unread, process state)
168
+ clearSessionState(sessionId);
169
+
167
170
  // Clear current session if it's the one being removed
168
171
  if (sessionState.currentSession?.id === sessionId) {
169
172
  sessionState.currentSession = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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
  },