@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
@@ -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}
@@ -223,6 +223,12 @@
223
223
  let previousUrl = '';
224
224
  $effect(() => {
225
225
  if (!url || url === previousUrl) return;
226
+ // Ignore browser-internal error pages (e.g. DNS failure) — they are not real URLs
227
+ // and should never trigger a new navigation attempt.
228
+ if (url.startsWith('chrome-error://') || url.startsWith('chrome://')) {
229
+ previousUrl = url;
230
+ return;
231
+ }
226
232
  if (mcpLaunchInProgress) {
227
233
  previousUrl = url;
228
234
  urlInput = url;
@@ -306,7 +312,7 @@
306
312
 
307
313
  // Initialize URL input
308
314
  $effect(() => {
309
- if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
315
+ if (url && !url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('file://')) {
310
316
  url = 'http://' + url;
311
317
  }
312
318
  if (url && !urlInput) {
@@ -319,7 +325,7 @@
319
325
  if (!urlInput.trim()) return;
320
326
 
321
327
  let processedUrl = urlInput.trim();
322
- if (!processedUrl.startsWith('http://') && !processedUrl.startsWith('https://')) {
328
+ if (!processedUrl.startsWith('http://') && !processedUrl.startsWith('https://') && !processedUrl.startsWith('file://')) {
323
329
  processedUrl = 'http://' + processedUrl;
324
330
  }
325
331
 
@@ -447,7 +453,8 @@
447
453
  },
448
454
  getSessionInfo: () => sessionInfo,
449
455
  getIsStreamReady: () => isStreamReady,
450
- getErrorMessage: () => errorMessage
456
+ getErrorMessage: () => errorMessage,
457
+ getIsMcpControlled: () => isCurrentTabMcpControlled()
451
458
  };
452
459
  </script>
453
460
 
@@ -26,6 +26,7 @@
26
26
  onStatsUpdate = $bindable<(stats: BrowserWebCodecsStreamStats | null) => void>(() => {}),
27
27
  onRequestScreencastRefresh = $bindable<() => void>(() => {}), // Called when stream is stuck
28
28
  touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
29
+ touchTarget = undefined as HTMLElement | undefined, // Container element for touch events
29
30
  onTouchCursorUpdate = $bindable<(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) => void>(() => {})
30
31
  } = $props();
31
32
 
@@ -36,6 +37,12 @@
36
37
  let isStartingStream = false; // Prevent concurrent start attempts
37
38
  let lastStartRequestId: string | null = null; // Track the last start request to prevent duplicates
38
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
+
39
46
  let canvasElement = $state<HTMLCanvasElement | undefined>();
40
47
  let setupCanvasTimeout: ReturnType<typeof setTimeout> | undefined;
41
48
 
@@ -99,6 +106,31 @@
99
106
  lastProjectId = currentProjectId;
100
107
  });
101
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
+
102
134
  // Sync navigation state with webCodecsService
103
135
  // This prevents recovery when DataChannel closes during navigation
104
136
  $effect(() => {
@@ -424,32 +456,27 @@
424
456
 
425
457
  // Start WebCodecs streaming
426
458
  async function startStreaming() {
427
- 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}`);
428
460
 
429
461
  if (!sessionId || !canvasElement) {
430
- debug.log('webcodecs', `[DIAG] startStreaming() early exit: missing sessionId=${!sessionId} or canvasElement=${!canvasElement}`);
431
462
  return;
432
463
  }
433
464
 
434
465
  // Prevent concurrent start attempts
435
466
  if (isStartingStream) {
436
- debug.log('webcodecs', '[DIAG] startStreaming() skipped: already starting stream');
467
+ debug.log('webcodecs', 'startStreaming() skipped: already starting stream');
437
468
  return;
438
469
  }
439
470
 
440
471
  // If already streaming same session, skip
441
472
  if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
442
- debug.log('webcodecs', '[DIAG] startStreaming() skipped: already streaming same session');
473
+ debug.log('webcodecs', 'startStreaming() skipped: already streaming same session');
443
474
  return;
444
475
  }
445
476
 
446
- // Prevent duplicate requests for same session
447
- const requestId = `${sessionId}-${Date.now()}`;
448
- if (lastStartRequestId && lastStartRequestId.startsWith(sessionId)) {
449
- debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
450
- return;
451
- }
452
- 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;
453
480
 
454
481
  isStartingStream = true;
455
482
  isStreamStarting = true; // Show loading overlay
@@ -463,10 +490,15 @@
463
490
  if (isWebCodecsActive && activeStreamingSessionId !== sessionId) {
464
491
  debug.log('webcodecs', `Session mismatch (active: ${activeStreamingSessionId}, requested: ${sessionId}), stopping old stream first`);
465
492
  await stopStreaming();
466
- // Small delay to ensure cleanup is complete
467
493
  await new Promise(resolve => setTimeout(resolve, 100));
468
494
  }
469
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
+
470
502
  // Create WebCodecs service if not exists
471
503
  if (!webCodecsService) {
472
504
  if (!projectId) {
@@ -479,7 +511,11 @@
479
511
  // Setup error handler
480
512
  webCodecsService.setErrorHandler((error: Error) => {
481
513
  debug.error('webcodecs', 'Error:', error);
482
- 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).
483
519
  connectionFailed = true;
484
520
  });
485
521
 
@@ -554,27 +590,50 @@
554
590
  const retryDelay = 300;
555
591
 
556
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
+
557
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
+
558
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
+
559
617
  if (success) {
560
618
  isWebCodecsActive = true;
561
619
  isConnected = true;
562
620
  activeStreamingSessionId = sessionId;
563
- consecutiveFailures = 0; // Reset failure counter on success
564
- startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
565
- hasRestoredSnapshot = false; // Reset after using
621
+ consecutiveFailures = 0;
622
+ startHealthCheck(hasRestoredSnapshot);
623
+ hasRestoredSnapshot = false;
566
624
  debug.log('webcodecs', 'Streaming started successfully');
567
625
  } else {
568
- // Service handles errors internally and returns false.
569
- // Never retry here — retrying immediately creates a stop/start loop.
570
- debug.warn('webcodecs', 'Streaming start returned false, stopping retries');
626
+ retries++;
627
+ if (retries < maxRetries) {
628
+ debug.warn('webcodecs', `Streaming start returned false, retrying in ${retryDelay * retries}ms (${retries}/${maxRetries})`);
629
+ await new Promise(resolve => setTimeout(resolve, retryDelay * retries));
630
+ continue;
631
+ }
632
+ debug.error('webcodecs', 'Streaming start failed after all retries');
633
+ break;
571
634
  }
572
- // Always break after the service returns (success or failure).
573
- // The service catches all exceptions internally, so the catch block
574
- // below never runs, making retries/retryDelay dead code anyway.
575
635
  break;
576
636
  } catch (error: any) {
577
- // This block only runs if the service unexpectedly throws.
578
637
  const isRetriable = error?.message?.includes('not found') ||
579
638
  error?.message?.includes('invalid') ||
580
639
  error?.message?.includes('Failed to start') ||
@@ -759,6 +818,7 @@
759
818
  return;
760
819
  }
761
820
 
821
+ const myGeneration = streamingGeneration;
762
822
  consecutiveFailures++;
763
823
  debug.log('webcodecs', `Recovery attempt ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES} for session ${sessionId}`);
764
824
 
@@ -771,11 +831,18 @@
771
831
 
772
832
  // Stop and restart streaming
773
833
  try {
774
- isRecovering = true; // Show "Reconnecting..." overlay
775
- hasReceivedFirstFrame = false; // Reset for recovery
834
+ isRecovering = true;
835
+ hasReceivedFirstFrame = false;
776
836
  await stopStreaming();
777
- lastStartRequestId = null; // Clear to allow new start request
778
- 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
+
779
846
  await startStreaming();
780
847
  } catch (error) {
781
848
  debug.error('webcodecs', 'Recovery failed:', error);
@@ -797,42 +864,43 @@
797
864
  return;
798
865
  }
799
866
 
800
- 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})`);
801
869
 
802
870
  try {
803
871
  isRecovering = true;
804
872
  isStartingStream = true;
805
-
806
- // Set isReconnecting to prevent loading overlay during reconnect
807
- // This ensures the last frame stays visible instead of "Loading preview..."
808
873
  isReconnecting = true;
809
874
 
810
- // Don't reset hasReceivedFirstFrame - keep showing last frame during reconnect
811
-
812
- // Use reconnectToExistingStream which does NOT stop backend streaming
813
875
  const success = await webCodecsService.reconnectToExistingStream(sessionId, canvasElement);
814
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
+
815
883
  if (success) {
816
884
  isWebCodecsActive = true;
817
885
  isConnected = true;
818
886
  activeStreamingSessionId = sessionId;
819
887
  consecutiveFailures = 0;
820
- startHealthCheck(true); // Skip resetting hasReceivedFirstFrame to keep overlay stable
888
+ startHealthCheck(true);
821
889
  debug.log('webcodecs', '✅ Fast reconnect successful');
822
890
  } else {
823
891
  throw new Error('Reconnect returned false');
824
892
  }
825
893
  } catch (error) {
826
894
  debug.error('webcodecs', 'Fast reconnect failed:', error);
827
- // Fall back to regular recovery on failure
828
895
  consecutiveFailures++;
829
896
  isStartingStream = false;
830
- isReconnecting = false; // Reset on failure
831
- attemptRecovery();
897
+ isReconnecting = false;
898
+ if (myGeneration === streamingGeneration) {
899
+ attemptRecovery();
900
+ }
832
901
  } finally {
833
902
  isRecovering = false;
834
903
  isStartingStream = false;
835
- // Note: isReconnecting will be reset when first frame is received
836
904
  }
837
905
  }
838
906
 
@@ -945,12 +1013,27 @@
945
1013
 
946
1014
  // Stop existing streaming first if session changed
947
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
+
948
1025
  const doStartStreaming = async () => {
1026
+ // Bail immediately if tab already changed
1027
+ if (capturedGeneration !== streamingGeneration) return;
1028
+
949
1029
  if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
950
1030
  debug.log('webcodecs', `Session changed from ${activeStreamingSessionId} to ${sessionId}, stopping old stream first`);
951
1031
  await stopStreaming();
952
- // Wait a bit for cleanup
953
- 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;
954
1037
  }
955
1038
  await startStreaming();
956
1039
  };
@@ -958,7 +1041,7 @@
958
1041
  // Small delay to ensure backend session is ready
959
1042
  const timeout = setTimeout(() => {
960
1043
  doStartStreaming();
961
- }, 50);
1044
+ }, 30);
962
1045
 
963
1046
  return () => clearTimeout(timeout);
964
1047
  }
@@ -988,10 +1071,9 @@
988
1071
  let lastMoveTime = 0;
989
1072
  const handleMouseMove = (e: MouseEvent) => {
990
1073
  const now = Date.now();
991
- // Low-end optimized throttle: reduced CPU usage
992
- // 32ms hover = ~30fps, 16ms drag = ~60fps
993
- const throttleMs = isDragging ? 16 : 32;
994
- 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) {
995
1077
  lastMoveTime = now;
996
1078
  handleCanvasMouseMove(e, canvas);
997
1079
  }
@@ -1002,20 +1084,6 @@
1002
1084
  canvas.focus();
1003
1085
  });
1004
1086
 
1005
- const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
1006
- let lastTouchMoveTime = 0;
1007
- const touchMoveHandler = (e: TouchEvent) => {
1008
- const now = Date.now();
1009
- if (now - lastTouchMoveTime >= 16) {
1010
- lastTouchMoveTime = now;
1011
- handleTouchMove(e, canvas);
1012
- }
1013
- };
1014
- const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
1015
-
1016
- canvas.addEventListener('touchstart', touchStartHandler, { passive: false });
1017
- canvas.addEventListener('touchmove', touchMoveHandler, { passive: false });
1018
- canvas.addEventListener('touchend', touchEndHandler, { passive: false });
1019
1087
 
1020
1088
  const handleMouseLeave = () => {
1021
1089
  if (isMouseDown) {
@@ -1045,13 +1113,38 @@
1045
1113
  canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
1046
1114
  canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
1047
1115
  canvas.removeEventListener('mousemove', handleMouseMove);
1048
- canvas.removeEventListener('touchstart', touchStartHandler);
1049
- canvas.removeEventListener('touchmove', touchMoveHandler);
1050
- canvas.removeEventListener('touchend', touchEndHandler);
1051
1116
  };
1052
1117
  }
1053
1118
  });
1054
1119
 
1120
+ // Attach touch events to touchTarget (Container's previewContainer) instead of canvas
1121
+ $effect(() => {
1122
+ if (!touchTarget || !canvasElement) return;
1123
+
1124
+ const canvas = canvasElement;
1125
+ let lastTouchMoveTime = 0;
1126
+
1127
+ const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
1128
+ const touchMoveHandler = (e: TouchEvent) => {
1129
+ const now = Date.now();
1130
+ if (now - lastTouchMoveTime >= 16) {
1131
+ lastTouchMoveTime = now;
1132
+ handleTouchMove(e, canvas);
1133
+ }
1134
+ };
1135
+ const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
1136
+
1137
+ touchTarget.addEventListener('touchstart', touchStartHandler, { passive: false });
1138
+ touchTarget.addEventListener('touchmove', touchMoveHandler, { passive: false });
1139
+ touchTarget.addEventListener('touchend', touchEndHandler, { passive: false });
1140
+
1141
+ return () => {
1142
+ touchTarget.removeEventListener('touchstart', touchStartHandler);
1143
+ touchTarget.removeEventListener('touchmove', touchMoveHandler);
1144
+ touchTarget.removeEventListener('touchend', touchEndHandler);
1145
+ };
1146
+ });
1147
+
1055
1148
  // Convert canvas coordinates to viewport (screen) coordinates for VirtualCursor display
1056
1149
  function canvasToScreen(cx: number, cy: number): { x: number; y: number } {
1057
1150
  if (!canvasElement) return { x: 0, y: 0 };
@@ -1391,7 +1484,8 @@
1391
1484
  getStats: () => webCodecsService?.getStats() ?? null,
1392
1485
  getLatency: () => latencyMs,
1393
1486
  // Navigation handling
1394
- notifyNavigationComplete
1487
+ notifyNavigationComplete,
1488
+ freezeForSpaNavigation: () => webCodecsService?.freezeForSpaNavigation()
1395
1489
  };
1396
1490
  });
1397
1491