@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.
- package/backend/index.ts +12 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +188 -31
- package/backend/preview/browser/browser-pool.ts +1 -1
- package/backend/preview/browser/browser-preview-service.ts +23 -0
- package/backend/preview/browser/browser-tab-manager.ts +16 -1
- package/backend/preview/browser/browser-video-capture.ts +2 -2
- package/backend/preview/browser/scripts/video-stream.ts +39 -4
- package/backend/terminal/stream-manager.ts +40 -26
- package/backend/ws/preview/browser/webcodecs.ts +11 -0
- package/backend/ws/preview/index.ts +8 -0
- package/backend/ws/system/operations.ts +23 -0
- package/frontend/components/chat/input/ChatInput.svelte +3 -3
- package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
- package/frontend/components/common/overlay/Dialog.svelte +1 -1
- package/frontend/components/common/overlay/Lightbox.svelte +2 -2
- package/frontend/components/common/overlay/Modal.svelte +2 -2
- package/frontend/components/common/xterm/XTerm.svelte +6 -1
- package/frontend/components/git/ConflictResolver.svelte +1 -1
- package/frontend/components/git/GitModal.svelte +2 -2
- package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
- package/frontend/components/preview/browser/components/Canvas.svelte +40 -23
- package/frontend/components/preview/browser/components/Container.svelte +8 -5
- package/frontend/components/preview/browser/components/Toolbar.svelte +16 -1
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -0
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +37 -4
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
- package/frontend/components/terminal/Terminal.svelte +1 -29
- package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
- package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
- package/frontend/components/workspace/PanelHeader.svelte +30 -22
- package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +110 -18
- package/frontend/services/project/status.service.ts +11 -1
- package/frontend/stores/core/sessions.svelte.ts +11 -1
- package/frontend/stores/features/terminal.svelte.ts +56 -26
- package/frontend/stores/ui/theme.svelte.ts +1 -1
- package/frontend/utils/ws.ts +42 -0
- package/index.html +2 -2
- package/package.json +1 -1
- package/shared/utils/ws-client.ts +21 -4
- 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
|
|
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-
|
|
390
|
-
onclick={toggleDeviceDropdown}
|
|
391
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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-
|
|
503
|
-
onclick={() => previewPanelRef?.panelActions?.toggleRotation()}
|
|
504
|
-
|
|
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-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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:
|
|
246
|
-
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
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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' ? '#
|
|
99
|
+
const themeColor = mode === 'dark' ? '#0e172b' : '#ffffff';
|
|
100
100
|
metaThemeColor.setAttribute('content', themeColor);
|
|
101
101
|
}
|
|
102
102
|
|
package/frontend/utils/ws.ts
CHANGED
|
@@ -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="#
|
|
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 ? '#
|
|
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.
|
|
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",
|