@myrialabs/clopen 0.2.8 → 0.2.10

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/index.ts +12 -0
  2. package/backend/preview/browser/browser-navigation-tracker.ts +188 -31
  3. package/backend/preview/browser/browser-pool.ts +1 -1
  4. package/backend/preview/browser/browser-preview-service.ts +23 -0
  5. package/backend/preview/browser/browser-tab-manager.ts +16 -1
  6. package/backend/preview/browser/browser-video-capture.ts +2 -2
  7. package/backend/preview/browser/scripts/video-stream.ts +39 -4
  8. package/backend/terminal/stream-manager.ts +40 -26
  9. package/backend/ws/preview/browser/webcodecs.ts +11 -0
  10. package/backend/ws/preview/index.ts +8 -0
  11. package/backend/ws/system/operations.ts +23 -0
  12. package/frontend/components/chat/input/ChatInput.svelte +3 -3
  13. package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
  14. package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
  15. package/frontend/components/common/overlay/Dialog.svelte +1 -1
  16. package/frontend/components/common/overlay/Lightbox.svelte +2 -2
  17. package/frontend/components/common/overlay/Modal.svelte +2 -2
  18. package/frontend/components/common/xterm/XTerm.svelte +6 -1
  19. package/frontend/components/git/ConflictResolver.svelte +1 -1
  20. package/frontend/components/git/GitModal.svelte +2 -2
  21. package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
  22. package/frontend/components/preview/browser/components/Canvas.svelte +40 -23
  23. package/frontend/components/preview/browser/components/Container.svelte +8 -5
  24. package/frontend/components/preview/browser/components/Toolbar.svelte +16 -1
  25. package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -0
  26. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +37 -4
  27. package/frontend/components/settings/SettingsModal.svelte +1 -1
  28. package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
  29. package/frontend/components/terminal/Terminal.svelte +1 -29
  30. package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
  31. package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
  32. package/frontend/components/workspace/PanelHeader.svelte +30 -22
  33. package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
  34. package/frontend/services/preview/browser/browser-webcodecs.service.ts +110 -18
  35. package/frontend/services/project/status.service.ts +11 -1
  36. package/frontend/stores/core/sessions.svelte.ts +11 -1
  37. package/frontend/stores/features/terminal.svelte.ts +56 -26
  38. package/frontend/stores/ui/theme.svelte.ts +1 -1
  39. package/frontend/utils/ws.ts +42 -0
  40. package/index.html +2 -2
  41. package/package.json +1 -1
  42. package/shared/utils/ws-client.ts +21 -4
  43. package/static/manifest.json +2 -2
@@ -47,6 +47,9 @@
47
47
  // Mobile detection
48
48
  let isMobile = $state(false);
49
49
 
50
+ // Touchscreen detection
51
+ let isTouchDevice = $state(false);
52
+
50
53
  // Chat session users (other users in the same chat session, excluding self)
51
54
  const chatSessionUsers = $derived.by(() => {
52
55
  if (panelId !== 'chat') return [];
@@ -142,6 +145,7 @@
142
145
  onMount(() => {
143
146
  handleResize();
144
147
  if (browser) {
148
+ isTouchDevice = navigator.maxTouchPoints > 0 || 'ontouchstart' in window;
145
149
  window.addEventListener('resize', handleResize);
146
150
  }
147
151
  });
@@ -383,12 +387,13 @@
383
387
  {/if} -->
384
388
 
385
389
  <!-- Device size dropdown -->
386
- <div class="relative {isMobile ? '' : 'mr-1.5'}">
390
+ <div class="relative">
387
391
  <button
388
392
  type="button"
389
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
390
- onclick={toggleDeviceDropdown}
391
- title="Select device size"
393
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md transition-all duration-150 {previewPanelRef?.panelActions?.getIsMcpControlled() ? 'text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50' : 'text-slate-500 cursor-pointer hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
394
+ onclick={previewPanelRef?.panelActions?.getIsMcpControlled() ? undefined : toggleDeviceDropdown}
395
+ disabled={previewPanelRef?.panelActions?.getIsMcpControlled()}
396
+ title={previewPanelRef?.panelActions?.getIsMcpControlled() ? 'Controlled by MCP agent' : 'Select device size'}
392
397
  >
393
398
  {#if previewPanelRef?.panelActions?.getDeviceSize() === 'desktop'}
394
399
  <Icon name="lucide:monitor" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
@@ -481,27 +486,30 @@
481
486
  {/if}
482
487
  </div>
483
488
 
484
- <!-- Touch mode toggle (scroll ↔ trackpad cursor) -->
485
- <button
486
- type="button"
487
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md cursor-pointer transition-all duration-150 hover:bg-violet-500/10
488
- {previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 hover:text-slate-900 dark:hover:text-slate-100'}"
489
- onclick={() => {
490
- const current = previewPanelRef?.panelActions?.getTouchMode() || 'scroll';
491
- previewPanelRef?.panelActions?.setTouchMode(current === 'scroll' ? 'cursor' : 'scroll');
492
- }}
493
- title={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Trackpad mode: 1-finger moves cursor, tap=click, 2-finger scroll/right-click' : 'Scroll mode: touch scrolls the page (tap to click)'}
494
- >
495
- <Icon name={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'lucide:mouse-pointer-2' : 'lucide:pointer'} class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
496
- <span class="text-xs font-medium">{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Cursor' : 'Touch'}</span>
497
- </button>
489
+ <!-- Touch mode toggle (scroll ↔ trackpad cursor) — only shown on touchscreen devices -->
490
+ {#if isTouchDevice}
491
+ <button
492
+ type="button"
493
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md cursor-pointer transition-all duration-150 hover:bg-violet-500/10
494
+ {previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 hover:text-slate-900 dark:hover:text-slate-100'}"
495
+ onclick={() => {
496
+ const current = previewPanelRef?.panelActions?.getTouchMode() || 'scroll';
497
+ previewPanelRef?.panelActions?.setTouchMode(current === 'scroll' ? 'cursor' : 'scroll');
498
+ }}
499
+ title={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Trackpad mode: 1-finger moves cursor, tap=click, 2-finger scroll/right-click' : 'Scroll mode: touch scrolls the page (tap to click)'}
500
+ >
501
+ <Icon name={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'lucide:mouse-pointer-2' : 'lucide:pointer'} class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
502
+ <span class="text-xs font-medium">{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Cursor' : 'Touch'}</span>
503
+ </button>
504
+ {/if}
498
505
 
499
506
  <!-- Rotation toggle -->
500
507
  <button
501
508
  type="button"
502
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
503
- onclick={() => previewPanelRef?.panelActions?.toggleRotation()}
504
- title="Toggle orientation"
509
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md transition-all duration-150 {previewPanelRef?.panelActions?.getIsMcpControlled() ? 'text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50' : 'text-slate-500 cursor-pointer hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
510
+ onclick={previewPanelRef?.panelActions?.getIsMcpControlled() ? undefined : () => previewPanelRef?.panelActions?.toggleRotation()}
511
+ disabled={previewPanelRef?.panelActions?.getIsMcpControlled()}
512
+ title={previewPanelRef?.panelActions?.getIsMcpControlled() ? 'Controlled by MCP agent' : 'Toggle orientation'}
505
513
  >
506
514
  <Icon name="lucide:rotate-cw" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
507
515
  <span class="text-xs font-medium">
@@ -510,7 +518,7 @@
510
518
  </button>
511
519
 
512
520
  <!-- Scale info badge -->
513
- <div class="flex items-center gap-1.5 {isMobile ? 'px-2.5 h-9 bg-transparent' : 'px-2 h-6 bg-slate-100/60 dark:bg-slate-800/40'} rounded-md text-xs font-medium text-slate-500">
521
+ <div class="flex items-center gap-1.5 {isMobile ? 'px-1 h-9 bg-transparent' : 'px-1 h-6 bg-slate-100/60 dark:bg-slate-800/40'} rounded-md text-xs font-medium text-slate-500">
514
522
  <Icon name="lucide:move-diagonal" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
515
523
  <span>{Math.round((previewPanelRef?.panelActions?.getScale() || 1) * 100)}%</span>
516
524
  </div>
@@ -113,6 +113,7 @@
113
113
  getSessionInfo: () => browserPreviewRef?.browserActions?.getSessionInfo() || null,
114
114
  getIsStreamReady: () => browserPreviewRef?.browserActions?.getIsStreamReady() || false,
115
115
  getErrorMessage: () => browserPreviewRef?.browserActions?.getErrorMessage() || null,
116
+ getIsMcpControlled: () => browserPreviewRef?.browserActions?.getIsMcpControlled() || false,
116
117
  setDeviceSize: (size: DeviceSize) => {
117
118
  if (browserPreviewRef?.browserActions) {
118
119
  browserPreviewRef.browserActions.changeDeviceSize(size);
@@ -133,6 +133,9 @@ export class BrowserWebCodecsService {
133
133
  private isNavigating = false;
134
134
  private navigationCleanupFn: (() => void) | null = null;
135
135
 
136
+ // SPA navigation frame freeze — holds last frame briefly during SPA transitions
137
+ private spaFreezeUntil = 0;
138
+
136
139
  // WebSocket cleanup
137
140
  private wsCleanupFunctions: Array<() => void> = [];
138
141
 
@@ -237,16 +240,32 @@ export class BrowserWebCodecsService {
237
240
  sdp: response.offer.sdp
238
241
  });
239
242
  } else {
240
- debug.log('webcodecs', `[DIAG] No offer in stream-start response, fetching via stream-offer`);
241
- const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
242
- debug.log('webcodecs', `[DIAG] preview:browser-stream-offer response: hasOffer=${!!offerResponse.offer}`);
243
- if (offerResponse.offer) {
243
+ // Offer not ready yet peer may still be initializing. Retry with backoff.
244
+ debug.log('webcodecs', `[DIAG] No offer in stream-start response, retrying stream-offer with backoff`);
245
+
246
+ let offer: { type: string; sdp?: string } | undefined;
247
+ const offerMaxRetries = 5;
248
+ const offerRetryDelay = 200;
249
+
250
+ for (let attempt = 0; attempt < offerMaxRetries; attempt++) {
251
+ if (attempt > 0) {
252
+ await new Promise(resolve => setTimeout(resolve, offerRetryDelay * attempt));
253
+ }
254
+ debug.log('webcodecs', `[DIAG] stream-offer attempt ${attempt + 1}/${offerMaxRetries}`);
255
+ const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
256
+ if (offerResponse.offer) {
257
+ offer = offerResponse.offer;
258
+ break;
259
+ }
260
+ }
261
+
262
+ if (offer) {
244
263
  await this.handleOffer({
245
- type: offerResponse.offer.type as RTCSdpType,
246
- sdp: offerResponse.offer.sdp
264
+ type: offer.type as RTCSdpType,
265
+ sdp: offer.sdp
247
266
  });
248
267
  } else {
249
- throw new Error('No offer received from server');
268
+ throw new Error('No offer received from server after retries');
250
269
  }
251
270
  }
252
271
 
@@ -338,16 +357,22 @@ export class BrowserWebCodecsService {
338
357
  // Handle ICE candidates
339
358
  this.peerConnection.onicecandidate = (event) => {
340
359
  if (event.candidate && this.sessionId) {
360
+ const candidateInit: RTCIceCandidateInit = {
361
+ candidate: event.candidate.candidate,
362
+ sdpMid: event.candidate.sdpMid,
363
+ sdpMLineIndex: event.candidate.sdpMLineIndex
364
+ };
365
+
341
366
  // Backend uses active tab automatically
342
- ws.http('preview:browser-stream-ice', {
343
- candidate: {
344
- candidate: event.candidate.candidate,
345
- sdpMid: event.candidate.sdpMid,
346
- sdpMLineIndex: event.candidate.sdpMLineIndex
347
- }
348
- }).catch((error) => {
367
+ ws.http('preview:browser-stream-ice', { candidate: candidateInit }).catch((error) => {
349
368
  debug.warn('webcodecs', 'Failed to send ICE candidate:', error);
350
369
  });
370
+
371
+ // Also send loopback version for VPN compatibility (same-machine peers)
372
+ const loopback = this.createLoopbackCandidate(candidateInit);
373
+ if (loopback) {
374
+ ws.http('preview:browser-stream-ice', { candidate: loopback }).catch(() => {});
375
+ }
351
376
  }
352
377
  };
353
378
 
@@ -624,6 +649,17 @@ export class BrowserWebCodecsService {
624
649
  return;
625
650
  }
626
651
 
652
+ // During SPA navigation freeze, skip rendering to hold the last frame
653
+ // This prevents brief white flashes during SPA page transitions
654
+ if (this.spaFreezeUntil > 0 && Date.now() < this.spaFreezeUntil) {
655
+ frame.close();
656
+ return;
657
+ }
658
+ // Auto-reset freeze after it expires
659
+ if (this.spaFreezeUntil > 0) {
660
+ this.spaFreezeUntil = 0;
661
+ }
662
+
627
663
  try {
628
664
  // Update stats
629
665
  this.stats.videoFramesDecoded++;
@@ -849,9 +885,16 @@ export class BrowserWebCodecsService {
849
885
 
850
886
  const cleanupNavComplete = ws.on('preview:browser-navigation', (data) => {
851
887
  if (data.sessionId === this.sessionId) {
852
- // Keep isNavigating true for a short period to allow reconnection
853
- // Will be reset when new frames arrive or reconnection completes
854
888
  debug.log('webcodecs', `Navigation completed (direct WS) for session ${data.sessionId}`);
889
+
890
+ // If isNavigating was NOT set by navigation-loading (SPA-like case where
891
+ // framenavigated fires without a document request), set it now so the
892
+ // subsequent DataChannel close triggers fast reconnect instead of full recovery
893
+ if (!this.isNavigating) {
894
+ this.isNavigating = true;
895
+ debug.log('webcodecs', '✅ Set isNavigating=true on navigation complete (no loading event preceded)');
896
+ }
897
+
855
898
  // Signal reconnecting state IMMEDIATELY when navigation completes
856
899
  // This eliminates the gap between isNavigating=false and DataChannel close
857
900
  // ensuring the overlay stays visible continuously
@@ -862,11 +905,41 @@ export class BrowserWebCodecsService {
862
905
  }
863
906
  });
864
907
 
865
- this.wsCleanupFunctions = [cleanupIce, cleanupState, cleanupCursor, cleanupNavLoading, cleanupNavComplete];
908
+ // Listen for SPA navigation events (pushState/replaceState/hash changes)
909
+ // Reset isNavigating if it was set by a preceding navigation-loading event
910
+ // that the SPA router intercepted (cancelled the full navigation)
911
+ const cleanupNavSpa = ws.on('preview:browser-navigation-spa', (data) => {
912
+ if (data.sessionId === this.sessionId && this.isNavigating) {
913
+ debug.log('webcodecs', '🔄 SPA navigation received - resetting isNavigating (no stream restart needed)');
914
+ this.isNavigating = false;
915
+ }
916
+ });
917
+
918
+ this.wsCleanupFunctions = [cleanupIce, cleanupState, cleanupCursor, cleanupNavLoading, cleanupNavComplete, cleanupNavSpa];
866
919
  }
867
920
 
868
921
  /**
869
- * Add ICE candidate
922
+ * Create a loopback (127.0.0.1) copy of a host ICE candidate.
923
+ * Ensures WebRTC connects via loopback when VPN (e.g. Cloudflare WARP)
924
+ * interferes with host candidate connectivity between same-machine peers.
925
+ */
926
+ private createLoopbackCandidate(candidate: RTCIceCandidateInit): RTCIceCandidateInit | null {
927
+ if (!candidate.candidate) return null;
928
+ if (!candidate.candidate.includes('typ host')) return null;
929
+
930
+ const parts = candidate.candidate.split(' ');
931
+ if (parts.length < 8) return null;
932
+
933
+ // Index 4 is the address field in ICE candidate format
934
+ const address = parts[4];
935
+ if (address === '127.0.0.1' || address === '::1') return null;
936
+
937
+ parts[4] = '127.0.0.1';
938
+ return { ...candidate, candidate: parts.join(' ') };
939
+ }
940
+
941
+ /**
942
+ * Add ICE candidate (+ loopback variant for VPN compatibility)
870
943
  */
871
944
  private async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
872
945
  if (!this.peerConnection) return;
@@ -876,6 +949,16 @@ export class BrowserWebCodecsService {
876
949
  } catch (error) {
877
950
  debug.warn('webcodecs', 'Add ICE candidate error:', error);
878
951
  }
952
+
953
+ // Also try loopback version for VPN compatibility (same-machine peers)
954
+ const loopback = this.createLoopbackCandidate(candidate);
955
+ if (loopback) {
956
+ try {
957
+ await this.peerConnection.addIceCandidate(new RTCIceCandidate(loopback));
958
+ } catch {
959
+ // Expected to fail if loopback is not applicable
960
+ }
961
+ }
879
962
  }
880
963
 
881
964
  /**
@@ -1382,6 +1465,15 @@ export class BrowserWebCodecsService {
1382
1465
  this.onFirstFrame = handler;
1383
1466
  }
1384
1467
 
1468
+ /**
1469
+ * Freeze frame rendering briefly during SPA navigation.
1470
+ * Holds the current canvas content to prevent white flash during
1471
+ * SPA page transitions (pushState/replaceState).
1472
+ */
1473
+ freezeForSpaNavigation(durationMs = 150): void {
1474
+ this.spaFreezeUntil = Date.now() + durationMs;
1475
+ }
1476
+
1385
1477
  setErrorHandler(handler: (error: Error) => void): void {
1386
1478
  this.onError = handler;
1387
1479
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { getOrCreateAnonymousUser, type AnonymousUser } from '$shared/utils/anonymous-user';
10
- import ws from '$frontend/utils/ws';
10
+ import ws, { onWsReconnect } from '$frontend/utils/ws';
11
11
  import { debug } from '$shared/utils/logger';
12
12
 
13
13
  export interface ProjectStatus {
@@ -44,6 +44,16 @@ class ProjectStatusService {
44
44
  this.currentUser = await getOrCreateAnonymousUser();
45
45
  debug.log('project', 'Initialized with user:', this.currentUser?.name);
46
46
 
47
+ // Re-join project presence after WebSocket reconnection.
48
+ // Without this, the new connection loses presence tracking and
49
+ // panels (Git, Terminal, Preview, etc.) miss status updates.
50
+ onWsReconnect(() => {
51
+ if (this.currentProjectId && this.currentUser) {
52
+ ws.emit('projects:join', { userName: this.currentUser.name });
53
+ debug.log('project', 'Re-joined project presence after reconnection');
54
+ }
55
+ });
56
+
47
57
  this.unsubscribe = ws.on('projects:presence-updated', (data) => {
48
58
  try {
49
59
  if (data.type === 'presence-updated' && data.data) {
@@ -10,7 +10,7 @@
10
10
  import type { ChatSession, SDKMessageFormatter } from '$shared/types/database/schema';
11
11
  import type { SDKMessage } from '$shared/types/messaging';
12
12
  import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
- import ws from '$frontend/utils/ws';
13
+ import ws, { onWsReconnect } from '$frontend/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/stores/ui/edit-mode.svelte';
16
16
  import { markSessionUnread, markSessionRead, appState } from '$frontend/stores/core/app.svelte';
@@ -376,6 +376,16 @@ export async function reloadSessionsForProject(): Promise<string | null> {
376
376
  * automatically switch to the new shared session.
377
377
  */
378
378
  function setupCollaborativeListeners() {
379
+ // Re-join chat session room after WebSocket reconnection.
380
+ // Without this, the new connection is not in the session room and
381
+ // misses all chat events (stream, partial, complete, input sync, etc.).
382
+ onWsReconnect(() => {
383
+ if (sessionState.currentSession?.id) {
384
+ ws.emit('chat:join-session', { chatSessionId: sessionState.currentSession.id });
385
+ debug.log('session', 'Re-joined chat session room after reconnection:', sessionState.currentSession.id);
386
+ }
387
+ });
388
+
379
389
  // Listen for new session available notifications from other users.
380
390
  // Does NOT auto-switch — adds session to list and shows notification.
381
391
  ws.on('sessions:session-available', async (data: { session: ChatSession }) => {
@@ -17,6 +17,7 @@ interface TerminalState {
17
17
  executingSessionIds: Set<string>; // Track multiple executing sessions
18
18
  sessionExecutionStates: Map<string, boolean>; // Track execution state per session
19
19
  lineBuffers: Map<string, string>; // Line buffering for chunked PTY output
20
+ flushTimers: Map<string, ReturnType<typeof setTimeout>>; // Auto-flush timers for buffered output
20
21
  }
21
22
 
22
23
  // Terminal store state
@@ -28,7 +29,8 @@ const terminalState = $state<TerminalState>({
28
29
  lastCommandWasCancelled: false,
29
30
  executingSessionIds: new Set(),
30
31
  sessionExecutionStates: new Map(),
31
- lineBuffers: new Map()
32
+ lineBuffers: new Map(),
33
+ flushTimers: new Map()
32
34
  });
33
35
 
34
36
  // Computed properties
@@ -214,8 +216,13 @@ export const terminalStore = {
214
216
  debug.error('terminal', `🔴 [closeSession] Failed to remove from project context:`, error);
215
217
  }
216
218
 
217
- // Clear any buffered content for this session
219
+ // Clear any buffered content and flush timers for this session
218
220
  terminalState.lineBuffers.delete(sessionId);
221
+ const closeFlushTimer = terminalState.flushTimers.get(sessionId);
222
+ if (closeFlushTimer) {
223
+ clearTimeout(closeFlushTimer);
224
+ terminalState.flushTimers.delete(sessionId);
225
+ }
219
226
 
220
227
  // Remove execution states for this session
221
228
  terminalState.sessionExecutionStates.delete(sessionId);
@@ -285,6 +292,13 @@ export const terminalStore = {
285
292
 
286
293
  // Flush any buffered content before cancel (if was executing)
287
294
  if (wasExecuting) {
295
+ // Clear flush timer first
296
+ const cancelFlushTimer = terminalState.flushTimers.get(activeSession.id);
297
+ if (cancelFlushTimer) {
298
+ clearTimeout(cancelFlushTimer);
299
+ terminalState.flushTimers.delete(activeSession.id);
300
+ }
301
+
288
302
  const remainingBuffer = terminalState.lineBuffers.get(activeSession.id);
289
303
  if (remainingBuffer && remainingBuffer.length > 0) {
290
304
  this.addLineToSession(activeSession.id, {
@@ -353,31 +367,41 @@ export const terminalStore = {
353
367
 
354
368
  // Process buffered output to handle chunked PTY data properly
355
369
  processBufferedOutput(sessionId: string, content: string, type: 'output' | 'error'): void {
356
- // Buffer incomplete lines to avoid splitting words like "Reply" into "R" and "eply"
370
+ // Clear any pending flush timer for this session
371
+ const existingTimer = terminalState.flushTimers.get(sessionId);
372
+ if (existingTimer) {
373
+ clearTimeout(existingTimer);
374
+ terminalState.flushTimers.delete(sessionId);
375
+ }
376
+
357
377
  let buffer = terminalState.lineBuffers.get(sessionId) || '';
358
378
  buffer += content;
359
-
360
- // Only send complete chunks to avoid word splitting
361
- // If buffer ends with a partial ANSI sequence or in middle of a word, wait for more
362
- if (buffer.length < 2) {
363
- // Very short buffer, likely incomplete - wait for more
364
- terminalState.lineBuffers.set(sessionId, buffer);
365
- return;
366
- }
367
-
379
+
368
380
  // Check if we're in the middle of an ANSI escape sequence
369
381
  const lastEscIndex = buffer.lastIndexOf('\x1b');
370
382
  if (lastEscIndex >= 0 && lastEscIndex > buffer.length - 10) {
371
- // Might be in middle of escape sequence, check if it's complete
372
383
  const remaining = buffer.substring(lastEscIndex);
373
384
  if (!/^(\x1b\[[0-9;]*[a-zA-Z]|\x1b\[\?[0-9]+[lh])/.test(remaining)) {
374
- // Incomplete escape sequence, wait for more
385
+ // Incomplete escape sequence - hold briefly, auto-flush after 8ms
375
386
  terminalState.lineBuffers.set(sessionId, buffer);
387
+ const flushTimer = setTimeout(() => {
388
+ terminalState.flushTimers.delete(sessionId);
389
+ const pending = terminalState.lineBuffers.get(sessionId);
390
+ if (pending && pending.length > 0) {
391
+ this.addLineToSession(sessionId, {
392
+ content: pending,
393
+ type: type,
394
+ timestamp: new Date()
395
+ });
396
+ terminalState.lineBuffers.set(sessionId, '');
397
+ }
398
+ }, 8);
399
+ terminalState.flushTimers.set(sessionId, flushTimer);
376
400
  return;
377
401
  }
378
402
  }
379
-
380
- // Send the buffer and clear it
403
+
404
+ // Flush immediately - no artificial delay for complete data
381
405
  if (buffer.length > 0) {
382
406
  this.addLineToSession(sessionId, {
383
407
  content: buffer,
@@ -390,15 +414,11 @@ export const terminalStore = {
390
414
 
391
415
  // Session Content Management
392
416
  addLineToSession(sessionId: string, line: TerminalLine): void {
393
- terminalState.sessions = terminalState.sessions.map(session =>
394
- session.id === sessionId
395
- ? {
396
- ...session,
397
- lines: [...session.lines, line],
398
- lastUsedAt: new Date()
399
- }
400
- : session
401
- );
417
+ const session = terminalState.sessions.find(s => s.id === sessionId);
418
+ if (session) {
419
+ session.lines.push(line);
420
+ session.lastUsedAt = new Date();
421
+ }
402
422
  },
403
423
 
404
424
  updateSessionHistory(sessionId: string, history: string[]): void {
@@ -411,8 +431,13 @@ export const terminalStore = {
411
431
 
412
432
 
413
433
  clearSession(sessionId: string): void {
414
- // Clear any buffered content for this session
434
+ // Clear any buffered content and flush timers for this session
415
435
  terminalState.lineBuffers.delete(sessionId);
436
+ const clearFlushTimer = terminalState.flushTimers.get(sessionId);
437
+ if (clearFlushTimer) {
438
+ clearTimeout(clearFlushTimer);
439
+ terminalState.flushTimers.delete(sessionId);
440
+ }
416
441
 
417
442
  // CRITICAL FIX: Actually clear the session lines history
418
443
  // This ensures when switching tabs, the cleared terminal stays clear
@@ -600,6 +625,11 @@ export const terminalStore = {
600
625
  */
601
626
  removeSessionFromStore(sessionId: string): void {
602
627
  terminalState.lineBuffers.delete(sessionId);
628
+ const removeFlushTimer = terminalState.flushTimers.get(sessionId);
629
+ if (removeFlushTimer) {
630
+ clearTimeout(removeFlushTimer);
631
+ terminalState.flushTimers.delete(sessionId);
632
+ }
603
633
  terminalState.sessionExecutionStates.delete(sessionId);
604
634
  terminalState.executingSessionIds.delete(sessionId);
605
635
  terminalState.sessions = terminalState.sessions.filter(s => s.id !== sessionId);
@@ -96,7 +96,7 @@ function updateThemeColor(mode: 'light' | 'dark') {
96
96
  }
97
97
 
98
98
  // Set appropriate theme color
99
- const themeColor = mode === 'dark' ? '#0a0a0a' : '#ffffff';
99
+ const themeColor = mode === 'dark' ? '#0e172b' : '#ffffff';
100
100
  metaThemeColor.setAttribute('content', themeColor);
101
101
  }
102
102
 
@@ -7,6 +7,7 @@
7
7
  import { WSClient } from '$shared/utils/ws-client';
8
8
  import type { WSAPI } from '$backend/ws';
9
9
  import { setConnectionStatus } from '$frontend/stores/ui/connection.svelte';
10
+ import { debug } from '$shared/utils/logger';
10
11
 
11
12
  /**
12
13
  * Get WebSocket URL based on environment
@@ -18,6 +19,28 @@ function getWebSocketUrl(): string {
18
19
  return `${protocol}//${host}/ws`;
19
20
  }
20
21
 
22
+ // ============================================================================
23
+ // Reconnect Handler Registry
24
+ // ============================================================================
25
+
26
+ /** Handlers to run after WebSocket reconnection (re-join rooms, restore subscriptions) */
27
+ const reconnectHandlers = new Set<() => void>();
28
+
29
+ /**
30
+ * Register a handler to run after WebSocket reconnection.
31
+ * Use this to re-join rooms (chat:join-session, projects:join) and
32
+ * restore subscriptions that are lost when the connection drops.
33
+ * Returns an unsubscribe function.
34
+ */
35
+ export function onWsReconnect(handler: () => void): () => void {
36
+ reconnectHandlers.add(handler);
37
+ return () => { reconnectHandlers.delete(handler); };
38
+ }
39
+
40
+ // ============================================================================
41
+ // WebSocket Client
42
+ // ============================================================================
43
+
21
44
  const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
22
45
  autoReconnect: true,
23
46
  maxReconnectAttempts: 0, // Infinite reconnect
@@ -25,6 +48,16 @@ const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
25
48
  maxReconnectDelay: 30000,
26
49
  onStatusChange: (status, reconnectAttempts) => {
27
50
  setConnectionStatus(status, reconnectAttempts);
51
+ },
52
+ onReconnect: () => {
53
+ debug.log('websocket', `Running ${reconnectHandlers.size} reconnect handler(s)`);
54
+ for (const handler of reconnectHandlers) {
55
+ try {
56
+ handler();
57
+ } catch (err) {
58
+ debug.error('websocket', 'Reconnect handler error:', err);
59
+ }
60
+ }
28
61
  }
29
62
  });
30
63
 
@@ -45,4 +78,13 @@ window.addEventListener('beforeunload', () => {
45
78
  ws.disconnect();
46
79
  });
47
80
 
81
+ // Force reload when page is restored from bfcache (back-forward cache).
82
+ // After beforeunload, all WS listeners are cleared and the connection is dead.
83
+ // A full reload ensures all state (handlers, room subscriptions) is re-initialized.
84
+ window.addEventListener('pageshow', (event) => {
85
+ if (event.persisted) {
86
+ window.location.reload();
87
+ }
88
+ });
89
+
48
90
  export default ws;
package/index.html CHANGED
@@ -9,7 +9,7 @@
9
9
  name="description"
10
10
  content="Clopen - Modern web UI for Claude Code & OpenCode with real browser preview, git management, multi-account support, file management, checkpoints, collaboration, and integrated terminal. Built with Bun and Svelte 5."
11
11
  />
12
- <meta name="theme-color" content="#0a0a0a" />
12
+ <meta name="theme-color" content="#0e172b" />
13
13
  <title>Clopen</title>
14
14
 
15
15
  <!-- DM Sans - Local self-hosted font -->
@@ -47,7 +47,7 @@
47
47
  // Update meta theme-color for mobile browsers
48
48
  const metaThemeColor = document.querySelector('meta[name="theme-color"]');
49
49
  if (metaThemeColor) {
50
- metaThemeColor.setAttribute('content', isDark ? '#0a0a0a' : '#ffffff');
50
+ metaThemeColor.setAttribute('content', isDark ? '#0e172b' : '#ffffff');
51
51
  }
52
52
  } catch (e) {
53
53
  // Fallback to system preference if anything fails
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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",