@myrialabs/clopen 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +21 -3
- package/backend/index.ts +25 -3
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/frontend/components/chat/input/ChatInput.svelte +6 -1
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/files/FileViewer.svelte +13 -2
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
- package/frontend/components/preview/browser/components/Container.svelte +2 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
- package/frontend/services/chat/chat.service.ts +6 -1
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
- package/package.json +1 -1
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
<span class="text-xs font-medium text-slate-700 dark:text-slate-300">Command:</span>
|
|
34
34
|
</div>
|
|
35
35
|
{#if timeout}
|
|
36
|
-
<div class="inline-block ml-auto text-
|
|
36
|
+
<div class="inline-block ml-auto text-3xs bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 px-2 py-0.5 rounded">
|
|
37
37
|
Timeout: {timeout}ms
|
|
38
38
|
</div>
|
|
39
39
|
{/if}
|
|
40
40
|
</div>
|
|
41
41
|
|
|
42
42
|
<!-- Terminal-style command display -->
|
|
43
|
-
<div class="bg-slate-50 dark:bg-slate-950 border border-slate-200/60 dark:border-slate-800/60 rounded-md p-2.5 font-mono text-sm">
|
|
43
|
+
<div class="max-h-64 overflow-y-auto bg-slate-50 dark:bg-slate-950 border border-slate-200/60 dark:border-slate-800/60 rounded-md p-2.5 font-mono text-sm">
|
|
44
44
|
<div class="flex items-start gap-2">
|
|
45
45
|
<span class="text-green-600 dark:text-green-400 select-none">$</span>
|
|
46
46
|
<div class="flex-1 text-slate-900 dark:text-slate-200 break-all">
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
externallyChanged?: boolean;
|
|
41
41
|
onForceReload?: () => void;
|
|
42
42
|
isBinary?: boolean;
|
|
43
|
+
projectPath?: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const {
|
|
@@ -56,9 +57,19 @@
|
|
|
56
57
|
onToggleWordWrap,
|
|
57
58
|
externallyChanged = false,
|
|
58
59
|
onForceReload,
|
|
59
|
-
isBinary = false
|
|
60
|
+
isBinary = false,
|
|
61
|
+
projectPath = ''
|
|
60
62
|
}: Props = $props();
|
|
61
63
|
|
|
64
|
+
// Relative path for display
|
|
65
|
+
const displayPath = $derived.by(() => {
|
|
66
|
+
if (!file) return '';
|
|
67
|
+
if (projectPath && file.path.startsWith(projectPath)) {
|
|
68
|
+
return file.path.slice(projectPath.length).replace(/^[/\\]/, '');
|
|
69
|
+
}
|
|
70
|
+
return file.path;
|
|
71
|
+
});
|
|
72
|
+
|
|
62
73
|
// Theme state
|
|
63
74
|
const isDark = $derived(themeStore.isDark);
|
|
64
75
|
const monacoTheme = $derived(isDark ? 'vs-dark' : 'vs-light');
|
|
@@ -273,7 +284,7 @@
|
|
|
273
284
|
{file.name}
|
|
274
285
|
</h3>
|
|
275
286
|
<p class="text-xs text-slate-600 dark:text-slate-400 truncate mt-0.5">
|
|
276
|
-
<span class="hidden sm:inline">{
|
|
287
|
+
<span class="hidden sm:inline">{displayPath} • </span> {formatFileSize(file.size || 0)}
|
|
277
288
|
</p>
|
|
278
289
|
</div>
|
|
279
290
|
</div>
|
|
@@ -160,6 +160,7 @@
|
|
|
160
160
|
if (currentProjectId && currentProjectId !== previousProjectId && previousProjectId !== '') {
|
|
161
161
|
debug.log('preview', `🔄 Project changed: ${previousProjectId} → ${currentProjectId}`);
|
|
162
162
|
previousProjectId = currentProjectId;
|
|
163
|
+
sessionsRecovered = false; // Reset for new project (enables empty tab creation if needed)
|
|
163
164
|
coordinator.switchProject();
|
|
164
165
|
}
|
|
165
166
|
});
|
|
@@ -47,17 +47,24 @@
|
|
|
47
47
|
let isRecovering = $state(false); // Track recovery attempts
|
|
48
48
|
let connectionFailed = $state(false); // Track if connection actually failed (not just slow)
|
|
49
49
|
let hasRequestedScreencastRefresh = false; // Track if we've already requested refresh for this stream
|
|
50
|
+
let screencastRefreshCount = 0; // Track retry count for stuck detection
|
|
50
51
|
let navigationJustCompleted = false; // Track if navigation just completed (for fast refresh)
|
|
51
52
|
|
|
53
|
+
// Canvas snapshot storage for instant tab switching
|
|
54
|
+
// Stores a clone of the canvas per sessionId so switching back shows content immediately
|
|
55
|
+
const canvasSnapshots = new Map<string, HTMLCanvasElement>();
|
|
56
|
+
const MAX_SNAPSHOTS = 10;
|
|
57
|
+
let hasRestoredSnapshot = false; // Prevents canvas clear/reset during streaming start
|
|
58
|
+
|
|
52
59
|
// Recovery is only triggered by ACTUAL failures, not timeouts
|
|
53
60
|
// - ICE connection failed
|
|
54
61
|
// - WebCodecs connection closed unexpectedly
|
|
55
62
|
// - Explicit errors
|
|
56
63
|
const MAX_CONSECUTIVE_FAILURES = 2;
|
|
57
64
|
const HEALTH_CHECK_INTERVAL = 2000; // Check every 2 seconds for connection health
|
|
58
|
-
const FRAME_CHECK_INTERVAL =
|
|
59
|
-
const STUCK_STREAM_TIMEOUT =
|
|
60
|
-
const NAVIGATION_FAST_REFRESH_DELAY =
|
|
65
|
+
const FRAME_CHECK_INTERVAL = 100; // Fallback poll for first frame (primary path is onFirstFrame callback)
|
|
66
|
+
const STUCK_STREAM_TIMEOUT = 3000; // Fallback: Request screencast refresh after 3 seconds of connected but no frame
|
|
67
|
+
const NAVIGATION_FAST_REFRESH_DELAY = 300; // Fast refresh after navigation: 300ms
|
|
61
68
|
|
|
62
69
|
// Sync isStreamReady with hasReceivedFirstFrame for parent component
|
|
63
70
|
$effect(() => {
|
|
@@ -73,6 +80,10 @@
|
|
|
73
80
|
if (lastProjectId && currentProjectId && lastProjectId !== currentProjectId) {
|
|
74
81
|
debug.log('webcodecs', `🔄 Project changed (${lastProjectId} → ${currentProjectId}), destroying old WebCodecs service`);
|
|
75
82
|
|
|
83
|
+
// Clear canvas snapshots - they belong to old project's sessions
|
|
84
|
+
canvasSnapshots.clear();
|
|
85
|
+
hasRestoredSnapshot = false;
|
|
86
|
+
|
|
76
87
|
// Destroy old service
|
|
77
88
|
if (webCodecsService) {
|
|
78
89
|
webCodecsService.destroy();
|
|
@@ -416,7 +427,10 @@
|
|
|
416
427
|
|
|
417
428
|
isStartingStream = true;
|
|
418
429
|
isStreamStarting = true; // Show loading overlay
|
|
419
|
-
|
|
430
|
+
// Don't reset if we restored a snapshot - keep showing it
|
|
431
|
+
if (!hasRestoredSnapshot) {
|
|
432
|
+
hasReceivedFirstFrame = false; // Reset first frame state
|
|
433
|
+
}
|
|
420
434
|
|
|
421
435
|
try {
|
|
422
436
|
// If streaming a different session, stop first
|
|
@@ -480,6 +494,25 @@
|
|
|
480
494
|
onStatsUpdate(stats);
|
|
481
495
|
});
|
|
482
496
|
|
|
497
|
+
// Setup first frame handler - fires immediately when first frame decoded
|
|
498
|
+
// This eliminates the 500ms polling delay for hiding the loading overlay
|
|
499
|
+
webCodecsService.setFirstFrameHandler(() => {
|
|
500
|
+
if (!hasReceivedFirstFrame) {
|
|
501
|
+
debug.log('webcodecs', 'First frame callback - immediately updating UI');
|
|
502
|
+
hasReceivedFirstFrame = true;
|
|
503
|
+
consecutiveFailures = 0;
|
|
504
|
+
connectionFailed = false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Always reset reconnecting state on first real frame
|
|
508
|
+
// (outside !hasReceivedFirstFrame to handle snapshot + reconnect case)
|
|
509
|
+
if (isReconnecting) {
|
|
510
|
+
setTimeout(() => {
|
|
511
|
+
isReconnecting = false;
|
|
512
|
+
}, 300);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
483
516
|
// Setup cursor change handler
|
|
484
517
|
webCodecsService.setOnCursorChange((cursor: string) => {
|
|
485
518
|
updateCanvasCursor(cursor);
|
|
@@ -491,8 +524,8 @@
|
|
|
491
524
|
|
|
492
525
|
let success = false;
|
|
493
526
|
let retries = 0;
|
|
494
|
-
const maxRetries = 5;
|
|
495
|
-
const retryDelay =
|
|
527
|
+
const maxRetries = 5;
|
|
528
|
+
const retryDelay = 300;
|
|
496
529
|
|
|
497
530
|
while (!success && retries < maxRetries) {
|
|
498
531
|
try {
|
|
@@ -502,7 +535,8 @@
|
|
|
502
535
|
isConnected = true;
|
|
503
536
|
activeStreamingSessionId = sessionId;
|
|
504
537
|
consecutiveFailures = 0; // Reset failure counter on success
|
|
505
|
-
startHealthCheck(); //
|
|
538
|
+
startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
|
|
539
|
+
hasRestoredSnapshot = false; // Reset after using
|
|
506
540
|
debug.log('webcodecs', 'Streaming started successfully');
|
|
507
541
|
break;
|
|
508
542
|
}
|
|
@@ -533,6 +567,7 @@
|
|
|
533
567
|
} finally {
|
|
534
568
|
isStartingStream = false;
|
|
535
569
|
isStreamStarting = false; // Hide "Launching browser..." (but may still show "Connecting..." until first frame)
|
|
570
|
+
hasRestoredSnapshot = false; // Always reset in finally
|
|
536
571
|
}
|
|
537
572
|
}
|
|
538
573
|
|
|
@@ -561,6 +596,7 @@
|
|
|
561
596
|
}
|
|
562
597
|
connectionFailed = false;
|
|
563
598
|
hasRequestedScreencastRefresh = false; // Reset for new stream
|
|
599
|
+
screencastRefreshCount = 0; // Reset retry counter
|
|
564
600
|
|
|
565
601
|
const startTime = Date.now();
|
|
566
602
|
|
|
@@ -587,6 +623,7 @@
|
|
|
587
623
|
consecutiveFailures = 0;
|
|
588
624
|
connectionFailed = false;
|
|
589
625
|
hasRequestedScreencastRefresh = false; // Reset on success
|
|
626
|
+
screencastRefreshCount = 0; // Reset retry counter on success
|
|
590
627
|
|
|
591
628
|
// Reset reconnecting state after successful frame reception
|
|
592
629
|
// This completes the fast reconnect cycle
|
|
@@ -621,10 +658,21 @@
|
|
|
621
658
|
// STUCK STREAM DETECTION (FALLBACK): If connected but no first frame for too long,
|
|
622
659
|
// request screencast refresh (hot-swap) to restart CDP screencast.
|
|
623
660
|
// This handles cases where WebRTC is connected but CDP frames aren't flowing.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
661
|
+
// Retries: 1st at 3s (screencast refresh), 2nd at 6s (another refresh), 3rd at 10s (full recovery)
|
|
662
|
+
if (stats?.isConnected && !stats?.firstFrameRendered && !hasRequestedScreencastRefresh) {
|
|
663
|
+
const MAX_SCREENCAST_RETRIES = 2;
|
|
664
|
+
const retryThreshold = STUCK_STREAM_TIMEOUT + (screencastRefreshCount * 3000); // 3s, 6s
|
|
665
|
+
|
|
666
|
+
if (elapsed >= retryThreshold && screencastRefreshCount < MAX_SCREENCAST_RETRIES) {
|
|
667
|
+
screencastRefreshCount++;
|
|
668
|
+
debug.warn('webcodecs', `Stream stuck (connected, no frame for ${elapsed}ms), screencast refresh attempt ${screencastRefreshCount}/${MAX_SCREENCAST_RETRIES}`);
|
|
669
|
+
onRequestScreencastRefresh();
|
|
670
|
+
} else if (elapsed >= 10000 && screencastRefreshCount >= MAX_SCREENCAST_RETRIES) {
|
|
671
|
+
// Screencast refreshes didn't help - attempt full recovery
|
|
672
|
+
debug.warn('webcodecs', `Stream still stuck after ${screencastRefreshCount} screencast refreshes (${elapsed}ms), attempting full recovery`);
|
|
673
|
+
hasRequestedScreencastRefresh = true; // Prevent further retries
|
|
674
|
+
attemptRecovery();
|
|
675
|
+
}
|
|
628
676
|
}
|
|
629
677
|
|
|
630
678
|
}, FRAME_CHECK_INTERVAL);
|
|
@@ -770,11 +818,11 @@
|
|
|
770
818
|
lastStartRequestId = null; // Clear to allow new requests
|
|
771
819
|
// Note: Don't reset hasReceivedFirstFrame here - let startStreaming do it
|
|
772
820
|
// This prevents flashing when switching tabs
|
|
773
|
-
// Clear canvas to prevent stale frames, BUT keep last frame during navigation
|
|
774
|
-
if (!isNavigating) {
|
|
821
|
+
// Clear canvas to prevent stale frames, BUT keep last frame during navigation or snapshot restore
|
|
822
|
+
if (!isNavigating && !hasRestoredSnapshot) {
|
|
775
823
|
clearCanvas();
|
|
776
824
|
} else {
|
|
777
|
-
debug.log('webcodecs',
|
|
825
|
+
debug.log('webcodecs', `Skipping canvas clear - navigation: ${isNavigating}, snapshot: ${hasRestoredSnapshot}`);
|
|
778
826
|
}
|
|
779
827
|
}
|
|
780
828
|
}
|
|
@@ -813,10 +861,52 @@
|
|
|
813
861
|
const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
|
|
814
862
|
|
|
815
863
|
if (needsStreaming) {
|
|
816
|
-
// Clear canvas immediately when session changes to prevent stale frames
|
|
817
864
|
if (activeStreamingSessionId !== sessionId) {
|
|
818
|
-
|
|
819
|
-
|
|
865
|
+
// SNAPSHOT: Save current canvas before switching to new session
|
|
866
|
+
if (activeStreamingSessionId && hasReceivedFirstFrame && canvasElement.width > 0) {
|
|
867
|
+
try {
|
|
868
|
+
const clone = document.createElement('canvas');
|
|
869
|
+
clone.width = canvasElement.width;
|
|
870
|
+
clone.height = canvasElement.height;
|
|
871
|
+
const cloneCtx = clone.getContext('2d');
|
|
872
|
+
if (cloneCtx) {
|
|
873
|
+
cloneCtx.drawImage(canvasElement, 0, 0);
|
|
874
|
+
// Limit snapshot count
|
|
875
|
+
if (canvasSnapshots.size >= MAX_SNAPSHOTS) {
|
|
876
|
+
const firstKey = canvasSnapshots.keys().next().value;
|
|
877
|
+
if (firstKey) canvasSnapshots.delete(firstKey);
|
|
878
|
+
}
|
|
879
|
+
canvasSnapshots.set(activeStreamingSessionId, clone);
|
|
880
|
+
debug.log('webcodecs', `📸 Saved canvas snapshot for session ${activeStreamingSessionId}`);
|
|
881
|
+
}
|
|
882
|
+
} catch (e) {
|
|
883
|
+
debug.warn('webcodecs', 'Failed to capture canvas snapshot:', e);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// SNAPSHOT: Restore for new session if available
|
|
888
|
+
const existingSnapshot = canvasSnapshots.get(sessionId);
|
|
889
|
+
if (existingSnapshot) {
|
|
890
|
+
setupCanvasInternal(); // Ensure canvas dimensions are correct
|
|
891
|
+
try {
|
|
892
|
+
const ctx = canvasElement.getContext('2d');
|
|
893
|
+
if (ctx) {
|
|
894
|
+
ctx.drawImage(existingSnapshot, 0, 0, canvasElement.width, canvasElement.height);
|
|
895
|
+
hasRestoredSnapshot = true;
|
|
896
|
+
// Don't reset hasReceivedFirstFrame - snapshot is visible
|
|
897
|
+
debug.log('webcodecs', `📸 Restored canvas snapshot for session ${sessionId}`);
|
|
898
|
+
}
|
|
899
|
+
} catch (e) {
|
|
900
|
+
debug.warn('webcodecs', 'Failed to restore canvas snapshot:', e);
|
|
901
|
+
hasRestoredSnapshot = false;
|
|
902
|
+
clearCanvas();
|
|
903
|
+
hasReceivedFirstFrame = false;
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
hasRestoredSnapshot = false;
|
|
907
|
+
clearCanvas();
|
|
908
|
+
hasReceivedFirstFrame = false; // Reset to show loading overlay
|
|
909
|
+
}
|
|
820
910
|
}
|
|
821
911
|
|
|
822
912
|
// Stop existing streaming first if session changed
|
|
@@ -831,12 +921,10 @@
|
|
|
831
921
|
await startStreaming();
|
|
832
922
|
};
|
|
833
923
|
|
|
834
|
-
//
|
|
835
|
-
// This is especially important during viewport/device change
|
|
836
|
-
// when session is being recreated
|
|
924
|
+
// Small delay to ensure backend session is ready
|
|
837
925
|
const timeout = setTimeout(() => {
|
|
838
926
|
doStartStreaming();
|
|
839
|
-
},
|
|
927
|
+
}, 50);
|
|
840
928
|
|
|
841
929
|
return () => clearTimeout(timeout);
|
|
842
930
|
}
|
|
@@ -1039,6 +1127,7 @@
|
|
|
1039
1127
|
|
|
1040
1128
|
onDestroy(() => {
|
|
1041
1129
|
stopHealthCheck(); // Stop health monitoring
|
|
1130
|
+
canvasSnapshots.clear(); // Free snapshot memory
|
|
1042
1131
|
if (webCodecsService) {
|
|
1043
1132
|
webCodecsService.destroy();
|
|
1044
1133
|
webCodecsService = null;
|
|
@@ -52,8 +52,9 @@
|
|
|
52
52
|
let previewContainer = $state<HTMLDivElement | undefined>();
|
|
53
53
|
|
|
54
54
|
// Solid loading overlay: shown during initial load states
|
|
55
|
+
// Skip when lastFrameData exists (tab was previously loaded - snapshot handles display)
|
|
55
56
|
const showSolidOverlay = $derived(
|
|
56
|
-
isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting)
|
|
57
|
+
isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting && !lastFrameData)
|
|
57
58
|
);
|
|
58
59
|
|
|
59
60
|
// Navigation overlay state with debounce to prevent flickering during state transitions
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
{@const isActive = tab.id === activeTabId}
|
|
223
223
|
<button
|
|
224
224
|
type="button"
|
|
225
|
-
class="group relative flex items-center justify-center gap-1
|
|
225
|
+
class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
|
|
226
226
|
{isActive
|
|
227
227
|
? 'text-violet-600 dark:text-violet-400'
|
|
228
228
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
@@ -230,13 +230,6 @@
|
|
|
230
230
|
role="tab"
|
|
231
231
|
tabindex="0"
|
|
232
232
|
>
|
|
233
|
-
{#if tab.id === mcpControlledTabId}
|
|
234
|
-
<Icon name="lucide:bot" class="w-3 h-3 flex-shrink-0 text-amber-500" />
|
|
235
|
-
{:else if tab.isLoading}
|
|
236
|
-
<Icon name="lucide:loader-circle" class="w-3 h-3 animate-spin flex-shrink-0" />
|
|
237
|
-
{:else}
|
|
238
|
-
<Icon name="lucide:globe" class="w-3 h-3 flex-shrink-0" />
|
|
239
|
-
{/if}
|
|
240
233
|
<span class="truncate max-w-28" title={tab.url}>
|
|
241
234
|
{tab.title || 'New Tab'}
|
|
242
235
|
</span>
|
|
@@ -486,7 +486,10 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
486
486
|
|
|
487
487
|
if (!existingTabs || existingTabs.count === 0) {
|
|
488
488
|
debug.log('preview', '📭 No existing sessions to recover');
|
|
489
|
-
|
|
489
|
+
// Notify parent about 0-tab recovery (enables empty tab creation)
|
|
490
|
+
if (onSessionsRecovered) {
|
|
491
|
+
onSessionsRecovered(0);
|
|
492
|
+
}
|
|
490
493
|
return;
|
|
491
494
|
}
|
|
492
495
|
|
|
@@ -781,7 +784,7 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
781
784
|
const newProjectId = getProjectId();
|
|
782
785
|
debug.log('preview', `🔄 Switching to project: ${newProjectId}`);
|
|
783
786
|
|
|
784
|
-
// Set restore lock during project switch
|
|
787
|
+
// Set restore lock during entire project switch (prevents race conditions)
|
|
785
788
|
isRestoring = true;
|
|
786
789
|
|
|
787
790
|
try {
|
|
@@ -795,13 +798,18 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
795
798
|
browserCleanup.clearAll();
|
|
796
799
|
|
|
797
800
|
// Recover sessions from new project
|
|
798
|
-
// Note: recoverExistingSessions will handle its own lock
|
|
799
|
-
isRestoring = false;
|
|
800
801
|
await recoverExistingSessions();
|
|
801
802
|
} catch (error) {
|
|
802
803
|
debug.error('preview', '❌ Error switching project:', error);
|
|
804
|
+
} finally {
|
|
803
805
|
isRestoring = false;
|
|
804
806
|
}
|
|
807
|
+
|
|
808
|
+
// After recovery and lock release, create empty tab if no tabs exist
|
|
809
|
+
if (tabManager.getAllTabs().length === 0) {
|
|
810
|
+
debug.log('preview', '📭 No tabs after project switch, creating empty tab');
|
|
811
|
+
createNewTab('');
|
|
812
|
+
}
|
|
805
813
|
}
|
|
806
814
|
|
|
807
815
|
return {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
{@const isActive = session.isActive}
|
|
38
38
|
<button
|
|
39
39
|
type="button"
|
|
40
|
-
class="group relative flex items-center justify-center gap-1
|
|
40
|
+
class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
|
|
41
41
|
{isActive
|
|
42
42
|
? 'text-violet-600 dark:text-violet-400'
|
|
43
43
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
role="tab"
|
|
46
46
|
tabindex="0"
|
|
47
47
|
>
|
|
48
|
-
<Icon name="lucide:terminal" class="w-3 h-3 flex-shrink-0" />
|
|
49
48
|
<span class="truncate max-w-28">{session.name}</span>
|
|
50
49
|
<!-- Close button -->
|
|
51
50
|
<span
|
|
@@ -196,7 +196,12 @@ class ChatService {
|
|
|
196
196
|
this.streamCompleted = true;
|
|
197
197
|
this.reconnected = false;
|
|
198
198
|
this.activeProcessId = null;
|
|
199
|
-
|
|
199
|
+
// Don't clear isCancelling here — it causes a race with presence.
|
|
200
|
+
// The chat:cancelled WS event arrives before broadcastPresence() updates,
|
|
201
|
+
// so clearing isCancelling lets the presence $effect re-enable isLoading
|
|
202
|
+
// (because hasActiveForSession is still true). The presence $effect will
|
|
203
|
+
// clear isCancelling once the stream is actually gone from presence.
|
|
204
|
+
this.setProcessState({ isLoading: false, isWaitingInput: false });
|
|
200
205
|
|
|
201
206
|
// Mark any tool_use blocks that never got a tool_result
|
|
202
207
|
this.markInterruptedTools();
|
|
@@ -115,17 +115,16 @@ export class BrowserWebCodecsService {
|
|
|
115
115
|
private lastAudioBytesReceived = 0;
|
|
116
116
|
private lastStatsTime = 0;
|
|
117
117
|
|
|
118
|
-
// ICE servers
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
122
|
-
];
|
|
118
|
+
// ICE servers - empty for local connections (both peers on same machine)
|
|
119
|
+
// STUN servers are unnecessary for localhost and add 100-500ms ICE gathering latency
|
|
120
|
+
private readonly iceServers: RTCIceServer[] = [];
|
|
123
121
|
|
|
124
122
|
// Callbacks
|
|
125
123
|
private onConnectionChange: ((connected: boolean) => void) | null = null;
|
|
126
124
|
private onConnectionFailed: (() => void) | null = null;
|
|
127
125
|
private onNavigationReconnect: (() => void) | null = null; // Fast reconnection after navigation
|
|
128
126
|
private onReconnectingStart: (() => void) | null = null; // Signals reconnecting state started (for UI)
|
|
127
|
+
private onFirstFrame: (() => void) | null = null; // Fires immediately when first frame is decoded
|
|
129
128
|
private onError: ((error: Error) => void) | null = null;
|
|
130
129
|
private onStats: ((stats: BrowserWebCodecsStreamStats) => void) | null = null;
|
|
131
130
|
private onCursorChange: ((cursor: string) => void) | null = null;
|
|
@@ -641,6 +640,11 @@ export class BrowserWebCodecsService {
|
|
|
641
640
|
this.firstFrameTimestamp = frame.timestamp;
|
|
642
641
|
debug.log('webcodecs', 'First video frame rendered');
|
|
643
642
|
|
|
643
|
+
// Notify immediately so UI can hide loading overlay without polling delay
|
|
644
|
+
if (this.onFirstFrame) {
|
|
645
|
+
this.onFirstFrame();
|
|
646
|
+
}
|
|
647
|
+
|
|
644
648
|
// Reset navigation state - frames are flowing, navigation is complete
|
|
645
649
|
if (this.isNavigating) {
|
|
646
650
|
debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
|
|
@@ -1477,6 +1481,10 @@ export class BrowserWebCodecsService {
|
|
|
1477
1481
|
this.onReconnectingStart = handler;
|
|
1478
1482
|
}
|
|
1479
1483
|
|
|
1484
|
+
setFirstFrameHandler(handler: () => void): void {
|
|
1485
|
+
this.onFirstFrame = handler;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1480
1488
|
setErrorHandler(handler: (error: Error) => void): void {
|
|
1481
1489
|
this.onError = handler;
|
|
1482
1490
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|