@myrialabs/clopen 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/chat/stream-manager.ts +24 -13
- package/backend/engine/adapters/claude/stream.ts +10 -19
- package/backend/mcp/project-context.ts +20 -0
- package/backend/mcp/servers/browser-automation/actions.ts +0 -2
- package/backend/mcp/servers/browser-automation/browser.ts +86 -132
- package/backend/mcp/servers/browser-automation/inspection.ts +5 -11
- package/backend/preview/browser/browser-mcp-control.ts +175 -180
- package/backend/preview/browser/browser-pool.ts +3 -1
- package/backend/preview/browser/browser-preview-service.ts +3 -3
- package/backend/preview/browser/browser-tab-manager.ts +1 -1
- package/backend/preview/browser/browser-video-capture.ts +12 -14
- package/backend/preview/browser/scripts/audio-stream.ts +11 -0
- package/backend/preview/browser/scripts/video-stream.ts +14 -14
- package/backend/preview/browser/types.ts +7 -7
- package/backend/preview/index.ts +1 -1
- package/backend/ws/chat/stream.ts +1 -1
- package/backend/ws/preview/browser/tab-info.ts +5 -2
- package/backend/ws/preview/index.ts +3 -3
- package/frontend/components/chat/input/ChatInput.svelte +0 -3
- package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
- package/frontend/components/chat/message/MessageBubble.svelte +2 -2
- package/frontend/components/history/HistoryModal.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +15 -1
- package/frontend/components/preview/browser/components/Canvas.svelte +323 -49
- package/frontend/components/preview/browser/components/Container.svelte +21 -0
- package/frontend/components/preview/browser/components/Toolbar.svelte +3 -3
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +15 -0
- package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +78 -51
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
- package/frontend/components/workspace/PanelHeader.svelte +15 -0
- package/frontend/components/workspace/panels/GitPanel.svelte +22 -13
- package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
- package/frontend/services/chat/chat.service.ts +3 -7
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +32 -135
- package/frontend/stores/core/app.svelte.ts +4 -3
- package/frontend/stores/core/presence.svelte.ts +3 -2
- package/frontend/stores/core/sessions.svelte.ts +2 -0
- package/frontend/stores/ui/notification.svelte.ts +4 -1
- 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
|
|
|
@@ -385,7 +406,7 @@
|
|
|
385
406
|
// This matches the loading overlay background roughly
|
|
386
407
|
if (ctx) {
|
|
387
408
|
ctx.imageSmoothingEnabled = true;
|
|
388
|
-
ctx.imageSmoothingQuality = '
|
|
409
|
+
ctx.imageSmoothingQuality = 'medium';
|
|
389
410
|
ctx.fillStyle = '#f1f5f9'; // slate-100 - neutral light color
|
|
390
411
|
ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
|
|
391
412
|
}
|
|
@@ -403,24 +424,29 @@
|
|
|
403
424
|
|
|
404
425
|
// Start WebCodecs streaming
|
|
405
426
|
async function startStreaming() {
|
|
406
|
-
|
|
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', '
|
|
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', '
|
|
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', `
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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',
|
|
1004
|
-
canvas.removeEventListener('touchmove',
|
|
1005
|
-
canvas.removeEventListener('touchend',
|
|
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;
|
|
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
|
|
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
|
|
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 (
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
x
|
|
1051
|
-
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
|
|
1316
|
+
if (!sessionId) return;
|
|
1058
1317
|
event.preventDefault();
|
|
1059
1318
|
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1319
|
+
if (touchMode === 'cursor') {
|
|
1320
|
+
handleTrackpadTouchEnd(event);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1062
1323
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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} />
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// Tab state
|
|
26
26
|
tabs = $bindable<any[]>([]),
|
|
27
27
|
activeTabId = $bindable<string | null>(null),
|
|
28
|
-
|
|
28
|
+
mcpControlledTabIds = $bindable<Set<string>>(new Set()),
|
|
29
29
|
|
|
30
30
|
// Callbacks
|
|
31
31
|
onGoClick = $bindable<() => void>(() => {}),
|
|
@@ -233,11 +233,11 @@
|
|
|
233
233
|
<span class="truncate max-w-28" title={tab.url}>
|
|
234
234
|
{tab.title || 'New Tab'}
|
|
235
235
|
</span>
|
|
236
|
-
{#if tab.id
|
|
236
|
+
{#if mcpControlledTabIds.has(tab.id)}
|
|
237
237
|
<span title="MCP Controlled" class="flex"><Icon name="lucide:lock" class="w-3 h-3 flex-shrink-0 text-amber-500" /></span>
|
|
238
238
|
{/if}
|
|
239
239
|
<!-- Close button -->
|
|
240
|
-
{#if tab.id
|
|
240
|
+
{#if !mcpControlledTabIds.has(tab.id)}
|
|
241
241
|
<span
|
|
242
242
|
role="button"
|
|
243
243
|
tabindex="0"
|
|
@@ -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) {
|