@myrialabs/clopen 0.2.10 → 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 +5 -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 +31 -3
- package/backend/preview/browser/browser-preview-service.ts +0 -34
- package/backend/preview/browser/browser-video-capture.ts +13 -1
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- 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/components/Canvas.svelte +119 -42
- package/frontend/components/preview/browser/components/Container.svelte +18 -3
- package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- 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 +170 -46
- package/package.json +2 -2
|
@@ -16,7 +16,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
16
16
|
.http(
|
|
17
17
|
'preview:browser-stream-start',
|
|
18
18
|
{
|
|
19
|
-
data: t.Object({
|
|
19
|
+
data: t.Object({
|
|
20
|
+
tabId: t.Optional(t.String())
|
|
21
|
+
}),
|
|
20
22
|
response: t.Object({
|
|
21
23
|
success: t.Boolean(),
|
|
22
24
|
message: t.Optional(t.String()),
|
|
@@ -34,9 +36,10 @@ export const streamPreviewHandler = createRouter()
|
|
|
34
36
|
// Get project-specific preview service
|
|
35
37
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
// Use explicit tabId if provided, otherwise fall back to active tab
|
|
40
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
38
41
|
if (!tab) {
|
|
39
|
-
throw new Error('No active tab');
|
|
42
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const sessionId = tab.id;
|
|
@@ -73,7 +76,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
73
76
|
.http(
|
|
74
77
|
'preview:browser-stream-offer',
|
|
75
78
|
{
|
|
76
|
-
data: t.Object({
|
|
79
|
+
data: t.Object({
|
|
80
|
+
tabId: t.Optional(t.String())
|
|
81
|
+
}),
|
|
77
82
|
response: t.Object({
|
|
78
83
|
success: t.Boolean(),
|
|
79
84
|
offer: t.Optional(
|
|
@@ -90,9 +95,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
90
95
|
// Get project-specific preview service
|
|
91
96
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
92
97
|
|
|
93
|
-
const tab = previewService.getActiveTab();
|
|
98
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
94
99
|
if (!tab) {
|
|
95
|
-
throw new Error('No active tab');
|
|
100
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
const offer = await previewService.getWebCodecsOffer(tab.id);
|
|
@@ -117,7 +122,8 @@ export const streamPreviewHandler = createRouter()
|
|
|
117
122
|
answer: t.Object({
|
|
118
123
|
type: t.String(),
|
|
119
124
|
sdp: t.Optional(t.String())
|
|
120
|
-
})
|
|
125
|
+
}),
|
|
126
|
+
tabId: t.Optional(t.String())
|
|
121
127
|
}),
|
|
122
128
|
response: t.Object({
|
|
123
129
|
success: t.Boolean()
|
|
@@ -129,9 +135,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
129
135
|
// Get project-specific preview service
|
|
130
136
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
131
137
|
|
|
132
|
-
const tab = previewService.getActiveTab();
|
|
138
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
133
139
|
if (!tab) {
|
|
134
|
-
throw new Error('No active tab');
|
|
140
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
const { answer } = data;
|
|
@@ -150,7 +156,8 @@ export const streamPreviewHandler = createRouter()
|
|
|
150
156
|
candidate: t.Optional(t.String()),
|
|
151
157
|
sdpMid: t.Optional(t.Union([t.String(), t.Null()])),
|
|
152
158
|
sdpMLineIndex: t.Optional(t.Union([t.Number(), t.Null()]))
|
|
153
|
-
})
|
|
159
|
+
}),
|
|
160
|
+
tabId: t.Optional(t.String())
|
|
154
161
|
}),
|
|
155
162
|
response: t.Object({
|
|
156
163
|
success: t.Boolean()
|
|
@@ -162,9 +169,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
162
169
|
// Get project-specific preview service
|
|
163
170
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
164
171
|
|
|
165
|
-
const tab = previewService.getActiveTab();
|
|
172
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
166
173
|
if (!tab) {
|
|
167
|
-
throw new Error('No active tab');
|
|
174
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
const { candidate } = data;
|
|
@@ -178,7 +185,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
178
185
|
.http(
|
|
179
186
|
'preview:browser-stream-stop',
|
|
180
187
|
{
|
|
181
|
-
data: t.Object({
|
|
188
|
+
data: t.Object({
|
|
189
|
+
tabId: t.Optional(t.String())
|
|
190
|
+
}),
|
|
182
191
|
response: t.Object({
|
|
183
192
|
success: t.Boolean()
|
|
184
193
|
})
|
|
@@ -189,9 +198,9 @@ export const streamPreviewHandler = createRouter()
|
|
|
189
198
|
// Get project-specific preview service
|
|
190
199
|
const previewService = browserPreviewServiceManager.getService(projectId);
|
|
191
200
|
|
|
192
|
-
const tab = previewService.getActiveTab();
|
|
201
|
+
const tab = data.tabId ? previewService.getTab(data.tabId) : previewService.getActiveTab();
|
|
193
202
|
if (!tab) {
|
|
194
|
-
throw new Error('No active tab');
|
|
203
|
+
throw new Error(data.tabId ? `Tab not found: ${data.tabId}` : 'No active tab');
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
await previewService.stopWebCodecsStreaming(tab.id);
|
|
@@ -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) -->
|
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
let isStartingStream = false; // Prevent concurrent start attempts
|
|
38
38
|
let lastStartRequestId: string | null = null; // Track the last start request to prevent duplicates
|
|
39
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
|
+
|
|
40
46
|
let canvasElement = $state<HTMLCanvasElement | undefined>();
|
|
41
47
|
let setupCanvasTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
42
48
|
|
|
@@ -100,6 +106,31 @@
|
|
|
100
106
|
lastProjectId = currentProjectId;
|
|
101
107
|
});
|
|
102
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
|
+
|
|
103
134
|
// Sync navigation state with webCodecsService
|
|
104
135
|
// This prevents recovery when DataChannel closes during navigation
|
|
105
136
|
$effect(() => {
|
|
@@ -425,32 +456,27 @@
|
|
|
425
456
|
|
|
426
457
|
// Start WebCodecs streaming
|
|
427
458
|
async function startStreaming() {
|
|
428
|
-
debug.log('webcodecs', `
|
|
459
|
+
debug.log('webcodecs', `startStreaming() called: sessionId=${sessionId}, generation=${streamingGeneration}`);
|
|
429
460
|
|
|
430
461
|
if (!sessionId || !canvasElement) {
|
|
431
|
-
debug.log('webcodecs', `[DIAG] startStreaming() early exit: missing sessionId=${!sessionId} or canvasElement=${!canvasElement}`);
|
|
432
462
|
return;
|
|
433
463
|
}
|
|
434
464
|
|
|
435
465
|
// Prevent concurrent start attempts
|
|
436
466
|
if (isStartingStream) {
|
|
437
|
-
debug.log('webcodecs', '
|
|
467
|
+
debug.log('webcodecs', 'startStreaming() skipped: already starting stream');
|
|
438
468
|
return;
|
|
439
469
|
}
|
|
440
470
|
|
|
441
471
|
// If already streaming same session, skip
|
|
442
472
|
if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
|
|
443
|
-
debug.log('webcodecs', '
|
|
473
|
+
debug.log('webcodecs', 'startStreaming() skipped: already streaming same session');
|
|
444
474
|
return;
|
|
445
475
|
}
|
|
446
476
|
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
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;
|
|
454
480
|
|
|
455
481
|
isStartingStream = true;
|
|
456
482
|
isStreamStarting = true; // Show loading overlay
|
|
@@ -464,10 +490,15 @@
|
|
|
464
490
|
if (isWebCodecsActive && activeStreamingSessionId !== sessionId) {
|
|
465
491
|
debug.log('webcodecs', `Session mismatch (active: ${activeStreamingSessionId}, requested: ${sessionId}), stopping old stream first`);
|
|
466
492
|
await stopStreaming();
|
|
467
|
-
// Small delay to ensure cleanup is complete
|
|
468
493
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
469
494
|
}
|
|
470
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
|
+
|
|
471
502
|
// Create WebCodecs service if not exists
|
|
472
503
|
if (!webCodecsService) {
|
|
473
504
|
if (!projectId) {
|
|
@@ -480,7 +511,11 @@
|
|
|
480
511
|
// Setup error handler
|
|
481
512
|
webCodecsService.setErrorHandler((error: Error) => {
|
|
482
513
|
debug.error('webcodecs', 'Error:', error);
|
|
483
|
-
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).
|
|
484
519
|
connectionFailed = true;
|
|
485
520
|
});
|
|
486
521
|
|
|
@@ -555,19 +590,39 @@
|
|
|
555
590
|
const retryDelay = 300;
|
|
556
591
|
|
|
557
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
|
+
|
|
558
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
|
+
|
|
559
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
|
+
|
|
560
617
|
if (success) {
|
|
561
618
|
isWebCodecsActive = true;
|
|
562
619
|
isConnected = true;
|
|
563
620
|
activeStreamingSessionId = sessionId;
|
|
564
|
-
consecutiveFailures = 0;
|
|
565
|
-
startHealthCheck(hasRestoredSnapshot);
|
|
566
|
-
hasRestoredSnapshot = false;
|
|
621
|
+
consecutiveFailures = 0;
|
|
622
|
+
startHealthCheck(hasRestoredSnapshot);
|
|
623
|
+
hasRestoredSnapshot = false;
|
|
567
624
|
debug.log('webcodecs', 'Streaming started successfully');
|
|
568
625
|
} else {
|
|
569
|
-
// Service handles errors internally and returns false.
|
|
570
|
-
// Retry after a delay — the peer/offer may need more time to initialize.
|
|
571
626
|
retries++;
|
|
572
627
|
if (retries < maxRetries) {
|
|
573
628
|
debug.warn('webcodecs', `Streaming start returned false, retrying in ${retryDelay * retries}ms (${retries}/${maxRetries})`);
|
|
@@ -579,7 +634,6 @@
|
|
|
579
634
|
}
|
|
580
635
|
break;
|
|
581
636
|
} catch (error: any) {
|
|
582
|
-
// This block only runs if the service unexpectedly throws.
|
|
583
637
|
const isRetriable = error?.message?.includes('not found') ||
|
|
584
638
|
error?.message?.includes('invalid') ||
|
|
585
639
|
error?.message?.includes('Failed to start') ||
|
|
@@ -764,6 +818,7 @@
|
|
|
764
818
|
return;
|
|
765
819
|
}
|
|
766
820
|
|
|
821
|
+
const myGeneration = streamingGeneration;
|
|
767
822
|
consecutiveFailures++;
|
|
768
823
|
debug.log('webcodecs', `Recovery attempt ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES} for session ${sessionId}`);
|
|
769
824
|
|
|
@@ -776,11 +831,18 @@
|
|
|
776
831
|
|
|
777
832
|
// Stop and restart streaming
|
|
778
833
|
try {
|
|
779
|
-
isRecovering = true;
|
|
780
|
-
hasReceivedFirstFrame = false;
|
|
834
|
+
isRecovering = true;
|
|
835
|
+
hasReceivedFirstFrame = false;
|
|
781
836
|
await stopStreaming();
|
|
782
|
-
lastStartRequestId = null;
|
|
783
|
-
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
|
+
|
|
784
846
|
await startStreaming();
|
|
785
847
|
} catch (error) {
|
|
786
848
|
debug.error('webcodecs', 'Recovery failed:', error);
|
|
@@ -802,42 +864,43 @@
|
|
|
802
864
|
return;
|
|
803
865
|
}
|
|
804
866
|
|
|
805
|
-
|
|
867
|
+
const myGeneration = streamingGeneration;
|
|
868
|
+
debug.log('webcodecs', `🚀 Fast reconnect for session ${sessionId} (gen=${myGeneration})`);
|
|
806
869
|
|
|
807
870
|
try {
|
|
808
871
|
isRecovering = true;
|
|
809
872
|
isStartingStream = true;
|
|
810
|
-
|
|
811
|
-
// Set isReconnecting to prevent loading overlay during reconnect
|
|
812
|
-
// This ensures the last frame stays visible instead of "Loading preview..."
|
|
813
873
|
isReconnecting = true;
|
|
814
874
|
|
|
815
|
-
// Don't reset hasReceivedFirstFrame - keep showing last frame during reconnect
|
|
816
|
-
|
|
817
|
-
// Use reconnectToExistingStream which does NOT stop backend streaming
|
|
818
875
|
const success = await webCodecsService.reconnectToExistingStream(sessionId, canvasElement);
|
|
819
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
|
+
|
|
820
883
|
if (success) {
|
|
821
884
|
isWebCodecsActive = true;
|
|
822
885
|
isConnected = true;
|
|
823
886
|
activeStreamingSessionId = sessionId;
|
|
824
887
|
consecutiveFailures = 0;
|
|
825
|
-
startHealthCheck(true);
|
|
888
|
+
startHealthCheck(true);
|
|
826
889
|
debug.log('webcodecs', '✅ Fast reconnect successful');
|
|
827
890
|
} else {
|
|
828
891
|
throw new Error('Reconnect returned false');
|
|
829
892
|
}
|
|
830
893
|
} catch (error) {
|
|
831
894
|
debug.error('webcodecs', 'Fast reconnect failed:', error);
|
|
832
|
-
// Fall back to regular recovery on failure
|
|
833
895
|
consecutiveFailures++;
|
|
834
896
|
isStartingStream = false;
|
|
835
|
-
isReconnecting = false;
|
|
836
|
-
|
|
897
|
+
isReconnecting = false;
|
|
898
|
+
if (myGeneration === streamingGeneration) {
|
|
899
|
+
attemptRecovery();
|
|
900
|
+
}
|
|
837
901
|
} finally {
|
|
838
902
|
isRecovering = false;
|
|
839
903
|
isStartingStream = false;
|
|
840
|
-
// Note: isReconnecting will be reset when first frame is received
|
|
841
904
|
}
|
|
842
905
|
}
|
|
843
906
|
|
|
@@ -950,12 +1013,27 @@
|
|
|
950
1013
|
|
|
951
1014
|
// Stop existing streaming first if session changed
|
|
952
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
|
+
|
|
953
1025
|
const doStartStreaming = async () => {
|
|
1026
|
+
// Bail immediately if tab already changed
|
|
1027
|
+
if (capturedGeneration !== streamingGeneration) return;
|
|
1028
|
+
|
|
954
1029
|
if (activeStreamingSessionId && activeStreamingSessionId !== sessionId) {
|
|
955
1030
|
debug.log('webcodecs', `Session changed from ${activeStreamingSessionId} to ${sessionId}, stopping old stream first`);
|
|
956
1031
|
await stopStreaming();
|
|
957
|
-
//
|
|
958
|
-
|
|
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;
|
|
959
1037
|
}
|
|
960
1038
|
await startStreaming();
|
|
961
1039
|
};
|
|
@@ -963,7 +1041,7 @@
|
|
|
963
1041
|
// Small delay to ensure backend session is ready
|
|
964
1042
|
const timeout = setTimeout(() => {
|
|
965
1043
|
doStartStreaming();
|
|
966
|
-
},
|
|
1044
|
+
}, 30);
|
|
967
1045
|
|
|
968
1046
|
return () => clearTimeout(timeout);
|
|
969
1047
|
}
|
|
@@ -993,10 +1071,9 @@
|
|
|
993
1071
|
let lastMoveTime = 0;
|
|
994
1072
|
const handleMouseMove = (e: MouseEvent) => {
|
|
995
1073
|
const now = Date.now();
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
|
|
999
|
-
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) {
|
|
1000
1077
|
lastMoveTime = now;
|
|
1001
1078
|
handleCanvasMouseMove(e, canvas);
|
|
1002
1079
|
}
|
|
@@ -70,6 +70,19 @@
|
|
|
70
70
|
let showNavigationOverlay = $state(false);
|
|
71
71
|
let overlayHideTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
72
72
|
|
|
73
|
+
// Immediately reset navigation overlay on tab switch to prevent stale overlay from old tab
|
|
74
|
+
let previousOverlaySessionId: string | null = null;
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (sessionId !== previousOverlaySessionId) {
|
|
77
|
+
previousOverlaySessionId = sessionId;
|
|
78
|
+
if (overlayHideTimeout) {
|
|
79
|
+
clearTimeout(overlayHideTimeout);
|
|
80
|
+
overlayHideTimeout = null;
|
|
81
|
+
}
|
|
82
|
+
showNavigationOverlay = false;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
73
86
|
// Debounced navigation overlay - only for user-initiated toolbar navigations
|
|
74
87
|
// In-browser navigations (link clicks) only show progress bar, not this overlay
|
|
75
88
|
// This makes the preview behave like a real browser
|
|
@@ -90,9 +103,11 @@
|
|
|
90
103
|
else if (!shouldShowOverlay && showNavigationOverlay && !overlayHideTimeout) {
|
|
91
104
|
overlayHideTimeout = setTimeout(() => {
|
|
92
105
|
overlayHideTimeout = null;
|
|
93
|
-
// Re-check
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
// Re-check: only isNavigating controls this overlay.
|
|
107
|
+
// isReconnecting is intentionally excluded — it serves a different purpose
|
|
108
|
+
// (preventing solid loading overlay) and can stay true for a long time
|
|
109
|
+
// (e.g. ICE recovery), which would keep the overlay stuck indefinitely.
|
|
110
|
+
if (!isNavigating) {
|
|
96
111
|
showNavigationOverlay = false;
|
|
97
112
|
}
|
|
98
113
|
}, 100); // 100ms debounce
|