@myrialabs/clopen 0.2.10 → 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 (26) 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 +5 -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 +31 -3
  8. package/backend/preview/browser/browser-preview-service.ts +0 -34
  9. package/backend/preview/browser/browser-video-capture.ts +13 -1
  10. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  11. package/backend/preview/browser/types.ts +7 -6
  12. package/backend/ws/preview/browser/interact.ts +46 -50
  13. package/backend/ws/preview/browser/webcodecs.ts +24 -15
  14. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  15. package/frontend/components/files/FileNode.svelte +16 -58
  16. package/frontend/components/git/CommitForm.svelte +1 -1
  17. package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
  18. package/frontend/components/preview/browser/components/Container.svelte +18 -3
  19. package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
  20. package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
  21. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
  22. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  23. package/frontend/services/chat/chat.service.ts +25 -3
  24. package/frontend/services/notification/push.service.ts +2 -2
  25. package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
  26. package/package.json +2 -2
@@ -16,7 +16,9 @@ export const streamPreviewHandler = createRouter()
16
16
  .http(
17
17
  'preview:browser-stream-start',
18
18
  {
19
- data: t.Object({}),
19
+ data: t.Object({
20
+ tabId: t.Optional(t.String())
21
+ }),
20
22
  response: t.Object({
21
23
  success: t.Boolean(),
22
24
  message: t.Optional(t.String()),
@@ -34,9 +36,10 @@ export const streamPreviewHandler = createRouter()
34
36
  // Get project-specific preview service
35
37
  const previewService = browserPreviewServiceManager.getService(projectId);
36
38
 
37
- const tab = previewService.getActiveTab();
39
+ // Use explicit tabId if provided, otherwise fall back to active tab
40
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
38
41
  if (!tab) {
39
- throw new Error('No active tab');
42
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
40
43
  }
41
44
 
42
45
  const sessionId = tab.id;
@@ -73,7 +76,9 @@ export const streamPreviewHandler = createRouter()
73
76
  .http(
74
77
  'preview:browser-stream-offer',
75
78
  {
76
- data: t.Object({}),
79
+ data: t.Object({
80
+ tabId: t.Optional(t.String())
81
+ }),
77
82
  response: t.Object({
78
83
  success: t.Boolean(),
79
84
  offer: t.Optional(
@@ -90,9 +95,9 @@ export const streamPreviewHandler = createRouter()
90
95
  // Get project-specific preview service
91
96
  const previewService = browserPreviewServiceManager.getService(projectId);
92
97
 
93
- const tab = previewService.getActiveTab();
98
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
94
99
  if (!tab) {
95
- throw new Error('No active tab');
100
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
96
101
  }
97
102
 
98
103
  const offer = await previewService.getWebCodecsOffer(tab.id);
@@ -117,7 +122,8 @@ export const streamPreviewHandler = createRouter()
117
122
  answer: t.Object({
118
123
  type: t.String(),
119
124
  sdp: t.Optional(t.String())
120
- })
125
+ }),
126
+ tabId: t.Optional(t.String())
121
127
  }),
122
128
  response: t.Object({
123
129
  success: t.Boolean()
@@ -129,9 +135,9 @@ export const streamPreviewHandler = createRouter()
129
135
  // Get project-specific preview service
130
136
  const previewService = browserPreviewServiceManager.getService(projectId);
131
137
 
132
- const tab = previewService.getActiveTab();
138
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
133
139
  if (!tab) {
134
- throw new Error('No active tab');
140
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
135
141
  }
136
142
 
137
143
  const { answer } = data;
@@ -150,7 +156,8 @@ export const streamPreviewHandler = createRouter()
150
156
  candidate: t.Optional(t.String()),
151
157
  sdpMid: t.Optional(t.Union([t.String(), t.Null()])),
152
158
  sdpMLineIndex: t.Optional(t.Union([t.Number(), t.Null()]))
153
- })
159
+ }),
160
+ tabId: t.Optional(t.String())
154
161
  }),
155
162
  response: t.Object({
156
163
  success: t.Boolean()
@@ -162,9 +169,9 @@ export const streamPreviewHandler = createRouter()
162
169
  // Get project-specific preview service
163
170
  const previewService = browserPreviewServiceManager.getService(projectId);
164
171
 
165
- const tab = previewService.getActiveTab();
172
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
166
173
  if (!tab) {
167
- throw new Error('No active tab');
174
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
168
175
  }
169
176
 
170
177
  const { candidate } = data;
@@ -178,7 +185,9 @@ export const streamPreviewHandler = createRouter()
178
185
  .http(
179
186
  'preview:browser-stream-stop',
180
187
  {
181
- data: t.Object({}),
188
+ data: t.Object({
189
+ tabId: t.Optional(t.String())
190
+ }),
182
191
  response: t.Object({
183
192
  success: t.Boolean()
184
193
  })
@@ -189,9 +198,9 @@ export const streamPreviewHandler = createRouter()
189
198
  // Get project-specific preview service
190
199
  const previewService = browserPreviewServiceManager.getService(projectId);
191
200
 
192
- const tab = previewService.getActiveTab();
201
+ const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
193
202
  if (!tab) {
194
- throw new Error('No active tab');
203
+ throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
195
204
  }
196
205
 
197
206
  await previewService.stopWebCodecsStreaming(tab.id);
@@ -48,15 +48,30 @@
48
48
  function getColorClasses(type: string) {
49
49
  switch (type) {
50
50
  case 'success':
51
- return 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200';
51
+ return 'bg-green-50 border-green-300 text-green-900 dark:bg-green-950 dark:border-green-700 dark:text-green-100';
52
52
  case 'error':
53
- return 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200';
53
+ return 'bg-red-50 border-red-300 text-red-900 dark:bg-red-950 dark:border-red-700 dark:text-red-100';
54
54
  case 'warning':
55
- return 'bg-amber-50 border-amber-200 text-amber-800 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200';
55
+ return 'bg-amber-50 border-amber-300 text-amber-900 dark:bg-amber-950 dark:border-amber-700 dark:text-amber-100';
56
56
  case 'info':
57
- return 'bg-slate-50 border-slate-200 text-slate-800 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200';
57
+ return 'bg-blue-50 border-blue-300 text-blue-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100';
58
58
  default:
59
- return 'bg-slate-50 border-slate-200 text-slate-800 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200';
59
+ return 'bg-slate-50 border-slate-300 text-slate-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100';
60
+ }
61
+ }
62
+
63
+ function getIconColorClass(type: string) {
64
+ switch (type) {
65
+ case 'success':
66
+ return 'text-green-600 dark:text-green-400';
67
+ case 'error':
68
+ return 'text-red-600 dark:text-red-400';
69
+ case 'warning':
70
+ return 'text-amber-600 dark:text-amber-400';
71
+ case 'info':
72
+ return 'text-blue-600 dark:text-blue-400';
73
+ default:
74
+ return 'text-slate-600 dark:text-slate-400';
60
75
  }
61
76
  }
62
77
  </script>
@@ -68,27 +83,27 @@
68
83
  role="alert"
69
84
  aria-live="polite"
70
85
  >
71
- <div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg p-4 {getColorClasses(notification.type)}">
86
+ <div class="border rounded-lg p-4 shadow-lg {getColorClasses(notification.type)}">
72
87
  <div class="flex items-start space-x-3">
73
- <div class="flex-shrink-0">
88
+ <div class="flex-shrink-0 {getIconColorClass(notification.type)}">
74
89
  <Icon name={getIcon(notification.type)} class="w-5 h-5" />
75
90
  </div>
76
91
 
77
92
  <div class="flex-1 min-w-0">
78
93
  <div class="flex items-center justify-between">
79
- <h4 class="font-medium text-sm">
94
+ <h4 class="font-semibold text-sm">
80
95
  {notification.title}
81
96
  </h4>
82
97
  <button
83
98
  onclick={handleDismiss}
84
- class="flex-shrink-0 ml-2 p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
99
+ class="flex flex-shrink-0 ml-2 p-1 rounded opacity-60 hover:opacity-100 transition-opacity"
85
100
  aria-label="Dismiss notification"
86
101
  >
87
102
  <Icon name="lucide:x" class="w-4 h-4" />
88
103
  </button>
89
104
  </div>
90
105
 
91
- <p class="text-sm opacity-90 mt-1">
106
+ <p class="text-sm opacity-80 mt-1">
92
107
  {notification.message}
93
108
  </p>
94
109
 
@@ -100,7 +115,7 @@
100
115
  action.action();
101
116
  handleDismiss();
102
117
  }}
103
- class="text-xs font-medium px-3 py-1 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-md transition-colors"
118
+ class="text-xs font-medium px-3 py-1 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 rounded-md transition-colors"
104
119
  >
105
120
  {action.label}
106
121
  </button>
@@ -62,50 +62,29 @@
62
62
 
63
63
  let nodeElement: HTMLDivElement;
64
64
  let menuButtonElement: HTMLButtonElement;
65
- let showAbove = $state(false);
66
-
67
- // Context menu positioning
68
- let menuOpenedViaContextMenu = $state(false);
69
- let contextMenuX = $state(0);
70
- let contextMenuY = $state(0);
71
-
72
- function checkMenuPosition() {
73
- if (!menuButtonElement) return;
74
-
75
- const rect = menuButtonElement.getBoundingClientRect();
76
- const dockContainer = nodeElement?.closest('.overflow-auto');
77
-
78
- if (!dockContainer) {
79
- // Fallback ke viewport jika tidak ada container
80
- const viewportHeight = window.innerHeight;
81
- const menuHeight = 100;
82
- showAbove = rect.bottom + menuHeight > viewportHeight && rect.top > menuHeight;
83
- return;
84
- }
85
-
86
- const dockRect = dockContainer.getBoundingClientRect();
87
- const menuHeight = 100; // Estimasi tinggi menu dropdown
88
-
89
- // Hitung ruang yang tersedia di bawah dan di atas dalam dock container
90
- const spaceBelow = dockRect.bottom - rect.bottom;
91
- const spaceAbove = rect.top - dockRect.top;
92
-
93
- // Jika tidak cukup ruang di bawah untuk menu dan ada cukup ruang di atas, tampilkan di atas
94
- showAbove = spaceBelow < menuHeight && spaceAbove > menuHeight;
65
+ let menuStyle = $state('');
66
+
67
+ function computeMenuStyle(x: number, y: number, alignRight: boolean): string {
68
+ const menuHeight = 200;
69
+ const isAbove = y + menuHeight > window.innerHeight && y > menuHeight;
70
+ const verticalStyle = isAbove
71
+ ? `bottom: ${window.innerHeight - y}px;`
72
+ : `top: ${y}px;`;
73
+ const horizontalStyle = alignRight ? `right: ${x}px;` : `left: ${x}px;`;
74
+ return `${horizontalStyle} ${verticalStyle}`;
95
75
  }
96
76
 
97
77
  function toggleMenu(event: Event) {
98
78
  event.stopPropagation();
99
79
  if (!isMenuOpen) {
100
- checkMenuPosition();
101
- menuOpenedViaContextMenu = false; // Opened via button click
80
+ const rect = menuButtonElement.getBoundingClientRect();
81
+ menuStyle = computeMenuStyle(window.innerWidth - rect.right, rect.bottom, true);
102
82
  }
103
83
  onMenuToggle?.(file.path);
104
84
  }
105
85
 
106
86
  function closeMenu() {
107
- onMenuToggle?.(file.path); // Toggle to close
108
- menuOpenedViaContextMenu = false;
87
+ onMenuToggle?.(file.path);
109
88
  }
110
89
 
111
90
  function getDisplayIcon(fileName: string, isDirectory: boolean): IconName {
@@ -127,32 +106,11 @@
127
106
  function handleContextMenu(event: MouseEvent) {
128
107
  event.preventDefault();
129
108
  if (!isMenuOpen) {
130
- // Save mouse position for context menu positioning
131
- contextMenuX = event.clientX;
132
- contextMenuY = event.clientY;
133
- menuOpenedViaContextMenu = true;
134
- // Check position based on mouse Y relative to dock container
135
- checkContextMenuPosition(event.clientY);
109
+ menuStyle = computeMenuStyle(event.clientX, event.clientY, false);
136
110
  }
137
111
  onMenuToggle?.(file.path);
138
112
  }
139
113
 
140
- function checkContextMenuPosition(mouseY: number) {
141
- const dockContainer = nodeElement?.closest('.overflow-auto');
142
- const menuHeight = 100;
143
-
144
- if (!dockContainer) {
145
- const viewportHeight = window.innerHeight;
146
- showAbove = mouseY + menuHeight > viewportHeight && mouseY > menuHeight;
147
- return;
148
- }
149
-
150
- const dockRect = dockContainer.getBoundingClientRect();
151
- const spaceBelow = dockRect.bottom - mouseY;
152
- const spaceAbove = mouseY - dockRect.top;
153
- showAbove = spaceBelow < menuHeight && spaceAbove > menuHeight;
154
- }
155
-
156
114
  function handleAction(action: string, event: Event) {
157
115
  event.stopPropagation();
158
116
  onAction?.(action, file);
@@ -250,8 +208,8 @@
250
208
  <div
251
209
  role="menu"
252
210
  tabindex="-1"
253
- class="{menuOpenedViaContextMenu ? 'fixed' : 'absolute right-0'} {showAbove && !menuOpenedViaContextMenu ? 'bottom-full -mb-5' : !menuOpenedViaContextMenu ? 'top-full -mt-5' : ''} bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-1 w-44 max-h-80 overflow-y-auto z-50 shadow-lg"
254
- style={menuOpenedViaContextMenu ? `left: ${contextMenuX}px; ${showAbove ? `bottom: ${window.innerHeight - contextMenuY}px;` : `top: ${contextMenuY}px;`}` : ''}
211
+ class="fixed bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-1 w-44 max-h-80 overflow-y-auto z-50 shadow-lg"
212
+ style={menuStyle}
255
213
  onclick={(e) => e.stopPropagation()}
256
214
  >
257
215
  <!-- New File & New Folder (hanya untuk directory) -->
@@ -77,7 +77,7 @@
77
77
 
78
78
  <div class="px-2 py-2">
79
79
  <div class="flex flex-col gap-1.5">
80
- <div class="relative">
80
+ <div class="flex relative">
81
81
  <textarea
82
82
  bind:this={textareaEl}
83
83
  bind:value={commitMessage}
@@ -37,6 +37,12 @@
37
37
  let isStartingStream = false; // Prevent concurrent start attempts
38
38
  let lastStartRequestId: string | null = null; // Track the last start request to prevent duplicates
39
39
 
40
+ // Generation counter: increments on every session change (tab switch).
41
+ // Async operations (startStreaming, recovery) capture the current generation
42
+ // and bail out if it has changed, preventing stale operations from corrupting
43
+ // the new tab's state.
44
+ let streamingGeneration = 0;
45
+
40
46
  let canvasElement = $state<HTMLCanvasElement | undefined>();
41
47
  let setupCanvasTimeout: ReturnType<typeof setTimeout> | undefined;
42
48
 
@@ -100,6 +106,31 @@
100
106
  lastProjectId = currentProjectId;
101
107
  });
102
108
 
109
+ // Track session changes to reset stale state and increment generation counter.
110
+ // This runs BEFORE the streaming $effect, ensuring isReconnecting from the old
111
+ // tab doesn't leak into the new tab and that stale async operations bail out.
112
+ let lastTrackedSessionId: string | null = null;
113
+ $effect(() => {
114
+ const currentSessionId = sessionId;
115
+ if (currentSessionId !== lastTrackedSessionId) {
116
+ if (lastTrackedSessionId !== null) {
117
+ // Session actually changed (tab switch) — not initial mount
118
+ streamingGeneration++;
119
+ debug.log('webcodecs', `Session changed ${lastTrackedSessionId} → ${currentSessionId}, generation=${streamingGeneration}`);
120
+
121
+ // Reset states that belong to the old tab
122
+ if (isReconnecting) {
123
+ isReconnecting = false;
124
+ }
125
+ if (isNavigating) {
126
+ isNavigating = false;
127
+ }
128
+ lastStartRequestId = null; // Allow new start request for new session
129
+ }
130
+ lastTrackedSessionId = currentSessionId;
131
+ }
132
+ });
133
+
103
134
  // Sync navigation state with webCodecsService
104
135
  // This prevents recovery when DataChannel closes during navigation
105
136
  $effect(() => {
@@ -425,32 +456,27 @@
425
456
 
426
457
  // Start WebCodecs streaming
427
458
  async function startStreaming() {
428
- debug.log('webcodecs', `[DIAG] startStreaming() called: sessionId=${sessionId}, canvasElement=${!!canvasElement}, isStartingStream=${isStartingStream}, isWebCodecsActive=${isWebCodecsActive}, activeStreamingSessionId=${activeStreamingSessionId}, lastStartRequestId=${lastStartRequestId}`);
459
+ debug.log('webcodecs', `startStreaming() called: sessionId=${sessionId}, generation=${streamingGeneration}`);
429
460
 
430
461
  if (!sessionId || !canvasElement) {
431
- debug.log('webcodecs', `[DIAG] startStreaming() early exit: missing sessionId=${!sessionId} or canvasElement=${!canvasElement}`);
432
462
  return;
433
463
  }
434
464
 
435
465
  // Prevent concurrent start attempts
436
466
  if (isStartingStream) {
437
- debug.log('webcodecs', '[DIAG] startStreaming() skipped: already starting stream');
467
+ debug.log('webcodecs', 'startStreaming() skipped: already starting stream');
438
468
  return;
439
469
  }
440
470
 
441
471
  // If already streaming same session, skip
442
472
  if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
443
- debug.log('webcodecs', '[DIAG] startStreaming() skipped: already streaming same session');
473
+ debug.log('webcodecs', 'startStreaming() skipped: already streaming same session');
444
474
  return;
445
475
  }
446
476
 
447
- // Prevent duplicate requests for same session
448
- const requestId = `${sessionId}-${Date.now()}`;
449
- if (lastStartRequestId && lastStartRequestId.startsWith(sessionId)) {
450
- debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
451
- return;
452
- }
453
- lastStartRequestId = requestId;
477
+ // Capture current generation if it changes during async operations,
478
+ // it means the user switched tabs and this operation is stale
479
+ const myGeneration = streamingGeneration;
454
480
 
455
481
  isStartingStream = true;
456
482
  isStreamStarting = true; // Show loading overlay
@@ -464,10 +490,15 @@
464
490
  if (isWebCodecsActive && activeStreamingSessionId !== sessionId) {
465
491
  debug.log('webcodecs', `Session mismatch (active: ${activeStreamingSessionId}, requested: ${sessionId}), stopping old stream first`);
466
492
  await stopStreaming();
467
- // Small delay to ensure cleanup is complete
468
493
  await new Promise(resolve => setTimeout(resolve, 100));
469
494
  }
470
495
 
496
+ // Bail out if tab switched during cleanup
497
+ if (myGeneration !== streamingGeneration) {
498
+ debug.log('webcodecs', `Stale startStreaming (gen ${myGeneration} != ${streamingGeneration}), aborting`);
499
+ return;
500
+ }
501
+
471
502
  // Create WebCodecs service if not exists
472
503
  if (!webCodecsService) {
473
504
  if (!projectId) {
@@ -480,7 +511,11 @@
480
511
  // Setup error handler
481
512
  webCodecsService.setErrorHandler((error: Error) => {
482
513
  debug.error('webcodecs', 'Error:', error);
483
- isStartingStream = false;
514
+ // NOTE: do NOT reset isStartingStream here.
515
+ // This handler fires from inside webCodecsService.startStreaming (before it returns false).
516
+ // Canvas.svelte's startStreaming retry loop is still running with isStartingStream=true.
517
+ // Resetting it here releases the concurrency guard prematurely, causing multiple
518
+ // concurrent streaming sessions to start (each triggering the streaming $effect).
484
519
  connectionFailed = true;
485
520
  });
486
521
 
@@ -555,19 +590,39 @@
555
590
  const retryDelay = 300;
556
591
 
557
592
  while (!success && retries < maxRetries) {
593
+ // Check generation before each attempt
594
+ if (myGeneration !== streamingGeneration) {
595
+ debug.log('webcodecs', `Stale startStreaming retry (gen ${myGeneration} != ${streamingGeneration}), aborting`);
596
+ break;
597
+ }
598
+
558
599
  try {
600
+ // Guard: webCodecsService can be destroyed by a concurrent tab/project switch
601
+ if (!webCodecsService) {
602
+ debug.warn('webcodecs', 'webCodecsService became null during startStreaming, aborting');
603
+ break;
604
+ }
605
+
559
606
  success = await webCodecsService.startStreaming(sessionId, canvasElement);
607
+
608
+ // Check generation after async operation
609
+ if (myGeneration !== streamingGeneration) {
610
+ debug.log('webcodecs', `Tab switched during startStreaming (gen ${myGeneration} != ${streamingGeneration}), discarding result`);
611
+ if (success && webCodecsService) {
612
+ await webCodecsService.stopStreaming();
613
+ }
614
+ break;
615
+ }
616
+
560
617
  if (success) {
561
618
  isWebCodecsActive = true;
562
619
  isConnected = true;
563
620
  activeStreamingSessionId = sessionId;
564
- consecutiveFailures = 0; // Reset failure counter on success
565
- startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
566
- hasRestoredSnapshot = false; // Reset after using
621
+ consecutiveFailures = 0;
622
+ startHealthCheck(hasRestoredSnapshot);
623
+ hasRestoredSnapshot = false;
567
624
  debug.log('webcodecs', 'Streaming started successfully');
568
625
  } else {
569
- // Service handles errors internally and returns false.
570
- // Retry after a delay — the peer/offer may need more time to initialize.
571
626
  retries++;
572
627
  if (retries < maxRetries) {
573
628
  debug.warn('webcodecs', `Streaming start returned false, retrying in ${retryDelay * retries}ms (${retries}/${maxRetries})`);
@@ -579,7 +634,6 @@
579
634
  }
580
635
  break;
581
636
  } catch (error: any) {
582
- // This block only runs if the service unexpectedly throws.
583
637
  const isRetriable = error?.message?.includes('not found') ||
584
638
  error?.message?.includes('invalid') ||
585
639
  error?.message?.includes('Failed to start') ||
@@ -764,6 +818,7 @@
764
818
  return;
765
819
  }
766
820
 
821
+ const myGeneration = streamingGeneration;
767
822
  consecutiveFailures++;
768
823
  debug.log('webcodecs', `Recovery attempt ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES} for session ${sessionId}`);
769
824
 
@@ -776,11 +831,18 @@
776
831
 
777
832
  // Stop and restart streaming
778
833
  try {
779
- isRecovering = true; // Show "Reconnecting..." overlay
780
- hasReceivedFirstFrame = false; // Reset for recovery
834
+ isRecovering = true;
835
+ hasReceivedFirstFrame = false;
781
836
  await stopStreaming();
782
- lastStartRequestId = null; // Clear to allow new start request
783
- await new Promise(resolve => setTimeout(resolve, 500)); // Wait for cleanup
837
+ lastStartRequestId = null;
838
+ await new Promise(resolve => setTimeout(resolve, 500));
839
+
840
+ // Bail out if tab switched during cleanup
841
+ if (myGeneration !== streamingGeneration) {
842
+ debug.log('webcodecs', 'Recovery aborted - tab switched during cleanup');
843
+ return;
844
+ }
845
+
784
846
  await startStreaming();
785
847
  } catch (error) {
786
848
  debug.error('webcodecs', 'Recovery failed:', error);
@@ -802,42 +864,43 @@
802
864
  return;
803
865
  }
804
866
 
805
- debug.log('webcodecs', `🚀 Fast reconnect for session ${sessionId} (reconnect only, no backend stop)`);
867
+ const myGeneration = streamingGeneration;
868
+ debug.log('webcodecs', `🚀 Fast reconnect for session ${sessionId} (gen=${myGeneration})`);
806
869
 
807
870
  try {
808
871
  isRecovering = true;
809
872
  isStartingStream = true;
810
-
811
- // Set isReconnecting to prevent loading overlay during reconnect
812
- // This ensures the last frame stays visible instead of "Loading preview..."
813
873
  isReconnecting = true;
814
874
 
815
- // Don't reset hasReceivedFirstFrame - keep showing last frame during reconnect
816
-
817
- // Use reconnectToExistingStream which does NOT stop backend streaming
818
875
  const success = await webCodecsService.reconnectToExistingStream(sessionId, canvasElement);
819
876
 
877
+ // Bail out if tab switched during reconnect
878
+ if (myGeneration !== streamingGeneration) {
879
+ debug.log('webcodecs', 'Fast reconnect aborted - tab switched');
880
+ return;
881
+ }
882
+
820
883
  if (success) {
821
884
  isWebCodecsActive = true;
822
885
  isConnected = true;
823
886
  activeStreamingSessionId = sessionId;
824
887
  consecutiveFailures = 0;
825
- startHealthCheck(true); // Skip resetting hasReceivedFirstFrame to keep overlay stable
888
+ startHealthCheck(true);
826
889
  debug.log('webcodecs', '✅ Fast reconnect successful');
827
890
  } else {
828
891
  throw new Error('Reconnect returned false');
829
892
  }
830
893
  } catch (error) {
831
894
  debug.error('webcodecs', 'Fast reconnect failed:', error);
832
- // Fall back to regular recovery on failure
833
895
  consecutiveFailures++;
834
896
  isStartingStream = false;
835
- isReconnecting = false; // Reset on failure
836
- attemptRecovery();
897
+ isReconnecting = false;
898
+ if (myGeneration === streamingGeneration) {
899
+ attemptRecovery();
900
+ }
837
901
  } finally {
838
902
  isRecovering = false;
839
903
  isStartingStream = false;
840
- // Note: isReconnecting will be reset when first frame is received
841
904
  }
842
905
  }
843
906
 
@@ -950,12 +1013,27 @@
950
1013
 
951
1014
  // Stop existing streaming first if session changed
952
1015
  // This ensures clean state before starting new stream
1016
+ const capturedGeneration = streamingGeneration;
1017
+
1018
+ // IMMEDIATELY block the old session's frames from painting onto the canvas.
1019
+ // Without this, A's DataChannel continues delivering frames for up to 30ms
1020
+ // after we clear/snapshot-restore the canvas, overwriting B's content.
1021
+ if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
1022
+ webCodecsService?.pauseRendering();
1023
+ }
1024
+
953
1025
  const doStartStreaming = async () => {
1026
+ // Bail immediately if tab already changed
1027
+ if (capturedGeneration !== streamingGeneration) return;
1028
+
954
1029
  if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
955
1030
  debug.log('webcodecs', `Session changed from ${activeStreamingSessionId} to ${sessionId}, stopping old stream first`);
956
1031
  await stopStreaming();
957
- // Wait a bit for cleanup
958
- await new Promise(resolve => setTimeout(resolve, 100));
1032
+ // Bail if tab changed during cleanup
1033
+ if (capturedGeneration !== streamingGeneration) return;
1034
+ // Short wait for backend cleanup
1035
+ await new Promise(resolve => setTimeout(resolve, 50));
1036
+ if (capturedGeneration !== streamingGeneration) return;
959
1037
  }
960
1038
  await startStreaming();
961
1039
  };
@@ -963,7 +1041,7 @@
963
1041
  // Small delay to ensure backend session is ready
964
1042
  const timeout = setTimeout(() => {
965
1043
  doStartStreaming();
966
- }, 50);
1044
+ }, 30);
967
1045
 
968
1046
  return () => clearTimeout(timeout);
969
1047
  }
@@ -993,10 +1071,9 @@
993
1071
  let lastMoveTime = 0;
994
1072
  const handleMouseMove = (e: MouseEvent) => {
995
1073
  const now = Date.now();
996
- // Low-end optimized throttle: reduced CPU usage
997
- // 32ms hover = ~30fps, 16ms drag = ~60fps
998
- const throttleMs = isDragging ? 16 : 32;
999
- if (now - lastMoveTime >= throttleMs) {
1074
+ // 32ms = ~30fps enough for smooth hover/drag while keeping CDP pipeline clear
1075
+ // for clicks and keypresses (halving the rate halves CDP queue pressure)
1076
+ if (now - lastMoveTime >= 32) {
1000
1077
  lastMoveTime = now;
1001
1078
  handleCanvasMouseMove(e, canvas);
1002
1079
  }
@@ -70,6 +70,19 @@
70
70
  let showNavigationOverlay = $state(false);
71
71
  let overlayHideTimeout: ReturnType<typeof setTimeout> | null = null;
72
72
 
73
+ // Immediately reset navigation overlay on tab switch to prevent stale overlay from old tab
74
+ let previousOverlaySessionId: string | null = null;
75
+ $effect(() => {
76
+ if (sessionId !== previousOverlaySessionId) {
77
+ previousOverlaySessionId = sessionId;
78
+ if (overlayHideTimeout) {
79
+ clearTimeout(overlayHideTimeout);
80
+ overlayHideTimeout = null;
81
+ }
82
+ showNavigationOverlay = false;
83
+ }
84
+ });
85
+
73
86
  // Debounced navigation overlay - only for user-initiated toolbar navigations
74
87
  // In-browser navigations (link clicks) only show progress bar, not this overlay
75
88
  // This makes the preview behave like a real browser
@@ -90,9 +103,11 @@
90
103
  else if (!shouldShowOverlay && showNavigationOverlay && !overlayHideTimeout) {
91
104
  overlayHideTimeout = setTimeout(() => {
92
105
  overlayHideTimeout = null;
93
- // Re-check if we should still hide
94
- const stillShouldHide = !(isNavigating || isReconnecting) || !isStreamReady;
95
- if (stillShouldHide) {
106
+ // Re-check: only isNavigating controls this overlay.
107
+ // isReconnecting is intentionally excluded it serves a different purpose
108
+ // (preventing solid loading overlay) and can stay true for a long time
109
+ // (e.g. ICE recovery), which would keep the overlay stuck indefinitely.
110
+ if (!isNavigating) {
96
111
  showNavigationOverlay = false;
97
112
  }
98
113
  }, 100); // 100ms debounce