@myrialabs/clopen 0.2.6 → 0.2.7

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 (28) hide show
  1. package/backend/chat/stream-manager.ts +1 -1
  2. package/backend/engine/adapters/claude/stream.ts +10 -19
  3. package/backend/mcp/servers/browser-automation/browser.ts +23 -6
  4. package/backend/preview/browser/browser-mcp-control.ts +32 -16
  5. package/backend/preview/browser/browser-pool.ts +3 -1
  6. package/backend/preview/browser/browser-tab-manager.ts +1 -1
  7. package/backend/preview/browser/scripts/audio-stream.ts +11 -0
  8. package/backend/ws/chat/stream.ts +1 -1
  9. package/backend/ws/preview/browser/tab-info.ts +5 -2
  10. package/frontend/components/chat/input/ChatInput.svelte +0 -3
  11. package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
  12. package/frontend/components/history/HistoryModal.svelte +1 -1
  13. package/frontend/components/preview/browser/BrowserPreview.svelte +14 -0
  14. package/frontend/components/preview/browser/components/Canvas.svelte +322 -48
  15. package/frontend/components/preview/browser/components/Container.svelte +21 -0
  16. package/frontend/components/preview/browser/core/coordinator.svelte.ts +15 -0
  17. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
  18. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
  19. package/frontend/components/workspace/PanelHeader.svelte +15 -0
  20. package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
  21. package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
  22. package/frontend/services/chat/chat.service.ts +3 -7
  23. package/frontend/services/preview/browser/browser-webcodecs.service.ts +30 -133
  24. package/frontend/stores/core/app.svelte.ts +4 -3
  25. package/frontend/stores/core/presence.svelte.ts +3 -2
  26. package/frontend/stores/core/sessions.svelte.ts +2 -0
  27. package/frontend/stores/ui/notification.svelte.ts +4 -1
  28. package/package.json +1 -1
@@ -24,7 +24,9 @@
24
24
  onCursorUpdate = $bindable<(cursor: string) => void>(() => {}),
25
25
  onFrameUpdate = $bindable<(data: any) => void>(() => {}),
26
26
  onStatsUpdate = $bindable<(stats: BrowserWebCodecsStreamStats | null) => void>(() => {}),
27
- onRequestScreencastRefresh = $bindable<() => void>(() => {}) // Called when stream is stuck
27
+ onRequestScreencastRefresh = $bindable<() => void>(() => {}), // Called when stream is stuck
28
+ touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
29
+ onTouchCursorUpdate = $bindable<(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) => void>(() => {})
28
30
  } = $props();
29
31
 
30
32
  // WebCodecs service instance
@@ -83,6 +85,7 @@
83
85
  // Clear canvas snapshots - they belong to old project's sessions
84
86
  canvasSnapshots.clear();
85
87
  hasRestoredSnapshot = false;
88
+ lastStartRequestId = null; // Clear so new project sessions aren't blocked by old tab IDs
86
89
 
87
90
  // Destroy old service
88
91
  if (webCodecsService) {
@@ -296,6 +299,24 @@
296
299
  let mouseDownTime = $state(0);
297
300
  let dragStarted = $state(false); // Track if we've sent mousedown for drag
298
301
 
302
+ // Touch-specific tracking (non-reactive for performance)
303
+ let longPressTimer: ReturnType<typeof setTimeout> | null = null;
304
+ let touchLongPressed = false;
305
+ let lastTouchCoords: { x: number; y: number } | null = null;
306
+
307
+ // Trackpad cursor state (cursor/trackpad mode - persists between touch gestures)
308
+ let trackpadCursorX = 0;
309
+ let trackpadCursorY = 0;
310
+ let trackpadLastClientX = 0;
311
+ let trackpadLastClientY = 0;
312
+ let trackpadTouchStartClientX = 0;
313
+ let trackpadTouchStartClientY = 0;
314
+ let trackpadTwoFingerActive = false;
315
+ let trackpadTwoFingerStartTime = 0;
316
+ let trackpadTwoFingerLastCenterX = 0;
317
+ let trackpadTwoFingerLastCenterY = 0;
318
+ let trackpadTwoFingerTotalDist = 0;
319
+
299
320
  function handleCanvasMouseDown(event: MouseEvent, canvas: HTMLCanvasElement) {
300
321
  if (!sessionId) return;
301
322
 
@@ -403,24 +424,29 @@
403
424
 
404
425
  // Start WebCodecs streaming
405
426
  async function startStreaming() {
406
- if (!sessionId || !canvasElement) return;
427
+ debug.log('webcodecs', `[DIAG] startStreaming() called: sessionId=${sessionId}, canvasElement=${!!canvasElement}, isStartingStream=${isStartingStream}, isWebCodecsActive=${isWebCodecsActive}, activeStreamingSessionId=${activeStreamingSessionId}, lastStartRequestId=${lastStartRequestId}`);
428
+
429
+ if (!sessionId || !canvasElement) {
430
+ debug.log('webcodecs', `[DIAG] startStreaming() early exit: missing sessionId=${!sessionId} or canvasElement=${!canvasElement}`);
431
+ return;
432
+ }
407
433
 
408
434
  // Prevent concurrent start attempts
409
435
  if (isStartingStream) {
410
- debug.log('webcodecs', 'Already starting stream, skipping duplicate call');
436
+ debug.log('webcodecs', '[DIAG] startStreaming() skipped: already starting stream');
411
437
  return;
412
438
  }
413
439
 
414
440
  // If already streaming same session, skip
415
441
  if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
416
- debug.log('webcodecs', 'Already streaming same session, skipping');
442
+ debug.log('webcodecs', '[DIAG] startStreaming() skipped: already streaming same session');
417
443
  return;
418
444
  }
419
445
 
420
446
  // Prevent duplicate requests for same session
421
447
  const requestId = `${sessionId}-${Date.now()}`;
422
448
  if (lastStartRequestId && lastStartRequestId.startsWith(sessionId)) {
423
- debug.log('webcodecs', `Duplicate start request for ${sessionId}, skipping`);
449
+ debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
424
450
  return;
425
451
  }
426
452
  lastStartRequestId = requestId;
@@ -538,10 +564,17 @@
538
564
  startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
539
565
  hasRestoredSnapshot = false; // Reset after using
540
566
  debug.log('webcodecs', 'Streaming started successfully');
541
- break;
567
+ } else {
568
+ // Service handles errors internally and returns false.
569
+ // Never retry here — retrying immediately creates a stop/start loop.
570
+ debug.warn('webcodecs', 'Streaming start returned false, stopping retries');
542
571
  }
572
+ // Always break after the service returns (success or failure).
573
+ // The service catches all exceptions internally, so the catch block
574
+ // below never runs, making retries/retryDelay dead code anyway.
575
+ break;
543
576
  } catch (error: any) {
544
- // Check if it's a retriable error - might just need more time
577
+ // This block only runs if the service unexpectedly throws.
545
578
  const isRetriable = error?.message?.includes('not found') ||
546
579
  error?.message?.includes('invalid') ||
547
580
  error?.message?.includes('Failed to start') ||
@@ -554,11 +587,9 @@
554
587
  await new Promise(resolve => setTimeout(resolve, retryDelay));
555
588
  } else {
556
589
  debug.error('webcodecs', 'Max retries reached, streaming still not ready');
557
- // Don't throw - just fail silently and let user retry manually
558
590
  break;
559
591
  }
560
592
  } else {
561
- // Other errors - don't retry, just log
562
593
  debug.error('webcodecs', 'Streaming error:', error);
563
594
  break;
564
595
  }
@@ -850,6 +881,8 @@
850
881
  // Start/restart streaming when session is ready
851
882
  // This handles both initial start and session changes (viewport switch, etc.)
852
883
  $effect(() => {
884
+ debug.log('webcodecs', `[DIAG] streaming $effect triggered: sessionId=${sessionId}, canvasElement=${!!canvasElement}, sessionInfo=${!!sessionInfo}, isReconnecting=${isReconnecting}, isWebCodecsActive=${isWebCodecsActive}, activeStreamingSessionId=${activeStreamingSessionId}`);
885
+
853
886
  if (sessionId && canvasElement && sessionInfo) {
854
887
  // Skip during fast reconnect - fastReconnect() handles this case
855
888
  if (isReconnecting) {
@@ -859,6 +892,7 @@
859
892
 
860
893
  // Check if we need to start or restart streaming
861
894
  const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
895
+ debug.log('webcodecs', `[DIAG] streaming $effect: needsStreaming=${needsStreaming}`);
862
896
 
863
897
  if (needsStreaming) {
864
898
  if (activeStreamingSessionId !== sessionId) {
@@ -968,9 +1002,20 @@
968
1002
  canvas.focus();
969
1003
  });
970
1004
 
971
- canvas.addEventListener('touchstart', (e) => handleTouchStart(e, canvas), { passive: false });
972
- canvas.addEventListener('touchmove', (e) => handleTouchMove(e, canvas), { passive: false });
973
- canvas.addEventListener('touchend', (e) => handleTouchEnd(e, canvas), { passive: false });
1005
+ const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
1006
+ let lastTouchMoveTime = 0;
1007
+ const touchMoveHandler = (e: TouchEvent) => {
1008
+ const now = Date.now();
1009
+ if (now - lastTouchMoveTime >= 16) {
1010
+ lastTouchMoveTime = now;
1011
+ handleTouchMove(e, canvas);
1012
+ }
1013
+ };
1014
+ const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
1015
+
1016
+ canvas.addEventListener('touchstart', touchStartHandler, { passive: false });
1017
+ canvas.addEventListener('touchmove', touchMoveHandler, { passive: false });
1018
+ canvas.addEventListener('touchend', touchEndHandler, { passive: false });
974
1019
 
975
1020
  const handleMouseLeave = () => {
976
1021
  if (isMouseDown) {
@@ -1000,76 +1045,299 @@
1000
1045
  canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
1001
1046
  canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
1002
1047
  canvas.removeEventListener('mousemove', handleMouseMove);
1003
- canvas.removeEventListener('touchstart', (e) => handleTouchStart(e, canvas));
1004
- canvas.removeEventListener('touchmove', (e) => handleTouchMove(e, canvas));
1005
- canvas.removeEventListener('touchend', (e) => handleTouchEnd(e, canvas));
1048
+ canvas.removeEventListener('touchstart', touchStartHandler);
1049
+ canvas.removeEventListener('touchmove', touchMoveHandler);
1050
+ canvas.removeEventListener('touchend', touchEndHandler);
1006
1051
  };
1007
1052
  }
1008
1053
  });
1009
1054
 
1055
+ // Convert canvas coordinates to viewport (screen) coordinates for VirtualCursor display
1056
+ function canvasToScreen(cx: number, cy: number): { x: number; y: number } {
1057
+ if (!canvasElement) return { x: 0, y: 0 };
1058
+ const rect = canvasElement.getBoundingClientRect();
1059
+ return {
1060
+ x: rect.left + cx * (rect.width / canvasElement.width),
1061
+ y: rect.top + cy * (rect.height / canvasElement.height)
1062
+ };
1063
+ }
1064
+
1065
+ // Show / hide cursor when touchMode changes
1066
+ $effect(() => {
1067
+ if (touchMode === 'cursor') {
1068
+ // Init cursor at canvas center on first activation
1069
+ if (canvasElement && trackpadCursorX === 0 && trackpadCursorY === 0) {
1070
+ trackpadCursorX = canvasElement.width / 2;
1071
+ trackpadCursorY = canvasElement.height / 2;
1072
+ }
1073
+ if (canvasElement) {
1074
+ const pos = canvasToScreen(trackpadCursorX, trackpadCursorY);
1075
+ onTouchCursorUpdate({ x: pos.x, y: pos.y, visible: true });
1076
+ }
1077
+ } else {
1078
+ onTouchCursorUpdate({ x: 0, y: 0, visible: false });
1079
+ }
1080
+ });
1081
+
1082
+ // ── Trackpad (cursor) mode handlers ───────────────────────────────────────
1083
+
1084
+ function handleTrackpadTouchStart(event: TouchEvent) {
1085
+ if (event.touches.length >= 2) {
1086
+ // Second finger joined → switch to two-finger mode
1087
+ if (!trackpadTwoFingerActive) {
1088
+ // Cancel any pending single-finger actions
1089
+ if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
1090
+ if (touchLongPressed && dragStarted) {
1091
+ sendInteraction({ type: 'mouseup', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY), button: 'left' });
1092
+ }
1093
+ isMouseDown = false;
1094
+ dragStarted = false;
1095
+ touchLongPressed = false;
1096
+ }
1097
+ trackpadTwoFingerActive = true;
1098
+ trackpadTwoFingerStartTime = Date.now();
1099
+ trackpadTwoFingerTotalDist = 0;
1100
+ const t1 = event.touches[0];
1101
+ const t2 = event.touches[1];
1102
+ trackpadTwoFingerLastCenterX = (t1.clientX + t2.clientX) / 2;
1103
+ trackpadTwoFingerLastCenterY = (t1.clientY + t2.clientY) / 2;
1104
+ return;
1105
+ }
1106
+
1107
+ if (trackpadTwoFingerActive) return; // Ignore until two-finger gesture fully ends
1108
+
1109
+ // Single finger
1110
+ const touch = event.touches[0];
1111
+ trackpadTouchStartClientX = touch.clientX;
1112
+ trackpadTouchStartClientY = touch.clientY;
1113
+ trackpadLastClientX = touch.clientX;
1114
+ trackpadLastClientY = touch.clientY;
1115
+ isMouseDown = true;
1116
+ mouseDownTime = Date.now();
1117
+ dragStarted = false;
1118
+ touchLongPressed = false;
1119
+
1120
+ // Long-press (600ms without movement) → drag mode
1121
+ longPressTimer = setTimeout(() => {
1122
+ if (!isMouseDown) return;
1123
+ const dist = Math.sqrt(
1124
+ Math.pow(trackpadLastClientX - trackpadTouchStartClientX, 2) +
1125
+ Math.pow(trackpadLastClientY - trackpadTouchStartClientY, 2)
1126
+ );
1127
+ if (dist < 8) {
1128
+ touchLongPressed = true;
1129
+ dragStarted = true;
1130
+ sendInteraction({ type: 'mousedown', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY), button: 'left' });
1131
+ }
1132
+ }, 600);
1133
+ }
1134
+
1135
+ function handleTrackpadTouchMove(event: TouchEvent) {
1136
+ if (!canvasElement) return;
1137
+
1138
+ if (event.touches.length >= 2 && trackpadTwoFingerActive) {
1139
+ // Two-finger scroll
1140
+ const t1 = event.touches[0];
1141
+ const t2 = event.touches[1];
1142
+ const centerX = (t1.clientX + t2.clientX) / 2;
1143
+ const centerY = (t1.clientY + t2.clientY) / 2;
1144
+ const deltaX = trackpadTwoFingerLastCenterX - centerX;
1145
+ const deltaY = trackpadTwoFingerLastCenterY - centerY;
1146
+ trackpadTwoFingerLastCenterX = centerX;
1147
+ trackpadTwoFingerLastCenterY = centerY;
1148
+ trackpadTwoFingerTotalDist += Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1149
+ if (Math.abs(deltaX) > 0.3 || Math.abs(deltaY) > 0.3) {
1150
+ const rect = canvasElement.getBoundingClientRect();
1151
+ const scale = canvasElement.width / rect.width;
1152
+ sendInteraction({ type: 'scroll', deltaX: deltaX * scale * 2, deltaY: deltaY * scale * 2 });
1153
+ }
1154
+ return;
1155
+ }
1156
+
1157
+ if (event.touches.length !== 1 || !isMouseDown || trackpadTwoFingerActive) return;
1158
+
1159
+ const touch = event.touches[0];
1160
+ const deltaClientX = touch.clientX - trackpadLastClientX;
1161
+ const deltaClientY = touch.clientY - trackpadLastClientY;
1162
+ trackpadLastClientX = touch.clientX;
1163
+ trackpadLastClientY = touch.clientY;
1164
+
1165
+ // Cancel long-press if finger moved significantly
1166
+ const totalDist = Math.sqrt(
1167
+ Math.pow(touch.clientX - trackpadTouchStartClientX, 2) +
1168
+ Math.pow(touch.clientY - trackpadTouchStartClientY, 2)
1169
+ );
1170
+ if (totalDist > 8 && longPressTimer) {
1171
+ clearTimeout(longPressTimer);
1172
+ longPressTimer = null;
1173
+ }
1174
+
1175
+ // Convert screen delta → canvas delta and move cursor
1176
+ const rect = canvasElement.getBoundingClientRect();
1177
+ const scale = canvasElement.width / rect.width;
1178
+ trackpadCursorX = Math.max(0, Math.min(canvasElement.width, trackpadCursorX + deltaClientX * scale));
1179
+ trackpadCursorY = Math.max(0, Math.min(canvasElement.height, trackpadCursorY + deltaClientY * scale));
1180
+
1181
+ // Send mousemove so the browser sees hover state changes
1182
+ sendInteraction({ type: 'mousemove', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY) });
1183
+
1184
+ // Update virtual cursor display
1185
+ const pos = canvasToScreen(trackpadCursorX, trackpadCursorY);
1186
+ onTouchCursorUpdate({ x: pos.x, y: pos.y, visible: true });
1187
+ }
1188
+
1189
+ function handleTrackpadTouchEnd(event: TouchEvent) {
1190
+ if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
1191
+
1192
+ const remainingTouches = event.touches.length;
1193
+
1194
+ if (trackpadTwoFingerActive) {
1195
+ if (remainingTouches === 0) {
1196
+ // All fingers lifted: check for two-finger tap → right click
1197
+ const duration = Date.now() - trackpadTwoFingerStartTime;
1198
+ if (duration < 300 && trackpadTwoFingerTotalDist < 20) {
1199
+ sendInteraction({ type: 'rightclick', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY) });
1200
+ }
1201
+ trackpadTwoFingerActive = false;
1202
+ } else if (remainingTouches === 1) {
1203
+ // One finger remains: transition back to single-finger tracking
1204
+ const touch = event.touches[0];
1205
+ trackpadLastClientX = touch.clientX;
1206
+ trackpadLastClientY = touch.clientY;
1207
+ trackpadTouchStartClientX = touch.clientX;
1208
+ trackpadTouchStartClientY = touch.clientY;
1209
+ isMouseDown = true;
1210
+ mouseDownTime = Date.now();
1211
+ trackpadTwoFingerActive = false;
1212
+ }
1213
+ return;
1214
+ }
1215
+
1216
+ if (!isMouseDown) return;
1217
+
1218
+ if (touchLongPressed && dragStarted) {
1219
+ sendInteraction({ type: 'mouseup', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY), button: 'left' });
1220
+ } else {
1221
+ // Tap: short + minimal movement → left click at cursor position
1222
+ const duration = Date.now() - mouseDownTime;
1223
+ const moveDist = Math.sqrt(
1224
+ Math.pow(trackpadLastClientX - trackpadTouchStartClientX, 2) +
1225
+ Math.pow(trackpadLastClientY - trackpadTouchStartClientY, 2)
1226
+ );
1227
+ if (duration < 250 && moveDist < 10) {
1228
+ sendInteraction({ type: 'click', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY) });
1229
+ }
1230
+ }
1231
+
1232
+ isMouseDown = false;
1233
+ dragStarted = false;
1234
+ touchLongPressed = false;
1235
+ }
1236
+
1237
+ // ── Touch event handlers (dispatch to scroll or trackpad mode) ────────────
1238
+
1010
1239
  // Touch event handlers
1011
1240
  function handleTouchStart(event: TouchEvent, canvas: HTMLCanvasElement) {
1012
1241
  if (!sessionId || event.touches.length === 0) return;
1013
1242
  event.preventDefault();
1014
1243
 
1244
+ if (touchMode === 'cursor') {
1245
+ handleTrackpadTouchStart(event);
1246
+ return;
1247
+ }
1248
+
1249
+ // ── Scroll mode ──────────────────────────────────────────────────────────
1250
+ if (event.touches.length > 1) return;
1251
+
1015
1252
  const coords = getCanvasCoordinates(event, canvas);
1016
1253
  isMouseDown = true;
1017
1254
  mouseDownTime = Date.now();
1018
1255
  dragStartPos = { x: coords.x, y: coords.y };
1019
1256
  dragCurrentPos = { x: coords.x, y: coords.y };
1020
- dragStarted = false; // Reset drag started flag
1257
+ dragStarted = false;
1258
+ touchLongPressed = false;
1259
+ lastTouchCoords = { x: coords.x, y: coords.y };
1260
+
1261
+ // Long-press detection: after 500ms without significant movement → drag mode
1262
+ longPressTimer = setTimeout(() => {
1263
+ if (!isMouseDown || !dragStartPos) return;
1264
+ const dist = dragCurrentPos
1265
+ ? Math.sqrt(
1266
+ Math.pow(dragCurrentPos.x - dragStartPos.x, 2) +
1267
+ Math.pow(dragCurrentPos.y - dragStartPos.y, 2)
1268
+ )
1269
+ : 0;
1270
+ if (dist < 10) {
1271
+ touchLongPressed = true;
1272
+ dragStarted = true;
1273
+ sendInteraction({ type: 'mousedown', x: dragStartPos.x, y: dragStartPos.y, button: 'left' });
1274
+ }
1275
+ }, 500);
1021
1276
  }
1022
1277
 
1023
1278
  function handleTouchMove(event: TouchEvent, canvas: HTMLCanvasElement) {
1024
- if (!sessionId || event.touches.length === 0 || !isMouseDown || !dragStartPos) return;
1279
+ if (!sessionId || event.touches.length === 0) return;
1025
1280
  event.preventDefault();
1026
1281
 
1282
+ if (touchMode === 'cursor') {
1283
+ handleTrackpadTouchMove(event);
1284
+ return;
1285
+ }
1286
+
1287
+ // ── Scroll mode ──────────────────────────────────────────────────────────
1288
+ if (!isMouseDown || !dragStartPos) return;
1289
+
1027
1290
  const coords = getCanvasCoordinates(event, canvas);
1028
1291
  dragCurrentPos = { x: coords.x, y: coords.y };
1029
1292
 
1030
- const dragDistance = Math.sqrt(
1293
+ const dist = Math.sqrt(
1031
1294
  Math.pow(coords.x - dragStartPos.x, 2) + Math.pow(coords.y - dragStartPos.y, 2)
1032
1295
  );
1033
1296
 
1034
- if (dragDistance > 15) {
1035
- // Send mousedown on first drag detection
1036
- if (!dragStarted) {
1037
- sendInteraction({
1038
- type: 'mousedown',
1039
- x: dragStartPos.x,
1040
- y: dragStartPos.y,
1041
- button: 'left'
1042
- });
1043
- dragStarted = true;
1044
- }
1297
+ if (dist > 10 && longPressTimer) {
1298
+ clearTimeout(longPressTimer);
1299
+ longPressTimer = null;
1300
+ }
1045
1301
 
1302
+ if (touchLongPressed) {
1046
1303
  isDragging = true;
1047
- // Send mousemove to continue dragging (mouse is already down)
1048
- sendInteraction({
1049
- type: 'mousemove',
1050
- x: coords.x,
1051
- y: coords.y
1052
- });
1304
+ sendInteraction({ type: 'mousemove', x: coords.x, y: coords.y });
1305
+ } else {
1306
+ if (lastTouchCoords) {
1307
+ const deltaX = lastTouchCoords.x - coords.x;
1308
+ const deltaY = lastTouchCoords.y - coords.y;
1309
+ sendInteraction({ type: 'scroll', deltaX, deltaY });
1310
+ }
1311
+ lastTouchCoords = { x: coords.x, y: coords.y };
1053
1312
  }
1054
1313
  }
1055
1314
 
1056
1315
  function handleTouchEnd(event: TouchEvent, canvas: HTMLCanvasElement) {
1057
- if (!sessionId || !isMouseDown) return;
1316
+ if (!sessionId) return;
1058
1317
  event.preventDefault();
1059
1318
 
1060
- if (dragStartPos) {
1061
- const endPos = dragCurrentPos || dragStartPos;
1319
+ if (touchMode === 'cursor') {
1320
+ handleTrackpadTouchEnd(event);
1321
+ return;
1322
+ }
1062
1323
 
1063
- // If drag was started (mousedown was sent), send mouseup
1064
- if (dragStarted) {
1065
- sendInteraction({
1066
- type: 'mouseup',
1067
- x: endPos.x,
1068
- y: endPos.y,
1069
- button: 'left'
1070
- });
1071
- } else {
1072
- // No drag occurred, this is a tap/click
1324
+ // ── Scroll mode ──────────────────────────────────────────────────────────
1325
+ if (!isMouseDown) return;
1326
+
1327
+ if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
1328
+
1329
+ if (touchLongPressed && dragStarted) {
1330
+ const endPos = dragCurrentPos || dragStartPos;
1331
+ if (endPos) sendInteraction({ type: 'mouseup', x: endPos.x, y: endPos.y, button: 'left' });
1332
+ } else if (!isDragging && dragStartPos) {
1333
+ const touchDuration = Date.now() - mouseDownTime;
1334
+ const dist = dragCurrentPos
1335
+ ? Math.sqrt(
1336
+ Math.pow(dragCurrentPos.x - dragStartPos.x, 2) +
1337
+ Math.pow(dragCurrentPos.y - dragStartPos.y, 2)
1338
+ )
1339
+ : 0;
1340
+ if (touchDuration < 300 && dist < 15) {
1073
1341
  sendInteraction({ type: 'click', x: dragStartPos.x, y: dragStartPos.y });
1074
1342
  }
1075
1343
  }
@@ -1079,6 +1347,8 @@
1079
1347
  dragStartPos = null;
1080
1348
  dragCurrentPos = null;
1081
1349
  dragStarted = false;
1350
+ touchLongPressed = false;
1351
+ lastTouchCoords = null;
1082
1352
  }
1083
1353
 
1084
1354
  function getCanvasElement() {
@@ -1128,6 +1398,10 @@
1128
1398
  onDestroy(() => {
1129
1399
  stopHealthCheck(); // Stop health monitoring
1130
1400
  canvasSnapshots.clear(); // Free snapshot memory
1401
+ if (longPressTimer) {
1402
+ clearTimeout(longPressTimer);
1403
+ longPressTimer = null;
1404
+ }
1131
1405
  if (webCodecsService) {
1132
1406
  webCodecsService.destroy();
1133
1407
  webCodecsService = null;
@@ -44,12 +44,16 @@
44
44
  // Preview dimensions (bindable to parent)
45
45
  previewDimensions = $bindable<any>({ scale: 1 }),
46
46
 
47
+ // Touch interaction mode
48
+ touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
49
+
47
50
  // Callbacks
48
51
  onInteraction = $bindable<(action: any) => void>(() => {}),
49
52
  onRetry = $bindable<() => void>(() => {})
50
53
  } = $props();
51
54
 
52
55
  let previewContainer = $state<HTMLDivElement | undefined>();
56
+ let touchCursorPos = $state<{ x: number; y: number; visible: boolean; clicking?: boolean }>({ x: 0, y: 0, visible: false });
53
57
 
54
58
  // Solid loading overlay: shown during initial load states
55
59
  // Skip when lastFrameData exists (tab was previously loaded - snapshot handles display)
@@ -57,6 +61,11 @@
57
61
  isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting && !lastFrameData)
58
62
  );
59
63
 
64
+ // Diagnostic: log whenever showSolidOverlay changes
65
+ $effect(() => {
66
+ debug.log('preview', `[DIAG] showSolidOverlay=${showSolidOverlay} (isLaunchingBrowser=${isLaunchingBrowser}, sessionInfo=${!!sessionInfo}, isStreamReady=${isStreamReady}, isNavigating=${isNavigating}, isReconnecting=${isReconnecting}, lastFrameData=${!!lastFrameData})`);
67
+ });
68
+
60
69
  // Navigation overlay state with debounce to prevent flickering during state transitions
61
70
  let showNavigationOverlay = $state(false);
62
71
  let overlayHideTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -89,6 +98,10 @@
89
98
  }
90
99
  });
91
100
 
101
+ function handleTouchCursorUpdate(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) {
102
+ touchCursorPos = { x: pos.x, y: pos.y, visible: pos.visible, clicking: pos.clicking };
103
+ }
104
+
92
105
  onDestroy(() => {
93
106
  if (overlayHideTimeout) {
94
107
  clearTimeout(overlayHideTimeout);
@@ -371,10 +384,12 @@
371
384
  bind:isStreamReady
372
385
  bind:isNavigating
373
386
  bind:isReconnecting
387
+ bind:touchMode
374
388
  onInteraction={handleCanvasInteraction}
375
389
  onCursorUpdate={handleCursorUpdate}
376
390
  onFrameUpdate={handleFrameUpdate}
377
391
  onRequestScreencastRefresh={handleScreencastRefresh}
392
+ onTouchCursorUpdate={handleTouchCursorUpdate}
378
393
  />
379
394
  </div>
380
395
  {/if}
@@ -427,6 +442,7 @@
427
442
  </div>
428
443
  {/if}
429
444
 
445
+
430
446
  </div>
431
447
  {:else}
432
448
  <div
@@ -445,6 +461,11 @@
445
461
  <VirtualCursor cursor={virtualCursor} />
446
462
  {/if}
447
463
 
464
+ <!-- Touch Cursor - shown in cursor simulation mode -->
465
+ {#if touchMode === 'cursor' && touchCursorPos.visible}
466
+ <VirtualCursor cursor={touchCursorPos} />
467
+ {/if}
468
+
448
469
  <!-- MCP Virtual Cursor -->
449
470
  {#if mcpVirtualCursor.visible}
450
471
  <VirtualCursor cursor={mcpVirtualCursor} />
@@ -510,6 +510,13 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
510
510
  if (backendTab.isActive && frontendId) {
511
511
  activeTabFrontendId = frontendId;
512
512
  }
513
+
514
+ // Restore MCP control state if this tab was being controlled
515
+ if (backendTab.isMcpControlled && frontendId) {
516
+ debug.log('preview', `🎮 Restoring MCP control state for recovered tab: ${frontendId} (session: ${backendTab.tabId})`);
517
+ mcpHandler.restoreControlState(frontendId, backendTab.tabId);
518
+ }
519
+
513
520
  totalRestored++;
514
521
  }
515
522
 
@@ -536,6 +543,11 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
536
543
 
537
544
  debug.log('preview', `✅ Session recovery complete - restored ${totalRestored} tabs`);
538
545
 
546
+ // Diagnostic: dump state of all restored tabs
547
+ for (const tab of tabManager.getAllTabs()) {
548
+ debug.log('preview', `[DIAG] Restored tab: id=${tab.id}, sessionId=${tab.sessionId}, url=${tab.url}, isConnected=${tab.isConnected}, isStreamReady=${tab.isStreamReady}, isLaunchingBrowser=${tab.isLaunchingBrowser}, sessionInfo=${!!tab.sessionInfo}`);
549
+ }
550
+
539
551
  // Notify parent that sessions were recovered
540
552
  if (onSessionsRecovered) {
541
553
  onSessionsRecovered(totalRestored);
@@ -797,6 +809,9 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
797
809
  // Clear frontend tracking
798
810
  browserCleanup.clearAll();
799
811
 
812
+ // Reset MCP control state to avoid stale tab IDs after tab recreation
813
+ mcpHandler.resetControlState();
814
+
800
815
  // Recover sessions from new project
801
816
  await recoverExistingSessions();
802
817
  } catch (error) {
@@ -145,8 +145,8 @@ export function createMcpHandler(config: McpHandlerConfig) {
145
145
  }
146
146
 
147
147
  function handleCursorPosition(data: { sessionId: string; x: number; y: number; timestamp: number; source: 'mcp' }) {
148
- // Only update cursor if this is for the active session and MCP is controlling
149
- if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && transformBrowserToDisplayCoordinates) {
148
+ // Only show cursor if MCP is controlling AND user is currently viewing that tab
149
+ if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && isCurrentTabMcpControlled() && transformBrowserToDisplayCoordinates) {
150
150
  const transformedPosition = transformBrowserToDisplayCoordinates(data.x, data.y);
151
151
  if (transformedPosition && onCursorUpdate) {
152
152
  onCursorUpdate(transformedPosition.x, transformedPosition.y, false);
@@ -155,7 +155,8 @@ export function createMcpHandler(config: McpHandlerConfig) {
155
155
  }
156
156
 
157
157
  function handleCursorClick(data: { sessionId: string; x: number; y: number; timestamp: number; source: 'mcp' }) {
158
- if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && transformBrowserToDisplayCoordinates) {
158
+ // Only show cursor click if MCP is controlling AND user is currently viewing that tab
159
+ if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && isCurrentTabMcpControlled() && transformBrowserToDisplayCoordinates) {
159
160
  const transformedPosition = transformBrowserToDisplayCoordinates(data.x, data.y);
160
161
  if (transformedPosition && onCursorUpdate) {
161
162
  onCursorUpdate(transformedPosition.x, transformedPosition.y, true);
@@ -163,6 +164,36 @@ export function createMcpHandler(config: McpHandlerConfig) {
163
164
  }
164
165
  }
165
166
 
167
+ /**
168
+ * Restore MCP control state after session recovery (browser refresh or project switch)
169
+ * Called when recovered backend tab was previously MCP-controlled
170
+ */
171
+ function restoreControlState(frontendTabId: string, browserSessionId: string): void {
172
+ debug.log('preview', `🔄 Restoring MCP control state for tab: ${frontendTabId} (session: ${browserSessionId})`);
173
+ mcpControlState = {
174
+ isControlled: true,
175
+ controlledTabId: frontendTabId,
176
+ browserSessionId: browserSessionId,
177
+ startedAt: Date.now()
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Reset MCP control state (called before project switch recovery)
183
+ */
184
+ function resetControlState(): void {
185
+ debug.log('preview', `🔄 Resetting MCP control state`);
186
+ mcpControlState = {
187
+ isControlled: false,
188
+ controlledTabId: null,
189
+ browserSessionId: null,
190
+ startedAt: null
191
+ };
192
+ if (onCursorHide) {
193
+ onCursorHide();
194
+ }
195
+ }
196
+
166
197
  function handleTestCompleted(_data: { sessionId: string; timestamp: number; source: 'mcp' }) {
167
198
  // Cursor is hidden via chat:complete / chat:cancelled listeners instead,
168
199
  // because test-completed fires per-tool-call, not at end of full request.
@@ -297,6 +328,8 @@ export function createMcpHandler(config: McpHandlerConfig) {
297
328
  setupEventListeners,
298
329
  isCurrentTabMcpControlled,
299
330
  getControlState,
331
+ restoreControlState,
332
+ resetControlState,
300
333
  get mcpControlState() { return mcpControlState; }
301
334
  };
302
335
  }