@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
@@ -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) {
@@ -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