@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
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const parsedCommand = $derived(parseCommandParts(command));
|
|
19
|
+
|
|
20
|
+
function formatTimeout(ms: number): string {
|
|
21
|
+
if (ms < 1000) return `${ms}ms`;
|
|
22
|
+
if (ms < 60_000) return `${ms / 1000}s`;
|
|
23
|
+
if (ms < 3_600_000) return `${ms / 60_000}m`;
|
|
24
|
+
return `${ms / 3_600_000}h`;
|
|
25
|
+
}
|
|
19
26
|
</script>
|
|
20
27
|
|
|
21
28
|
<!-- Description (if provided) -->
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
</div>
|
|
35
42
|
{#if timeout}
|
|
36
43
|
<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
|
-
Timeout: {timeout}
|
|
44
|
+
Timeout: {formatTimeout(timeout)}
|
|
38
45
|
</div>
|
|
39
46
|
{/if}
|
|
40
47
|
</div>
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
out:fade={{ duration: 150, easing: cubicOut }}
|
|
185
185
|
>
|
|
186
186
|
<div
|
|
187
|
-
class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl max-w-md w-full p-6 space-y-4 shadow-xl"
|
|
187
|
+
class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl max-w-md w-full p-6 space-y-4 shadow-xl max-h-[calc(100dvh-2rem)] overflow-y-auto"
|
|
188
188
|
role="document"
|
|
189
189
|
onclick={(e) => e.stopPropagation()}
|
|
190
190
|
onkeydown={(e) => e.stopPropagation()}
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
|
|
172
172
|
<!-- Content container -->
|
|
173
173
|
<div
|
|
174
|
-
class="relative max-w-[95vw] max-h-[
|
|
174
|
+
class="relative max-w-[95vw] max-h-[95dvh] flex items-center justify-center"
|
|
175
175
|
onclick={(e) => e.stopPropagation()}
|
|
176
176
|
onkeydown={(e) => e.stopPropagation()}
|
|
177
177
|
role="document"
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
<img
|
|
185
185
|
src="data:{mediaType};base64,{data}"
|
|
186
186
|
alt="Full size view"
|
|
187
|
-
class="max-w-full max-h-[
|
|
187
|
+
class="max-w-full max-h-[90dvh] object-contain rounded-lg shadow-2xl"
|
|
188
188
|
loading="eager"
|
|
189
189
|
/>
|
|
190
190
|
{:else if type === 'document'}
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
md: 'max-w-[95vw] md:max-w-lg',
|
|
52
52
|
lg: 'max-w-[95vw] md:max-w-2xl',
|
|
53
53
|
xl: 'max-w-[95vw] md:max-w-4xl',
|
|
54
|
-
full: 'max-w-[95vw] md:max-w-[90vw]
|
|
54
|
+
full: 'max-w-[95vw] md:max-w-[90vw]'
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
// Auto-focus management
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
<div
|
|
107
107
|
class="bg-white dark:bg-slate-900 rounded-lg md:rounded-xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full {sizeClasses[
|
|
108
108
|
size
|
|
109
|
-
]} max-h-[
|
|
109
|
+
]} max-h-[calc(100dvh-1rem)] md:max-h-[calc(100dvh-2rem)] overflow-hidden flex flex-col {className}"
|
|
110
110
|
role="document"
|
|
111
111
|
onclick={(e) => e.stopPropagation()}
|
|
112
112
|
onkeydown={(e) => e.stopPropagation()}
|
|
@@ -635,7 +635,7 @@
|
|
|
635
635
|
<!-- Pure xterm.js terminal container -->
|
|
636
636
|
<div
|
|
637
637
|
bind:this={terminalContainer}
|
|
638
|
-
class="w-full h-full overflow-hidden bg-
|
|
638
|
+
class="w-full h-full overflow-hidden bg-white dark:bg-slate-900/70 {className} select-none"
|
|
639
639
|
style="transition: opacity 0.2s ease-in-out; user-select: text;"
|
|
640
640
|
role="textbox"
|
|
641
641
|
tabindex="0"
|
|
@@ -677,6 +677,11 @@
|
|
|
677
677
|
height: 100% !important;
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
+
:global(.xterm .xterm-scrollable-element) {
|
|
681
|
+
background: transparent !important;
|
|
682
|
+
height: 100% !important;
|
|
683
|
+
}
|
|
684
|
+
|
|
680
685
|
:global(.xterm .xterm-helper-textarea) {
|
|
681
686
|
height: 100% !important;
|
|
682
687
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
{#if isOpen}
|
|
27
27
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onclick={onClose}>
|
|
28
28
|
<div
|
|
29
|
-
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[
|
|
29
|
+
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 my-4 max-h-[calc(100dvh-2rem)] flex flex-col"
|
|
30
30
|
onclick={(e) => e.stopPropagation()}
|
|
31
31
|
>
|
|
32
32
|
<!-- Header -->
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
let gitPanelRef: any = $state();
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
|
-
<Modal {isOpen} {onClose} size="full" className="!max-h-[
|
|
16
|
+
<Modal {isOpen} {onClose} size="full" className="!max-h-[85dvh] !max-w-[95vw] md:!max-w-5xl">
|
|
17
17
|
{#snippet header()}
|
|
18
18
|
<div class="flex items-center justify-between px-4 py-3 md:px-6 md:py-4">
|
|
19
19
|
<div class="flex items-center gap-2.5">
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
{/snippet}
|
|
74
74
|
|
|
75
75
|
{#snippet children()}
|
|
76
|
-
<div class="h-[
|
|
76
|
+
<div class="h-[65dvh] -mx-4 -my-6 md:-mx-6">
|
|
77
77
|
<GitPanel bind:this={gitPanelRef} />
|
|
78
78
|
</div>
|
|
79
79
|
{/snippet}
|
|
@@ -223,6 +223,12 @@
|
|
|
223
223
|
let previousUrl = '';
|
|
224
224
|
$effect(() => {
|
|
225
225
|
if (!url || url === previousUrl) return;
|
|
226
|
+
// Ignore browser-internal error pages (e.g. DNS failure) — they are not real URLs
|
|
227
|
+
// and should never trigger a new navigation attempt.
|
|
228
|
+
if (url.startsWith('chrome-error://') || url.startsWith('chrome://')) {
|
|
229
|
+
previousUrl = url;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
226
232
|
if (mcpLaunchInProgress) {
|
|
227
233
|
previousUrl = url;
|
|
228
234
|
urlInput = url;
|
|
@@ -306,7 +312,7 @@
|
|
|
306
312
|
|
|
307
313
|
// Initialize URL input
|
|
308
314
|
$effect(() => {
|
|
309
|
-
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
|
315
|
+
if (url && !url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('file://')) {
|
|
310
316
|
url = 'http://' + url;
|
|
311
317
|
}
|
|
312
318
|
if (url && !urlInput) {
|
|
@@ -319,7 +325,7 @@
|
|
|
319
325
|
if (!urlInput.trim()) return;
|
|
320
326
|
|
|
321
327
|
let processedUrl = urlInput.trim();
|
|
322
|
-
if (!processedUrl.startsWith('http://') && !processedUrl.startsWith('https://')) {
|
|
328
|
+
if (!processedUrl.startsWith('http://') && !processedUrl.startsWith('https://') && !processedUrl.startsWith('file://')) {
|
|
323
329
|
processedUrl = 'http://' + processedUrl;
|
|
324
330
|
}
|
|
325
331
|
|
|
@@ -447,7 +453,8 @@
|
|
|
447
453
|
},
|
|
448
454
|
getSessionInfo: () => sessionInfo,
|
|
449
455
|
getIsStreamReady: () => isStreamReady,
|
|
450
|
-
getErrorMessage: () => errorMessage
|
|
456
|
+
getErrorMessage: () => errorMessage,
|
|
457
|
+
getIsMcpControlled: () => isCurrentTabMcpControlled()
|
|
451
458
|
};
|
|
452
459
|
</script>
|
|
453
460
|
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
onStatsUpdate = $bindable<(stats: BrowserWebCodecsStreamStats | null) => void>(() => {}),
|
|
27
27
|
onRequestScreencastRefresh = $bindable<() => void>(() => {}), // Called when stream is stuck
|
|
28
28
|
touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
|
|
29
|
+
touchTarget = undefined as HTMLElement | undefined, // Container element for touch events
|
|
29
30
|
onTouchCursorUpdate = $bindable<(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) => void>(() => {})
|
|
30
31
|
} = $props();
|
|
31
32
|
|
|
@@ -566,12 +567,16 @@
|
|
|
566
567
|
debug.log('webcodecs', 'Streaming started successfully');
|
|
567
568
|
} else {
|
|
568
569
|
// Service handles errors internally and returns false.
|
|
569
|
-
//
|
|
570
|
-
|
|
570
|
+
// Retry after a delay — the peer/offer may need more time to initialize.
|
|
571
|
+
retries++;
|
|
572
|
+
if (retries < maxRetries) {
|
|
573
|
+
debug.warn('webcodecs', `Streaming start returned false, retrying in ${retryDelay * retries}ms (${retries}/${maxRetries})`);
|
|
574
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay * retries));
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
debug.error('webcodecs', 'Streaming start failed after all retries');
|
|
578
|
+
break;
|
|
571
579
|
}
|
|
572
|
-
// Always break after the service returns (success or failure).
|
|
573
|
-
// The service catches all exceptions internally, so the catch block
|
|
574
|
-
// below never runs, making retries/retryDelay dead code anyway.
|
|
575
580
|
break;
|
|
576
581
|
} catch (error: any) {
|
|
577
582
|
// This block only runs if the service unexpectedly throws.
|
|
@@ -1002,20 +1007,6 @@
|
|
|
1002
1007
|
canvas.focus();
|
|
1003
1008
|
});
|
|
1004
1009
|
|
|
1005
|
-
const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
|
|
1006
|
-
let lastTouchMoveTime = 0;
|
|
1007
|
-
const touchMoveHandler = (e: TouchEvent) => {
|
|
1008
|
-
const now = Date.now();
|
|
1009
|
-
if (now - lastTouchMoveTime >= 16) {
|
|
1010
|
-
lastTouchMoveTime = now;
|
|
1011
|
-
handleTouchMove(e, canvas);
|
|
1012
|
-
}
|
|
1013
|
-
};
|
|
1014
|
-
const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
|
|
1015
|
-
|
|
1016
|
-
canvas.addEventListener('touchstart', touchStartHandler, { passive: false });
|
|
1017
|
-
canvas.addEventListener('touchmove', touchMoveHandler, { passive: false });
|
|
1018
|
-
canvas.addEventListener('touchend', touchEndHandler, { passive: false });
|
|
1019
1010
|
|
|
1020
1011
|
const handleMouseLeave = () => {
|
|
1021
1012
|
if (isMouseDown) {
|
|
@@ -1045,13 +1036,38 @@
|
|
|
1045
1036
|
canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
|
|
1046
1037
|
canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
|
|
1047
1038
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
1048
|
-
canvas.removeEventListener('touchstart', touchStartHandler);
|
|
1049
|
-
canvas.removeEventListener('touchmove', touchMoveHandler);
|
|
1050
|
-
canvas.removeEventListener('touchend', touchEndHandler);
|
|
1051
1039
|
};
|
|
1052
1040
|
}
|
|
1053
1041
|
});
|
|
1054
1042
|
|
|
1043
|
+
// Attach touch events to touchTarget (Container's previewContainer) instead of canvas
|
|
1044
|
+
$effect(() => {
|
|
1045
|
+
if (!touchTarget || !canvasElement) return;
|
|
1046
|
+
|
|
1047
|
+
const canvas = canvasElement;
|
|
1048
|
+
let lastTouchMoveTime = 0;
|
|
1049
|
+
|
|
1050
|
+
const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
|
|
1051
|
+
const touchMoveHandler = (e: TouchEvent) => {
|
|
1052
|
+
const now = Date.now();
|
|
1053
|
+
if (now - lastTouchMoveTime >= 16) {
|
|
1054
|
+
lastTouchMoveTime = now;
|
|
1055
|
+
handleTouchMove(e, canvas);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
|
|
1059
|
+
|
|
1060
|
+
touchTarget.addEventListener('touchstart', touchStartHandler, { passive: false });
|
|
1061
|
+
touchTarget.addEventListener('touchmove', touchMoveHandler, { passive: false });
|
|
1062
|
+
touchTarget.addEventListener('touchend', touchEndHandler, { passive: false });
|
|
1063
|
+
|
|
1064
|
+
return () => {
|
|
1065
|
+
touchTarget.removeEventListener('touchstart', touchStartHandler);
|
|
1066
|
+
touchTarget.removeEventListener('touchmove', touchMoveHandler);
|
|
1067
|
+
touchTarget.removeEventListener('touchend', touchEndHandler);
|
|
1068
|
+
};
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1055
1071
|
// Convert canvas coordinates to viewport (screen) coordinates for VirtualCursor display
|
|
1056
1072
|
function canvasToScreen(cx: number, cy: number): { x: number; y: number } {
|
|
1057
1073
|
if (!canvasElement) return { x: 0, y: 0 };
|
|
@@ -1391,7 +1407,8 @@
|
|
|
1391
1407
|
getStats: () => webCodecsService?.getStats() ?? null,
|
|
1392
1408
|
getLatency: () => latencyMs,
|
|
1393
1409
|
// Navigation handling
|
|
1394
|
-
notifyNavigationComplete
|
|
1410
|
+
notifyNavigationComplete,
|
|
1411
|
+
freezeForSpaNavigation: () => webCodecsService?.freezeForSpaNavigation()
|
|
1395
1412
|
};
|
|
1396
1413
|
});
|
|
1397
1414
|
|
|
@@ -70,10 +70,11 @@
|
|
|
70
70
|
let showNavigationOverlay = $state(false);
|
|
71
71
|
let overlayHideTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
72
72
|
|
|
73
|
-
// Debounced navigation overlay -
|
|
74
|
-
//
|
|
73
|
+
// Debounced navigation overlay - only for user-initiated toolbar navigations
|
|
74
|
+
// In-browser navigations (link clicks) only show progress bar, not this overlay
|
|
75
|
+
// This makes the preview behave like a real browser
|
|
75
76
|
$effect(() => {
|
|
76
|
-
const shouldShowOverlay =
|
|
77
|
+
const shouldShowOverlay = isNavigating && isStreamReady;
|
|
77
78
|
|
|
78
79
|
// Cancel any pending hide when overlay should show
|
|
79
80
|
if (shouldShowOverlay && overlayHideTimeout) {
|
|
@@ -385,6 +386,7 @@
|
|
|
385
386
|
bind:isNavigating
|
|
386
387
|
bind:isReconnecting
|
|
387
388
|
bind:touchMode
|
|
389
|
+
touchTarget={previewContainer}
|
|
388
390
|
onInteraction={handleCanvasInteraction}
|
|
389
391
|
onCursorUpdate={handleCursorUpdate}
|
|
390
392
|
onFrameUpdate={handleFrameUpdate}
|
|
@@ -408,7 +410,8 @@
|
|
|
408
410
|
</div>
|
|
409
411
|
{/if}
|
|
410
412
|
|
|
411
|
-
<!-- Navigation Overlay:
|
|
413
|
+
<!-- Navigation Overlay: Only for user-initiated toolbar navigations (Go button/Enter) -->
|
|
414
|
+
<!-- In-browser link clicks only show the progress bar, not this overlay -->
|
|
412
415
|
{#if showNavigationOverlay}
|
|
413
416
|
<div
|
|
414
417
|
class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] flex items-center justify-center z-10"
|
|
@@ -416,7 +419,7 @@
|
|
|
416
419
|
<div class="flex flex-col items-center gap-2">
|
|
417
420
|
<Icon name="lucide:loader-circle" class="w-8 h-8 animate-spin text-violet-600" />
|
|
418
421
|
<div class="text-slate-600 dark:text-slate-300 text-center">
|
|
419
|
-
<div class="text-sm font-medium">
|
|
422
|
+
<div class="text-sm font-medium">Navigating...</div>
|
|
420
423
|
</div>
|
|
421
424
|
</div>
|
|
422
425
|
</div>
|
|
@@ -163,6 +163,21 @@
|
|
|
163
163
|
progressPercent = 0;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// Reset progress bar immediately when active tab changes
|
|
167
|
+
// This prevents stale progress from a previous tab leaking into the new tab
|
|
168
|
+
let previousActiveTabId = $state<string | null>(null);
|
|
169
|
+
$effect(() => {
|
|
170
|
+
if (activeTabId !== previousActiveTabId) {
|
|
171
|
+
previousActiveTabId = activeTabId;
|
|
172
|
+
// Immediately stop any running progress animation and clear pending timeouts
|
|
173
|
+
stopProgress();
|
|
174
|
+
if (progressCompleteTimeout) {
|
|
175
|
+
clearTimeout(progressCompleteTimeout);
|
|
176
|
+
progressCompleteTimeout = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
166
181
|
// Watch loading states to control progress bar
|
|
167
182
|
// Progress bar should be active during:
|
|
168
183
|
// 1. isLaunchingBrowser: API call to launch browser
|
|
@@ -214,7 +229,7 @@
|
|
|
214
229
|
</script>
|
|
215
230
|
|
|
216
231
|
<!-- Preview Toolbar -->
|
|
217
|
-
<div class="relative bg-
|
|
232
|
+
<div class="relative bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
|
218
233
|
<!-- Tabs bar (Git-style underline tabs) — separated with its own border-bottom -->
|
|
219
234
|
{#if tabs.length > 0}
|
|
220
235
|
<div class="relative flex items-center overflow-x-auto border-b border-slate-200 dark:border-slate-700">
|
|
@@ -771,6 +771,19 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
771
771
|
debug.warn('preview', `Tab not found for sessionId: ${data.sessionId}`);
|
|
772
772
|
}
|
|
773
773
|
});
|
|
774
|
+
|
|
775
|
+
// Listen for SPA navigation events (pushState/replaceState)
|
|
776
|
+
ws.on('preview:browser-navigation-spa', (data: { sessionId: string; type: string; url: string; timestamp: number }) => {
|
|
777
|
+
debug.log('preview', `🔄 SPA navigation event received: ${data.sessionId} → ${data.url}`);
|
|
778
|
+
|
|
779
|
+
const tab = tabManager.tabs.find(t => t.sessionId === data.sessionId);
|
|
780
|
+
if (tab) {
|
|
781
|
+
streamHandler.handleStreamMessage({
|
|
782
|
+
type: 'navigation-spa',
|
|
783
|
+
data: { url: data.url }
|
|
784
|
+
}, tab.id);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
774
787
|
});
|
|
775
788
|
}
|
|
776
789
|
|
|
@@ -73,6 +73,10 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
73
73
|
handleNavigation(targetTabId, message.data, tab);
|
|
74
74
|
break;
|
|
75
75
|
|
|
76
|
+
case 'navigation-spa':
|
|
77
|
+
handleNavigationSpa(targetTabId, message.data, tab);
|
|
78
|
+
break;
|
|
79
|
+
|
|
76
80
|
case 'new-window':
|
|
77
81
|
handleNewWindow(message.data);
|
|
78
82
|
break;
|
|
@@ -172,13 +176,14 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
172
176
|
function handleNavigationLoading(tabId: string, data: any) {
|
|
173
177
|
if (data && data.url) {
|
|
174
178
|
const tab = tabManager.getTab(tabId);
|
|
175
|
-
// isNavigating: true if session already exists (navigating within same session)
|
|
176
|
-
// isNavigating: false if no session yet (initial load)
|
|
177
|
-
const isNavigating = tab?.sessionId ? true : false;
|
|
178
179
|
|
|
180
|
+
// Only set isLoading (progress bar) for in-browser navigations.
|
|
181
|
+
// Do NOT set isNavigating here — that flag is reserved for user-initiated
|
|
182
|
+
// toolbar navigations (Go button/Enter), which is set in navigateBrowserForTab().
|
|
183
|
+
// This prevents the "Loading preview..." overlay from showing on link clicks
|
|
184
|
+
// within the browser, making it behave like a real browser.
|
|
179
185
|
tabManager.updateTab(tabId, {
|
|
180
186
|
isLoading: true,
|
|
181
|
-
isNavigating,
|
|
182
187
|
url: data.url,
|
|
183
188
|
title: getTabTitle(data.url)
|
|
184
189
|
});
|
|
@@ -216,6 +221,34 @@ export function createStreamMessageHandler(config: StreamMessageHandlerConfig) {
|
|
|
216
221
|
}
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
function handleNavigationSpa(tabId: string, data: any, tab: PreviewTab) {
|
|
225
|
+
if (data && data.url && data.url !== tab.url) {
|
|
226
|
+
debug.log('preview', `🔄 SPA navigation for tab ${tabId}: ${tab.url} → ${data.url}`);
|
|
227
|
+
|
|
228
|
+
// Freeze canvas briefly to avoid showing white flash during SPA transition
|
|
229
|
+
// The last rendered frame is held while the DOM settles
|
|
230
|
+
tab.canvasAPI?.freezeForSpaNavigation?.();
|
|
231
|
+
|
|
232
|
+
// SPA navigation: update URL/title and reset any loading states.
|
|
233
|
+
// A preceding navigation-loading event may have set isLoading=true
|
|
234
|
+
// (e.g., if the browser started a document request before the SPA
|
|
235
|
+
// router intercepted it). Reset those states here since the SPA
|
|
236
|
+
// handled the navigation without a full page reload.
|
|
237
|
+
// Video streaming continues uninterrupted since page context is unchanged.
|
|
238
|
+
tabManager.updateTab(tabId, {
|
|
239
|
+
url: data.url,
|
|
240
|
+
title: getTabTitle(data.url),
|
|
241
|
+
isLoading: false,
|
|
242
|
+
isNavigating: false
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Update parent if this is the active tab
|
|
246
|
+
if (tabId === tabManager.activeTabId && onNavigationUpdate) {
|
|
247
|
+
onNavigationUpdate(tabId, data.url);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
219
252
|
function handleNewWindow(data: any) {
|
|
220
253
|
if (data && data.url) {
|
|
221
254
|
tabManager.createTab(data.url);
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
role="dialog"
|
|
107
107
|
aria-labelledby="settings-title"
|
|
108
108
|
tabindex="-1"
|
|
109
|
-
class="flex flex-col w-full max-w-225 h-[85dvh] max-h-175 bg-slate-50 dark:bg-slate-950 border border-
|
|
109
|
+
class="flex flex-col w-full max-w-225 h-[85dvh] max-h-175 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] dark:shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] max-md:max-w-full max-md:h-dvh max-md:max-h-dvh max-md:rounded-none"
|
|
110
110
|
onclick={(e) => e.stopPropagation()}
|
|
111
111
|
onkeydown={(e) => e.stopPropagation()}
|
|
112
112
|
in:scale={{ duration: 250, easing: cubicOut, start: 0.95 }}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { initializeProjects, projectState } from '$frontend/stores/core/projects.svelte';
|
|
3
|
-
import { initializeStore } from '$frontend/stores/core/app.svelte';
|
|
4
|
-
import { sessionState } from '$frontend/stores/core/sessions.svelte';
|
|
5
2
|
import { addNotification } from '$frontend/stores/ui/notification.svelte';
|
|
6
|
-
import {
|
|
3
|
+
import { resetToDefaults } from '$frontend/stores/features/settings.svelte';
|
|
7
4
|
import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
|
|
8
|
-
import { terminalStore } from '$frontend/stores/features/terminal.svelte';
|
|
9
5
|
import Icon from '../../common/display/Icon.svelte';
|
|
10
6
|
import { debug } from '$shared/utils/logger';
|
|
11
7
|
import ws from '$frontend/utils/ws';
|
|
12
8
|
|
|
13
|
-
let isExporting = $state(false);
|
|
14
9
|
let isClearing = $state(false);
|
|
15
10
|
|
|
16
11
|
async function clearData() {
|
|
@@ -26,82 +21,26 @@
|
|
|
26
21
|
if (confirmed) {
|
|
27
22
|
isClearing = true;
|
|
28
23
|
try {
|
|
29
|
-
localStorage.clear();
|
|
30
|
-
sessionStorage.clear();
|
|
31
|
-
|
|
32
24
|
const response = await ws.http('system:clear-data', {});
|
|
33
25
|
|
|
34
26
|
if (response.cleared) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await initializeStore();
|
|
39
|
-
resetToDefaults();
|
|
40
|
-
|
|
41
|
-
addNotification({
|
|
42
|
-
type: 'success',
|
|
43
|
-
title: 'Data Cleared',
|
|
44
|
-
message: 'All data has been cleared successfully'
|
|
45
|
-
});
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
sessionStorage.clear();
|
|
29
|
+
window.location.reload();
|
|
46
30
|
}
|
|
47
31
|
} catch (error) {
|
|
48
32
|
debug.error('settings', 'Error clearing data:', error);
|
|
33
|
+
isClearing = false;
|
|
49
34
|
addNotification({
|
|
50
35
|
type: 'error',
|
|
51
36
|
title: 'Clear Data Error',
|
|
52
37
|
message: 'Failed to clear all data',
|
|
53
38
|
duration: 4000
|
|
54
39
|
});
|
|
55
|
-
} finally {
|
|
56
|
-
isClearing = false;
|
|
57
40
|
}
|
|
58
41
|
}
|
|
59
42
|
}
|
|
60
43
|
|
|
61
|
-
async function exportData() {
|
|
62
|
-
isExporting = true;
|
|
63
|
-
try {
|
|
64
|
-
const [projects, sessions, messages] = await Promise.all([
|
|
65
|
-
ws.http('projects:list', {}),
|
|
66
|
-
ws.http('sessions:list', {}),
|
|
67
|
-
ws.http('messages:list', { session_id: '', include_all: true })
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
const data = {
|
|
71
|
-
projects: projects || projectState.projects,
|
|
72
|
-
sessions: sessions || sessionState.sessions,
|
|
73
|
-
messages: messages || sessionState.messages,
|
|
74
|
-
settings: settings,
|
|
75
|
-
exportedAt: new Date().toISOString(),
|
|
76
|
-
version: '1.0'
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
80
|
-
const url = URL.createObjectURL(blob);
|
|
81
|
-
const a = document.createElement('a');
|
|
82
|
-
a.href = url;
|
|
83
|
-
a.download = `clopen-data-${new Date().toISOString().split('T')[0]}.json`;
|
|
84
|
-
a.click();
|
|
85
|
-
URL.revokeObjectURL(url);
|
|
86
|
-
|
|
87
|
-
addNotification({
|
|
88
|
-
type: 'success',
|
|
89
|
-
title: 'Export Complete',
|
|
90
|
-
message: 'Your data has been exported successfully'
|
|
91
|
-
});
|
|
92
|
-
} catch (error) {
|
|
93
|
-
debug.error('settings', 'Export error:', error);
|
|
94
|
-
addNotification({
|
|
95
|
-
type: 'error',
|
|
96
|
-
title: 'Export Error',
|
|
97
|
-
message: 'Failed to export data',
|
|
98
|
-
duration: 4000
|
|
99
|
-
});
|
|
100
|
-
} finally {
|
|
101
|
-
isExporting = false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
44
|
async function resetSettings() {
|
|
106
45
|
const confirmed = await showConfirm({
|
|
107
46
|
title: 'Reset Settings',
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
<script lang="ts">
|
|
6
6
|
import { terminalStore } from '$frontend/stores/features/terminal.svelte';
|
|
7
7
|
import { projectState } from '$frontend/stores/core/projects.svelte';
|
|
8
|
-
import { getShortcutLabels } from '$frontend/utils/platform';
|
|
9
8
|
import TerminalTabs from './TerminalTabs.svelte';
|
|
10
|
-
import LoadingSpinner from '../common/feedback/LoadingSpinner.svelte';
|
|
11
9
|
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
12
10
|
import XTerm from '$frontend/components/common/xterm/XTerm.svelte';
|
|
13
11
|
|
|
@@ -26,9 +24,6 @@
|
|
|
26
24
|
let isCancelling = $state(false);
|
|
27
25
|
let terminalContainer: HTMLDivElement | undefined = $state();
|
|
28
26
|
|
|
29
|
-
// Get platform-specific shortcut labels
|
|
30
|
-
const shortcuts = $derived(getShortcutLabels());
|
|
31
|
-
|
|
32
27
|
// Initialize terminal only once when component mounts
|
|
33
28
|
let isInitialized = false;
|
|
34
29
|
$effect(() => {
|
|
@@ -258,7 +253,7 @@
|
|
|
258
253
|
aria-label="Terminal application">
|
|
259
254
|
|
|
260
255
|
<!-- Terminal Header with Tabs -->
|
|
261
|
-
<div class="flex-shrink-0 bg-
|
|
256
|
+
<div class="flex-shrink-0 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
|
262
257
|
<!-- Terminal Tabs -->
|
|
263
258
|
<TerminalTabs
|
|
264
259
|
sessions={terminalStore.sessions}
|
|
@@ -292,29 +287,6 @@
|
|
|
292
287
|
</div>
|
|
293
288
|
{/if}
|
|
294
289
|
|
|
295
|
-
<!-- Terminal status bar -->
|
|
296
|
-
<div class="flex-shrink-0 px-2 py-0.5 bg-slate-100 dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 text-3xs text-slate-500 dark:text-slate-500 font-mono">
|
|
297
|
-
<div class="flex items-center justify-between">
|
|
298
|
-
<div class="flex items-center space-x-3">
|
|
299
|
-
<span class="hidden sm:inline"><kbd class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-3xs text-slate-700 dark:text-slate-300">↑↓</kbd> History</span>
|
|
300
|
-
<span class="hidden md:inline"><kbd class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-3xs text-slate-700 dark:text-slate-300">Ctrl+L</kbd> Clear</span>
|
|
301
|
-
<span class="hidden sm:inline"><kbd class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-3xs text-slate-700 dark:text-slate-300">{shortcuts.cancel}</kbd> Interrupt
|
|
302
|
-
{#if isCancelling}
|
|
303
|
-
<span class="animate-pulse ml-1">(cancelling...)</span>
|
|
304
|
-
{/if}
|
|
305
|
-
</span>
|
|
306
|
-
</div>
|
|
307
|
-
<div class="flex items-center space-x-1.5">
|
|
308
|
-
{#if hasActiveProject}
|
|
309
|
-
<span class="text-emerald-500 text-xs">●</span>
|
|
310
|
-
<span class="hidden sm:inline">Ready</span>
|
|
311
|
-
{:else}
|
|
312
|
-
<span class="text-amber-500 text-xs">●</span>
|
|
313
|
-
<span class="hidden sm:inline">No Project</span>
|
|
314
|
-
{/if}
|
|
315
|
-
</div>
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
290
|
</div>
|
|
319
291
|
|
|
320
292
|
<style>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import Modal from '$frontend/components/common/overlay/Modal.svelte';
|
|
5
5
|
import Checkbox from '$frontend/components/common/form/Checkbox.svelte';
|
|
6
6
|
|
|
7
|
-
let port = $state(
|
|
7
|
+
let port = $state<number | null>(null);
|
|
8
8
|
let autoStopMinutes = $state(60);
|
|
9
9
|
let showWarning = $state(false);
|
|
10
10
|
let dontShowWarningAgain = $state(false);
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
);
|
|
21
21
|
|
|
22
22
|
async function handleStartTunnel() {
|
|
23
|
+
if (!port) return;
|
|
24
|
+
|
|
23
25
|
// Check if tunnel already exists for this port
|
|
24
26
|
if (tunnelStore.getTunnel(port)) {
|
|
25
27
|
warningMessage = `Tunnel already active on port ${port}`;
|
|
@@ -44,9 +46,9 @@
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// Get loading and progress state for current port
|
|
47
|
-
const isLoading = $derived(tunnelStore.isLoading(port));
|
|
48
|
-
const progress = $derived(tunnelStore.getProgress(port));
|
|
49
|
-
const error = $derived(tunnelStore.getError(port));
|
|
49
|
+
const isLoading = $derived(tunnelStore.isLoading(port ?? 0));
|
|
50
|
+
const progress = $derived(tunnelStore.getProgress(port ?? 0));
|
|
51
|
+
const error = $derived(tunnelStore.getError(port ?? 0));
|
|
50
52
|
|
|
51
53
|
function openWarningModal() {
|
|
52
54
|
// Clear any previous warning messages
|
|
@@ -151,7 +153,7 @@
|
|
|
151
153
|
<!-- Start Button -->
|
|
152
154
|
<button
|
|
153
155
|
onclick={openWarningModal}
|
|
154
|
-
disabled={isLoading}
|
|
156
|
+
disabled={isLoading || !port}
|
|
155
157
|
class="inline-flex items-center justify-center font-semibold transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed w-full px-3 md:px-4 py-2.5 text-sm rounded-lg bg-violet-600 hover:bg-violet-700 text-white gap-2"
|
|
156
158
|
>
|
|
157
159
|
{#if isLoading}
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
aria-label="Project Navigator"
|
|
169
169
|
>
|
|
170
170
|
<nav
|
|
171
|
-
class="flex flex-col h-full bg-
|
|
171
|
+
class="flex flex-col h-full bg-white dark:bg-slate-900/95 transition-all duration-200 {isCollapsed
|
|
172
172
|
? 'items-center'
|
|
173
173
|
: ''}"
|
|
174
174
|
>
|