@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
@@ -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
- // Inject video encoder + audio capture scripts
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 = 3;
387
- const retryDelay = 50;
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
- (window as any).__sendIceCandidate({
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 reasonable bandwidth:
232
+ * Optimized for visual quality with reduced resource usage:
233
233
  * - Software encoding (hardwareAcceleration: 'no-preference')
234
- * - JPEG quality 70 preserves thin borders/text, VP8 handles bandwidth
235
- * - VP8 at 1.2Mbps for crisp UI/text rendering
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: 1_200_000,
245
- keyframeInterval: 3,
246
- screenshotQuality: 70,
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
- // Reset mouse state first to ensure clean state
112
- try { await session.page.mouse.up(); } catch { }
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
- // Reset mouse state before click to prevent "already pressed" errors
144
- // This ensures a clean state for each click operation
145
- try {
146
- await session.page.mouse.up();
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
- // Update cursor position and detect cursor type in browser context (fire-and-forget, don't await)
247
- // This replaces the disabled cursor-tracking script (blocked by CloudFlare)
248
- session.page.evaluate((data) => {
249
- const { x, y } = data;
250
- // Detect cursor type from element under mouse
251
- let cursor = 'default';
252
- try {
253
- const el = document.elementFromPoint(x, y);
254
- if (el) {
255
- cursor = window.getComputedStyle(el).cursor || 'default';
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
- // Reset mouse state first
318
- try { await session.page.mouse.up(); } catch { }
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
- if (['ArrowDown', 'ArrowUp'].includes(action.key)) {
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
- const tab = previewService.getActiveTab();
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}