@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.
- 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 +5 -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 +31 -3
- package/backend/preview/browser/browser-preview-service.ts +0 -34
- package/backend/preview/browser/browser-video-capture.ts +13 -1
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- 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/components/Canvas.svelte +119 -42
- package/frontend/components/preview/browser/components/Container.svelte +18 -3
- package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- 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 +170 -46
- 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
|
-
//
|
|
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);
|
|
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=
|
|
308
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
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) {
|
|
@@ -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
|
/**
|