@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.
- package/README.md +61 -27
- package/backend/chat/stream-manager.ts +11 -7
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +17 -0
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +219 -34
- package/backend/preview/browser/browser-pool.ts +1 -1
- package/backend/preview/browser/browser-preview-service.ts +23 -34
- package/backend/preview/browser/browser-tab-manager.ts +16 -1
- package/backend/preview/browser/browser-video-capture.ts +15 -3
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/scripts/video-stream.ts +39 -4
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +35 -15
- package/backend/ws/preview/index.ts +8 -0
- package/frontend/components/chat/input/ChatInput.svelte +3 -3
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/files/FileNode.svelte +16 -58
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
- package/frontend/components/preview/browser/components/Canvas.svelte +158 -64
- package/frontend/components/preview/browser/components/Container.svelte +26 -8
- package/frontend/components/preview/browser/components/Toolbar.svelte +35 -18
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +26 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +66 -9
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- package/frontend/components/workspace/PanelHeader.svelte +8 -6
- package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
- package/frontend/services/chat/chat.service.ts +25 -3
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +277 -61
- 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
|
-
//
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
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">
|
|
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);
|
|
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=
|
|
293
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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-
|
|
394
|
-
onclick={toggleDeviceDropdown}
|
|
395
|
-
|
|
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-
|
|
509
|
-
onclick={() => previewPanelRef?.panelActions?.toggleRotation()}
|
|
510
|
-
|
|
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
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
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
|
/**
|