@myrialabs/clopen 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -27
- package/backend/chat/stream-manager.ts +11 -7
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +17 -0
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +219 -34
- package/backend/preview/browser/browser-pool.ts +1 -1
- package/backend/preview/browser/browser-preview-service.ts +23 -34
- package/backend/preview/browser/browser-tab-manager.ts +16 -1
- package/backend/preview/browser/browser-video-capture.ts +15 -3
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/scripts/video-stream.ts +39 -4
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +35 -15
- package/backend/ws/preview/index.ts +8 -0
- package/frontend/components/chat/input/ChatInput.svelte +3 -3
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/files/FileNode.svelte +16 -58
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
- package/frontend/components/preview/browser/components/Canvas.svelte +158 -64
- package/frontend/components/preview/browser/components/Container.svelte +26 -8
- package/frontend/components/preview/browser/components/Toolbar.svelte +35 -18
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +26 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +66 -9
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- package/frontend/components/workspace/PanelHeader.svelte +8 -6
- package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
- package/frontend/services/chat/chat.service.ts +25 -3
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +277 -61
- package/package.json +2 -2
|
@@ -48,15 +48,30 @@
|
|
|
48
48
|
function getColorClasses(type: string) {
|
|
49
49
|
switch (type) {
|
|
50
50
|
case 'success':
|
|
51
|
-
return 'bg-green-50 border-green-
|
|
51
|
+
return 'bg-green-50 border-green-300 text-green-900 dark:bg-green-950 dark:border-green-700 dark:text-green-100';
|
|
52
52
|
case 'error':
|
|
53
|
-
return 'bg-red-50 border-red-
|
|
53
|
+
return 'bg-red-50 border-red-300 text-red-900 dark:bg-red-950 dark:border-red-700 dark:text-red-100';
|
|
54
54
|
case 'warning':
|
|
55
|
-
return 'bg-amber-50 border-amber-
|
|
55
|
+
return 'bg-amber-50 border-amber-300 text-amber-900 dark:bg-amber-950 dark:border-amber-700 dark:text-amber-100';
|
|
56
56
|
case 'info':
|
|
57
|
-
return 'bg-
|
|
57
|
+
return 'bg-blue-50 border-blue-300 text-blue-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100';
|
|
58
58
|
default:
|
|
59
|
-
return 'bg-slate-50 border-slate-
|
|
59
|
+
return 'bg-slate-50 border-slate-300 text-slate-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getIconColorClass(type: string) {
|
|
64
|
+
switch (type) {
|
|
65
|
+
case 'success':
|
|
66
|
+
return 'text-green-600 dark:text-green-400';
|
|
67
|
+
case 'error':
|
|
68
|
+
return 'text-red-600 dark:text-red-400';
|
|
69
|
+
case 'warning':
|
|
70
|
+
return 'text-amber-600 dark:text-amber-400';
|
|
71
|
+
case 'info':
|
|
72
|
+
return 'text-blue-600 dark:text-blue-400';
|
|
73
|
+
default:
|
|
74
|
+
return 'text-slate-600 dark:text-slate-400';
|
|
60
75
|
}
|
|
61
76
|
}
|
|
62
77
|
</script>
|
|
@@ -68,27 +83,27 @@
|
|
|
68
83
|
role="alert"
|
|
69
84
|
aria-live="polite"
|
|
70
85
|
>
|
|
71
|
-
<div class="
|
|
86
|
+
<div class="border rounded-lg p-4 shadow-lg {getColorClasses(notification.type)}">
|
|
72
87
|
<div class="flex items-start space-x-3">
|
|
73
|
-
<div class="flex-shrink-0">
|
|
88
|
+
<div class="flex-shrink-0 {getIconColorClass(notification.type)}">
|
|
74
89
|
<Icon name={getIcon(notification.type)} class="w-5 h-5" />
|
|
75
90
|
</div>
|
|
76
91
|
|
|
77
92
|
<div class="flex-1 min-w-0">
|
|
78
93
|
<div class="flex items-center justify-between">
|
|
79
|
-
<h4 class="font-
|
|
94
|
+
<h4 class="font-semibold text-sm">
|
|
80
95
|
{notification.title}
|
|
81
96
|
</h4>
|
|
82
97
|
<button
|
|
83
98
|
onclick={handleDismiss}
|
|
84
|
-
class="flex-shrink-0 ml-2 p-1
|
|
99
|
+
class="flex flex-shrink-0 ml-2 p-1 rounded opacity-60 hover:opacity-100 transition-opacity"
|
|
85
100
|
aria-label="Dismiss notification"
|
|
86
101
|
>
|
|
87
102
|
<Icon name="lucide:x" class="w-4 h-4" />
|
|
88
103
|
</button>
|
|
89
104
|
</div>
|
|
90
105
|
|
|
91
|
-
<p class="text-sm opacity-
|
|
106
|
+
<p class="text-sm opacity-80 mt-1">
|
|
92
107
|
{notification.message}
|
|
93
108
|
</p>
|
|
94
109
|
|
|
@@ -100,7 +115,7 @@
|
|
|
100
115
|
action.action();
|
|
101
116
|
handleDismiss();
|
|
102
117
|
}}
|
|
103
|
-
class="text-xs font-medium px-3 py-1 bg-
|
|
118
|
+
class="text-xs font-medium px-3 py-1 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 rounded-md transition-colors"
|
|
104
119
|
>
|
|
105
120
|
{action.label}
|
|
106
121
|
</button>
|
|
@@ -62,50 +62,29 @@
|
|
|
62
62
|
|
|
63
63
|
let nodeElement: HTMLDivElement;
|
|
64
64
|
let menuButtonElement: HTMLButtonElement;
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const rect = menuButtonElement.getBoundingClientRect();
|
|
76
|
-
const dockContainer = nodeElement?.closest('.overflow-auto');
|
|
77
|
-
|
|
78
|
-
if (!dockContainer) {
|
|
79
|
-
// Fallback ke viewport jika tidak ada container
|
|
80
|
-
const viewportHeight = window.innerHeight;
|
|
81
|
-
const menuHeight = 100;
|
|
82
|
-
showAbove = rect.bottom + menuHeight > viewportHeight && rect.top > menuHeight;
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const dockRect = dockContainer.getBoundingClientRect();
|
|
87
|
-
const menuHeight = 100; // Estimasi tinggi menu dropdown
|
|
88
|
-
|
|
89
|
-
// Hitung ruang yang tersedia di bawah dan di atas dalam dock container
|
|
90
|
-
const spaceBelow = dockRect.bottom - rect.bottom;
|
|
91
|
-
const spaceAbove = rect.top - dockRect.top;
|
|
92
|
-
|
|
93
|
-
// Jika tidak cukup ruang di bawah untuk menu dan ada cukup ruang di atas, tampilkan di atas
|
|
94
|
-
showAbove = spaceBelow < menuHeight && spaceAbove > menuHeight;
|
|
65
|
+
let menuStyle = $state('');
|
|
66
|
+
|
|
67
|
+
function computeMenuStyle(x: number, y: number, alignRight: boolean): string {
|
|
68
|
+
const menuHeight = 200;
|
|
69
|
+
const isAbove = y + menuHeight > window.innerHeight && y > menuHeight;
|
|
70
|
+
const verticalStyle = isAbove
|
|
71
|
+
? `bottom: ${window.innerHeight - y}px;`
|
|
72
|
+
: `top: ${y}px;`;
|
|
73
|
+
const horizontalStyle = alignRight ? `right: ${x}px;` : `left: ${x}px;`;
|
|
74
|
+
return `${horizontalStyle} ${verticalStyle}`;
|
|
95
75
|
}
|
|
96
76
|
|
|
97
77
|
function toggleMenu(event: Event) {
|
|
98
78
|
event.stopPropagation();
|
|
99
79
|
if (!isMenuOpen) {
|
|
100
|
-
|
|
101
|
-
|
|
80
|
+
const rect = menuButtonElement.getBoundingClientRect();
|
|
81
|
+
menuStyle = computeMenuStyle(window.innerWidth - rect.right, rect.bottom, true);
|
|
102
82
|
}
|
|
103
83
|
onMenuToggle?.(file.path);
|
|
104
84
|
}
|
|
105
85
|
|
|
106
86
|
function closeMenu() {
|
|
107
|
-
onMenuToggle?.(file.path);
|
|
108
|
-
menuOpenedViaContextMenu = false;
|
|
87
|
+
onMenuToggle?.(file.path);
|
|
109
88
|
}
|
|
110
89
|
|
|
111
90
|
function getDisplayIcon(fileName: string, isDirectory: boolean): IconName {
|
|
@@ -127,32 +106,11 @@
|
|
|
127
106
|
function handleContextMenu(event: MouseEvent) {
|
|
128
107
|
event.preventDefault();
|
|
129
108
|
if (!isMenuOpen) {
|
|
130
|
-
|
|
131
|
-
contextMenuX = event.clientX;
|
|
132
|
-
contextMenuY = event.clientY;
|
|
133
|
-
menuOpenedViaContextMenu = true;
|
|
134
|
-
// Check position based on mouse Y relative to dock container
|
|
135
|
-
checkContextMenuPosition(event.clientY);
|
|
109
|
+
menuStyle = computeMenuStyle(event.clientX, event.clientY, false);
|
|
136
110
|
}
|
|
137
111
|
onMenuToggle?.(file.path);
|
|
138
112
|
}
|
|
139
113
|
|
|
140
|
-
function checkContextMenuPosition(mouseY: number) {
|
|
141
|
-
const dockContainer = nodeElement?.closest('.overflow-auto');
|
|
142
|
-
const menuHeight = 100;
|
|
143
|
-
|
|
144
|
-
if (!dockContainer) {
|
|
145
|
-
const viewportHeight = window.innerHeight;
|
|
146
|
-
showAbove = mouseY + menuHeight > viewportHeight && mouseY > menuHeight;
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const dockRect = dockContainer.getBoundingClientRect();
|
|
151
|
-
const spaceBelow = dockRect.bottom - mouseY;
|
|
152
|
-
const spaceAbove = mouseY - dockRect.top;
|
|
153
|
-
showAbove = spaceBelow < menuHeight && spaceAbove > menuHeight;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
114
|
function handleAction(action: string, event: Event) {
|
|
157
115
|
event.stopPropagation();
|
|
158
116
|
onAction?.(action, file);
|
|
@@ -250,8 +208,8 @@
|
|
|
250
208
|
<div
|
|
251
209
|
role="menu"
|
|
252
210
|
tabindex="-1"
|
|
253
|
-
class="
|
|
254
|
-
style={
|
|
211
|
+
class="fixed bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-1 w-44 max-h-80 overflow-y-auto z-50 shadow-lg"
|
|
212
|
+
style={menuStyle}
|
|
255
213
|
onclick={(e) => e.stopPropagation()}
|
|
256
214
|
>
|
|
257
215
|
<!-- New File & New Folder (hanya untuk directory) -->
|
|
@@ -223,6 +223,12 @@
|
|
|
223
223
|
let previousUrl = '';
|
|
224
224
|
$effect(() => {
|
|
225
225
|
if (!url || url === previousUrl) return;
|
|
226
|
+
// Ignore browser-internal error pages (e.g. DNS failure) — they are not real URLs
|
|
227
|
+
// and should never trigger a new navigation attempt.
|
|
228
|
+
if (url.startsWith('chrome-error://') || url.startsWith('chrome://')) {
|
|
229
|
+
previousUrl = url;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
226
232
|
if (mcpLaunchInProgress) {
|
|
227
233
|
previousUrl = url;
|
|
228
234
|
urlInput = url;
|
|
@@ -306,7 +312,7 @@
|
|
|
306
312
|
|
|
307
313
|
// Initialize URL input
|
|
308
314
|
$effect(() => {
|
|
309
|
-
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
|
315
|
+
if (url && !url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('file://')) {
|
|
310
316
|
url = 'http://' + url;
|
|
311
317
|
}
|
|
312
318
|
if (url && !urlInput) {
|
|
@@ -319,7 +325,7 @@
|
|
|
319
325
|
if (!urlInput.trim()) return;
|
|
320
326
|
|
|
321
327
|
let processedUrl = urlInput.trim();
|
|
322
|
-
if (!processedUrl.startsWith('http://') && !processedUrl.startsWith('https://')) {
|
|
328
|
+
if (!processedUrl.startsWith('http://') && !processedUrl.startsWith('https://') && !processedUrl.startsWith('file://')) {
|
|
323
329
|
processedUrl = 'http://' + processedUrl;
|
|
324
330
|
}
|
|
325
331
|
|
|
@@ -447,7 +453,8 @@
|
|
|
447
453
|
},
|
|
448
454
|
getSessionInfo: () => sessionInfo,
|
|
449
455
|
getIsStreamReady: () => isStreamReady,
|
|
450
|
-
getErrorMessage: () => errorMessage
|
|
456
|
+
getErrorMessage: () => errorMessage,
|
|
457
|
+
getIsMcpControlled: () => isCurrentTabMcpControlled()
|
|
451
458
|
};
|
|
452
459
|
</script>
|
|
453
460
|
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
onStatsUpdate = $bindable<(stats: BrowserWebCodecsStreamStats | null) => void>(() => {}),
|
|
27
27
|
onRequestScreencastRefresh = $bindable<() => void>(() => {}), // Called when stream is stuck
|
|
28
28
|
touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
|
|
29
|
+
touchTarget = undefined as HTMLElement | undefined, // Container element for touch events
|
|
29
30
|
onTouchCursorUpdate = $bindable<(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) => void>(() => {})
|
|
30
31
|
} = $props();
|
|
31
32
|
|
|
@@ -36,6 +37,12 @@
|
|
|
36
37
|
let isStartingStream = false; // Prevent concurrent start attempts
|
|
37
38
|
let lastStartRequestId: string | null = null; // Track the last start request to prevent duplicates
|
|
38
39
|
|
|
40
|
+
// Generation counter: increments on every session change (tab switch).
|
|
41
|
+
// Async operations (startStreaming, recovery) capture the current generation
|
|
42
|
+
// and bail out if it has changed, preventing stale operations from corrupting
|
|
43
|
+
// the new tab's state.
|
|
44
|
+
let streamingGeneration = 0;
|
|
45
|
+
|
|
39
46
|
let canvasElement = $state<HTMLCanvasElement | undefined>();
|
|
40
47
|
let setupCanvasTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
41
48
|
|
|
@@ -99,6 +106,31 @@
|
|
|
99
106
|
lastProjectId = currentProjectId;
|
|
100
107
|
});
|
|
101
108
|
|
|
109
|
+
// Track session changes to reset stale state and increment generation counter.
|
|
110
|
+
// This runs BEFORE the streaming $effect, ensuring isReconnecting from the old
|
|
111
|
+
// tab doesn't leak into the new tab and that stale async operations bail out.
|
|
112
|
+
let lastTrackedSessionId: string | null = null;
|
|
113
|
+
$effect(() => {
|
|
114
|
+
const currentSessionId = sessionId;
|
|
115
|
+
if (currentSessionId !== lastTrackedSessionId) {
|
|
116
|
+
if (lastTrackedSessionId !== null) {
|
|
117
|
+
// Session actually changed (tab switch) — not initial mount
|
|
118
|
+
streamingGeneration++;
|
|
119
|
+
debug.log('webcodecs', `Session changed ${lastTrackedSessionId} → ${currentSessionId}, generation=${streamingGeneration}`);
|
|
120
|
+
|
|
121
|
+
// Reset states that belong to the old tab
|
|
122
|
+
if (isReconnecting) {
|
|
123
|
+
isReconnecting = false;
|
|
124
|
+
}
|
|
125
|
+
if (isNavigating) {
|
|
126
|
+
isNavigating = false;
|
|
127
|
+
}
|
|
128
|
+
lastStartRequestId = null; // Allow new start request for new session
|
|
129
|
+
}
|
|
130
|
+
lastTrackedSessionId = currentSessionId;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
102
134
|
// Sync navigation state with webCodecsService
|
|
103
135
|
// This prevents recovery when DataChannel closes during navigation
|
|
104
136
|
$effect(() => {
|
|
@@ -424,32 +456,27 @@
|
|
|
424
456
|
|
|
425
457
|
// Start WebCodecs streaming
|
|
426
458
|
async function startStreaming() {
|
|
427
|
-
debug.log('webcodecs', `
|
|
459
|
+
debug.log('webcodecs', `startStreaming() called: sessionId=${sessionId}, generation=${streamingGeneration}`);
|
|
428
460
|
|
|
429
461
|
if (!sessionId || !canvasElement) {
|
|
430
|
-
debug.log('webcodecs', `[DIAG] startStreaming() early exit: missing sessionId=${!sessionId} or canvasElement=${!canvasElement}`);
|
|
431
462
|
return;
|
|
432
463
|
}
|
|
433
464
|
|
|
434
465
|
// Prevent concurrent start attempts
|
|
435
466
|
if (isStartingStream) {
|
|
436
|
-
debug.log('webcodecs', '
|
|
467
|
+
debug.log('webcodecs', 'startStreaming() skipped: already starting stream');
|
|
437
468
|
return;
|
|
438
469
|
}
|
|
439
470
|
|
|
440
471
|
// If already streaming same session, skip
|
|
441
472
|
if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
|
|
442
|
-
debug.log('webcodecs', '
|
|
473
|
+
debug.log('webcodecs', 'startStreaming() skipped: already streaming same session');
|
|
443
474
|
return;
|
|
444
475
|
}
|
|
445
476
|
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
lastStartRequestId = requestId;
|
|
477
|
+
// Capture current generation — if it changes during async operations,
|
|
478
|
+
// it means the user switched tabs and this operation is stale
|
|
479
|
+
const myGeneration = streamingGeneration;
|
|
453
480
|
|
|
454
481
|
isStartingStream = true;
|
|
455
482
|
isStreamStarting = true; // Show loading overlay
|
|
@@ -463,10 +490,15 @@
|
|
|
463
490
|
if (isWebCodecsActive && activeStreamingSessionId !== sessionId) {
|
|
464
491
|
debug.log('webcodecs', `Session mismatch (active: ${activeStreamingSessionId}, requested: ${sessionId}), stopping old stream first`);
|
|
465
492
|
await stopStreaming();
|
|
466
|
-
// Small delay to ensure cleanup is complete
|
|
467
493
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
468
494
|
}
|
|
469
495
|
|
|
496
|
+
// Bail out if tab switched during cleanup
|
|
497
|
+
if (myGeneration !== streamingGeneration) {
|
|
498
|
+
debug.log('webcodecs', `Stale startStreaming (gen ${myGeneration} != ${streamingGeneration}), aborting`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
470
502
|
// Create WebCodecs service if not exists
|
|
471
503
|
if (!webCodecsService) {
|
|
472
504
|
if (!projectId) {
|
|
@@ -479,7 +511,11 @@
|
|
|
479
511
|
// Setup error handler
|
|
480
512
|
webCodecsService.setErrorHandler((error: Error) => {
|
|
481
513
|
debug.error('webcodecs', 'Error:', error);
|
|
482
|
-
isStartingStream
|
|
514
|
+
// NOTE: do NOT reset isStartingStream here.
|
|
515
|
+
// This handler fires from inside webCodecsService.startStreaming (before it returns false).
|
|
516
|
+
// Canvas.svelte's startStreaming retry loop is still running with isStartingStream=true.
|
|
517
|
+
// Resetting it here releases the concurrency guard prematurely, causing multiple
|
|
518
|
+
// concurrent streaming sessions to start (each triggering the streaming $effect).
|
|
483
519
|
connectionFailed = true;
|
|
484
520
|
});
|
|
485
521
|
|
|
@@ -554,27 +590,50 @@
|
|
|
554
590
|
const retryDelay = 300;
|
|
555
591
|
|
|
556
592
|
while (!success && retries < maxRetries) {
|
|
593
|
+
// Check generation before each attempt
|
|
594
|
+
if (myGeneration !== streamingGeneration) {
|
|
595
|
+
debug.log('webcodecs', `Stale startStreaming retry (gen ${myGeneration} != ${streamingGeneration}), aborting`);
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
|
|
557
599
|
try {
|
|
600
|
+
// Guard: webCodecsService can be destroyed by a concurrent tab/project switch
|
|
601
|
+
if (!webCodecsService) {
|
|
602
|
+
debug.warn('webcodecs', 'webCodecsService became null during startStreaming, aborting');
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
|
|
558
606
|
success = await webCodecsService.startStreaming(sessionId, canvasElement);
|
|
607
|
+
|
|
608
|
+
// Check generation after async operation
|
|
609
|
+
if (myGeneration !== streamingGeneration) {
|
|
610
|
+
debug.log('webcodecs', `Tab switched during startStreaming (gen ${myGeneration} != ${streamingGeneration}), discarding result`);
|
|
611
|
+
if (success && webCodecsService) {
|
|
612
|
+
await webCodecsService.stopStreaming();
|
|
613
|
+
}
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
|
|
559
617
|
if (success) {
|
|
560
618
|
isWebCodecsActive = true;
|
|
561
619
|
isConnected = true;
|
|
562
620
|
activeStreamingSessionId = sessionId;
|
|
563
|
-
consecutiveFailures = 0;
|
|
564
|
-
startHealthCheck(hasRestoredSnapshot);
|
|
565
|
-
hasRestoredSnapshot = false;
|
|
621
|
+
consecutiveFailures = 0;
|
|
622
|
+
startHealthCheck(hasRestoredSnapshot);
|
|
623
|
+
hasRestoredSnapshot = false;
|
|
566
624
|
debug.log('webcodecs', 'Streaming started successfully');
|
|
567
625
|
} else {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
626
|
+
retries++;
|
|
627
|
+
if (retries < maxRetries) {
|
|
628
|
+
debug.warn('webcodecs', `Streaming start returned false, retrying in ${retryDelay * retries}ms (${retries}/${maxRetries})`);
|
|
629
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay * retries));
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
debug.error('webcodecs', 'Streaming start failed after all retries');
|
|
633
|
+
break;
|
|
571
634
|
}
|
|
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
635
|
break;
|
|
576
636
|
} catch (error: any) {
|
|
577
|
-
// This block only runs if the service unexpectedly throws.
|
|
578
637
|
const isRetriable = error?.message?.includes('not found') ||
|
|
579
638
|
error?.message?.includes('invalid') ||
|
|
580
639
|
error?.message?.includes('Failed to start') ||
|
|
@@ -759,6 +818,7 @@
|
|
|
759
818
|
return;
|
|
760
819
|
}
|
|
761
820
|
|
|
821
|
+
const myGeneration = streamingGeneration;
|
|
762
822
|
consecutiveFailures++;
|
|
763
823
|
debug.log('webcodecs', `Recovery attempt ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES} for session ${sessionId}`);
|
|
764
824
|
|
|
@@ -771,11 +831,18 @@
|
|
|
771
831
|
|
|
772
832
|
// Stop and restart streaming
|
|
773
833
|
try {
|
|
774
|
-
isRecovering = true;
|
|
775
|
-
hasReceivedFirstFrame = false;
|
|
834
|
+
isRecovering = true;
|
|
835
|
+
hasReceivedFirstFrame = false;
|
|
776
836
|
await stopStreaming();
|
|
777
|
-
lastStartRequestId = null;
|
|
778
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
837
|
+
lastStartRequestId = null;
|
|
838
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
839
|
+
|
|
840
|
+
// Bail out if tab switched during cleanup
|
|
841
|
+
if (myGeneration !== streamingGeneration) {
|
|
842
|
+
debug.log('webcodecs', 'Recovery aborted - tab switched during cleanup');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
779
846
|
await startStreaming();
|
|
780
847
|
} catch (error) {
|
|
781
848
|
debug.error('webcodecs', 'Recovery failed:', error);
|
|
@@ -797,42 +864,43 @@
|
|
|
797
864
|
return;
|
|
798
865
|
}
|
|
799
866
|
|
|
800
|
-
|
|
867
|
+
const myGeneration = streamingGeneration;
|
|
868
|
+
debug.log('webcodecs', `🚀 Fast reconnect for session ${sessionId} (gen=${myGeneration})`);
|
|
801
869
|
|
|
802
870
|
try {
|
|
803
871
|
isRecovering = true;
|
|
804
872
|
isStartingStream = true;
|
|
805
|
-
|
|
806
|
-
// Set isReconnecting to prevent loading overlay during reconnect
|
|
807
|
-
// This ensures the last frame stays visible instead of "Loading preview..."
|
|
808
873
|
isReconnecting = true;
|
|
809
874
|
|
|
810
|
-
// Don't reset hasReceivedFirstFrame - keep showing last frame during reconnect
|
|
811
|
-
|
|
812
|
-
// Use reconnectToExistingStream which does NOT stop backend streaming
|
|
813
875
|
const success = await webCodecsService.reconnectToExistingStream(sessionId, canvasElement);
|
|
814
876
|
|
|
877
|
+
// Bail out if tab switched during reconnect
|
|
878
|
+
if (myGeneration !== streamingGeneration) {
|
|
879
|
+
debug.log('webcodecs', 'Fast reconnect aborted - tab switched');
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
815
883
|
if (success) {
|
|
816
884
|
isWebCodecsActive = true;
|
|
817
885
|
isConnected = true;
|
|
818
886
|
activeStreamingSessionId = sessionId;
|
|
819
887
|
consecutiveFailures = 0;
|
|
820
|
-
startHealthCheck(true);
|
|
888
|
+
startHealthCheck(true);
|
|
821
889
|
debug.log('webcodecs', '✅ Fast reconnect successful');
|
|
822
890
|
} else {
|
|
823
891
|
throw new Error('Reconnect returned false');
|
|
824
892
|
}
|
|
825
893
|
} catch (error) {
|
|
826
894
|
debug.error('webcodecs', 'Fast reconnect failed:', error);
|
|
827
|
-
// Fall back to regular recovery on failure
|
|
828
895
|
consecutiveFailures++;
|
|
829
896
|
isStartingStream = false;
|
|
830
|
-
isReconnecting = false;
|
|
831
|
-
|
|
897
|
+
isReconnecting = false;
|
|
898
|
+
if (myGeneration === streamingGeneration) {
|
|
899
|
+
attemptRecovery();
|
|
900
|
+
}
|
|
832
901
|
} finally {
|
|
833
902
|
isRecovering = false;
|
|
834
903
|
isStartingStream = false;
|
|
835
|
-
// Note: isReconnecting will be reset when first frame is received
|
|
836
904
|
}
|
|
837
905
|
}
|
|
838
906
|
|
|
@@ -945,12 +1013,27 @@
|
|
|
945
1013
|
|
|
946
1014
|
// Stop existing streaming first if session changed
|
|
947
1015
|
// This ensures clean state before starting new stream
|
|
1016
|
+
const capturedGeneration = streamingGeneration;
|
|
1017
|
+
|
|
1018
|
+
// IMMEDIATELY block the old session's frames from painting onto the canvas.
|
|
1019
|
+
// Without this, A's DataChannel continues delivering frames for up to 30ms
|
|
1020
|
+
// after we clear/snapshot-restore the canvas, overwriting B's content.
|
|
1021
|
+
if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
|
|
1022
|
+
webCodecsService?.pauseRendering();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
948
1025
|
const doStartStreaming = async () => {
|
|
1026
|
+
// Bail immediately if tab already changed
|
|
1027
|
+
if (capturedGeneration !== streamingGeneration) return;
|
|
1028
|
+
|
|
949
1029
|
if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
|
|
950
1030
|
debug.log('webcodecs', `Session changed from ${activeStreamingSessionId} to ${sessionId}, stopping old stream first`);
|
|
951
1031
|
await stopStreaming();
|
|
952
|
-
//
|
|
953
|
-
|
|
1032
|
+
// Bail if tab changed during cleanup
|
|
1033
|
+
if (capturedGeneration !== streamingGeneration) return;
|
|
1034
|
+
// Short wait for backend cleanup
|
|
1035
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1036
|
+
if (capturedGeneration !== streamingGeneration) return;
|
|
954
1037
|
}
|
|
955
1038
|
await startStreaming();
|
|
956
1039
|
};
|
|
@@ -958,7 +1041,7 @@
|
|
|
958
1041
|
// Small delay to ensure backend session is ready
|
|
959
1042
|
const timeout = setTimeout(() => {
|
|
960
1043
|
doStartStreaming();
|
|
961
|
-
},
|
|
1044
|
+
}, 30);
|
|
962
1045
|
|
|
963
1046
|
return () => clearTimeout(timeout);
|
|
964
1047
|
}
|
|
@@ -988,10 +1071,9 @@
|
|
|
988
1071
|
let lastMoveTime = 0;
|
|
989
1072
|
const handleMouseMove = (e: MouseEvent) => {
|
|
990
1073
|
const now = Date.now();
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
if (now - lastMoveTime >= throttleMs) {
|
|
1074
|
+
// 32ms = ~30fps — enough for smooth hover/drag while keeping CDP pipeline clear
|
|
1075
|
+
// for clicks and keypresses (halving the rate halves CDP queue pressure)
|
|
1076
|
+
if (now - lastMoveTime >= 32) {
|
|
995
1077
|
lastMoveTime = now;
|
|
996
1078
|
handleCanvasMouseMove(e, canvas);
|
|
997
1079
|
}
|
|
@@ -1002,20 +1084,6 @@
|
|
|
1002
1084
|
canvas.focus();
|
|
1003
1085
|
});
|
|
1004
1086
|
|
|
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 });
|
|
1019
1087
|
|
|
1020
1088
|
const handleMouseLeave = () => {
|
|
1021
1089
|
if (isMouseDown) {
|
|
@@ -1045,13 +1113,38 @@
|
|
|
1045
1113
|
canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
|
|
1046
1114
|
canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
|
|
1047
1115
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
1048
|
-
canvas.removeEventListener('touchstart', touchStartHandler);
|
|
1049
|
-
canvas.removeEventListener('touchmove', touchMoveHandler);
|
|
1050
|
-
canvas.removeEventListener('touchend', touchEndHandler);
|
|
1051
1116
|
};
|
|
1052
1117
|
}
|
|
1053
1118
|
});
|
|
1054
1119
|
|
|
1120
|
+
// Attach touch events to touchTarget (Container's previewContainer) instead of canvas
|
|
1121
|
+
$effect(() => {
|
|
1122
|
+
if (!touchTarget || !canvasElement) return;
|
|
1123
|
+
|
|
1124
|
+
const canvas = canvasElement;
|
|
1125
|
+
let lastTouchMoveTime = 0;
|
|
1126
|
+
|
|
1127
|
+
const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
|
|
1128
|
+
const touchMoveHandler = (e: TouchEvent) => {
|
|
1129
|
+
const now = Date.now();
|
|
1130
|
+
if (now - lastTouchMoveTime >= 16) {
|
|
1131
|
+
lastTouchMoveTime = now;
|
|
1132
|
+
handleTouchMove(e, canvas);
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
|
|
1136
|
+
|
|
1137
|
+
touchTarget.addEventListener('touchstart', touchStartHandler, { passive: false });
|
|
1138
|
+
touchTarget.addEventListener('touchmove', touchMoveHandler, { passive: false });
|
|
1139
|
+
touchTarget.addEventListener('touchend', touchEndHandler, { passive: false });
|
|
1140
|
+
|
|
1141
|
+
return () => {
|
|
1142
|
+
touchTarget.removeEventListener('touchstart', touchStartHandler);
|
|
1143
|
+
touchTarget.removeEventListener('touchmove', touchMoveHandler);
|
|
1144
|
+
touchTarget.removeEventListener('touchend', touchEndHandler);
|
|
1145
|
+
};
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1055
1148
|
// Convert canvas coordinates to viewport (screen) coordinates for VirtualCursor display
|
|
1056
1149
|
function canvasToScreen(cx: number, cy: number): { x: number; y: number } {
|
|
1057
1150
|
if (!canvasElement) return { x: 0, y: 0 };
|
|
@@ -1391,7 +1484,8 @@
|
|
|
1391
1484
|
getStats: () => webCodecsService?.getStats() ?? null,
|
|
1392
1485
|
getLatency: () => latencyMs,
|
|
1393
1486
|
// Navigation handling
|
|
1394
|
-
notifyNavigationComplete
|
|
1487
|
+
notifyNavigationComplete,
|
|
1488
|
+
freezeForSpaNavigation: () => webCodecsService?.freezeForSpaNavigation()
|
|
1395
1489
|
};
|
|
1396
1490
|
});
|
|
1397
1491
|
|