@myrialabs/clopen 0.2.5 → 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 (43) hide show
  1. package/backend/chat/stream-manager.ts +136 -10
  2. package/backend/database/queries/session-queries.ts +9 -0
  3. package/backend/engine/adapters/claude/error-handler.ts +7 -2
  4. package/backend/engine/adapters/claude/stream.ts +16 -7
  5. package/backend/index.ts +25 -3
  6. package/backend/mcp/servers/browser-automation/browser.ts +23 -6
  7. package/backend/preview/browser/browser-mcp-control.ts +32 -16
  8. package/backend/preview/browser/browser-pool.ts +3 -1
  9. package/backend/preview/browser/browser-preview-service.ts +16 -17
  10. package/backend/preview/browser/browser-tab-manager.ts +1 -1
  11. package/backend/preview/browser/browser-video-capture.ts +199 -156
  12. package/backend/preview/browser/scripts/audio-stream.ts +11 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  14. package/backend/snapshot/helpers.ts +15 -2
  15. package/backend/ws/chat/stream.ts +1 -1
  16. package/backend/ws/preview/browser/tab-info.ts +5 -2
  17. package/backend/ws/snapshot/restore.ts +43 -2
  18. package/frontend/components/chat/input/ChatInput.svelte +6 -4
  19. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  20. package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
  21. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  22. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  23. package/frontend/components/files/FileViewer.svelte +13 -2
  24. package/frontend/components/history/HistoryModal.svelte +1 -1
  25. package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
  26. package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
  27. package/frontend/components/preview/browser/components/Container.svelte +23 -1
  28. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  29. package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
  30. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
  31. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
  32. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  33. package/frontend/components/workspace/PanelHeader.svelte +15 -0
  34. package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
  35. package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
  36. package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
  37. package/frontend/services/chat/chat.service.ts +9 -8
  38. package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
  39. package/frontend/stores/core/app.svelte.ts +4 -3
  40. package/frontend/stores/core/presence.svelte.ts +3 -2
  41. package/frontend/stores/core/sessions.svelte.ts +2 -0
  42. package/frontend/stores/ui/notification.svelte.ts +4 -1
  43. 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
@@ -47,17 +49,24 @@
47
49
  let isRecovering = $state(false); // Track recovery attempts
48
50
  let connectionFailed = $state(false); // Track if connection actually failed (not just slow)
49
51
  let hasRequestedScreencastRefresh = false; // Track if we've already requested refresh for this stream
52
+ let screencastRefreshCount = 0; // Track retry count for stuck detection
50
53
  let navigationJustCompleted = false; // Track if navigation just completed (for fast refresh)
51
54
 
55
+ // Canvas snapshot storage for instant tab switching
56
+ // Stores a clone of the canvas per sessionId so switching back shows content immediately
57
+ const canvasSnapshots = new Map<string, HTMLCanvasElement>();
58
+ const MAX_SNAPSHOTS = 10;
59
+ let hasRestoredSnapshot = false; // Prevents canvas clear/reset during streaming start
60
+
52
61
  // Recovery is only triggered by ACTUAL failures, not timeouts
53
62
  // - ICE connection failed
54
63
  // - WebCodecs connection closed unexpectedly
55
64
  // - Explicit errors
56
65
  const MAX_CONSECUTIVE_FAILURES = 2;
57
66
  const HEALTH_CHECK_INTERVAL = 2000; // Check every 2 seconds for connection health
58
- const FRAME_CHECK_INTERVAL = 500; // Check for first frame every 500ms (just for UI update, not recovery)
59
- const STUCK_STREAM_TIMEOUT = 5000; // Fallback: Request screencast refresh after 5 seconds of connected but no frame
60
- const NAVIGATION_FAST_REFRESH_DELAY = 500; // Fast refresh after navigation: 500ms
67
+ const FRAME_CHECK_INTERVAL = 100; // Fallback poll for first frame (primary path is onFirstFrame callback)
68
+ const STUCK_STREAM_TIMEOUT = 3000; // Fallback: Request screencast refresh after 3 seconds of connected but no frame
69
+ const NAVIGATION_FAST_REFRESH_DELAY = 300; // Fast refresh after navigation: 300ms
61
70
 
62
71
  // Sync isStreamReady with hasReceivedFirstFrame for parent component
63
72
  $effect(() => {
@@ -73,6 +82,11 @@
73
82
  if (lastProjectId && currentProjectId && lastProjectId !== currentProjectId) {
74
83
  debug.log('webcodecs', `🔄 Project changed (${lastProjectId} → ${currentProjectId}), destroying old WebCodecs service`);
75
84
 
85
+ // Clear canvas snapshots - they belong to old project's sessions
86
+ canvasSnapshots.clear();
87
+ hasRestoredSnapshot = false;
88
+ lastStartRequestId = null; // Clear so new project sessions aren't blocked by old tab IDs
89
+
76
90
  // Destroy old service
77
91
  if (webCodecsService) {
78
92
  webCodecsService.destroy();
@@ -285,6 +299,24 @@
285
299
  let mouseDownTime = $state(0);
286
300
  let dragStarted = $state(false); // Track if we've sent mousedown for drag
287
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
+
288
320
  function handleCanvasMouseDown(event: MouseEvent, canvas: HTMLCanvasElement) {
289
321
  if (!sessionId) return;
290
322
 
@@ -392,31 +424,39 @@
392
424
 
393
425
  // Start WebCodecs streaming
394
426
  async function startStreaming() {
395
- 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
+ }
396
433
 
397
434
  // Prevent concurrent start attempts
398
435
  if (isStartingStream) {
399
- debug.log('webcodecs', 'Already starting stream, skipping duplicate call');
436
+ debug.log('webcodecs', '[DIAG] startStreaming() skipped: already starting stream');
400
437
  return;
401
438
  }
402
439
 
403
440
  // If already streaming same session, skip
404
441
  if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
405
- debug.log('webcodecs', 'Already streaming same session, skipping');
442
+ debug.log('webcodecs', '[DIAG] startStreaming() skipped: already streaming same session');
406
443
  return;
407
444
  }
408
445
 
409
446
  // Prevent duplicate requests for same session
410
447
  const requestId = `${sessionId}-${Date.now()}`;
411
448
  if (lastStartRequestId && lastStartRequestId.startsWith(sessionId)) {
412
- debug.log('webcodecs', `Duplicate start request for ${sessionId}, skipping`);
449
+ debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
413
450
  return;
414
451
  }
415
452
  lastStartRequestId = requestId;
416
453
 
417
454
  isStartingStream = true;
418
455
  isStreamStarting = true; // Show loading overlay
419
- hasReceivedFirstFrame = false; // Reset first frame state
456
+ // Don't reset if we restored a snapshot - keep showing it
457
+ if (!hasRestoredSnapshot) {
458
+ hasReceivedFirstFrame = false; // Reset first frame state
459
+ }
420
460
 
421
461
  try {
422
462
  // If streaming a different session, stop first
@@ -480,6 +520,25 @@
480
520
  onStatsUpdate(stats);
481
521
  });
482
522
 
523
+ // Setup first frame handler - fires immediately when first frame decoded
524
+ // This eliminates the 500ms polling delay for hiding the loading overlay
525
+ webCodecsService.setFirstFrameHandler(() => {
526
+ if (!hasReceivedFirstFrame) {
527
+ debug.log('webcodecs', 'First frame callback - immediately updating UI');
528
+ hasReceivedFirstFrame = true;
529
+ consecutiveFailures = 0;
530
+ connectionFailed = false;
531
+ }
532
+
533
+ // Always reset reconnecting state on first real frame
534
+ // (outside !hasReceivedFirstFrame to handle snapshot + reconnect case)
535
+ if (isReconnecting) {
536
+ setTimeout(() => {
537
+ isReconnecting = false;
538
+ }, 300);
539
+ }
540
+ });
541
+
483
542
  // Setup cursor change handler
484
543
  webCodecsService.setOnCursorChange((cursor: string) => {
485
544
  updateCanvasCursor(cursor);
@@ -491,8 +550,8 @@
491
550
 
492
551
  let success = false;
493
552
  let retries = 0;
494
- const maxRetries = 5; // Increased for rapid tab switching
495
- const retryDelay = 500; // Increased delay for backend cleanup
553
+ const maxRetries = 5;
554
+ const retryDelay = 300;
496
555
 
497
556
  while (!success && retries < maxRetries) {
498
557
  try {
@@ -502,12 +561,20 @@
502
561
  isConnected = true;
503
562
  activeStreamingSessionId = sessionId;
504
563
  consecutiveFailures = 0; // Reset failure counter on success
505
- startHealthCheck(); // Start monitoring for stuck streams
564
+ startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
565
+ hasRestoredSnapshot = false; // Reset after using
506
566
  debug.log('webcodecs', 'Streaming started successfully');
507
- 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');
508
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;
509
576
  } catch (error: any) {
510
- // Check if it's a retriable error - might just need more time
577
+ // This block only runs if the service unexpectedly throws.
511
578
  const isRetriable = error?.message?.includes('not found') ||
512
579
  error?.message?.includes('invalid') ||
513
580
  error?.message?.includes('Failed to start') ||
@@ -520,11 +587,9 @@
520
587
  await new Promise(resolve => setTimeout(resolve, retryDelay));
521
588
  } else {
522
589
  debug.error('webcodecs', 'Max retries reached, streaming still not ready');
523
- // Don't throw - just fail silently and let user retry manually
524
590
  break;
525
591
  }
526
592
  } else {
527
- // Other errors - don't retry, just log
528
593
  debug.error('webcodecs', 'Streaming error:', error);
529
594
  break;
530
595
  }
@@ -533,6 +598,7 @@
533
598
  } finally {
534
599
  isStartingStream = false;
535
600
  isStreamStarting = false; // Hide "Launching browser..." (but may still show "Connecting..." until first frame)
601
+ hasRestoredSnapshot = false; // Always reset in finally
536
602
  }
537
603
  }
538
604
 
@@ -561,6 +627,7 @@
561
627
  }
562
628
  connectionFailed = false;
563
629
  hasRequestedScreencastRefresh = false; // Reset for new stream
630
+ screencastRefreshCount = 0; // Reset retry counter
564
631
 
565
632
  const startTime = Date.now();
566
633
 
@@ -587,6 +654,7 @@
587
654
  consecutiveFailures = 0;
588
655
  connectionFailed = false;
589
656
  hasRequestedScreencastRefresh = false; // Reset on success
657
+ screencastRefreshCount = 0; // Reset retry counter on success
590
658
 
591
659
  // Reset reconnecting state after successful frame reception
592
660
  // This completes the fast reconnect cycle
@@ -621,10 +689,21 @@
621
689
  // STUCK STREAM DETECTION (FALLBACK): If connected but no first frame for too long,
622
690
  // request screencast refresh (hot-swap) to restart CDP screencast.
623
691
  // This handles cases where WebRTC is connected but CDP frames aren't flowing.
624
- if (stats?.isConnected && !stats?.firstFrameRendered && elapsed >= STUCK_STREAM_TIMEOUT && !hasRequestedScreencastRefresh) {
625
- debug.warn('webcodecs', `Stream appears stuck (connected but no frame for ${elapsed}ms), requesting screencast refresh`);
626
- hasRequestedScreencastRefresh = true;
627
- onRequestScreencastRefresh();
692
+ // Retries: 1st at 3s (screencast refresh), 2nd at 6s (another refresh), 3rd at 10s (full recovery)
693
+ if (stats?.isConnected && !stats?.firstFrameRendered && !hasRequestedScreencastRefresh) {
694
+ const MAX_SCREENCAST_RETRIES = 2;
695
+ const retryThreshold = STUCK_STREAM_TIMEOUT + (screencastRefreshCount * 3000); // 3s, 6s
696
+
697
+ if (elapsed >= retryThreshold && screencastRefreshCount < MAX_SCREENCAST_RETRIES) {
698
+ screencastRefreshCount++;
699
+ debug.warn('webcodecs', `Stream stuck (connected, no frame for ${elapsed}ms), screencast refresh attempt ${screencastRefreshCount}/${MAX_SCREENCAST_RETRIES}`);
700
+ onRequestScreencastRefresh();
701
+ } else if (elapsed >= 10000 && screencastRefreshCount >= MAX_SCREENCAST_RETRIES) {
702
+ // Screencast refreshes didn't help - attempt full recovery
703
+ debug.warn('webcodecs', `Stream still stuck after ${screencastRefreshCount} screencast refreshes (${elapsed}ms), attempting full recovery`);
704
+ hasRequestedScreencastRefresh = true; // Prevent further retries
705
+ attemptRecovery();
706
+ }
628
707
  }
629
708
 
630
709
  }, FRAME_CHECK_INTERVAL);
@@ -770,11 +849,11 @@
770
849
  lastStartRequestId = null; // Clear to allow new requests
771
850
  // Note: Don't reset hasReceivedFirstFrame here - let startStreaming do it
772
851
  // This prevents flashing when switching tabs
773
- // Clear canvas to prevent stale frames, BUT keep last frame during navigation
774
- if (!isNavigating) {
852
+ // Clear canvas to prevent stale frames, BUT keep last frame during navigation or snapshot restore
853
+ if (!isNavigating && !hasRestoredSnapshot) {
775
854
  clearCanvas();
776
855
  } else {
777
- debug.log('webcodecs', 'Skipping canvas clear during navigation - keeping last frame');
856
+ debug.log('webcodecs', `Skipping canvas clear - navigation: ${isNavigating}, snapshot: ${hasRestoredSnapshot}`);
778
857
  }
779
858
  }
780
859
  }
@@ -802,6 +881,8 @@
802
881
  // Start/restart streaming when session is ready
803
882
  // This handles both initial start and session changes (viewport switch, etc.)
804
883
  $effect(() => {
884
+ debug.log('webcodecs', `[DIAG] streaming $effect triggered: sessionId=${sessionId}, canvasElement=${!!canvasElement}, sessionInfo=${!!sessionInfo}, isReconnecting=${isReconnecting}, isWebCodecsActive=${isWebCodecsActive}, activeStreamingSessionId=${activeStreamingSessionId}`);
885
+
805
886
  if (sessionId && canvasElement && sessionInfo) {
806
887
  // Skip during fast reconnect - fastReconnect() handles this case
807
888
  if (isReconnecting) {
@@ -811,12 +892,55 @@
811
892
 
812
893
  // Check if we need to start or restart streaming
813
894
  const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
895
+ debug.log('webcodecs', `[DIAG] streaming $effect: needsStreaming=${needsStreaming}`);
814
896
 
815
897
  if (needsStreaming) {
816
- // Clear canvas immediately when session changes to prevent stale frames
817
898
  if (activeStreamingSessionId !== sessionId) {
818
- clearCanvas();
819
- hasReceivedFirstFrame = false; // Reset to show loading overlay
899
+ // SNAPSHOT: Save current canvas before switching to new session
900
+ if (activeStreamingSessionId && hasReceivedFirstFrame && canvasElement.width > 0) {
901
+ try {
902
+ const clone = document.createElement('canvas');
903
+ clone.width = canvasElement.width;
904
+ clone.height = canvasElement.height;
905
+ const cloneCtx = clone.getContext('2d');
906
+ if (cloneCtx) {
907
+ cloneCtx.drawImage(canvasElement, 0, 0);
908
+ // Limit snapshot count
909
+ if (canvasSnapshots.size >= MAX_SNAPSHOTS) {
910
+ const firstKey = canvasSnapshots.keys().next().value;
911
+ if (firstKey) canvasSnapshots.delete(firstKey);
912
+ }
913
+ canvasSnapshots.set(activeStreamingSessionId, clone);
914
+ debug.log('webcodecs', `📸 Saved canvas snapshot for session ${activeStreamingSessionId}`);
915
+ }
916
+ } catch (e) {
917
+ debug.warn('webcodecs', 'Failed to capture canvas snapshot:', e);
918
+ }
919
+ }
920
+
921
+ // SNAPSHOT: Restore for new session if available
922
+ const existingSnapshot = canvasSnapshots.get(sessionId);
923
+ if (existingSnapshot) {
924
+ setupCanvasInternal(); // Ensure canvas dimensions are correct
925
+ try {
926
+ const ctx = canvasElement.getContext('2d');
927
+ if (ctx) {
928
+ ctx.drawImage(existingSnapshot, 0, 0, canvasElement.width, canvasElement.height);
929
+ hasRestoredSnapshot = true;
930
+ // Don't reset hasReceivedFirstFrame - snapshot is visible
931
+ debug.log('webcodecs', `📸 Restored canvas snapshot for session ${sessionId}`);
932
+ }
933
+ } catch (e) {
934
+ debug.warn('webcodecs', 'Failed to restore canvas snapshot:', e);
935
+ hasRestoredSnapshot = false;
936
+ clearCanvas();
937
+ hasReceivedFirstFrame = false;
938
+ }
939
+ } else {
940
+ hasRestoredSnapshot = false;
941
+ clearCanvas();
942
+ hasReceivedFirstFrame = false; // Reset to show loading overlay
943
+ }
820
944
  }
821
945
 
822
946
  // Stop existing streaming first if session changed
@@ -831,12 +955,10 @@
831
955
  await startStreaming();
832
956
  };
833
957
 
834
- // Longer delay to ensure backend session is fully ready
835
- // This is especially important during viewport/device change
836
- // when session is being recreated
958
+ // Small delay to ensure backend session is ready
837
959
  const timeout = setTimeout(() => {
838
960
  doStartStreaming();
839
- }, 200);
961
+ }, 50);
840
962
 
841
963
  return () => clearTimeout(timeout);
842
964
  }
@@ -880,9 +1002,20 @@
880
1002
  canvas.focus();
881
1003
  });
882
1004
 
883
- canvas.addEventListener('touchstart', (e) => handleTouchStart(e, canvas), { passive: false });
884
- canvas.addEventListener('touchmove', (e) => handleTouchMove(e, canvas), { passive: false });
885
- 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 });
886
1019
 
887
1020
  const handleMouseLeave = () => {
888
1021
  if (isMouseDown) {
@@ -912,76 +1045,299 @@
912
1045
  canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
913
1046
  canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
914
1047
  canvas.removeEventListener('mousemove', handleMouseMove);
915
- canvas.removeEventListener('touchstart', (e) => handleTouchStart(e, canvas));
916
- canvas.removeEventListener('touchmove', (e) => handleTouchMove(e, canvas));
917
- canvas.removeEventListener('touchend', (e) => handleTouchEnd(e, canvas));
1048
+ canvas.removeEventListener('touchstart', touchStartHandler);
1049
+ canvas.removeEventListener('touchmove', touchMoveHandler);
1050
+ canvas.removeEventListener('touchend', touchEndHandler);
918
1051
  };
919
1052
  }
920
1053
  });
921
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
+
922
1239
  // Touch event handlers
923
1240
  function handleTouchStart(event: TouchEvent, canvas: HTMLCanvasElement) {
924
1241
  if (!sessionId || event.touches.length === 0) return;
925
1242
  event.preventDefault();
926
1243
 
1244
+ if (touchMode === 'cursor') {
1245
+ handleTrackpadTouchStart(event);
1246
+ return;
1247
+ }
1248
+
1249
+ // ── Scroll mode ──────────────────────────────────────────────────────────
1250
+ if (event.touches.length > 1) return;
1251
+
927
1252
  const coords = getCanvasCoordinates(event, canvas);
928
1253
  isMouseDown = true;
929
1254
  mouseDownTime = Date.now();
930
1255
  dragStartPos = { x: coords.x, y: coords.y };
931
1256
  dragCurrentPos = { x: coords.x, y: coords.y };
932
- 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);
933
1276
  }
934
1277
 
935
1278
  function handleTouchMove(event: TouchEvent, canvas: HTMLCanvasElement) {
936
- if (!sessionId || event.touches.length === 0 || !isMouseDown || !dragStartPos) return;
1279
+ if (!sessionId || event.touches.length === 0) return;
937
1280
  event.preventDefault();
938
1281
 
1282
+ if (touchMode === 'cursor') {
1283
+ handleTrackpadTouchMove(event);
1284
+ return;
1285
+ }
1286
+
1287
+ // ── Scroll mode ──────────────────────────────────────────────────────────
1288
+ if (!isMouseDown || !dragStartPos) return;
1289
+
939
1290
  const coords = getCanvasCoordinates(event, canvas);
940
1291
  dragCurrentPos = { x: coords.x, y: coords.y };
941
1292
 
942
- const dragDistance = Math.sqrt(
1293
+ const dist = Math.sqrt(
943
1294
  Math.pow(coords.x - dragStartPos.x, 2) + Math.pow(coords.y - dragStartPos.y, 2)
944
1295
  );
945
1296
 
946
- if (dragDistance > 15) {
947
- // Send mousedown on first drag detection
948
- if (!dragStarted) {
949
- sendInteraction({
950
- type: 'mousedown',
951
- x: dragStartPos.x,
952
- y: dragStartPos.y,
953
- button: 'left'
954
- });
955
- dragStarted = true;
956
- }
1297
+ if (dist > 10 && longPressTimer) {
1298
+ clearTimeout(longPressTimer);
1299
+ longPressTimer = null;
1300
+ }
957
1301
 
1302
+ if (touchLongPressed) {
958
1303
  isDragging = true;
959
- // Send mousemove to continue dragging (mouse is already down)
960
- sendInteraction({
961
- type: 'mousemove',
962
- x: coords.x,
963
- y: coords.y
964
- });
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 };
965
1312
  }
966
1313
  }
967
1314
 
968
1315
  function handleTouchEnd(event: TouchEvent, canvas: HTMLCanvasElement) {
969
- if (!sessionId || !isMouseDown) return;
1316
+ if (!sessionId) return;
970
1317
  event.preventDefault();
971
1318
 
972
- if (dragStartPos) {
973
- const endPos = dragCurrentPos || dragStartPos;
1319
+ if (touchMode === 'cursor') {
1320
+ handleTrackpadTouchEnd(event);
1321
+ return;
1322
+ }
974
1323
 
975
- // If drag was started (mousedown was sent), send mouseup
976
- if (dragStarted) {
977
- sendInteraction({
978
- type: 'mouseup',
979
- x: endPos.x,
980
- y: endPos.y,
981
- button: 'left'
982
- });
983
- } else {
984
- // 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) {
985
1341
  sendInteraction({ type: 'click', x: dragStartPos.x, y: dragStartPos.y });
986
1342
  }
987
1343
  }
@@ -991,6 +1347,8 @@
991
1347
  dragStartPos = null;
992
1348
  dragCurrentPos = null;
993
1349
  dragStarted = false;
1350
+ touchLongPressed = false;
1351
+ lastTouchCoords = null;
994
1352
  }
995
1353
 
996
1354
  function getCanvasElement() {
@@ -1039,6 +1397,11 @@
1039
1397
 
1040
1398
  onDestroy(() => {
1041
1399
  stopHealthCheck(); // Stop health monitoring
1400
+ canvasSnapshots.clear(); // Free snapshot memory
1401
+ if (longPressTimer) {
1402
+ clearTimeout(longPressTimer);
1403
+ longPressTimer = null;
1404
+ }
1042
1405
  if (webCodecsService) {
1043
1406
  webCodecsService.destroy();
1044
1407
  webCodecsService = null;