@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.
Files changed (25) hide show
  1. package/backend/chat/stream-manager.ts +136 -10
  2. package/backend/database/queries/session-queries.ts +9 -0
  3. package/backend/engine/adapters/claude/error-handler.ts +7 -2
  4. package/backend/engine/adapters/claude/stream.ts +21 -3
  5. package/backend/index.ts +25 -3
  6. package/backend/preview/browser/browser-preview-service.ts +16 -17
  7. package/backend/preview/browser/browser-video-capture.ts +199 -156
  8. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  9. package/backend/snapshot/helpers.ts +15 -2
  10. package/backend/ws/snapshot/restore.ts +43 -2
  11. package/frontend/components/chat/input/ChatInput.svelte +6 -1
  12. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  13. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  14. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  15. package/frontend/components/files/FileViewer.svelte +13 -2
  16. package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
  17. package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
  18. package/frontend/components/preview/browser/components/Container.svelte +2 -1
  19. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  20. package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
  21. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  22. package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
  23. package/frontend/services/chat/chat.service.ts +6 -1
  24. package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
  25. 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-xs bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 px-2 py-0.5 rounded">
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">{file.path} • </span> {formatFileSize(file.size || 0)}
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 = 500; // Check for first frame every 500ms (just for UI update, not recovery)
59
- const STUCK_STREAM_TIMEOUT = 5000; // Fallback: Request screencast refresh after 5 seconds of connected but no frame
60
- const NAVIGATION_FAST_REFRESH_DELAY = 500; // Fast refresh after navigation: 500ms
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
- hasReceivedFirstFrame = false; // Reset first frame state
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; // Increased for rapid tab switching
495
- const retryDelay = 500; // Increased delay for backend cleanup
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(); // Start monitoring for stuck streams
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
- if (stats?.isConnected && !stats?.firstFrameRendered && elapsed >= STUCK_STREAM_TIMEOUT && !hasRequestedScreencastRefresh) {
625
- debug.warn('webcodecs', `Stream appears stuck (connected but no frame for ${elapsed}ms), requesting screencast refresh`);
626
- hasRequestedScreencastRefresh = true;
627
- onRequestScreencastRefresh();
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', 'Skipping canvas clear during navigation - keeping last frame');
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
- clearCanvas();
819
- hasReceivedFirstFrame = false; // Reset to show loading overlay
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
- // Longer delay to ensure backend session is fully ready
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
- }, 200);
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 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
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
- isRestoring = false;
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 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
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
@@ -1344,6 +1344,7 @@
1344
1344
  externallyChanged={displayExternallyChanged}
1345
1345
  onForceReload={forceReloadTab}
1346
1346
  isBinary={displayIsBinary}
1347
+ projectPath={projectPath}
1347
1348
  />
1348
1349
  </div>
1349
1350
  </div>
@@ -196,7 +196,12 @@ class ChatService {
196
196
  this.streamCompleted = true;
197
197
  this.reconnected = false;
198
198
  this.activeProcessId = null;
199
- this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
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
- private readonly iceServers: RTCIceServer[] = [
120
- { urls: 'stun:stun.l.google.com:19302' },
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.5",
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",