@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
|
@@ -94,6 +94,7 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
// Forward navigation events and handle video streaming restart
|
|
97
|
+
// Only full navigations (framenavigated) need streaming restart
|
|
97
98
|
this.navigationTracker.on('navigation', async (data) => {
|
|
98
99
|
this.emit('preview:browser-navigation', data);
|
|
99
100
|
|
|
@@ -121,6 +122,12 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
121
122
|
this.emit('preview:browser-navigation-loading', data);
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
// Forward SPA navigation events (pushState/replaceState)
|
|
126
|
+
// No streaming restart needed — page context is unchanged
|
|
127
|
+
this.navigationTracker.on('navigation-spa', (data) => {
|
|
128
|
+
this.emit('preview:browser-navigation-spa', data);
|
|
129
|
+
});
|
|
130
|
+
|
|
124
131
|
// Forward new window events
|
|
125
132
|
this.tabManager.on('new-window', (data) => {
|
|
126
133
|
this.emit('preview:browser-new-window', data);
|
|
@@ -140,6 +147,10 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
140
147
|
this.emit('preview:browser-tab-navigated', data);
|
|
141
148
|
});
|
|
142
149
|
|
|
150
|
+
this.tabManager.on('preview:browser-viewport-changed', (data) => {
|
|
151
|
+
this.emit('preview:browser-viewport-changed', data);
|
|
152
|
+
});
|
|
153
|
+
|
|
143
154
|
// Forward video capture events
|
|
144
155
|
this.videoCapture.on('ice-candidate', (data) => {
|
|
145
156
|
this.emit('preview:browser-webcodecs-ice-candidate', data);
|
|
@@ -249,6 +260,9 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
249
260
|
// Stop WebCodecs streaming first
|
|
250
261
|
await this.stopWebCodecsStreaming(tabId);
|
|
251
262
|
|
|
263
|
+
// Cleanup navigation tracker CDP session
|
|
264
|
+
await this.navigationTracker.cleanupSession(tabId);
|
|
265
|
+
|
|
252
266
|
// Clear cursor tracking for this tab
|
|
253
267
|
this.interactionHandler.clearSessionCursor(tabId);
|
|
254
268
|
|
|
@@ -650,6 +664,11 @@ class BrowserPreviewServiceManager {
|
|
|
650
664
|
ws.emit.project(projectId, 'preview:browser-navigation', data);
|
|
651
665
|
});
|
|
652
666
|
|
|
667
|
+
// Forward SPA navigation events (pushState/replaceState — URL-only update)
|
|
668
|
+
service.on('preview:browser-navigation-spa', (data) => {
|
|
669
|
+
ws.emit.project(projectId, 'preview:browser-navigation-spa', data);
|
|
670
|
+
});
|
|
671
|
+
|
|
653
672
|
// Forward tab events
|
|
654
673
|
service.on('preview:browser-tab-opened', (data) => {
|
|
655
674
|
debug.log('preview', `🚀 Forwarding preview:browser-tab-opened to project ${projectId}:`, data);
|
|
@@ -668,6 +687,10 @@ class BrowserPreviewServiceManager {
|
|
|
668
687
|
ws.emit.project(projectId, 'preview:browser-tab-navigated', data);
|
|
669
688
|
});
|
|
670
689
|
|
|
690
|
+
service.on('preview:browser-viewport-changed', (data) => {
|
|
691
|
+
ws.emit.project(projectId, 'preview:browser-viewport-changed', data);
|
|
692
|
+
});
|
|
693
|
+
|
|
671
694
|
// Forward console events
|
|
672
695
|
service.on('preview:browser-console-message', (data) => {
|
|
673
696
|
ws.emit.project(projectId, 'preview:browser-console-message', data);
|
|
@@ -846,37 +869,3 @@ class BrowserPreviewServiceManager {
|
|
|
846
869
|
// Service manager instance (singleton)
|
|
847
870
|
export const browserPreviewServiceManager = new BrowserPreviewServiceManager();
|
|
848
871
|
|
|
849
|
-
// Graceful shutdown handlers
|
|
850
|
-
const gracefulShutdown = async (signal: string) => {
|
|
851
|
-
try {
|
|
852
|
-
await browserPreviewServiceManager.cleanup();
|
|
853
|
-
process.exit(0);
|
|
854
|
-
} catch (error) {
|
|
855
|
-
process.exit(1);
|
|
856
|
-
}
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
// Handle various termination signals
|
|
860
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
861
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
862
|
-
process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
|
|
863
|
-
|
|
864
|
-
// Handle Windows-specific signals
|
|
865
|
-
if (process.platform === 'win32') {
|
|
866
|
-
process.on('SIGBREAK', () => gracefulShutdown('SIGBREAK'));
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Handle uncaught exceptions and unhandled rejections
|
|
870
|
-
process.on('uncaughtException', async (error) => {
|
|
871
|
-
await browserPreviewServiceManager.cleanup();
|
|
872
|
-
process.exit(1);
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
process.on('unhandledRejection', async (reason, promise) => {
|
|
876
|
-
await browserPreviewServiceManager.cleanup();
|
|
877
|
-
process.exit(1);
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
// Handle process exit
|
|
881
|
-
process.on('exit', (code) => {
|
|
882
|
-
});
|
|
@@ -730,6 +730,21 @@ export class BrowserTabManager extends EventEmitter {
|
|
|
730
730
|
// await page.evaluateOnNewDocument(cursorTrackingScript);
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Returns true for errors where retrying is pointless because the page/session is gone.
|
|
735
|
+
*/
|
|
736
|
+
private isNonRetryableError(error: unknown): boolean {
|
|
737
|
+
if (error instanceof Error) {
|
|
738
|
+
const msg = error.message;
|
|
739
|
+
return (
|
|
740
|
+
msg.includes('Session closed') ||
|
|
741
|
+
msg.includes('detached Frame') ||
|
|
742
|
+
error.constructor.name === 'TargetCloseError'
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
|
|
733
748
|
/**
|
|
734
749
|
* Navigate with retry, including Cloudflare auto-pass detection and CAPTCHA popup dismissal.
|
|
735
750
|
*/
|
|
@@ -750,7 +765,7 @@ export class BrowserTabManager extends EventEmitter {
|
|
|
750
765
|
} catch (error) {
|
|
751
766
|
retries--;
|
|
752
767
|
debug.warn('preview', `⚠️ Navigation failed, ${retries} retries left:`, error);
|
|
753
|
-
if (retries === 0) throw error;
|
|
768
|
+
if (retries === 0 || this.isNonRetryableError(error)) throw error;
|
|
754
769
|
|
|
755
770
|
// Wait before retry
|
|
756
771
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
@@ -42,6 +42,7 @@ interface VideoStreamSession {
|
|
|
42
42
|
pendingCandidates: RTCIceCandidateInit[];
|
|
43
43
|
scriptInjected: boolean; // Track if persistent script was injected
|
|
44
44
|
scriptsPreInjected: boolean; // Track if scripts were pre-injected during tab creation
|
|
45
|
+
audioOnNewDocumentInjected: boolean; // Track if evaluateOnNewDocument was registered for audio
|
|
45
46
|
stats: {
|
|
46
47
|
videoBytesSent: number;
|
|
47
48
|
audioBytesSent: number;
|
|
@@ -97,6 +98,7 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
97
98
|
pendingCandidates: [],
|
|
98
99
|
scriptInjected: true,
|
|
99
100
|
scriptsPreInjected: false, // Set to true only after injection completes
|
|
101
|
+
audioOnNewDocumentInjected: false,
|
|
100
102
|
stats: {
|
|
101
103
|
videoBytesSent: 0,
|
|
102
104
|
audioBytesSent: 0,
|
|
@@ -162,7 +164,16 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
162
164
|
});
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
//
|
|
167
|
+
// Register audio capture as a startup script — runs before page scripts on every new document load.
|
|
168
|
+
// Critical for SPAs that create AudioContext during initialization (before page.evaluate runs).
|
|
169
|
+
// The idempotency guard in audioCaptureScript prevents double-injection.
|
|
170
|
+
const session = this.sessions.get(sessionId);
|
|
171
|
+
if (session && !session.audioOnNewDocumentInjected) {
|
|
172
|
+
await page.evaluateOnNewDocument(audioCaptureScript, config.audio);
|
|
173
|
+
session.audioOnNewDocumentInjected = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Inject video encoder + audio capture scripts into the current page context
|
|
166
177
|
await page.evaluate(videoEncoderScript, videoConfig);
|
|
167
178
|
await page.evaluate(audioCaptureScript, config.audio);
|
|
168
179
|
}
|
|
@@ -220,6 +231,7 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
220
231
|
pendingCandidates: [],
|
|
221
232
|
scriptInjected: false,
|
|
222
233
|
scriptsPreInjected: false,
|
|
234
|
+
audioOnNewDocumentInjected: false,
|
|
223
235
|
stats: {
|
|
224
236
|
videoBytesSent: 0,
|
|
225
237
|
audioBytesSent: 0,
|
|
@@ -383,8 +395,8 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
383
395
|
return null;
|
|
384
396
|
}
|
|
385
397
|
|
|
386
|
-
const maxRetries =
|
|
387
|
-
const retryDelay =
|
|
398
|
+
const maxRetries = 6;
|
|
399
|
+
const retryDelay = 150;
|
|
388
400
|
|
|
389
401
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
390
402
|
try {
|
|
@@ -16,6 +16,11 @@ import type { StreamingConfig } from '../types';
|
|
|
16
16
|
* This script intercepts AudioContext and captures all audio
|
|
17
17
|
*/
|
|
18
18
|
export function audioCaptureScript(config: StreamingConfig['audio']) {
|
|
19
|
+
// Idempotency guard — prevent double-injection when both evaluateOnNewDocument
|
|
20
|
+
// and page.evaluate inject this script into the same page context.
|
|
21
|
+
if ((window as any).__audioCaptureInstalled) return;
|
|
22
|
+
(window as any).__audioCaptureInstalled = true;
|
|
23
|
+
|
|
19
24
|
// Check AudioEncoder support
|
|
20
25
|
if (typeof AudioEncoder === 'undefined') {
|
|
21
26
|
(window as any).__audioEncoderSupported = false;
|
|
@@ -38,6 +38,23 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
38
38
|
// STUN servers are unnecessary for localhost and add 100-500ms ICE gathering latency
|
|
39
39
|
const iceServers: { urls: string }[] = [];
|
|
40
40
|
|
|
41
|
+
// Create a loopback (127.0.0.1) copy of a host ICE candidate.
|
|
42
|
+
// Ensures WebRTC connects via loopback when VPN (e.g. Cloudflare WARP)
|
|
43
|
+
// interferes with host candidate connectivity between same-machine peers.
|
|
44
|
+
function createLoopbackCandidate(candidate: { candidate?: string; sdpMid?: string | null; sdpMLineIndex?: number | null }) {
|
|
45
|
+
if (!candidate.candidate) return null;
|
|
46
|
+
if (!candidate.candidate.includes('typ host')) return null;
|
|
47
|
+
|
|
48
|
+
const parts = candidate.candidate.split(' ');
|
|
49
|
+
if (parts.length < 8) return null;
|
|
50
|
+
|
|
51
|
+
const address = parts[4];
|
|
52
|
+
if (address === '127.0.0.1' || address === '::1') return null;
|
|
53
|
+
|
|
54
|
+
parts[4] = '127.0.0.1';
|
|
55
|
+
return { ...candidate, candidate: parts.join(' ') };
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
// Check cursor style from page
|
|
42
59
|
function checkCursor() {
|
|
43
60
|
try {
|
|
@@ -103,11 +120,18 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
103
120
|
// Handle ICE candidates
|
|
104
121
|
peerConnection.onicecandidate = (event) => {
|
|
105
122
|
if (event.candidate && (window as any).__sendIceCandidate) {
|
|
106
|
-
|
|
123
|
+
const candidateInit = {
|
|
107
124
|
candidate: event.candidate.candidate,
|
|
108
125
|
sdpMid: event.candidate.sdpMid,
|
|
109
126
|
sdpMLineIndex: event.candidate.sdpMLineIndex
|
|
110
|
-
}
|
|
127
|
+
};
|
|
128
|
+
(window as any).__sendIceCandidate(candidateInit);
|
|
129
|
+
|
|
130
|
+
// Also send loopback version for VPN compatibility (same-machine peers)
|
|
131
|
+
const loopback = createLoopbackCandidate(candidateInit);
|
|
132
|
+
if (loopback) {
|
|
133
|
+
(window as any).__sendIceCandidate(loopback);
|
|
134
|
+
}
|
|
111
135
|
}
|
|
112
136
|
};
|
|
113
137
|
|
|
@@ -337,16 +361,27 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
337
361
|
}
|
|
338
362
|
}
|
|
339
363
|
|
|
340
|
-
// Add ICE candidate
|
|
364
|
+
// Add ICE candidate (+ loopback variant for VPN compatibility)
|
|
341
365
|
async function addIceCandidate(candidate: RTCIceCandidateInit) {
|
|
342
366
|
if (!peerConnection) return false;
|
|
343
367
|
|
|
344
368
|
try {
|
|
345
369
|
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
346
|
-
return true;
|
|
347
370
|
} catch (error) {
|
|
348
371
|
return false;
|
|
349
372
|
}
|
|
373
|
+
|
|
374
|
+
// Also try loopback version for VPN compatibility (same-machine peers)
|
|
375
|
+
const loopback = createLoopbackCandidate(candidate);
|
|
376
|
+
if (loopback) {
|
|
377
|
+
try {
|
|
378
|
+
await peerConnection.addIceCandidate(new RTCIceCandidate(loopback as RTCIceCandidateInit));
|
|
379
|
+
} catch {
|
|
380
|
+
// Expected to fail if loopback is not applicable
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return true;
|
|
350
385
|
}
|
|
351
386
|
|
|
352
387
|
// Reconfigure video encoder with new dimensions (hot-swap)
|
|
@@ -229,10 +229,11 @@ export interface StreamingConfig {
|
|
|
229
229
|
/**
|
|
230
230
|
* Default streaming configuration
|
|
231
231
|
*
|
|
232
|
-
* Optimized for visual quality with
|
|
232
|
+
* Optimized for visual quality with reduced resource usage:
|
|
233
233
|
* - Software encoding (hardwareAcceleration: 'no-preference')
|
|
234
|
-
* - JPEG quality
|
|
235
|
-
* - VP8 at 1.2Mbps
|
|
234
|
+
* - JPEG quality 65: slightly lower than before but still preserves thin borders/text
|
|
235
|
+
* - VP8 at 1.0Mbps: ~17% reduction from 1.2Mbps, sharp edges preserved by VP8 codec
|
|
236
|
+
* - keyframeInterval 5s: less frequent large keyframes, saves bandwidth on static pages
|
|
236
237
|
* - Opus for audio (efficient and widely supported)
|
|
237
238
|
*/
|
|
238
239
|
export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
|
|
@@ -241,9 +242,9 @@ export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
|
|
|
241
242
|
width: 0,
|
|
242
243
|
height: 0,
|
|
243
244
|
framerate: 24,
|
|
244
|
-
bitrate:
|
|
245
|
-
keyframeInterval:
|
|
246
|
-
screenshotQuality:
|
|
245
|
+
bitrate: 1_000_000,
|
|
246
|
+
keyframeInterval: 5,
|
|
247
|
+
screenshotQuality: 65,
|
|
247
248
|
hardwareAcceleration: 'no-preference',
|
|
248
249
|
latencyMode: 'realtime'
|
|
249
250
|
},
|
|
@@ -12,6 +12,9 @@ import type { KeyInput } from 'puppeteer';
|
|
|
12
12
|
import { debug } from '$shared/utils/logger';
|
|
13
13
|
import { sleep } from '$shared/utils/async';
|
|
14
14
|
|
|
15
|
+
// Throttle cursor detection evaluate calls per session (100ms = ~10/sec is plenty)
|
|
16
|
+
const lastCursorEvalTime = new Map<string, number>();
|
|
17
|
+
|
|
15
18
|
// Helper function to check if error is navigation-related
|
|
16
19
|
function isNavigationError(error: Error): boolean {
|
|
17
20
|
const msg = error.message.toLowerCase();
|
|
@@ -108,8 +111,9 @@ export const interactPreviewHandler = createRouter()
|
|
|
108
111
|
switch (action.type) {
|
|
109
112
|
case 'mousedown':
|
|
110
113
|
try {
|
|
111
|
-
//
|
|
112
|
-
|
|
114
|
+
// Fire-and-forget reset — CDP processes commands in FIFO order so
|
|
115
|
+
// this completes before move/down even though we skip the await.
|
|
116
|
+
session.page.mouse.up().catch(() => {});
|
|
113
117
|
// Move to position and press button
|
|
114
118
|
await session.page.mouse.move(action.x!, action.y!, { steps: 1 });
|
|
115
119
|
await session.page.mouse.down({ button: action.button === 'right' ? 'right' : 'left' });
|
|
@@ -140,14 +144,10 @@ export const interactPreviewHandler = createRouter()
|
|
|
140
144
|
|
|
141
145
|
case 'click':
|
|
142
146
|
try {
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} catch { /* Ignore - mouse might not be pressed */ }
|
|
148
|
-
|
|
149
|
-
// IMPORTANT: Check for select element BEFORE clicking
|
|
150
|
-
// If it's a select, we'll emit event to frontend instead of clicking
|
|
147
|
+
// Check for select element BEFORE clicking.
|
|
148
|
+
// Skip the mouse.up() reset: page.mouse.click() is atomic (down+up),
|
|
149
|
+
// and Canvas.svelte always sends mouseup before sending click, so the
|
|
150
|
+
// mouse state is already clean at this point.
|
|
151
151
|
const selectInfo = await previewService.checkForSelectElement(session.id, action.x!, action.y!);
|
|
152
152
|
if (selectInfo) {
|
|
153
153
|
// Select element detected - event emitted by checkForSelectElement
|
|
@@ -243,37 +243,40 @@ export const interactPreviewHandler = createRouter()
|
|
|
243
243
|
await session.page.mouse.move(action.x!, action.y!, {
|
|
244
244
|
steps: action.steps || 1 // Reduced from 5 to 1 for faster response
|
|
245
245
|
});
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
246
|
+
// Cursor detection via page.evaluate — throttled to ~10/sec per session.
|
|
247
|
+
// Running it on every mousemove queues extra CDP commands that delay clicks/keypresses.
|
|
248
|
+
const nowMs = Date.now();
|
|
249
|
+
const lastEval = lastCursorEvalTime.get(session.id) ?? 0;
|
|
250
|
+
if (nowMs - lastEval >= 100) {
|
|
251
|
+
lastCursorEvalTime.set(session.id, nowMs);
|
|
252
|
+
session.page.evaluate((data) => {
|
|
253
|
+
const { x, y } = data;
|
|
254
|
+
let cursor = 'default';
|
|
255
|
+
try {
|
|
256
|
+
const el = document.elementFromPoint(x, y);
|
|
257
|
+
if (el) {
|
|
258
|
+
cursor = window.getComputedStyle(el).cursor || 'default';
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
|
|
262
|
+
const existing = (window as any).__cursorInfo;
|
|
263
|
+
if (existing) {
|
|
264
|
+
existing.cursor = cursor;
|
|
265
|
+
existing.x = x;
|
|
266
|
+
existing.y = y;
|
|
267
|
+
existing.timestamp = Date.now();
|
|
268
|
+
existing.hasRecentInteraction = true;
|
|
269
|
+
} else {
|
|
270
|
+
(window as any).__cursorInfo = {
|
|
271
|
+
cursor,
|
|
272
|
+
x,
|
|
273
|
+
y,
|
|
274
|
+
timestamp: Date.now(),
|
|
275
|
+
hasRecentInteraction: true
|
|
276
|
+
};
|
|
256
277
|
}
|
|
257
|
-
} catch {}
|
|
258
|
-
|
|
259
|
-
// Initialize or update __cursorInfo
|
|
260
|
-
const existing = (window as any).__cursorInfo;
|
|
261
|
-
if (existing) {
|
|
262
|
-
existing.cursor = cursor;
|
|
263
|
-
existing.x = x;
|
|
264
|
-
existing.y = y;
|
|
265
|
-
existing.timestamp = Date.now();
|
|
266
|
-
existing.hasRecentInteraction = true;
|
|
267
|
-
} else {
|
|
268
|
-
(window as any).__cursorInfo = {
|
|
269
|
-
cursor,
|
|
270
|
-
x,
|
|
271
|
-
y,
|
|
272
|
-
timestamp: Date.now(),
|
|
273
|
-
hasRecentInteraction: true
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
}, { x: action.x!, y: action.y! }).catch(() => { /* Ignore evaluation errors */ });
|
|
278
|
+
}, { x: action.x!, y: action.y! }).catch(() => { /* Ignore evaluation errors */ });
|
|
279
|
+
}
|
|
277
280
|
} catch (error) {
|
|
278
281
|
if (error instanceof Error && isNavigationError(error)) {
|
|
279
282
|
ws.emit.user(userId, 'preview:browser-interacted', { action: action.type, message: 'Action deferred (navigation)', deferred: true });
|
|
@@ -297,8 +300,6 @@ export const interactPreviewHandler = createRouter()
|
|
|
297
300
|
|
|
298
301
|
case 'doubleclick':
|
|
299
302
|
try {
|
|
300
|
-
// Reset mouse state first
|
|
301
|
-
try { await session.page.mouse.up(); } catch { }
|
|
302
303
|
await session.page.mouse.click(action.x!, action.y!, { clickCount: 2 });
|
|
303
304
|
} catch (error) {
|
|
304
305
|
if (error instanceof Error) {
|
|
@@ -314,8 +315,8 @@ export const interactPreviewHandler = createRouter()
|
|
|
314
315
|
|
|
315
316
|
case 'rightclick':
|
|
316
317
|
try {
|
|
317
|
-
//
|
|
318
|
-
|
|
318
|
+
// Fire-and-forget reset (see mousedown comment for rationale)
|
|
319
|
+
session.page.mouse.up().catch(() => {});
|
|
319
320
|
|
|
320
321
|
// IMPORTANT: Check for context menu
|
|
321
322
|
// We'll emit context menu event to frontend for custom overlay
|
|
@@ -385,12 +386,7 @@ export const interactPreviewHandler = createRouter()
|
|
|
385
386
|
await session.page.keyboard.press(action.key as KeyInput);
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
|
|
389
|
-
try {
|
|
390
|
-
await sleep(50);
|
|
391
|
-
} catch { }
|
|
392
|
-
}
|
|
393
|
-
}
|
|
389
|
+
}
|
|
394
390
|
break;
|
|
395
391
|
|
|
396
392
|
case 'checkselectoptions':
|
|
@@ -16,7 +16,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
16
16
|
.http(
|
|
17
17
|
'preview:browser-stream-start',
|
|
18
18
|
{
|
|
19
|
-
data: t.Object({
|
|
19
|
+
data: t.Object({
|
|
20
|
+
tabId: t.Optional(t.String())
|
|
21
|
+
}),
|
|
20
22
|
response: t.Object({
|
|
21
23
|
success: t.Boolean(),
|
|
22
24
|
message: t.Optional(t.String()),
|
|
@@ -34,9 +36,10 @@ export const streamPreviewHandler = createRouter()
|
|
|
34
36
|
// Get project-specific preview service
|
|
35
37
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
// Use explicit tabId if provided, otherwise fall back to active tab
|
|
40
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
38
41
|
if (!tab) {
|
|
39
|
-
throw new Error('No active tab');
|
|
42
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const sessionId = tab.id;
|
|
@@ -73,7 +76,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
73
76
|
.http(
|
|
74
77
|
'preview:browser-stream-offer',
|
|
75
78
|
{
|
|
76
|
-
data: t.Object({
|
|
79
|
+
data: t.Object({
|
|
80
|
+
tabId: t.Optional(t.String())
|
|
81
|
+
}),
|
|
77
82
|
response: t.Object({
|
|
78
83
|
success: t.Boolean(),
|
|
79
84
|
offer: t.Optional(
|
|
@@ -90,9 +95,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
90
95
|
// Get project-specific preview service
|
|
91
96
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
92
97
|
|
|
93
|
-
const tab = previewService.getActiveTab();
|
|
98
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
94
99
|
if (!tab) {
|
|
95
|
-
throw new Error('No active tab');
|
|
100
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
const offer = await previewService.getWebCodecsOffer(tab.id);
|
|
@@ -117,7 +122,8 @@ export const streamPreviewHandler = createRouter()
|
|
|
117
122
|
answer: t.Object({
|
|
118
123
|
type: t.String(),
|
|
119
124
|
sdp: t.Optional(t.String())
|
|
120
|
-
})
|
|
125
|
+
}),
|
|
126
|
+
tabId: t.Optional(t.String())
|
|
121
127
|
}),
|
|
122
128
|
response: t.Object({
|
|
123
129
|
success: t.Boolean()
|
|
@@ -129,9 +135,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
129
135
|
// Get project-specific preview service
|
|
130
136
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
131
137
|
|
|
132
|
-
const tab = previewService.getActiveTab();
|
|
138
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
133
139
|
if (!tab) {
|
|
134
|
-
throw new Error('No active tab');
|
|
140
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
const { answer } = data;
|
|
@@ -150,7 +156,8 @@ export const streamPreviewHandler = createRouter()
|
|
|
150
156
|
candidate: t.Optional(t.String()),
|
|
151
157
|
sdpMid: t.Optional(t.Union([t.String(), t.Null()])),
|
|
152
158
|
sdpMLineIndex: t.Optional(t.Union([t.Number(), t.Null()]))
|
|
153
|
-
})
|
|
159
|
+
}),
|
|
160
|
+
tabId: t.Optional(t.String())
|
|
154
161
|
}),
|
|
155
162
|
response: t.Object({
|
|
156
163
|
success: t.Boolean()
|
|
@@ -162,9 +169,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
162
169
|
// Get project-specific preview service
|
|
163
170
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
164
171
|
|
|
165
|
-
const tab = previewService.getActiveTab();
|
|
172
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
166
173
|
if (!tab) {
|
|
167
|
-
throw new Error('No active tab');
|
|
174
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
const { candidate } = data;
|
|
@@ -178,7 +185,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
178
185
|
.http(
|
|
179
186
|
'preview:browser-stream-stop',
|
|
180
187
|
{
|
|
181
|
-
data: t.Object({
|
|
188
|
+
data: t.Object({
|
|
189
|
+
tabId: t.Optional(t.String())
|
|
190
|
+
}),
|
|
182
191
|
response: t.Object({
|
|
183
192
|
success: t.Boolean()
|
|
184
193
|
})
|
|
@@ -189,9 +198,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
189
198
|
// Get project-specific preview service
|
|
190
199
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
191
200
|
|
|
192
|
-
const tab = previewService.getActiveTab();
|
|
201
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
193
202
|
if (!tab) {
|
|
194
|
-
throw new Error('No active tab');
|
|
203
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
await previewService.stopWebCodecsStreaming(tab.id);
|
|
@@ -252,6 +261,17 @@ export const streamPreviewHandler = createRouter()
|
|
|
252
261
|
url: t.String(),
|
|
253
262
|
timestamp: t.Number()
|
|
254
263
|
})
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
// Server → Client: SPA navigation (pushState/replaceState — URL-only update, no page reload)
|
|
267
|
+
.emit(
|
|
268
|
+
'preview:browser-navigation-spa',
|
|
269
|
+
t.Object({
|
|
270
|
+
sessionId: t.String(),
|
|
271
|
+
type: t.String(),
|
|
272
|
+
url: t.String(),
|
|
273
|
+
timestamp: t.Number()
|
|
274
|
+
})
|
|
255
275
|
);
|
|
256
276
|
|
|
257
277
|
// Setup event forwarding from preview service to WebSocket
|
|
@@ -143,4 +143,12 @@ export const previewRouter = createRouter()
|
|
|
143
143
|
sessionId: t.String(),
|
|
144
144
|
timestamp: t.Number(),
|
|
145
145
|
source: t.Literal('mcp')
|
|
146
|
+
}))
|
|
147
|
+
.emit('preview:browser-viewport-changed', t.Object({
|
|
148
|
+
tabId: t.String(),
|
|
149
|
+
deviceSize: t.String(),
|
|
150
|
+
rotation: t.String(),
|
|
151
|
+
width: t.Number(),
|
|
152
|
+
height: t.Number(),
|
|
153
|
+
timestamp: t.Number()
|
|
146
154
|
}));
|
|
@@ -416,12 +416,12 @@
|
|
|
416
416
|
ondrop={fileHandling.handleDrop}
|
|
417
417
|
>
|
|
418
418
|
<div class="flex-1">
|
|
419
|
-
<!-- Engine/Model Picker -->
|
|
420
|
-
<EngineModelPicker />
|
|
421
|
-
|
|
422
419
|
<!-- Edit Mode Indicator -->
|
|
423
420
|
<EditModeIndicator onCancel={handleCancelEdit} />
|
|
424
421
|
|
|
422
|
+
<!-- Engine/Model Picker -->
|
|
423
|
+
<EngineModelPicker />
|
|
424
|
+
|
|
425
425
|
<div class="flex items-end">
|
|
426
426
|
<textarea
|
|
427
427
|
bind:this={textareaElement}
|