@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
@@ -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
@@ -163,57 +163,58 @@
163
163
  progressPercent = 0;
164
164
  }
165
165
 
166
- // Reset progress bar immediately when active tab changes
167
- // This prevents stale progress from a previous tab leaking into the new tab
166
+ // Reset progress bar immediately when active tab changes.
167
+ // A brief suppression window prevents the loading $effect from
168
+ // restarting progress before global state has synced to the new tab.
168
169
  let previousActiveTabId = $state<string | null>(null);
170
+ let tabSwitchSuppressUntil = 0;
169
171
  $effect(() => {
170
172
  if (activeTabId !== previousActiveTabId) {
171
173
  previousActiveTabId = activeTabId;
172
- // Immediately stop any running progress animation and clear pending timeouts
173
174
  stopProgress();
174
175
  if (progressCompleteTimeout) {
175
176
  clearTimeout(progressCompleteTimeout);
176
177
  progressCompleteTimeout = null;
177
178
  }
179
+ // Suppress loading $effect for a short window to allow state sync
180
+ tabSwitchSuppressUntil = Date.now() + 150;
178
181
  }
179
182
  });
180
183
 
181
184
  // Watch loading states to control progress bar
182
- // Progress bar should be active during:
183
- // 1. isLaunchingBrowser: API call to launch browser
184
- // 2. sessionInfo exists but isStreamReady false: waiting for first frame (initial load)
185
- // 3. isNavigating: navigating within same session (link click)
186
- // 4. isReconnecting: fast reconnect after navigation (keeps progress bar visible)
187
- // 5. isLoading: generic loading state
188
185
  $effect(() => {
189
186
  const waitingForInitialFrame = sessionInfo && !isStreamReady && !isNavigating && !isReconnecting;
190
187
  const shouldShowProgress = isLoading || isLaunchingBrowser || isNavigating || isReconnecting || waitingForInitialFrame;
191
188
 
189
+ // Skip during tab switch suppression window — state may be stale from old tab
190
+ if (Date.now() < tabSwitchSuppressUntil) {
191
+ return;
192
+ }
193
+
192
194
  // Cancel any pending completion when a loading state becomes active
193
195
  if (shouldShowProgress && progressCompleteTimeout) {
194
196
  clearTimeout(progressCompleteTimeout);
195
197
  progressCompleteTimeout = null;
196
198
  }
197
199
 
198
- // Only start if not already showing progress (prevent restart)
199
200
  if (shouldShowProgress && !showProgress) {
200
201
  startProgressAnimation();
201
202
  }
202
- // Only complete if currently showing progress - with debounce to handle state transitions
203
- // This prevents the progress bar from briefly completing during isNavigating → isReconnecting transition
204
203
  else if (!shouldShowProgress && showProgress && !progressCompleteTimeout) {
205
204
  progressCompleteTimeout = setTimeout(() => {
206
205
  progressCompleteTimeout = null;
207
- // Re-check if we should still complete (state might have changed)
208
206
  const stillShouldComplete = !isLoading && !isLaunchingBrowser && !isNavigating && !isReconnecting;
209
207
  const stillWaitingForFrame = sessionInfo && !isStreamReady && !isNavigating && !isReconnecting;
210
208
  if (stillShouldComplete && !stillWaitingForFrame) {
211
209
  completeProgress();
212
210
  }
213
- }, 100); // 100ms debounce to handle state transitions
211
+ }, 100);
214
212
  }
215
213
  });
216
214
 
215
+ // Whether the currently active tab is under MCP control
216
+ const isMcpControlled = $derived(activeTabId != null && mcpControlledTabIds.has(activeTabId));
217
+
217
218
  // Cleanup animation frame on component destroy
218
219
  onDestroy(() => {
219
220
  if (progressAnimationId) {
@@ -304,8 +305,9 @@
304
305
  oninput={handleUrlInput}
305
306
  onfocus={() => isUserTyping = true}
306
307
  onblur={() => isUserTyping = false}
307
- placeholder="Enter URL to preview..."
308
- class="flex-1 pl-3 py-2 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis"
308
+ placeholder={isMcpControlled ? 'MCP controlled — navigation disabled' : 'Enter URL to preview...'}
309
+ disabled={isMcpControlled}
310
+ class="flex-1 pl-3 py-2 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis disabled:opacity-50 disabled:cursor-not-allowed"
309
311
  />
310
312
  <div class="flex items-center gap-1 px-1.5">
311
313
  {#if url}
@@ -318,8 +320,8 @@
318
320
  </button>
319
321
  <button
320
322
  onclick={handleRefresh}
321
- disabled={isLoading}
322
- class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50"
323
+ disabled={isLoading || isMcpControlled}
324
+ class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
323
325
  title="Refresh current page"
324
326
  >
325
327
  <Icon name={isLoading ? 'lucide:loader-circle' : 'lucide:refresh-cw'} class="w-4 h-4 transition-transform duration-200 {isLoading ? 'animate-spin' : 'hover:rotate-180'}" />
@@ -327,9 +329,9 @@
327
329
  {/if}
328
330
  <button
329
331
  onclick={handleGoClick}
330
- disabled={!urlInput.trim() || isLoading}
331
- class="ml-0.5 px-3.5 py-1 text-xs font-medium rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50"
332
- title="Navigate to URL"
332
+ disabled={!urlInput.trim() || isLoading || isMcpControlled}
333
+ class="ml-0.5 px-3.5 py-1 text-xs font-medium rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
334
+ title={isMcpControlled ? 'Navigation disabled — MCP controlled' : 'Navigate to URL'}
333
335
  >
334
336
  Go
335
337
  </button>
@@ -175,6 +175,17 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
175
175
  if (onErrorChange) onErrorChange(null);
176
176
  }
177
177
 
178
+ // Backend created this tab with setActive=true, which may override a tab switch
179
+ // that happened while the 60-second launch call was awaiting. Re-assert the
180
+ // correct active tab on the backend if the user switched away during launch.
181
+ if (tabId !== tabManager.activeTabId && tabManager.activeTabId) {
182
+ const currentActiveTab = tabManager.getTab(tabManager.activeTabId);
183
+ if (currentActiveTab?.sessionId) {
184
+ debug.log('preview', `🔄 Tab switched during launch — re-asserting active backend tab: ${tabManager.activeTabId}`);
185
+ void switchToBackendTab(currentActiveTab.sessionId, getProjectId());
186
+ }
187
+ }
188
+
178
189
  } else {
179
190
  const errorMsg = result.error || 'Unknown error';
180
191
  debug.error('preview', `❌ Browser launch failed:`, errorMsg);
@@ -210,7 +221,8 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
210
221
 
211
222
  // Get current projectId
212
223
  const projectId = getProjectId();
213
- const result = await navigateBrowserOp(newUrl, projectId);
224
+ // Send explicit tabId (backend sessionId) to prevent cross-contamination during rapid switching
225
+ const result = await navigateBrowserOp(newUrl, projectId, tab.sessionId);
214
226
 
215
227
  if (result.success) {
216
228
  const finalUrl = result.finalUrl || newUrl;
@@ -173,6 +173,9 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
173
173
  tabManager.updateTab(tabId, { consoleLogs: [] });
174
174
  }
175
175
 
176
+ // Safety timeout handles to clear stale isLoading if navigation-complete never arrives
177
+ const loadingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
178
+
176
179
  function handleNavigationLoading(tabId: string, data: any) {
177
180
  if (data && data.url) {
178
181
  const tab = tabManager.getTab(tabId);
@@ -180,17 +183,26 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
180
183
  // Only set isLoading (progress bar) for in-browser navigations.
181
184
  // Do NOT set isNavigating here — that flag is reserved for user-initiated
182
185
  // toolbar navigations (Go button/Enter), which is set in navigateBrowserForTab().
183
- // This prevents the "Loading preview..." overlay from showing on link clicks
184
- // within the browser, making it behave like a real browser.
185
186
  tabManager.updateTab(tabId, {
186
187
  isLoading: true,
187
188
  url: data.url,
188
189
  title: getTabTitle(data.url)
189
190
  });
190
191
 
191
- // Only update parent if this is the active tab AND not already navigating via HTTP
192
- // When navigating via HTTP (Go button), the HTTP response will handle URL updates
193
- // to avoid race conditions with stream events overwriting the final redirected URL
192
+ // Safety timeout: if no navigation-complete event arrives within 15s,
193
+ // clear isLoading to prevent it from being stuck indefinitely.
194
+ // This handles aborted navigations (user clicks another link before load).
195
+ const existing = loadingTimeouts.get(tabId);
196
+ if (existing) clearTimeout(existing);
197
+ loadingTimeouts.set(tabId, setTimeout(() => {
198
+ loadingTimeouts.delete(tabId);
199
+ const currentTab = tabManager.getTab(tabId);
200
+ if (currentTab?.isLoading) {
201
+ debug.warn('preview', `⏰ Safety timeout: clearing stale isLoading for tab ${tabId}`);
202
+ tabManager.updateTab(tabId, { isLoading: false });
203
+ }
204
+ }, 15000));
205
+
194
206
  if (tabId === tabManager.activeTabId && onNavigationUpdate && !tab?.isNavigating) {
195
207
  onNavigationUpdate(tabId, data.url);
196
208
  }
@@ -198,6 +210,13 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
198
210
  }
199
211
 
200
212
  function handleNavigation(tabId: string, data: any, tab: PreviewTab) {
213
+ // Clear safety timeout — navigation completed normally
214
+ const timeout = loadingTimeouts.get(tabId);
215
+ if (timeout) {
216
+ clearTimeout(timeout);
217
+ loadingTimeouts.delete(tabId);
218
+ }
219
+
201
220
  if (data && data.url && data.url !== tab.url) {
202
221
  debug.log('preview', `🧭 Navigation completed for tab ${tabId}: ${tab.url} → ${data.url}`);
203
222
  tabManager.updateTab(tabId, {
@@ -207,12 +226,10 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
207
226
  title: getTabTitle(data.url)
208
227
  });
209
228
 
210
- // Only update parent if this is the active tab
211
229
  if (tabId === tabManager.activeTabId && onNavigationUpdate) {
212
230
  onNavigationUpdate(tabId, data.url);
213
231
  }
214
232
  } else if (data && data.url === tab.url) {
215
- // Same URL but navigation completed (e.g., page refresh)
216
233
  debug.log('preview', `🔄 Same URL navigation completed for tab ${tabId}: ${data.url}`);
217
234
  tabManager.updateTab(tabId, {
218
235
  isLoading: false,
@@ -222,6 +239,13 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
222
239
  }
223
240
 
224
241
  function handleNavigationSpa(tabId: string, data: any, tab: PreviewTab) {
242
+ // Clear safety timeout — SPA navigation also resolves the loading state
243
+ const timeout = loadingTimeouts.get(tabId);
244
+ if (timeout) {
245
+ clearTimeout(timeout);
246
+ loadingTimeouts.delete(tabId);
247
+ }
248
+
225
249
  if (data && data.url && data.url !== tab.url) {
226
250
  debug.log('preview', `🔄 SPA navigation for tab ${tabId}: ${tab.url} → ${data.url}`);
227
251
 
@@ -122,9 +122,10 @@ export async function launchBrowser(
122
122
  }
123
123
 
124
124
  /**
125
- * Navigate active tab to new URL
125
+ * Navigate a specific tab to new URL.
126
+ * Requires explicit tabId to prevent cross-contamination during rapid tab switching.
126
127
  */
127
- export async function navigateBrowser(newUrl: string, projectId: string): Promise<NavigateResult> {
128
+ export async function navigateBrowser(newUrl: string, projectId: string, tabId?: string): Promise<NavigateResult> {
128
129
  if (!newUrl) {
129
130
  return { success: false, error: 'No URL provided' };
130
131
  }
@@ -134,8 +135,8 @@ export async function navigateBrowser(newUrl: string, projectId: string): Promis
134
135
  }
135
136
 
136
137
  try {
137
- // Backend uses active tab automatically
138
- const data = await ws.http('preview:browser-tab-navigate', { url: newUrl }, 30000);
138
+ // Always send explicit tabId to prevent race conditions during rapid tab switching
139
+ const data = await ws.http('preview:browser-tab-navigate', { url: newUrl, tabId }, 30000);
139
140
 
140
141
  return { success: true, finalUrl: data.finalUrl };
141
142
  } catch (error) {
@@ -73,7 +73,7 @@
73
73
  let openCodeCommandCopiedTimer: ReturnType<typeof setTimeout> | null = null;
74
74
 
75
75
  // Debug PTY (xterm.js)
76
- const showDebug = $state(false);
76
+ const showDebug = $state(true);
77
77
  let debugTermContainer = $state<HTMLDivElement>();
78
78
  let debugTerminal: Terminal | null = null;
79
79
  let debugFitAddon: FitAddon | null = null;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { addNotification } from '$frontend/stores/ui/notification.svelte';
3
- import { resetToDefaults } from '$frontend/stores/features/settings.svelte';
4
3
  import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
5
4
  import Icon from '../../common/display/Icon.svelte';
6
5
  import { debug } from '$shared/utils/logger';
@@ -41,68 +40,16 @@
41
40
  }
42
41
  }
43
42
 
44
- async function resetSettings() {
45
- const confirmed = await showConfirm({
46
- title: 'Reset Settings',
47
- message:
48
- 'Are you sure you want to reset all settings to defaults? This will not delete your projects or conversations.',
49
- type: 'warning',
50
- confirmText: 'Reset Settings',
51
- cancelText: 'Cancel'
52
- });
53
43
 
54
- if (confirmed) {
55
- resetToDefaults();
56
- addNotification({
57
- type: 'success',
58
- title: 'Settings Reset',
59
- message: 'All settings have been restored to defaults'
60
- });
61
- }
62
- }
63
44
  </script>
64
45
 
65
46
  <div class="py-1">
66
47
  <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Data Management</h3>
67
48
  <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">
68
- Export, import, or clear your application data
49
+ Manage your application data
69
50
  </p>
70
51
 
71
52
  <div class="flex flex-col gap-4">
72
- <!-- Storage Info -->
73
- <div
74
- class="flex items-center gap-3.5 p-4 bg-slate-100/50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 rounded-lg"
75
- >
76
- <Icon name="lucide:hard-drive" class="w-5 h-5 text-slate-500 dark:text-slate-400" />
77
- <div class="flex-1">
78
- <div class="text-sm font-medium text-slate-900 dark:text-slate-100">Local Storage</div>
79
- <div class="text-xs text-slate-600 dark:text-slate-400">Data is stored locally in your browser</div>
80
- </div>
81
- </div>
82
-
83
- <!-- Action Cards -->
84
- <div class="flex flex-col gap-3">
85
- <!-- Reset Settings -->
86
- <div
87
- class="flex items-center justify-between gap-4 py-3 px-4 bg-slate-100/50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 rounded-lg"
88
- >
89
- <div class="flex items-center gap-3">
90
- <Icon name="lucide:refresh-cw" class="w-4.5 h-4.5 text-slate-600 dark:text-slate-400" />
91
- <div class="text-left">
92
- <div class="text-sm font-medium text-slate-900 dark:text-slate-100">Reset Settings</div>
93
- <div class="text-xs text-slate-600 dark:text-slate-400">Restore default settings</div>
94
- </div>
95
- </div>
96
- <button
97
- type="button"
98
- class="px-4 py-2 bg-slate-200 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium cursor-pointer transition-all duration-150 hover:bg-slate-300 dark:hover:bg-slate-600 hover:border-slate-400 dark:hover:border-slate-500 disabled:opacity-60 disabled:cursor-not-allowed whitespace-nowrap"
99
- onclick={resetSettings}
100
- >
101
- Reset
102
- </button>
103
- </div>
104
- </div>
105
-
106
53
  <!-- Danger Zone -->
107
54
  <div class="mt-2">
108
55
  <div class="flex items-center gap-2 mb-3 text-xs font-medium text-red-600 dark:text-red-400">