@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
@@ -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}ms
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-[95vh] flex items-center justify-center"
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-[90vh] object-contain rounded-lg shadow-2xl"
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] max-h-[90vh]'
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-[95vh] md:max-h-[90vh] overflow-hidden flex flex-col {className}"
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-slate-50 dark:bg-slate-900/70 {className} select-none"
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-[85vh] flex flex-col"
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-[85vh] !max-w-[95vw] md:!max-w-5xl">
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-[65vh] -mx-4 -my-6 md:-mx-6">
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
- // Never retry hereretrying immediately creates a stop/start loop.
570
- debug.warn('webcodecs', 'Streaming start returned false, stopping retries');
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 - similar to progress bar logic
74
- // Shows immediately when navigating/reconnecting, hides with delay to prevent flicker
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 = (isNavigating || isReconnecting) && isStreamReady;
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: Semi-transparent overlay during navigation/reconnect (shows last frame behind) -->
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">Loading preview...</div>
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-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
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-violet-500/20 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"
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 { settings, resetToDefaults } from '$frontend/stores/features/settings.svelte';
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
- terminalStore.clearAllSessions();
36
- projectState.currentProject = null;
37
- await initializeProjects();
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-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
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(3000);
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-slate-50 dark:bg-slate-900/95 transition-all duration-200 {isCollapsed
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
  >