@myrialabs/clopen 0.2.5 → 0.2.7

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 (43) 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 +16 -7
  5. package/backend/index.ts +25 -3
  6. package/backend/mcp/servers/browser-automation/browser.ts +23 -6
  7. package/backend/preview/browser/browser-mcp-control.ts +32 -16
  8. package/backend/preview/browser/browser-pool.ts +3 -1
  9. package/backend/preview/browser/browser-preview-service.ts +16 -17
  10. package/backend/preview/browser/browser-tab-manager.ts +1 -1
  11. package/backend/preview/browser/browser-video-capture.ts +199 -156
  12. package/backend/preview/browser/scripts/audio-stream.ts +11 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  14. package/backend/snapshot/helpers.ts +15 -2
  15. package/backend/ws/chat/stream.ts +1 -1
  16. package/backend/ws/preview/browser/tab-info.ts +5 -2
  17. package/backend/ws/snapshot/restore.ts +43 -2
  18. package/frontend/components/chat/input/ChatInput.svelte +6 -4
  19. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  20. package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
  21. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  22. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  23. package/frontend/components/files/FileViewer.svelte +13 -2
  24. package/frontend/components/history/HistoryModal.svelte +1 -1
  25. package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
  26. package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
  27. package/frontend/components/preview/browser/components/Container.svelte +23 -1
  28. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  29. package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
  30. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
  31. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
  32. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  33. package/frontend/components/workspace/PanelHeader.svelte +15 -0
  34. package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
  35. package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
  36. package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
  37. package/frontend/services/chat/chat.service.ts +9 -8
  38. package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
  39. package/frontend/stores/core/app.svelte.ts +4 -3
  40. package/frontend/stores/core/presence.svelte.ts +3 -2
  41. package/frontend/stores/core/sessions.svelte.ts +2 -0
  42. package/frontend/stores/ui/notification.svelte.ts +4 -1
  43. package/package.json +1 -1
@@ -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;
@@ -165,7 +164,7 @@ export class BrowserWebCodecsService {
165
164
  * Start WebCodecs streaming for a preview session
166
165
  */
167
166
  async startStreaming(sessionId: string, canvas: HTMLCanvasElement): Promise<boolean> {
168
- debug.log('webcodecs', `Starting streaming for session: ${sessionId}`);
167
+ debug.log('webcodecs', `[DIAG] startStreaming called: sessionId=${sessionId}, isConnected=${this.isConnected}, existingSessionId=${this.sessionId}`);
169
168
 
170
169
  if (!BrowserWebCodecsService.isSupported()) {
171
170
  debug.error('webcodecs', 'Not supported in this browser');
@@ -183,7 +182,11 @@ export class BrowserWebCodecsService {
183
182
  this.audioContext = new AudioContext({ sampleRate: 48000 });
184
183
  }
185
184
  if (this.audioContext.state === 'suspended') {
186
- await this.audioContext.resume().catch(() => {});
185
+ // Fire-and-forget: don't await — after page refresh (no user gesture),
186
+ // resume() returns a promise that NEVER resolves until user interacts.
187
+ // Awaiting it would block streaming indefinitely. Audio will resume
188
+ // automatically on first user interaction via the safety net in playAudioFrame.
189
+ this.audioContext.resume().catch(() => {});
187
190
  }
188
191
 
189
192
  // Clean up any existing connection
@@ -215,7 +218,9 @@ export class BrowserWebCodecsService {
215
218
  this.setupEventListeners();
216
219
 
217
220
  // Request server to start streaming and get offer
221
+ debug.log('webcodecs', `[DIAG] Sending preview:browser-stream-start for session: ${sessionId}`);
218
222
  const response = await ws.http('preview:browser-stream-start', {}, 30000);
223
+ debug.log('webcodecs', `[DIAG] preview:browser-stream-start response: success=${response.success}, hasOffer=${!!response.offer}, message=${response.message}`);
219
224
 
220
225
  if (!response.success) {
221
226
  throw new Error(response.message || 'Failed to start streaming');
@@ -226,12 +231,15 @@ export class BrowserWebCodecsService {
226
231
 
227
232
  // Set remote description (offer)
228
233
  if (response.offer) {
234
+ debug.log('webcodecs', `[DIAG] Using offer from stream-start response`);
229
235
  await this.handleOffer({
230
236
  type: response.offer.type as RTCSdpType,
231
237
  sdp: response.offer.sdp
232
238
  });
233
239
  } else {
240
+ debug.log('webcodecs', `[DIAG] No offer in stream-start response, fetching via stream-offer`);
234
241
  const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
242
+ debug.log('webcodecs', `[DIAG] preview:browser-stream-offer response: hasOffer=${!!offerResponse.offer}`);
235
243
  if (offerResponse.offer) {
236
244
  await this.handleOffer({
237
245
  type: offerResponse.offer.type as RTCSdpType,
@@ -242,7 +250,7 @@ export class BrowserWebCodecsService {
242
250
  }
243
251
  }
244
252
 
245
- debug.log('webcodecs', 'Streaming setup complete');
253
+ debug.log('webcodecs', '[DIAG] Streaming setup complete, waiting for ICE/DataChannel');
246
254
  return true;
247
255
  } catch (error) {
248
256
  debug.error('webcodecs', 'Failed to start streaming:', error);
@@ -596,9 +604,9 @@ export class BrowserWebCodecsService {
596
604
  this.audioContext = new AudioContext({ sampleRate: 48000 });
597
605
  }
598
606
 
599
- // Resume if suspended (may happen without user gesture)
607
+ // Resume if suspended fire-and-forget, same reason as in startStreaming
600
608
  if (this.audioContext.state === 'suspended') {
601
- await this.audioContext.resume();
609
+ this.audioContext.resume().catch(() => {});
602
610
  }
603
611
 
604
612
  debug.log('webcodecs', `AudioContext initialized (state: ${this.audioContext.state})`);
@@ -641,6 +649,11 @@ export class BrowserWebCodecsService {
641
649
  this.firstFrameTimestamp = frame.timestamp;
642
650
  debug.log('webcodecs', 'First video frame rendered');
643
651
 
652
+ // Notify immediately so UI can hide loading overlay without polling delay
653
+ if (this.onFirstFrame) {
654
+ this.onFirstFrame();
655
+ }
656
+
644
657
  // Reset navigation state - frames are flowing, navigation is complete
645
658
  if (this.isNavigating) {
646
659
  debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
@@ -744,33 +757,28 @@ export class BrowserWebCodecsService {
744
757
  }
745
758
 
746
759
  /**
747
- * Play audio frame with proper AV synchronization
748
- *
749
- * The key insight: Audio and video timestamps from the server use the same
750
- * performance.now() origin. However, audio may start LATER than video if the
751
- * page has no audio initially (silence is skipped).
760
+ * Play audio frame using simple back-to-back scheduling.
752
761
  *
753
- * Solution: Use lastVideoTimestamp (currently rendered video) as the reference
754
- * point, not the first video frame timestamp. This ensures we synchronize
755
- * audio to the CURRENT video position, not the initial position.
762
+ * Frames are scheduled immediately one after another. When a gap occurs
763
+ * (e.g. silence was skipped server-side and audio resumes), the schedule
764
+ * is reset with a 50ms lookahead so playback starts cleanly without
765
+ * audible pops or stutters from scheduling in the past.
756
766
  */
757
767
  private playAudioFrame(audioData: AudioData): void {
758
768
  if (!this.audioContext) return;
759
769
 
760
- // Safety net: resume AudioContext if it somehow got suspended
770
+ // Safety net: resume AudioContext if suspended
761
771
  if (this.audioContext.state === 'suspended') {
762
772
  this.audioContext.resume().catch(() => {});
763
773
  }
764
774
 
765
775
  try {
766
- // Create AudioBuffer
767
776
  const buffer = this.audioContext.createBuffer(
768
777
  audioData.numberOfChannels,
769
778
  audioData.numberOfFrames,
770
779
  audioData.sampleRate
771
780
  );
772
781
 
773
- // Copy audio data to buffer
774
782
  for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
775
783
  const options = {
776
784
  planeIndex: channel,
@@ -778,134 +786,27 @@ export class BrowserWebCodecsService {
778
786
  frameCount: audioData.numberOfFrames,
779
787
  format: 'f32-planar' as AudioSampleFormat
780
788
  };
781
-
782
- const requiredSize = audioData.allocationSize(options);
783
- const tempBuffer = new ArrayBuffer(requiredSize);
784
- const tempFloat32 = new Float32Array(tempBuffer);
789
+ // allocationSize() returns bytes — wrap in ArrayBuffer so Float32Array length is correct
790
+ const tempFloat32 = new Float32Array(new ArrayBuffer(audioData.allocationSize(options)));
785
791
  audioData.copyTo(tempFloat32, options);
786
-
787
- const channelData = buffer.getChannelData(channel);
788
- channelData.set(tempFloat32);
792
+ buffer.getChannelData(channel).set(tempFloat32);
789
793
  }
790
794
 
791
795
  const currentTime = this.audioContext.currentTime;
792
- const audioTimestamp = audioData.timestamp; // microseconds
793
- const bufferDuration = buffer.duration;
794
- const now = performance.now();
795
-
796
- // Wait for video to establish sync before playing audio
797
- if (!this.syncEstablished || this.lastVideoTimestamp === 0) {
798
- // No video yet - skip this audio frame to prevent desync
799
- return;
800
- }
801
-
802
- // Phase 1: Calibration - collect samples to determine stable offset
803
- if (this.audioCalibrationSamples < this.CALIBRATION_SAMPLES) {
804
- // Calculate the offset between audio timestamp and video timeline
805
- // audioVideoOffset > 0 means audio is AHEAD of video in stream time
806
- // audioVideoOffset < 0 means audio is BEHIND video in stream time
807
- const audioVideoOffset = (audioTimestamp - this.lastVideoTimestamp) / 1000000; // seconds
808
-
809
- // Also account for the time elapsed since video was rendered
810
- const timeSinceVideoRender = (now - this.lastVideoRealTime) / 1000; // seconds
811
-
812
- // Expected audio position relative to current video position
813
- // If audio and video are in sync, audio should play at:
814
- // currentTime + audioVideoOffset - timeSinceVideoRender
815
- const expectedOffset = audioVideoOffset - timeSinceVideoRender;
816
-
817
- this.audioOffsetAccumulator += expectedOffset;
818
- this.audioCalibrationSamples++;
819
-
820
- if (this.audioCalibrationSamples === this.CALIBRATION_SAMPLES) {
821
- // Calibration complete - calculate average offset
822
- this.calibratedAudioOffset = this.audioOffsetAccumulator / this.CALIBRATION_SAMPLES;
823
-
824
- // Clamp the offset to reasonable bounds (-500ms to +500ms)
825
- // Beyond this, something is wrong and we should just play immediately
826
- if (this.calibratedAudioOffset < -0.5) {
827
- debug.warn('webcodecs', `Audio calibration: offset ${(this.calibratedAudioOffset * 1000).toFixed(0)}ms too negative, clamping to -500ms`);
828
- this.calibratedAudioOffset = -0.5;
829
- } else if (this.calibratedAudioOffset > 0.5) {
830
- debug.warn('webcodecs', `Audio calibration: offset ${(this.calibratedAudioOffset * 1000).toFixed(0)}ms too positive, clamping to +500ms`);
831
- this.calibratedAudioOffset = 0.5;
832
- }
833
-
834
- // Initialize nextAudioPlayTime based on calibrated offset
835
- // Add small buffer (30ms) for smooth playback
836
- this.nextAudioPlayTime = currentTime + Math.max(0.03, this.calibratedAudioOffset + 0.03);
837
- this.audioSyncInitialized = true;
838
-
839
- debug.log('webcodecs', `Audio calibration complete: offset=${(this.calibratedAudioOffset * 1000).toFixed(1)}ms, startTime=${(this.nextAudioPlayTime - currentTime).toFixed(3)}s from now`);
840
- } else {
841
- // Still calibrating - skip this audio frame
842
- return;
843
- }
844
- }
845
796
 
846
- // Phase 2: Synchronized playback with drift correction
847
- let targetPlayTime = this.nextAudioPlayTime;
848
-
849
- // If we've fallen too far behind (buffer underrun), reset
850
- if (targetPlayTime < currentTime - 0.01) {
851
- // We're behind by more than 10ms, need to catch up
852
- targetPlayTime = currentTime + 0.02; // Small buffer to recover
853
- debug.warn('webcodecs', 'Audio buffer underrun, resetting playback');
854
- }
855
-
856
- // Periodic drift check (every ~500ms worth of audio, ~25 frames)
857
- if (this.stats.audioFramesDecoded % 25 === 0) {
858
- // Recalculate where audio SHOULD be based on current video position
859
- const audioVideoOffset = (audioTimestamp - this.lastVideoTimestamp) / 1000000;
860
- const timeSinceVideoRender = (now - this.lastVideoRealTime) / 1000;
861
- const expectedOffset = audioVideoOffset - timeSinceVideoRender;
862
-
863
- // Where audio should play relative to currentTime
864
- const idealTime = currentTime + expectedOffset + 0.03; // +30ms buffer
865
-
866
- const drift = targetPlayTime - idealTime;
867
-
868
- // Apply correction based on drift magnitude
869
- if (Math.abs(drift) > 0.2) {
870
- // Large drift (>200ms) - aggressive correction (80%)
871
- targetPlayTime -= drift * 0.8;
872
- debug.warn('webcodecs', `Large audio drift: ${(drift * 1000).toFixed(0)}ms, aggressive correction`);
873
- } else if (Math.abs(drift) > 0.05) {
874
- // Medium drift (50-200ms) - moderate correction (40%)
875
- targetPlayTime -= drift * 0.4;
876
- }
877
- // Small drift (<50ms) - no correction, continuous playback handles it
797
+ // When the scheduler has fallen behind (gap due to silence or decode delay),
798
+ // reset with a 50ms lookahead so the next chunk starts cleanly.
799
+ if (this.nextAudioPlayTime < currentTime) {
800
+ this.nextAudioPlayTime = currentTime + 0.05;
878
801
  }
879
802
 
880
- // Ensure we don't schedule in the past
881
- if (targetPlayTime < currentTime + 0.005) {
882
- targetPlayTime = currentTime + 0.005;
883
- }
884
-
885
- // Schedule this buffer
886
803
  const source = this.audioContext.createBufferSource();
887
804
  source.buffer = buffer;
888
805
  source.connect(this.audioContext.destination);
889
- source.start(targetPlayTime);
890
-
891
- // Track scheduled buffer
892
- this.audioBufferQueue.push({ buffer, scheduledTime: targetPlayTime });
806
+ source.start(this.nextAudioPlayTime);
893
807
 
894
- // Update next play time for continuous scheduling (back-to-back)
895
- this.nextAudioPlayTime = targetPlayTime + bufferDuration;
896
-
897
- // Limit queue size to prevent memory buildup
898
- if (this.audioBufferQueue.length > this.maxAudioQueueSize) {
899
- this.audioBufferQueue.shift();
900
- }
901
-
902
- // Cleanup old scheduled buffers
903
- source.onended = () => {
904
- const index = this.audioBufferQueue.findIndex(item => item.buffer === buffer);
905
- if (index !== -1) {
906
- this.audioBufferQueue.splice(index, 1);
907
- }
908
- };
808
+ // Back-to-back: next chunk plays immediately after this one ends
809
+ this.nextAudioPlayTime += buffer.duration;
909
810
  } catch (error) {
910
811
  debug.warn('webcodecs', 'Audio playback error:', error);
911
812
  }
@@ -1477,6 +1378,10 @@ export class BrowserWebCodecsService {
1477
1378
  this.onReconnectingStart = handler;
1478
1379
  }
1479
1380
 
1381
+ setFirstFrameHandler(handler: () => void): void {
1382
+ this.onFirstFrame = handler;
1383
+ }
1384
+
1480
1385
  setErrorHandler(handler: (error: Error) => void): void {
1481
1386
  this.onError = handler;
1482
1387
  }
@@ -214,10 +214,11 @@ export function isSessionUnread(sessionId: string): boolean {
214
214
 
215
215
  /**
216
216
  * Check if a project has any unread sessions.
217
+ * Optionally exclude a specific session (e.g. the currently viewed one).
217
218
  */
218
- export function hasUnreadSessionsForProject(projectId: string): boolean {
219
- for (const pId of appState.unreadSessions.values()) {
220
- if (pId === projectId) return true;
219
+ export function hasUnreadSessionsForProject(projectId: string, excludeSessionId?: string): boolean {
220
+ for (const [sId, pId] of appState.unreadSessions.entries()) {
221
+ if (pId === projectId && sId !== excludeSessionId) return true;
221
222
  }
222
223
  return false;
223
224
  }
@@ -119,8 +119,9 @@ export function getProjectStatusColor(projectId: string): string {
119
119
  return 'bg-amber-500';
120
120
  }
121
121
 
122
- // Check for unread sessions in this project
123
- if (hasUnreadSessionsForProject(projectId)) return 'bg-blue-500';
122
+ // Check for unread sessions in this project, excluding the currently viewed session
123
+ const currentSessionId = sessionState.currentSession?.id;
124
+ if (hasUnreadSessionsForProject(projectId, currentSessionId)) return 'bg-blue-500';
124
125
 
125
126
  return 'bg-slate-500/30';
126
127
  }
@@ -295,6 +295,8 @@ export async function loadSessions() {
295
295
  // wiping out any stream_event injected by catchup.
296
296
  await loadMessagesForSession(targetSession.id);
297
297
  sessionState.currentSession = targetSession;
298
+ // Clear unread status — user is actively viewing this session
299
+ markSessionRead(targetSession.id);
298
300
  // Join chat session room so we receive session-scoped events
299
301
  // (stream, input sync, edit mode, model sync).
300
302
  // Critical after refresh — without it, connection misses all events.
@@ -6,6 +6,9 @@ export const notificationStore = $state({
6
6
  maxNotifications: 5
7
7
  });
8
8
 
9
+ // Monotonic counter for unique notification IDs
10
+ let _notificationCounter = 0;
11
+
9
12
  // Derived values as functions (cannot export derived state from modules)
10
13
  export function hasNotifications() {
11
14
  return notificationStore.notifications.length > 0;
@@ -18,7 +21,7 @@ export function notificationCount() {
18
21
  // Notification management functions
19
22
  export function addNotification(notification: Omit<ToastNotification, 'id'>) {
20
23
  const newNotification: ToastNotification = {
21
- id: Date.now().toString(),
24
+ id: `${Date.now()}-${++_notificationCounter}`,
22
25
  ...notification,
23
26
  duration: notification.duration || 5000 // Default 5 seconds
24
27
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",