@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
@@ -70,10 +70,24 @@
70
70
  let showNavigationOverlay = $state(false);
71
71
  let overlayHideTimeout: ReturnType<typeof setTimeout> | null = null;
72
72
 
73
- // Debounced navigation overlay - similar to progress bar logic
74
- // Shows immediately when navigating/reconnecting, hides with delay to prevent flicker
73
+ // Immediately reset navigation overlay on tab switch to prevent stale overlay from old tab
74
+ let previousOverlaySessionId: string | null = null;
75
75
  $effect(() => {
76
- const shouldShowOverlay = (isNavigating || isReconnecting) && isStreamReady;
76
+ if (sessionId !== previousOverlaySessionId) {
77
+ previousOverlaySessionId = sessionId;
78
+ if (overlayHideTimeout) {
79
+ clearTimeout(overlayHideTimeout);
80
+ overlayHideTimeout = null;
81
+ }
82
+ showNavigationOverlay = false;
83
+ }
84
+ });
85
+
86
+ // Debounced navigation overlay - only for user-initiated toolbar navigations
87
+ // In-browser navigations (link clicks) only show progress bar, not this overlay
88
+ // This makes the preview behave like a real browser
89
+ $effect(() => {
90
+ const shouldShowOverlay = isNavigating && isStreamReady;
77
91
 
78
92
  // Cancel any pending hide when overlay should show
79
93
  if (shouldShowOverlay && overlayHideTimeout) {
@@ -89,9 +103,11 @@
89
103
  else if (!shouldShowOverlay && showNavigationOverlay && !overlayHideTimeout) {
90
104
  overlayHideTimeout = setTimeout(() => {
91
105
  overlayHideTimeout = null;
92
- // Re-check if we should still hide
93
- const stillShouldHide = !(isNavigating || isReconnecting) || !isStreamReady;
94
- 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) {
95
111
  showNavigationOverlay = false;
96
112
  }
97
113
  }, 100); // 100ms debounce
@@ -385,6 +401,7 @@
385
401
  bind:isNavigating
386
402
  bind:isReconnecting
387
403
  bind:touchMode
404
+ touchTarget={previewContainer}
388
405
  onInteraction={handleCanvasInteraction}
389
406
  onCursorUpdate={handleCursorUpdate}
390
407
  onFrameUpdate={handleFrameUpdate}
@@ -408,7 +425,8 @@
408
425
  </div>
409
426
  {/if}
410
427
 
411
- <!-- Navigation Overlay: Semi-transparent overlay during navigation/reconnect (shows last frame behind) -->
428
+ <!-- Navigation Overlay: Only for user-initiated toolbar navigations (Go button/Enter) -->
429
+ <!-- In-browser link clicks only show the progress bar, not this overlay -->
412
430
  {#if showNavigationOverlay}
413
431
  <div
414
432
  class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] flex items-center justify-center z-10"
@@ -416,7 +434,7 @@
416
434
  <div class="flex flex-col items-center gap-2">
417
435
  <Icon name="lucide:loader-circle" class="w-8 h-8 animate-spin text-violet-600" />
418
436
  <div class="text-slate-600 dark:text-slate-300 text-center">
419
- <div class="text-sm font-medium">Loading preview...</div>
437
+ <div class="text-sm font-medium">Navigating...</div>
420
438
  </div>
421
439
  </div>
422
440
  </div>
@@ -163,42 +163,58 @@
163
163
  progressPercent = 0;
164
164
  }
165
165
 
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.
169
+ let previousActiveTabId = $state<string | null>(null);
170
+ let tabSwitchSuppressUntil = 0;
171
+ $effect(() => {
172
+ if (activeTabId !== previousActiveTabId) {
173
+ previousActiveTabId = activeTabId;
174
+ stopProgress();
175
+ if (progressCompleteTimeout) {
176
+ clearTimeout(progressCompleteTimeout);
177
+ progressCompleteTimeout = null;
178
+ }
179
+ // Suppress loading $effect for a short window to allow state sync
180
+ tabSwitchSuppressUntil = Date.now() + 150;
181
+ }
182
+ });
183
+
166
184
  // Watch loading states to control progress bar
167
- // Progress bar should be active during:
168
- // 1. isLaunchingBrowser: API call to launch browser
169
- // 2. sessionInfo exists but isStreamReady false: waiting for first frame (initial load)
170
- // 3. isNavigating: navigating within same session (link click)
171
- // 4. isReconnecting: fast reconnect after navigation (keeps progress bar visible)
172
- // 5. isLoading: generic loading state
173
185
  $effect(() => {
174
186
  const waitingForInitialFrame = sessionInfo && !isStreamReady && !isNavigating && !isReconnecting;
175
187
  const shouldShowProgress = isLoading || isLaunchingBrowser || isNavigating || isReconnecting || waitingForInitialFrame;
176
188
 
189
+ // Skip during tab switch suppression window — state may be stale from old tab
190
+ if (Date.now() < tabSwitchSuppressUntil) {
191
+ return;
192
+ }
193
+
177
194
  // Cancel any pending completion when a loading state becomes active
178
195
  if (shouldShowProgress && progressCompleteTimeout) {
179
196
  clearTimeout(progressCompleteTimeout);
180
197
  progressCompleteTimeout = null;
181
198
  }
182
199
 
183
- // Only start if not already showing progress (prevent restart)
184
200
  if (shouldShowProgress && !showProgress) {
185
201
  startProgressAnimation();
186
202
  }
187
- // Only complete if currently showing progress - with debounce to handle state transitions
188
- // This prevents the progress bar from briefly completing during isNavigating → isReconnecting transition
189
203
  else if (!shouldShowProgress && showProgress && !progressCompleteTimeout) {
190
204
  progressCompleteTimeout = setTimeout(() => {
191
205
  progressCompleteTimeout = null;
192
- // Re-check if we should still complete (state might have changed)
193
206
  const stillShouldComplete = !isLoading && !isLaunchingBrowser && !isNavigating && !isReconnecting;
194
207
  const stillWaitingForFrame = sessionInfo && !isStreamReady && !isNavigating && !isReconnecting;
195
208
  if (stillShouldComplete && !stillWaitingForFrame) {
196
209
  completeProgress();
197
210
  }
198
- }, 100); // 100ms debounce to handle state transitions
211
+ }, 100);
199
212
  }
200
213
  });
201
214
 
215
+ // Whether the currently active tab is under MCP control
216
+ const isMcpControlled = $derived(activeTabId != null && mcpControlledTabIds.has(activeTabId));
217
+
202
218
  // Cleanup animation frame on component destroy
203
219
  onDestroy(() => {
204
220
  if (progressAnimationId) {
@@ -289,8 +305,9 @@
289
305
  oninput={handleUrlInput}
290
306
  onfocus={() => isUserTyping = true}
291
307
  onblur={() => isUserTyping = false}
292
- placeholder="Enter URL to preview..."
293
- 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"
294
311
  />
295
312
  <div class="flex items-center gap-1 px-1.5">
296
313
  {#if url}
@@ -303,8 +320,8 @@
303
320
  </button>
304
321
  <button
305
322
  onclick={handleRefresh}
306
- disabled={isLoading}
307
- 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"
308
325
  title="Refresh current page"
309
326
  >
310
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'}" />
@@ -312,9 +329,9 @@
312
329
  {/if}
313
330
  <button
314
331
  onclick={handleGoClick}
315
- disabled={!urlInput.trim() || isLoading}
316
- 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"
317
- 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'}
318
335
  >
319
336
  Go
320
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;
@@ -771,6 +783,19 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
771
783
  debug.warn('preview', `Tab not found for sessionId: ${data.sessionId}`);
772
784
  }
773
785
  });
786
+
787
+ // Listen for SPA navigation events (pushState/replaceState)
788
+ ws.on('preview:browser-navigation-spa', (data: { sessionId: string; type: string; url: string; timestamp: number }) => {
789
+ debug.log('preview', `🔄 SPA navigation event received: ${data.sessionId} → ${data.url}`);
790
+
791
+ const tab = tabManager.tabs.find(t => t.sessionId === data.sessionId);
792
+ if (tab) {
793
+ streamHandler.handleStreamMessage({
794
+ type: 'navigation-spa',
795
+ data: { url: data.url }
796
+ }, tab.id);
797
+ }
798
+ });
774
799
  });
775
800
  }
776
801
 
@@ -73,6 +73,10 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
73
73
  handleNavigation(targetTabId, message.data, tab);
74
74
  break;
75
75
 
76
+ case 'navigation-spa':
77
+ handleNavigationSpa(targetTabId, message.data, tab);
78
+ break;
79
+
76
80
  case 'new-window':
77
81
  handleNewWindow(message.data);
78
82
  break;
@@ -169,23 +173,36 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
169
173
  tabManager.updateTab(tabId, { consoleLogs: [] });
170
174
  }
171
175
 
176
+ // Safety timeout handles to clear stale isLoading if navigation-complete never arrives
177
+ const loadingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
178
+
172
179
  function handleNavigationLoading(tabId: string, data: any) {
173
180
  if (data && data.url) {
174
181
  const tab = tabManager.getTab(tabId);
175
- // isNavigating: true if session already exists (navigating within same session)
176
- // isNavigating: false if no session yet (initial load)
177
- const isNavigating = tab?.sessionId ? true : false;
178
182
 
183
+ // Only set isLoading (progress bar) for in-browser navigations.
184
+ // Do NOT set isNavigating here — that flag is reserved for user-initiated
185
+ // toolbar navigations (Go button/Enter), which is set in navigateBrowserForTab().
179
186
  tabManager.updateTab(tabId, {
180
187
  isLoading: true,
181
- isNavigating,
182
188
  url: data.url,
183
189
  title: getTabTitle(data.url)
184
190
  });
185
191
 
186
- // Only update parent if this is the active tab AND not already navigating via HTTP
187
- // When navigating via HTTP (Go button), the HTTP response will handle URL updates
188
- // 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
+
189
206
  if (tabId === tabManager.activeTabId && onNavigationUpdate && !tab?.isNavigating) {
190
207
  onNavigationUpdate(tabId, data.url);
191
208
  }
@@ -193,6 +210,13 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
193
210
  }
194
211
 
195
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
+
196
220
  if (data && data.url && data.url !== tab.url) {
197
221
  debug.log('preview', `🧭 Navigation completed for tab ${tabId}: ${tab.url} → ${data.url}`);
198
222
  tabManager.updateTab(tabId, {
@@ -202,12 +226,10 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
202
226
  title: getTabTitle(data.url)
203
227
  });
204
228
 
205
- // Only update parent if this is the active tab
206
229
  if (tabId === tabManager.activeTabId && onNavigationUpdate) {
207
230
  onNavigationUpdate(tabId, data.url);
208
231
  }
209
232
  } else if (data && data.url === tab.url) {
210
- // Same URL but navigation completed (e.g., page refresh)
211
233
  debug.log('preview', `🔄 Same URL navigation completed for tab ${tabId}: ${data.url}`);
212
234
  tabManager.updateTab(tabId, {
213
235
  isLoading: false,
@@ -216,6 +238,41 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
216
238
  }
217
239
  }
218
240
 
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
+
249
+ if (data && data.url && data.url !== tab.url) {
250
+ debug.log('preview', `🔄 SPA navigation for tab ${tabId}: ${tab.url} → ${data.url}`);
251
+
252
+ // Freeze canvas briefly to avoid showing white flash during SPA transition
253
+ // The last rendered frame is held while the DOM settles
254
+ tab.canvasAPI?.freezeForSpaNavigation?.();
255
+
256
+ // SPA navigation: update URL/title and reset any loading states.
257
+ // A preceding navigation-loading event may have set isLoading=true
258
+ // (e.g., if the browser started a document request before the SPA
259
+ // router intercepted it). Reset those states here since the SPA
260
+ // handled the navigation without a full page reload.
261
+ // Video streaming continues uninterrupted since page context is unchanged.
262
+ tabManager.updateTab(tabId, {
263
+ url: data.url,
264
+ title: getTabTitle(data.url),
265
+ isLoading: false,
266
+ isNavigating: false
267
+ });
268
+
269
+ // Update parent if this is the active tab
270
+ if (tabId === tabManager.activeTabId && onNavigationUpdate) {
271
+ onNavigationUpdate(tabId, data.url);
272
+ }
273
+ }
274
+ }
275
+
219
276
  function handleNewWindow(data: any) {
220
277
  if (data && data.url) {
221
278
  tabManager.createTab(data.url);
@@ -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) {
@@ -390,9 +390,10 @@
390
390
  <div class="relative">
391
391
  <button
392
392
  type="button"
393
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
394
- onclick={toggleDeviceDropdown}
395
- title="Select device size"
393
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md transition-all duration-150 {previewPanelRef?.panelActions?.getIsMcpControlled() ? 'text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50' : 'text-slate-500 cursor-pointer hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
394
+ onclick={previewPanelRef?.panelActions?.getIsMcpControlled() ? undefined : toggleDeviceDropdown}
395
+ disabled={previewPanelRef?.panelActions?.getIsMcpControlled()}
396
+ title={previewPanelRef?.panelActions?.getIsMcpControlled() ? 'Controlled by MCP agent' : 'Select device size'}
396
397
  >
397
398
  {#if previewPanelRef?.panelActions?.getDeviceSize() === 'desktop'}
398
399
  <Icon name="lucide:monitor" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
@@ -505,9 +506,10 @@
505
506
  <!-- Rotation toggle -->
506
507
  <button
507
508
  type="button"
508
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
509
- onclick={() => previewPanelRef?.panelActions?.toggleRotation()}
510
- title="Toggle orientation"
509
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md transition-all duration-150 {previewPanelRef?.panelActions?.getIsMcpControlled() ? 'text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50' : 'text-slate-500 cursor-pointer hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
510
+ onclick={previewPanelRef?.panelActions?.getIsMcpControlled() ? undefined : () => previewPanelRef?.panelActions?.toggleRotation()}
511
+ disabled={previewPanelRef?.panelActions?.getIsMcpControlled()}
512
+ title={previewPanelRef?.panelActions?.getIsMcpControlled() ? 'Controlled by MCP agent' : 'Toggle orientation'}
511
513
  >
512
514
  <Icon name="lucide:rotate-cw" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
513
515
  <span class="text-xs font-medium">
@@ -113,6 +113,7 @@
113
113
  getSessionInfo: () => browserPreviewRef?.browserActions?.getSessionInfo() || null,
114
114
  getIsStreamReady: () => browserPreviewRef?.browserActions?.getIsStreamReady() || false,
115
115
  getErrorMessage: () => browserPreviewRef?.browserActions?.getErrorMessage() || null,
116
+ getIsMcpControlled: () => browserPreviewRef?.browserActions?.getIsMcpControlled() || false,
116
117
  setDeviceSize: (size: DeviceSize) => {
117
118
  if (browserPreviewRef?.browserActions) {
118
119
  browserPreviewRef.browserActions.changeDeviceSize(size);
@@ -39,6 +39,7 @@ class ChatService {
39
39
  private lastEventSeq = new Map<string, number>(); // Sequence-based deduplication
40
40
  private cancelledProcessIds = new Set<string>(); // Track ALL cancelled streams to ignore late events
41
41
  private reconnected: boolean = false; // Whether we've reconnected to an active stream
42
+ private cancelSafetyTimer: ReturnType<typeof setTimeout> | null = null;
42
43
 
43
44
  static loadingTexts: string[] = [
44
45
  'thinking', 'processing', 'analyzing', 'calculating', 'computing',
@@ -170,6 +171,7 @@ class ChatService {
170
171
 
171
172
  this.streamCompleted = true;
172
173
  this.reconnected = false;
174
+ this.clearCancelSafetyTimer();
173
175
  this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
174
176
 
175
177
  // Mark any tool_use blocks that never got a tool_result
@@ -196,6 +198,7 @@ class ChatService {
196
198
  this.streamCompleted = true;
197
199
  this.reconnected = false;
198
200
  this.activeProcessId = null;
201
+ this.clearCancelSafetyTimer();
199
202
  // Don't clear isCancelling here — it causes a race with presence.
200
203
  // The chat:cancelled WS event arrives before broadcastPresence() updates,
201
204
  // so clearing isCancelling lets the presence $effect re-enable isLoading
@@ -485,9 +488,28 @@ class ChatService {
485
488
  // before a stale non-reasoning stream_event instead of at the end).
486
489
  this.cleanupStreamEvents();
487
490
 
488
- // No safety timeout needed cancel completion is confirmed via WS events:
489
- // chat:cancelled clears isLoading, then presence update clears isCancelling.
490
- // If WS disconnects, reconnection logic re-fetches presence and clears state.
491
+ // Safety timeout: if backend events (chat:cancelled + presence update) don't
492
+ // arrive within 10 seconds, force-clear isCancelling to prevent infinite loader.
493
+ // This catches edge cases: WS disconnect during cancel, engine.cancel() timeout,
494
+ // or race conditions between presence update and chat:cancelled event ordering.
495
+ this.clearCancelSafetyTimer();
496
+ this.cancelSafetyTimer = setTimeout(() => {
497
+ this.cancelSafetyTimer = null;
498
+ if (appState.isCancelling) {
499
+ debug.warn('chat', 'Cancel safety timeout: force-clearing isCancelling after 10s');
500
+ this.setProcessState({ isCancelling: false, isLoading: false }, chatSessionId);
501
+ }
502
+ }, 10000);
503
+ }
504
+
505
+ /**
506
+ * Clear the cancel safety timer (called when cancel completes normally)
507
+ */
508
+ private clearCancelSafetyTimer(): void {
509
+ if (this.cancelSafetyTimer) {
510
+ clearTimeout(this.cancelSafetyTimer);
511
+ this.cancelSafetyTimer = null;
512
+ }
491
513
  }
492
514
 
493
515
  /**
@@ -54,8 +54,8 @@ async function sendNotification(
54
54
 
55
55
  try {
56
56
  const notification = new Notification(title, {
57
- icon: '/favicon.ico',
58
- badge: '/favicon.ico',
57
+ icon: '/favicon.svg',
58
+ badge: '/favicon.svg',
59
59
  ...options
60
60
  });
61
61