@myrialabs/clopen 0.2.10 → 0.2.12
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 +114 -16
- package/backend/database/queries/project-queries.ts +1 -4
- package/backend/database/queries/session-queries.ts +36 -1
- package/backend/database/queries/snapshot-queries.ts +122 -0
- package/backend/database/utils/connection.ts +17 -11
- package/backend/engine/adapters/claude/stream.ts +12 -2
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +18 -2
- 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/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +41 -2
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- package/backend/ws/projects/crud.ts +72 -7
- package/backend/ws/sessions/crud.ts +119 -2
- package/backend/ws/system/operations.ts +14 -39
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +14 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +16 -73
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- 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/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
- package/frontend/components/workspace/MobileNavigator.svelte +57 -10
- package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
- package/frontend/services/chat/chat.service.ts +111 -16
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +4 -1
- package/package.json +2 -2
|
@@ -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
|
|
@@ -163,57 +163,58 @@
|
|
|
163
163
|
progressPercent = 0;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
// Reset progress bar immediately when active tab changes
|
|
167
|
-
//
|
|
166
|
+
// Reset progress bar immediately when active tab changes.
|
|
167
|
+
// A brief suppression window prevents the loading $effect from
|
|
168
|
+
// restarting progress before global state has synced to the new tab.
|
|
168
169
|
let previousActiveTabId = $state<string | null>(null);
|
|
170
|
+
let tabSwitchSuppressUntil = 0;
|
|
169
171
|
$effect(() => {
|
|
170
172
|
if (activeTabId !== previousActiveTabId) {
|
|
171
173
|
previousActiveTabId = activeTabId;
|
|
172
|
-
// Immediately stop any running progress animation and clear pending timeouts
|
|
173
174
|
stopProgress();
|
|
174
175
|
if (progressCompleteTimeout) {
|
|
175
176
|
clearTimeout(progressCompleteTimeout);
|
|
176
177
|
progressCompleteTimeout = null;
|
|
177
178
|
}
|
|
179
|
+
// Suppress loading $effect for a short window to allow state sync
|
|
180
|
+
tabSwitchSuppressUntil = Date.now() + 150;
|
|
178
181
|
}
|
|
179
182
|
});
|
|
180
183
|
|
|
181
184
|
// Watch loading states to control progress bar
|
|
182
|
-
// Progress bar should be active during:
|
|
183
|
-
// 1. isLaunchingBrowser: API call to launch browser
|
|
184
|
-
// 2. sessionInfo exists but isStreamReady false: waiting for first frame (initial load)
|
|
185
|
-
// 3. isNavigating: navigating within same session (link click)
|
|
186
|
-
// 4. isReconnecting: fast reconnect after navigation (keeps progress bar visible)
|
|
187
|
-
// 5. isLoading: generic loading state
|
|
188
185
|
$effect(() => {
|
|
189
186
|
const waitingForInitialFrame = sessionInfo && !isStreamReady && !isNavigating && !isReconnecting;
|
|
190
187
|
const shouldShowProgress = isLoading || isLaunchingBrowser || isNavigating || isReconnecting || waitingForInitialFrame;
|
|
191
188
|
|
|
189
|
+
// Skip during tab switch suppression window — state may be stale from old tab
|
|
190
|
+
if (Date.now() < tabSwitchSuppressUntil) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
192
194
|
// Cancel any pending completion when a loading state becomes active
|
|
193
195
|
if (shouldShowProgress && progressCompleteTimeout) {
|
|
194
196
|
clearTimeout(progressCompleteTimeout);
|
|
195
197
|
progressCompleteTimeout = null;
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
// Only start if not already showing progress (prevent restart)
|
|
199
200
|
if (shouldShowProgress && !showProgress) {
|
|
200
201
|
startProgressAnimation();
|
|
201
202
|
}
|
|
202
|
-
// Only complete if currently showing progress - with debounce to handle state transitions
|
|
203
|
-
// This prevents the progress bar from briefly completing during isNavigating → isReconnecting transition
|
|
204
203
|
else if (!shouldShowProgress && showProgress && !progressCompleteTimeout) {
|
|
205
204
|
progressCompleteTimeout = setTimeout(() => {
|
|
206
205
|
progressCompleteTimeout = null;
|
|
207
|
-
// Re-check if we should still complete (state might have changed)
|
|
208
206
|
const stillShouldComplete = !isLoading && !isLaunchingBrowser && !isNavigating && !isReconnecting;
|
|
209
207
|
const stillWaitingForFrame = sessionInfo && !isStreamReady && !isNavigating && !isReconnecting;
|
|
210
208
|
if (stillShouldComplete && !stillWaitingForFrame) {
|
|
211
209
|
completeProgress();
|
|
212
210
|
}
|
|
213
|
-
}, 100);
|
|
211
|
+
}, 100);
|
|
214
212
|
}
|
|
215
213
|
});
|
|
216
214
|
|
|
215
|
+
// Whether the currently active tab is under MCP control
|
|
216
|
+
const isMcpControlled = $derived(activeTabId != null && mcpControlledTabIds.has(activeTabId));
|
|
217
|
+
|
|
217
218
|
// Cleanup animation frame on component destroy
|
|
218
219
|
onDestroy(() => {
|
|
219
220
|
if (progressAnimationId) {
|
|
@@ -304,8 +305,9 @@
|
|
|
304
305
|
oninput={handleUrlInput}
|
|
305
306
|
onfocus={() => isUserTyping = true}
|
|
306
307
|
onblur={() => isUserTyping = false}
|
|
307
|
-
placeholder=
|
|
308
|
-
|
|
308
|
+
placeholder={isMcpControlled ? 'MCP controlled — navigation disabled' : 'Enter URL to preview...'}
|
|
309
|
+
disabled={isMcpControlled}
|
|
310
|
+
class="flex-1 pl-3 py-2 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis disabled:opacity-50 disabled:cursor-not-allowed"
|
|
309
311
|
/>
|
|
310
312
|
<div class="flex items-center gap-1 px-1.5">
|
|
311
313
|
{#if url}
|
|
@@ -318,8 +320,8 @@
|
|
|
318
320
|
</button>
|
|
319
321
|
<button
|
|
320
322
|
onclick={handleRefresh}
|
|
321
|
-
disabled={isLoading}
|
|
322
|
-
class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50"
|
|
323
|
+
disabled={isLoading || isMcpControlled}
|
|
324
|
+
class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
323
325
|
title="Refresh current page"
|
|
324
326
|
>
|
|
325
327
|
<Icon name={isLoading ? 'lucide:loader-circle' : 'lucide:refresh-cw'} class="w-4 h-4 transition-transform duration-200 {isLoading ? 'animate-spin' : 'hover:rotate-180'}" />
|
|
@@ -327,9 +329,9 @@
|
|
|
327
329
|
{/if}
|
|
328
330
|
<button
|
|
329
331
|
onclick={handleGoClick}
|
|
330
|
-
disabled={!urlInput.trim() || isLoading}
|
|
331
|
-
class="ml-0.5 px-3.5 py-1 text-xs font-medium rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50"
|
|
332
|
-
title=
|
|
332
|
+
disabled={!urlInput.trim() || isLoading || isMcpControlled}
|
|
333
|
+
class="ml-0.5 px-3.5 py-1 text-xs font-medium rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
334
|
+
title={isMcpControlled ? 'Navigation disabled — MCP controlled' : 'Navigate to URL'}
|
|
333
335
|
>
|
|
334
336
|
Go
|
|
335
337
|
</button>
|
|
@@ -175,6 +175,17 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
175
175
|
if (onErrorChange) onErrorChange(null);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// Backend created this tab with setActive=true, which may override a tab switch
|
|
179
|
+
// that happened while the 60-second launch call was awaiting. Re-assert the
|
|
180
|
+
// correct active tab on the backend if the user switched away during launch.
|
|
181
|
+
if (tabId !== tabManager.activeTabId && tabManager.activeTabId) {
|
|
182
|
+
const currentActiveTab = tabManager.getTab(tabManager.activeTabId);
|
|
183
|
+
if (currentActiveTab?.sessionId) {
|
|
184
|
+
debug.log('preview', `🔄 Tab switched during launch — re-asserting active backend tab: ${tabManager.activeTabId}`);
|
|
185
|
+
void switchToBackendTab(currentActiveTab.sessionId, getProjectId());
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
178
189
|
} else {
|
|
179
190
|
const errorMsg = result.error || 'Unknown error';
|
|
180
191
|
debug.error('preview', `❌ Browser launch failed:`, errorMsg);
|
|
@@ -210,7 +221,8 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
210
221
|
|
|
211
222
|
// Get current projectId
|
|
212
223
|
const projectId = getProjectId();
|
|
213
|
-
|
|
224
|
+
// Send explicit tabId (backend sessionId) to prevent cross-contamination during rapid switching
|
|
225
|
+
const result = await navigateBrowserOp(newUrl, projectId, tab.sessionId);
|
|
214
226
|
|
|
215
227
|
if (result.success) {
|
|
216
228
|
const finalUrl = result.finalUrl || newUrl;
|
|
@@ -173,6 +173,9 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
173
173
|
tabManager.updateTab(tabId, { consoleLogs: [] });
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// Safety timeout handles to clear stale isLoading if navigation-complete never arrives
|
|
177
|
+
const loadingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
178
|
+
|
|
176
179
|
function handleNavigationLoading(tabId: string, data: any) {
|
|
177
180
|
if (data && data.url) {
|
|
178
181
|
const tab = tabManager.getTab(tabId);
|
|
@@ -180,17 +183,26 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
180
183
|
// Only set isLoading (progress bar) for in-browser navigations.
|
|
181
184
|
// Do NOT set isNavigating here — that flag is reserved for user-initiated
|
|
182
185
|
// toolbar navigations (Go button/Enter), which is set in navigateBrowserForTab().
|
|
183
|
-
// This prevents the "Loading preview..." overlay from showing on link clicks
|
|
184
|
-
// within the browser, making it behave like a real browser.
|
|
185
186
|
tabManager.updateTab(tabId, {
|
|
186
187
|
isLoading: true,
|
|
187
188
|
url: data.url,
|
|
188
189
|
title: getTabTitle(data.url)
|
|
189
190
|
});
|
|
190
191
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
192
|
+
// Safety timeout: if no navigation-complete event arrives within 15s,
|
|
193
|
+
// clear isLoading to prevent it from being stuck indefinitely.
|
|
194
|
+
// This handles aborted navigations (user clicks another link before load).
|
|
195
|
+
const existing = loadingTimeouts.get(tabId);
|
|
196
|
+
if (existing) clearTimeout(existing);
|
|
197
|
+
loadingTimeouts.set(tabId, setTimeout(() => {
|
|
198
|
+
loadingTimeouts.delete(tabId);
|
|
199
|
+
const currentTab = tabManager.getTab(tabId);
|
|
200
|
+
if (currentTab?.isLoading) {
|
|
201
|
+
debug.warn('preview', `⏰ Safety timeout: clearing stale isLoading for tab ${tabId}`);
|
|
202
|
+
tabManager.updateTab(tabId, { isLoading: false });
|
|
203
|
+
}
|
|
204
|
+
}, 15000));
|
|
205
|
+
|
|
194
206
|
if (tabId === tabManager.activeTabId && onNavigationUpdate && !tab?.isNavigating) {
|
|
195
207
|
onNavigationUpdate(tabId, data.url);
|
|
196
208
|
}
|
|
@@ -198,6 +210,13 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
198
210
|
}
|
|
199
211
|
|
|
200
212
|
function handleNavigation(tabId: string, data: any, tab: PreviewTab) {
|
|
213
|
+
// Clear safety timeout — navigation completed normally
|
|
214
|
+
const timeout = loadingTimeouts.get(tabId);
|
|
215
|
+
if (timeout) {
|
|
216
|
+
clearTimeout(timeout);
|
|
217
|
+
loadingTimeouts.delete(tabId);
|
|
218
|
+
}
|
|
219
|
+
|
|
201
220
|
if (data && data.url && data.url !== tab.url) {
|
|
202
221
|
debug.log('preview', `🧭 Navigation completed for tab ${tabId}: ${tab.url} → ${data.url}`);
|
|
203
222
|
tabManager.updateTab(tabId, {
|
|
@@ -207,12 +226,10 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
207
226
|
title: getTabTitle(data.url)
|
|
208
227
|
});
|
|
209
228
|
|
|
210
|
-
// Only update parent if this is the active tab
|
|
211
229
|
if (tabId === tabManager.activeTabId && onNavigationUpdate) {
|
|
212
230
|
onNavigationUpdate(tabId, data.url);
|
|
213
231
|
}
|
|
214
232
|
} else if (data && data.url === tab.url) {
|
|
215
|
-
// Same URL but navigation completed (e.g., page refresh)
|
|
216
233
|
debug.log('preview', `🔄 Same URL navigation completed for tab ${tabId}: ${data.url}`);
|
|
217
234
|
tabManager.updateTab(tabId, {
|
|
218
235
|
isLoading: false,
|
|
@@ -222,6 +239,13 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
222
239
|
}
|
|
223
240
|
|
|
224
241
|
function handleNavigationSpa(tabId: string, data: any, tab: PreviewTab) {
|
|
242
|
+
// Clear safety timeout — SPA navigation also resolves the loading state
|
|
243
|
+
const timeout = loadingTimeouts.get(tabId);
|
|
244
|
+
if (timeout) {
|
|
245
|
+
clearTimeout(timeout);
|
|
246
|
+
loadingTimeouts.delete(tabId);
|
|
247
|
+
}
|
|
248
|
+
|
|
225
249
|
if (data && data.url && data.url !== tab.url) {
|
|
226
250
|
debug.log('preview', `🔄 SPA navigation for tab ${tabId}: ${tab.url} → ${data.url}`);
|
|
227
251
|
|
|
@@ -122,9 +122,10 @@ export async function launchBrowser(
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* Navigate
|
|
125
|
+
* Navigate a specific tab to new URL.
|
|
126
|
+
* Requires explicit tabId to prevent cross-contamination during rapid tab switching.
|
|
126
127
|
*/
|
|
127
|
-
export async function navigateBrowser(newUrl: string, projectId: string): Promise<NavigateResult> {
|
|
128
|
+
export async function navigateBrowser(newUrl: string, projectId: string, tabId?: string): Promise<NavigateResult> {
|
|
128
129
|
if (!newUrl) {
|
|
129
130
|
return { success: false, error: 'No URL provided' };
|
|
130
131
|
}
|
|
@@ -134,8 +135,8 @@ export async function navigateBrowser(newUrl: string, projectId: string): Promis
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
try {
|
|
137
|
-
//
|
|
138
|
-
const data = await ws.http('preview:browser-tab-navigate', { url: newUrl }, 30000);
|
|
138
|
+
// Always send explicit tabId to prevent race conditions during rapid tab switching
|
|
139
|
+
const data = await ws.http('preview:browser-tab-navigate', { url: newUrl, tabId }, 30000);
|
|
139
140
|
|
|
140
141
|
return { success: true, finalUrl: data.finalUrl };
|
|
141
142
|
} catch (error) {
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
let openCodeCommandCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
74
74
|
|
|
75
75
|
// Debug PTY (xterm.js)
|
|
76
|
-
const showDebug = $state(
|
|
76
|
+
const showDebug = $state(true);
|
|
77
77
|
let debugTermContainer = $state<HTMLDivElement>();
|
|
78
78
|
let debugTerminal: Terminal | null = null;
|
|
79
79
|
let debugFitAddon: FitAddon | null = null;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { addNotification } from '$frontend/stores/ui/notification.svelte';
|
|
3
|
-
import { resetToDefaults } from '$frontend/stores/features/settings.svelte';
|
|
4
3
|
import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
|
|
5
4
|
import Icon from '../../common/display/Icon.svelte';
|
|
6
5
|
import { debug } from '$shared/utils/logger';
|
|
@@ -41,68 +40,16 @@
|
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
async function resetSettings() {
|
|
45
|
-
const confirmed = await showConfirm({
|
|
46
|
-
title: 'Reset Settings',
|
|
47
|
-
message:
|
|
48
|
-
'Are you sure you want to reset all settings to defaults? This will not delete your projects or conversations.',
|
|
49
|
-
type: 'warning',
|
|
50
|
-
confirmText: 'Reset Settings',
|
|
51
|
-
cancelText: 'Cancel'
|
|
52
|
-
});
|
|
53
43
|
|
|
54
|
-
if (confirmed) {
|
|
55
|
-
resetToDefaults();
|
|
56
|
-
addNotification({
|
|
57
|
-
type: 'success',
|
|
58
|
-
title: 'Settings Reset',
|
|
59
|
-
message: 'All settings have been restored to defaults'
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
44
|
</script>
|
|
64
45
|
|
|
65
46
|
<div class="py-1">
|
|
66
47
|
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Data Management</h3>
|
|
67
48
|
<p class="text-sm text-slate-600 dark:text-slate-500 mb-5">
|
|
68
|
-
|
|
49
|
+
Manage your application data
|
|
69
50
|
</p>
|
|
70
51
|
|
|
71
52
|
<div class="flex flex-col gap-4">
|
|
72
|
-
<!-- Storage Info -->
|
|
73
|
-
<div
|
|
74
|
-
class="flex items-center gap-3.5 p-4 bg-slate-100/50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 rounded-lg"
|
|
75
|
-
>
|
|
76
|
-
<Icon name="lucide:hard-drive" class="w-5 h-5 text-slate-500 dark:text-slate-400" />
|
|
77
|
-
<div class="flex-1">
|
|
78
|
-
<div class="text-sm font-medium text-slate-900 dark:text-slate-100">Local Storage</div>
|
|
79
|
-
<div class="text-xs text-slate-600 dark:text-slate-400">Data is stored locally in your browser</div>
|
|
80
|
-
</div>
|
|
81
|
-
</div>
|
|
82
|
-
|
|
83
|
-
<!-- Action Cards -->
|
|
84
|
-
<div class="flex flex-col gap-3">
|
|
85
|
-
<!-- Reset Settings -->
|
|
86
|
-
<div
|
|
87
|
-
class="flex items-center justify-between gap-4 py-3 px-4 bg-slate-100/50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 rounded-lg"
|
|
88
|
-
>
|
|
89
|
-
<div class="flex items-center gap-3">
|
|
90
|
-
<Icon name="lucide:refresh-cw" class="w-4.5 h-4.5 text-slate-600 dark:text-slate-400" />
|
|
91
|
-
<div class="text-left">
|
|
92
|
-
<div class="text-sm font-medium text-slate-900 dark:text-slate-100">Reset Settings</div>
|
|
93
|
-
<div class="text-xs text-slate-600 dark:text-slate-400">Restore default settings</div>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
<button
|
|
97
|
-
type="button"
|
|
98
|
-
class="px-4 py-2 bg-slate-200 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium cursor-pointer transition-all duration-150 hover:bg-slate-300 dark:hover:bg-slate-600 hover:border-slate-400 dark:hover:border-slate-500 disabled:opacity-60 disabled:cursor-not-allowed whitespace-nowrap"
|
|
99
|
-
onclick={resetSettings}
|
|
100
|
-
>
|
|
101
|
-
Reset
|
|
102
|
-
</button>
|
|
103
|
-
</div>
|
|
104
|
-
</div>
|
|
105
|
-
|
|
106
53
|
<!-- Danger Zone -->
|
|
107
54
|
<div class="mt-2">
|
|
108
55
|
<div class="flex items-center gap-2 mb-3 text-xs font-medium text-red-600 dark:text-red-400">
|